diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3320ad7fc..e1a3e1cf7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -310,7 +310,7 @@ jobs: run: node scripts/ensure-native-modules.cjs - name: Run Windows-relevant unit tests - run: npx tsx --import ./tests/setup/test-home.ts --experimental-test-module-mocks --test tests/unit/windows-service-esm.test.ts tests/unit/service-windows-branch.test.ts tests/unit/windows-service-lifecycle-honesty.test.ts tests/unit/windows-installer-shims.test.ts tests/unit/windows-spawn-primitives.test.ts tests/unit/manager-browser-open.test.ts tests/unit/eaddrinuse-diagnostics-contract.test.ts tests/unit/tui-env-flags.test.ts tests/unit/windows-launch-spec.test.ts tests/unit/service-lifecycle-cli.test.ts + run: npx tsx --import ./tests/setup/test-home.ts --experimental-test-module-mocks --test tests/unit/windows-service-esm.test.ts tests/unit/service-windows-branch.test.ts tests/unit/windows-service-lifecycle-honesty.test.ts tests/unit/windows-installer-shims.test.ts tests/unit/windows-spawn-primitives.test.ts tests/unit/manager-browser-open.test.ts tests/unit/eaddrinuse-diagnostics-contract.test.ts tests/unit/tui-env-flags.test.ts tests/unit/windows-launch-spec.test.ts tests/unit/service-lifecycle-cli.test.ts tests/unit/claude-sdk-windows-launch.test.ts tests/unit/claude-sdk-session.test.ts tests/unit/claude-sdk-control.test.ts tests/unit/claude-sdk-core-hardening.test.ts tests/unit/claude-sdk-deferred-core.test.ts # The check name is the job `name:` below and is load-bearing: the branch # ruleset and the publish gate both match on the literal string diff --git a/structure/infra.md b/structure/infra.md index e660db7e0..a7ce5cb04 100644 --- a/structure/infra.md +++ b/structure/infra.md @@ -131,6 +131,13 @@ the cmd shim or direct Node entry point instead. ### Direct smoke utilities +`test.yml`의 `windows-unit`은 기존 Windows 서비스·설치·실행 검사에 더해 +`claude-sdk-windows-launch`, `claude-sdk-session`, `claude-sdk-control`, +`claude-sdk-core-hardening`, `claude-sdk-deferred-core`의 명시적 테스트 파일을 실행한다. +SDK query와 프로세스 경계는 fixture로 격리하며, macOS에서 같은 파일을 실행한 결과는 +실제 Windows job의 성공 증거를 대신하지 않는다. 기존 10개 파일·Node 22·aggregate +검증과 code-change skip 거부 조건은 유지한다. + #### 테스트 격리는 프로세스 단위지 DB 단위가 아니다 `tests/run.mts` 는 `isolation:'process'` 로 파일마다 자식 프로세스를 띄우지만, **모든 @@ -143,8 +150,9 @@ the cmd shim or direct Node entry point instead. - `src/core/db.ts` 의 `journal_mode = WAL` + `busy_timeout = 5000`. WAL 에서 리더는 라이터를 막지 않으므로 평범한 동시 접근은 잠금이 되지 않는다. - 파일별 opt-in 격리 `tests/setup/isolated-home.ts`. 이건 좁고 구체적인 위험 - — `isAlive` 를 스텁한 전역 파괴적 sweep 이 다른 프로세스의 행을 지우는 경우 — - 에만 필요하며, DB 를 여는 파일 전부가 아니라 그런 sweep 을 하는 파일만 넣는다. + — `isAlive` 를 스텁한 전역 파괴적 sweep 이 다른 프로세스의 행을 지우거나, + session singleton을 sentinel로 교체·복원하는 검사가 다른 파일을 덮는 경우 — + 에만 필요하며, DB 를 여는 파일 전부가 아니라 이런 공유 상태 위험이 있는 파일에 넣는다. 로그에서 `database is locked` 를 봤다고 곧장 경합이라고 결론내지 말 것. `tests/unit/memory-search-provider.test.ts` 는 `BEGIN IMMEDIATE` 를 4.2초 잡는 자식을 diff --git a/tests/unit/claude-sdk-session.test.ts b/tests/unit/claude-sdk-session.test.ts index 1e42d1b9b..4862a804a 100644 --- a/tests/unit/claude-sdk-session.test.ts +++ b/tests/unit/claude-sdk-session.test.ts @@ -1,5 +1,6 @@ -import test, { mock } from 'node:test'; +import test, { mock, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import { createClaudeProcessOwner } from '../../src/agent/runtime/claude-sdk-process.ts'; // Session recording is injected below; do not initialize a real shared SQLite. mock.module('../../src/trace/activity-journal.js', { namedExports: { appendActivityBody: () => null, markActivityFailure: () => {} } }); @@ -40,6 +41,56 @@ async function fixture(extra: Record = {}) { } const result = (text: unknown = 'answer') => ({ type: 'result', subtype: 'success', is_error: false, result: text, session_id: 'native', usage: { input_tokens: 3, output_tokens: 4 } }); +const heldChildOptions = () => ({ command: process.execPath, + args: ['-e', "process.stdout.write('SDK_CHILD_READY\\n'); process.stdin.on('data', data => { if (data.toString().trim() === 'PING') process.stdout.write('SDK_CHILD_PONG\\n'); }); setInterval(()=>{},1000);"], + env: process.env, signal: new AbortController().signal }); + +function trackHeldChild(t: TestContext, child: ChildProcessWithoutNullStreams) { + let buffer = '', bytes = 0, failure: Error | undefined; + const lines: string[] = []; + let pending: { expected: string; resolve(): void; reject(error: Error): void; timer: ReturnType } | undefined; + const pump = () => { + if (!pending || (!failure && lines.length === 0)) return; + const waiting = pending; pending = undefined; clearTimeout(waiting.timer); + const line = lines.shift(); + if (failure) waiting.reject(failure); + else if (line !== waiting.expected) waiting.reject(new Error(`Unexpected held-child marker: ${line}`)); + else waiting.resolve(); + }; + child.stdout.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > 4096) { failure = new Error('Held-child output exceeded bound'); pump(); return; } + buffer += chunk.toString(); + while (buffer.includes('\n')) { + const at = buffer.indexOf('\n'); lines.push(buffer.slice(0, at)); buffer = buffer.slice(at + 1); + } + pump(); + }); + child.once('error', error => { failure = error; pump(); }); + const closed = new Promise(resolve => child.once('close', () => { + failure ??= new Error('Held child closed before requested marker'); pump(); resolve(); + })); + t.after(async () => { + if (pending) { clearTimeout(pending.timer); pending = undefined; } + // This fixed Node fixture creates no descendants. Fallback cleanup must + // not let a failed owner assertion strand it; it never makes the test pass. + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + let timer: ReturnType | undefined; + try { + await Promise.race([closed, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Held child cleanup did not close')), 10_000); + })]); + } finally { clearTimeout(timer); } + }); + return { closed, expectLine(expected: string) { + assert.equal(pending, undefined, 'one held-child observation at a time'); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending = undefined; reject(new Error('Held-child marker deadline')); }, 10_000); + pending = { expected, resolve, reject, timer }; pump(); + }); + } }; +} + test('one reader and query serve sequential turns with captured jaw identity', async t => { const f = await fixture(); t.after(() => f.session.close()); const first = f.session.send({ text: 'one' }, () => {}); @@ -123,22 +174,41 @@ test('custom child drains stderr beyond pipe capacity and observes actual exit', child.stdout.resume(); await owner.wait(); assert.equal(child.exitCode, 0); assert.equal(owner.activeCount, 0); assert.equal(owner.stderrBytes, 1024 * 1024); }); -test('custom child termination waits for exit, not killed flag', async () => { +test('custom child termination waits for exit, not killed flag', { timeout: 15_000 }, async t => { const owner = createClaudeProcessOwner(); - const child = owner.spawn({ command: process.execPath, args: ['-e', 'setTimeout(()=>process.exit(23),2000)'], - env: process.env, signal: new AbortController().signal }); - child.stdout.resume(); owner.terminate(); await owner.wait(); - assert.equal(owner.activeCount, 0); assert.notEqual(child.exitCode, 23); assert.ok(child.exitCode !== null || child.signalCode !== null); -}); -test('query factory error after child spawn retires only its created child', async () => { - let child; - await assert.rejects(fixture({ queryFactory: ({ options }) => { - child = options.spawnClaudeCodeProcess({ command: process.execPath, args: ['-e', 'setTimeout(()=>process.exit(23),2000)'], - env: process.env, signal: new AbortController().signal }); + const child = owner.spawn(heldChildOptions()); + const observed = trackHeldChild(t, child); + await observed.expectLine('SDK_CHILD_READY'); + owner.terminate(); + assert.equal(owner.activeCount, 1, 'termination request is not observed process close'); + await owner.wait(); + assert.equal(owner.activeCount, 0); + assert.ok(child.exitCode !== null || child.signalCode !== null); + await observed.closed; +}); +test('query factory error after child spawn retires only its created child', { timeout: 15_000 }, async t => { + const foreignOwner = createClaudeProcessOwner(); + const foreign = foreignOwner.spawn(heldChildOptions()); + const foreignObserved = trackHeldChild(t, foreign); + await foreignObserved.expectLine('SDK_CHILD_READY'); + let child: ChildProcessWithoutNullStreams | undefined; + let observed: ReturnType | undefined; + await assert.rejects(fixture({ + // Real process cleanup uses production's 5000ms default, not the 100ms + // budget for in-memory fake readers. Windows has a real 2000ms grace. + closeTimeoutMs: undefined, + queryFactory: ({ options }) => { + child = options.spawnClaudeCodeProcess(heldChildOptions()); + observed = trackHeldChild(t, child); throw new Error('factory failure'); } }), /factory failure/); + assert.ok(child); assert.ok(observed); assert.ok(child.exitCode !== null || child.signalCode !== null); - assert.notEqual(child.exitCode, 23); + await observed.closed; + foreign.stdin.write('PING\n'); + await foreignObserved.expectLine('SDK_CHILD_PONG'); + assert.equal(foreign.exitCode, null); assert.equal(foreign.signalCode, null); + assert.equal(foreignOwner.activeCount, 1, 'failed factory owns no sibling process'); }); test('turn-start observer can revoke ownership before input offer', async () => { let current = true; diff --git a/tests/unit/manager-lifecycle-persistence.test.ts b/tests/unit/manager-lifecycle-persistence.test.ts index 2e42347f1..fc3bfb5fa 100644 --- a/tests/unit/manager-lifecycle-persistence.test.ts +++ b/tests/unit/manager-lifecycle-persistence.test.ts @@ -1,8 +1,10 @@ -import test from 'node:test'; +import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; +import { setImmediate as checkpoint } from 'node:timers/promises'; import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DashboardLifecycleManager } from '../../src/manager/lifecycle.js'; @@ -36,6 +38,31 @@ function tmpRoot(): string { return mkdtempSync(join(tmpdir(), 'jaw-persist-test-')); } +// Synchronous prune methods intentionally schedule persistence. Observe the +// public promises, not the store's caught internal queue, before removing roots. +function capturePersistence(t: TestContext): () => Promise { + const pending: Promise[] = []; + for (const method of ['save', 'deleteMarker'] as const) { + const original = LifecycleStore.prototype[method]; + t.mock.method(LifecycleStore.prototype, method, function (this: LifecycleStore, ...args: unknown[]) { + const operation = Reflect.apply(original, this, args) as Promise; + pending.push(operation); + return operation; + }); + } + return async () => { + let seen = 0; + const failures: unknown[] = []; + while (seen < pending.length) { + const batch = pending.slice(seen); seen = pending.length; + for (const result of await Promise.allSettled(batch)) { + if (result.status === 'rejected') failures.push(result.reason); + } + } + if (failures.length) throw new AggregateError(failures, 'Fixture persistence failed'); + }; +} + function makeOnline(port: number): DashboardInstance { return { port, @@ -390,7 +417,8 @@ test('stopAll on hydrated detached registry sends SIGTERM and prunes persisted s test('activeEntry prunes detached entry whose PID is gone', async (t) => { const root = tmpRoot(); - t.after(() => rmSync(root, { recursive: true, force: true })); + const drain = capturePersistence(t); + t.after(async () => { try { await drain(); } finally { rmSync(root, { recursive: true, force: true }); } }); const home = join(root, '.cli-jaw-3458'); await plantPersistedEntry(root, 3458, 99030, 'l'.repeat(32), home); let alive = true; @@ -413,7 +441,8 @@ test('activeEntry prunes detached entry whose PID is gone', async (t) => { test('decorateScanResult prunes detached entry when scan reports offline', async (t) => { const root = tmpRoot(); - t.after(() => rmSync(root, { recursive: true, force: true })); + const drain = capturePersistence(t); + t.after(async () => { try { await drain(); } finally { rmSync(root, { recursive: true, force: true }); } }); const home = join(root, '.cli-jaw-3458'); await plantPersistedEntry(root, 3458, 99040, 'm'.repeat(32), home); const manager = new DashboardLifecycleManager({ @@ -433,6 +462,81 @@ test('decorateScanResult prunes detached entry when scan reports offline', async assert.equal(result.instances[0]?.lifecycle?.canStart, true); }); +for (const first of ['write', 'delete'] as const) test(`fixture drain waits for both real operations when ${first} finishes first`, async t => { + const root = tmpRoot(), home = join(root, 'home'); + const drain = capturePersistence(t); + let releaseWrite!: () => void, releaseDelete!: () => void; + let enteredWrite!: () => void, enteredDelete!: () => void, gated = false; + const writeGate = new Promise(resolve => { releaseWrite = resolve; }); + const deleteGate = new Promise(resolve => { releaseDelete = resolve; }); + const writing = new Promise(resolve => { enteredWrite = resolve; }); + const deleting = new Promise(resolve => { enteredDelete = resolve; }); + t.after(async () => { + releaseWrite(); releaseDelete(); + try { await drain(); } finally { rmSync(root, { recursive: true, force: true }); } + }); + const store = new LifecycleStore({ managerPort: MGR, storageRoot: root, fsImpl: { + mkdir, readFile, rename, existsSync, + writeFile: async (...args: Parameters) => { + if (gated) { enteredWrite(); await writeGate; } + return writeFile(...args); + }, + rm: async (...args: Parameters) => { enteredDelete(); await deleteGate; return rm(...args); }, + } }); + await store.writeMarker(home, { schemaVersion: 1, managedBy: 'cli-jaw-dashboard', managerPort: MGR, + port: 3458, pid: 99030, token: 'fixture-token', startedAt: '2026-09-07T00:00:00Z' }); + gated = true; + const saved = store.save([]), removed = store.deleteMarker(home); + let drained = false; + const waiting = drain().then(() => { drained = true; }); + await Promise.all([writing, deleting]); + assert.equal(drained, false); + if (first === 'write') { releaseWrite(); await saved; } + else { releaseDelete(); await removed; } + await checkpoint(); // Let a prematurely completed drain publish its result. + assert.equal(drained, false, 'the other real filesystem operation is still pending'); + assert.equal(existsSync(root), true); + releaseWrite(); releaseDelete(); await waiting; + assert.equal(drained, true); + assert.deepEqual((await store.load()).entries, []); + assert.equal(existsSync(join(home, '.dashboard-managed.json')), false); +}); + +test('fixture drain collects failures only after every public persistence operation settles', async t => { + const root = tmpRoot(), home = join(root, 'home'); + const drain = capturePersistence(t); + let failWrites = false, releaseDelete!: () => void; + const deleteGate = new Promise(resolve => { releaseDelete = resolve; }); + const writeFailure = new Error('fixture write failed'); + const store = new LifecycleStore({ managerPort: MGR, storageRoot: root, fsImpl: { + mkdir, readFile, rename, existsSync, + writeFile: async (...args: Parameters) => { + if (failWrites) throw writeFailure; + return writeFile(...args); + }, + rm: async () => { await deleteGate; throw 0; }, + } }); + let waiting: Promise | undefined; + t.after(async () => { + releaseDelete(); + try { await waiting; } finally { rmSync(root, { recursive: true, force: true }); } + }); + await store.writeMarker(home, { schemaVersion: 1, managedBy: 'cli-jaw-dashboard', managerPort: MGR, + port: 3458, pid: 99030, token: 'fixture-token', startedAt: '2026-09-07T00:00:00Z' }); + failWrites = true; + const saved = store.save([]); + void store.deleteMarker(home); // drain attaches to the exact returned promise. + let settled = false; + waiting = drain().then(() => { settled = true; return null; }, error => { settled = true; return error; }); + await assert.rejects(saved, /fixture write failed/); + await checkpoint(); + assert.equal(settled, false, 'one failure cannot skip the other pending operation'); + releaseDelete(); + const error = await waiting; + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors, [writeFailure, 0]); +}); + test('concurrent start(port) calls only spawn one child (per-port lock)', async (t) => { const root = tmpRoot(); t.after(() => rmSync(root, { recursive: true, force: true }));