Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions structure/infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'` 로 파일마다 자식 프로세스를 띄우지만, **모든
Expand All @@ -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초 잡는 자식을
Expand Down
96 changes: 83 additions & 13 deletions tests/unit/claude-sdk-session.test.ts
Original file line number Diff line number Diff line change
@@ -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: () => {} } });
Expand Down Expand Up @@ -40,6 +41,56 @@ async function fixture(extra: Record<string, unknown> = {}) {
}
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<typeof setTimeout> } | 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<void>(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<typeof setTimeout> | undefined;
try {
await Promise.race([closed, new Promise<never>((_, 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<void>((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' }, () => {});
Expand Down Expand Up @@ -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<typeof trackHeldChild> | 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;
Expand Down
110 changes: 107 additions & 3 deletions tests/unit/manager-lifecycle-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> {
const pending: Promise<void>[] = [];
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<void>;
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,
Expand Down Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -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<void>(resolve => { releaseWrite = resolve; });
const deleteGate = new Promise<void>(resolve => { releaseDelete = resolve; });
const writing = new Promise<void>(resolve => { enteredWrite = resolve; });
const deleting = new Promise<void>(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<typeof writeFile>) => {
if (gated) { enteredWrite(); await writeGate; }
return writeFile(...args);
},
rm: async (...args: Parameters<typeof rm>) => { 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<void>(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<typeof writeFile>) => {
if (failWrites) throw writeFailure;
return writeFile(...args);
},
rm: async () => { await deleteGate; throw 0; },
} });
let waiting: Promise<unknown> | 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 }));
Expand Down
Loading