From 43ab40e7bb3718074d755867d2716be7f062c8cd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 9 Sep 2026 03:45:56 +0000 Subject: [PATCH] test: host termination kills the vendor group; brace banners, decoys and format characters; non-reclaimable stale lock gives up; ledger line in status Co-Authored-By: Claude Fable 5.1 --- test/gstack-memorable.test.ts | 22 +++++++++++++- test/memorable-user-prompt-hook.test.ts | 40 ++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/test/gstack-memorable.test.ts b/test/gstack-memorable.test.ts index a25eb52be..5979f2b24 100644 --- a/test/gstack-memorable.test.ts +++ b/test/gstack-memorable.test.ts @@ -422,7 +422,9 @@ describe('enable/disable failure paths (coverage audit)', () => { const w = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-egress-receipt'), 'write', '--sink', 'memorable-recall', '--host', 'local:/x/memorable', '--class', 'c', '--no-payload', '--consent', 'memorable_recall=on'], { env, encoding: 'utf8', timeout: 20_000 }); expect(w.status).toBe(0); } - expect(run(['status']).stdout).toContain('receipts: 2 for sink memorable-recall'); + const st = run(['status']).stdout; + expect(st).toContain('receipts: 2 for sink memorable-recall'); + expect(st).toMatch(/ledger: .*egress\.jsonl \(\d+ KiB; this sink appends two lines per prompt\)/); }); }); @@ -446,6 +448,24 @@ describe('lifecycle lock and static pins', () => { expect(fs.existsSync(lock)).toBe(false); }); + test('a stale lock that cannot be reclaimed (locks dir not writable) still reaches the 5 s give-up instead of spinning', () => { + if (!canRevokeWrites()) return; // chmod is advisory here + const locksDir = path.join(env.GSTACK_HOME, 'locks'); + const lock = path.join(locksDir, 'memorable-bridge.lock'); + fs.mkdirSync(lock, { recursive: true }); + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lock, old, old); + fs.chmodSync(locksDir, 0o555); // mv/rmdir of the stale lock now fails + const t0 = Date.now(); + let r; + try { r = run(['disable']); } finally { fs.chmodSync(locksDir, 0o755); } + const wall = Date.now() - t0; + expect(r.status).toBe(5); + expect(r.stderr).toContain('another gstack-memorable is running'); + expect(wall).toBeGreaterThan(4000); + expect(wall).toBeLessThan(12_000); + }, 30_000); + test('a fresh lock with no bookkeeping yet (the mkdir-to-owner gap) is waited on, never reclaimed', () => { const lock = path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock'); fs.mkdirSync(lock, { recursive: true }); // no owner, no ts: a holder that just won mkdir diff --git a/test/memorable-user-prompt-hook.test.ts b/test/memorable-user-prompt-hook.test.ts index 7d5148a3b..fb4e5a963 100644 --- a/test/memorable-user-prompt-hook.test.ts +++ b/test/memorable-user-prompt-hook.test.ts @@ -51,7 +51,7 @@ if [ "$MODE" = exit-before-read ]; then exit 0; fi if [ "$MODE" = print-before-read ]; then cat "$HOME/out.json"; exit 0; fi cat > "$HOME/stdin.bin" case "$MODE" in - sleep) sleep 10 ;; + sleep) sleep "10.\${MEMORABLE_TEST_NONCE:-0}" ;; fork-sleep) sh -c "sleep 30.\${MEMORABLE_TEST_NONCE:-0}" ;; bg-then-exit) sh -c "sleep 20.\${MEMORABLE_TEST_NONCE:-0}" & cat "$HOME/out.json"; exit 0 ;; echo-stderr) cat "$HOME/stdin.bin" >&2; exit 1 ;; @@ -474,10 +474,17 @@ describe('pure helpers', () => { expect(firstJsonObject('{"unterminated": ')).toBeNull(); expect(firstJsonObject('no braces here')).toBeNull(); expect(firstJsonObject('{"s": "\\"}"}')).toEqual({ s: '"}' }); + // a banner WITH braces or quotes before the answer, and a decoy object without hookSpecificOutput + expect(pickAdditionalContext(`loaded {3} memories\n${answer}`)).toBe('kept {"}"} braces in strings'); + expect(pickAdditionalContext(`warn: "{" unexpected\n${answer}`)).toBe('kept {"}"} braces in strings'); + expect(pickAdditionalContext(`{"progress": 1}\n${answer}`)).toBe('kept {"}"} braces in strings'); + expect(pickAdditionalContext('{a {a {a {a')).toBeNull(); }); - test('stripControl drops C0 controls, CR and DEL but keeps tab and newline', () => { + test('stripControl drops C0 controls, CR, DEL and Unicode format characters but keeps tab, newline and ZWJ', () => { const input = 'a' + String.fromCharCode(0) + 'b' + String.fromCharCode(27) + '\tc\nd' + String.fromCharCode(127) + 'e\rf\r\ng'; expect(stripControl(input)).toBe('ab\tc\ndef\ng'); + expect(stripControl('x\u202Ey\u200Bz\u00ADw')).toBe('xyzw'); // bidi override, ZWSP, soft hyphen + expect(stripControl('\u{1F468}\u200D\u{1F4BB}')).toBe('\u{1F468}\u200D\u{1F4BB}'); // ZWJ emoji sequence intact }); test('safeStderrTail passes plain diagnostics and withholds a tail carrying a MEDIUM or HIGH shape', () => { expect(safeStderrTail(' auth failed:\n retry later ')).toBe('auth failed: retry later'); @@ -508,6 +515,10 @@ describe('pure helpers', () => { logHookError('vendor timeout: at 12:00:01', t0 + LOG_RATE_LIMIT_MS + 2, 'vendor timeout'); logHookError('vendor timeout: at 12:00:02', t0 + LOG_RATE_LIMIT_MS + 3, 'vendor timeout'); expect(lines()).toHaveLength(4); + // two alternating failures within the window cost two lines, not one per prompt + const t1 = t0 + 2 * LOG_RATE_LIMIT_MS; + logHookError('X', t1); logHookError('Y', t1 + 1); logHookError('X', t1 + 2); logHookError('Y', t1 + 3); + expect(lines()).toHaveLength(6); if (process.platform !== 'win32') expect(fs.statSync(path.join(home, '.gstack', 'hook-errors.log')).mode & 0o077).toBe(0); } finally { if (prev === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prev; @@ -644,12 +655,12 @@ describe('trust-policy lookup outcomes (review coverage, second pass)', () => { const set = spawnSync('bash', [POLICY, 'set', 'https://github.com/example/unrelated.git', 'deny'], { env, encoding: 'utf8', timeout: 20_000 }); expect(set.status).toBe(0); } - test('store present, cwd is a plain directory (not a repo): recall proceeds', () => { + test('store present, cwd is a plain directory (not a repo): recall proceeds, even under a non-English locale', () => { gateOn(); withStore(); const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-plain-')); try { - const r = runHook(JSON.stringify({ prompt: 'hello', cwd: plain }), {}, plain); + const r = runHook(JSON.stringify({ prompt: 'hello', cwd: plain }), { LANG: 'de_DE.UTF-8', LANGUAGE: 'de_DE:de', LC_ALL: 'de_DE.UTF-8' }, plain); expect(r.stdout).toContain('remembered'); expect(receipts()).toHaveLength(1); } finally { @@ -685,3 +696,24 @@ describe('trust-policy lookup outcomes (review coverage, second pass)', () => { } }); }); + +describe('host termination mid-flight', () => { + test('SIGTERM to the shim while the vendor is running: the vendor group dies with it, exit 0, logged', async () => { + gateOn(); + fs.writeFileSync(path.join(home, 'mode'), 'sleep'); + const nonce = `${process.pid}${Date.now()}`; + const child = Bun.spawn(['bash', HOOK], { stdin: Buffer.from(PROMPT), env: { ...env, MEMORABLE_TEST_NONCE: nonce }, stdout: 'pipe', stderr: 'pipe' }); + // wait until the fake vendor is up (its calls.log line), then terminate the shim the way a host would + for (let i = 0; i < 100 && !calls(); i++) await Bun.sleep(30); + expect(calls()).toBe('hook user-prompt\n'); + await Bun.sleep(150); + child.kill('SIGTERM'); + const code = await child.exited; + expect(code).toBe(0); + await Bun.sleep(200); + const survivors = spawnSync('sh', ['-c', `ps -eo args | grep '^sleep 10.${nonce}$' || true`], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + expect(survivors).toBe(''); + expect(errLog()).toContain('terminated by SIGTERM'); + expect(receipts()).toHaveLength(1); // the receipt stands; its outcome is missing (reads unknown) + }, 15_000); +});