Skip to content

fix(pty-daemon): release macOS PTY master descriptors - #5795

Merged
Kitenite merged 3 commits into
mainfrom
fix/pty-daemon-release-master-fds
Jul 20, 2026
Merged

Kitenite merged 3 commits into
mainfrom
fix/pty-daemon-release-master-fds

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 20, 2026 •

Copy link
Copy Markdown
Member

Summary

  • add idempotent master-fd disposal for native node-pty adapters and inherited/adopted PTYs
  • dispose owned descriptors on natural exit, explicit close, failed or partial open/adoption, and normal daemon shutdown
  • preserve successful handoff ownership by skipping predecessor signaling and disposal after the successor acknowledges adoption
  • keep TreeKiller escalation and socket backpressure behavior intact

Root cause

The standalone pty-daemon removed exited sessions from its store but did not have an explicit descriptor-ownership teardown contract. The host-service reaper cannot close fds held by the detached daemon process, so master descriptors could accumulate across long-lived, high-churn workloads until macOS exhausted kern.tty.ptmx_max.

Implementation

Pty.dispose() now releases the process-owned master descriptor exactly once. Native PTYs destroy node-pty's read/write streams, while adopted PTYs destroy their inherited tty.ReadStream. Close paths signal through the existing TreeKiller before disposal, and its escalation chain continues independently so detached/background descendants are still reaped.

Failed native construction and later partial-open setup kill and dispose the newly created PTY. Partial successor adoption closes only the successor's inherited copies without signaling shells that remain owned by the predecessor. Explicitly closed sessions are immediately excluded from fd handoff while their eventual exit notification still completes normal store cleanup.

Successful handoff still calls close({ killSessions: false }), which neither kills nor disposes predecessor adapters.

The package README now documents the ownership rule and lifecycle coverage.

Validation

  • bun run test — 66 passed
  • bun run build:daemon
  • bun run test:integration — 56 passed, including control-plane, handoff, flow-control, TreeKiller, signal recovery, and new real-fd lifecycle suites
  • 128-cycle server churn with real OS descriptors
  • 128-cycle real node-pty master-fd churn plus native/adopted idempotency checks
  • bun run typecheck — repo-wide, 35 tasks passed
  • bun run lint:fix
  • bun run lint

Closes #5305


Open in Stage

Summary by cubic

Fixes PTY master-fd leaks by disposing descriptors on teardown across pty-daemon. Also prevents hidden macOS /dev/ptmx leaks by pinning node-pty to a fixed version.

  • Bug Fixes

    • Added dispose() to Pty (idempotent) for native node-pty and adopted PTYs.
    • Dispose on natural exit, explicit close (kill then dispose), failed/partial spawn or adoption, and daemon shutdown; skip after successful handoff.
    • Hardened open/adoption rollback: close only the successor’s inherited copies on failure.
    • Mark sessions exited on close and ignore stale exit events so recycled ids aren’t deleted.
  • Dependencies

    • Pin node-pty to 1.2.0-beta.14 to fix hidden macOS /dev/ptmx descriptor leaks; applied in apps/desktop, packages/host-service, and packages/pty-daemon.

Written for commit f22fb4a. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added an idempotent dispose() API for PTY instances (optionally preserving exit polling).
  • Bug Fixes
    • Improved PTY/session cleanup on open/adopt failures, close, shutdown, and natural exits.
    • Ensured stale exit events won’t affect newer sessions using the same id.
    • Prevented fd/PTY leaks during partial adoption and exit callback ordering.
  • Tests
    • Expanded coverage for native/adopted master fd lifecycle, churn, forced termination, and handoff behavior.
    • Strengthened integration and handler tests with dispose-aware fakes.
  • Documentation
    • Updated PTY daemon README with clearer fd lifecycle and testing guidance.

@ghost

ghost commented Jul 20, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 7 individual chapters for you:

Title
1 Define the PTY disposal interface
2 Implement disposal in PTY adapters
3 Secure PTY creation and adoption paths
4 Wire disposal into the Server lifecycle
5 Update protocol handlers for explicit close
6 Update existing tests for new interface
7 Add lifecycle integration tests and docs
Open in Stage

Chapters generated by Stage for commit 2d022b6 on Jul 20, 2026 5:38am UTC.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

PTY adapters now support idempotent master-descriptor disposal. Open, close, exit, snapshot adoption, shutdown, and handoff paths perform explicit ownership cleanup, with tests covering native, adopted, and server-managed file descriptors.

Changes

PTY lifecycle cleanup

Layer / File(s) Summary
Adapter disposal and construction cleanup
packages/pty-daemon/src/Pty/Pty.ts
Native and adopted PTY adapters track disposal, guard pause/resume after disposal, and clean up failed spawn or adoption attempts.
Open and close handler cleanup
packages/pty-daemon/src/handlers/handlers.ts, packages/pty-daemon/src/handlers/handlers.test.ts
Open failures remove partial sessions and kill/dispose PTYs; close separates kill errors from disposal and marks sessions exited.
Server ownership and handoff cleanup
packages/pty-daemon/src/Server/Server.ts
Snapshot adoption rolls back partial state, shutdown disposes owned PTYs, and exit handling releases PTY resources while protecting recycled sessions and handoff descriptors.
Descriptor lifecycle validation and supporting updates
packages/pty-daemon/test/*lifecycle.test.ts, packages/pty-daemon/src/SessionStore/*.test.ts, packages/pty-daemon/test/byte-fidelity.test.ts, packages/pty-daemon/test/integration.test.ts, packages/pty-daemon/package.json, packages/pty-daemon/README.md, apps/desktop/package.json, packages/host-service/package.json
Tests cover native, adopted, and server-managed descriptor cleanup, and supporting mocks, scripts, dependency versions, and documentation reflect the disposal contract.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server
  participant Pty
  participant SessionStore
  Client->>Server: open session
  Server->>Pty: spawn PTY
  Server->>SessionStore: store session
  Client->>Server: close session
  Server->>Pty: kill and dispose
  Pty-->>Server: exit callback
  Server->>SessionStore: remove matching session
Loading

Possibly related PRs

Suggested reviewers: saddlepaddle

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address the reported master-fd leak on exit, teardown, and failed/partial spawns, matching the linked issue's fix scope.
Out of Scope Changes check ✅ Passed The diff stays focused on PTY lifecycle fixes, tests, docs, and a dependency pin needed for the leak fix; no unrelated code appears.
Title check ✅ Passed The title is concise, conventional, and accurately summarizes the main change: releasing PTY master descriptors in pty-daemon.
Description check ✅ Passed The description covers summary, root cause, implementation, and validation with relevant details, though it doesn't match the template headings exactly.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pty-daemon-release-master-fds

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/pty-daemon/src/Pty/Pty.ts (1)

638-659: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

adoptFromFd leaks the inherited fd when pid/dims validation fails.

The fd-format check is fine to leave unprotected (nothing to close), but the pid validity check and validateDims(meta.cols, meta.rows) both run before the try block that closes fd on failure. If either throws, this inherited descriptor (already owned by this process via stdio) is never closed — exactly the kind of "failed/partial adoption" leak this PR is meant to close.

🐛 Proposed fix
 export function adoptFromFd({ fd, pid, meta }: AdoptOptions): Pty {
 	if (!Number.isInteger(fd) || fd < 0) {
 		throw new Error(`invalid fd: ${fd}`);
 	}
-	if (!Number.isInteger(pid) || pid <= 0) {
-		throw new Error(`invalid pid: ${pid}`);
-	}
-	validateDims(meta.cols, meta.rows);
 	try {
+		if (!Number.isInteger(pid) || pid <= 0) {
+			throw new Error(`invalid pid: ${pid}`);
+		}
+		validateDims(meta.cols, meta.rows);
 		return new AdoptedPty(fd, pid, meta);
 	} catch (err) {
 		try {
 			fs.closeSync(fd);
 		} catch {
 			// Preserve the adoption error.
 		}
 		throw err;
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pty-daemon/src/Pty/Pty.ts` around lines 638 - 659, Update
adoptFromFd so the pid validation and validateDims(meta.cols, meta.rows)
execution are covered by the existing cleanup try/catch, ensuring fd is closed
when either validation throws. Keep the fd-format check outside cleanup, and
preserve the original validation or construction error after attempting
fs.closeSync(fd).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/pty-daemon/src/Server/Server.ts`:
- Around line 127-166: Make rollback disposal in adoptSnapshot best-effort: wrap
both the inner pty.dispose() and each outer session.pty.dispose() in independent
try/catch blocks so disposal failures cannot replace the original adoption error
or stop cleanup of subsequent sessions. Preserve store deletion and rethrow the
original error after attempting all rollback operations.

---

Outside diff comments:
In `@packages/pty-daemon/src/Pty/Pty.ts`:
- Around line 638-659: Update adoptFromFd so the pid validation and
validateDims(meta.cols, meta.rows) execution are covered by the existing cleanup
try/catch, ensuring fd is closed when either validation throws. Keep the
fd-format check outside cleanup, and preserve the original validation or
construction error after attempting fs.closeSync(fd).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bc3e6c25-f402-432b-a0c3-f177a4e57fcf

📥 Commits

Reviewing files that changed from the base of the PR and between 2ce34e6 and 2d022b6.

📒 Files selected for processing (11)
  • packages/pty-daemon/README.md
  • packages/pty-daemon/package.json
  • packages/pty-daemon/src/Pty/Pty.ts
  • packages/pty-daemon/src/Server/Server.ts
  • packages/pty-daemon/src/SessionStore/SessionStore.test.ts
  • packages/pty-daemon/src/SessionStore/snapshot.test.ts
  • packages/pty-daemon/src/handlers/handlers.test.ts
  • packages/pty-daemon/src/handlers/handlers.ts
  • packages/pty-daemon/test/byte-fidelity.test.ts
  • packages/pty-daemon/test/fd-lifecycle.test.ts
  • packages/pty-daemon/test/server-fd-lifecycle.test.ts

@greptile-apps

greptile-apps Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an explicit, idempotent dispose() method to the Pty interface and both concrete adapters (NodePtyAdapter, AdoptedPty) so that PTY master file descriptors are released deterministically on every teardown path — natural exit, explicit pane close, failed spawn/adoption, and normal shutdown — fixing an fd accumulation issue on long-lived daemons.

  • NodePtyAdapter.dispose() calls term.destroy() to close node-pty's read socket; AdoptedPty.dispose() calls reader.destroy() on the inherited tty.ReadStream. Both guards are idempotent via a disposed flag.
  • handleClose is restructured to always call dispose() and eagerly set session.exited = true before returning, and adoptSnapshot gains rollback logic that disposes only the successor's inherited copies on partial failure.
  • Two new integration test files drive 128-cycle churn with real OS descriptors to validate the fix.

Confidence Score: 3/5

The fd-leak fix is sound and the new test coverage is thorough, but Server.ts has a store-corruption window introduced by this PR that needs a one-line identity guard before merging.

The wireSession exit closure deletes from the session store using the session ID string as its sole key. Before this PR that was safe because session.exited was only set inside wireSession itself (right before the delete), so no other code could legally recycle the same ID while the old PTY was still alive. This PR makes handleClose eagerly set session.exited = true, which allows handleOpen to recycle the same session ID while the old PTY has not yet fired its exit event. When that exit event fires, the closure calls store.delete(session.id) and silently removes the newly-created replacement session. The rest of the change — the dispose mechanics, the spawn/adoption rollback, the new 128-cycle test suites — looks correct and well-thought-out.

packages/pty-daemon/src/Server/Server.ts — the wireSession exit callback needs an identity guard before deleting from the store.

Important Files Changed

Filename Overview
packages/pty-daemon/src/Server/Server.ts Adds dispose calls in close() and wireSession exit handler, and rollback logic in adoptSnapshot. The wireSession exit closure deletes by session ID string without an identity guard; combined with handleClose eagerly setting session.exited = true, this opens a window where the old exit callback can delete a recycled session from the store.
packages/pty-daemon/src/Pty/Pty.ts Adds dispose() to NodePtyAdapter and AdoptedPty with an idempotency guard; wires dispose into the natural-exit path and partial-construction cleanup. AdoptedPty.dispose() deliberately omits clearing livenessTimer so the timer can still drive store cleanup via onExit.
packages/pty-daemon/src/handlers/handlers.ts Restructures handleOpen to kill+dispose a partially-constructed PTY on failure; restructures handleClose to always dispose the fd and eagerly mark session.exited = true before returning, even if kill raised an error.
packages/pty-daemon/test/server-fd-lifecycle.test.ts New Bun integration suite verifying ownership across natural-exit churn, explicit close, shutdown, and successful handoff. fdIsOpen swallows non-EBADF errors and returns true, which could mask unexpected fd states.
packages/pty-daemon/test/fd-lifecycle.test.ts New Node integration suite exercising real node-pty master-fd closure across 128-cycle churn and idempotency of both native and adopted dispose; fdIsOpen correctly rethrows unexpected errors.
packages/pty-daemon/src/handlers/handlers.test.ts Unit tests updated to track disposed state on fake PTYs; adds coverage for partial-open cleanup when wireSession throws.
packages/pty-daemon/src/SessionStore/SessionStore.test.ts Trivial addition of dispose: () => {} to the fake Pty stub to satisfy the updated interface.
packages/pty-daemon/src/SessionStore/snapshot.test.ts Same trivial interface stub update as SessionStore.test.ts.
packages/pty-daemon/package.json Adds the two new test files to the test and test:integration scripts.
packages/pty-daemon/README.md Documents the new PTY master ownership rule and lists the two new integration test files in the test directory table.

Comments Outside Diff (1)

  1. packages/pty-daemon/src/Server/Server.ts, line 525-558 (link)

    P1 Stale-key store delete can wipe a recycled session

    wireSession's exit closure captures session.id (a string) and calls this.store.delete(session.id) keyed only on the string. This PR's handleClose now eagerly sets session.exited = true before the shell actually dies, which means handleOpen can legally recycle the same ID (lines 53-54 of handlers.ts) and insert a new Session object under the same key while the old PTY is still alive. When the old PTY finally exits, the closure fires and silently removes the new session from the store.

    Before this PR, session.exited was set only inside wireSession itself (after store.delete had already run), so the recycling window did not exist. Fix: guard the delete with an identity check so it only removes the entry it originally installed:

    if (this.store.get(session.id) === session) {
        this.store.delete(session.id);
    }
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/pty-daemon/src/Server/Server.ts
    Line: 525-558
    
    Comment:
    **Stale-key store delete can wipe a recycled session**
    
    `wireSession`'s exit closure captures `session.id` (a string) and calls `this.store.delete(session.id)` keyed only on the string. This PR's `handleClose` now eagerly sets `session.exited = true` before the shell actually dies, which means `handleOpen` can legally recycle the same ID (lines 53-54 of `handlers.ts`) and insert a new `Session` object under the same key while the old PTY is still alive. When the old PTY finally exits, the closure fires and silently removes the new session from the store.
    
    Before this PR, `session.exited` was set only inside `wireSession` itself (after `store.delete` had already run), so the recycling window did not exist. Fix: guard the delete with an identity check so it only removes the entry it originally installed:
    
    ```ts
    if (this.store.get(session.id) === session) {
        this.store.delete(session.id);
    }
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/pty-daemon/src/Server/Server.ts:525-558
**Stale-key store delete can wipe a recycled session**

`wireSession`'s exit closure captures `session.id` (a string) and calls `this.store.delete(session.id)` keyed only on the string. This PR's `handleClose` now eagerly sets `session.exited = true` before the shell actually dies, which means `handleOpen` can legally recycle the same ID (lines 53-54 of `handlers.ts`) and insert a new `Session` object under the same key while the old PTY is still alive. When the old PTY finally exits, the closure fires and silently removes the new session from the store.

Before this PR, `session.exited` was set only inside `wireSession` itself (after `store.delete` had already run), so the recycling window did not exist. Fix: guard the delete with an identity check so it only removes the entry it originally installed:

```ts
if (this.store.get(session.id) === session) {
    this.store.delete(session.id);
}
```

### Issue 2 of 3
packages/pty-daemon/test/server-fd-lifecycle.test.ts:53-60
`fdIsOpen` swallows unexpected `fstatSync` errors and returns `true`, so a spurious EPERM or other OS error would be misread as "fd is open" and silently pass the assertion. The version in `fd-lifecycle.test.ts` is stricter — it rethrows anything that isn't EBADF. This suite should match.

```suggestion
function fdIsOpen(fd: number): boolean {
	try {
		fs.fstatSync(fd);
		return true;
	} catch (err) {
		if ((err as NodeJS.ErrnoException).code === "EBADF") return false;
		throw err;
	}
}
```

### Issue 3 of 3
packages/pty-daemon/src/Pty/Pty.ts:524-535
**`livenessTimer` left running after explicit `dispose()` — intent needs a comment**

When `dispose()` is called outside `onExit` (from `handleClose`, server shutdown, or the `adoptSnapshot` rollback), the 1-second liveness poll keeps firing until `isPidAlive(pid)` returns false. At that point `onExit` fires, clears the timer, and drives the `wireSession` store-cleanup callback. This is intentional — the timer is the only path that delivers the cleanup event to `wireSession` for adopted PTYs — but the omission is not documented. A short note mirroring the "Do not touch TreeKiller" comment above would make the design explicit for future readers.

Reviews (1): Last reviewed commit: "fix(pty-daemon): dispose master fds on t..." | Re-trigger Greptile

Comment on lines +53 to +60
function fdIsOpen(fd: number): boolean {
try {
fs.fstatSync(fd);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code !== "EBADF";
}
}

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.

P2 fdIsOpen swallows unexpected fstatSync errors and returns true, so a spurious EPERM or other OS error would be misread as "fd is open" and silently pass the assertion. The version in fd-lifecycle.test.ts is stricter — it rethrows anything that isn't EBADF. This suite should match.

Suggested change
function fdIsOpen(fd: number): boolean {
try {
fs.fstatSync(fd);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code !== "EBADF";
}
}
function fdIsOpen(fd: number): boolean {
try {
fs.fstatSync(fd);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EBADF") return false;
throw err;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/pty-daemon/test/server-fd-lifecycle.test.ts
Line: 53-60

Comment:
`fdIsOpen` swallows unexpected `fstatSync` errors and returns `true`, so a spurious EPERM or other OS error would be misread as "fd is open" and silently pass the assertion. The version in `fd-lifecycle.test.ts` is stricter — it rethrows anything that isn't EBADF. This suite should match.

```suggestion
function fdIsOpen(fd: number): boolean {
	try {
		fs.fstatSync(fd);
		return true;
	} catch (err) {
		if ((err as NodeJS.ErrnoException).code === "EBADF") return false;
		throw err;
	}
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread packages/pty-daemon/src/Pty/Pty.ts Outdated
Comment on lines +524 to +535
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// Do not touch TreeKiller or its pending escalation. Closing the adopted
// stream releases only this daemon's inherited descriptor; kill() owns
// process-tree signaling and may still be finishing in the background.
try {
this.reader.destroy();
} catch {
// The stream may already have closed after EOF/EIO.
}
}

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.

P2 livenessTimer left running after explicit dispose() — intent needs a comment

When dispose() is called outside onExit (from handleClose, server shutdown, or the adoptSnapshot rollback), the 1-second liveness poll keeps firing until isPidAlive(pid) returns false. At that point onExit fires, clears the timer, and drives the wireSession store-cleanup callback. This is intentional — the timer is the only path that delivers the cleanup event to wireSession for adopted PTYs — but the omission is not documented. A short note mirroring the "Do not touch TreeKiller" comment above would make the design explicit for future readers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/pty-daemon/src/Pty/Pty.ts
Line: 524-535

Comment:
**`livenessTimer` left running after explicit `dispose()` — intent needs a comment**

When `dispose()` is called outside `onExit` (from `handleClose`, server shutdown, or the `adoptSnapshot` rollback), the 1-second liveness poll keeps firing until `isPidAlive(pid)` returns false. At that point `onExit` fires, clears the timer, and drives the `wireSession` store-cleanup callback. This is intentional — the timer is the only path that delivers the cleanup event to `wireSession` for adopted PTYs — but the omission is not documented. A short note mirroring the "Do not touch TreeKiller" comment above would make the design explicit for future readers.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/pty-daemon/src/Pty/Pty.ts (1)

524-539: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Stop the adopted PTY poll once the session is already untracked

dispose() is also used in the adoption-rollback path after the session has been deleted from the store. In that case, reader.destroy() can skip the onExit cleanup, so livenessTimer keeps polling isPidAlive() and retains the AdoptedPty until the old shell exits. The interval is unref'd, but it still does work for an untracked session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pty-daemon/src/Pty/Pty.ts` around lines 524 - 539, The dispose()
logic in AdoptedPty must stop livenessTimer when the session has already been
removed from tracking during adoption rollback. Update dispose() to clear the
timer before or alongside reader.destroy(), while preserving onExit cleanup and
existing TreeKiller behavior for normally tracked sessions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/pty-daemon/src/Pty/Pty.ts`:
- Around line 524-539: The dispose() logic in AdoptedPty must stop livenessTimer
when the session has already been removed from tracking during adoption
rollback. Update dispose() to clear the timer before or alongside
reader.destroy(), while preserving onExit cleanup and existing TreeKiller
behavior for normally tracked sessions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e02d197-8d1b-42af-aca7-7f14abf6e932

📥 Commits

Reviewing files that changed from the base of the PR and between 2d022b6 and a7fb7c0.

📒 Files selected for processing (4)
  • packages/pty-daemon/src/Pty/Pty.ts
  • packages/pty-daemon/src/Server/Server.ts
  • packages/pty-daemon/test/integration.test.ts
  • packages/pty-daemon/test/server-fd-lifecycle.test.ts

@Kitenite Kitenite changed the title fix(pty-daemon): dispose master fds on teardown fix(pty-daemon): release macOS PTY master descriptors Jul 20, 2026

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/pty-daemon/package.json (1)

32-33: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Run server-fd-lifecycle.test.ts under Node, not Bun.

This suite exercises real PTYs, but the README explicitly states that node-pty does not work under Bun. Move it from test to test:integration, where it will run through Node with the other real-PTY suites.

Proposed script correction
-		"test": "bun test src/protocol src/SessionStore src/handlers src/Pty/Pty.test.ts src/process-tree.test.ts test/server-fd-lifecycle.test.ts test/no-encoding-hops.test.ts",
-		"test:integration": "node --experimental-strip-types --test test/integration.test.ts test/control-plane.test.ts test/signal-recovery.test.ts test/byte-fidelity.test.ts test/handoff.test.ts test/foreground-process.test.ts test/flow-control.test.ts test/kill-tree.test.ts test/fd-lifecycle.test.ts",
+		"test": "bun test src/protocol src/SessionStore src/handlers src/Pty/Pty.test.ts src/process-tree.test.ts test/no-encoding-hops.test.ts",
+		"test:integration": "node --experimental-strip-types --test test/integration.test.ts test/control-plane.test.ts test/signal-recovery.test.ts test/byte-fidelity.test.ts test/handoff.test.ts test/foreground-process.test.ts test/flow-control.test.ts test/kill-tree.test.ts test/fd-lifecycle.test.ts test/server-fd-lifecycle.test.ts",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pty-daemon/package.json` around lines 32 - 33, Move
test/server-fd-lifecycle.test.ts from the Bun-based "test" script to the
Node-based "test:integration" script in package.json, preserving the existing
test path and ensuring it runs with the real-PTY integration suites.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/pty-daemon/package.json`:
- Around line 32-33: Move test/server-fd-lifecycle.test.ts from the Bun-based
"test" script to the Node-based "test:integration" script in package.json,
preserving the existing test path and ensuring it runs with the real-PTY
integration suites.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fbed0db3-50da-4bb3-8823-73fbbaa4a07f

📥 Commits

Reviewing files that changed from the base of the PR and between a7fb7c0 and f22fb4a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • apps/desktop/package.json
  • packages/host-service/package.json
  • packages/pty-daemon/README.md
  • packages/pty-daemon/package.json
  • packages/pty-daemon/src/Pty/Pty.ts
  • packages/pty-daemon/src/Server/Server.ts
  • packages/pty-daemon/test/fd-lifecycle.test.ts
  • packages/pty-daemon/test/handoff.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/pty-daemon/test/fd-lifecycle.test.ts

@Kitenite
Kitenite merged commit 7515dd4 into main Jul 20, 2026
19 checks passed
Laurence-NZ added a commit to Laurence-NZ/superset-pwsh that referenced this pull request Jul 22, 2026
Laurence-NZ added a commit to Laurence-NZ/superset-pwsh that referenced this pull request Jul 23, 2026

This branch was previously deployed

1 inactive deployment
preview — f22fb4a7 Deployed Jul 20, 2026 by Kitenite via Deploy Web #6705
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.

pty-daemon leaks pty master fds for exited agents → exhausts kern.tty.ptmx_max, blocks all new agent spawns (and other apps)

1 participant