← reports

#2641 · Forced terminal cleanup does not close PTY resources

Bug High Effort: Medium host open on GitHub 2026-08-28 · base ec8f4ef04105

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

ClaimStatusEvidence
The forced close path can finish without closing the PTY resource.VerifiedTwo clean test runs sent both kill signals. The resource disposal count stayed at zero.
Repeated forced closes can retain host PTY resources.VerifiedThe manager removes each session without a resource-close call when no exit event arrives.
A full macOS PTY pool prevents later terminal starts.Unverified hereThe test host uses Linux. This investigation did not repeat the long macOS resource exhaustion.
A frequent plugin scan makes the problem appear sooner.Unverified hereThe unit test does not run a third-party plugin.

3. Environment

4. Minimal reproduction

  1. Use a clean checkout at the trusted base commit.
  2. Run the frozen install and the full Turbo build.
  3. Save the test below as apps/host-daemon/src/terminals/terminal-manager-disposal.test.ts.
  4. 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.