virtfs: rename Request to Rdwr

Read, Write, and Rdwr are the three atomic 9P operations:
- Read: non-blocking read
- Write: non-blocking write (fire-and-forget)
- Rdwr: atomic write-then-read (blocking, produces result)

BlockOnce and Stream are special cases of Read.
Rdwr is its own primitive — not a variant of either.
This commit is contained in:
Levi Neely 2026-08-14 14:42:19 +02:00
parent 8b6b08b54f
commit ab4668cb3f
9 changed files with 52 additions and 51 deletions

View File

@ -97,7 +97,7 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
), ),
virtfs.FileNode("ctl", 0666, virtfs.FileNode("ctl", 0666,
virtfs.Doc("Server control. Write: 'invalidate', 'kill'"), virtfs.Doc("Server control. Write: 'invalidate', 'kill'"),
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){ return dispatch(map[string]func([]string) ([]byte, error){
"invalidate": func(_ []string) ([]byte, error) { "invalidate": func(_ []string) ([]byte, error) {
if mc != nil { if mc != nil {
@ -127,7 +127,7 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
), ),
virtfs.FileNode("generate", 0666, virtfs.FileNode("generate", 0666,
virtfs.Doc("One-shot LLM generation"), virtfs.Doc("One-shot LLM generation"),
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
var req backend.GenerateRequest var req backend.GenerateRequest
if err := json.Unmarshal(data, &req); err != nil { if err := json.Unmarshal(data, &req); err != nil {
req.Prompt = strings.TrimSpace(string(data)) req.Prompt = strings.TrimSpace(string(data))
@ -158,7 +158,7 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
virtfs.Doc("Session management"), virtfs.Doc("Session management"),
virtfs.FileNode("new", 0666, virtfs.FileNode("new", 0666,
virtfs.Doc("Create session"), virtfs.Doc("Create session"),
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
args := strings.Fields(string(data)) args := strings.Fields(string(data))
name, remote := "", "" name, remote := "", ""
for _, arg := range args { for _, arg := range args {
@ -246,7 +246,7 @@ func buildSessionChildren(
}), }),
), ),
virtfs.FileNode("ctl", 0666, virtfs.FileNode("ctl", 0666,
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){ return dispatch(map[string]func([]string) ([]byte, error){
"kill": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() }, "kill": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() },
".": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() }, ".": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() },
@ -288,7 +288,7 @@ func buildSessionChildren(
), ),
virtfs.DirNode("agent", virtfs.DirNode("agent",
virtfs.FileNode("new", 0666, virtfs.FileNode("new", 0666,
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
ag, err := session.CreateAgent(s.Name(), strings.Fields(string(data))) ag, err := session.CreateAgent(s.Name(), strings.Fields(string(data)))
if err != nil { if err != nil {
return nil, fmt.Errorf("create agent: %w", err) return nil, fmt.Errorf("create agent: %w", err)
@ -509,7 +509,7 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}), }),
), ),
virtfs.FileNode("ctl", 0666, virtfs.FileNode("ctl", 0666,
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){ return dispatch(map[string]func([]string) ([]byte, error){
"kill": func(_ []string) ([]byte, error) { "kill": func(_ []string) ([]byte, error) {
s.RemoveAgent(a.ID()) s.RemoveAgent(a.ID())

View File

@ -596,7 +596,7 @@ func write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errFcall(fc, "bad fid") return errFcall(fc, "bad fid")
} }
if f.entry != nil && f.entry.RequestRespMode() { if f.entry != nil && f.entry.RdwrMode() {
f.readCache = nil // clear cached response for next read f.readCache = nil // clear cached response for next read
cs.mu.Unlock() cs.mu.Unlock()
if err := f.entry.Write(fc.Data); err != nil { if err := f.entry.Write(fc.Data); err != nil {
@ -681,7 +681,7 @@ func clunk(root *fs.Tree, cs *connState, fc *plan9.Fcall) *plan9.Fcall {
data = make([]byte, len(f.writeBuf)) data = make([]byte, len(f.writeBuf))
copy(data, f.writeBuf) copy(data, f.writeBuf)
if f.entry != nil { if f.entry != nil {
isRequest = f.entry.RequestRespMode() isRequest = f.entry.RdwrMode()
} }
} }
delete(cs.fids, fc.Fid) delete(cs.fids, fc.Fid)

View File

@ -118,7 +118,7 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
), ),
virtfs.FileNode("tools", 0666, virtfs.FileNode("tools", 0666,
virtfs.Doc("Tools: write agent ID, read tool list (rdwr)"), virtfs.Doc("Tools: write agent ID, read tool list (rdwr)"),
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
agentID := strings.TrimSpace(string(data)) agentID := strings.TrimSpace(string(data))
if agentID == "" { if agentID == "" {
return nil, fmt.Errorf("agent ID required") return nil, fmt.Errorf("agent ID required")
@ -179,14 +179,14 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
virtfs.DirNode("proc", virtfs.DirNode("proc",
virtfs.FileNode("list", 0666, virtfs.FileNode("list", 0666,
virtfs.Doc("List processes: write agent ID (or empty for all), read pid\\tstate\\ttool"), virtfs.Doc("List processes: write agent ID (or empty for all), read pid\\tstate\\ttool"),
virtfs.Request(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
agentID := strings.TrimSpace(string(data)) agentID := strings.TrimSpace(string(data))
return []byte(srv.Fs.ListProcsForAgent(agentID)), nil return []byte(srv.Fs.ListProcsForAgent(agentID)), nil
}), }),
), ),
virtfs.FileNode("new", 0666, virtfs.FileNode("new", 0666,
virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"), virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"),
virtfs.Request(func(ctx context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) {
result, err := srv.Fs.HandleProcNew(ctx, string(data)) result, err := srv.Fs.HandleProcNew(ctx, string(data))
if err != nil { if err != nil {
return nil, err return nil, err
@ -196,7 +196,7 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
), ),
virtfs.FileNode("new.bg", 0666, virtfs.FileNode("new.bg", 0666,
virtfs.Doc("Execute tool in background: write token + tool + args, read pid"), virtfs.Doc("Execute tool in background: write token + tool + args, read pid"),
virtfs.Request(func(ctx context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) {
result, err := srv.Fs.HandleProcNewBg(ctx, string(data)) result, err := srv.Fs.HandleProcNewBg(ctx, string(data))
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -372,7 +372,7 @@ func handleRead(cs *connState, fc *plan9.Fcall, tree *virtfs.Tree) *plan9.Fcall
} }
// For rdwr files, check if we've already read // For rdwr files, check if we've already read
if f.entry.RequestRespMode() && f.readDone { if f.entry.RdwrMode() && f.readDone {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: nil} return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: nil}
} }

View File

@ -18,7 +18,7 @@ func BuildTree(spec FsNodeDecl) *Tree {
func validate(d *FsNodeDecl, path string) { func validate(d *FsNodeDecl, path string) {
hasChildren := len(d.Children) > 0 hasChildren := len(d.Children) > 0
hasBindings := d.Bindings != nil hasBindings := d.Bindings != nil
hasFileHandlers := d.Read != nil || d.Write != nil || d.BlockOnce != nil || d.Stream != nil || d.Request != nil hasFileHandlers := d.Read != nil || d.Write != nil || d.BlockOnce != nil || d.Stream != nil || d.Rdwr != nil
isDir := hasChildren isDir := hasChildren
@ -39,11 +39,11 @@ func validate(d *FsNodeDecl, path string) {
if d.Stream != nil { if d.Stream != nil {
blocking++ blocking++
} }
if d.Request != nil { if d.Rdwr != nil {
blocking++ blocking++
} }
if blocking > 1 { if blocking > 1 {
panic(fmt.Sprintf("BuildTree: %q: at most one of BlockOnce, Stream, Request may be set", fullPath)) panic(fmt.Sprintf("BuildTree: %q: at most one of BlockOnce, Stream, Rdwr may be set", fullPath))
} }
// Template nodes must have Bindings set. // Template nodes must have Bindings set.
@ -145,7 +145,7 @@ func listDir(d *FsNodeDecl, uid, gid string) ([]os.DirEntry, error) {
if m == 0 { if m == 0 {
m = 0755 m = 0755
} }
hasFileHandlers := c.Read != nil || c.Write != nil || c.BlockOnce != nil || c.Stream != nil || c.Request != nil hasFileHandlers := c.Read != nil || c.Write != nil || c.BlockOnce != nil || c.Stream != nil || c.Rdwr != nil
isChildDir := len(c.Children) > 0 || (c.Bindings != nil && !hasFileHandlers) isChildDir := len(c.Children) > 0 || (c.Bindings != nil && !hasFileHandlers)
if isChildDir { if isChildDir {
entries = append(entries, dirEntry(c.Name, m)) entries = append(entries, dirEntry(c.Name, m))
@ -186,7 +186,7 @@ func statDir(d *FsNodeDecl, uid, gid, name string) (os.FileInfo, error) {
} }
cu := resolveUID(child, uid) cu := resolveUID(child, uid)
cg := resolveGID(child, gid) cg := resolveGID(child, gid)
hasFileHandlers := child.Read != nil || child.Write != nil || child.BlockOnce != nil || child.Stream != nil || child.Request != nil hasFileHandlers := child.Read != nil || child.Write != nil || child.BlockOnce != nil || child.Stream != nil || child.Rdwr != nil
isChildDir := len(child.Children) > 0 || (child.Bindings != nil && !hasFileHandlers) isChildDir := len(child.Children) > 0 || (child.Bindings != nil && !hasFileHandlers)
if isChildDir { if isChildDir {
return &SyntheticFileInfo{Name_: name, Mode_: cm | os.ModeDir, IsDir_: true, UID_: cu, GID_: cg}, nil return &SyntheticFileInfo{Name_: name, Mode_: cm | os.ModeDir, IsDir_: true, UID_: cu, GID_: cg}, nil
@ -247,7 +247,7 @@ func openLeaf(d *FsNodeDecl, uid, gid string, mode os.FileMode, name string) (Fi
} }
var blockFn func(context.Context, string) ([]byte, string, error) var blockFn func(context.Context, string) ([]byte, string, error)
var isBlocking, isStreaming, isRequest bool var isBlocking, isStreaming, isRdwr bool
switch { switch {
case d.BlockOnce != nil: case d.BlockOnce != nil:
blockFn = d.BlockOnce blockFn = d.BlockOnce
@ -255,8 +255,8 @@ func openLeaf(d *FsNodeDecl, uid, gid string, mode os.FileMode, name string) (Fi
case d.Stream != nil: case d.Stream != nil:
blockFn = d.Stream blockFn = d.Stream
isStreaming = true isStreaming = true
case d.Request != nil: case d.Rdwr != nil:
fn := d.Request fn := d.Rdwr
var result []byte var result []byte
var reqCtx context.Context var reqCtx context.Context
var reqCancel context.CancelFunc var reqCancel context.CancelFunc
@ -285,7 +285,7 @@ func openLeaf(d *FsNodeDecl, uid, gid string, mode os.FileMode, name string) (Fi
} }
return nil return nil
} }
isRequest = true isRdwr = true
return &fileConfig{ return &fileConfig{
StatFn: func() (os.FileInfo, error) { StatFn: func() (os.FileInfo, error) {
if d.Stat != nil { if d.Stat != nil {
@ -300,7 +300,7 @@ func openLeaf(d *FsNodeDecl, uid, gid string, mode os.FileMode, name string) (Fi
BlockingReadFn: blockFn, BlockingReadFn: blockFn,
BlockingRead_: isBlocking, BlockingRead_: isBlocking,
StreamMode_: isStreaming, StreamMode_: isStreaming,
RequestResp_: isRequest, Rdwr_: isRdwr,
}, nil }, nil
default: default:
blockFn = notBlocking blockFn = notBlocking
@ -319,7 +319,7 @@ func openLeaf(d *FsNodeDecl, uid, gid string, mode os.FileMode, name string) (Fi
BlockingReadFn: blockFn, BlockingReadFn: blockFn,
BlockingRead_: isBlocking, BlockingRead_: isBlocking,
StreamMode_: isStreaming, StreamMode_: isStreaming,
RequestResp_: isRequest, Rdwr_: isRdwr,
}, nil }, nil
} }

View File

@ -63,8 +63,8 @@ type FsNodeDecl struct {
BlockOnce func(ctx context.Context, base string) ([]byte, string, error) BlockOnce func(ctx context.Context, base string) ([]byte, string, error)
Stream func(ctx context.Context, base string) ([]byte, string, error) Stream func(ctx context.Context, base string) ([]byte, string, error)
// Request — rdwr: write triggers result, read returns it once. // Rdwr — atomic write-then-read: write triggers computation, read returns result once.
Request func(ctx context.Context, data []byte) ([]byte, error) Rdwr func(ctx context.Context, data []byte) ([]byte, error)
// Tremove + Trename. // Tremove + Trename.
Remove func() error Remove func() error
@ -187,9 +187,10 @@ func BlockOnceRaw(fn func(context.Context, string) ([]byte, string, error)) Node
return func(d *FsNodeDecl) { d.BlockOnce = fn } return func(d *FsNodeDecl) { d.BlockOnce = fn }
} }
// Request sets a context-aware request-response handler (rdwr pattern). // Rdwr sets an atomic write-then-read handler.
func Request(fn func(context.Context, []byte) ([]byte, error)) NodeOption { // Write triggers computation; the subsequent read returns the result once.
return func(d *FsNodeDecl) { d.Request = fn } func Rdwr(fn func(context.Context, []byte) ([]byte, error)) NodeOption {
return func(d *FsNodeDecl) { d.Rdwr = fn }
} }
// RemoveNode sets the removal handler for a node. // RemoveNode sets the removal handler for a node.

View File

@ -31,12 +31,12 @@ func TestNodeOptions(t *testing.T) {
} }
}) })
t.Run("Request", func(t *testing.T) { t.Run("Rdwr", func(t *testing.T) {
spec := FileNode("test", 0666, Request(func(_ context.Context, data []byte) ([]byte, error) { spec := FileNode("test", 0666, Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return nil, nil return nil, nil
})) }))
if spec.Request == nil { if spec.Rdwr == nil {
t.Error("Request not set") t.Error("Rdwr not set")
} }
}) })
@ -161,7 +161,7 @@ func TestFileConfigMethods(t *testing.T) {
func TestFileConfigModes(t *testing.T) { func TestFileConfigModes(t *testing.T) {
fc := &fileConfig{} fc := &fileConfig{}
if fc.BlockingReadMode() || fc.StreamMode() || fc.RequestRespMode() { if fc.BlockingReadMode() || fc.StreamMode() || fc.RdwrMode() {
t.Error("default modes should be false") t.Error("default modes should be false")
} }
@ -171,8 +171,8 @@ func TestFileConfigModes(t *testing.T) {
if !(&fileConfig{StreamMode_: true}).StreamMode() { if !(&fileConfig{StreamMode_: true}).StreamMode() {
t.Error("StreamMode should be true") t.Error("StreamMode should be true")
} }
if !(&fileConfig{RequestResp_: true}).RequestRespMode() { if !(&fileConfig{Rdwr_: true}).RdwrMode() {
t.Error("RequestRespMode should be true") t.Error("RdwrMode should be true")
} }
} }
@ -279,9 +279,9 @@ func TestOpenLeafStream(t *testing.T) {
} }
} }
func TestOpenLeafRequest(t *testing.T) { func TestOpenLeafRdwr(t *testing.T) {
spec := DirNode("/", spec := DirNode("/",
FileNode("request", 0666, Request(func(_ context.Context, data []byte) ([]byte, error) { FileNode("request", 0666, Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return append([]byte("echo:"), data...), nil return append([]byte("echo:"), data...), nil
})), })),
) )
@ -290,8 +290,8 @@ func TestOpenLeafRequest(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Open: %v", err) t.Fatalf("Open: %v", err)
} }
if !f.RequestRespMode() { if !f.RdwrMode() {
t.Error("expected RequestRespMode") t.Error("expected RdwrMode")
} }
if err := f.Write([]byte("test")); err != nil { if err := f.Write([]byte("test")); err != nil {
t.Fatalf("Write: %v", err) t.Fatalf("Write: %v", err)
@ -305,9 +305,9 @@ func TestOpenLeafRequest(t *testing.T) {
} }
} }
func TestOpenLeafRequestError(t *testing.T) { func TestOpenLeafRdwrError(t *testing.T) {
spec := DirNode("/", spec := DirNode("/",
FileNode("request", 0666, Request(func(_ context.Context, data []byte) ([]byte, error) { FileNode("request", 0666, Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return nil, errors.New("request error") return nil, errors.New("request error")
})), })),
) )
@ -321,11 +321,11 @@ func TestOpenLeafRequestError(t *testing.T) {
} }
} }
func TestOpenLeafRequestWithFallbackRead(t *testing.T) { func TestOpenLeafRdwrWithFallbackRead(t *testing.T) {
spec := DirNode("/", spec := DirNode("/",
FileNode("request", 0666, FileNode("request", 0666,
Read(func() ([]byte, error) { return []byte("fallback"), nil }), Read(func() ([]byte, error) { return []byte("fallback"), nil }),
Request(func(_ context.Context, data []byte) ([]byte, error) { return []byte("response"), nil }), Rdwr(func(_ context.Context, data []byte) ([]byte, error) { return []byte("response"), nil }),
), ),
) )
tree := BuildTree(spec) tree := BuildTree(spec)
@ -342,11 +342,11 @@ func TestOpenLeafRequestWithFallbackRead(t *testing.T) {
} }
} }
func TestOpenLeafRequestCancelOnClose(t *testing.T) { func TestOpenLeafRdwrCancelOnClose(t *testing.T) {
canceled := make(chan struct{}) canceled := make(chan struct{})
started := make(chan struct{}) started := make(chan struct{})
spec := DirNode("/", spec := DirNode("/",
FileNode("request", 0666, Request(func(ctx context.Context, data []byte) ([]byte, error) { FileNode("request", 0666, Rdwr(func(ctx context.Context, data []byte) ([]byte, error) {
close(started) close(started)
<-ctx.Done() <-ctx.Done()
close(canceled) close(canceled)

View File

@ -25,10 +25,10 @@ type File interface {
// Read-side contracts (mutually exclusive — at most one true): // Read-side contracts (mutually exclusive — at most one true):
// BlockingReadMode — blocks up to 5s for next value, returns it or empty, EOF on 2nd read // BlockingReadMode — blocks up to 5s for next value, returns it or empty, EOF on 2nd read
// StreamMode — blocks indefinitely, streams data incrementally, FD lives until EOF // StreamMode — blocks indefinitely, streams data incrementally, FD lives until EOF
// RequestRespMode — write blocks until result produced, read returns result once (rdwr) // RdwrMode — write blocks until result produced, read returns result once
BlockingReadMode() bool BlockingReadMode() bool
StreamMode() bool StreamMode() bool
RequestRespMode() bool RdwrMode() bool
} }
// fileConfig implements File via function pointers. // fileConfig implements File via function pointers.
@ -40,7 +40,7 @@ type fileConfig struct {
BlockingReadFn func(context.Context, string) ([]byte, string, error) BlockingReadFn func(context.Context, string) ([]byte, string, error)
BlockingRead_ bool // one-shot waiter: block 5s, return once, EOF on 2nd read BlockingRead_ bool // one-shot waiter: block 5s, return once, EOF on 2nd read
StreamMode_ bool // indefinite stream: block forever, keep FD open StreamMode_ bool // indefinite stream: block forever, keep FD open
RequestResp_ bool // rdwr: write blocks on write, read returns result once Rdwr_ bool // rdwr: write blocks on write, read returns result once
} }
func (e *fileConfig) Stat() (os.FileInfo, error) { return e.StatFn() } func (e *fileConfig) Stat() (os.FileInfo, error) { return e.StatFn() }
@ -57,7 +57,7 @@ func (e *fileConfig) BlockingRead(ctx context.Context, base string) ([]byte, str
} }
func (e *fileConfig) BlockingReadMode() bool { return e.BlockingRead_ } func (e *fileConfig) BlockingReadMode() bool { return e.BlockingRead_ }
func (e *fileConfig) StreamMode() bool { return e.StreamMode_ } func (e *fileConfig) StreamMode() bool { return e.StreamMode_ }
func (e *fileConfig) RequestRespMode() bool { return e.RequestResp_ } func (e *fileConfig) RdwrMode() bool { return e.Rdwr_ }
// SyntheticFileInfo implements os.FileInfo for entries with no backing file. // SyntheticFileInfo implements os.FileInfo for entries with no backing file.
type SyntheticFileInfo struct { type SyntheticFileInfo struct {

View File

@ -256,7 +256,7 @@ func TestGenerateHelpModes(t *testing.T) {
spec := DirNode("/", spec := DirNode("/",
FileNode("ro", 0444, Doc("read only file")), FileNode("ro", 0444, Doc("read only file")),
FileNode("rw", 0644, Doc("read write file"), Write(func(_ []byte) error { return nil })), FileNode("rw", 0644, Doc("read write file"), Write(func(_ []byte) error { return nil })),
FileNode("ctl", 0200, Doc("control file"), Request(func(_ context.Context, _ []byte) ([]byte, error) { return nil, nil })), FileNode("ctl", 0200, Doc("control file"), Rdwr(func(_ context.Context, _ []byte) ([]byte, error) { return nil, nil })),
) )
help := GenerateHelp(spec) help := GenerateHelp(spec)
if help == "" { if help == "" {