Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/daemon/internal/sessionview/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
//
// A view that declares a [Shim] also runs the Session's process relay, the shim binary in relay mode (package processshim). The launcher starts it before the process, under the same restrictions and as the same user, with a listening socket at processshim.SocketPath on a read-only mount and its end of a socket pair whose other end is [View.Relay]. The relay is the only process that receives the descriptors a shim hands over; the broker outside the view holds only its end of the pair. The launcher's drain ignores the relay, which ends with the view.
//
// The world receives the View cgroup identity and the launcher PID namespace identity with its FUSE connection, so a world implementation can correlate kernel request thread IDs with that View.
//
// Each view runs in a cgroup v2 of its own, which Start creates in [Spec].CgroupParent. The launcher is cloned into it with CLONE_INTO_CGROUP, so no process of the view ever runs outside it. The cgroup hierarchy is the only record of what the views own: [Recover] ends every cgroup an earlier owner of the parent left, with all its processes.
//
// Teardown never waits unconditionally. Once the launcher has exited, the view shuts its end of the relay connection down, ends every process in its cgroup with cgroup.kill and stops the world server, which ends the requests still pending on the view's FUSE connection so that a process blocked on the world can exit. Only when the world server and the launcher have ended and the cgroup is empty, all within 30 seconds, does it remove the cgroup. Otherwise Wait and Close report [ErrCleanup] and the cgroup stays for Recover; nothing touches it after the teardown. When Start's context ends during the build, Start kills the launcher, stops waiting for it and tears the view down the same way, so a launcher blocked on the world cannot hold Start past this bound.
Expand Down
4 changes: 4 additions & 0 deletions apps/daemon/internal/sessionview/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ type WorldMount struct {
UID, GID uint32
// Mountpoints are the view paths the launcher mounts over. The world presents each one with its type, whether or not the sandbox has the path, and keeps it and its ancestors stable for the view's lifetime: a mountpoint that disappears or changes identity detaches what is mounted on it.
Mountpoints []Mountpoint
// Cgroup is the cgroup ID every process of the view runs in, and PidNS the identity (st_dev, st_ino of /proc/<pid>/ns/pid) of the PID namespace the launcher mounted in, which the FUSE headers name threads in. Zero values mean unknown.
Cgroup uint64
PidNSDev uint64
PidNSIno uint64
}

// Presentation is how the world presents the mountpoints.
Expand Down
10 changes: 9 additions & 1 deletion apps/daemon/internal/sessionview/view_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,15 @@ func (v *View) handshake(ctx context.Context, spec *Spec) error {
}
} else {
v.dev = files[0]
world, present, err := spec.World(ctx, v.dev, WorldMount{Options: fuseOptions, Flags: fuseFlags, UID: spec.Process.UID, GID: spec.Process.GID, Mountpoints: mps})
wm := WorldMount{Options: fuseOptions, Flags: fuseFlags, UID: spec.Process.UID, GID: spec.Process.GID, Mountpoints: mps}
var st unix.Stat_t
if err := unix.Stat(v.cgroup, &st); err == nil {
wm.Cgroup = st.Ino
}
if err := unix.Stat(fmt.Sprintf("/proc/%d/ns/pid", v.cmd.Process.Pid), &st); err == nil {
wm.PidNSDev, wm.PidNSIno = uint64(st.Dev), st.Ino
}
world, present, err := spec.World(ctx, v.dev, wm)
if err != nil {
return &Error{Kind: ErrWorld, Op: "serve", Err: err}
}
Expand Down
10 changes: 9 additions & 1 deletion apps/daemon/internal/worldfs/doc.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package worldfs serves a Session's world, the sandbox file system, as the FUSE file system a sessionview launcher mounts at the view's root. Every kernel request becomes at most one File request (see internal/sandboxfs), apart from the redial and lock recovery described below, so processes in the view read and write sandbox files natively; nothing on the agent host shows through, and nothing is created in the sandbox to support the view.
// Package worldfs serves a Session's world, the sandbox file system, as the FUSE file system a sessionview launcher mounts at the view's root. Kernel requests become File requests (see internal/sandboxfs), with syscall-local metadata batching and the redial and lock recovery described below, so processes in the view read and write sandbox files natively; nothing on the agent host shows through, and nothing is created in the sandbox to support the view.
//
// [World.Serve] implements [sessionview.World]. Within the context sessionview.Start passes, it dials the attachment's File stream, checks Describe, attaches the world export (sandboxfs.WorldExport) and presents the view's mountpoints; it then serves the launcher's /dev/fuse connection with go-fuse's raw API.
//
Expand Down Expand Up @@ -49,6 +49,14 @@
//
// Entry, attribute and negative timeouts are zero, every open returns FOPEN_DIRECT_IO, and the frontend never asks for the writeback cache, KEEP_CACHE or CACHE_DIR. A private mapping of a world file works, and the kernel reads its pages with READ when they fault; a shared mapping fails with ENODEV, since the frontend does not negotiate DIRECT_IO_ALLOW_MMAP. default_permissions stays off: the service decides access. The frontend negotiates only BIG_WRITES, MAX_PAGES (requests up to the service's read and write limit), PARALLEL_DIROPS, ATOMIC_O_TRUNC, POSIX_LOCKS and FLOCK_LOCKS.
//
// # Metadata batching
//
// On Linux amd64, one process-wide set of BPF raw tracepoints observes eligible non-mutating metadata syscalls in registered View cgroups. sessionview supplies each world its cgroup and the mount's PID namespace identity. The first matching kernel LOOKUP may use File.Walk for a plain absolute path; later LOOKUP and node GETATTR requests consume matching parent/name or node results once, only while the kernel still names the same thread and syscall epoch. The kernel continues to execute the original syscall. Handle checks, permissions, synthetic mountpoints and pinned ancestor checks retain their ordinary paths. No metadata is reused across syscalls.
//
// BPF availability is an internal optimization condition, not a File capability or installation prerequisite: when loading is unavailable, every request follows the same ordinary FUSE mapping. The programs require the namespace PID helper (Linux 5.7 or later), kernel BPF support and permission to load and attach them. Other architectures use the ordinary mapping. While any scoped world remains, all host syscalls pay the programs' initial cgroup filter; the last world removes all programs and maps.
//
// Kernel scope hints occupy a bounded LRU map and can be discarded at any time. Missing or changed hints stop consumption immediately. Userspace retains at most a bounded set of operations; unused Walk references are forgotten when their operation ends, when a periodic map check sees that its epoch ended, or on world teardown. That periodic cleanup never decides whether metadata can be consumed. Unregistering a View removes its hints; an in-flight tracepoint can leave a disposable hint until LRU eviction or the last world's teardown, but hints hold no remote references.
//
// # Errors
//
// A sandboxfs errno maps to its Linux errno through one table, StaleNode and StaleHandle map to ESTALE, and every other failure maps to EIO. Nothing a request did not do is reported as done.
Expand Down
6 changes: 6 additions & 0 deletions apps/daemon/internal/worldfs/export_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build linux

package worldfs

// ScopeErr exposes unavailable scope diagnostics to external package tests.
func (w *World) ScopeErr() error { return w.fs.scopeErr }
13 changes: 13 additions & 0 deletions apps/daemon/internal/worldfs/ops_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ func (f *frontend) Lookup(_ <-chan struct{}, in *fuse.InHeader, name string, out
if p.synthetic() {
return fuse.ENOENT
}
if pf, ok := f.lookupScoped(in.Pid, p, name); ok {
if pf.err != nil {
return status(pf.err)
}
f.adopt(pf.entry, out)
return fuse.OK
}
r, err := call(f, f.ctx, (*sandboxfs.Client).Lookup, &sandboxfs.LookupRequest{Parent: p.ref, Name: []byte(name)})
if err != nil {
return status(err)
Expand Down Expand Up @@ -74,6 +81,12 @@ func (f *frontend) GetAttr(_ <-chan struct{}, in *fuse.GetAttrIn, out *fuse.Attr
if !st.Ok() {
return st
}
if t.Kind == sandboxfs.TargetNode {
if a, ok := f.attrScoped(in.Pid, n); ok {
f.fill(n, a, &out.Attr)
return fuse.OK
}
}
r, err := call(f, f.ctx, (*sandboxfs.Client).GetAttr, &sandboxfs.GetAttrRequest{Target: t})
if err != nil {
return status(err)
Expand Down
183 changes: 183 additions & 0 deletions apps/daemon/internal/worldfs/prefetch_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//go:build linux

package worldfs

import (
"strings"
"time"

"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs"
)

// Operation-local prefetch driven by the kernel scope records of scope_linux.go.
//
// An operation is one eligible syscall instance of one thread, identified by the record's epoch. Its
// entries serve exactly the (parent, name) and node the kernel asks for, once each, and only while the
// kernel still holds the same record: a consumption after the syscall exited is impossible, because the
// exit removed or replaced the record before the thread could run again. Entries never consumed are
// forgotten when the operation is replaced, when the reaper finds its record gone, or when the world stops.

type prefetchKey struct {
parent sandboxfs.NodeRef
name string
}

// prefetched is one walked step: the entry, or the failure that stopped the walk.
type prefetched struct {
entry sandboxfs.Entry
err error
}

type operation struct {
epoch uint64
entries map[prefetchKey]prefetched
attrs map[sandboxfs.NodeRef]sandboxfs.Attr
}

const reapEvery = 500 * time.Millisecond

// lookupScoped answers a LOOKUP of (p, name) from the thread's current operation, starting one with a Walk when the thread's syscall record names a plain path whose first component is this request. ok is false when the request takes its ordinary path.
func (f *frontend) lookupScoped(tid uint32, p *inode, name string) (prefetched, bool) {
if !f.scoped.Load() {
return prefetched{}, false
}
rec, live := lookupScope(f.cgroup, tid)
f.mu.Lock()
op := f.operations[tid]
if op != nil && (!live || op.epoch != rec.epoch) {
f.endLocked(tid)
op = nil
}
if op != nil {
pf, ok := f.takeLocked(op, p.ref, name)
f.mu.Unlock()
return pf, ok
}
f.mu.Unlock()
if !live || p != f.root {
return prefetched{}, false
}
// Pinned chains are contiguous from the root, so a first component that is not pinned leads to no pinned or synthetic node.
var names [][]byte
for i, c := range strings.Split(strings.TrimPrefix(rec.path, "/"), "/") {
if c == "" || c == "." || c == ".." || uint64(len(c)) > uint64(f.caps.MaxNameBytes) || i == 0 && f.root.fixed[c] != nil || len(names) == int(f.caps.MaxWalkComponents) {
break
}
names = append(names, []byte(c))
}
if len(names) == 0 || string(names[0]) != name {
return prefetched{}, false
}
r, err := call(f, f.ctx, (*sandboxfs.Client).Walk, &sandboxfs.WalkRequest{Parent: f.root.ref, Names: names})
if err != nil {
// The Walk failed where a Lookup of the first component would have: the same failure answers the request.
return prefetched{err: err}, true
}
op = &operation{epoch: rec.epoch, entries: map[prefetchKey]prefetched{}, attrs: map[sandboxfs.NodeRef]sandboxfs.Attr{}}
parent := f.root.ref
for i, e := range r.Entries {
op.entries[prefetchKey{parent, string(names[i])}] = prefetched{entry: e}
op.attrs[e.Node] = e.Attr
parent = e.Node
}
if r.Failure != nil && len(r.Entries) < len(names) {
op.entries[prefetchKey{parent, string(names[len(r.Entries)])}] = prefetched{err: r.Failure}
}
f.mu.Lock()
defer f.mu.Unlock()
f.endLocked(tid)
if f.closed.Load() || f.dead.Load() {
f.forgetLocked(op)
return prefetched{}, false
}
// Finished threads can leave entries until the reaper runs. Bound that
// userspace set independently of the concurrently active kernel records.
if len(f.operations) >= maxScopes {
pf, ok := f.takeLocked(op, p.ref, name)
f.forgetLocked(op)
return pf, ok
}
// The thread is still inside this syscall, since its request is unanswered, so its record is unchanged; the record is checked again anyway before anything later is consumed.
if f.operations == nil {
f.operations = map[uint32]*operation{}
}
f.operations[tid] = op
pf, ok := f.takeLocked(op, p.ref, name)
return pf, ok
}

// attrScoped answers a GETATTR of node n from the thread's current operation while its kernel record is unchanged.
func (f *frontend) attrScoped(tid uint32, n *inode) (sandboxfs.Attr, bool) {
if !f.scoped.Load() {
return sandboxfs.Attr{}, false
}
f.mu.Lock()
defer f.mu.Unlock()
op := f.operations[tid]
if op == nil {
return sandboxfs.Attr{}, false
}
if rec, live := lookupScope(f.cgroup, tid); !live || rec.epoch != op.epoch {
f.endLocked(tid)
return sandboxfs.Attr{}, false
}
a, ok := op.attrs[n.ref]
if ok {
delete(op.attrs, n.ref)
}
return a, ok
}

// takeLocked consumes the prefetched step for (parent, name). The caller holds mu.
func (f *frontend) takeLocked(op *operation, parent sandboxfs.NodeRef, name string) (prefetched, bool) {
k := prefetchKey{parent, name}
p, ok := op.entries[k]
if ok {
delete(op.entries, k)
}
return p, ok
}

// endLocked drops the operation of tid and forgets what it still holds. The caller holds mu.
func (f *frontend) endLocked(tid uint32) {
if op := f.operations[tid]; op != nil {
delete(f.operations, tid)
f.forgetLocked(op)
}
}

func (f *frontend) forgetLocked(op *operation) {
for _, p := range op.entries {
if p.err == nil {
f.unref(p.entry.Node, 1)
}
}
}

// reap forgets what operations whose syscall has ended left behind, until the drainer stops. Its period bounds resource retention only; consumption never depends on it.
func (f *frontend) reap() {
defer close(f.reaped)
t := time.NewTicker(reapEvery)
defer t.Stop()
for {
select {
case <-f.drainCtx.Done():
return
case <-t.C:
}
f.mu.Lock()
for tid, op := range f.operations {
if rec, live := lookupScope(f.cgroup, tid); !live || rec.epoch != op.epoch {
f.endLocked(tid)
}
}
f.mu.Unlock()
}
}

// endAll ends every operation when the world stops. The caller holds mu.
func (f *frontend) endAll() {
for tid := range f.operations {
f.endLocked(tid)
}
}
Loading
Loading