From 4ff3961dfd9058802933cdb10126d0fa2a79257a Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:29:35 +0200 Subject: [PATCH] 9p: add proc/ directory for detached process inspection - ls /s/{id}/proc/ lists detached PIDs - cat /s/{id}/proc/{pid} reads ring buffer output - rm /s/{id}/proc/{pid} dismisses the process - echo detach > /s/{id}/ctl detaches running process - echo "signal {pid} [TERM|KILL]" > /s/{id}/ctl signals detached process Walk validates PID exists in detached list before succeeding. --- server/server.go | 22 ++++++++++++-- session/session.go | 65 +++++++++++++++++++++++++++++++++++++++++- session/sessionfile.go | 19 ++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/server/server.go b/server/server.go index 20e9599..760cec1 100644 --- a/server/server.go +++ b/server/server.go @@ -161,12 +161,15 @@ func (s *Server) dirMode(path string) plan9.Perm { func (s *Server) filePerm(path string) os.FileMode { // Session files have per-file overrides. if strings.HasPrefix(path, fs.PathSessions) { - // Peer files are writable. + // Peer/proc file permissions. name := strings.TrimPrefix(path, fs.PathSessions) parts := strings.SplitN(name, "/", 3) if len(parts) == 3 && parts[1] == "peer" { return 0666 } + if len(parts) == 3 && parts[1] == "proc" { + return 0444 + } base := pathBase(path) if m, ok := fs.Perms[fs.PathSessionFile].Files[base]; ok { return m @@ -503,9 +506,9 @@ func (s *Server) pathType(path string) string { } return "dir" } - // /s/{id}/peer — directory + // /s/{id}/peer or /s/{id}/proc — directory parts := strings.SplitN(name, "/", 3) - if len(parts) == 2 && parts[1] == "peer" { + if len(parts) == 2 && (parts[1] == "peer" || parts[1] == "proc") { return "dir" } // /s/{id}/peer/{peer-id} — verify peer exists @@ -519,6 +522,19 @@ func (s *Server) pathType(path string) string { } return "" } + // /s/{id}/proc/{pid} — verify PID exists in detached list + if len(parts) == 3 && parts[1] == "proc" { + sess := s.sessionMgr.Session(parts[0]) + if sess == nil { + return "" + } + for _, p := range sess.Core.ListDetached() { + if fmt.Sprintf("%d", p.PID) == parts[2] { + return "file" + } + } + return "" + } // /s/{id}/{file} — file return "file" } diff --git a/session/session.go b/session/session.go index e891b7a..7858d26 100644 --- a/session/session.go +++ b/session/session.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sort" + "strconv" "strings" "sync" "sync/atomic" @@ -265,8 +266,9 @@ func (s *Manager) Readdir(name string) ([]os.DirEntry, error) { if err != nil { return nil, err } - // Append peer/ directory + // Append peer/ and proc/ directories entries = append(entries, fs.DirEntry("peer", 0755)) + entries = append(entries, fs.DirEntry("proc", 0755)) return entries, nil } // {id}/peer — list peer session IDs @@ -278,6 +280,15 @@ func (s *Manager) Readdir(name string) ([]os.DirEntry, error) { } return entries, nil } + // {id}/proc — list detached process PIDs + if len(parts) == 2 && parts[1] == "proc" { + procs := sess.Core.ListDetached() + entries := make([]os.DirEntry, len(procs)) + for i, p := range procs { + entries[i] = fs.FileEntry(fmt.Sprintf("%d", p.PID), 0666) + } + return entries, nil + } return nil, fmt.Errorf("%s: not a directory", name) } @@ -408,6 +419,14 @@ func (s *Manager) stat(name string) (os.FileInfo, error) { } return &fs.SyntheticFileInfo{Name_: peerID, Mode_: 0666}, nil } + // Proc directory: {id}/proc + if parts[1] == "proc" { + if len(parts) == 2 { + return &fs.SyntheticFileInfo{Name_: "proc", Mode_: 0755, IsDir_: true}, nil + } + // Proc file: {id}/proc/{pid} — read-only output + return &fs.SyntheticFileInfo{Name_: parts[2], Mode_: 0444}, nil + } // Session file: {id}/{file} sfs, err := s.openStore(sess) if err != nil { @@ -423,6 +442,35 @@ func (s *Manager) openEntry(name string) (fs.File, error) { // Peer file: {id}/peer/{peer-id} — write submits prompt to peer parts := strings.SplitN(name, "/", 3) + + // Proc file: {id}/proc/{pid} — read returns ring buffer output + if len(parts) == 3 && parts[1] == "proc" { + sessID := parts[0] + sess := s.Session(sessID) + if sess == nil { + return nil, fmt.Errorf("%s: not found", name) + } + pidStr := parts[2] + pid, err := strconv.Atoi(pidStr) + if err != nil { + return nil, fmt.Errorf("invalid pid: %s", pidStr) + } + return &fs.FileConfig{ + StatFn: func() (os.FileInfo, error) { + return &fs.SyntheticFileInfo{Name_: pidStr, Mode_: 0444}, nil + }, + ReadFn: func() ([]byte, error) { + output, err := sess.Core.GetDetachedOutput(pid) + if err != nil { + return nil, err + } + return []byte(output), nil + }, + WriteFn: func([]byte) error { return fmt.Errorf("read-only") }, + BlockingReadFn: notBlocking, + }, nil + } + if len(parts) == 3 && parts[1] == "peer" { sessID := parts[0] peerID := parts[2] @@ -521,6 +569,21 @@ func (s *Manager) del(name string) error { if len(parts) == 3 && parts[1] == "peer" { return s.PeerRemove(sessID, parts[2]) } + // Proc dismiss: {id}/proc/{pid} + if len(parts) == 3 && parts[1] == "proc" { + sess := s.Session(sessID) + if sess == nil { + return fmt.Errorf("session not found: %s", sessID) + } + pid, err := strconv.Atoi(parts[2]) + if err != nil { + return fmt.Errorf("invalid pid: %s", parts[2]) + } + if !sess.Core.DismissDetached(pid) { + return fmt.Errorf("process %d not found or still running", pid) + } + return nil + } // Session files are synthetic; allow rm -r to continue. return nil } diff --git a/session/sessionfile.go b/session/sessionfile.go index 8dd5d89..4e01d65 100644 --- a/session/sessionfile.go +++ b/session/sessionfile.go @@ -543,6 +543,25 @@ func (h *sessionHelper) handleCtl(input string) error { copy(data, h.sess.log) h.sess.mu.RUnlock() return h.saveTranscript(data) + case "detach": + if !h.sess.Core.Detach() { + return fmt.Errorf("no running process to detach") + } + case "signal": + if len(cmd) < 2 { + return fmt.Errorf("usage: signal [TERM|KILL]") + } + pid, err := strconv.Atoi(cmd[1]) + if err != nil { + return fmt.Errorf("invalid pid: %s", cmd[1]) + } + sig := 15 // SIGTERM default + if len(cmd) >= 3 && strings.ToUpper(cmd[2]) == "KILL" { + sig = 9 + } + if err := h.sess.Core.SignalDetached(pid, sig); err != nil { + return err + } case "invalidate": if h.invalidateModels != nil { h.invalidateModels()