Files
hapi/cli/vitest.config.ts
T
SSU-WEI HUANGandGitHub 291e7bc40b fix(test): stop runner integration suite from leaking detached process trees (#1515) (#1521)
* fix(test): stop runner integration suite from leaking detached process trees (#1515)

The default CLI test run included runner.integration.test.ts, which spawns
real detached runner/session process trees. A failing, timed-out, or
interrupted test (or a plain runner stop) left those trees alive under
PID 1 — on the Mac this accumulated ~600 Node/Bun/agent processes and
several GiB of RSS over repeated runs.

Test harness changes only; production runner session-preservation
semantics are untouched:

- Exclude runner.integration.test.ts from the default parallel unit-test
  suite; move it into a dedicated serial integration project
  (vitest.integration.config.ts, 'bun run test:integration'). The
  20-session stress test is opt-in via HAPI_RUN_STRESS_TESTS=true.
- Add a test-owned process/session registry (processRegistry.ts): every
  runner, runner-spawned session, and terminal-style child is registered
  immediately after spawn; afterEach/afterAll run two-stage cleanup
  (logical stopRunnerSession first, then bounded process-tree kill),
  followed by a marker sweep for agent grandchildren reparented to PID 1.
- Add a per-run HAPI_TEST_MARKER env stamp + identity/secret env
  neutralization for test children (integrationEnv.ts) so outer HAPI/pi
  session variables never leak into test processes and the final audit
  can recognize test-owned processes by env alone.
- Final suite audit in globalSetup teardown: reap anything still
  carrying the run marker and fail with PID/command diagnostics if
  anything cannot be reaped, before removing the temp home.
- Regression coverage: a deliberately failing test registers a detached
  child and the follow-up audit must find zero test-owned processes.
- CI: replace the dead .env.integration-test step with a dedicated
  integration job running the serial project.

* refactor(test): drop unused killByChildProcess import and child field from registry

* chore(test): raise integration hookTimeout to 60s for slow teardown hosts

* fix(test): fail loudly when the process-table audit cannot scan; assert regression child death

Bot review #1521 findings:
- A failed `ps` scan (unsupported flags, buffer exhaustion, permissions)
  previously returned [] and silently disabled both teardown audit layers.
  It now throws; globalSetup teardown catches the scan error into the
  audit error (temp home is still removed) so the run fails visibly.
- The regression audit test cleaned the leak with the reaper before
  asserting, and force-killed the fresh marked runner. The failing
  test's direct child PID is now asserted dead in afterEach right after
  registry cleanup (before the marker sweep), and the audit test stops
  its own runner gracefully before reaping.

* fix(test): bound the logical cleanup phase so a hung runner cannot stall the hook

Bot review #1521: stopRunnerSession carries the worker's 60s HTTP timeout
(setup.ts raises HAPI_RUNNER_HTTP_TIMEOUT for the stress test), and the
integration hook timeout is also 60s — N sequential stops could exhaust
the hook budget before the process-tree fallback and marker sweep ran,
recreating the very leak this change prevents.

Logical shutdown is now parallel (Promise.allSettled over all tracked
sessions) and the whole phase (stops + PID resolution) races against a
15s budget, so stage-2 tree-kill and the marker sweep always get their
share of the hook window.

* fix(test): bound graceful runner stop in hooks; keep credentials out of audit diagnostics

Bot review #1521 (follow-up):
- stopRunner()'s HTTP stop can burn the worker-wide 60s timeout on a
  hung-but-live runner, starving the marker sweep within the hook budget.
  afterEach/afterAll now race the graceful stop against a 10s bound; a
  runner that does not stop in time is force-reaped by the sweep (it
  carries the run marker) and the next beforeEach's alive-PID guard
  ignores any stale state file.
- The env-bearing ps scan (ps eww) was also used for diagnostics, so the
  first 500 chars of a short-command process could print inherited
  credentials (CLI_API_TOKEN etc.) into teardown error logs. The scan now
  only identifies marked PIDs; command lines are fetched separately
  without 'e', falling back to '(command unavailable)' instead of the
  env dump.

* fix(test): reap runner model-probe orphans before the zero-survivor inspection

Bot review #1521 (Minor): inspect-before-reap. Applying it exposed a real
race: each test's runner legitimately spawns marker-carrying children at
startup (agent acp + agent --list-models model-catalog probes). Stopping
the runner orphans them (ppid 1) with the run marker, so the audit test's
OWN runner polluted the pure inspection with fresh probes spawned after
the failing test's sweep window.

- reapTestOwnedProcesses now re-kills every re-scan iteration instead of
  killing once and only re-scanning, so a process that survived its first
  SIGKILL (mid-exec) or spawned mid-kill is not given a free pass.
- The regression audit test stops its runner, reaps (clearing its own
  legitimate orphan probes), then inspects: anything still marked is a
  genuine survivor the bounded reaper could not remove and fails the
  suite. Killable leaks from the failing test are already asserted dead
  in afterEach before the sweep runs.

* fix(test): strictly bound the marker reaper; make per-test sweep unconditional and verified

Bot review #1521 (follow-up):
- The 10s reap deadline did not bound the awaited per-tree kills: each
  killProcessTreeByPid can wait up to 2s per PID, so several stuck
  processes could still exceed the 60s hook budget. Every process in a
  test-owned tree carries the marker (env is inherited), so tree-walking
  is unnecessary: the reaper now SIGKILLs every marked PID found by each
  scan, fire-and-forget, and re-scans every 250ms — the deadline strictly
  bounds the function.
- The per-test sweep was skipped when the direct-child assertion failed
  first, and its survivors were ignored. afterEach now snapshots the
  regression-child state BEFORE the unconditional sweep, then verifies
  both the registry result and the sweep leftovers.

* fix(test): replace it.fails regression with a direct assertion test

Bot review #1521 (Minor): Vitest applies the it.fails expected-failure
inversion after afterEach, so a broken registry assertion inside the hook
would be masked as an expected failure, and the marker sweep would erase
the evidence before the follow-up audit ran.

The regression is now a normal test that registers a detached child at
spawn time, deliberately performs NO per-test teardown, runs only the
spawn-time registered cleanup, and asserts the child PID is dead. The
afterEach no longer carries the registry-leak assertion (moved into the
test body where it cannot be inverted); the per-test sweep assertion and
the final audit test are unchanged.

* fix(test): bound registry stage-2 tree-kills; require live regression fixture

Bot review #1521 (follow-up):
- Stage-2 killProcessTreeByPid awaits per descendant serially and can
  consume the whole 60s hook for a large/stuck tree. Signals are all
  delivered synchronously (children first) before any waiting, so racing
  the awaits against a 5s budget bounds the phase without skipping any
  kill; waitForAllDead still verifies the outcome.
- The regression test could pass vacuously if its fixture exited during
  the startup delay (the registry exit listener would remove it before
  cleanup). It now asserts the child is alive before running cleanup.

* fix(test): kill registered roots with bare synchronous SIGKILL, no pgrep walk

Bot review #1521 (follow-up): racing the mapped killProcessTreeByPid
calls against a timer does not bound the phase — evaluating the map
invokes each call immediately, and each runs the recursive synchronous
pgrep walk before its first await, which can consume the hook before the
timer, runner stop, or marker sweep run.

Stage-2 now SIGKILLs registered roots directly (fire-and-forget, no
tree walk, no per-PID waits) and waits a bounded 5s for death.
Descendants are reaped by the unconditional marker sweep immediately
afterward — every descendant inherits the run marker, so tree-walking is
unnecessary.

* fix(test): drop duplicate process-death wait in registry cleanup

Bot review #1521 (Minor): the duplicated waitForAllDead delayed the
authoritative marker sweep by another 5s under the exact stuck-process
condition the harness must handle. Keep the single bounded wait; the
afterEach marker sweep remains the guarantee.
2026-08-12 09:29:59 +08:00

37 lines
1.1 KiB
TypeScript

import { defineConfig } from 'vitest/config'
import { resolve } from 'node:path'
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['src/**/*.test.ts'],
exclude: [
// Runner integration tests spawn real detached runner/session
// process trees and must run serially through the dedicated
// integration project (`bun run test:integration`, see
// vitest.integration.config.ts), not inside the parallel
// unit-test suite.
'**/runner.integration.test.ts',
],
globalSetup: './src/test/globalSetup.ts',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/**',
'dist/**',
'**/*.d.ts',
'**/*.config.*',
'**/mockData/**',
],
},
},
resolve: {
alias: {
'@': resolve('./src'),
},
},
})