Skip to content

Add the PTY service behind the embedded terminal - #23

Merged
cjimti merged 2 commits into
mainfrom
feat/2-pty-service
Aug 3, 2026
Merged

cjimti merged 2 commits into
mainfrom
feat/2-pty-service

Conversation

@cjimti

@cjimti cjimti commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #2

What this does

Adds internal/pty, the backend service that owns real PTY sessions running the user's real shell (DESIGN.md §3.2, §8), and composes it into the app so quitting m6t cannot orphan a shell.

The package is transport-agnostic by construction: it takes an argv slice and a window size and returns an identifier. It knows nothing about WebSockets, terminal tabs or the Wails bridge, which is what lets the stream server (#3) and the UI (#4) land without touching process lifecycle.

internal/pty/
  pty.go             Options, SessionID, Exit, Attachment, shell/env/size resolution
  manager.go         Manager: Create / Attach / Write / Resize / Kill / Shutdown
  session.go         one PTY + its child: output pump, fan-out, exit publication
  ring.go            fixed-capacity scrollback (256KB/session)
  hangup_unix.go     SIGHUP -> SIGKILL, addressed to the child's process group
  hangup_windows.go  no SIGHUP exists; terminate the ConPTY child

Design notes worth reviewing

Ownership contract. A session outlives its child. On exit it records the status, notifies consumers and stops producing, but stays registered so a consumer can still attach and replay the final scrollback — the "your shell exited" state a terminal tab shows. Kill and Shutdown are the only things that drop a session. The cost is that a tab which is never closed holds its 256KB ring; that cleanup obligation lands on #3/#4.

Never block the child. Output always goes to the ring. Each attached consumer has a 64-chunk queue, and a consumer that falls behind has chunks dropped, not queued — back-pressure onto the PTY would let a stalled WebSocket freeze the user's terminal. Dropping loses bytes mid-stream, so Attachment.Chunks documents that a consumer wanting rendering fidelity should redraw from a fresh Attach rather than assume continuity. Worth a look from whoever picks up #3.

Process groups, not processes. go-pty starts the child in its own session, so the shell is a process-group leader. Termination signals -pgid, not the pid: signalling only the shell would reap it and orphan the claude or vim it started. That is what makes "no zombie processes" true rather than aspirational.

Ordering of output vs. exit. run() waits for the child, gives the pump drainGrace to see EOF on its own, and only then publishes the exit event — so a consumer never sees "exited" before the last screenful the child wrote.

Deviation from the issue (please confirm)

The issue specifies creack/pty on Unix and aymanbagabas/go-pty on Windows. This uses go-pty on both platforms.

Reason: go-pty wraps creack/pty on Unix anyway (it is in the dependency tree either way), and it owns process creation internally. The two-library split requires calling exec.Command(shell, args...) directly, which trips gosec G204 "subprocess launched with variable" — I verified this fires against the pinned gosec 2.28.0. Clearing it would have needed either a repo-wide G204 exclusion (a real regression: G204 is exactly the rule guarding the git/kubectl/helm argv paths in #5) or a #nosec annotation, which CLAUDE.md reserves for maintainer sign-off. Using go-pty means no suppression exists to review.

Net effect: one dependency instead of two, and the only genuinely platform-specific code left is hangup_*.go.

New dependencies, all on the allowlist: aymanbagabas/go-pty (MIT), creack/pty (MIT), u-root/u-root (BSD-3-Clause).

How it was verified

make verify — all checks passed, run against the exact commit content.

Gate Floor Result
coverage-report 80% 96.7% total
patch-coverage 85% 97.1% (268/276 changed lines)
internal/pty — 97.6%
lint / gosec / govulncheck / semgrep / licenses / bindings-check / build-check clean clean
dead-code informational nothing reported

Acceptance criteria from the issue:

  • Unix: session runs sh -c 'echo hello; sleep 60', output read, resize succeeds, kill observed via exit event, child reaped (ProcessState populated — no zombie)
  • Session survives having no consumer: 400×1KB of output with nothing attached still runs to completion, ring caps at exactly 256KB, oldest evicted
  • Exit status is real, not just "an event arrived" — exit 7 asserts Code == 7, exit 3 asserts Code == 3
  • Shutdown kills every session concurrently and reaps each one
  • ErrNoSuchSession from Attach/Write/Resize/Kill
  • Windows path compiles (GOOS=windows vet + test-binary build) and is smoke-tested in CI

Adversarial review — what it caught

The SIGKILL-escalation test was passing for the wrong reason. It killed ~148µs after spawn, before sh had installed trap "" HUP, so SIGHUP arrived at default disposition and the child died instantly — the escalation path never executed. A probe confirmed the implementation is correct (the child survives 4s once the trap is actually in place). The test now blocks on a TRAP-INSTALLED marker before killing, and asserts the kill takes at least killGrace. It would have been flaky-green in CI otherwise.

Also fixed during review: Attachment.Chunks shares one backing array across all consumers of a session, which was an undocumented aliasing contract. Now stated explicitly.

Structural ratchets raised (3)

Each is justified in the diff, at the line that raises it:

  1. maxAppFields 1 → 2 — the PTY service arrives as a single *pty.Manager handle, which is the one-handle-per-service case the ceiling's own comment describes. maxAppMethods is unchanged at 1: no new Wails-bound API.
  2. Import graph pin — adds internal/app → internal/pty and internal/pty → {}. The service imports no first-party package, which is what keeps it usable from Loopback stream server #3 without dragging the Wails layer along.
  3. structuralPins — new internal/pty entry (654 LOC / ceiling 750, 7 exported). This is the first LOC ceiling set from a real measurement rather than policy, so locCeilingNote was updated to say so; internal/app and buildinfo remain policy-seeded pending Project registry and tabs #5.

CI change

The Test job runs on ubuntu only, so the ConPTY path would otherwise reach a release having passed every gate without ever executing. Added a PTY smoke-test step to the existing Build matrix, so go test ./internal/pty/... now runs on ubuntu, macOS and Windows. Parity holds: it is the same suite make test runs on a maintainer's host.

Known limitation

The Windows path is compile-verified and cross-vetted locally, but I have no Windows machine here — this PR's Windows CI job is the first real execution of the ConPTY path. That job is the one to watch on this PR.

Checklist

  • One issue per PR; acceptance criteria of the linked issue are met
  • No new dependencies outside the license allowlist (MIT/BSD/Apache-2.0/ISC/0BSD)
  • External binaries invoked only through the shared exec helper — n/a: no os/exec use at all. go-pty owns process creation, and .semgrep/go-security.yml already names the PTY service as the one legitimate place a shell is spawned.
  • Consistent with DESIGN.md (deviation called out explicitly above)

cjimti added 2 commits August 3, 2026 10:19
internal/pty owns session lifecycle, scrollback and platform
termination; internal/app composes it and drains it on shutdown.

Uses aymanbagabas/go-pty on both platforms rather than splitting
creack/pty and go-pty: it wraps creack on Unix and owns process
creation, so gosec G204 never fires and no suppression is needed.

Raises three structural ratchets with justification in the diff.

Closes #2
go-pty resolves a bare command name relative to Cmd.Dir on Windows, so
a session created with a Cwd looked for the shell inside that directory
and failed with "file does not exist".

This was not confined to tests. The Windows default shell is the bare
name "powershell.exe" and every project terminal sets Cwd to the project
root, so no terminal could have opened on Windows at all.

start() now calls exec.LookPath first, which also turns "the shell is
not installed" into an error at Create rather than a session that exists
and immediately dies. exec.LookPath does not trip gosec G204.

Caught by the Windows CI smoke test added in the previous commit.
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@cjimti
cjimti merged commit cec1bb1 into main Aug 3, 2026
11 checks passed
@cjimti
cjimti deleted the feat/2-pty-service branch August 3, 2026 18:14
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.

1 participant