Skip to content

feat(backend): PTY-attach terminal streaming over /mux WebSocket#50

Merged
Pritom14 merged 9 commits into
mainfrom
feat/terminal-streaming
May 31, 2026
Merged

feat(backend): PTY-attach terminal streaming over /mux WebSocket#50
Pritom14 merged 9 commits into
mainfrom
feat/terminal-streaming

Conversation

@Pritom14
Copy link
Copy Markdown
Collaborator

@Pritom14 Pritom14 commented May 31, 2026

Closes #51

Summary

Ports the terminal-streaming surface from the legacy TypeScript repo to the Go backend. In the old stack a separate Node WebSocket process was required because Next.js cannot hijack the HTTP connection for an upgrade. Go's net/http upgrades in-process, so this lands as a single multiplexed /mux endpoint owned by the daemon, with the stream logic in a dedicated feature package.

The change is split into three reviewable commits: the feature package with its tests, the httpd transport that mounts it, and the daemon wiring.

What changed

New internal/terminal feature package (owns the workflow):

  • protocol.go: the channel-tagged JSON wire protocol. Channels are terminal, subscribe, sessions, and system. Terminal payloads are base64 because raw PTY bytes are not guaranteed valid UTF-8.
  • session.go: a single attached PTY session. Defines the PTYSource seam (supplies the attach command and liveness) and the ptyProcess/spawnFunc seam that lets fan-out, ring replay, and re-attach be tested without a real PTY, tmux, or network. Includes bounded re-attach with backoff for resilience across transient tmux drops.
  • ring.go: a 50KB ring buffer that replays recent output to a client on subscribe so a reconnecting terminal is not blank.
  • manager.go: the Manager that accepts connections, multiplexes channels, runs the write loop and heartbeat, and bridges the CDC broadcaster onto the sessions channel. Defines its EventSource and wsConn interfaces next to where they are consumed.
  • pty_unix.go / pty_windows.go: the real spawn via creack/pty on Unix; a clear not-supported error on Windows for now.

internal/httpd transport (owns only upgrade and decoding):

  • mux.go: mounts /mux, performs the WebSocket upgrade, sets a 1MiB read limit, and adapts coder/websocket to the manager's wsConn. It does not reach into the terminal internals. When the manager is nil the route is simply not mounted.
  • server.go / router.go: thread the optional *terminal.Manager through New and NewRouter.

Daemon wiring (main.go):

  • Constructs the tmux runtime adapter as the PTYSource, builds the terminal manager fed by the CDC broadcaster, and passes it into httpd.New. Raw PTY bytes never flow through the CDC change_log; only session-state events reach the sessions channel via the broadcaster.

How the flow looks

browser xterm.js
      |  WebSocket /mux  (channel-tagged JSON frames)
      v
httpd.muxHandler  (upgrade + 1MiB read limit + decode only)
      v
terminal.Manager.Serve  (multiplex channels, write loop, heartbeat)
      |                         \
      | terminal channel         \ sessions channel
      v                           v
terminal.session              cdc.Broadcaster.Subscribe
  PTYSource.AttachCommand       (session-state events only)
  tmux attach -t =<id>
      v
creack/pty  <-->  ring buffer (50KB, replayed on subscribe)

A client opens a terminal channel; the session attaches to the tmux pane, streams PTY bytes out base64-encoded, and replays the ring buffer to late subscribers. In parallel the sessions channel forwards CDC session-state events from the broadcaster so the UI can refresh without polling. If the pane drops, the session re-attaches with bounded backoff; on clean exit it reports exited and stops.

Testing

  • The spawnFunc seam keeps the bulk of the suite hermetic: ring replay, fan-out, re-attach, protocol wire shape, and manager multiplexing all run without a real PTY or network.
  • mux_test.go exercises the genuine upgrade plus wsjson plus Serve plus creack/pty path against a throwaway shell.
  • session_integration_test.go drives a real tmux pane (TestSessionStreamsRealTmuxPane), guarded so it skips where tmux is absent.
  • Full suite is green under go test -race ./...; go build ./... and go vet ./... are clean.

Notes

This was rebased onto the current main, which carries the trigger-driven CDC refactor. The sessions channel payload was updated to match the new cdc.Event shape (typed Type enum, ProjectID added, revision dropped).

Pritom14 added 3 commits May 31, 2026 19:23
Add internal/terminal: a transport-agnostic feature package that attaches
a PTY to a session's tmux pane and multiplexes the byte stream to WebSocket
clients, plus a session-state channel fed by the CDC broadcaster.

The PTY is reached through a small PTYSource interface (satisfied by the
tmux runtime adapter) and spawned via an injectable spawnFunc, so fan-out,
the 50KB replay ring, and re-attach resilience all test without a real
process, tmux, or network. A tmux-guarded integration test exercises the
real creack/pty path end-to-end.

Raw PTY bytes never touch the CDC change_log; only the sessions channel is
CDC-fed. Windows PTY spawning is stubbed pending a ConPTY path.
Add the /mux route: httpd performs the WebSocket upgrade (coder/websocket)
and adapts the connection to terminal.wsConn via wsjson, then hands it to
terminal.Manager.Serve. httpd owns only the upgrade and transport
adaptation; all stream logic stays in internal/terminal.

The route is mounted outside the per-request Timeout middleware (the
connection is long-lived) and is omitted entirely when no manager is wired,
so the daemon degrades to no terminal surface rather than failing. New/
NewRouter take the manager; main.go passes nil until commit 3 wires it.

mux_test.go drives the real upgrade + wsjson + Serve + creack/pty path with
a throwaway shell command, so it needs no tmux.
Construct the tmux runtime and a terminal.Manager fed by the CDC
broadcaster, and hand it to httpd.New so the /mux WebSocket surface goes
live. httpd.New now runs after the CDC substrate so the broadcaster exists
when the manager subscribes; the listener still binds before running.json
is written, preserving fail-fast on port conflict. The manager is closed on
shutdown alongside the CDC pipeline and lifecycle stack.
@Pritom14 Pritom14 force-pushed the feat/terminal-streaming branch from 87ae6ac to 4d90d59 Compare May 31, 2026 13:54
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 31, 2026

Greptile Summary

This PR ports the terminal-streaming surface from the legacy TypeScript/Node stack to Go, landing as a single multiplexed /mux WebSocket endpoint owned by the daemon. The implementation introduces a new internal/terminal package with PTY attachment, fan-out to multiple subscribers, a 50 KB replay ring buffer, and bounded re-attach logic, wired into internal/httpd via a thin upgrade adapter.

  • internal/terminal owns the full workflow: channel-tagged JSON wire protocol (protocol.go), per-pane PTY session with re-attach resilience (session.go), ring buffer replay (ring.go), and the connection multiplexer (manager.go). Race conditions from previous reviews — replay/fanout ordering and the subscribe-to-assign window — are resolved via a deliver method that atomically appends to the ring and fans out under s.mu, and an exitFired guard that re-deletes a stale c.terms[id] entry if onExit fires before the assignment completes.
  • internal/httpd/mux.go does only WebSocket upgrade + 1 MiB read-limit + coderConn adapter; all stream logic stays in internal/terminal.
  • main.go wires the tmux runtime adapter and CDC broadcaster into the terminal manager and passes it to httpd.New; sessions carry the manager's context so Manager.Close() shuts them down cleanly on daemon exit.

Confidence Score: 5/5

Safe to merge. The two concurrency defects identified in previous rounds — replay/fanout duplicate delivery and the subscribe-to-assign stale-entry — are correctly closed by the deliver atomic method and the exitFired guard. No new correctness issues were found.

The central locking invariant (deliver serialises ring-append and fanout under s.mu; subscribe snapshots and replays under the same lock) is sound and well-tested including a 400-iteration race-cover test. The exitFired guard handles the late-exit window correctly. PTY teardown is idempotent via sync.Once. Daemon shutdown ordering (defer termMgr.Close() after HTTP server drains) is correct. The two suggestions are style/robustness improvements, not correctness blockers.

No files require special attention. The two inline suggestions on session.go (clear s.pty after close) and manager.go (collapse the double-lock in handleSubscribe) are low-risk improvements worth considering before merge.

Important Files Changed

Filename Overview
backend/internal/terminal/session.go Core PTY session lifecycle: attach, copyOut via deliver (atomic append+fanout under s.mu), ring replay under s.mu, exitFired guard for subscribe-to-assign window — all correctly serialised. Previous race conditions are fixed.
backend/internal/terminal/manager.go Connection multiplexer: write loop, heartbeat, openTerminal subscribe-to-assign fix (exitFired + re-delete under c.mu), cleanup unsubscribes and closes cleanly. Stale entry bug is addressed correctly.
backend/internal/terminal/ring.go 50 KB ring buffer with snapshot-is-copy guarantee. append+snapshot always called under s.mu in production, so ringBuffer.mu is redundant but harmless. Append logic with tail-truncation is correct.
backend/internal/httpd/mux.go Clean upgrade + coderConn adapter. InsecureSkipVerify noted in previous review comment (loopback-only intent); no other transport issues.
backend/internal/terminal/pty_unix.go creackPTY wraps creack/pty; sync.Once on Close prevents double-Wait deadlock correctly. Kill before Wait for clean process teardown.
backend/main.go Terminal manager constructed before httpd.New; defer termMgr.Close() runs after run() returns (after HTTP shutdown), so shutdown ordering is correct. CDC broadcaster wired to sessions channel cleanly.
backend/internal/terminal/manager_test.go Thorough hermetic tests covering fan-out, replay, subscribe-to-assign window (400-iteration race test), heartbeat, and enqueue overflow. CDC forwarding and reopen-after-exit scenarios well covered.
backend/internal/terminal/session_test.go Unit tests for fan-out, ring replay, write/resize passthrough, re-attach policy, and attach-command error path — all hermetic via fakeSpawner.
backend/internal/httpd/mux_test.go Integration test exercises the real upgrade + wsjson + Serve + creack/pty path with a throwaway shell; system ping/pong also covered.

Sequence Diagram

sequenceDiagram
    participant Client as xterm.js (Browser)
    participant HTTP as httpd.muxHandler
    participant Mgr as terminal.Manager.Serve
    participant Sess as terminal.session.run
    participant PTY as creack/pty (tmux attach)
    participant Ring as ringBuffer (50 KB)
    participant CDC as cdc.Broadcaster

    Client->>HTTP: WS Upgrade /mux
    HTTP->>Mgr: Serve(ctx, coderConn)
    Mgr->>Mgr: spawn writeLoop + heartbeatLoop

    Client->>Mgr: "{ch:terminal, type:open, id:t1}"
    Mgr->>Sess: openSession(t1) → session.run(m.ctx)
    Sess->>PTY: defaultSpawn(argv) via spawnFunc seam
    Mgr->>Client: "{ch:terminal, type:opened}"
    Mgr->>Sess: subscribe(onData, onExit) [replay under s.mu]

    loop PTY output
        PTY-->>Sess: Read(buf)
        Sess->>Ring: deliver(chunk) [append + fanout under s.mu]
        Ring-->>Mgr: onData(chunk) → enqueue base64 frame
        Mgr-->>Client: "{ch:terminal, type:data, data:base64}"
    end

    Client->>Mgr: "{ch:terminal, type:data, data:base64 keystrokes}"
    Mgr->>Sess: session.write(raw)
    Sess->>PTY: Write(raw)

    Client->>Mgr: "{ch:subscribe}"
    Mgr->>CDC: events.Subscribe(onEvent)
    CDC-->>Mgr: onEvent(cdc.Event)
    Mgr-->>Client: "{ch:sessions, type:snapshot, session:{...}}"

    PTY-->>Sess: EOF / error → copyOut returns
    Sess->>Sess: shouldReattach? [IsAlive check + backoff]
    alt pane dead
        Sess->>Mgr: markExited → onExit callbacks
        Mgr->>Client: "{ch:terminal, type:exited}"
    else session alive
        Sess->>PTY: "re-attach (bounded to maxReattach=5)"
    end
Loading

Reviews (5): Last reviewed commit: "fix(terminal): guard subscribe-to-assign..." | Re-trigger Greptile

Comment thread backend/internal/terminal/session.go
…n lock

A new subscriber could receive the same PTY bytes twice: the ring snapshot
was taken under s.mu but replayed after unlock, while copyOut appended to the
ring and fanned out as two separate lock acquisitions. A chunk appended before
the snapshot could then be fanned out after the replay, delivering it in both.
The same gap let an exited frame overtake the replay.

Make append+fanout one atomic step under s.mu (deliver) and replay the snapshot
before releasing the lock in subscribe, so the two critical sections fully
serialize and each chunk reaches a subscriber exactly once.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread backend/internal/terminal/ring_test.go
Comment thread backend/internal/terminal/session_integration_test.go
Comment thread backend/internal/terminal/protocol_test.go
Comment thread backend/internal/terminal/fakes_test.go
Pritom14 and others added 2 commits May 31, 2026 20:46
…lock

The session run loop closes the PTY after copyOut returns, and session.close
(via Manager.Close) closes the same PTY again. creackPTY.Close called cmd.Wait
each time, and a second concurrent Wait on the same process blocks forever, so
daemon shutdown deadlocked whenever a terminal was still attached. fakePTY is
idempotent via sync.Once, so the unit suite never exercised this; a real tmux
attach surfaced it.

Guard close+kill+wait with a sync.Once so Wait runs exactly once. Add a
regression test that double-closes a real PTY under a watchdog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts:
#	backend/internal/httpd/router.go
Comment thread backend/internal/terminal/manager.go Outdated
Pritom14 and others added 2 commits May 31, 2026 23:04
Opening a terminal whose session has exited left c.terms[id] set to a
no-op (already-exited path) or to a never-cleared unsubscribe (exit after
open), so the open guard silently dropped every later open for that id on
the connection until close/reconnect. Clients also saw exited/data before
the opened ack.

Ack opened before subscribe so it always precedes replay/data/exited;
have subscribe report whether the pane was already terminal and skip
registering in that case; and clear the connection entry from the exit
callback for panes that exit after open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The exit callback enqueued the exited frame before deleting c.terms[id],
so a client reopening on receipt of exited could hit the open guard while
the entry was still set and have its open dropped. Delete first so the
cleared entry is visible by the time the client sees exited.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread backend/internal/terminal/manager.go
A session can exit and run onExit (which deletes c.terms[id]) in the gap
between subscribe returning exited=false and openTerminal assigning
c.terms[id]. The delete is a no-op there since the key isn't set yet, so
the later assign resurrects a stale entry for a dead pane, trapping every
future open for that id on the connection. Re-apply the delete after the
assign when onExit fired in the window, tracked by a c.mu-guarded flag.

Add a stress regression test that races the exit against the assign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Pritom14 Pritom14 merged commit 11b602b into main May 31, 2026
2 checks passed
harshitsinghbhandari added a commit that referenced this pull request May 31, 2026
The shutdown endpoint test was authored against the pre-rebase
httpd.New(cfg, log) signature. After rebasing onto main, the terminal
manager (from #50) made termMgr a required third arg. Pass nil — the
test exercises /shutdown, not /mux, so the terminal surface stays off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port terminal streaming to the Go backend over a /mux WebSocket

3 participants