diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index dd0d425c2..1039c5315 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -187,7 +187,11 @@ export async function createClaudeFixture({ parent = os.tmpdir(), prefix = 'fres return complete.split('\n').filter(Boolean).map((line) => JSON.parse(line)) }, async cleanup() { - await fs.rm(root, { recursive: true, force: true }) + // maxRetries makes fs.rm retry ENOTEMPTY/EBUSY/EPERM: a native CLI that + // is still draining into its fixture (HOME/TMPDIR live under root) can + // re-create a file between the unlink sweep and the final rmdir, and + // with the default maxRetries=0 that race rejects the whole cleanup. + await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }, } } diff --git a/test/integration/providers/claude-permission-contract.mjs b/test/integration/providers/claude-permission-contract.mjs index 2203bbb10..e45341836 100644 --- a/test/integration/providers/claude-permission-contract.mjs +++ b/test/integration/providers/claude-permission-contract.mjs @@ -156,18 +156,26 @@ async function runScenario(name, spec) { let provider let sidecar let sidecarExit + // Resolves when the sidecar (and, after the group kill below, its whole + // process tree) has exited; the finally awaits it before touching the + // fixture so a draining native CLI cannot race fixture.cleanup(). + let exited = Promise.resolve() let failure let settingsLocal = null const written = {} try { provider = await startLocalProvider({ respond }) + // detached puts the sidecar in its own process group: the native CLI it + // spawns is the fixture's concurrent writer, so the force path below has + // to kill the group, not just the sidecar. sidecar = spawn(process.execPath, [path.join(repoRoot, 'crates/freshell-claude-sidecar/index.mjs')], { cwd: path.join(repoRoot, 'crates/freshell-claude-sidecar'), env: fixture.env(provider.url, { CLAUDE_CMD: pinned.nativeCli, FRESHELL_CLAUDE_ONECLI_API_KEY: '1', npm_config_update_notifier: 'false' }), stdio: ['pipe', 'pipe', 'pipe'], + detached: true, }) sidecar.stdin.on('error', () => {}) - const exited = new Promise((resolve) => sidecar.once('exit', (code, signal) => { sidecarExit = { code, signal }; resolve() })) + exited = new Promise((resolve) => sidecar.once('exit', (code, signal) => { sidecarExit = { code, signal }; resolve() })) createInterface({ input: sidecar.stderr }).on('line', () => {}) const send = (message) => sidecar.stdin.write(`${JSON.stringify(message)}\n`) const waiters = [] @@ -223,9 +231,22 @@ async function runScenario(name, spec) { failure = String(error?.message ?? error).slice(0, 1000) log('error', 'scenario.failed', { scenario: name, error: failure }) } finally { - if (sidecar && !sidecarExit) sidecar.kill('SIGKILL') + if (sidecar && !sidecarExit) { + if (process.platform !== 'win32') { + try { process.kill(-sidecar.pid, 'SIGKILL') } catch { /* the group is already gone */ } + } + sidecar.kill('SIGKILL') + } + await exited if (provider) await provider.close() - await fixture.cleanup() + try { + await fixture.cleanup() + } catch (error) { + // A cleanup failure is a reported outcome (fixtureRemoved=false below), + // never an unhandled rejection: the receipt is this harness's only + // contract, and a crash here eats it for every scenario at once. + log('error', 'fixture.cleanup_failed', { scenario: name, error: String(error?.code ?? error?.message ?? error) }) + } } const fixtureRemoved = !(await fs.stat(fixture.root).catch(() => null)) const relabel = (value) => JSON.parse(JSON.stringify(value).replaceAll(fixture.root, '')) diff --git a/test/integration/providers/claude-permission-contract.test.ts b/test/integration/providers/claude-permission-contract.test.ts index 663ba4017..3faabc7d8 100644 --- a/test/integration/providers/claude-permission-contract.test.ts +++ b/test/integration/providers/claude-permission-contract.test.ts @@ -15,6 +15,8 @@ // (PinnedClaudeMissingError): nothing here is ever skipped. import { spawn, type ChildProcess } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -162,3 +164,84 @@ describe(`Claude permission suggestions and plan approvals over the pinned CLI $ expect(empty!.tool.input).toEqual({}) }) }) + +// The harness's contract with this suite is ONE parseable stdout line — +// always. A cloud vitest shard (2026-10-29) lost a whole run because a +// fixture-cleanup rejection (a draining native CLI re-creating files mid +// `fs.rm`) escaped runScenario's finally as an unhandled rejection and +// killed the harness before the receipt was printed. This pins the +// containment: cleanup failures surface through the receipt +// (fixtureRemoved=false), never as a crash that eats it. +describe('Claude permission contract harness survives a fixture-cleanup failure', () => { + it('still prints one receipt line reporting fixtureRemoved=false for every scenario instead of crashing', async () => { + // Deterministic sabotage: once a scenario's own setup is complete (its + // outside/notes.txt marker exists, and nothing writes into the fixture + // root itself after that), make the root unwritable so fixture.cleanup() + // fails with EACCES — the same finally block that raced the live CLI. + const chmodded = new Set() + const watcher = setInterval(() => { + void (async () => { + let entries: string[] + try { + entries = await fs.readdir(os.tmpdir()) + } catch { + return + } + for (const entry of entries) { + if (!entry.startsWith('freshell-claude-perm-') || chmodded.has(entry)) continue + const root = path.join(os.tmpdir(), entry) + const setupDone = await fs.stat(path.join(root, 'outside', 'notes.txt')).then(() => true, () => false) + if (!setupDone) continue + chmodded.add(entry) + await fs.chmod(root, 0o500).catch(() => { + chmodded.delete(entry) + }) + } + })() + }, 25) + + const child = spawn(process.execPath, [HARNESS, REPO_ROOT], { + cwd: REPO_ROOT, + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: 'C.UTF-8' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk }) + child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + try { + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error(`the cleanup-failure harness run timed out; stderr=${stderr.slice(-2000)}`)) + }, 200_000) + timer.unref() + child.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.once('exit', (code, signal) => { + clearTimeout(timer) + resolve({ code, signal }) + }) + }) + expect(chmodded, `sabotaged fixture roots (${[...chmodded].join(', ')}); stderr=${stderr.slice(-2000)}`).toHaveLength(8) + const lines = stdout.trim().split('\n').filter(Boolean) + expect(lines, `the receipt must survive a cleanup failure; stderr tail: ${stderr.slice(-2000)}`).toHaveLength(1) + const sabotagedReceipt = JSON.parse(lines[0]!) as Receipt + expect(Object.keys(sabotagedReceipt.scenarios)).toHaveLength(8) + for (const name of Object.keys(sabotagedReceipt.scenarios)) { + expect(sabotagedReceipt.scenarios[name]!.fixtureRemoved, `${name}: the receipt reports the cleanup failure`).toBe(false) + } + expect(exit.code).toBe(1) + } finally { + clearInterval(watcher) + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + for (const entry of chmodded) { + const root = path.join(os.tmpdir(), entry) + await fs.chmod(root, 0o700).catch(() => {}) + await fs.rm(root, { recursive: true, force: true }).catch(() => {}) + } + } + }, 220_000) +})