Skip to content

fix(test): fixture CLIs self-reap when orphaned and dispose kills the tree - #445

Merged
omridevk merged 2 commits into
mainfrom
fix/fixture-orphan-reap
Aug 12, 2026
Merged

fix(test): fixture CLIs self-reap when orphaned and dispose kills the tree#445
omridevk merged 2 commits into
mainfrom
fix/fixture-orphan-reap

Conversation

@omridevk

@omridevk omridevk commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Evidence: 30 orphaned fake-claude processes (aged 3-9 days) accumulated on the dev machine. Any abnormally-terminated test run (vitest timeout SIGKILL, Ctrl-C, an interrupted agent run) skips teardown, and the spawned fixture CLI is left waiting on stdin forever.
  • Layer 1 — fixture self-reap (packages/core/test/fixtures/fake-claude.ts): the fixture now polls process.ppid and exits once it no longer matches the pid captured at start.
    • A stdin end/close listener was the first idea, but it's the wrong mechanism for this fixture's actual spawn protocol: @tanstack/ai-sandbox's spawnNdjson writes the prompt and calls stdin.end() immediately after spawning (proc.stdin.write(input); await proc.stdin.end()), regardless of parent liveness. Stdin already closes within milliseconds of every run, hang or not — it cannot distinguish "orphaned" from "normal in-flight." A ppid poll is the correct signal here, since the fixture only ever needs to outlive its parent's process lifetime, not its stdin.
    • Confirmed empirically: spawned the real fixture through the actual production path (bootCoreApp()chat.send@tanstack/ai-claude-code@tanstack/ai-sandbox-local-process), then SIGKILLed the parent test process.
      • Pre-fix: fixture pid survived 3+ seconds, ppid reparented to 1 (ps -p <pid> -o pid,ppid,statSs, alive at every 1s check).
      • Post-fix: ps -p <pid> returned no such process within ~300ms of the kill.
  • Layer 2 — spawn-side teardown hygiene (packages/harness-testkit/src/create-testkit.ts): cleanup() previously only aborted attach-stream subscriptions and called app.dispose(), which drains in-flight runs on a 5s deadline and then just logs an error and moves on — it never aborted the run's own AbortController, so a test that throws before manually stopping a long-running session left its spawned child running past cleanup. cleanup() now also calls chat.stop for every live session (the existing SIGTERM→SIGKILL escalation in packages/core/src/chat/sandbox.ts handles the actual kill), bounded to 3s so a test harness that doesn't honor abort (e.g. a deliberately held scripted turn, exercised by transcript-durability.it.test.ts's T3) can't turn this into a hang.

Regression test

packages/core/test/chat/fake-claude-orphan-reap.it.test.ts spawns fake-claude.ts through a real intermediate parent process (packages/core/test/fixtures/orphan-parent-sim.ts) using pipe stdio and its own process group — the worst-case spawn shape, verified against @tanstack/ai-sandbox-local-process's handle.js (the actual point that spawns the real claude child), which uses the same shape: default 'pipe' stdio, detached: true on POSIX. SIGKILLs that parent, and asserts the fixture exits on its own via vi.waitFor — no mocks, real OS-level process kill and process.kill(pid, 0) liveness checks.

  • Verified failing against the pre-fix fixture: assertion fails after the 3s vi.waitFor timeout (orphan survives).
  • Verified passing post-fix.
  • Revert-checked: re-applied the pre-fix fixture with the test present → fails for the stated reason; restored the fix → passes again.

Gates (all run in this worktree)

  • pnpm exec tsc -p packages/harness-testkit/tsconfig.json --noEmit — pass
  • pnpm turbo run typecheck --filter=@conciv/core --filter=@conciv/harness-testkit --filter=@conciv/harness --filter=@conciv/harness-init — pass
  • TURBO_CONCURRENCY=1 VITEST_MAX_FORKS=1 pnpm turbo run test --concurrency=1 --filter=@conciv/core --filter=@conciv/harness-testkit — pass (115 test files / 445 tests passed in @conciv/core, all @conciv/harness-testkit suites green)
  • pnpm lint (oxlint, scoped to touched files) — pass, 0 errors
  • pnpm exec oxfmt --check (touched files) — pass
  • pnpm exec fallow audit --changed-since main --format json — verdict pass, 0 introduced findings
  • pnpm exec fallow audit --format json --quiet --explain --gate-marker agent — verdict pass

Test plan

  • Regression test added and revert-checked
  • Empirical kill-proof captured pre-fix and post-fix (quoted above)
  • Full package test suites green for @conciv/core and @conciv/harness-testkit
  • No lingering test-spawned processes left behind after the run

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved cleanup reliability by stopping active sessions with a three-second timeout before shutting down related services.
    • Added safeguards to ensure child processes terminate automatically when their parent process exits unexpectedly.
  • Tests

    • Added integration coverage for orphaned child-process handling and automatic termination.
    • Added supporting test scenarios for readiness detection, forced parent termination, and cleanup fallback behavior.

… tree

30 orphaned fake-claude processes (aged 3-9 days) were found accumulated
on the dev machine: any abnormally-terminated test run (vitest timeout
SIGKILL, Ctrl-C, an interrupted agent run) skips teardown, and the spawned
fixture CLI is left waiting on stdin forever.

Two layers:

1. Fixture self-reap (packages/core/test/fixtures/fake-claude.ts): the
   fixture polls process.ppid and exits once it no longer matches the pid
   captured at start. A stdin end/close listener was considered first, but
   the real spawn protocol (@tanstack/ai-sandbox's spawnNdjson) writes the
   prompt and calls stdin.end() immediately after spawning, regardless of
   parent liveness, so stdin already closes seconds before any hang begins
   in every run - it cannot distinguish "orphaned" from "normal". A ppid
   poll is the correct signal here since the fixture only outlives its
   parent's process lifetime, never its stdin.

2. Spawn-side teardown hygiene (packages/harness-testkit/src/create-testkit.ts):
   testkit cleanup() previously only aborted attach-stream subscriptions and
   called app.dispose(), which drains in-flight runs on a 5s deadline and
   then just logs an error and moves on - it never aborted the run's own
   AbortController, so a test that throws before manually stopping a
   long-running session left its spawned child running past cleanup.
   cleanup() now also calls chat.stop for every live session (SIGTERM then
   SIGKILL escalation already lives in packages/core/src/chat/sandbox.ts),
   bounded to 3s so a test harness that doesn't honor abort (e.g. a
   deliberately held scripted turn) can't turn this into a hang.

Regression test: packages/core/test/chat/fake-claude-orphan-reap.it.test.ts
spawns fake-claude.ts through a real intermediate parent process
(packages/core/test/fixtures/orphan-parent-sim.ts, pipe stdio + its own
process group - the worst-case spawn shape, verified against
@tanstack/ai-sandbox-local-process's handle.js which spawns the real
"claude" child the same way: default 'pipe' stdio, detached: true on
POSIX), SIGKILLs that parent, and asserts the fixture exits on its own
using vi.waitFor. Verified failing against the pre-fix fixture (times out
at 3s) and passing post-fix; verified empirically too, killing the actual
process spawned via bootCoreApp() + local-process sandbox: pre-fix it
survived 3+ seconds with ppid reparented to 1, post-fix it was gone within
~300ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@omridevk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f187464c-094c-45fb-9d81-37e20641a544

📥 Commits

Reviewing files that changed from the base of the PR and between fe60f0b and cfff9ec.

📒 Files selected for processing (3)
  • packages/core/test/chat/fake-claude-orphan-reap.it.test.ts
  • packages/core/test/fixtures/fake-claude.ts
  • packages/harness-testkit/src/create-testkit.ts
📝 Walkthrough

Walkthrough

The PR adds parent-process monitoring to the fake Claude fixture, an integration test for orphan reaping, and bounded active-session cleanup in the harness testkit.

Changes

Orphan Process Reaping

Layer / File(s) Summary
Watchdog and orphan reaping validation
packages/core/test/fixtures/fake-claude.ts, packages/core/test/fixtures/orphan-parent-sim.ts, packages/core/test/chat/fake-claude-orphan-reap.it.test.ts
The fake Claude process exits after detecting a changed parent PID. The simulator launches the process and reports readiness. The integration test kills the parent and verifies child termination.

Bounded Testkit Cleanup

Layer / File(s) Summary
Bounded active-session cleanup
packages/harness-testkit/src/create-testkit.ts, packages/harness-testkit/package.json
cleanup stops active sessions with suppressed individual errors and a three-second overall timeout before disposing the app and server. The package adds p-timeout.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OrphanReapTest
  participant OrphanParentSim
  participant FakeClaude
  OrphanReapTest->>OrphanParentSim: spawn simulator
  OrphanParentSim->>FakeClaude: spawn hanging child
  FakeClaude-->>OrphanParentSim: signal readiness
  OrphanParentSim-->>OrphanReapTest: report child PID
  OrphanReapTest->>OrphanParentSim: SIGKILL parent
  FakeClaude->>FakeClaude: detect changed process.ppid
  FakeClaude-->>OrphanReapTest: terminate
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the fixture orphan-reaping and process-tree cleanup changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fixture-orphan-reap

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

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

Actionable comments posted: 2

🤖 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/core/test/chat/fake-claude-orphan-reap.it.test.ts`:
- Around line 22-40: Wrap the setup and assertions after parentPid validation in
a try/finally so failures during readiness parsing or liveness checks cannot
leak processes. In the finally block, kill orphan-parent-sim when parentPid is
still alive, and conditionally kill the parsed fixturePid when it is defined and
still alive; retain the existing fallback cleanup for the fixture.

In `@packages/harness-testkit/src/create-testkit.ts`:
- Around line 145-149: Bound session discovery within the cleanup timeout in the
surrounding testkit disposal flow: include rpc.sessions.list in the promise
passed to pTimeout rather than awaiting it beforehand, and provide the RPC
request with the available cancellation signal or timeout mechanism. Keep
stopping discovered sessions within the same deadline, and do not rely on
fallback to cancel the underlying request.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38744310-2aee-4a4b-9949-2defce6fc7ce

📥 Commits

Reviewing files that changed from the base of the PR and between 09d466d and fe60f0b.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • packages/core/test/chat/fake-claude-orphan-reap.it.test.ts
  • packages/core/test/fixtures/fake-claude.ts
  • packages/core/test/fixtures/orphan-parent-sim.ts
  • packages/harness-testkit/package.json
  • packages/harness-testkit/src/create-testkit.ts

Comment thread packages/core/test/chat/fake-claude-orphan-reap.it.test.ts
Comment thread packages/harness-testkit/src/create-testkit.ts Outdated

Copilot AI 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.

Pull request overview

Improves test-process teardown so fixture CLIs terminate when orphaned and active sessions are stopped during cleanup.

Changes:

  • Adds parent-PID monitoring to fake-claude.
  • Stops running sessions with a bounded cleanup period.
  • Adds an OS-level orphan-reaping regression test.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/core/test/fixtures/fake-claude.ts Adds orphan self-reaping.
packages/core/test/fixtures/orphan-parent-sim.ts Simulates an orphaning parent process.
packages/core/test/chat/fake-claude-orphan-reap.it.test.ts Tests fixture termination after parent death.
packages/harness-testkit/src/create-testkit.ts Stops sessions during cleanup.
packages/harness-testkit/package.json Adds p-timeout.
pnpm-lock.yaml Records dependency resolution changes.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pnpm-lock.yaml
'@tanstack/react-router':
specifier: latest
version: 1.170.23(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.170.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Attempted minimization (reset to main's lockfile + pnpm add p-timeout --filter @conciv/harness-testkit): the floating-latest TanStack graph still re-resolves on any resolve pass (113-line diff, same churn class). Declining per repo precedent (#433): in-range collateral from latest specifiers, CI-validated.

},
cleanup: async () => {
for (const abort of aborts) abort.abort()
const liveSessions = (await rpc.sessions.list(undefined).catch(() => [])) ?? []

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed against contract.ts:90 / rows.ts:8-19 (includeHidden input, hidden+running on SessionMeta) and fixed in cfff9ec — cleanup now lists {includeHidden: true} and stops only running sessions.

Comment on lines +22 to +40
const parent = spawn(process.execPath, ['--import', tsxEntry, parentSimPath], {stdio: ['ignore', 'pipe', 'pipe']})
const parentPid = parent.pid
if (parentPid === undefined) throw new Error('orphan-parent-sim did not spawn')

const [chunk] = await once(parent.stdout, 'data')
if (!(chunk instanceof Buffer)) throw new Error('expected a Buffer chunk from stdout')
const match = /READY (\d+)/.exec(chunk.toString())
if (!match) throw new Error('orphan-parent-sim did not report READY')
const fixturePid = Number(match[1])
expect(isAlive(fixturePid)).toBe(true)

process.kill(parentPid, 'SIGKILL')
await vi.waitFor(() => expect(isAlive(parentPid)).toBe(false), {timeout: 2000, interval: 50})

try {
await vi.waitFor(() => expect(isAlive(fixturePid)).toBe(false), {timeout: 3000, interval: 50})
} finally {
if (isAlive(fixturePid)) process.kill(fixturePid, 'SIGKILL')
}
Replace the standalone ORPHAN_POLL_MS/reapWhenOrphaned() helper in
fake-claude.ts with an inline ppid-poll block (interval now 1000ms,
matching the LSP-watchdog convention), and drop create-testkit.ts's
hand-rolled withTimeout in favor of p-timeout (matching
browser-fixture.ts's existing usage). The cleanup's session discovery
call is now folded inside the timed promise so a stalled
rpc.sessions.list no longer blocks disposal past the 3s bound, and it
lists with includeHidden so a deleted-but-still-running session isn't
skipped; only sessions the API marks running get stopped.

orphan-parent-sim.ts drops detached:true (SIGKILL never propagates to
children, so orphaning works without it) and the stdio error
swallowers; CONCIV_FAKE_HANG doesn't exit on stdin EOF (verified), so
stdin can go fully to 'ignore'. To make the IT deterministic without a
guessed delay, the sim now waits for fake-claude's own
CONCIV_TEST_ARGV_FILE write (which happens right after the reap
interval installs) before reporting READY, so the test's immediate
SIGKILL can never race the reap installation.

The IT test itself now reads READY via a single stdout 'data' event
and drops the old speculative 300ms settle window + second liveness
check; the whole run is now wrapped in try/finally from before the
first await so a thrown READY-parse or assertion can't leak the
parent-sim or fixture process into later tests. The final reap
timeout widened to 5000ms to cover the slower 1s poll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@omridevk
omridevk force-pushed the fix/fixture-orphan-reap branch from fe60f0b to cfff9ec Compare August 12, 2026 22:23
@omridevk
omridevk merged commit 4c50bad into main Aug 12, 2026
24 checks passed
@omridevk
omridevk deleted the fix/fixture-orphan-reap branch August 12, 2026 22:36
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.

2 participants