Skip to content

Commit 411fa4e

Browse files
committed
diag: Windows kill-after-exit stress (scratch, do not merge)
1 parent 732ed4a commit 411fa4e

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

‎.diag/guarded.test.ts‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
import { spawnSync } from 'node:child_process';
6+
import { createPendingQuestionRecorder, readPendingQuestion, recordPendingQuestion } from './helpers/plan-count-pending-question';
7+
import { readPlanCountTranscript } from './helpers/plan-count-transcript';
8+
import captured from './fixtures/autoplan-routing-manual-skills-ac.json';
9+
10+
// Exact retained public question; hook envelopes and owned temp paths are
11+
// synthetic controls. AC retry 2's unpublished native payload is unknown.
12+
function fixture() {
13+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pending-auq-' quote $()-"));
14+
const cwd = path.join(root, 'repo with spaces');
15+
const config = path.join(root, 'config');
16+
const project = path.join(config, 'projects', 'fixture');
17+
fs.mkdirSync(cwd, { recursive: true });
18+
fs.mkdirSync(project, { recursive: true });
19+
const session = captured.call.sessionId;
20+
const transcriptPath = path.join(project, `${session}.jsonl`);
21+
const startedAt = Date.now();
22+
fs.writeFileSync(transcriptPath, JSON.stringify({ cwd, sessionId: session, isSidechain: false,
23+
timestamp: new Date().toISOString(), message: { role: 'assistant', content: [{ type: 'text', text: 'Review setup.' }] },
24+
}) + '\n');
25+
const recorder = createPendingQuestionRecorder(cwd, config);
26+
const event = (kind = 'PreToolUse', id = captured.call.toolUseId) => ({ hook_event_name: kind,
27+
tool_name: 'AskUserQuestion', session_id: session, tool_use_id: id, cwd,
28+
transcript_path: transcriptPath, tool_input: { questions: structuredClone(captured.call.questions) },
29+
});
30+
const write = (value: unknown) => recordPendingQuestion(typeof value === 'string' ? value : JSON.stringify(value), recorder.file, cwd, config);
31+
const transcript = () => readPlanCountTranscript(config, cwd);
32+
const read = (native = transcript(), after = startedAt) => readPendingQuestion(recorder.file, cwd, config, after, native);
33+
const dispose = () => { recorder.dispose(); fs.rmSync(root, { recursive: true, force: true }); };
34+
return { root, cwd, config, recorder, startedAt, session, transcriptPath, event, write, read, transcript, dispose };
35+
}
36+
37+
describe('opt-in pending native AskUserQuestion capture', () => {
38+
test('a scoped request supplies pending identity, never an answer or coverage', () => {
39+
const f = fixture();
40+
try {
41+
expect(f.read()).toBeUndefined();
42+
f.write(f.event());
43+
expect(f.read()).toMatchObject({ sessionId: f.session, toolUseId: captured.call.toolUseId,
44+
source: 'pre_tool_use', answered: false, questions: captured.call.questions });
45+
expect(f.read()?.answers).toBeUndefined();
46+
expect(f.read()?.answeredAt).toBeUndefined();
47+
} finally { f.dispose(); }
48+
});
49+
50+
test.each(['PostToolUse', 'PostToolUseFailure'])('%s closes a request and a replay cannot reopen it', kind => {
51+
const f = fixture();
52+
try {
53+
f.write(f.event());
54+
expect(f.read()).toBeDefined();
55+
f.write(f.event(kind));
56+
expect(f.read()).toBeUndefined();
57+
f.write(f.event());
58+
expect(f.read()).toBeUndefined();
59+
f.write(f.event('PreToolUse', 'toolu_new_owned_question'));
60+
expect(f.read()?.toolUseId).toBe('toolu_new_owned_question');
61+
} finally { f.dispose(); }
62+
});
63+
64+
test('a duplicate pending request does not refresh its evidence timestamp', async () => {
65+
const f = fixture();
66+
try {
67+
f.write(f.event());
68+
expect(f.read()).toBeDefined();
69+
const before = fs.readFileSync(f.recorder.file, 'utf8');
70+
await Bun.sleep(5);
71+
f.write(f.event());
72+
expect(fs.readFileSync(f.recorder.file, 'utf8')).toBe(before);
73+
} finally { f.dispose(); }
74+
});
75+
76+
test('a completion observed before its request cannot reopen', () => {
77+
const f = fixture();
78+
try {
79+
f.write(f.event('PostToolUse'));
80+
f.write(f.event());
81+
expect(f.read()).toBeUndefined();
82+
} finally { f.dispose(); }
83+
});
84+
85+
test('concurrent calls and conflicting same-call payloads poison the ambiguous pending capture', () => {
86+
for (const sameId of [false, true]) {
87+
const f = fixture();
88+
try {
89+
f.write(f.event());
90+
const next = f.event('PreToolUse', sameId ? captured.call.toolUseId : 'toolu_other_pending');
91+
if (sameId) next.tool_input.questions[0]!.question += ' Changed.';
92+
f.write(next);
93+
expect(f.read()).toBeUndefined();
94+
f.write(f.event('PostToolUse'));
95+
f.write(f.event('PreToolUse', 'toolu_later'));
96+
expect(f.read()).toBeUndefined();
97+
} finally { f.dispose(); }
98+
}
99+
});
100+
101+
test.each(['cwd', 'session', 'subagent'])('foreign %s cannot replace or clear the owned request', field => {
102+
const f = fixture();
103+
try {
104+
f.write(f.event());
105+
const prior = f.read();
106+
for (const kind of ['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) {
107+
const event: Record<string, unknown> = f.event(kind);
108+
if (field === 'cwd') event.cwd = path.join(f.root, 'foreign');
109+
if (field === 'session') {
110+
event.session_id = '3f7e6255-331b-4c3f-b5b6-cc9481be0548';
111+
event.transcript_path = path.join(path.dirname(f.transcriptPath), `${event.session_id}.jsonl`);
112+
fs.writeFileSync(event.transcript_path as string, '');
113+
}
114+
if (field === 'subagent') event.agent_id = 'foreign-subagent';
115+
f.write(event);
116+
expect(f.read()).toEqual(prior);
117+
}
118+
} finally { f.dispose(); }
119+
});
120+
121+
test.each(['empty questions', 'too many questions', 'too few options', 'too many options', 'invalid option',
122+
'oversize', 'outside transcript path', 'wrong tool name', 'missing cwd'])('malformed scoped %s fails closed', kind => {
123+
const f = fixture();
124+
try {
125+
f.write(f.event());
126+
const event = f.event();
127+
const question = event.tool_input.questions[0]!;
128+
if (kind === 'empty questions') event.tool_input.questions = [];
129+
if (kind === 'too many questions') event.tool_input.questions = Array.from({ length: 5 }, () => structuredClone(question));
130+
if (kind === 'too few options') question.options.splice(1);
131+
if (kind === 'too many options') question.options = Array.from({ length: 5 }, (_, i) => ({ label: `Option ${i}`, description: '' }));
132+
if (kind === 'invalid option') question.options[0]!.label = '';
133+
if (kind === 'oversize') question.question = 'x'.repeat(128 * 1024);
134+
if (kind === 'outside transcript path') event.transcript_path = path.join(f.root, `${f.session}.jsonl`);
135+
if (kind === 'wrong tool name') event.tool_name = 'Write';
136+
if (kind === 'missing cwd') delete (event as Partial<typeof event>).cwd;
137+
f.write(event);
138+
expect(f.read()).toBeUndefined();
139+
f.write(f.event());
140+
expect(f.read()).toBeUndefined();
141+
} finally { f.dispose(); }
142+
});
143+
144+
test('a late result for another call cannot clear the current pending request', () => {
145+
const f = fixture();
146+
try {
147+
f.write(f.event());
148+
const current = f.read();
149+
f.write(f.event('PostToolUse', 'toolu_earlier_call'));
150+
expect(f.read()).toEqual(current);
151+
f.write(f.event('PreToolUse', 'toolu_earlier_call'));
152+
expect(f.read()).toEqual(current);
153+
} finally { f.dispose(); }
154+
});
155+
156+
test('read requires the same ready native session, isolated directory and current epoch', () => {
157+
const f = fixture();
158+
try {
159+
f.write(f.event());
160+
const native = f.transcript();
161+
expect(f.read(native)).toBeDefined();
162+
expect(f.read(native, Date.now() + 1_000)).toBeUndefined();
163+
for (const status of ['missing', 'error'] as const) {
164+
expect(f.read({ ...native, status })).toBeUndefined();
165+
}
166+
const foreign = structuredClone(native);
167+
foreign.assistantMessages[0]!.sessionId = '3f7e6255-331b-4c3f-b5b6-cc9481be0548';
168+
expect(f.read(foreign)).toBeUndefined();
169+
const mixed = structuredClone(native);
170+
mixed.assistantMessages.push({ ...foreign.assistantMessages[0]! });
171+
expect(f.read(mixed)).toBeUndefined();
172+
expect(readPendingQuestion(undefined, f.cwd, f.config, f.startedAt, native)).toBeUndefined();
173+
expect(readPendingQuestion(f.recorder.file, f.cwd, null, f.startedAt, native)).toBeUndefined();
174+
expect(readPendingQuestion(f.recorder.file, path.join(f.root, 'other'), f.config, f.startedAt, native)).toBeUndefined();
175+
} finally { f.dispose(); }
176+
});
177+
178+
test('published native identity takes precedence even while unanswered or failed', () => {
179+
const f = fixture();
180+
try {
181+
f.write(f.event());
182+
for (const state of [{ answered: false }, { answered: true }, { answered: false, failed: true }]) {
183+
const native = f.transcript();
184+
native.calls.push({ ...structuredClone(captured.call), sessionId: f.session, ...state });
185+
expect(f.read(native)).toBeUndefined();
186+
}
187+
} finally { f.dispose(); }
188+
});
189+
190+
test('generated shell hooks are exact, bounded, correctly quoted and silent on valid or broken input', () => {
191+
const f = fixture();
192+
try {
193+
expect(Object.keys(f.recorder.hooks).sort()).toEqual(['PostToolUse', 'PostToolUseFailure', 'PreToolUse']);
194+
for (const kind of ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] as const) {
195+
const matchers = f.recorder.hooks[kind];
196+
expect(matchers).toHaveLength(1);
197+
expect(matchers[0]!.matcher).toBe('^AskUserQuestion$');
198+
expect(matchers[0]!.hooks).toHaveLength(1);
199+
const hook = matchers[0]!.hooks[0]!;
200+
expect(hook.timeout).toBe(5);
201+
for (const input of [JSON.stringify(f.event(kind)), '{invalid json']) {
202+
const result = spawnSync('bash', ['-c', hook.command], { cwd: f.cwd, input, encoding: 'utf8', timeout: 6_000 });
203+
expect(result.error).toBeUndefined();
204+
expect(result.status).toBe(0);
205+
expect(result.stdout).toBe('');
206+
}
207+
}
208+
} finally { f.dispose(); }
209+
});
210+
test('a hook whose input never ends closes within its own bound and remains silent', async () => {
211+
const f = fixture();
212+
const hook = f.recorder.hooks.PreToolUse[0]!.hooks[0]!;
213+
const child = Bun.spawn(['bash', '-c', ['exec', hook.command].join(' ')], { cwd: f.cwd,
214+
stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
215+
// Use an actual shell command argument; no fixture string is interpolated.
216+
let forced = false;
217+
const timer = setTimeout(() => { forced = true; child.kill('SIGKILL'); }, 6_000);
218+
try {
219+
const [code, stdout] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
220+
expect(forced).toBe(false);
221+
expect(code).toBe(0);
222+
expect(stdout).toBe('');
223+
expect(f.read()).toBeUndefined();
224+
expect(fs.existsSync(f.recorder.file + '.invalid')).toBe(true);
225+
} finally {
226+
clearTimeout(timer);
227+
if (child.exitCode === null && child.signalCode === null) {
228+
child.stdin.end();
229+
child.kill('SIGKILL');
230+
}
231+
await child.exited;
232+
f.dispose();
233+
}
234+
}, 7_000);
235+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: diag-win-kill
2+
on:
3+
push:
4+
branches: [capy/diag-win-kill]
5+
permissions:
6+
contents: read
7+
jobs:
8+
stress:
9+
runs-on: windows-latest
10+
timeout-minutes: 30
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
variant: [orig, guarded, orig-bun142]
15+
n: [1, 2, 3, 4]
16+
steps:
17+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
18+
with:
19+
persist-credentials: false
20+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
21+
with:
22+
bun-version: ${{ matrix.variant == 'orig-bun142' && '1.4.2' || '1.4.0' }}
23+
- name: Select variant
24+
shell: bash
25+
run: |
26+
git config --global user.email "windows-ci@gstack.test"
27+
git config --global user.name "Windows CI"
28+
if [ "${{ matrix.variant }}" = guarded ]; then cp .diag/guarded.test.ts test/autoplan-pending-question.test.ts; fi
29+
bun --version
30+
- run: bun install --frozen-lockfile
31+
- name: Stress
32+
shell: bash
33+
env:
34+
TEMP: ${{ runner.temp }}
35+
TMP: ${{ runner.temp }}
36+
run: |
37+
fails=0; aborts=0
38+
for i in $(seq 1 25); do
39+
set +e
40+
bun test test/autoplan-pending-question.test.ts > out.txt 2>&1
41+
code=$?
42+
set -e
43+
if grep -q -E 'GetQueuedCompletionStatus|GLib-ERROR|EBADF' out.txt; then aborts=$((aborts+1)); grep -E 'GetQueuedCompletionStatus|GLib-ERROR|EBADF' out.txt | head -2; fi
44+
if [ $code -ne 0 ]; then fails=$((fails+1)); echo "iter $i exit $code"; tail -4 out.txt; fi
45+
done
46+
echo "RESULT variant=${{ matrix.variant }} n=${{ matrix.n }} iterations=25 nonzero=$fails native_aborts=$aborts"

0 commit comments

Comments
 (0)