#2641 · Forced terminal cleanup does not close PTY resources
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The host daemon can finish a forced terminal close without a PTY resource-close operation. The manager sends two signals and removes its session. It only disposes event subscriptions. Repeated fallback closes can therefore retain native PTY resources and stop later terminal starts.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The forced close path can finish without closing the PTY resource. | Verified | Two clean test runs sent both kill signals. The resource disposal count stayed at zero. |
| Repeated forced closes can retain host PTY resources. | Verified | The manager removes each session without a resource-close call when no exit event arrives. |
| A full macOS PTY pool prevents later terminal starts. | Unverified here | The test host uses Linux. This investigation did not repeat the long macOS resource exhaustion. |
| A frequent plugin scan makes the problem appear sooner. | Unverified here | The unit test does not run a third-party plugin. |
3. Environment
- bb commit:
ec8f4ef04105c2cb5a59f7a9a9328bf2b16b39ce - Host: Linux 7.0.0-30-generic x86_64
- Node: v24.18.0
- pnpm: 9.15.0
- Two clean clones used the same trusted commit.
- No server port, provider, or bb data directory was used.
4. Minimal reproduction
- Use a clean checkout at the trusted base commit.
- Run the frozen install and the full Turbo build.
- Save the test below as
apps/host-daemon/src/terminals/terminal-manager-disposal.test.ts. - Run this command:
pnpm exec turbo run test --filter=@bb/host-daemon --only -- --run src/terminals/terminal-manager-disposal.test.ts
Expected result:
disposeCount = 1
Actual result in both clean runs:
AssertionError: expected +0 to be 1 Expected: 1 Received: 0
The complete reproduction test follows.
import { afterEach, describe, expect, it, vi } from "vitest";
import { RuntimeManager } from "../runtime-manager.js";
import type { HostDaemonLogger } from "../logger.js";
import {
TerminalManager,
type SpawnTerminalPtyArgs,
type TerminalPtyAdapter,
type TerminalPtyDisposable,
type TerminalPtyExit,
type TerminalPtyProcess,
} from "./terminal-manager.js";
class ResourceTrackingPty implements TerminalPtyProcess {
readonly killSignals: (NodeJS.Signals | null)[] = [];
disposeCount = 0;
dispose(): void {
this.disposeCount += 1;
}
kill(signal?: NodeJS.Signals): void {
this.killSignals.push(signal ?? null);
}
onData(_listener: (data: string) => void): TerminalPtyDisposable {
return { dispose: () => undefined };
}
onExit(_listener: (event: TerminalPtyExit) => void): TerminalPtyDisposable {
return { dispose: () => undefined };
}
resize(_cols: number, _rows: number): void {}
write(_data: Buffer | string): void {}
}
class ResourceTrackingAdapter implements TerminalPtyAdapter {
readonly pty = new ResourceTrackingPty();
spawn(_args: SpawnTerminalPtyArgs): TerminalPtyProcess {
return this.pty;
}
}
function createLogger(): HostDaemonLogger {
return {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
}
describe("TerminalManager PTY disposal", () => {
afterEach(() => {
vi.useRealTimers();
});
it("disposes the PTY resource after forced session cleanup", async () => {
vi.useFakeTimers();
const adapter = new ResourceTrackingAdapter();
const manager = new TerminalManager({
closeGracePeriodMs: 10,
logger: createLogger(),
ptyAdapter: adapter,
resolveShell: async () => "/bin/sh",
runtimeManager: new RuntimeManager({
createRuntime: () => {
throw new Error("Runtime creation is not expected");
},
provisionWorkspace: async () => {
throw new Error("Workspace provision is not expected");
},
}),
sendMessage: () => true,
});
await manager.handleMessage({
type: "terminal.open",
requestId: "open-1",
terminalId: "term-1",
threadId: "thr-1",
target: { kind: "host_path", cwd: "/tmp" },
cols: 80,
rows: 24,
start: { mode: "shell" },
});
await manager.handleMessage({
type: "terminal.close",
terminalId: "term-1",
reason: "user",
});
await vi.advanceTimersByTimeAsync(10);
expect(adapter.pty.killSignals).toEqual([null, "SIGKILL"]);
expect(adapter.pty.disposeCount).toBe(1);
});
});
5. Root cause
The internal PTY contract has no method that closes the PTY resource. The node-pty adapter therefore exposes signals, event subscriptions, resize, and write operations only.
The fallback close path sends SIGKILL and immediately finishes the session. Session finish clears timers, removes the session, and disposes two listeners. It never closes the underlying PTY resource.
This omission matters when node-pty does not send an exit event. The fallback path reports completion, but the native event source can still own its descriptor. Each later fallback can add another retained resource.
6. Proposed fix
Add a resource disposal operation to the host daemon PTY adapter. Map it to the node-pty terminal destroy operation at the dependency boundary. Call it once when the manager finishes every session. Add tests for normal exit, forced close, repeated close, and adapter disposal.
7. Related issues
No open pull request links to issue 2641. Issue 2308 also concerns retained host processes, but it uses a different lifecycle path. No reviewed issue matched this PTY resource omission.
8. Appendix
Verification
The first clean clone failed the focused assertion at commit ec8f4ef04105. A second clean clone used a new install and full build. The same command failed with the same zero disposal count. No report correction was necessary.
Commands
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --concurrency=1 pnpm exec turbo run test --filter=@bb/host-daemon --only -- --run src/terminals/terminal-manager-disposal.test.ts git log ec8f4ef04105..origin/main --oneline -- apps/host-daemon/src/terminals/terminal-manager.ts
Limits
The deterministic manager defect reproduced on Linux. This investigation did not repeat the long macOS descriptor count or a third-party plugin schedule.
The issue data was untrusted. This investigation ran only trusted repository code and the new focused test.