@tanstack/ai-sandbox 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/esm/agents-file.d.ts +15 -0
- package/dist/esm/agents-file.js +47 -1
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/bootstrap.js +2 -1
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/contracts.d.ts +12 -8
- package/dist/esm/git-exec.js +2 -0
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/journal-sweep.js +3 -2
- package/dist/esm/journal-sweep.js.map +1 -1
- package/dist/esm/middleware.js +5 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/reap.js +2 -1
- package/dist/esm/reap.js.map +1 -1
- package/dist/esm/sandbox.d.ts +2 -0
- package/dist/esm/sandbox.js +19 -2
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/shell.js +5 -4
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.js +4 -2
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/testkit/journal-conformance.js +6 -3
- package/dist/esm/testkit/journal-conformance.js.map +1 -1
- package/dist/esm/testkit/reaper-conformance.js +13 -8
- package/dist/esm/testkit/reaper-conformance.js.map +1 -1
- package/dist/esm/testkit/takeover-conformance.js +4 -3
- package/dist/esm/testkit/takeover-conformance.js.map +1 -1
- package/dist/esm/tool-bridge.js +1 -1
- package/dist/esm/tool-bridge.js.map +1 -1
- package/package.json +14 -4
- package/skills/ai-sandbox/SKILL.md +13 -8
- package/src/agents-file.ts +65 -0
- package/src/bootstrap.ts +5 -1
- package/src/contracts.ts +12 -8
- package/src/git-exec.ts +7 -0
- package/src/index.ts +2 -0
- package/src/middleware.ts +4 -1
- package/src/sandbox.ts +23 -0
- package/src/tool-bridge.ts +3 -1
package/dist/esm/shell.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shell.js","names":[],"sources":["../../src/shell.ts"],"sourcesContent":["/**\n * Internal persistent bootstrap shell.\n *\n * Spawns a single `sh` process via {@link SandboxHandle.process.spawn} and\n * drives it over stdin/stdout with a sentinel-echo protocol. Commands run\n * sequentially inside the same shell so `cd`, exported variables, etc. persist\n * across calls — exactly the exec model the bootstrap setup plan needs.\n *\n * Providers WITHOUT a writable host→process stdin (`capabilities.writableStdin\n * === false`, e.g. Cloudflare / Daytona / Vercel) can't be driven over stdin, so\n * {@link createBootstrapShell} transparently falls back to an exec-backed shell\n * ({@link createExecBootstrapShell}) that threads `cwd`/env across `exec` calls\n * to reproduce the same persistent-shell semantics.\n *\n * This module is internal-only and must NOT be re-exported from\n * `packages/ai-sandbox/src/index.ts`.\n */\nimport type { SandboxHandle } from './contracts'\n\n/**\n * Parse the output of `export -p` (or `declare -x`) into a plain env map.\n * Shared by the stdin shell's `forkState` and the exec-backed shell.\n */\nfunction parseExports(output: string): Record<string, string> {\n const env: Record<string, string> = {}\n for (const line of output.split('\\n')) {\n const trimmed = line.trim()\n // Match `declare -x KEY=...` or `export KEY=...` forms.\n const match =\n /^(?:declare\\s+-x\\s+|export\\s+)([A-Za-z_][A-Za-z0-9_]*)(?:=\"((?:[^\"\\\\]|\\\\.)*)\")?$/.exec(\n trimmed,\n )\n if (match === null) continue\n const key = match[1]\n if (key === undefined) continue\n // Value may be absent for exported-but-unset vars; skip those.\n const raw = match[2]\n if (raw === undefined) continue\n // Unescape backslash-escaped chars inside double quotes.\n env[key] = raw.replace(/\\\\(.)/g, '$1')\n }\n return env\n}\n\n/** The surface the bootstrap engine uses. */\nexport interface BootstrapShell {\n /** Run a shell command and capture its stdout + exit code. */\n run: (command: string) => Promise<{ exitCode: number; stdout: string }>\n /**\n * Snapshot the shell's current working directory and exported environment.\n * Used to fork parallel exec calls that inherit the serial shell's state.\n */\n forkState: () => Promise<{ cwd: string; env: Record<string, string> }>\n /** End the shell session (closes stdin, kills the process). */\n dispose: () => Promise<void>\n}\n\n/** Options for {@link createBootstrapShell}. */\nexport interface BootstrapShellOptions {\n /** Working directory to start the shell in (passed as ProcessOptions.cwd). */\n cwd?: string\n /**\n * Belt-and-braces deadline for a single `run()` to see its sentinel. The\n * primary termination condition is the stdout stream ending (see\n * {@link createBootstrapShell}); this only catches a shell that is alive,\n * silent, and never going to answer. Generous by default because setup steps\n * legitimately run for a long time (`npm install`, image pulls).\n */\n commandTimeoutMs?: number\n}\n\n/** Default {@link BootstrapShellOptions.commandTimeoutMs} — 30 minutes. */\nconst DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1000\n\n/** Race marker for the per-command deadline. A symbol cannot collide with a\n * literal stdout line (a line of text `'timeout'` would). */\nconst TIMED_OUT = Symbol('bootstrap-shell-timeout')\n\n/**\n * Spawn one `sh` process and return a {@link BootstrapShell} that drives it\n * via the sentinel-echo protocol.\n *\n * Protocol: for each `run(cmd)` call, we write\n * `<cmd>; printf \"\\n__BSSH_<N>__ $?\\n\"` to stdin, then read stdout lines\n * until we see a line matching `__BSSH_<N>__ <exitCode>`. Everything before\n * that line is the command's stdout; the trailing integer is the exit code.\n * The counter `N` is a module-level monotonic integer — no Date.now / random.\n */\nexport async function createBootstrapShell(\n handle: SandboxHandle,\n opts: BootstrapShellOptions = {},\n): Promise<BootstrapShell> {\n // Providers without a writable host→process stdin can't run the sentinel-echo\n // protocol below (it feeds commands over stdin), so use the exec-backed shell.\n if (!handle.capabilities.writableStdin) {\n return createExecBootstrapShell(handle, opts)\n }\n const proc = await handle.process.spawn('sh', { cwd: opts.cwd })\n\n /*\n * We need to read stdout lines across multiple run() calls while keeping\n * the iterator open. Buffer chunks into lines manually.\n */\n const lineBuffer: Array<string> = []\n // `null` means \"the stdout stream ended\" — distinct from an empty line, which\n // `sh` emits constantly. Collapsing the two is what let a dead shell feed an\n // infinite supply of `''` into a sentinel-hunting loop.\n let pending: Array<(line: string | null) => void> = []\n let streamDone = false\n let streamError: unknown\n\n /** Feed the stdout async-iterable into the shared line queue. */\n async function drainStdout(): Promise<void> {\n let partial = ''\n try {\n for await (const chunk of proc.stdout) {\n partial += chunk\n const parts = partial.split('\\n')\n // All but the last element are complete lines.\n for (let i = 0; i < parts.length - 1; i++) {\n const line = parts[i] as string\n const resolver = pending.shift()\n if (resolver !== undefined) {\n resolver(line)\n } else {\n lineBuffer.push(line)\n }\n }\n partial = parts[parts.length - 1] as string\n }\n // Flush any trailing partial line.\n if (partial.length > 0) {\n const line = partial\n const resolver = pending.shift()\n if (resolver !== undefined) {\n resolver(line)\n } else {\n lineBuffer.push(line)\n }\n }\n } catch (error) {\n // A throw while iterating stdout (transport reset, provider stream error)\n // must NOT leave waiters parked on a promise nobody resolves. Record it so\n // `run()` can name the cause, and fall through to the `finally` that\n // unblocks everyone.\n streamError = error\n } finally {\n streamDone = true\n // Unblock any remaining waiters with the end-of-stream marker.\n for (const resolver of pending) {\n resolver(null)\n }\n pending = []\n }\n }\n\n /*\n * Start draining immediately; do NOT await — runs concurrently. The `try/catch`\n * inside `drainStdout` means this promise never rejects, so there is no\n * unhandled rejection while nothing is awaiting it, and `dispose()` can await\n * it unconditionally.\n */\n const drainPromise = drainStdout()\n\n /** Read the next line from the shared queue, or `null` once stdout ended. */\n function nextLine(): Promise<string | null> {\n const buffered = lineBuffer.shift()\n if (buffered !== undefined) {\n return Promise.resolve(buffered)\n }\n if (streamDone) {\n return Promise.resolve(null)\n }\n return new Promise<string | null>((resolve) => {\n pending.push(resolve)\n })\n }\n\n let counter = 0\n\n async function run(\n command: string,\n ): Promise<{ exitCode: number; stdout: string }> {\n const id = counter\n counter += 1\n const sentinel = `__BSSH_${id}__`\n\n // Write the command followed by a sentinel printf to stdin. Merge the\n // command's stderr into stdout (`{ … ; } 2>&1`) so a failing setup step's\n // error text is captured and can be surfaced — otherwise only the exit code\n // is visible. `$?` after the group is still the command's own exit code.\n await proc.stdin.write(\n `{ ${command} ; } 2>&1; printf \"\\\\n${sentinel} $?\\\\n\"\\n`,\n )\n\n const outputLines: Array<string> = []\n\n /*\n * Read lines until we find the sentinel — but the wait MUST be able to end\n * without one. `sh` can exit before it ever prints the sentinel (a missing\n * binary, an OOM kill, the provider reaping the sandbox mid-bootstrap), and\n * a loop whose only exit is the sentinel then spins on end-of-stream\n * forever, pushing into `outputLines` until the host process dies of memory\n * exhaustion. Two independent terminators:\n * 1. `nextLine()` yields `null` the moment stdout is done — the real fix,\n * it fires as soon as the shell is gone.\n * 2. A deadline, for a shell that stays alive and simply never answers.\n */\n let timer: ReturnType<typeof setTimeout> | undefined\n const deadline = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(\n () => resolve(TIMED_OUT),\n opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,\n )\n })\n\n try {\n for (;;) {\n const line = await Promise.race([nextLine(), deadline])\n if (line === TIMED_OUT) {\n throw new Error(\n `bootstrap shell: timed out after ${\n opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS\n }ms waiting for the sentinel of command: ${command}`,\n )\n }\n if (line === null) {\n throw new Error(\n `bootstrap shell: the shell exited before the sentinel was printed; command: ${command}`,\n streamError === undefined ? undefined : { cause: streamError },\n )\n }\n if (line.startsWith(`${sentinel} `)) {\n const codeStr = line.slice(sentinel.length + 1).trim()\n const exitCode = parseInt(codeStr, 10)\n return {\n exitCode: Number.isFinite(exitCode) ? exitCode : 1,\n stdout: outputLines.join('\\n'),\n }\n }\n outputLines.push(line)\n }\n } finally {\n clearTimeout(timer)\n }\n }\n\n async function forkState(): Promise<{\n cwd: string\n env: Record<string, string>\n }> {\n const pwdResult = await run('pwd')\n const cwd = pwdResult.stdout.trim()\n\n const exportResult = await run('export -p')\n return { cwd, env: parseExports(exportResult.stdout) }\n }\n\n async function dispose(): Promise<void> {\n await proc.stdin.end()\n await proc.kill()\n // Drain the stdout iterator to completion so there are no dangling promises.\n await drainPromise\n }\n\n return { run, forkState, dispose }\n}\n\n/**\n * Exec-backed {@link BootstrapShell} for providers WITHOUT a writable stdin.\n *\n * There is no persistent process to feed commands into, so persistence of `cd`\n * and exported variables is reproduced by threading state across discrete\n * {@link SandboxHandle.process.exec} calls: each `run()` executes the command in\n * the tracked cwd+env, then captures the resulting `pwd` and `export -p` (via\n * marker lines) so the NEXT command inherits any directory change or exports.\n */\nexport function createExecBootstrapShell(\n handle: SandboxHandle,\n opts: BootstrapShellOptions = {},\n): BootstrapShell {\n let cwd = opts.cwd ?? '/'\n let env: Record<string, string> = {}\n let counter = 0\n\n async function run(\n command: string,\n ): Promise<{ exitCode: number; stdout: string }> {\n const id = counter\n counter += 1\n const sentinel = `__BSSH_${id}__`\n\n // Run the command, then emit its exit code, cwd and exported env behind\n // marker lines so we can recover state even when the command itself fails\n // (no `set -e`). Capturing `$?` immediately after the command keeps the\n // reported exit code the command's own, not the trailing introspection's.\n const script = [\n command,\n `__bssh_rc=$?`,\n `printf '\\\\n%s %s\\\\n' '${sentinel}' \"$__bssh_rc\"`,\n `printf '%s\\\\n' '${sentinel}_CWD'`,\n `pwd`,\n `printf '%s\\\\n' '${sentinel}_ENV'`,\n `export -p`,\n ].join('\\n')\n\n const res = await handle.process.exec(script, { cwd, env })\n\n const cmdOut: Array<string> = []\n const cwdLines: Array<string> = []\n const envLines: Array<string> = []\n let exitCode = res.exitCode\n let phase: 'cmd' | 'await-cwd' | 'cwd' | 'env' = 'cmd'\n\n for (const line of res.stdout.split('\\n')) {\n if (phase === 'cmd') {\n if (line.startsWith(`${sentinel} `)) {\n const parsed = parseInt(line.slice(sentinel.length + 1).trim(), 10)\n exitCode = Number.isFinite(parsed) ? parsed : res.exitCode\n phase = 'await-cwd'\n continue\n }\n cmdOut.push(line)\n } else if (phase === 'await-cwd') {\n if (line === `${sentinel}_CWD`) phase = 'cwd'\n } else if (phase === 'cwd') {\n if (line === `${sentinel}_ENV`) phase = 'env'\n else cwdLines.push(line)\n } else {\n envLines.push(line)\n }\n }\n\n // `pwd` prints a single line; the last non-empty one is the new cwd.\n const newCwd = cwdLines\n .map((l) => l.trim())\n .filter(Boolean)\n .pop()\n if (newCwd) cwd = newCwd\n const newEnv = parseExports(envLines.join('\\n'))\n if (Object.keys(newEnv).length > 0) env = newEnv\n\n return { exitCode, stdout: cmdOut.join('\\n') }\n }\n\n function forkState(): Promise<{ cwd: string; env: Record<string, string> }> {\n return Promise.resolve({ cwd, env: { ...env } })\n }\n\n function dispose(): Promise<void> {\n // Nothing to tear down — there is no persistent process.\n return Promise.resolve()\n }\n\n return { run, forkState, dispose }\n}\n"],"mappings":";;;;;AAuBA,SAAS,aAAa,QAAwC;CAC5D,MAAM,MAA8B,CAAC;CACrC,KAAK,MAAM,QAAQ,OAAO,MAAM,IAAI,GAAG;EACrC,MAAM,UAAU,KAAK,KAAK;EAE1B,MAAM,QACJ,mFAAmF,KACjF,OACF;EACF,IAAI,UAAU,MAAM;EACpB,MAAM,MAAM,MAAM;EAClB,IAAI,QAAQ,KAAA,GAAW;EAEvB,MAAM,MAAM,MAAM;EAClB,IAAI,QAAQ,KAAA,GAAW;EAEvB,IAAI,OAAO,IAAI,QAAQ,UAAU,IAAI;CACvC;CACA,OAAO;AACT;;AA8BA,IAAM,6BAA6B,OAAU;;;AAI7C,IAAM,YAAY,OAAO,yBAAyB;;;;;;;;;;;AAYlD,eAAsB,qBACpB,QACA,OAA8B,CAAC,GACN;CAGzB,IAAI,CAAC,OAAO,aAAa,eACvB,OAAO,yBAAyB,QAAQ,IAAI;CAE9C,MAAM,OAAO,MAAM,OAAO,QAAQ,MAAM,MAAM,EAAE,KAAK,KAAK,IAAI,CAAC;CAM/D,MAAM,aAA4B,CAAC;CAInC,IAAI,UAAgD,CAAC;CACrD,IAAI,aAAa;CACjB,IAAI;;CAGJ,eAAe,cAA6B;EAC1C,IAAI,UAAU;EACd,IAAI;GACF,WAAW,MAAM,SAAS,KAAK,QAAQ;IACrC,WAAW;IACX,MAAM,QAAQ,QAAQ,MAAM,IAAI;IAEhC,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,SAAS,GAAG,KAAK;KACzC,MAAM,OAAO,MAAM;KACnB,MAAM,WAAW,QAAQ,MAAM;KAC/B,IAAI,aAAa,KAAA,GACf,SAAS,IAAI;UAEb,WAAW,KAAK,IAAI;IAExB;IACA,UAAU,MAAM,MAAM,SAAS;GACjC;GAEA,IAAI,QAAQ,SAAS,GAAG;IACtB,MAAM,OAAO;IACb,MAAM,WAAW,QAAQ,MAAM;IAC/B,IAAI,aAAa,KAAA,GACf,SAAS,IAAI;SAEb,WAAW,KAAK,IAAI;GAExB;EACF,SAAS,OAAO;GAKd,cAAc;EAChB,UAAU;GACR,aAAa;GAEb,KAAK,MAAM,YAAY,SACrB,SAAS,IAAI;GAEf,UAAU,CAAC;EACb;CACF;CAQA,MAAM,eAAe,YAAY;;CAGjC,SAAS,WAAmC;EAC1C,MAAM,WAAW,WAAW,MAAM;EAClC,IAAI,aAAa,KAAA,GACf,OAAO,QAAQ,QAAQ,QAAQ;EAEjC,IAAI,YACF,OAAO,QAAQ,QAAQ,IAAI;EAE7B,OAAO,IAAI,SAAwB,YAAY;GAC7C,QAAQ,KAAK,OAAO;EACtB,CAAC;CACH;CAEA,IAAI,UAAU;CAEd,eAAe,IACb,SAC+C;EAC/C,MAAM,KAAK;EACX,WAAW;EACX,MAAM,WAAW,UAAU,GAAG;EAM9B,MAAM,KAAK,MAAM,MACf,KAAK,QAAQ,wBAAwB,SAAS,UAChD;EAEA,MAAM,cAA6B,CAAC;EAapC,IAAI;EACJ,MAAM,WAAW,IAAI,SAA2B,YAAY;GAC1D,QAAQ,iBACA,QAAQ,SAAS,GACvB,KAAK,oBAAoB,0BAC3B;EACF,CAAC;EAED,IAAI;GACF,SAAS;IACP,MAAM,OAAO,MAAM,QAAQ,KAAK,CAAC,SAAS,GAAG,QAAQ,CAAC;IACtD,IAAI,SAAS,WACX,MAAM,IAAI,MACR,oCACE,KAAK,oBAAoB,2BAC1B,0CAA0C,SAC7C;IAEF,IAAI,SAAS,MACX,MAAM,IAAI,MACR,+EAA+E,WAC/E,gBAAgB,KAAA,IAAY,KAAA,IAAY,EAAE,OAAO,YAAY,CAC/D;IAEF,IAAI,KAAK,WAAW,GAAG,SAAS,EAAE,GAAG;KACnC,MAAM,UAAU,KAAK,MAAM,SAAS,SAAS,CAAC,CAAC,CAAC,KAAK;KACrD,MAAM,WAAW,SAAS,SAAS,EAAE;KACrC,OAAO;MACL,UAAU,OAAO,SAAS,QAAQ,IAAI,WAAW;MACjD,QAAQ,YAAY,KAAK,IAAI;KAC/B;IACF;IACA,YAAY,KAAK,IAAI;GACvB;EACF,UAAU;GACR,aAAa,KAAK;EACpB;CACF;CAEA,eAAe,YAGZ;EAKD,OAAO;GAAE,MAHG,MADY,IAAI,KAAK,EAAA,CACX,OAAO,KAGpB;GAAK,KAAK,cAAa,MADL,IAAI,WAAW,EAAA,CACG,MAAM;EAAE;CACvD;CAEA,eAAe,UAAyB;EACtC,MAAM,KAAK,MAAM,IAAI;EACrB,MAAM,KAAK,KAAK;EAEhB,MAAM;CACR;CAEA,OAAO;EAAE;EAAK;EAAW;CAAQ;AACnC;;;;;;;;;;AAWA,SAAgB,yBACd,QACA,OAA8B,CAAC,GACf;CAChB,IAAI,MAAM,KAAK,OAAO;CACtB,IAAI,MAA8B,CAAC;CACnC,IAAI,UAAU;CAEd,eAAe,IACb,SAC+C;EAC/C,MAAM,KAAK;EACX,WAAW;EACX,MAAM,WAAW,UAAU,GAAG;EAM9B,MAAM,SAAS;GACb;GACA;GACA,yBAAyB,SAAS;GAClC,mBAAmB,SAAS;GAC5B;GACA,mBAAmB,SAAS;GAC5B;EACF,CAAC,CAAC,KAAK,IAAI;EAEX,MAAM,MAAM,MAAM,OAAO,QAAQ,KAAK,QAAQ;GAAE;GAAK;EAAI,CAAC;EAE1D,MAAM,SAAwB,CAAC;EAC/B,MAAM,WAA0B,CAAC;EACjC,MAAM,WAA0B,CAAC;EACjC,IAAI,WAAW,IAAI;EACnB,IAAI,QAA6C;EAEjD,KAAK,MAAM,QAAQ,IAAI,OAAO,MAAM,IAAI,GACtC,IAAI,UAAU,OAAO;GACnB,IAAI,KAAK,WAAW,GAAG,SAAS,EAAE,GAAG;IACnC,MAAM,SAAS,SAAS,KAAK,MAAM,SAAS,SAAS,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE;IAClE,WAAW,OAAO,SAAS,MAAM,IAAI,SAAS,IAAI;IAClD,QAAQ;IACR;GACF;GACA,OAAO,KAAK,IAAI;EAClB,OAAO,IAAI,UAAU;OACf,SAAS,GAAG,SAAS,OAAO,QAAQ;EAAA,OACnC,IAAI,UAAU,OACnB,IAAI,SAAS,GAAG,SAAS,OAAO,QAAQ;OACnC,SAAS,KAAK,IAAI;OAEvB,SAAS,KAAK,IAAI;EAKtB,MAAM,SAAS,SACZ,KAAK,MAAM,EAAE,KAAK,CAAC,CAAC,CACpB,OAAO,OAAO,CAAC,CACf,IAAI;EACP,IAAI,QAAQ,MAAM;EAClB,MAAM,SAAS,aAAa,SAAS,KAAK,IAAI,CAAC;EAC/C,IAAI,OAAO,KAAK,MAAM,CAAC,CAAC,SAAS,GAAG,MAAM;EAE1C,OAAO;GAAE;GAAU,QAAQ,OAAO,KAAK,IAAI;EAAE;CAC/C;CAEA,SAAS,YAAmE;EAC1E,OAAO,QAAQ,QAAQ;GAAE;GAAK,KAAK,EAAE,GAAG,IAAI;EAAE,CAAC;CACjD;CAEA,SAAS,UAAyB;EAEhC,OAAO,QAAQ,QAAQ;CACzB;CAEA,OAAO;EAAE;EAAK;EAAW;CAAQ;AACnC"}
|
|
1
|
+
{"version":3,"file":"shell.js","names":[],"sources":["../../src/shell.ts"],"sourcesContent":["/**\n * Internal persistent bootstrap shell.\n *\n * Spawns a single `sh` process via {@link SandboxHandle.process.spawn} and\n * drives it over stdin/stdout with a sentinel-echo protocol. Commands run\n * sequentially inside the same shell so `cd`, exported variables, etc. persist\n * across calls — exactly the exec model the bootstrap setup plan needs.\n *\n * Providers WITHOUT a writable host→process stdin (`capabilities.writableStdin\n * === false`, e.g. Cloudflare / Daytona / Vercel) can't be driven over stdin, so\n * {@link createBootstrapShell} transparently falls back to an exec-backed shell\n * ({@link createExecBootstrapShell}) that threads `cwd`/env across `exec` calls\n * to reproduce the same persistent-shell semantics.\n *\n * This module is internal-only and must NOT be re-exported from\n * `packages/ai-sandbox/src/index.ts`.\n */\nimport type { SandboxHandle } from './contracts'\n\n/**\n * Parse the output of `export -p` (or `declare -x`) into a plain env map.\n * Shared by the stdin shell's `forkState` and the exec-backed shell.\n */\nfunction parseExports(output: string): Record<string, string> {\n const env: Record<string, string> = {}\n for (const line of output.split('\\n')) {\n const trimmed = line.trim()\n // Match `declare -x KEY=...` or `export KEY=...` forms.\n const match =\n /^(?:declare\\s+-x\\s+|export\\s+)([A-Za-z_][A-Za-z0-9_]*)(?:=\"((?:[^\"\\\\]|\\\\.)*)\")?$/.exec(\n trimmed,\n )\n if (match === null) continue\n const key = match[1]\n if (key === undefined) continue\n // Value may be absent for exported-but-unset vars; skip those.\n const raw = match[2]\n if (raw === undefined) continue\n // Unescape backslash-escaped chars inside double quotes.\n env[key] = raw.replace(/\\\\(.)/g, '$1')\n }\n return env\n}\n\n/** The surface the bootstrap engine uses. */\nexport interface BootstrapShell {\n /** Run a shell command and capture its stdout + exit code. */\n run: (command: string) => Promise<{ exitCode: number; stdout: string }>\n /**\n * Snapshot the shell's current working directory and exported environment.\n * Used to fork parallel exec calls that inherit the serial shell's state.\n */\n forkState: () => Promise<{ cwd: string; env: Record<string, string> }>\n /** End the shell session (closes stdin, kills the process). */\n dispose: () => Promise<void>\n}\n\n/** Options for {@link createBootstrapShell}. */\nexport interface BootstrapShellOptions {\n /** Working directory to start the shell in (passed as ProcessOptions.cwd). */\n cwd?: string\n /**\n * Belt-and-braces deadline for a single `run()` to see its sentinel. The\n * primary termination condition is the stdout stream ending (see\n * {@link createBootstrapShell}); this only catches a shell that is alive,\n * silent, and never going to answer. Generous by default because setup steps\n * legitimately run for a long time (`npm install`, image pulls).\n */\n commandTimeoutMs?: number\n}\n\n/** Default {@link BootstrapShellOptions.commandTimeoutMs} — 30 minutes. */\nconst DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1000\n\n/** Race marker for the per-command deadline. A symbol cannot collide with a\n * literal stdout line (a line of text `'timeout'` would). */\nconst TIMED_OUT = Symbol('bootstrap-shell-timeout')\n\n/**\n * Spawn one `sh` process and return a {@link BootstrapShell} that drives it\n * via the sentinel-echo protocol.\n *\n * Protocol: for each `run(cmd)` call, we write\n * `<cmd>; printf \"\\n__BSSH_<N>__ $?\\n\"` to stdin, then read stdout lines\n * until we see a line matching `__BSSH_<N>__ <exitCode>`. Everything before\n * that line is the command's stdout; the trailing integer is the exit code.\n * The counter `N` is a module-level monotonic integer — no Date.now / random.\n */\nexport async function createBootstrapShell(\n handle: SandboxHandle,\n opts: BootstrapShellOptions = {},\n): Promise<BootstrapShell> {\n // Providers without a writable host→process stdin can't run the sentinel-echo\n // protocol below (it feeds commands over stdin), so use the exec-backed shell.\n if (!handle.capabilities.writableStdin) {\n return createExecBootstrapShell(handle, opts)\n }\n const proc = await handle.process.spawn('sh', { cwd: opts.cwd })\n\n /*\n * We need to read stdout lines across multiple run() calls while keeping\n * the iterator open. Buffer chunks into lines manually.\n */\n const lineBuffer: Array<string> = []\n // `null` means \"the stdout stream ended\" — distinct from an empty line, which\n // `sh` emits constantly. Collapsing the two is what let a dead shell feed an\n // infinite supply of `''` into a sentinel-hunting loop.\n let pending: Array<(line: string | null) => void> = []\n let streamDone = false\n let streamError: unknown\n\n /** Feed the stdout async-iterable into the shared line queue. */\n async function drainStdout(): Promise<void> {\n let partial = ''\n try {\n for await (const chunk of proc.stdout) {\n partial += chunk\n const parts = partial.split('\\n')\n // All but the last element are complete lines.\n for (let i = 0; i < parts.length - 1; i++) {\n const line = parts[i] as string\n const resolver = pending.shift()\n if (resolver !== undefined) {\n resolver(line)\n } else {\n lineBuffer.push(line)\n }\n }\n partial = parts[parts.length - 1] as string\n }\n // Flush any trailing partial line.\n if (partial.length > 0) {\n const line = partial\n const resolver = pending.shift()\n if (resolver !== undefined) {\n resolver(line)\n } else {\n lineBuffer.push(line)\n }\n }\n } catch (error) {\n // A throw while iterating stdout (transport reset, provider stream error)\n // must NOT leave waiters parked on a promise nobody resolves. Record it so\n // `run()` can name the cause, and fall through to the `finally` that\n // unblocks everyone.\n streamError = error\n } finally {\n streamDone = true\n // Unblock any remaining waiters with the end-of-stream marker.\n for (const resolver of pending) {\n resolver(null)\n }\n pending = []\n }\n }\n\n /*\n * Start draining immediately; do NOT await — runs concurrently. The `try/catch`\n * inside `drainStdout` means this promise never rejects, so there is no\n * unhandled rejection while nothing is awaiting it, and `dispose()` can await\n * it unconditionally.\n */\n const drainPromise = drainStdout()\n\n /** Read the next line from the shared queue, or `null` once stdout ended. */\n function nextLine(): Promise<string | null> {\n const buffered = lineBuffer.shift()\n if (buffered !== undefined) {\n return Promise.resolve(buffered)\n }\n if (streamDone) {\n return Promise.resolve(null)\n }\n return new Promise<string | null>((resolve) => {\n pending.push(resolve)\n })\n }\n\n let counter = 0\n\n async function run(\n command: string,\n ): Promise<{ exitCode: number; stdout: string }> {\n const id = counter\n counter += 1\n const sentinel = `__BSSH_${id}__`\n\n // Write the command followed by a sentinel printf to stdin. Merge the\n // command's stderr into stdout (`{ … ; } 2>&1`) so a failing setup step's\n // error text is captured and can be surfaced — otherwise only the exit code\n // is visible. `$?` after the group is still the command's own exit code.\n await proc.stdin.write(\n `{ ${command} ; } 2>&1; printf \"\\\\n${sentinel} $?\\\\n\"\\n`,\n )\n\n const outputLines: Array<string> = []\n\n /*\n * Read lines until we find the sentinel — but the wait MUST be able to end\n * without one. `sh` can exit before it ever prints the sentinel (a missing\n * binary, an OOM kill, the provider reaping the sandbox mid-bootstrap), and\n * a loop whose only exit is the sentinel then spins on end-of-stream\n * forever, pushing into `outputLines` until the host process dies of memory\n * exhaustion. Two independent terminators:\n * 1. `nextLine()` yields `null` the moment stdout is done — the real fix,\n * it fires as soon as the shell is gone.\n * 2. A deadline, for a shell that stays alive and simply never answers.\n */\n let timer: ReturnType<typeof setTimeout> | undefined\n const deadline = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(\n () => resolve(TIMED_OUT),\n opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,\n )\n })\n\n try {\n for (;;) {\n const line = await Promise.race([nextLine(), deadline])\n if (line === TIMED_OUT) {\n throw new Error(\n `bootstrap shell: timed out after ${\n opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS\n }ms waiting for the sentinel of command: ${command}`,\n )\n }\n if (line === null) {\n throw new Error(\n `bootstrap shell: the shell exited before the sentinel was printed; command: ${command}`,\n streamError === undefined ? undefined : { cause: streamError },\n )\n }\n if (line.startsWith(`${sentinel} `)) {\n const codeStr = line.slice(sentinel.length + 1).trim()\n const exitCode = parseInt(codeStr, 10)\n return {\n exitCode: Number.isFinite(exitCode) ? exitCode : 1,\n stdout: outputLines.join('\\n'),\n }\n }\n outputLines.push(line)\n }\n } finally {\n clearTimeout(timer)\n }\n }\n\n async function forkState(): Promise<{\n cwd: string\n env: Record<string, string>\n }> {\n const pwdResult = await run('pwd')\n const cwd = pwdResult.stdout.trim()\n\n const exportResult = await run('export -p')\n return { cwd, env: parseExports(exportResult.stdout) }\n }\n\n async function dispose(): Promise<void> {\n await proc.stdin.end()\n await proc.kill()\n // Drain the stdout iterator to completion so there are no dangling promises.\n await drainPromise\n }\n\n return { run, forkState, dispose }\n}\n\n/**\n * Exec-backed {@link BootstrapShell} for providers WITHOUT a writable stdin.\n *\n * There is no persistent process to feed commands into, so persistence of `cd`\n * and exported variables is reproduced by threading state across discrete\n * {@link SandboxHandle.process.exec} calls: each `run()` executes the command in\n * the tracked cwd+env, then captures the resulting `pwd` and `export -p` (via\n * marker lines) so the NEXT command inherits any directory change or exports.\n */\nexport function createExecBootstrapShell(\n handle: SandboxHandle,\n opts: BootstrapShellOptions = {},\n): BootstrapShell {\n let cwd = opts.cwd ?? '/'\n let env: Record<string, string> = {}\n let counter = 0\n\n async function run(\n command: string,\n ): Promise<{ exitCode: number; stdout: string }> {\n const id = counter\n counter += 1\n const sentinel = `__BSSH_${id}__`\n\n // Run the command, then emit its exit code, cwd and exported env behind\n // marker lines so we can recover state even when the command itself fails\n // (no `set -e`). Capturing `$?` immediately after the command keeps the\n // reported exit code the command's own, not the trailing introspection's.\n const script = [\n command,\n `__bssh_rc=$?`,\n `printf '\\\\n%s %s\\\\n' '${sentinel}' \"$__bssh_rc\"`,\n `printf '%s\\\\n' '${sentinel}_CWD'`,\n `pwd`,\n `printf '%s\\\\n' '${sentinel}_ENV'`,\n `export -p`,\n ].join('\\n')\n\n const res = await handle.process.exec(script, { cwd, env })\n\n const cmdOut: Array<string> = []\n const cwdLines: Array<string> = []\n const envLines: Array<string> = []\n let exitCode = res.exitCode\n let phase: 'cmd' | 'await-cwd' | 'cwd' | 'env' = 'cmd'\n\n for (const line of res.stdout.split('\\n')) {\n if (phase === 'cmd') {\n if (line.startsWith(`${sentinel} `)) {\n const parsed = parseInt(line.slice(sentinel.length + 1).trim(), 10)\n exitCode = Number.isFinite(parsed) ? parsed : res.exitCode\n phase = 'await-cwd'\n continue\n }\n cmdOut.push(line)\n } else if (phase === 'await-cwd') {\n if (line === `${sentinel}_CWD`) phase = 'cwd'\n } else if (phase === 'cwd') {\n if (line === `${sentinel}_ENV`) phase = 'env'\n else cwdLines.push(line)\n } else {\n envLines.push(line)\n }\n }\n\n // `pwd` prints a single line; the last non-empty one is the new cwd.\n const newCwd = cwdLines\n .map((l) => l.trim())\n .filter(Boolean)\n .pop()\n if (newCwd) cwd = newCwd\n const newEnv = parseExports(envLines.join('\\n'))\n if (Object.keys(newEnv).length > 0) env = newEnv\n\n return { exitCode, stdout: cmdOut.join('\\n') }\n }\n\n function forkState(): Promise<{ cwd: string; env: Record<string, string> }> {\n return Promise.resolve({ cwd, env: { ...env } })\n }\n\n function dispose(): Promise<void> {\n // Nothing to tear down — there is no persistent process.\n return Promise.resolve()\n }\n\n return { run, forkState, dispose }\n}\n"],"mappings":"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"}
|
|
@@ -34,7 +34,8 @@ function makeRecord(overrides) {
|
|
|
34
34
|
function runSandboxInstanceStoreConformance(name, makeStore) {
|
|
35
35
|
describe(`SandboxInstanceStore conformance: ${name}`, () => {
|
|
36
36
|
it("returns null for a missing key", async () => {
|
|
37
|
-
|
|
37
|
+
const store = await makeStore();
|
|
38
|
+
expect(await store.get("absent")).toBeNull();
|
|
38
39
|
});
|
|
39
40
|
it("round-trips an upserted record with all fields", async () => {
|
|
40
41
|
const store = await makeStore();
|
|
@@ -87,7 +88,8 @@ function runSandboxInstanceStoreConformance(name, makeStore) {
|
|
|
87
88
|
expect(await store.get("thread-1")).toBeNull();
|
|
88
89
|
});
|
|
89
90
|
it("delete of a missing key is a no-op", async () => {
|
|
90
|
-
|
|
91
|
+
const store = await makeStore();
|
|
92
|
+
await expect(store.delete("absent")).resolves.toBeUndefined();
|
|
91
93
|
});
|
|
92
94
|
});
|
|
93
95
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conformance.js","names":[],"sources":["../../../src/testkit/conformance.ts"],"sourcesContent":["/**\n * Conformance suite for a {@link SandboxInstanceStore} implementation.\n *\n * Run this against a fresh BYO store (or the in-memory reference) to prove it\n * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure\n * algorithm relies on — including insert-vs-overwrite and optional-field\n * handling (full replace must clear omitted optionals).\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport type {\n SandboxInstanceRecord,\n SandboxInstanceStore,\n} from '../instance-store'\n\n// The `./testkit` package.json subpath resolves to THIS module's build output\n// only (`dist/esm/testkit/conformance.js`), not a barrel over `src/testkit/`.\n// Re-export the journal, takeover, and reaper conformance suites here so they\n// are reachable from `@tanstack/ai-sandbox/testkit` without changing that\n// mapping.\nexport { runJournalConformance } from './journal-conformance'\nexport type { JournalConformanceConfig } from './journal-conformance'\nexport { runTakeoverConformance } from './takeover-conformance'\nexport type { TakeoverConformanceConfig } from './takeover-conformance'\nexport { runReaperConformance } from './reaper-conformance'\nexport type { ReaperConformanceConfig } from './reaper-conformance'\nexport { runDurableRunFieldsConformance } from './durable-run-fields-conformance'\nexport type { MakeRunStore } from './durable-run-fields-conformance'\nexport { makeFakeShellSpawn } from './shell-spawn'\n\nfunction makeRecord(\n overrides?: Partial<SandboxInstanceRecord>,\n): SandboxInstanceRecord {\n return {\n key: 'thread-1',\n provider: 'fake',\n providerSandboxId: 'sb-1',\n threadId: 'thread-1',\n updatedAt: 1,\n ...overrides,\n }\n}\n\n/**\n * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each\n * `it` gets a fresh store, so implementations may share process state across\n * calls without cross-test bleed only if `makeStore` returns an isolated store.\n */\nexport function runSandboxInstanceStoreConformance(\n name: string,\n makeStore: () => SandboxInstanceStore | Promise<SandboxInstanceStore>,\n): void {\n describe(`SandboxInstanceStore conformance: ${name}`, () => {\n it('returns null for a missing key', async () => {\n const store = await makeStore()\n expect(await store.get('absent')).toBeNull()\n })\n\n it('round-trips an upserted record with all fields', async () => {\n const store = await makeStore()\n const record = makeRecord({\n latestSnapshotId: 'snap-1',\n latestRunId: 'run-1',\n })\n await store.upsert(record)\n expect(await store.get(record.key)).toEqual(record)\n })\n\n it('omits absent optional fields on read', async () => {\n const store = await makeStore()\n const record = makeRecord()\n await store.upsert(record)\n const loaded = await store.get(record.key)\n expect(loaded).toEqual(record)\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n expect(loaded && 'latestRunId' in loaded).toBe(false)\n })\n\n it('overwrites an existing record on re-upsert', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' }))\n await store.upsert(\n makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }),\n )\n const loaded = await store.get('thread-1')\n expect(loaded?.providerSandboxId).toBe('sb-2')\n expect(loaded?.updatedAt).toBe(2)\n // The overwrite dropped latestSnapshotId — a durable store must clear it,\n // not retain the prior value.\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n })\n\n it('isolates records by key', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ key: 'a', threadId: 'a' }))\n await store.upsert(\n makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }),\n )\n expect((await store.get('a'))?.providerSandboxId).toBe('sb-1')\n expect((await store.get('b'))?.providerSandboxId).toBe('sb-b')\n })\n\n it('deletes a record', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord())\n await store.delete('thread-1')\n expect(await store.get('thread-1')).toBeNull()\n })\n\n it('delete of a missing key is a no-op', async () => {\n const store = await makeStore()\n await expect(store.delete('absent')).resolves.toBeUndefined()\n })\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAgCA,SAAS,WACP,WACuB;CACvB,OAAO;EACL,KAAK;EACL,UAAU;EACV,mBAAmB;EACnB,UAAU;EACV,WAAW;EACX,GAAG;CACL;AACF;;;;;;AAOA,SAAgB,mCACd,MACA,WACM;CACN,SAAS,qCAAqC,cAAc;EAC1D,GAAG,kCAAkC,YAAY;
|
|
1
|
+
{"version":3,"file":"conformance.js","names":[],"sources":["../../../src/testkit/conformance.ts"],"sourcesContent":["/**\n * Conformance suite for a {@link SandboxInstanceStore} implementation.\n *\n * Run this against a fresh BYO store (or the in-memory reference) to prove it\n * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure\n * algorithm relies on — including insert-vs-overwrite and optional-field\n * handling (full replace must clear omitted optionals).\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport type {\n SandboxInstanceRecord,\n SandboxInstanceStore,\n} from '../instance-store'\n\n// The `./testkit` package.json subpath resolves to THIS module's build output\n// only (`dist/esm/testkit/conformance.js`), not a barrel over `src/testkit/`.\n// Re-export the journal, takeover, and reaper conformance suites here so they\n// are reachable from `@tanstack/ai-sandbox/testkit` without changing that\n// mapping.\nexport { runJournalConformance } from './journal-conformance'\nexport type { JournalConformanceConfig } from './journal-conformance'\nexport { runTakeoverConformance } from './takeover-conformance'\nexport type { TakeoverConformanceConfig } from './takeover-conformance'\nexport { runReaperConformance } from './reaper-conformance'\nexport type { ReaperConformanceConfig } from './reaper-conformance'\nexport { runDurableRunFieldsConformance } from './durable-run-fields-conformance'\nexport type { MakeRunStore } from './durable-run-fields-conformance'\nexport { makeFakeShellSpawn } from './shell-spawn'\n\nfunction makeRecord(\n overrides?: Partial<SandboxInstanceRecord>,\n): SandboxInstanceRecord {\n return {\n key: 'thread-1',\n provider: 'fake',\n providerSandboxId: 'sb-1',\n threadId: 'thread-1',\n updatedAt: 1,\n ...overrides,\n }\n}\n\n/**\n * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each\n * `it` gets a fresh store, so implementations may share process state across\n * calls without cross-test bleed only if `makeStore` returns an isolated store.\n */\nexport function runSandboxInstanceStoreConformance(\n name: string,\n makeStore: () => SandboxInstanceStore | Promise<SandboxInstanceStore>,\n): void {\n describe(`SandboxInstanceStore conformance: ${name}`, () => {\n it('returns null for a missing key', async () => {\n const store = await makeStore()\n expect(await store.get('absent')).toBeNull()\n })\n\n it('round-trips an upserted record with all fields', async () => {\n const store = await makeStore()\n const record = makeRecord({\n latestSnapshotId: 'snap-1',\n latestRunId: 'run-1',\n })\n await store.upsert(record)\n expect(await store.get(record.key)).toEqual(record)\n })\n\n it('omits absent optional fields on read', async () => {\n const store = await makeStore()\n const record = makeRecord()\n await store.upsert(record)\n const loaded = await store.get(record.key)\n expect(loaded).toEqual(record)\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n expect(loaded && 'latestRunId' in loaded).toBe(false)\n })\n\n it('overwrites an existing record on re-upsert', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' }))\n await store.upsert(\n makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }),\n )\n const loaded = await store.get('thread-1')\n expect(loaded?.providerSandboxId).toBe('sb-2')\n expect(loaded?.updatedAt).toBe(2)\n // The overwrite dropped latestSnapshotId — a durable store must clear it,\n // not retain the prior value.\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n })\n\n it('isolates records by key', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ key: 'a', threadId: 'a' }))\n await store.upsert(\n makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }),\n )\n expect((await store.get('a'))?.providerSandboxId).toBe('sb-1')\n expect((await store.get('b'))?.providerSandboxId).toBe('sb-b')\n })\n\n it('deletes a record', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord())\n await store.delete('thread-1')\n expect(await store.get('thread-1')).toBeNull()\n })\n\n it('delete of a missing key is a no-op', async () => {\n const store = await makeStore()\n await expect(store.delete('absent')).resolves.toBeUndefined()\n })\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAgCA,SAAS,WACP,WACuB;CACvB,OAAO;EACL,KAAK;EACL,UAAU;EACV,mBAAmB;EACnB,UAAU;EACV,WAAW;EACX,GAAG;CACL;AACF;;;;;;AAOA,SAAgB,mCACd,MACA,WACM;CACN,SAAS,qCAAqC,cAAc;EAC1D,GAAG,kCAAkC,YAAY;GAC/C,MAAM,QAAQ,MAAM,UAAU;GAC9B,OAAO,MAAM,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAC,SAAS;EAC7C,CAAC;EAED,GAAG,kDAAkD,YAAY;GAC/D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;IACxB,kBAAkB;IAClB,aAAa;GACf,CAAC;GACD,MAAM,MAAM,OAAO,MAAM;GACzB,OAAO,MAAM,MAAM,IAAI,OAAO,GAAG,CAAC,CAAC,CAAC,QAAQ,MAAM;EACpD,CAAC;EAED,GAAG,wCAAwC,YAAY;GACrD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;GAC1B,MAAM,MAAM,OAAO,MAAM;GACzB,MAAM,SAAS,MAAM,MAAM,IAAI,OAAO,GAAG;GACzC,OAAO,MAAM,CAAC,CAAC,QAAQ,MAAM;GAC7B,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;GACzD,OAAO,UAAU,iBAAiB,MAAM,CAAC,CAAC,KAAK,KAAK;EACtD,CAAC;EAED,GAAG,8CAA8C,YAAY;GAC3D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,EAAE,kBAAkB,SAAS,CAAC,CAAC;GAC7D,MAAM,MAAM,OACV,WAAW;IAAE,mBAAmB;IAAQ,WAAW;GAAE,CAAC,CACxD;GACA,MAAM,SAAS,MAAM,MAAM,IAAI,UAAU;GACzC,OAAO,QAAQ,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7C,OAAO,QAAQ,SAAS,CAAC,CAAC,KAAK,CAAC;GAGhC,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;EAC3D,CAAC;EAED,GAAG,2BAA2B,YAAY;GACxC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW;IAAE,KAAK;IAAK,UAAU;GAAI,CAAC,CAAC;GAC1D,MAAM,MAAM,OACV,WAAW;IAAE,KAAK;IAAK,UAAU;IAAK,mBAAmB;GAAO,CAAC,CACnE;GACA,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7D,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;EAC/D,CAAC;EAED,GAAG,oBAAoB,YAAY;GACjC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,CAAC;GAC/B,MAAM,MAAM,OAAO,UAAU;GAC7B,OAAO,MAAM,MAAM,IAAI,UAAU,CAAC,CAAC,CAAC,SAAS;EAC/C,CAAC;EAED,GAAG,sCAAsC,YAAY;GACnD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,CAAC,SAAS,cAAc;EAC9D,CAAC;CACH,CAAC;AACH"}
|
|
@@ -213,8 +213,10 @@ function runJournalConformance(config) {
|
|
|
213
213
|
expectDeclaredStrategy(handle, config);
|
|
214
214
|
const paths = journalPaths(`conf-${Date.now()}`);
|
|
215
215
|
const command = journaledCommand(`printf '{"a":1}\\n{"b":2}\\n'`, paths);
|
|
216
|
-
|
|
217
|
-
expect(
|
|
216
|
+
const proc = await handle.process.spawn(command);
|
|
217
|
+
expect(await proc.wait()).toBe(0);
|
|
218
|
+
const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout);
|
|
219
|
+
expect(text).toBe(`{"a":1}\n{"b":2}\n${exitSentinelLine(paths, 0)}\n`);
|
|
218
220
|
} finally {
|
|
219
221
|
await dispose();
|
|
220
222
|
}
|
|
@@ -224,7 +226,8 @@ function runJournalConformance(config) {
|
|
|
224
226
|
try {
|
|
225
227
|
const paths = journalPaths(`conf-exit-${Date.now()}`);
|
|
226
228
|
await (await handle.process.spawn(journaledCommand("exit 7", paths))).wait();
|
|
227
|
-
|
|
229
|
+
const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout);
|
|
230
|
+
expect(text).toBe(`${exitSentinelLine(paths, 7)}\n`);
|
|
228
231
|
} finally {
|
|
229
232
|
await dispose();
|
|
230
233
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"journal-conformance.js","names":[],"sources":["../../../src/testkit/journal-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for the agent output journal.\n *\n * The journal design rests on two provider-level claims: a command string is\n * framed through a POSIX shell (so `>>` redirection works), and `tail -c +N -f`\n * is available. Both are asserted here against a real sandbox rather than\n * assumed from the audit.\n *\n * A provider that cannot satisfy them MUST declare `unsupported.reason`. There\n * is deliberately no silent-skip path: a conformance case that quietly returns\n * prints as a pass, which is how an unimplemented capability ships green. The\n * three FOLLOW cases obey the same rule through a second declaration,\n * {@link JournalConformanceConfig.followUnsupported} — see {@link itFollows} for\n * why the strategy has to be declared rather than detected at registration time,\n * and {@link expectDeclaredStrategy} for what keeps the declaration honest.\n *\n * THE THIRD FOLLOW CASE TESTS THE OTHER SIDE OF THE BOUNDARY, and it is here\n * because the first two do not. `killableProcesses` is what selects `'follow'`\n * over `'poll'`, and a wrong `true` means `tail -f` is spawned on the assumption\n * it can be reclaimed — leaking one follower per run when it cannot. The two\n * follow cases only ever asserted that the READER stops, which\n * `journal-reader.ts`'s `untilAborted` guarantees on its own by abandoning the\n * pipe the moment the signal fires. So both of them pass a provider whose\n * `kill()` is `() => Promise.resolve()`, and three of the four `true`\n * declarations in this repo were in fact false: Docker's `stream.destroy()` only\n * detached the client, local-process's `sh -c` forks so signalling the shell left\n * the command alive, and Vercel's `kill()` never called the SDK's real\n * `Command.kill` at all. Every one of them shipped green through this suite.\n * \"kills the sandbox-side process, not just the host's view of it\" is the case\n * that fails them — see its own comment for how it probes.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { randomUUID } from 'node:crypto'\nimport { describe, expect, it } from 'vitest'\nimport {\n exitSentinelLine,\n journalExistsCommand,\n journalPaths,\n journalReadCommand,\n journaledCommand,\n} from '../journal'\nimport { journalReadStrategy, readJournal } from '../journal-reader'\nimport type { JournalPaths } from '../journal'\nimport type { SandboxHandle } from '../contracts'\n\nexport interface JournalConformanceConfig {\n /** Provider name, used in the describe title. */\n name: string\n /** Create a live sandbox plus its teardown. */\n createHandle: () => Promise<{\n handle: SandboxHandle\n dispose: () => Promise<void>\n }>\n /**\n * Declare that this provider cannot journal, with the reason. Registers a\n * skipped case whose title carries the reason. Omit it and the suite runs.\n */\n unsupported?: { reason: string }\n /**\n * Declare that this provider's reads take the POLL strategy rather than the\n * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles,\n * because it lacks `backgroundProcesses` or `killableProcesses`. The two follow\n * cases then register as NAMED skips carrying the reason.\n *\n * Declare this ONLY when the provider really cannot follow. It is checked\n * against a live handle in a case that always runs\n * ({@link expectDeclaredStrategy}), so a wrong declaration fails the suite in\n * either direction rather than quietly removing coverage.\n */\n followUnsupported?: { reason: string }\n}\n\n/**\n * Per-case timeout. Every case here spawns a real sandbox and a real agent.\n *\n * 180s, not the 60s this used to be, and it matches the ceiling\n * `takeover-conformance.ts` already gives its heaviest cases. It is the one\n * wall-clock number left in the file and it is deliberately far outside the range\n * any healthy run needs: a case here makes half a dozen provider round-trips, and\n * ONE `docker exec` on a loaded daemon has been measured at 9.6s (see\n * `takeover-conformance.ts`'s `countingExec`) and at 20–45s on a saturated one, so\n * a 60s budget put the timeout itself in the same load-sensitive class as the\n * assertions that were removed from these cases — measured going red on cases that\n * pass in 7–13s each on a quiet machine.\n *\n * This bound exists only so a genuine hang FAILS instead of parking CI; it is not\n * an assertion about speed, and nothing here should be tuned to sit near it.\n */\nconst CASE_TIMEOUT_MS = 180_000\n\n/**\n * Register a case that only means anything on a provider whose reads FOLLOW.\n *\n * `journalReadStrategy` needs a live handle and a live handle needs the async\n * `createHandle`, so the strategy is not knowable when the cases are registered.\n * It is therefore DECLARED, and the declaration selects `it` or `it.skip` here.\n *\n * This exists because the alternative — checking the strategy inside the case and\n * returning early — is the silent-skip the module doc forbids. Such a case prints\n * `✓` with a duration and a title claiming a property was verified while every\n * real assertion in it (including the incremental-delivery handshake, which is\n * the entire reason the follow path exists) was skipped. A named `it.skip` prints\n * `↓` with the reason instead.\n */\nfunction itFollows(\n config: JournalConformanceConfig,\n title: string,\n fn: () => Promise<void>,\n): void {\n const unsupported = config.followUnsupported\n if (unsupported === undefined) {\n it(title, fn, CASE_TIMEOUT_MS)\n return\n }\n it.skip(\n `${title} — follow strategy unsupported: ${unsupported.reason}`,\n fn,\n CASE_TIMEOUT_MS,\n )\n}\n\n/**\n * Assert the live handle's read strategy is the one the config DECLARED.\n *\n * BOTH directions are defects, and neither is a skip. A provider that declared\n * `followUnsupported` but whose handles do follow silently loses the two cases it\n * could pass. One that declared nothing but polls would reach the follow\n * assertions and fail them for a reason unrelated to journaling — which is what\n * the previous `expect(handle.capabilities.killableProcesses).toBe(false)` branch\n * did to a provider with `backgroundProcesses: false, killableProcesses: true`.\n * Either way the config does not describe the provider, and that is worth\n * failing.\n */\nfunction expectDeclaredStrategy(\n handle: SandboxHandle,\n config: JournalConformanceConfig,\n): void {\n expect(journalReadStrategy(handle)).toBe(\n config.followUnsupported === undefined ? 'follow' : 'poll',\n )\n}\n\n/** Decode the base64 frame a journal read command produces into raw text. */\nfunction decodeJournalRead(stdout: string): string {\n return Buffer.from(stdout.replace(/\\s+/g, ''), 'base64').toString('utf8')\n}\n\n/**\n * Block until the run's journal file exists in the sandbox.\n *\n * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see\n * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp`\n * differently, so an `fs` probe would report the wrong file.\n *\n * Exported for `./reaper-conformance.ts`, which needs the same bounded,\n * shell-only wait before probing a still-producing run. Internal to the testkit;\n * not part of the `./testkit` public surface.\n */\nexport async function waitForJournal(\n handle: SandboxHandle,\n paths: JournalPaths,\n): Promise<void> {\n const deadline = Date.now() + 15_000\n for (;;) {\n const probe = await handle.process.exec(journalExistsCommand(paths))\n if (probe.exitCode === 0) return\n if (Date.now() > deadline) {\n throw new Error(`journal conformance: ${paths.journal} never appeared`)\n }\n await sleep(100)\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * An absolute path inside the sandbox that no other case, suite, or machine will\n * touch.\n *\n * Every character is in `[A-Za-z0-9./-]`, so these interpolate into the shell\n * commands below as a single word without quoting. `/tmp` and not the workspace:\n * on local-process a shell redirect reaches the host's real `/tmp` while\n * `handle.fs` resolves under the sandbox root (see rule 3 in `../journal.ts`),\n * and everything here is written AND read through the shell so the two never have\n * to agree.\n */\nfunction noncePath(label: string): string {\n return `/tmp/tanstack-journal-conformance-${label}-${randomUUID()}`\n}\n\n/** Iteration cap on the kill probe's loop, so nothing can outlive the suite. */\nconst PROBE_MAX_TICKS = 600\n\n/**\n * Bound on a journal read, so a reader that delivers nothing FAILS instead of\n * parking CI.\n *\n * Never an assertion, and deliberately far above anything a healthy read needs\n * (measured: 10–18s for the follow cases on both providers). Each case that uses\n * it proves its property some other way — a causal handshake, or\n * `backstop.aborted` — so this number can be raised freely and must never be the\n * thing a case is tuned against.\n */\nconst READ_BACKSTOP_MS = 90_000\n\n/**\n * How long to let an asynchronous kill land before the quiet window opens.\n *\n * A kill is asynchronous on every provider here — Docker signals through a\n * second `exec`, local-process signals a process group and lets the OS reap — so\n * one more heartbeat tick immediately after `kill()` resolves is not a survivor.\n */\nconst KILL_SETTLE_MS = 5_000\n\n/**\n * The quiet window: how long the heartbeat must stay frozen.\n *\n * This is NOT a load-sensitive bound, and the asymmetry is the point. A dead\n * process can never write again, so a slow or busy machine can only make this\n * window MORE reliable, never less — unlike a \"must happen within Nms\" ceiling,\n * which fails on load. Only a live survivor can end this window, and a live\n * survivor writes once a second.\n */\nconst HEARTBEAT_QUIET_MS = 6_000\n\n/**\n * Byte count of `path`, according to the SANDBOX'S OWN shell, or `null` when it\n * cannot be read.\n *\n * `wc -c` through the shell, never `handle.fs`: on local-process the two resolve\n * `/tmp` differently (rule 3), so an `fs` probe would answer about a file the\n * sandbox never wrote and the growth below would look frozen from the first\n * sample — a vacuous pass. Parsed strictly rather than coerced, so a shell\n * diagnostic cannot become `NaN` and compare unequal to itself.\n */\nasync function fileSize(\n handle: SandboxHandle,\n path: string,\n): Promise<number | null> {\n const probe = await handle.process.exec(`wc -c < ${path} 2>/dev/null`)\n const text = probe.stdout.trim()\n return /^\\d+$/.test(text) ? Number(text) : null\n}\n\n/**\n * Wait until `path` has grown to at least `bytes`, i.e. the probe process is\n * provably DOING WORK inside the sandbox, and answer whether it got there.\n *\n * Returning the observation rather than throwing keeps the verdict inside the\n * case's own `expect`: this is the \"before\" half of the assertion, and it is what\n * makes the \"after\" half a live detector instead of a formality.\n */\nasync function waitForTicks(\n handle: SandboxHandle,\n path: string,\n bytes: number,\n): Promise<boolean> {\n const deadline = Date.now() + 30_000\n for (;;) {\n const size = await fileSize(handle, path)\n if (size !== null && size >= bytes) return true\n if (Date.now() > deadline) return false\n // Matched to the heartbeat's own 1s period on purpose. Every poll is a\n // provider round-trip, so a 250ms interval spent three of them per tick it\n // could not possibly observe — pure pressure on the very `exec` path the rest\n // of the case depends on.\n await sleep(1_000)\n }\n}\n\n/**\n * Assert `createHandle` satisfies the journal conformance contract. Each `it`\n * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may\n * share process state across calls without cross-test bleed only if\n * `createHandle` returns an isolated sandbox.\n */\nexport function runJournalConformance(config: JournalConformanceConfig): void {\n describe(`journal conformance — ${config.name}`, () => {\n if (config.unsupported) {\n it.skip(`unsupported: ${config.unsupported.reason}`, () => {\n expect(true).toBe(true)\n })\n return\n }\n\n it(\n \"redirects a command's stdout into the journal and appends the exit sentinel\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n // Checked HERE, in a case that always runs, because\n // `followUnsupported` gates the two follow cases below: a declaration\n // that does not match the live handle must fail the suite rather than\n // remove coverage from it. This is the only place a `poll` declaration\n // can be caught, since the cases it skips never execute.\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-${Date.now()}`)\n const command = journaledCommand(\n `printf '{\"a\":1}\\\\n{\"b\":2}\\\\n'`,\n paths,\n )\n const proc = await handle.process.spawn(command)\n expect(await proc.wait()).toBe(0)\n\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`{\"a\":1}\\n{\"b\":2}\\n${exitSentinelLine(paths, 0)}\\n`)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n \"records the agent's non-zero exit in the sentinel\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-exit-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand('exit 7', paths),\n )\n await proc.wait()\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`${exitSentinelLine(paths, 7)}\\n`)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n \"keeps the agent's stderr out of the journal\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-err-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand(\n `printf '{\"a\":1}\\\\n'; printf 'a warning\\\\n' 1>&2`,\n paths,\n ),\n )\n await proc.wait()\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`{\"a\":1}\\n${exitSentinelLine(paths, 0)}\\n`)\n expect(text).not.toContain('a warning')\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n 'reads incrementally from a byte offset with absolute positions',\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-seek-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand(`printf '{\"a\":1}\\\\n{\"b\":2}\\\\n'`, paths),\n )\n await proc.wait()\n\n const all = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n strategy: 'poll',\n pollIntervalMs: 0,\n // Not an assertion — see {@link READ_BACKSTOP_MS}. This was\n // `AbortSignal.timeout(5_000)`, and it is a POLL read, so it costs one\n // provider round-trip per line: measured going red on a saturated\n // Docker daemon where a single `exec` took ~20s, while the lines it\n // asserts were perfectly correct.\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n all.push(line)\n if (all.length === 3) break\n }\n expect(all.map((l) => l.line)).toEqual([\n '{\"a\":1}',\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n\n const resumed = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: all[0]?.endPosition ?? 0,\n strategy: 'poll',\n pollIntervalMs: 0,\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n resumed.push(line)\n if (resumed.length === 2) break\n }\n expect(resumed.map((l) => l.line)).toEqual([\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n expect(resumed[0]?.endPosition).toBe(all[1]?.endPosition)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n // This case is the reason `journalFollowCommand` pipes into nothing.\n // `tail -f journal | base64` delivers ZERO bytes while the agent is still\n // running — measured on GNU coreutils 8.32 `base64` and on busybox 1.36.1\n // `base64` in Alpine — because the encoder buffers its stdout until its\n // stdin closes, which only happens when the reader kills `tail`.\n //\n // INCREMENTAL, NOT MERELY EVENTUAL, AND PROVED CAUSALLY RATHER THAN BY A\n // STOPWATCH. The agent writes its first line and then BLOCKS on a gate file\n // that only this reader can create, and it creates it only on receiving that\n // first line. So the agent cannot reach its second line until the first was\n // delivered — receiving `{\"b\":2}` at all IS the proof of incremental\n // delivery, and the `toEqual` below is the whole assertion. A buffering\n // filter reintroduced onto the follow path deadlocks instead: nothing is\n // delivered, the gate is never created, the agent never writes its second\n // line, the read ends on its signal and `seen` is empty.\n //\n // This replaced `expect(firstLineMs).toBeLessThan(3_000)`, which measured\n // MACHINE LOAD as much as behavior: it was observed failing in whole-suite\n // fleet runs while passing 5/5 in isolation, because one `exec`/`spawn` is a\n // provider round-trip whose latency this suite does not control (a\n // `docker exec` on a loaded daemon has been measured at 9.6s). Same instinct\n // as `takeover-conformance.ts`'s `countingExec`: anchor on the property, not\n // on the clock. A bound that goes red on a busy machine teaches people to\n // ignore the suite. Do NOT \"fix\" a failure here by widening a window —\n // there is no window left to widen.\n itFollows(\n config,\n 'follows a journal that is still being written, delivering each line before the next is produced',\n async () => {\n // Every assertion below sits after an `await`, so a case that threw its way\n // out of the loop early would report an unrelated failure; this one reports\n // \"nothing was asserted\", which is the failure this case used to HIDE.\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n const gate = noncePath('follow-gate')\n try {\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-follow-${Date.now()}`)\n // The wait is bounded in the SANDBOX too, and on timeout it emits a\n // line that names what went wrong instead of the expected one — so a\n // gate that never arrives fails the `toEqual` with `{\"gate\":\"never\"}`\n // rather than eventually satisfying it. 30 ticks so that diagnostic\n // lands INSIDE `CASE_TIMEOUT_MS`; a longer cap would just time the case\n // out and lose the message.\n const agentCommand =\n `printf '{\"a\":1}\\\\n'; ` +\n `i=0; while [ ! -f ${gate} ]; do ` +\n `i=$((i+1)); ` +\n `if [ $i -gt 30 ]; then printf '{\"gate\":\"never\"}\\\\n'; break; fi; ` +\n `sleep 1; done; ` +\n `printf '{\"b\":2}\\\\n'`\n // Not awaited anywhere: reading the `__exit` sentinel below IS the\n // proof it finished. (`SpawnHandle.wait()` is not safe to call after the\n // fact on every provider — local-process registers a `close` listener at\n // call time, so a `wait()` issued after the process already exited never\n // resolves.)\n void handle.process.spawn(journaledCommand(agentCommand, paths))\n // `tail` on a file that does not exist yet exits immediately, and the\n // agent's spawn and the reader's spawn race. Waiting removes that race\n // WITHOUT touching the property under test: with a buffering filter on\n // the follow path the journal still exists, `tail` still runs, and the\n // reader still receives nothing.\n await waitForJournal(handle, paths)\n // The premise, pinned: the gate is genuinely absent, so the agent\n // really is blocked and its second line really is downstream of this\n // reader. Without this a pre-existing gate path would make the case\n // pass without following anything.\n expect(\n (await handle.process.exec(`test -e ${gate}`)).exitCode,\n ).not.toBe(0)\n const seen: Array<string> = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n // Not the assertion — see {@link READ_BACKSTOP_MS}. The gate's own\n // 30-tick cap fires well inside it, so the `{\"gate\":\"never\"}`\n // diagnostic still reaches this reader.\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n seen.push(line.line)\n // Releases the agent, and only from inside the stream. `touch` is\n // not portable to every BusyBox build with these flags, so this\n // creates the file with a redirect, through the shell — `handle.fs`\n // would write a path the agent's `test -f` cannot see on\n // local-process (rule 3).\n if (seen.length === 1) {\n await handle.process.exec(`: >> ${gate}`)\n }\n if (seen.length === 3) break\n }\n expect(seen).toEqual([\n '{\"a\":1}',\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n } finally {\n // Unblocks the agent even when the case failed, so no `while` loop\n // outlives it on a provider whose sandbox teardown does not reap.\n await handle.process.exec(`: >> ${gate}`).catch(() => undefined)\n await dispose()\n }\n },\n )\n\n // The follow read must obey its own AbortSignal rather than waiting for the\n // provider's `kill` to close the stream — a provider whose kill misses a\n // grandchild would otherwise hang the reader past its deadline.\n itFollows(\n config,\n 'stops a follow read when its signal aborts, without a consumer break',\n async () => {\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n try {\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-abort-${Date.now()}`)\n // Outlives the read on purpose: the journal must still be open, and the\n // agent still running, when the signal fires.\n const agent = await handle.process.spawn(\n journaledCommand(`printf '{\"a\":1}\\\\n'; sleep 30`, paths),\n )\n try {\n await waitForJournal(handle, paths)\n const seen: Array<string> = []\n // The signal fires ON the first line rather than on a stopwatch. The\n // old form was `AbortSignal.timeout(3_000)`, which asked the provider\n // to spawn a `tail` AND deliver a line inside 3s — measured failing on\n // a loaded Windows machine (git-bash `sh` + `tail`, `seen` came back\n // empty) while passing in isolation, the same load-sensitivity as the\n // `firstLineMs` bound the case above replaced. Aborting from inside the\n // stream keeps the property exactly: the agent is still running, the\n // journal is still open, and the reader must stop because its SIGNAL\n // said so.\n const stop = new AbortController()\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. It is not the assertion — `backstopped` below proves it\n // was not what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n signal: AbortSignal.any([stop.signal, backstop]),\n })) {\n seen.push(line.line)\n // No `break`, ever: the pre-fix reader honored a consumer break but\n // rode straight past its signal, so a `break` here would pass it.\n stop.abort()\n }\n expect({ seen, backstopped: backstop.aborted }).toEqual({\n seen: ['{\"a\":1}'],\n // The loop ended, and NOT because the backstop timed out — which is\n // the causal witness that \"the signal ends it at all\", with no clock\n // in the assertion.\n backstopped: false,\n })\n } finally {\n await agent.kill()\n }\n } finally {\n await dispose()\n }\n },\n )\n\n // THE CAPABILITY, not the reader's reaction to it.\n //\n // `killableProcesses` is the flag `journalReadStrategy` reads to choose\n // `'follow'`, and the promise it makes is about the SANDBOX-SIDE process:\n // `tail -f` may be spawned because the caller can reclaim it. Every other\n // case in this file asserts only that the READER stopped, which\n // `untilAborted` delivers unilaterally by abandoning the pipe — so all of\n // them pass a provider whose `kill()` is `() => Promise.resolve()`. This one\n // asks the sandbox itself.\n //\n // WHAT IT SPAWNS, AND WHY THAT SHAPE. The heartbeat loop is BACKGROUNDED\n // (`( … ) & wait`), so the long-lived work is a grandchild of the wrapper\n // shell rather than the wrapper itself. That is deliberate: it is the shape\n // of the defects this bites on. `sh -c '<cmd>'` does not reliably exec its\n // command, so a provider that signals only the wrapper leaves the real work\n // running — measured on local-process POSIX, where `sh -c 'sleep 987654321'`\n // survived `child.kill('SIGKILL')` — and Docker's `kill -SIG -\"$pid\"` group\n // form exists precisely so a backgrounded grandchild is not orphaned. A probe\n // that `exec`ed itself into the wrapper would be reclaimed by the correct and\n // the broken implementation alike, and prove nothing.\n //\n // WHY IT MEASURES WORK AND NOT EXISTENCE. `kill -0 <pid>` was the obvious\n // probe and it is WRONG here, measured: alpine's PID 1 under this provider is\n // `tail -f /dev/null`, which never `wait()`s, so a correctly killed child\n // lingers as an unreaped `[sleep]` forever and `kill -0` answers 0 for it.\n // That fails a healthy provider. `ps` is no better and is why the identity\n // here is a file rather than a nonce in the command line: on local-process\n // under Windows the shell is git-bash, whose MSYS `ps` prints only the process\n // IMAGE PATH (`/usr/bin/sleep`) and never argv — verified, `ps`, `ps -ef` and\n // `ps -W` all omit it — so `ps | grep <nonce>` would match nothing there, and\n // \"no match\" is indistinguishable from \"it is gone\": a vacuous pass on\n // exactly the provider whose kill was broken. (`pgrep` is worse: BusyBox has\n // it, git-bash does not.) A host-side census is not portable at all — Docker's\n // container-side process has no host process, and a remote provider has none\n // either.\n //\n // So the probe is a heartbeat: the process appends one byte per second to a\n // nonce-named file, through the shell. Only a RUNNING process can do that. A\n // zombie cannot, a killed process cannot, and no other test on the machine\n // writes to that path.\n //\n // BOTH OBSERVATIONS ARE ASSERTED, in one object, and the \"before\" one is not\n // decoration: a frozen-file check passes trivially against a file that never\n // grew at all, which is the exact failure shape this whole review keeps\n // turning up. `tickedBeforeKill` is what makes the detector live.\n itFollows(\n config,\n \"kills the sandbox-side process, not just the host's view of it\",\n async () => {\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n const heartbeat = noncePath('killprobe-hb')\n const stop = noncePath('killprobe-stop')\n try {\n expectDeclaredStrategy(handle, config)\n // The `stop` file is how a SURVIVOR is reclaimed in teardown, since a\n // provider that fails this case cannot be trusted to kill it and the\n // suite must not leak a spinner either way. The tick cap is the second\n // net, for a teardown that never ran at all.\n const probe = await handle.process.spawn(\n `( i=0; while [ ! -f ${stop} ] && [ $i -lt ${PROBE_MAX_TICKS} ]; do ` +\n `printf '.' >> ${heartbeat}; i=$((i+1)); sleep 1; ` +\n `done ) & wait`,\n )\n // Two bytes, not one: one byte is \"it started\", two is \"it is looping\".\n const tickedBeforeKill = await waitForTicks(handle, heartbeat, 2)\n\n await probe.kill()\n await sleep(KILL_SETTLE_MS)\n const atSettle = await fileSize(handle, heartbeat)\n await sleep(HEARTBEAT_QUIET_MS)\n const afterQuietWindow = await fileSize(handle, heartbeat)\n\n expect({\n tickedBeforeKill,\n // An UNREADABLE sample counts as a tick, i.e. fails: the file was\n // provably readable a moment ago (`tickedBeforeKill`), so `null` here\n // means the probe itself broke and the quiet window proves nothing.\n // Two `null`s compare equal, which would otherwise read as \"frozen\".\n tickedAfterKill:\n atSettle === null ||\n afterQuietWindow === null ||\n atSettle !== afterQuietWindow,\n }).toEqual({ tickedBeforeKill: true, tickedAfterKill: false })\n } finally {\n await handle.process\n .exec(`: >> ${stop}; rm -f ${heartbeat}`)\n .catch(() => undefined)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"journal-conformance.js","names":[],"sources":["../../../src/testkit/journal-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for the agent output journal.\n *\n * The journal design rests on two provider-level claims: a command string is\n * framed through a POSIX shell (so `>>` redirection works), and `tail -c +N -f`\n * is available. Both are asserted here against a real sandbox rather than\n * assumed from the audit.\n *\n * A provider that cannot satisfy them MUST declare `unsupported.reason`. There\n * is deliberately no silent-skip path: a conformance case that quietly returns\n * prints as a pass, which is how an unimplemented capability ships green. The\n * three FOLLOW cases obey the same rule through a second declaration,\n * {@link JournalConformanceConfig.followUnsupported} — see {@link itFollows} for\n * why the strategy has to be declared rather than detected at registration time,\n * and {@link expectDeclaredStrategy} for what keeps the declaration honest.\n *\n * THE THIRD FOLLOW CASE TESTS THE OTHER SIDE OF THE BOUNDARY, and it is here\n * because the first two do not. `killableProcesses` is what selects `'follow'`\n * over `'poll'`, and a wrong `true` means `tail -f` is spawned on the assumption\n * it can be reclaimed — leaking one follower per run when it cannot. The two\n * follow cases only ever asserted that the READER stops, which\n * `journal-reader.ts`'s `untilAborted` guarantees on its own by abandoning the\n * pipe the moment the signal fires. So both of them pass a provider whose\n * `kill()` is `() => Promise.resolve()`, and three of the four `true`\n * declarations in this repo were in fact false: Docker's `stream.destroy()` only\n * detached the client, local-process's `sh -c` forks so signalling the shell left\n * the command alive, and Vercel's `kill()` never called the SDK's real\n * `Command.kill` at all. Every one of them shipped green through this suite.\n * \"kills the sandbox-side process, not just the host's view of it\" is the case\n * that fails them — see its own comment for how it probes.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { randomUUID } from 'node:crypto'\nimport { describe, expect, it } from 'vitest'\nimport {\n exitSentinelLine,\n journalExistsCommand,\n journalPaths,\n journalReadCommand,\n journaledCommand,\n} from '../journal'\nimport { journalReadStrategy, readJournal } from '../journal-reader'\nimport type { JournalPaths } from '../journal'\nimport type { SandboxHandle } from '../contracts'\n\nexport interface JournalConformanceConfig {\n /** Provider name, used in the describe title. */\n name: string\n /** Create a live sandbox plus its teardown. */\n createHandle: () => Promise<{\n handle: SandboxHandle\n dispose: () => Promise<void>\n }>\n /**\n * Declare that this provider cannot journal, with the reason. Registers a\n * skipped case whose title carries the reason. Omit it and the suite runs.\n */\n unsupported?: { reason: string }\n /**\n * Declare that this provider's reads take the POLL strategy rather than the\n * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles,\n * because it lacks `backgroundProcesses` or `killableProcesses`. The two follow\n * cases then register as NAMED skips carrying the reason.\n *\n * Declare this ONLY when the provider really cannot follow. It is checked\n * against a live handle in a case that always runs\n * ({@link expectDeclaredStrategy}), so a wrong declaration fails the suite in\n * either direction rather than quietly removing coverage.\n */\n followUnsupported?: { reason: string }\n}\n\n/**\n * Per-case timeout. Every case here spawns a real sandbox and a real agent.\n *\n * 180s, not the 60s this used to be, and it matches the ceiling\n * `takeover-conformance.ts` already gives its heaviest cases. It is the one\n * wall-clock number left in the file and it is deliberately far outside the range\n * any healthy run needs: a case here makes half a dozen provider round-trips, and\n * ONE `docker exec` on a loaded daemon has been measured at 9.6s (see\n * `takeover-conformance.ts`'s `countingExec`) and at 20–45s on a saturated one, so\n * a 60s budget put the timeout itself in the same load-sensitive class as the\n * assertions that were removed from these cases — measured going red on cases that\n * pass in 7–13s each on a quiet machine.\n *\n * This bound exists only so a genuine hang FAILS instead of parking CI; it is not\n * an assertion about speed, and nothing here should be tuned to sit near it.\n */\nconst CASE_TIMEOUT_MS = 180_000\n\n/**\n * Register a case that only means anything on a provider whose reads FOLLOW.\n *\n * `journalReadStrategy` needs a live handle and a live handle needs the async\n * `createHandle`, so the strategy is not knowable when the cases are registered.\n * It is therefore DECLARED, and the declaration selects `it` or `it.skip` here.\n *\n * This exists because the alternative — checking the strategy inside the case and\n * returning early — is the silent-skip the module doc forbids. Such a case prints\n * `✓` with a duration and a title claiming a property was verified while every\n * real assertion in it (including the incremental-delivery handshake, which is\n * the entire reason the follow path exists) was skipped. A named `it.skip` prints\n * `↓` with the reason instead.\n */\nfunction itFollows(\n config: JournalConformanceConfig,\n title: string,\n fn: () => Promise<void>,\n): void {\n const unsupported = config.followUnsupported\n if (unsupported === undefined) {\n it(title, fn, CASE_TIMEOUT_MS)\n return\n }\n it.skip(\n `${title} — follow strategy unsupported: ${unsupported.reason}`,\n fn,\n CASE_TIMEOUT_MS,\n )\n}\n\n/**\n * Assert the live handle's read strategy is the one the config DECLARED.\n *\n * BOTH directions are defects, and neither is a skip. A provider that declared\n * `followUnsupported` but whose handles do follow silently loses the two cases it\n * could pass. One that declared nothing but polls would reach the follow\n * assertions and fail them for a reason unrelated to journaling — which is what\n * the previous `expect(handle.capabilities.killableProcesses).toBe(false)` branch\n * did to a provider with `backgroundProcesses: false, killableProcesses: true`.\n * Either way the config does not describe the provider, and that is worth\n * failing.\n */\nfunction expectDeclaredStrategy(\n handle: SandboxHandle,\n config: JournalConformanceConfig,\n): void {\n expect(journalReadStrategy(handle)).toBe(\n config.followUnsupported === undefined ? 'follow' : 'poll',\n )\n}\n\n/** Decode the base64 frame a journal read command produces into raw text. */\nfunction decodeJournalRead(stdout: string): string {\n return Buffer.from(stdout.replace(/\\s+/g, ''), 'base64').toString('utf8')\n}\n\n/**\n * Block until the run's journal file exists in the sandbox.\n *\n * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see\n * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp`\n * differently, so an `fs` probe would report the wrong file.\n *\n * Exported for `./reaper-conformance.ts`, which needs the same bounded,\n * shell-only wait before probing a still-producing run. Internal to the testkit;\n * not part of the `./testkit` public surface.\n */\nexport async function waitForJournal(\n handle: SandboxHandle,\n paths: JournalPaths,\n): Promise<void> {\n const deadline = Date.now() + 15_000\n for (;;) {\n const probe = await handle.process.exec(journalExistsCommand(paths))\n if (probe.exitCode === 0) return\n if (Date.now() > deadline) {\n throw new Error(`journal conformance: ${paths.journal} never appeared`)\n }\n await sleep(100)\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * An absolute path inside the sandbox that no other case, suite, or machine will\n * touch.\n *\n * Every character is in `[A-Za-z0-9./-]`, so these interpolate into the shell\n * commands below as a single word without quoting. `/tmp` and not the workspace:\n * on local-process a shell redirect reaches the host's real `/tmp` while\n * `handle.fs` resolves under the sandbox root (see rule 3 in `../journal.ts`),\n * and everything here is written AND read through the shell so the two never have\n * to agree.\n */\nfunction noncePath(label: string): string {\n return `/tmp/tanstack-journal-conformance-${label}-${randomUUID()}`\n}\n\n/** Iteration cap on the kill probe's loop, so nothing can outlive the suite. */\nconst PROBE_MAX_TICKS = 600\n\n/**\n * Bound on a journal read, so a reader that delivers nothing FAILS instead of\n * parking CI.\n *\n * Never an assertion, and deliberately far above anything a healthy read needs\n * (measured: 10–18s for the follow cases on both providers). Each case that uses\n * it proves its property some other way — a causal handshake, or\n * `backstop.aborted` — so this number can be raised freely and must never be the\n * thing a case is tuned against.\n */\nconst READ_BACKSTOP_MS = 90_000\n\n/**\n * How long to let an asynchronous kill land before the quiet window opens.\n *\n * A kill is asynchronous on every provider here — Docker signals through a\n * second `exec`, local-process signals a process group and lets the OS reap — so\n * one more heartbeat tick immediately after `kill()` resolves is not a survivor.\n */\nconst KILL_SETTLE_MS = 5_000\n\n/**\n * The quiet window: how long the heartbeat must stay frozen.\n *\n * This is NOT a load-sensitive bound, and the asymmetry is the point. A dead\n * process can never write again, so a slow or busy machine can only make this\n * window MORE reliable, never less — unlike a \"must happen within Nms\" ceiling,\n * which fails on load. Only a live survivor can end this window, and a live\n * survivor writes once a second.\n */\nconst HEARTBEAT_QUIET_MS = 6_000\n\n/**\n * Byte count of `path`, according to the SANDBOX'S OWN shell, or `null` when it\n * cannot be read.\n *\n * `wc -c` through the shell, never `handle.fs`: on local-process the two resolve\n * `/tmp` differently (rule 3), so an `fs` probe would answer about a file the\n * sandbox never wrote and the growth below would look frozen from the first\n * sample — a vacuous pass. Parsed strictly rather than coerced, so a shell\n * diagnostic cannot become `NaN` and compare unequal to itself.\n */\nasync function fileSize(\n handle: SandboxHandle,\n path: string,\n): Promise<number | null> {\n const probe = await handle.process.exec(`wc -c < ${path} 2>/dev/null`)\n const text = probe.stdout.trim()\n return /^\\d+$/.test(text) ? Number(text) : null\n}\n\n/**\n * Wait until `path` has grown to at least `bytes`, i.e. the probe process is\n * provably DOING WORK inside the sandbox, and answer whether it got there.\n *\n * Returning the observation rather than throwing keeps the verdict inside the\n * case's own `expect`: this is the \"before\" half of the assertion, and it is what\n * makes the \"after\" half a live detector instead of a formality.\n */\nasync function waitForTicks(\n handle: SandboxHandle,\n path: string,\n bytes: number,\n): Promise<boolean> {\n const deadline = Date.now() + 30_000\n for (;;) {\n const size = await fileSize(handle, path)\n if (size !== null && size >= bytes) return true\n if (Date.now() > deadline) return false\n // Matched to the heartbeat's own 1s period on purpose. Every poll is a\n // provider round-trip, so a 250ms interval spent three of them per tick it\n // could not possibly observe — pure pressure on the very `exec` path the rest\n // of the case depends on.\n await sleep(1_000)\n }\n}\n\n/**\n * Assert `createHandle` satisfies the journal conformance contract. Each `it`\n * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may\n * share process state across calls without cross-test bleed only if\n * `createHandle` returns an isolated sandbox.\n */\nexport function runJournalConformance(config: JournalConformanceConfig): void {\n describe(`journal conformance — ${config.name}`, () => {\n if (config.unsupported) {\n it.skip(`unsupported: ${config.unsupported.reason}`, () => {\n expect(true).toBe(true)\n })\n return\n }\n\n it(\n \"redirects a command's stdout into the journal and appends the exit sentinel\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n // Checked HERE, in a case that always runs, because\n // `followUnsupported` gates the two follow cases below: a declaration\n // that does not match the live handle must fail the suite rather than\n // remove coverage from it. This is the only place a `poll` declaration\n // can be caught, since the cases it skips never execute.\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-${Date.now()}`)\n const command = journaledCommand(\n `printf '{\"a\":1}\\\\n{\"b\":2}\\\\n'`,\n paths,\n )\n const proc = await handle.process.spawn(command)\n expect(await proc.wait()).toBe(0)\n\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`{\"a\":1}\\n{\"b\":2}\\n${exitSentinelLine(paths, 0)}\\n`)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n \"records the agent's non-zero exit in the sentinel\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-exit-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand('exit 7', paths),\n )\n await proc.wait()\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`${exitSentinelLine(paths, 7)}\\n`)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n \"keeps the agent's stderr out of the journal\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-err-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand(\n `printf '{\"a\":1}\\\\n'; printf 'a warning\\\\n' 1>&2`,\n paths,\n ),\n )\n await proc.wait()\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`{\"a\":1}\\n${exitSentinelLine(paths, 0)}\\n`)\n expect(text).not.toContain('a warning')\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n 'reads incrementally from a byte offset with absolute positions',\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-seek-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand(`printf '{\"a\":1}\\\\n{\"b\":2}\\\\n'`, paths),\n )\n await proc.wait()\n\n const all = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n strategy: 'poll',\n pollIntervalMs: 0,\n // Not an assertion — see {@link READ_BACKSTOP_MS}. This was\n // `AbortSignal.timeout(5_000)`, and it is a POLL read, so it costs one\n // provider round-trip per line: measured going red on a saturated\n // Docker daemon where a single `exec` took ~20s, while the lines it\n // asserts were perfectly correct.\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n all.push(line)\n if (all.length === 3) break\n }\n expect(all.map((l) => l.line)).toEqual([\n '{\"a\":1}',\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n\n const resumed = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: all[0]?.endPosition ?? 0,\n strategy: 'poll',\n pollIntervalMs: 0,\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n resumed.push(line)\n if (resumed.length === 2) break\n }\n expect(resumed.map((l) => l.line)).toEqual([\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n expect(resumed[0]?.endPosition).toBe(all[1]?.endPosition)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n // This case is the reason `journalFollowCommand` pipes into nothing.\n // `tail -f journal | base64` delivers ZERO bytes while the agent is still\n // running — measured on GNU coreutils 8.32 `base64` and on busybox 1.36.1\n // `base64` in Alpine — because the encoder buffers its stdout until its\n // stdin closes, which only happens when the reader kills `tail`.\n //\n // INCREMENTAL, NOT MERELY EVENTUAL, AND PROVED CAUSALLY RATHER THAN BY A\n // STOPWATCH. The agent writes its first line and then BLOCKS on a gate file\n // that only this reader can create, and it creates it only on receiving that\n // first line. So the agent cannot reach its second line until the first was\n // delivered — receiving `{\"b\":2}` at all IS the proof of incremental\n // delivery, and the `toEqual` below is the whole assertion. A buffering\n // filter reintroduced onto the follow path deadlocks instead: nothing is\n // delivered, the gate is never created, the agent never writes its second\n // line, the read ends on its signal and `seen` is empty.\n //\n // This replaced `expect(firstLineMs).toBeLessThan(3_000)`, which measured\n // MACHINE LOAD as much as behavior: it was observed failing in whole-suite\n // fleet runs while passing 5/5 in isolation, because one `exec`/`spawn` is a\n // provider round-trip whose latency this suite does not control (a\n // `docker exec` on a loaded daemon has been measured at 9.6s). Same instinct\n // as `takeover-conformance.ts`'s `countingExec`: anchor on the property, not\n // on the clock. A bound that goes red on a busy machine teaches people to\n // ignore the suite. Do NOT \"fix\" a failure here by widening a window —\n // there is no window left to widen.\n itFollows(\n config,\n 'follows a journal that is still being written, delivering each line before the next is produced',\n async () => {\n // Every assertion below sits after an `await`, so a case that threw its way\n // out of the loop early would report an unrelated failure; this one reports\n // \"nothing was asserted\", which is the failure this case used to HIDE.\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n const gate = noncePath('follow-gate')\n try {\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-follow-${Date.now()}`)\n // The wait is bounded in the SANDBOX too, and on timeout it emits a\n // line that names what went wrong instead of the expected one — so a\n // gate that never arrives fails the `toEqual` with `{\"gate\":\"never\"}`\n // rather than eventually satisfying it. 30 ticks so that diagnostic\n // lands INSIDE `CASE_TIMEOUT_MS`; a longer cap would just time the case\n // out and lose the message.\n const agentCommand =\n `printf '{\"a\":1}\\\\n'; ` +\n `i=0; while [ ! -f ${gate} ]; do ` +\n `i=$((i+1)); ` +\n `if [ $i -gt 30 ]; then printf '{\"gate\":\"never\"}\\\\n'; break; fi; ` +\n `sleep 1; done; ` +\n `printf '{\"b\":2}\\\\n'`\n // Not awaited anywhere: reading the `__exit` sentinel below IS the\n // proof it finished. (`SpawnHandle.wait()` is not safe to call after the\n // fact on every provider — local-process registers a `close` listener at\n // call time, so a `wait()` issued after the process already exited never\n // resolves.)\n void handle.process.spawn(journaledCommand(agentCommand, paths))\n // `tail` on a file that does not exist yet exits immediately, and the\n // agent's spawn and the reader's spawn race. Waiting removes that race\n // WITHOUT touching the property under test: with a buffering filter on\n // the follow path the journal still exists, `tail` still runs, and the\n // reader still receives nothing.\n await waitForJournal(handle, paths)\n // The premise, pinned: the gate is genuinely absent, so the agent\n // really is blocked and its second line really is downstream of this\n // reader. Without this a pre-existing gate path would make the case\n // pass without following anything.\n expect(\n (await handle.process.exec(`test -e ${gate}`)).exitCode,\n ).not.toBe(0)\n const seen: Array<string> = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n // Not the assertion — see {@link READ_BACKSTOP_MS}. The gate's own\n // 30-tick cap fires well inside it, so the `{\"gate\":\"never\"}`\n // diagnostic still reaches this reader.\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n seen.push(line.line)\n // Releases the agent, and only from inside the stream. `touch` is\n // not portable to every BusyBox build with these flags, so this\n // creates the file with a redirect, through the shell — `handle.fs`\n // would write a path the agent's `test -f` cannot see on\n // local-process (rule 3).\n if (seen.length === 1) {\n await handle.process.exec(`: >> ${gate}`)\n }\n if (seen.length === 3) break\n }\n expect(seen).toEqual([\n '{\"a\":1}',\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n } finally {\n // Unblocks the agent even when the case failed, so no `while` loop\n // outlives it on a provider whose sandbox teardown does not reap.\n await handle.process.exec(`: >> ${gate}`).catch(() => undefined)\n await dispose()\n }\n },\n )\n\n // The follow read must obey its own AbortSignal rather than waiting for the\n // provider's `kill` to close the stream — a provider whose kill misses a\n // grandchild would otherwise hang the reader past its deadline.\n itFollows(\n config,\n 'stops a follow read when its signal aborts, without a consumer break',\n async () => {\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n try {\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-abort-${Date.now()}`)\n // Outlives the read on purpose: the journal must still be open, and the\n // agent still running, when the signal fires.\n const agent = await handle.process.spawn(\n journaledCommand(`printf '{\"a\":1}\\\\n'; sleep 30`, paths),\n )\n try {\n await waitForJournal(handle, paths)\n const seen: Array<string> = []\n // The signal fires ON the first line rather than on a stopwatch. The\n // old form was `AbortSignal.timeout(3_000)`, which asked the provider\n // to spawn a `tail` AND deliver a line inside 3s — measured failing on\n // a loaded Windows machine (git-bash `sh` + `tail`, `seen` came back\n // empty) while passing in isolation, the same load-sensitivity as the\n // `firstLineMs` bound the case above replaced. Aborting from inside the\n // stream keeps the property exactly: the agent is still running, the\n // journal is still open, and the reader must stop because its SIGNAL\n // said so.\n const stop = new AbortController()\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. It is not the assertion — `backstopped` below proves it\n // was not what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n signal: AbortSignal.any([stop.signal, backstop]),\n })) {\n seen.push(line.line)\n // No `break`, ever: the pre-fix reader honored a consumer break but\n // rode straight past its signal, so a `break` here would pass it.\n stop.abort()\n }\n expect({ seen, backstopped: backstop.aborted }).toEqual({\n seen: ['{\"a\":1}'],\n // The loop ended, and NOT because the backstop timed out — which is\n // the causal witness that \"the signal ends it at all\", with no clock\n // in the assertion.\n backstopped: false,\n })\n } finally {\n await agent.kill()\n }\n } finally {\n await dispose()\n }\n },\n )\n\n // THE CAPABILITY, not the reader's reaction to it.\n //\n // `killableProcesses` is the flag `journalReadStrategy` reads to choose\n // `'follow'`, and the promise it makes is about the SANDBOX-SIDE process:\n // `tail -f` may be spawned because the caller can reclaim it. Every other\n // case in this file asserts only that the READER stopped, which\n // `untilAborted` delivers unilaterally by abandoning the pipe — so all of\n // them pass a provider whose `kill()` is `() => Promise.resolve()`. This one\n // asks the sandbox itself.\n //\n // WHAT IT SPAWNS, AND WHY THAT SHAPE. The heartbeat loop is BACKGROUNDED\n // (`( … ) & wait`), so the long-lived work is a grandchild of the wrapper\n // shell rather than the wrapper itself. That is deliberate: it is the shape\n // of the defects this bites on. `sh -c '<cmd>'` does not reliably exec its\n // command, so a provider that signals only the wrapper leaves the real work\n // running — measured on local-process POSIX, where `sh -c 'sleep 987654321'`\n // survived `child.kill('SIGKILL')` — and Docker's `kill -SIG -\"$pid\"` group\n // form exists precisely so a backgrounded grandchild is not orphaned. A probe\n // that `exec`ed itself into the wrapper would be reclaimed by the correct and\n // the broken implementation alike, and prove nothing.\n //\n // WHY IT MEASURES WORK AND NOT EXISTENCE. `kill -0 <pid>` was the obvious\n // probe and it is WRONG here, measured: alpine's PID 1 under this provider is\n // `tail -f /dev/null`, which never `wait()`s, so a correctly killed child\n // lingers as an unreaped `[sleep]` forever and `kill -0` answers 0 for it.\n // That fails a healthy provider. `ps` is no better and is why the identity\n // here is a file rather than a nonce in the command line: on local-process\n // under Windows the shell is git-bash, whose MSYS `ps` prints only the process\n // IMAGE PATH (`/usr/bin/sleep`) and never argv — verified, `ps`, `ps -ef` and\n // `ps -W` all omit it — so `ps | grep <nonce>` would match nothing there, and\n // \"no match\" is indistinguishable from \"it is gone\": a vacuous pass on\n // exactly the provider whose kill was broken. (`pgrep` is worse: BusyBox has\n // it, git-bash does not.) A host-side census is not portable at all — Docker's\n // container-side process has no host process, and a remote provider has none\n // either.\n //\n // So the probe is a heartbeat: the process appends one byte per second to a\n // nonce-named file, through the shell. Only a RUNNING process can do that. A\n // zombie cannot, a killed process cannot, and no other test on the machine\n // writes to that path.\n //\n // BOTH OBSERVATIONS ARE ASSERTED, in one object, and the \"before\" one is not\n // decoration: a frozen-file check passes trivially against a file that never\n // grew at all, which is the exact failure shape this whole review keeps\n // turning up. `tickedBeforeKill` is what makes the detector live.\n itFollows(\n config,\n \"kills the sandbox-side process, not just the host's view of it\",\n async () => {\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n const heartbeat = noncePath('killprobe-hb')\n const stop = noncePath('killprobe-stop')\n try {\n expectDeclaredStrategy(handle, config)\n // The `stop` file is how a SURVIVOR is reclaimed in teardown, since a\n // provider that fails this case cannot be trusted to kill it and the\n // suite must not leak a spinner either way. The tick cap is the second\n // net, for a teardown that never ran at all.\n const probe = await handle.process.spawn(\n `( i=0; while [ ! -f ${stop} ] && [ $i -lt ${PROBE_MAX_TICKS} ]; do ` +\n `printf '.' >> ${heartbeat}; i=$((i+1)); sleep 1; ` +\n `done ) & wait`,\n )\n // Two bytes, not one: one byte is \"it started\", two is \"it is looping\".\n const tickedBeforeKill = await waitForTicks(handle, heartbeat, 2)\n\n await probe.kill()\n await sleep(KILL_SETTLE_MS)\n const atSettle = await fileSize(handle, heartbeat)\n await sleep(HEARTBEAT_QUIET_MS)\n const afterQuietWindow = await fileSize(handle, heartbeat)\n\n expect({\n tickedBeforeKill,\n // An UNREADABLE sample counts as a tick, i.e. fails: the file was\n // provably readable a moment ago (`tickedBeforeKill`), so `null` here\n // means the probe itself broke and the quiet window proves nothing.\n // Two `null`s compare equal, which would otherwise read as \"frozen\".\n tickedAfterKill:\n atSettle === null ||\n afterQuietWindow === null ||\n atSettle !== afterQuietWindow,\n }).toEqual({ tickedBeforeKill: true, tickedAfterKill: false })\n } finally {\n await handle.process\n .exec(`: >> ${stop}; rm -f ${heartbeat}`)\n .catch(() => undefined)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
@@ -112,7 +112,7 @@ var FENCE_QUIET_MS = 25;
|
|
|
112
112
|
*/
|
|
113
113
|
var READ_BACKSTOP_MS = 9e4;
|
|
114
114
|
/** Long enough that nothing in this suite is ever classified as expired. */
|
|
115
|
-
var NEVER_EXPIRES_MS =
|
|
115
|
+
var NEVER_EXPIRES_MS = 36e5;
|
|
116
116
|
/**
|
|
117
117
|
* A journal directory nothing else on the machine writes to, created fresh for
|
|
118
118
|
* EVERY case.
|
|
@@ -241,7 +241,7 @@ function conformanceLog() {
|
|
|
241
241
|
});
|
|
242
242
|
return offset;
|
|
243
243
|
})),
|
|
244
|
-
read: () =>
|
|
244
|
+
read: () => (async function* empty() {})(),
|
|
245
245
|
close: () => {
|
|
246
246
|
closes += 1;
|
|
247
247
|
return Promise.resolve();
|
|
@@ -439,11 +439,12 @@ function runReaperConformance(config) {
|
|
|
439
439
|
status: "completed",
|
|
440
440
|
finishedAt: Date.now()
|
|
441
441
|
});
|
|
442
|
-
|
|
442
|
+
const first = await pruneJournals({
|
|
443
443
|
handle,
|
|
444
444
|
runs,
|
|
445
445
|
dir
|
|
446
|
-
})
|
|
446
|
+
});
|
|
447
|
+
expect(first.deleted).toEqual([runId]);
|
|
447
448
|
const second = await pruneJournals({
|
|
448
449
|
handle,
|
|
449
450
|
runs,
|
|
@@ -462,7 +463,8 @@ function runReaperConformance(config) {
|
|
|
462
463
|
kept: [],
|
|
463
464
|
failures: []
|
|
464
465
|
});
|
|
465
|
-
|
|
466
|
+
const rerun = await handle.process.exec(journalCleanupCommand(paths));
|
|
467
|
+
expect(rerun.exitCode).toBe(0);
|
|
466
468
|
} finally {
|
|
467
469
|
await removeDir(handle, dir);
|
|
468
470
|
await dispose();
|
|
@@ -516,7 +518,8 @@ function runReaperConformance(config) {
|
|
|
516
518
|
it("emits stat's self-witness line for a populated directory, so the age gate is usable", { timeout: 6e4 }, async () => {
|
|
517
519
|
const { handle, dispose } = await config.createHandle();
|
|
518
520
|
const dir = caseDir();
|
|
519
|
-
const
|
|
521
|
+
const runId = uniqueRunId("witness");
|
|
522
|
+
const paths = journalPaths(runId, dir);
|
|
520
523
|
try {
|
|
521
524
|
await runAgent(handle, paths, ["1"]);
|
|
522
525
|
const listing = await mtimeListing(handle, dir);
|
|
@@ -800,9 +803,11 @@ function runReaperConformance(config) {
|
|
|
800
803
|
expect(followed).toEqual(["{\"delta\":\"1\"}", exitSentinelLine(paths, 0)]);
|
|
801
804
|
expect(await fileExists(handle, canary)).toBe(false);
|
|
802
805
|
}
|
|
803
|
-
|
|
806
|
+
const sidecar = await handle.process.exec(journalStderrReadCommand(paths));
|
|
807
|
+
expect(decodeJournalRead(sidecar.stdout)).toBe("boom\n");
|
|
804
808
|
expect(await fileExists(handle, canary)).toBe(false);
|
|
805
|
-
|
|
809
|
+
const names = await listNames(handle, dir);
|
|
810
|
+
expect(names.sort()).toEqual([journalName, basename(dir, paths.stderr)].sort());
|
|
806
811
|
expect(decodeJournalRunId(journalName)).toEqual({
|
|
807
812
|
kind: "runId",
|
|
808
813
|
runId
|