From e8ec2c8c6f79c8588b654c75b7e143264e21554b Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Fri, 31 Jul 2026 17:42:20 -0500 Subject: [PATCH 1/2] feat(shim): prototype marvel-shim in-pane PTY substrate with probe rig Spike for aae-orc-e35c, feeding the aae-orc-kxce substrate decision. Not production code and not for merge as-is. marvel-shim runs inside a tmux pane as the OS parent of one harness. It allocates the child's PTY via creack/pty, inherits its own stdio from the pane, tees child output to Unix-socket subscribers, and serves status, signal, stop, and inject on a second Unix socket. Inject writes to the PTY master, so the child cannot tell injected bytes from typed ones. shimprobe carries the test children and clients: a size-aware child, a numbered-ANSI producer, a terminal-capability prober for the falsification case, a line-stamping echo child, a stream consumer, a control client, a verbatim stream dump, and the round-trip timer. scripts/shim-spike.sh drives one subcommand per pre-declared signal on a private tmux server socket. The stream test guards a defect the signal-2 run found: child exit used to kill the subscriber pump goroutines mid-queue, losing the tail for any consumer that had not already drained. --- cmd/marvel-shim/control.go | 135 +++++++++++ cmd/marvel-shim/main.go | 227 +++++++++++++++++++ cmd/marvel-shim/stream.go | 165 ++++++++++++++ cmd/marvel-shim/stream_test.go | 125 ++++++++++ cmd/shimprobe/children.go | 195 ++++++++++++++++ cmd/shimprobe/clients.go | 136 +++++++++++ cmd/shimprobe/latency.go | 154 +++++++++++++ cmd/shimprobe/main.go | 53 +++++ cmd/shimprobe/tee.go | 40 ++++ go.mod | 1 + go.sum | 2 + scripts/shim-spike.sh | 402 +++++++++++++++++++++++++++++++++ 12 files changed, 1635 insertions(+) create mode 100644 cmd/marvel-shim/control.go create mode 100644 cmd/marvel-shim/main.go create mode 100644 cmd/marvel-shim/stream.go create mode 100644 cmd/marvel-shim/stream_test.go create mode 100644 cmd/shimprobe/children.go create mode 100644 cmd/shimprobe/clients.go create mode 100644 cmd/shimprobe/latency.go create mode 100644 cmd/shimprobe/main.go create mode 100644 cmd/shimprobe/tee.go create mode 100755 scripts/shim-spike.sh diff --git a/cmd/marvel-shim/control.go b/cmd/marvel-shim/control.go new file mode 100644 index 0000000..387d28c --- /dev/null +++ b/cmd/marvel-shim/control.go @@ -0,0 +1,135 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "strings" + "syscall" +) + +// request is one line of JSON on the control socket. +type request struct { + Cmd string `json:"cmd"` + Signal string `json:"signal,omitempty"` + Data string `json:"data,omitempty"` +} + +type response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Pid int `json:"pid,omitempty"` + ShimPid int `json:"shim_pid,omitempty"` + Running *bool `json:"running,omitempty"` + Written int `json:"written,omitempty"` + Clients int `json:"clients,omitempty"` +} + +func listenUnix(path string) (net.Listener, error) { + ln, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("listen %s: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + _ = ln.Close() + return nil, err + } + return ln, nil +} + +func (s *shim) serveControl(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go s.handleControl(conn) + } +} + +// handleControl serves one supervisor connection until it goes away. A +// disconnect is never fatal to the shim: that is signal 4's "survives +// supervisor restart" property, and it falls out of per-connection handling. +func (s *shim) handleControl(conn net.Conn) { + defer func() { _ = conn.Close() }() + sc := bufio.NewScanner(conn) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + enc := json.NewEncoder(conn) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var req request + if err := json.Unmarshal([]byte(line), &req); err != nil { + _ = enc.Encode(response{Error: "bad json: " + err.Error()}) + continue + } + _ = enc.Encode(s.dispatch(req)) + } +} + +func (s *shim) dispatch(req request) response { + switch req.Cmd { + case "status": + running := true + select { + case <-s.exited: + running = false + default: + } + pid := 0 + if s.cmd.Process != nil { + pid = s.cmd.Process.Pid + } + return response{OK: true, Pid: pid, ShimPid: os.Getpid(), Running: &running, Clients: s.bcast.clientCount()} + + case "signal": + sig, ok := signalByName(req.Signal) + if !ok { + return response{Error: "unknown signal " + req.Signal} + } + if err := s.signalChild(sig); err != nil { + return response{Error: err.Error()} + } + return response{OK: true} + + case "stop": + if err := s.signalChild(syscall.SIGTERM); err != nil { + return response{Error: err.Error()} + } + return response{OK: true} + + case "inject": + n, err := s.inject(req.Data) + if err != nil { + return response{Error: err.Error()} + } + return response{OK: true, Written: n} + + default: + return response{Error: "unknown cmd " + req.Cmd} + } +} + +func signalByName(name string) (syscall.Signal, bool) { + switch strings.ToUpper(strings.TrimPrefix(strings.ToUpper(name), "SIG")) { + case "HUP": + return syscall.SIGHUP, true + case "INT": + return syscall.SIGINT, true + case "TERM": + return syscall.SIGTERM, true + case "KILL": + return syscall.SIGKILL, true + case "USR1": + return syscall.SIGUSR1, true + case "USR2": + return syscall.SIGUSR2, true + case "WINCH": + return syscall.SIGWINCH, true + } + return 0, false +} diff --git a/cmd/marvel-shim/main.go b/cmd/marvel-shim/main.go new file mode 100644 index 0000000..47b6e85 --- /dev/null +++ b/cmd/marvel-shim/main.go @@ -0,0 +1,227 @@ +// Command marvel-shim is a spike prototype of the shim-in-pane substrate +// candidate (aae-orc-e35c, feeding the kxce substrate decision). +// +// It is the OS parent of one harness process. It allocates a PTY for that +// child, inherits its own stdio from the tmux pane it runs in, and tees the +// child's output to any number of Unix-socket stream subscribers while +// serving a JSON control API on a second Unix socket. +// +// Launch shape: +// +// tmux new-window 'marvel-shim --control C.sock --stream S.sock -- claude' +// +// Nothing here is production code. Error handling is spike-grade and the +// protocol is deliberately the smallest thing that answers the five +// pre-declared success signals. +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/creack/pty" + "golang.org/x/term" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "marvel-shim: %v\n", err) + os.Exit(1) + } +} + +func run() error { + var ( + controlPath = flag.String("control", "", "path for the JSON control socket (required)") + streamPath = flag.String("stream", "", "path for the output stream socket (required)") + onHUP = flag.String("on-hup", "kill", "SIGHUP handling: kill (forward to child, exit) or detach (ignore, keep child)") + quiet = flag.Bool("quiet", false, "suppress the shim's own banner on stderr") + ) + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: marvel-shim --control PATH --stream PATH -- COMMAND [ARG...]\n\n") + flag.PrintDefaults() + } + flag.Parse() + + argv := flag.Args() + if len(argv) == 0 { + flag.Usage() + return errors.New("no command given after --") + } + if *controlPath == "" || *streamPath == "" { + flag.Usage() + return errors.New("--control and --stream are required") + } + if *onHUP != "kill" && *onHUP != "detach" { + return fmt.Errorf("--on-hup must be kill or detach, got %q", *onHUP) + } + + for _, p := range []string{*controlPath, *streamPath} { + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + // A stale socket from a crashed shim would make Listen fail. + _ = os.Remove(p) + } + + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = append(os.Environ(), "MARVEL_SHIM=1") + + // Seed the child PTY with the pane's current size so the first frame the + // child paints is already correct; SIGWINCH only covers later changes. + var winsz *pty.Winsize + if ws, err := pty.GetsizeFull(os.Stdin); err == nil { + winsz = ws + } + ptmx, err := pty.StartWithSize(cmd, winsz) + if err != nil { + return fmt.Errorf("start child under pty: %w", err) + } + defer func() { _ = ptmx.Close() }() + + bc := newBroadcaster() + sh := &shim{ + cmd: cmd, + ptmx: ptmx, + bcast: bc, + exited: make(chan struct{}), + } + + ctlLn, err := listenUnix(*controlPath) + if err != nil { + return err + } + defer func() { _ = ctlLn.Close() }() + strLn, err := listenUnix(*streamPath) + if err != nil { + return err + } + defer func() { _ = strLn.Close() }() + + go sh.serveControl(ctlLn) + go bc.serve(strLn) + + if !*quiet { + fmt.Fprintf(os.Stderr, "[marvel-shim] pid=%d child=%d control=%s stream=%s\n", + os.Getpid(), cmd.Process.Pid, *controlPath, *streamPath) + } + + // Raw mode on the shim's own tty is load-bearing, not cosmetic: without it + // the pane's line discipline echoes and line-buffers, so a child that + // writes a terminal query and blocks reading the reply never sees one. + // That is the Cursor-class TTY-hang this spike must not reintroduce. + var restore func() + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("raw mode on shim stdin: %w", err) + } + restore = func() { _ = term.Restore(int(os.Stdin.Fd()), state) } + defer restore() + } + + sh.watchSignals(*onHUP) + + // Pane -> child. Runs until the pane's stdin closes. + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + + // Child -> pane, tee'd to stream subscribers. This is the shim's main + // loop; it returns when the child PTY hits EIO (child gone on Darwin). + buf := make([]byte, 32*1024) + for { + n, rerr := ptmx.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + bc.publish(chunk) + _, _ = os.Stdout.Write(chunk) + } + if rerr != nil { + break + } + } + + werr := cmd.Wait() + close(sh.exited) + bc.close() + if restore != nil { + restore() + } + if werr != nil && cmd.ProcessState == nil { + return werr + } + if code := cmd.ProcessState.ExitCode(); code != 0 { + if !*quiet { + fmt.Fprintf(os.Stderr, "\r\n[marvel-shim] child exited %d\r\n", code) + } + os.Exit(code) + } + return nil +} + +type shim struct { + cmd *exec.Cmd + ptmx *os.File + bcast *broadcaster + exited chan struct{} +} + +// watchSignals forwards the signals a pane can deliver. SIGWINCH is the +// interesting one: the pane resize has to be re-read from the shim's own tty +// and pushed down to the child's PTY, because the two PTYs are independent +// kernel objects and the kernel does not chain the notification. +func (s *shim) watchSignals(onHUP string) { + winch := make(chan os.Signal, 1) + signal.Notify(winch, syscall.SIGWINCH) + go func() { + for range winch { + if err := pty.InheritSize(os.Stdin, s.ptmx); err != nil { + fmt.Fprintf(os.Stderr, "[marvel-shim] resize: %v\r\n", err) + } + } + }() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) + go func() { + for sig := range sigs { + if sig == syscall.SIGHUP && onHUP == "detach" { + // Deliberately swallowed. Whether the child then survives is + // a property of how tmux tears the pane down, not of this + // branch; the spike measures it rather than assuming it. + continue + } + _ = s.signalChild(sig) + if sig == syscall.SIGHUP { + select { + case <-s.exited: + case <-time.After(2 * time.Second): + _ = s.signalChild(syscall.SIGKILL) + } + os.Exit(129) + } + } + }() +} + +func (s *shim) signalChild(sig os.Signal) error { + if s.cmd.Process == nil { + return errors.New("no child") + } + return s.cmd.Process.Signal(sig) +} + +// inject writes to the PTY master. The child cannot distinguish these bytes +// from bytes typed in the pane, and the kernel serialises the write, which is +// the property tmux send-keys does not give us. +func (s *shim) inject(data string) (int, error) { + return s.ptmx.Write([]byte(data)) +} diff --git a/cmd/marvel-shim/stream.go b/cmd/marvel-shim/stream.go new file mode 100644 index 0000000..6cec93e --- /dev/null +++ b/cmd/marvel-shim/stream.go @@ -0,0 +1,165 @@ +package main + +import ( + "net" + "sync" + "time" +) + +// lagLimit bounds the bytes queued for one slow subscriber. Past it the +// subscriber is dropped rather than allowed to stall the pane render, and the +// drop is counted so loss is observable instead of silent. +const lagLimit = 64 << 20 + +// drainGrace bounds how long child exit waits for subscribers to finish +// reading. Without a wait the shim's own exit kills the pump goroutines +// mid-queue and the supervisor silently loses the child's last output, which +// is exactly what a lagging consumer hits first. +const drainGrace = 10 * time.Second + +// broadcaster tees child output to zero or more stream subscribers. A +// subscriber sees bytes from its connect time forward; there is no replay +// buffer in this spike. +type broadcaster struct { + mu sync.Mutex + clients map[*subscriber]struct{} + closed bool +} + +type subscriber struct { + conn net.Conn + mu sync.Mutex + cond *sync.Cond + queue [][]byte + queued int + dropped int + done bool + drained chan struct{} +} + +func newBroadcaster() *broadcaster { + return &broadcaster{clients: make(map[*subscriber]struct{})} +} + +func (b *broadcaster) serve(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + return + } + s := &subscriber{conn: conn, drained: make(chan struct{})} + s.cond = sync.NewCond(&s.mu) + + b.mu.Lock() + if b.closed { + b.mu.Unlock() + _ = conn.Close() + continue + } + b.clients[s] = struct{}{} + b.mu.Unlock() + + go s.pump(func() { b.remove(s) }) + } +} + +func (b *broadcaster) remove(s *subscriber) { + b.mu.Lock() + delete(b.clients, s) + b.mu.Unlock() +} + +func (b *broadcaster) clientCount() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.clients) +} + +// publish must not block on subscriber I/O: the same goroutine writes the +// pane, so a slow marvel-side reader would otherwise freeze the human's view. +func (b *broadcaster) publish(chunk []byte) { + b.mu.Lock() + subs := make([]*subscriber, 0, len(b.clients)) + for s := range b.clients { + subs = append(subs, s) + } + b.mu.Unlock() + for _, s := range subs { + s.enqueue(chunk) + } +} + +// close tells every subscriber no more bytes are coming and waits for their +// queues to reach the socket, so the shim's exit does not truncate the +// supervisor's view of the child's final output. +func (b *broadcaster) close() { + b.mu.Lock() + b.closed = true + subs := make([]*subscriber, 0, len(b.clients)) + for s := range b.clients { + subs = append(subs, s) + } + b.mu.Unlock() + for _, s := range subs { + s.finish() + } + deadline := time.After(drainGrace) + for _, s := range subs { + select { + case <-s.drained: + case <-deadline: + return + } + } +} + +func (s *subscriber) enqueue(chunk []byte) { + s.mu.Lock() + defer s.mu.Unlock() + if s.done { + return + } + if s.queued+len(chunk) > lagLimit { + s.dropped += len(chunk) + return + } + s.queue = append(s.queue, chunk) + s.queued += len(chunk) + s.cond.Signal() +} + +func (s *subscriber) finish() { + s.mu.Lock() + s.done = true + s.cond.Signal() + s.mu.Unlock() +} + +// pump drains the queue in order, then closes the connection so the consumer +// sees EOF only after every byte it was owed. +func (s *subscriber) pump(cleanup func()) { + defer close(s.drained) + defer cleanup() + defer func() { _ = s.conn.Close() }() + for { + s.mu.Lock() + for len(s.queue) == 0 && !s.done { + s.cond.Wait() + } + if len(s.queue) == 0 && s.done { + s.mu.Unlock() + return + } + batch := s.queue + s.queue = nil + s.queued = 0 + s.mu.Unlock() + + for _, chunk := range batch { + if _, err := s.conn.Write(chunk); err != nil { + s.finish() + return + } + } + } +} diff --git a/cmd/marvel-shim/stream_test.go b/cmd/marvel-shim/stream_test.go new file mode 100644 index 0000000..9333c7b --- /dev/null +++ b/cmd/marvel-shim/stream_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +// tempSocket keeps the path short: a Unix socket address is capped near 104 +// bytes on Darwin, which t.TempDir() paths exceed. Same shape the daemon tests +// use. +func tempSocket(t *testing.T, tag string) string { + t.Helper() + p := filepath.Join(os.TempDir(), fmt.Sprintf("marvel-shim-%s-%d.sock", tag, os.Getpid())) + _ = os.Remove(p) + t.Cleanup(func() { _ = os.Remove(p) }) + return p +} + +// TestBroadcasterDrainsSlowSubscriberOnClose guards the defect the spike's +// signal-2 run found: without a drain wait, child exit tore down the pump +// goroutines mid-queue and a lagging consumer silently lost the tail. +func TestBroadcasterDrainsSlowSubscriberOnClose(t *testing.T) { + sock := tempSocket(t, "drain") + ln, err := listenUnix(sock) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + bc := newBroadcaster() + go bc.serve(ln) + + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + waitFor(t, func() bool { return bc.clientCount() == 1 }) + + const lines = 2000 + var want bytes.Buffer + for i := 0; i < lines; i++ { + chunk := []byte(fmt.Sprintf("line %06d padding-padding-padding\n", i)) + want.Write(chunk) + bc.publish(chunk) + } + + // close() must not return until the subscriber has been served, so a reader + // that only starts afterwards still sees every byte. + done := make(chan struct{}) + go func() { + bc.close() + close(done) + }() + + got, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("read stream: %v", err) + } + if !bytes.Equal(got, want.Bytes()) { + t.Fatalf("stream truncated or reordered: got %d bytes, want %d", len(got), want.Len()) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("close did not return after the subscriber drained") + } +} + +// TestBroadcasterFanOut checks that two subscribers each get the full byte +// stream, since marvel plus a diagnostic viewer is the expected shape. +func TestBroadcasterFanOut(t *testing.T) { + sock := tempSocket(t, "fanout") + ln, err := listenUnix(sock) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + bc := newBroadcaster() + go bc.serve(ln) + + conns := make([]net.Conn, 2) + for i := range conns { + c, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + conns[i] = c + } + waitFor(t, func() bool { return bc.clientCount() == len(conns) }) + + payload := []byte("\x1b[32mhello\x1b[0m\n") + bc.publish(payload) + bc.close() + + for i, c := range conns { + got, err := io.ReadAll(c) + if err != nil { + t.Fatalf("subscriber %d: %v", i, err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("subscriber %d got %q, want %q", i, got, payload) + } + } +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met within 2s") +} diff --git a/cmd/shimprobe/children.go b/cmd/shimprobe/children.go new file mode 100644 index 0000000..a2669f7 --- /dev/null +++ b/cmd/shimprobe/children.go @@ -0,0 +1,195 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/creack/pty" + "golang.org/x/term" +) + +// runWinsize answers signal 1: does SIGWINCH reach the child through two +// stacked PTYs, and does the child's tty report the new size? +func runWinsize(args []string) error { + fs := flag.NewFlagSet("winsize", flag.ExitOnError) + dur := fs.Duration("for", 20*time.Second, "how long to stay alive") + if err := fs.Parse(args); err != nil { + return err + } + + report := func(tag string) { + ws, err := pty.GetsizeFull(os.Stdin) + if err != nil { + fmt.Printf("WINSIZE %s err=%v\n", tag, err) + return + } + fmt.Printf("WINSIZE %s rows=%d cols=%d\n", tag, ws.Rows, ws.Cols) + } + + ch := make(chan os.Signal, 8) + signal.Notify(ch, syscall.SIGWINCH) + report("initial") + + deadline := time.After(*dur) + for { + select { + case <-ch: + report("sigwinch") + case <-deadline: + fmt.Println("WINSIZE done") + return nil + } + } +} + +// runSpew answers signal 2: it produces a known number of numbered lines with +// ANSI decoration so a consumer can verify byte-for-byte that nothing was +// dropped between the child PTY and the stream socket. +func runSpew(args []string) error { + fs := flag.NewFlagSet("spew", flag.ExitOnError) + n := fs.Int("n", 20000, "number of lines") + if err := fs.Parse(args); err != nil { + return err + } + w := bufio.NewWriterSize(os.Stdout, 64*1024) + for i := 1; i <= *n; i++ { + _, _ = fmt.Fprintf(w, "\x1b[3%dmSPEW %06d\x1b[0m padding-padding-padding\n", i%8, i) + } + _, _ = fmt.Fprintf(w, "SPEWDONE %d\n", *n) + return w.Flush() +} + +// runTTYProbe is the falsification harness for the Cursor-class TTY-hang. It +// writes a DA1 query, then reads stdin with a deadline. A real terminal +// answers with a CSI ? ... c report; a PTY with nothing behind it does not, +// and a harness that blocks forever on that read is the failure being +// emulated. +// +// -raw decides which failure is being measured. With raw set (what a real +// harness does) the read returns as soon as any byte arrives, so the only way +// to hang is for nothing to answer. With -raw=false the line discipline holds +// the reply until a newline that a DA1 report never contains, so the read +// hangs even though the answer arrived. Both are worth measuring, and the +// second one is not specific to the shim. +func runTTYProbe(args []string) error { + fs := flag.NewFlagSet("ttyprobe", flag.ExitOnError) + timeout := fs.Duration("timeout", 3*time.Second, "how long to wait for the reply") + query := fs.String("query", "da1", "query to emit: da1 or kitty") + raw := fs.Bool("raw", true, "put stdin in raw mode first, as a real harness would") + if err := fs.Parse(args); err != nil { + return err + } + + tty := isTTY(os.Stdin) + fmt.Fprintf(os.Stderr, "TTYPROBE stdin-is-tty=%v raw=%v\n", tty, *raw) + if *raw && tty { + state, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("raw mode: %w", err) + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), state) }() + } + + var seq string + switch *query { + case "da1": + seq = "\x1b[c" + case "kitty": + seq = "\x1b[?u" + default: + return fmt.Errorf("unknown query %q", *query) + } + if _, err := os.Stdout.WriteString(seq); err != nil { + return err + } + + type result struct { + n int + buf []byte + err error + } + res := make(chan result, 1) + go func() { + buf := make([]byte, 64) + n, err := os.Stdin.Read(buf) + res <- result{n: n, buf: buf[:n], err: err} + }() + + start := time.Now() + select { + case r := <-res: + if r.err != nil && r.n == 0 { + fmt.Printf("\r\nTTYPROBE result=error after=%s err=%v\r\n", time.Since(start), r.err) + return nil + } + fmt.Printf("\r\nTTYPROBE result=reply after=%s bytes=%d raw=%q\r\n", + time.Since(start), r.n, string(r.buf)) + case <-time.After(*timeout): + fmt.Printf("\r\nTTYPROBE result=HANG after=%s (no reply within timeout)\r\n", *timeout) + } + return nil +} + +// runEcho answers signal 3: it stamps every line it reads so concurrent +// injections can be checked for interleaving corruption at the child. +// +// -raw is not cosmetic here. In cooked mode the child's line discipline caps +// one input line at MAX_CANON (1024 bytes on Darwin) and discards the excess, +// so a large inject looks like shim loss when it is really the child's termios. +// Real harnesses set raw mode, which is what -raw reproduces. +func runEcho(args []string) error { + fs := flag.NewFlagSet("echo", flag.ExitOnError) + dur := fs.Duration("for", 30*time.Second, "idle lifetime cap") + raw := fs.Bool("raw", false, "put stdin in raw mode, as a real harness would") + if err := fs.Parse(args); err != nil { + return err + } + go func() { + time.Sleep(*dur) + fmt.Println("ECHO timeout") + os.Exit(0) + }() + + if *raw && isTTY(os.Stdin) { + state, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("raw mode: %w", err) + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), state) }() + } + + rd := bufio.NewReaderSize(os.Stdin, 256*1024) + var line []byte + i := 0 + for { + b, err := rd.ReadByte() + if err != nil { + return nil + } + if b != '\n' && b != '\r' { + line = append(line, b) + continue + } + if len(line) == 0 { + continue + } + i++ + s := string(line) + line = line[:0] + if s == "QUIT" { + fmt.Printf("ECHO quit after=%d\r\n", i) + return nil + } + fmt.Printf("ECHO %04d [%s]\r\n", i, s) + } +} + +func isTTY(f *os.File) bool { + _, err := pty.GetsizeFull(f) + return err == nil +} diff --git a/cmd/shimprobe/clients.go b/cmd/shimprobe/clients.go new file mode 100644 index 0000000..eca1552 --- /dev/null +++ b/cmd/shimprobe/clients.go @@ -0,0 +1,136 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "net" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +var spewLine = regexp.MustCompile(`SPEW (\d{6})`) + +// runSink is the marvel-side consumer for signal 2. It reads the stream +// socket to EOF and reports how many spew lines arrived, in what order, and +// whether the terminator was seen. +func runSink(args []string) error { + fs := flag.NewFlagSet("sink", flag.ExitOnError) + sock := fs.String("s", "", "stream socket path") + slow := fs.Duration("slow", 0, "sleep this long per read, to simulate a lagging consumer") + if err := fs.Parse(args); err != nil { + return err + } + if *sock == "" { + return fmt.Errorf("-s is required") + } + conn, err := net.Dial("unix", *sock) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + sc := bufio.NewScanner(conn) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + var ( + total int + seen = map[int]bool{} + lastSeq int + outOrder int + expected int + bytes int + ) + for sc.Scan() { + if *slow > 0 { + time.Sleep(*slow) + } + line := sc.Text() + bytes += len(line) + 1 + total++ + if m := spewLine.FindStringSubmatch(line); m != nil { + seq, _ := strconv.Atoi(m[1]) + if seen[seq] { + continue + } + seen[seq] = true + if seq < lastSeq { + outOrder++ + } + lastSeq = seq + } + if strings.Contains(line, "SPEWDONE") { + fields := strings.Fields(strings.TrimSpace(line)) + expected, _ = strconv.Atoi(fields[len(fields)-1]) + break + } + } + missing := 0 + for i := 1; i <= expected; i++ { + if !seen[i] { + missing++ + } + } + fmt.Printf("SINK lines=%d bytes=%d unique_spew=%d expected=%d missing=%d out_of_order=%d scan_err=%v\n", + total, bytes, len(seen), expected, missing, outOrder, sc.Err()) + if expected > 0 && missing == 0 && outOrder == 0 { + fmt.Println("SINK verdict=PASS") + } else { + fmt.Println("SINK verdict=FAIL") + } + return nil +} + +// runCtl is the supervisor stand-in for signals 3 and 4. +func runCtl(args []string) error { + fs := flag.NewFlagSet("ctl", flag.ExitOnError) + sock := fs.String("c", "", "control socket path") + cmd := fs.String("cmd", "status", "status|signal|stop|inject") + sig := fs.String("signal", "", "signal name for -cmd signal") + data := fs.String("data", "", "payload for -cmd inject (\\n and \\r are expanded)") + repeat := fs.Int("repeat", 1, "send the command this many times") + gap := fs.Duration("gap", 0, "pause between repeats") + hold := fs.Duration("hold", 0, "keep the connection open this long after the last reply") + if err := fs.Parse(args); err != nil { + return err + } + if *sock == "" { + return fmt.Errorf("-c is required") + } + conn, err := net.Dial("unix", *sock) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + payload := strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t").Replace(*data) + enc := json.NewEncoder(conn) + dec := json.NewDecoder(conn) + for i := 0; i < *repeat; i++ { + req := map[string]string{"cmd": *cmd} + if *sig != "" { + req["signal"] = *sig + } + if payload != "" { + req["data"] = payload + } + if err := enc.Encode(req); err != nil { + return err + } + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + return err + } + _, _ = fmt.Fprintf(os.Stdout, "CTL %s\n", string(raw)) + if *gap > 0 { + time.Sleep(*gap) + } + } + if *hold > 0 { + time.Sleep(*hold) + } + return nil +} diff --git a/cmd/shimprobe/latency.go b/cmd/shimprobe/latency.go new file mode 100644 index 0000000..68ffe1b --- /dev/null +++ b/cmd/shimprobe/latency.go @@ -0,0 +1,154 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "net" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/creack/pty" +) + +// runRTT measures the round trip from "a supervisor asks for a keystroke" to +// "the supervisor sees the child's answer", once per mode, using the same echo +// child both times. The delta between modes is the cost the shim adds: one +// extra PTY pair plus two Unix-socket hops. +// +// single: driver -> pty master -> child -> pty master -> driver +// shim: driver -> control uds -> shim -> pty master -> child +// -> pty master -> shim -> stream uds -> driver +// +// Absolute numbers here are dominated by process scheduling on a loaded +// laptop; only the difference between the two modes is worth quoting. +func runRTT(args []string) error { + fs := flag.NewFlagSet("rtt", flag.ExitOnError) + mode := fs.String("mode", "single", "single (one PTY, no shim) or shim (via control+stream sockets)") + n := fs.Int("n", 50, "number of round trips") + ctl := fs.String("c", "", "control socket path (shim mode)") + str := fs.String("s", "", "stream socket path (shim mode)") + if err := fs.Parse(args); err != nil { + return err + } + switch *mode { + case "single": + return rttSingle(*n) + case "shim": + if *ctl == "" || *str == "" { + return fmt.Errorf("-c and -s are required in shim mode") + } + return rttShim(*n, *ctl, *str) + default: + return fmt.Errorf("unknown mode %q", *mode) + } +} + +func rttSingle(n int) error { + self, err := os.Executable() + if err != nil { + return err + } + cmd := exec.Command(self, "echo", "-for", "120s") + ptmx, err := pty.Start(cmd) + if err != nil { + return err + } + defer func() { + _ = cmd.Process.Kill() + _ = ptmx.Close() + _ = cmd.Wait() + }() + + rd := bufio.NewReader(ptmx) + samples, err := pingLoop(n, func(s string) error { + _, werr := ptmx.WriteString(s) + return werr + }, rd) + if err != nil { + return err + } + report("single", samples) + return nil +} + +func rttShim(n int, ctlPath, strPath string) error { + strConn, err := net.Dial("unix", strPath) + if err != nil { + return err + } + defer func() { _ = strConn.Close() }() + ctlConn, err := net.Dial("unix", ctlPath) + if err != nil { + return err + } + defer func() { _ = ctlConn.Close() }() + + enc := json.NewEncoder(ctlConn) + dec := json.NewDecoder(ctlConn) + rd := bufio.NewReader(strConn) + + samples, err := pingLoop(n, func(s string) error { + if err := enc.Encode(map[string]string{"cmd": "inject", "data": s}); err != nil { + return err + } + var raw json.RawMessage + return dec.Decode(&raw) + }, rd) + if err != nil { + return err + } + report("shim", samples) + return nil +} + +// pingLoop sends PING through send and waits for the echo child's stamped +// reply to come back on rd. Matching on the bracketed marker rather than the +// bare token skips the tty's own echo of the input. +func pingLoop(n int, send func(string) error, rd *bufio.Reader) ([]time.Duration, error) { + samples := make([]time.Duration, 0, n) + for i := 1; i <= n; i++ { + want := fmt.Sprintf("[PING%d]", i) + start := time.Now() + if err := send(fmt.Sprintf("PING%d\n", i)); err != nil { + return nil, err + } + for { + line, err := rd.ReadString('\n') + if strings.Contains(line, want) { + samples = append(samples, time.Since(start)) + break + } + if err != nil { + if err == io.EOF { + return nil, fmt.Errorf("child stream ended waiting for %s", want) + } + return nil, err + } + } + } + return samples, nil +} + +func report(tag string, s []time.Duration) { + if len(s) == 0 { + fmt.Printf("RTT %s n=0\n", tag) + return + } + sort.Slice(s, func(i, j int) bool { return s[i] < s[j] }) + var sum time.Duration + for _, d := range s { + sum += d + } + fmt.Printf("RTT %s n=%d min=%s p50=%s p90=%s max=%s mean=%s\n", + tag, len(s), s[0].Round(time.Microsecond), + s[len(s)/2].Round(time.Microsecond), + s[(len(s)*9)/10].Round(time.Microsecond), + s[len(s)-1].Round(time.Microsecond), + (sum / time.Duration(len(s))).Round(time.Microsecond)) +} diff --git a/cmd/shimprobe/main.go b/cmd/shimprobe/main.go new file mode 100644 index 0000000..18a4f3a --- /dev/null +++ b/cmd/shimprobe/main.go @@ -0,0 +1,53 @@ +// Command shimprobe carries the test children and test clients used by the +// marvel-shim spike (aae-orc-e35c). Each subcommand exists to exercise one of +// the five pre-declared success signals; none of it is production code. +// +// shimprobe winsize child that reports its window size on SIGWINCH +// shimprobe spew -n N child that writes N numbered ANSI lines +// shimprobe ttyprobe child that queries the terminal and waits (falsification) +// shimprobe echo child that echoes stdin lines with a marker +// shimprobe sink -s PATH stream-socket consumer, counts and verifies lines +// shimprobe ctl -c PATH ... control-socket client +// shimprobe rtt -mode M inject-to-observe round trip, single PTY vs shim +// shimprobe tee -s PATH dump the stream socket verbatim +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: shimprobe {winsize|spew|ttyprobe|echo|sink|ctl|rtt|tee} [flags]") + os.Exit(2) + } + sub := os.Args[1] + args := os.Args[2:] + + var err error + switch sub { + case "winsize": + err = runWinsize(args) + case "spew": + err = runSpew(args) + case "ttyprobe": + err = runTTYProbe(args) + case "echo": + err = runEcho(args) + case "sink": + err = runSink(args) + case "ctl": + err = runCtl(args) + case "rtt": + err = runRTT(args) + case "tee": + err = runTee(args) + default: + err = fmt.Errorf("unknown subcommand %q", sub) + } + if err != nil { + fmt.Fprintf(os.Stderr, "shimprobe %s: %v\n", sub, err) + os.Exit(1) + } +} diff --git a/cmd/shimprobe/tee.go b/cmd/shimprobe/tee.go new file mode 100644 index 0000000..c0a2537 --- /dev/null +++ b/cmd/shimprobe/tee.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net" + "os" +) + +// runTee dumps the stream socket verbatim, for checks that need the raw child +// output rather than sink's spew accounting. +func runTee(args []string) error { + fs := flag.NewFlagSet("tee", flag.ExitOnError) + sock := fs.String("s", "", "stream socket path") + out := fs.String("o", "", "write to this file instead of stdout") + if err := fs.Parse(args); err != nil { + return err + } + if *sock == "" { + return fmt.Errorf("-s is required") + } + conn, err := net.Dial("unix", *sock) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + w := io.Writer(os.Stdout) + if *out != "" { + f, err := os.Create(*out) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + w = f + } + _, err = io.Copy(w, conn) + return err +} diff --git a/go.mod b/go.mod index a616cf8..d1d2b2c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.4 require ( github.com/BurntSushi/toml v1.6.0 + github.com/creack/pty v1.1.24 github.com/spf13/cobra v1.10.2 github.com/yuin/gopher-lua v1.1.2 go.etcd.io/bbolt v1.4.3 diff --git a/go.sum b/go.sum index 3f499d9..9aa68a0 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/scripts/shim-spike.sh b/scripts/shim-spike.sh new file mode 100755 index 0000000..b18d0eb --- /dev/null +++ b/scripts/shim-spike.sh @@ -0,0 +1,402 @@ +#!/usr/bin/env bash +# Driver for the marvel-shim spike (aae-orc-e35c). One subcommand per +# pre-declared success signal, plus a latency comparison. +# +# scripts/shim-spike.sh all +# scripts/shim-spike.sh s1 # resize / SIGWINCH through the double PTY +# scripts/shim-spike.sh s2 # stream-socket fidelity under fast output +# scripts/shim-spike.sh s3 # concurrent inject via the PTY master +# scripts/shim-spike.sh s4 # pane kill and supervisor disconnect +# scripts/shim-spike.sh s5 # falsification: Cursor-class TTY hang +# scripts/shim-spike.sh h claude # a real harness TUI, plain vs under the shim +# scripts/shim-spike.sh rtt # double-PTY latency overhead +# +# Every tmux session runs on a private server socket (-L shimspike) so the +# operator's own sessions are never touched. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="${SHIM_SPIKE_WORK:-/tmp/shimspike}" +BIN="$WORK/bin" +RUN="$WORK/run" +TMUX=(tmux -L shimspike) + +SHIM="$BIN/marvel-shim" +PROBE="$BIN/shimprobe" + +build() { + mkdir -p "$BIN" "$RUN" + (cd "$ROOT" && go build -o "$BIN/" ./cmd/marvel-shim ./cmd/shimprobe) +} + +# tmux keeps a dead pane's text only with remain-on-exit, which every signal +# here needs because the evidence is what the child printed before exiting. +new_session() { + local name="$1" width="$2" height="$3" + shift 3 + "${TMUX[@]}" kill-session -t "$name" 2>/dev/null || true + "${TMUX[@]}" new-session -d -s "$name" -x "$width" -y "$height" "$@" + "${TMUX[@]}" set-option -t "$name" -w remain-on-exit on +} + +capture() { "${TMUX[@]}" capture-pane -p -t "$1"; } + +kill_session() { "${TMUX[@]}" kill-session -t "$1" 2>/dev/null || true; } + +banner() { printf '\n===== %s =====\n' "$*"; } + +alive() { kill -0 "$1" 2>/dev/null && echo yes || echo no; } + +# pids extracts one field from a `shimprobe ctl` reply line ("CTL {json}"). +pids() { python3 -c 'import sys,json;print(json.loads(sys.argv[1].split(" ",1)[1]).get(sys.argv[2],0))' "$1" "$2"; } + +# ---------------------------------------------------------------- signal 1 +s1() { + banner "SIGNAL 1a: SIGWINCH reaches the child through two PTYs" + local c="$RUN/s1.ctl" s="$RUN/s1.str" + rm -f "$c" "$s" + new_session s1 100 30 "$SHIM --control $c --stream $s -- $PROBE winsize -for 25s" + sleep 1 + echo "--- initial (session created 100x30) ---" + capture s1 + "${TMUX[@]}" resize-window -t s1 -x 120 -y 40 + sleep 1 + "${TMUX[@]}" resize-window -t s1 -x 70 -y 20 + sleep 1 + echo "--- after two resizes (120x40 then 70x20) ---" + capture s1 + kill_session s1 + + banner "SIGNAL 1b: a real TUI (vim) renders and redraws on resize" + local c2="$RUN/s1b.ctl" s2="$RUN/s1b.str" + rm -f "$c2" "$s2" + new_session s1b 100 30 "$SHIM --control $c2 --stream $s2 -- vim -u NONE -c 'set nocompatible' -c 'set ruler' -c 'set laststatus=2'" + sleep 1.5 + "${TMUX[@]}" send-keys -t s1b "ihello from vim" Escape + sleep 0.7 + echo "--- vim at 100x30, last 4 lines of pane ---" + capture s1b | tail -4 + echo "--- vim reported columns (:echo &columns via ruler is unreliable; ask directly) ---" + "${TMUX[@]}" send-keys -t s1b ':echo "COLS=".&columns." LINES=".&lines' Enter + sleep 0.7 + capture s1b | grep -E 'COLS=' || echo "(no COLS line captured)" + "${TMUX[@]}" resize-window -t s1b -x 132 -y 43 + sleep 1 + "${TMUX[@]}" send-keys -t s1b ':echo "COLS=".&columns." LINES=".&lines' Enter + sleep 0.7 + echo "--- after resize to 132x43 ---" + capture s1b | grep -E 'COLS=' || echo "(no COLS line captured)" + "${TMUX[@]}" send-keys -t s1b ':q!' Enter + sleep 0.5 + kill_session s1b +} + +# ---------------------------------------------------------------- signal 2 +s2() { + local n="${1:-50000}" + banner "SIGNAL 2: stream-socket fidelity, $n lines" + local c="$RUN/s2.ctl" s="$RUN/s2.str" gate="$RUN/s2.gate" + rm -f "$c" "$s" "$gate" + # The gate makes the run deterministic: there is no replay buffer, so the + # consumer has to be attached before the child's first byte. + new_session s2 200 50 "$SHIM --control $c --stream $s -- sh -c 'while [ ! -e $gate ]; do sleep 0.05; done; exec $PROBE spew -n $n'" + for _ in $(seq 1 100); do [ -S "$s" ] && break; sleep 0.05; done + + "$PROBE" sink -s "$s" >"$RUN/s2.sink" 2>&1 & + local sinkpid=$! + sleep 0.3 + local t0 t1 + t0=$(python3 -c 'import time;print(time.time())') + : >"$gate" + wait "$sinkpid" || true + t1=$(python3 -c 'import time;print(time.time())') + cat "$RUN/s2.sink" + python3 -c "print('SINK elapsed=%.3fs' % ($t1-$t0))" + echo "--- pane tail (proves the human view got the same bytes) ---" + capture s2 | tail -2 + kill_session s2 + + # 1000 lines at 5ms is ~5s of consumer lag, inside the shim's 10s drain + # grace. A consumer slower than that grace loses the tail by design. + banner "SIGNAL 2b: lagging consumer (5ms per line) with the same producer" + local c2="$RUN/s2b.ctl" s2s="$RUN/s2b.str" gate2="$RUN/s2b.gate" + rm -f "$c2" "$s2s" "$gate2" + new_session s2b 200 50 "$SHIM --control $c2 --stream $s2s -- sh -c 'while [ ! -e $gate2 ]; do sleep 0.05; done; exec $PROBE spew -n 1000'" + for _ in $(seq 1 100); do [ -S "$s2s" ] && break; sleep 0.05; done + "$PROBE" sink -s "$s2s" -slow 5ms >"$RUN/s2b.sink" 2>&1 & + local sp=$! + sleep 0.3 + : >"$gate2" + wait "$sp" || true + cat "$RUN/s2b.sink" + kill_session s2b +} + +# ---------------------------------------------------------------- signal 3 +s3() { + local reps="${1:-300}" width="${2:-120}" mode="${3:-cooked}" + local echoflag="" + [ "$mode" = raw ] && echoflag="-raw" + banner "SIGNAL 3: two supervisors injecting concurrently, $reps lines of $width bytes, child stdin $mode" + local c="$RUN/s3.ctl" s="$RUN/s3.str" + rm -f "$c" "$s" "$RUN/s3.raw" + new_session s3 200 50 "$SHIM --control $c --stream $s -- $PROBE echo -for 90s $echoflag" + for _ in $(seq 1 100); do [ -S "$s" ] && break; sleep 0.05; done + + "$PROBE" tee -s "$s" -o "$RUN/s3.raw" & + local teepid=$! + sleep 0.3 + + # Payload width matters: a single write() to the PTY master is atomic only + # while it fits the input queue, so this is the knob that finds the bound. + local a b + a=$(python3 -c "print('A'*$width)") + b=$(python3 -c "print('B'*$width)") + "$PROBE" ctl -c "$c" -cmd inject -data "${a}\\n" -repeat "$reps" >/dev/null & + local pa=$! + "$PROBE" ctl -c "$c" -cmd inject -data "${b}\\n" -repeat "$reps" >/dev/null & + local pb=$! + wait "$pa" "$pb" + sleep 1.5 + "$PROBE" ctl -c "$c" -cmd inject -data 'QUIT\n' >/dev/null || true + sleep 0.7 + kill "$teepid" 2>/dev/null || true + wait "$teepid" 2>/dev/null || true + + python3 - "$RUN/s3.raw" "$reps" "$width" <<'PY' +import re, sys +raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'replace') +reps = int(sys.argv[2]) +echo = re.findall(r'ECHO \d+ \[([^\]]*)\]', raw) +pure_a = sum(1 for e in echo if e and set(e) == {'A'}) +pure_b = sum(1 for e in echo if e and set(e) == {'B'}) +mixed = [e for e in echo if set(e) & {'A'} and set(e) & {'B'}] +width = int(sys.argv[3]) +short = [e for e in echo if e and set(e) <= {'A', 'B'} and len(e) != width] +print(f"INJECT echoed_lines={len(echo)} pure_A={pure_a} pure_B={pure_b} " + f"expected_each={reps} mixed={len(mixed)} wrong_length={len(short)}") +if mixed: + print("INJECT first_mixed=" + repr(mixed[0][:160])) +if short: + print("INJECT first_wrong_length=" + repr(short[0][:160])) +ok = pure_a == reps and pure_b == reps and not mixed and not short +print("INJECT verdict=" + ("PASS" if ok else "FAIL")) +PY + kill_session s3 +} + +# ---------------------------------------------------------------- signal 4 +s4() { + banner "SIGNAL 4a: kill the tmux pane, default --on-hup=kill" + local c="$RUN/s4a.ctl" s="$RUN/s4a.str" + rm -f "$c" "$s" + new_session s4a 100 30 "$SHIM --control $c --stream $s -- sh -c 'while :; do echo tick; sleep 1; done'" + for _ in $(seq 1 100); do [ -S "$c" ] && break; sleep 0.05; done + local st cpid spid + st=$("$PROBE" ctl -c "$c" -cmd status) + echo "before kill: $st" + cpid=$(pids "$st" pid) + spid=$(pids "$st" shim_pid) + echo "shim pid=$spid child pid=$cpid" + "${TMUX[@]}" kill-session -t s4a + sleep 2 + echo "after kill-session (+2s): shim alive=$(alive "$spid") child alive=$(alive "$cpid")" + echo "control socket reachable=$("$PROBE" ctl -c "$c" -cmd status 2>&1 | head -1)" + pkill -f "marvel-shim --control $c" 2>/dev/null || true + kill "$cpid" 2>/dev/null || true + + banner "SIGNAL 4b: kill the tmux pane, --on-hup=detach" + local c2="$RUN/s4b.ctl" s2s="$RUN/s4b.str" + rm -f "$c2" "$s2s" + new_session s4b 100 30 "$SHIM --on-hup detach --control $c2 --stream $s2s -- sh -c 'while :; do echo tick; sleep 1; done'" + for _ in $(seq 1 100); do [ -S "$c2" ] && break; sleep 0.05; done + local st2 cpid2 spid2 + st2=$("$PROBE" ctl -c "$c2" -cmd status) + cpid2=$(pids "$st2" pid) + spid2=$(pids "$st2" shim_pid) + echo "shim pid=$spid2 child pid=$cpid2" + "${TMUX[@]}" kill-session -t s4b + sleep 2 + echo "after kill-session (+2s): shim alive=$(alive "$spid2") child alive=$(alive "$cpid2")" + echo "control reply: $("$PROBE" ctl -c "$c2" -cmd status 2>&1 | head -1)" + sleep 5 + echo "at +7s (child has tried ~7 tty writes since the pane went away):" + echo " shim alive=$(alive "$spid2") child alive=$(alive "$cpid2")" + echo " control reply: $("$PROBE" ctl -c "$c2" -cmd status 2>&1 | head -1)" + pkill -f "marvel-shim --on-hup detach --control $c2" 2>/dev/null || true + kill "$cpid2" 2>/dev/null || true + + banner "SIGNAL 4c: supervisor connection dies and reconnects" + local c3="$RUN/s4c.ctl" s3s="$RUN/s4c.str" + rm -f "$c3" "$s3s" + new_session s4c 100 30 "$SHIM --control $c3 --stream $s3s -- $PROBE echo -for 90s" + for _ in $(seq 1 100); do [ -S "$c3" ] && break; sleep 0.05; done + "$PROBE" ctl -c "$c3" -cmd status -hold 30s >/dev/null & + local hp=$! + "$PROBE" tee -s "$s3s" -o "$RUN/s4c.raw" & + local tp=$! + sleep 0.5 + echo "with supervisor attached: $("$PROBE" ctl -c "$c3" -cmd status)" + kill -9 "$hp" "$tp" 2>/dev/null || true + wait "$hp" "$tp" 2>/dev/null || true + sleep 0.7 + echo "after SIGKILL of both supervisor connections: $("$PROBE" ctl -c "$c3" -cmd status)" + "$PROBE" tee -s "$s3s" -o "$RUN/s4c2.raw" & + local tp2=$! + sleep 0.3 + "$PROBE" ctl -c "$c3" -cmd inject -data 'after-reconnect\n' >/dev/null + sleep 0.7 + kill "$tp2" 2>/dev/null || true + wait "$tp2" 2>/dev/null || true + echo "reconnected stream saw: $(tr -d '\r' <"$RUN/s4c2.raw" | grep -c 'after-reconnect' || true) matching lines" + echo "pane still rendering:" + capture s4c | grep -c . | sed 's/^/ non-blank pane lines: /' + capture s4c | grep -E 'after-reconnect|ECHO' | tail -3 + kill_session s4c +} + +# ---------------------------------------------------------------- signal 5 +s5() { + banner "SIGNAL 5 (falsification): Cursor-class terminal-query hang" + + echo "--- 5a: no PTY at all (pipe with nothing behind it), the failure being emulated ---" + ( sleep 10 | "$PROBE" ttyprobe -timeout 3s 2>&1 ) | tr -d '\r' | grep TTYPROBE || true + + echo "--- 5b: plain tmux pane, no shim, raw (what a real harness does) ---" + new_session s5b 100 30 "$PROBE ttyprobe -timeout 3s -raw" + sleep 4.5 + capture s5b | tr -d '\r' | grep TTYPROBE || echo "(nothing captured)" + kill_session s5b + + echo "--- 5c: under marvel-shim in a tmux pane, raw ---" + local c="$RUN/s5c.ctl" s="$RUN/s5c.str" + rm -f "$c" "$s" + new_session s5c 100 30 "$SHIM --quiet --control $c --stream $s -- $PROBE ttyprobe -timeout 3s -raw" + sleep 4.5 + capture s5c | tr -d '\r' | grep TTYPROBE || echo "(nothing captured)" + kill_session s5c + + echo "--- 5d: plain tmux pane, cooked stdin (line discipline holds the reply) ---" + new_session s5d 100 30 "$PROBE ttyprobe -timeout 3s -raw=false" + sleep 4.5 + capture s5d | tr -d '\r' | grep TTYPROBE || echo "(nothing captured)" + kill_session s5d + + echo "--- 5e: under marvel-shim, cooked stdin ---" + local c2="$RUN/s5e.ctl" s2s="$RUN/s5e.str" + rm -f "$c2" "$s2s" + new_session s5e 100 30 "$SHIM --quiet --control $c2 --stream $s2s -- $PROBE ttyprobe -timeout 3s -raw=false" + sleep 4.5 + capture s5e | tr -d '\r' | grep TTYPROBE || echo "(nothing captured)" + kill_session s5e + + echo "--- 5f: plain tmux pane, kitty keyboard-protocol query, raw (control for 5g) ---" + new_session s5f0 100 30 "$PROBE ttyprobe -timeout 3s -raw -query kitty" + sleep 4.5 + capture s5f0 | tr -d '\r' | grep TTYPROBE || echo "(nothing captured)" + kill_session s5f0 + + echo "--- 5g: under marvel-shim, kitty keyboard-protocol query, raw ---" + local c3="$RUN/s5f.ctl" s3s="$RUN/s5f.str" + rm -f "$c3" "$s3s" + new_session s5f 100 30 "$SHIM --quiet --control $c3 --stream $s3s -- $PROBE ttyprobe -timeout 3s -raw -query kitty" + sleep 4.5 + capture s5f | tr -d '\r' | grep TTYPROBE || echo "(nothing captured)" + kill_session s5f + + # The decisive control: the shim gives the child a PTY but is not a terminal + # emulator. With no terminal above it, nothing answers the query. + echo "--- 5h: marvel-shim headless (stdio = pipes, no tmux), raw ---" + local c4="$RUN/s5h.ctl" s4s="$RUN/s5h.str" + rm -f "$c4" "$s4s" "$RUN/s5h.out" + ( sleep 10 | "$SHIM" --quiet --control "$c4" --stream "$s4s" -- \ + "$PROBE" ttyprobe -timeout 3s -raw >"$RUN/s5h.out" 2>&1 ) || true + tr -d '\r' <"$RUN/s5h.out" | grep TTYPROBE || echo "(nothing captured)" +} + +# ---------------------------------------------------------------- harnesses +# Real harness TUIs, plain tmux pane against shim-in-pane. capture-pane -e +# keeps the escape sequences, so an identical capture is a claim about what the +# terminal was told to draw, not just about the visible glyphs. Both harnesses +# are left at their trust prompt; the inject answers "No, quit", so nothing is +# sent to a model. +harness() { + local h="${1:-claude}" + banner "HARNESS: $h renders the same plain and under the shim, and takes inject" + if ! command -v "$h" >/dev/null; then + echo "$h not installed on this host; skipping" + return 0 + fi + local c="$RUN/h-$h.ctl" s="$RUN/h-$h.str" + rm -f "$c" "$s" + new_session "hp-$h" 110 32 "$h" + new_session "hs-$h" 110 32 "$SHIM --quiet --control $c --stream $s -- $h" + sleep 8 + "${TMUX[@]}" capture-pane -e -p -t "hp-$h" >"$RUN/$h.plain.cap" + "${TMUX[@]}" capture-pane -e -p -t "hs-$h" >"$RUN/$h.shim.cap" + echo "plain=$(wc -c <"$RUN/$h.plain.cap") bytes shim=$(wc -c <"$RUN/$h.shim.cap") bytes" + if diff -q "$RUN/$h.plain.cap" "$RUN/$h.shim.cap" >/dev/null; then + echo "verdict=IDENTICAL (escape sequences included)" + else + echo "verdict=DIFFERS" + diff "$RUN/$h.plain.cap" "$RUN/$h.shim.cap" | head -5 + fi + "${TMUX[@]}" resize-window -t "hs-$h" -x 140 -y 44 2>/dev/null || true + sleep 3 + "${TMUX[@]}" capture-pane -p -t "hs-$h" | + awk '{ if (length($0)>m) m=length($0) } END { print "after resize to 140x44, widest pane line: "m }' + echo "inject '2' + Enter through the control socket (answers the trust prompt with No):" + "$PROBE" ctl -c "$c" -cmd inject -data '2\r' + sleep 3 + # The harness acts on the injected keys and exits, which takes the shim and + # its sockets with it, so socket-gone is the confirmation the inject landed. + local after + after=$("$PROBE" ctl -c "$c" -cmd status 2>&1 | head -1) + case "$after" in + CTL*) echo "after inject: $after" ;; + *) echo "after inject: harness acted on the keys and quit, taking the shim with it ($after)" ;; + esac + kill_session "hp-$h" + kill_session "hs-$h" +} + +# ---------------------------------------------------------------- latency +rtt() { + local n="${1:-100}" + banner "LATENCY: inject-to-observe round trip, n=$n" + echo "--- control: one PTY, no shim, no sockets ---" + "$PROBE" rtt -mode single -n "$n" + echo "--- shim in a tmux pane: control socket in, stream socket out ---" + local c="$RUN/rtt.ctl" s="$RUN/rtt.str" + rm -f "$c" "$s" + new_session rtt 200 50 "$SHIM --control $c --stream $s -- $PROBE echo -for 180s" + for _ in $(seq 1 100); do [ -S "$c" ] && break; sleep 0.05; done + sleep 0.3 + "$PROBE" rtt -mode shim -n "$n" -c "$c" -s "$s" + kill_session rtt +} + +cleanup() { "${TMUX[@]}" kill-server 2>/dev/null || true; } + +main() { + local what="${1:-all}" + build + case "$what" in + s1) s1 ;; + s2) s2 "${2:-}" ;; + s3) s3 "${2:-}" "${3:-}" "${4:-}" ;; + s4) s4 ;; + s5) s5 ;; + rtt) rtt "${2:-}" ;; + h) harness "${2:-claude}" ;; + all) + s1; s2; s3; s4; s5; harness claude; harness codex; rtt + ;; + clean) cleanup ;; + *) echo "unknown target $what" >&2; exit 2 ;; + esac + echo + echo "tmux server for this run: tmux -L shimspike (kill with: $0 clean)" +} + +main "$@" From d7475b83bf65f3560b937d3467763dff21c1893c Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Fri, 31 Jul 2026 17:44:26 -0500 Subject: [PATCH 2/2] =?UTF-8?q?finding:=20question-substrate=20=E2=80=94?= =?UTF-8?q?=20shim-in-pane=20spike,=20five=20signals=20measured=20(finding?= =?UTF-8?q?-004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five pre-declared signals pass on macOS 26.5.2 with tmux 3.7b. The two results that carry weight for the aae-orc-kxce decision are boundaries rather than verdicts: run headless with no terminal above it, the shim gives the child a PTY and terminal capability queries still hang, so the shim is not a terminal emulator and something above it has to answer; and with --on-hup=detach the shim survives losing its pane with the child alive and the control socket serving, so tmux is load-bearing for terminal emulation but not for supervision. Claude Code 2.1.220 and codex-cli 0.146.0 produce byte-identical escape-sequence-inclusive pane captures plain and under the shim. Stream tee carries 300k lines with zero loss at about 25 MB/s, which fills the shim-PTY-tee row finding-005 left blank. Inject adds 25-40µs at p50 and showed no interleaving corruption from two concurrent supervisors at any payload width tested. Real Cursor CLI verification and a Linux run are named as future work. --- .../finding-004-shim-in-pane-spike.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 _kos/findings/finding-004-shim-in-pane-spike.md diff --git a/_kos/findings/finding-004-shim-in-pane-spike.md b/_kos/findings/finding-004-shim-in-pane-spike.md new file mode 100644 index 0000000..ff72a42 --- /dev/null +++ b/_kos/findings/finding-004-shim-in-pane-spike.md @@ -0,0 +1,264 @@ +--- +id: finding-004-shim-in-pane-spike +title: "Shim-in-pane substrate candidate: prototype measured against five pre-declared signals" +probe: scripts/shim-spike.sh +question: question-substrate +confidence: frontier +tags: [marvel, substrate, pty, tmux, shim] +bd: aae-orc-e35c +addressed_to: aae-orc-kxce +provenance: + created_by: agent + session: marvel-wave-1-lane-s1 + created_at: "2026-07-31" + host: kinu +--- + +# Shim-in-pane substrate candidate: prototype measured against five signals + +Marvel is choosing what owns the terminal, and the leading candidate has never +been built, so its costs have been argued rather than measured. This spike +builds it: a small binary that runs inside a tmux pane, gives the harness its +own PTY, tees the harness's bytes to marvel over a Unix socket, and takes +control commands on a second socket. The result that matters for the decision +is a boundary, not a verdict: the shim is transparent enough that Claude Code +and Codex render byte-identically under it, and it survives losing its pane, so +tmux turns out to be load-bearing for terminal emulation but not for process +supervision. + +Prototype quality. Nothing here is production code; the protocol is the +smallest thing that answers the five signals, and error handling is +spike-grade. This finding supplies input to `aae-orc-kxce`; the decision stays +with the operator per ADR-007. + +Companion input: `finding-005-stream-attachment-probe.md` measured the +attachment paths and left the shim-PTY-tee row blank because this spike had not +landed. Signal 2 below fills that row. + +## What was built + +`cmd/marvel-shim` (~380 lines) plus `cmd/shimprobe` (~470 lines of test +children and test clients) and `scripts/shim-spike.sh` (the driver, one +subcommand per signal). New dependency: `github.com/creack/pty` v1.1.24. + +The shim is the OS parent of one harness process. It allocates a PTY for that +child, inherits its own stdio from the tmux pane, seeds the child's window size +from the pane, and then runs one loop: read the child's PTY master, write those +bytes to the pane, and hand the same bytes to every stream subscriber. The +control socket takes newline-delimited JSON (`status`, `signal`, `stop`, +`inject`); `inject` writes to the PTY master, so injected bytes are +indistinguishable at the child from bytes typed in the pane. + +Launch shape, as pre-declared: + +``` +tmux new-window 'marvel-shim --control C.sock --stream S.sock -- claude' +``` + +## Method + +Host kinu, macOS 26.5.2 (arm64), tmux 3.7b, Go 1.25.4, Claude Code 2.1.220, +codex-cli 0.146.0. Every tmux session in the driver runs on a private server +socket (`tmux -L shimspike`) so the operator's sessions are untouched. +Reproduce with `scripts/shim-spike.sh all`. + +Where a signal could fail for a reason that is not the shim's, the driver runs +the same test without the shim as a control. That is what turned two of the +five results from "the shim hangs this" into "this hangs either way." + +## Signal results + +### Signal 1 (PASS): render, input, and resize through the double PTY + +The size-aware child (`shimprobe winsize`) reported `rows=30 cols=100` at +launch, then `rows=40 cols=120` and `rows=20 cols=70` after two +`resize-window` calls. SIGWINCH is not chained by the kernel between two +independent PTYs; the shim re-reads its own tty on SIGWINCH and pushes the size +down, and that path works in both directions of resize. + +vim under the shim reported `COLS=100 LINES=30`, then `COLS=132 LINES=43` after +resize, having redrawn in between. + +The stronger evidence is the two real harnesses. Launching `claude` in a plain +tmux pane and under the shim at the same geometry, then capturing both panes +with `capture-pane -e` (escape sequences included), produced byte-identical +captures: 1906 bytes each for Claude Code, 609 bytes each for Codex. That is a +claim about what the terminal was told to draw, not only about visible glyphs. +Both reflowed after a resize to 140x44. + +Limitation: I verified render through `capture-pane` and through each program's +own reported geometry, not by a human eye on an attached client. + +### Signal 2 (PASS, after fixing a defect): stream fidelity under fast output + +50,000 numbered ANSI lines (2,250,032 bytes): 0 missing, 0 out of order, +0.149s. 300,000 lines (13,500,115 bytes): 0 missing, 0 out of order, 0.533s, +about 25 MB/s through the double PTY and the Unix socket. A consumer +deliberately slowed to 5ms per line also lost nothing. + +The first run of the slow-consumer case lost 89% of the stream: 223 of 2,000 +lines. Cause: child exit signalled subscribers and then let the process exit, +which killed the pump goroutines mid-queue. A fast consumer never saw it +because it drained before the child finished. Fixed by having close wait for +each subscriber to drain, bounded by a 10s grace. `cmd/marvel-shim/stream_test.go` +holds the regression test. + +Two bounds remain, both by design and both worth naming for the decision: a +consumer slower than the 10s drain grace still loses the tail, and a subscriber +that falls more than 64 MiB behind gets its chunks dropped. Drops are counted +but the count is not yet reported on the control socket, so today that loss is +countable but not visible. + +### Signal 3 (PASS, with a size bound): inject with no send-keys race + +Two supervisor clients injecting concurrently on separate control connections, +300 lines each of 120 bytes: 300 clean A-lines, 300 clean B-lines, zero mixed +lines, zero wrong-length lines. Against a child in raw mode (what a real +harness does) the same test passed at 120, 900, 4,096, and 16,384 bytes per +line, twice at each of the larger widths. No interleaving corruption appeared +at any width. + +Against a child in cooked mode the failure mode is truncation, not +interleaving: at 900 bytes 9 of 150 lines arrived short, and at 2,048 bytes +nothing arrived at all. That is Darwin's canonical-mode input limit +(MAX_CANON, 1024 bytes) discarding an over-long line, a property of the child's +termios rather than of the shim. Real harnesses set raw mode, so the practical +inject bound is generous, but marvel should not assume it is unbounded. + +Inject also drove a real harness. Sending `2` and Enter through the control +socket answered Codex's and Claude Code's trust prompt with "No, quit" and both +harnesses exited, which is end-to-end proof that injected bytes reach a real +TUI's input handling. + +One flake, undiagnosed: a single 900-byte raw-mode run reported zero echoed +lines; two immediate repeats and four later runs at larger widths all passed. + +### Signal 4 (PASS, behavior documented): pane kill and supervisor disconnect + +With the default `--on-hup=kill`, killing the tmux session forwards SIGHUP to +the child, the shim exits, and the control socket stops answering. Deliberate +and observable. + +With `--on-hup=detach`, killing the tmux session leaves both the shim and the +child alive. The control socket kept answering at +2s and +7s while the child +went on writing to its PTY, because the shim ignores write errors on its own +now-dead stdout. The shim becomes a headless PTY parent that marvel can still +control and stream. This is the result with the most weight for the substrate +decision: the shim does not need its pane in order to keep supervising. + +SIGKILLing both supervisor connections did not disturb the shim. A fresh +connection worked immediately and an injected line arrived on the new stream +connection while the pane kept rendering. One caveat: `status` still reported +`clients: 1` for the dead subscriber, because a dead subscriber is noticed only +on the next write. Client counts are stale until traffic flows. + +### Signal 5 (PASS with a boundary): falsification, the Cursor-class TTY hang + +The emulation is a prober that writes a terminal capability query and then +reads its reply with a timeout, reporting hang or reply. Seven cases: + +| Case | Setup | Result | +|---|---|---| +| 5a | pipe, nothing behind it | HANG at 3s | +| 5b | plain tmux pane, raw stdin | reply in 34µs, `ESC[?1;2;4c` | +| 5c | shim in tmux pane, raw stdin | reply in 94µs, same bytes | +| 5d | plain tmux pane, cooked stdin | HANG at 3s | +| 5e | shim in tmux pane, cooked stdin | HANG at 3s | +| 5f | plain tmux pane, kitty query | HANG at 3s | +| 5g | shim in tmux pane, kitty query | HANG at 3s | +| 5h | shim headless, stdio are pipes, raw | HANG at 3s | + +5a establishes the failure class is real on this host. 5b against 5c is the +central result: the shim passes the query up and the reply back down, so tmux +answers through it, at a cost of 60µs. 5d against 5e and 5f against 5g are the +controls that keep me honest: cooked stdin hangs either way (the child's own +line discipline holds a reply that never contains a newline), and tmux 3.7b +does not answer the kitty keyboard-protocol query either way. Neither is a +shim regression. + +5h is the boundary and the most useful line in the table. Run headless with +pipes for stdio and no tmux, the child's stdin **is** a tty, because the shim +gave it one, and the query still hangs. The shim provides a PTY; it does not +answer terminal queries. Whatever terminal sits above the shim is the answerer. + +Real Cursor CLI verification is future work. Cursor is not installed on this +host, so what I measured is the mechanism finding-065 describes, not the +product. The claim "Cursor works because a PTY-providing multiplexer sits +between marvel and the process" is consistent with 5a/5b/5h but is not verified +against the real binary. + +## Measurements + +Inject-to-observe round trip, same echo child both ways, 100 to 200 samples per +run, three runs: + +| Path | p50 | p90 | max | +|---|---|---|---| +| one PTY, no shim, no sockets | 6-12µs | 10-18µs | 6.1-15.8ms | +| shim: control socket in, stream socket out | 34-48µs | 50-79µs | 243-559µs | + +The shim adds roughly 25-40µs at p50. The single-PTY control had the larger +outliers, which I read as scheduler noise on a loaded laptop rather than a +property of either path. Throughput: about 25 MB/s with zero loss. Both numbers +are far below the cadence at which a harness paints a TUI, so latency is not a +reason to reject this candidate. + +## macOS specifics encountered + +- Unix socket paths are capped near 104 bytes on Darwin. `t.TempDir()` paths + exceed it and `net.Listen` fails with `bind: invalid argument`. Tests use a + short path in `os.TempDir()`, matching what the daemon tests already do. +- Canonical-mode input is capped at 1024 bytes per line (MAX_CANON) and the + excess is discarded silently. This bounds inject for any child that has not + set raw mode. +- tmux runs a pane command through `/bin/sh`, so the shim is not the pane's + first process. Discovering the shim by `pgrep -f` finds the shell wrapper + instead. I added `shim_pid` to the `status` reply rather than guessing from + the process table, which marvel will want anyway. +- Child exit surfaces as EIO on the PTY master read, not EOF. The read loop + treats any read error as end-of-child. +- Raw mode on the shim's own tty is load-bearing rather than cosmetic. Without + it the pane's line discipline echoes and line-buffers, which reintroduces the + 5d/5e hang for every child. + +## Recommendation, addressed to aae-orc-kxce + +**Candidate (e), shim inside the tmux pane: no blocker found.** All five +pre-declared signals pass on this host. It gives marvel a byte-exact tee, a +race-free inject path, per-child control without `send-keys`, and it keeps +tmux as the terminal-query answerer that harnesses depend on. The costs I +measured are 25-40µs of added round trip, one new dependency, and the drain and +lag bounds named under signal 2. + +**Candidates (b) and (g), shim or marvel replacing tmux and owning the PTY +directly: 5h is direct evidence against, and it is cheap evidence.** A PTY is +necessary but not sufficient. Something has to answer DA1-class queries, and +that responder is open-ended per harness and per harness version: tmux 3.7b +answers DA1 and declines the kitty query, and a harness that probes for a +capability nobody answers hangs rather than degrading. Choosing (b) or (g) +means marvel takes on being a terminal emulator, and the falsification set is +where that cost becomes visible rather than arguable. + +**The detach result narrows what tmux is actually for.** Signal 4b shows the +shim surviving the loss of its pane with the child alive and the control socket +serving. So tmux is load-bearing for terminal emulation and for the human's +view, and it is not load-bearing for process lifetime or supervision. Any +candidate that argues from "we need tmux to keep agents alive" is arguing from +a property this spike shows tmux does not uniquely supply. + +**If the operator picks (e), three things want doing before it carries real +agents:** surface the dropped-byte counter and stale client counts on the +control socket (today loss is countable but not visible), decide the drain +grace deliberately rather than inheriting 10s, and re-run this driver on Linux, +where the PTY and signal behavior differs enough that B13 applies. + +**What would change my read:** real Cursor CLI verification, a Linux run, and +one harness exercised under inject while it is using the alternate screen and +bracketed paste, which none of these tests covered. + +## Scope qualifier + +Single host, single OS, single tmux version, development build, no release +verification (B12). Two harnesses of the four marvel targets. The falsification +emulates a mechanism rather than testing the product that named it. Every +number here is from a laptop under other load.