Skip to content

fix(pty): reap unattached PTY sessions to prevent orphan process leaks - #62342

Open
JohnnyWilson16 wants to merge 3 commits into
anomalyco:devfrom
JohnnyWilson16:fix/pty-unattached-process-leak
Open

JohnnyWilson16 wants to merge 3 commits into
anomalyco:devfrom
JohnnyWilson16:fix/pty-unattached-process-leak

Conversation

@JohnnyWilson16

@JohnnyWilson16 JohnnyWilson16 commented Nov 6, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #50699

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

When a client creates a PTY session via /api/pty but fails to attach (e.g., connect-token rejected by origin policy, early tab close) or drops connection while running, the underlying shell process runs indefinitely with no subscriber.

This change:

  1. Implements subscriber-driven reaper lifecycle: starts a 60-second grace timer when a session has 0 subscribers (on creation or when the last subscriber detaches/disconnects).
  2. Cancels the reaper timer immediately whenever a client attaches.
  3. Exposes Pty.configured({ timeout?: Duration.Input }) (with a 60-second default) to configure or inject the timeout cleanly without public environment variables.
  4. Safely catches defects and unexpected errors in the reaper fiber via Effect.catchCause and ErrorSummary logging.
  5. Terminates the child process (session.process.kill()) and removes the session when the grace timer expires with no active subscribers.

How did you verify your code works?

Added deterministic unit and integration tests using TestClock in packages/core/test/pty/pty-session.test.ts:

  1. reaps unattached sessions after the grace period — verifies unattached session is reaped after grace window.
  2. does not reap sessions while attached — verifies sessions with active attachments are not reaped.
  3. reaps session after grace period when last subscriber detaches — verifies reaper restarts upon client disconnect/detach.
  4. cancels reaper if client re-attaches before grace period expires — verifies re-attaching before timeout resets grace window.
  5. reaps unattached session using configured timeout — verifies custom timeout injection via Pty.configured({ timeout: "10 seconds" }).

Ran verification commands locally:

  • cd packages/core && bun test test/pty/ (24 pass, 2 skip)
  • cd packages/server && bun test test/pty-socket.test.ts (4 pass)
  • cd packages/core && bun run typecheck (0 errors)
  • bun x oxlint packages/core/src/pty.ts packages/core/test/pty/pty-session.test.ts (0 errors)

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #48691, #51828

Public API: /api/pty sessions with no attached client for 60s (never attached, or since the last detach) are now removed (behaviour change for API clients that never attach or reconnect late)

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Nov 6, 2026

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes #50699 as described. I ran the issue's repro (50 PTY sessions whose connect-token is refused) against opencode serve from source. On the base, all 50 shells and sessions were still there after 60s. With this PR, all 50 were gone. The PTY session tests and tsc pass.

Public API impact:

  • New environment variable OPENCODE_PTY_UNATTACHED_TIMEOUT (default 60 seconds). It is undocumented and its value isn't validated.
  • Behaviour change for POST /api/pty: sessions that aren't attached within the grace period are now killed and a pty.deleted event is published. API/SDK clients that create a PTY and only drive it with write, or attach later than 60s, will lose it. In-repo clients (the app and the TUI) use persistent PTYs, so they aren't affected. Existing callers that attach promptly keep working.

Concerns (details inline):

  • Only sessions that never attach are reaped. A client that attaches and then drops (tab closed, network loss) still leaves the shell running forever.
  • A malformed env value silently turns the reaper off.
  • The tests depend on real-time sleeps and on changing process.env.

Comment thread packages/core/src/pty.ts Resolved
Comment thread packages/core/src/pty.ts Resolved
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. This fixes #50699. I ran the issue's repro against opencode serve from source: on the base, all 50 shells from refused connect-tokens were still running after 60s. With this commit, all 50 shells and sessions were gone within 60s. The PTY session tests (12/12, stable over 4 runs), the server's PTY socket tests and tsc for packages/core pass.

My earlier concerns are resolved:

  • The OPENCODE_PTY_UNATTACHED_TIMEOUT env var is gone. The timeout is now a layer option, and invalid values fall back to the default.
  • Reaper failures are logged instead of silently dropped.
  • The timer restarts when the last subscriber detaches and is cancelled when a client re-attaches.
  • The tests use TestClock instead of real sleeps and no longer change process.env.

Public API impact: no new env vars, config keys, or HTTP routes. Pty.configured, Pty.layer and Pty.Options are new internal exports in @opencode/core. One behaviour change on /api/pty: a session with no attached client for 60s (never attached, or since its last detach) is now killed and a pty.deleted event is published. API/SDK clients that drive a PTY only through write, or reconnect after more than 60s, will lose the session. This is intended, but it's a change for those callers.

No blocking concerns.

Related: #48691, #51828

@benjaminkincses benjaminkincses left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one nit on the reaper error path, rest looks right.

Comment thread packages/core/src/pty.ts Resolved
@opencodejr

opencodejr Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Thanks. I checked both in cdf469b, and they resolve my earlier comments. The grace timer now restarts when the last subscriber detaches (including the onData/activate error paths) and is cancelled on re-attach. The reaper tests use TestClock with no env changes or real sleeps; they passed consistently across 4 runs, and the #50699 repro cleaned up all 50 shells within 60s. Nothing more needed from my side on these two threads.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only stops the reaper from logging an error when its timer is cancelled on attach or teardown; real failures are still logged. That looks right. The PTY session tests (12/12) and tsc for packages/core pass at this commit. This commit doesn't change the public API, and I have no new concerns.

@benjaminkincses benjaminkincses left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit fixed, good to go.

This branch has not been deployed

No deployments
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.

serve: /api/pty leaks orphan bash processes when connect-token is denied

2 participants