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.
This commit is contained in:
Levi Neely 2026-07-18 13:29:35 +02:00
parent 61240e02de
commit 4ff3961dfd
3 changed files with 102 additions and 4 deletions

View File

@ -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"
}

View File

@ -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
}

View File

@ -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 <pid> [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()