From 2ba6cfb7d2055410cd76473234017dd5a9b621a0 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sat, 8 Aug 2026 11:31:29 +0800 Subject: [PATCH 1/2] test(windows): validate crash recovery boundaries --- docs/windows-test-inventory.md | 22 +++++-------------- .../runtime-continuation-crash.test.ts | 10 +++++++-- .../__tests__/runtime-resume-crash.test.ts | 16 +++++++++----- .../sqlite-long-term-memory-crash.test.ts | 1 - .../__tests__/sqlite-runtime-crash.test.ts | 4 +--- 5 files changed, 25 insertions(+), 28 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index ff2aedeb462..1294528185b 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 30 | +| windows-backend-gap | 20 | | portable-candidate | 0 | | platform-contract | 35 | -Total Windows-excluded declarations: **65** +Total Windows-excluded declarations: **55** ## Inventory @@ -32,30 +32,22 @@ Total Windows-excluded declarations: **65** | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` bounds shell output instead of buffering until the global timeout | `process.platform === 'win32'` | | windows-backend-gap | `packages/headless/src/__tests__/sandbox.test.ts` copies portable workspace evidence while skipping process-local special files | `process.platform === 'win32'` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts` two UDS Clients query and control one Agent graph through Session invalidation | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts` production Host recovers Artifact publication and preserves deletes across owner death | `process.platform === 'win32' ? 'POSIX process death gate' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/automation-two-client-uds.test.ts` two UDS Clients share one revision-pinned Automation authority | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/control-endpoint.test.ts` runtime host control endpoint | `process.platform === 'win32'` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/daily-review-two-client-uds.test.ts` two UDS Clients share Daily Review config, generation, and restart recovery | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts` two UDS Clients and a restarted production Host share one Deep Research projection | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/execution-host-queue.test.ts` a killed Host is recovered exactly once before its successor becomes ready | `process.platform === 'win32' ? 'POSIX process death gate' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts` a live Host serves Interactive inspection over its real UDS while retaining exclusive ownership | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts` a live Host serves Interactive inspection over its real endpoint while retaining exclusive ownership | `process.platform === 'win32' ? 'Windows execution Host startup lifecycle' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` an automatic failed liveness check is connection-fatal and Client close stays local | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` bounded election does not launch a Candidate after handshake exhausts the deadline | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` reports one shutdown failure through close and closed while releasing ownership | `process.platform === 'win32'` | | platform-contract | `packages/runtime-host/src/__tests__/host-kernel.test.ts` publishes private POSIX endpoint and registration permissions | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/memory-two-client-uds.test.ts` two UDS clients share one recoverable Memory authority across Host death | `process.platform === 'win32' ? 'POSIX process death gate' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts` OAuth enrollment presents only on the initiating Client over real UDS | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts` OAuth enrollment honors Claude and Codex opt-out flags over real UDS | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts` two UDS Clients and a restarted production Host share one retry-safe Plan authority | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts` invalidates when a real published mutation loses its commit reply | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-resource-process.test.ts` real Host Runtime Resource process lifecycle | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-resource-two-client-uds.test.ts` a Host-owned PTY survives Desktop disconnect and transfers control to TUI | `process.platform === 'win32' ? 'POSIX UDS and shell integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` two UDS Clients share stable Session creation, CAS configuration, and catalog continuity | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` stable Session creation survives response loss and Host restart | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/session-effect-two-client-uds.test.ts` two UDS Clients share one durable Session recap effect | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | -| windows-backend-gap | `packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts` two UDS Clients share exact retryable Session branch and revision authority | `process.platform === 'win32' ? 'POSIX UDS integration' : false` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` two Clients share stable Session creation, CAS configuration, and catalog continuity | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` stable Session creation survives response loss and Host restart | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts` two Clients share exact retryable Session branch and revision authority | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/skill-catalog-two-client-uds.test.ts` two UDS clients converge on one lease-bound Skill catalog authority | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/usage-pricing-client-correlation.test.ts` fails the connection for a canonical response with mismatched ${mismatch.name} | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/usage-pricing-client-correlation.test.ts` rejects local invalid input without poisoning transport and correlates a private canonical copy | `process.platform === 'win32'` | @@ -86,7 +78,5 @@ Total Windows-excluded declarations: **65** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` preserves unknown commit semantics and consumes the completion ticket | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | -| windows-backend-gap | `packages/storage/src/__tests__/sqlite-long-term-memory-crash.test.ts` retains one committed Item and its receipt when killed between COMMIT and return | `process.platform === 'win32'` | -| windows-backend-gap | `packages/storage/src/__tests__/sqlite-runtime-crash.test.ts` SqliteRuntimeStore real-process crash boundaries | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/usage-stores.test.ts` classifies a renamed or replaced live root as a draining persistence failure | `process.platform === 'win32' ? 'Windows does not permit renaming a directory with an open SQLite database' : false` | | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` | diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index d9ef5c4bfee..cc65735c409 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -14,6 +14,7 @@ import { createSqliteAgentRunStore } from '@maka/storage'; import { type RuntimeContinuationFailpoint } from '../agent-run.js'; import { BackendRegistry, SessionManager } from '../session-manager.js'; import { FakeBackend } from '../fake-backend.js'; +import { terminateChildProcessTree } from '../process-tree-terminator.js'; const CRASH_CHILD_ENV = 'MAKA_RUNTIME_CONTINUATION_CRASH_CHILD'; const FAILPOINTS: readonly RuntimeContinuationFailpoint[] = [ @@ -60,6 +61,7 @@ if (process.env[CRASH_CHILD_ENV] === '1') { await store.close?.(); const { manager, + agentRunStore: recoveryAgentRunStore, runtimeEventStore: recoveryRuntimeStore, sessionStore: recoverySessionStore, } = createManager(workspaceRoot); @@ -102,6 +104,8 @@ if (process.env[CRASH_CHILD_ENV] === '1') { `${failpoint} left the continuation non-terminal`, ); } + recoveryAgentRunStore.close?.(); + runStore.close?.(); recoveryRuntimeStore.close(); await recoverySessionStore.close?.(); } @@ -212,6 +216,7 @@ async function suspendCrashChild( function createManager(workspaceRoot: string): { manager: SessionManager; + agentRunStore: ReturnType; runtimeEventStore: ReturnType; sessionStore: ReturnType; } { @@ -231,6 +236,7 @@ function createManager(workspaceRoot: string): { ); let id = 100; return { + agentRunStore: runStore, runtimeEventStore, sessionStore: store, manager: new SessionManager({ @@ -288,11 +294,11 @@ async function crashContinuationAt( await new Promise((resolve) => setTimeout(resolve, 10)); } if (!stdout.includes(`READY:${failpoint}\n`)) { - child.kill('SIGKILL'); + await terminateChildProcessTree(child, 'SIGKILL'); await closed; throw new Error(`${failpoint} child did not reach boundary: ${stderr || stdout}`); } - assert.equal(child.kill('SIGKILL'), true); + assert.equal(await terminateChildProcessTree(child, 'SIGKILL'), true); const [exitCode, signal] = await closed; assert.ok(exitCode !== 0 || signal !== null); } diff --git a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts index 7536a67e181..3632988bb54 100644 --- a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts @@ -15,6 +15,7 @@ import { buildResumePlanFromRuntimeEvents, type RuntimeResumeCommittedPrefix, } from '../runtime-resume.js'; +import { terminateChildProcessTree } from '../process-tree-terminator.js'; const CRASH_CHILD_ENV = 'MAKA_RUNTIME_RESUME_CRASH_CHILD'; @@ -41,7 +42,8 @@ if (process.env[CRASH_CHILD_ENV] === '1') { // Production creates the run header before any RuntimeEvent append. Keep the // crash boundary focused on the child event writer while preserving the // storage identity contract used when the ledger is reopened. - await createSqliteAgentRunStore(workspaceRoot).createRun({ + const runStore = createSqliteAgentRunStore(workspaceRoot); + await runStore.createRun({ runId, invocationId: `invocation-${runId}`, sessionId, @@ -55,6 +57,7 @@ if (process.env[CRASH_CHILD_ENV] === '1') { createdAt: 1, updatedAt: 1, }); + runStore.close?.(); await crashWriterAfterCommit({ workspaceRoot, @@ -86,6 +89,7 @@ if (process.env[CRASH_CHILD_ENV] === '1') { recoveredEvents, `${failpoint.id} projection mutated the durable ledger`, ); + reopened.close(); } } finally { await rm(root, { recursive: true, force: true }); @@ -150,19 +154,19 @@ async function crashWriterAfterCommit(input: { stderr += chunk; }); - const exited = once(child, 'exit') as Promise<[number | null, NodeJS.Signals | null]>; + const closed = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>; const deadline = Date.now() + 10_000; while (!stdout.includes('READY\n') && child.exitCode === null && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (!stdout.includes('READY\n')) { - child.kill('SIGKILL'); - await exited; + await terminateChildProcessTree(child, 'SIGKILL'); + await closed; throw new Error(`crash child did not reach committed boundary: ${stderr || stdout}`); } - assert.equal(child.kill('SIGKILL'), true); - const [exitCode, signal] = await exited; + assert.equal(await terminateChildProcessTree(child, 'SIGKILL'), true); + const [exitCode, signal] = await closed; assert.ok( exitCode !== 0 || signal !== null, 'crash child exited successfully instead of being killed', diff --git a/packages/storage/src/__tests__/sqlite-long-term-memory-crash.test.ts b/packages/storage/src/__tests__/sqlite-long-term-memory-crash.test.ts index 063cd5220a4..d7325a5954c 100644 --- a/packages/storage/src/__tests__/sqlite-long-term-memory-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-long-term-memory-crash.test.ts @@ -18,7 +18,6 @@ if (childMode) { await runCrashChild(childMode); } else { test('retains one committed Item and its receipt when killed between COMMIT and return', { - skip: process.platform === 'win32', timeout: 30_000, }, async () => { const root = await mkdtemp(join(tmpdir(), 'maka-long-term-memory-crash-')); diff --git a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts index 542b98e0d07..59a29c8ae9d 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts @@ -28,9 +28,7 @@ const childMode = process.env.MAKA_SQLITE_CRASH_CHILD; if (childMode) { await runCrashChild(childMode); } else { - describe('SqliteRuntimeStore real-process crash boundaries', { - skip: process.platform === 'win32', - }, () => { + describe('SqliteRuntimeStore real-process crash boundaries', () => { it('rolls back a process killed inside T1', { timeout: 30_000 }, async () => { await withKilledChild('inside_t1', async (store) => { assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), []); From a2a05a0ef048071e90a7d6d0df072fe3d957d6f9 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sat, 8 Aug 2026 12:36:00 +0800 Subject: [PATCH 2/2] fix(runtime): preserve POSIX crash termination --- .../src/__tests__/runtime-continuation-crash.test.ts | 9 +++++++-- .../runtime/src/__tests__/runtime-resume-crash.test.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index cc65735c409..96a2929a0ce 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -294,15 +294,20 @@ async function crashContinuationAt( await new Promise((resolve) => setTimeout(resolve, 10)); } if (!stdout.includes(`READY:${failpoint}\n`)) { - await terminateChildProcessTree(child, 'SIGKILL'); + await killCrashChild(child); await closed; throw new Error(`${failpoint} child did not reach boundary: ${stderr || stdout}`); } - assert.equal(await terminateChildProcessTree(child, 'SIGKILL'), true); + assert.equal(await killCrashChild(child), true); const [exitCode, signal] = await closed; assert.ok(exitCode !== 0 || signal !== null); } +function killCrashChild(child: ReturnType): Promise { + if (process.platform === 'win32') return terminateChildProcessTree(child, 'SIGKILL'); + return Promise.resolve(child.kill('SIGKILL')); +} + function assertPrefix( failpoint: RuntimeContinuationFailpoint, header: AgentRunHeader | undefined, diff --git a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts index 3632988bb54..cd5b30a0d6a 100644 --- a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts @@ -160,12 +160,12 @@ async function crashWriterAfterCommit(input: { await new Promise((resolve) => setTimeout(resolve, 10)); } if (!stdout.includes('READY\n')) { - await terminateChildProcessTree(child, 'SIGKILL'); + await killCrashChild(child); await closed; throw new Error(`crash child did not reach committed boundary: ${stderr || stdout}`); } - assert.equal(await terminateChildProcessTree(child, 'SIGKILL'), true); + assert.equal(await killCrashChild(child), true); const [exitCode, signal] = await closed; assert.ok( exitCode !== 0 || signal !== null, @@ -173,6 +173,11 @@ async function crashWriterAfterCommit(input: { ); } +function killCrashChild(child: ReturnType): Promise { + if (process.platform === 'win32') return terminateChildProcessTree(child, 'SIGKILL'); + return Promise.resolve(child.kill('SIGKILL')); +} + function ledgerEvents(sessionId: string, runId: string): RuntimeEvent[] { const identity = { sessionId,