Skip to content

Commit 6f563ef

Browse files
committed
fix: finish Windows CI portability
1 parent c497331 commit 6f563ef

6 files changed

Lines changed: 43 additions & 24 deletions

File tree

scripts/zcode-broker.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
44
import { chmod, readFile, unlink } from 'node:fs/promises';
55
import net from 'node:net';
6+
import { tmpdir } from 'node:os';
67
import { dirname, join } from 'node:path';
78
import { fileURLToPath } from 'node:url';
89

@@ -105,7 +106,11 @@ export async function ensureZCodeBroker(options) {
105106
if ((options.platform ?? process.platform) !== 'win32') await unlink(endpoint).catch(() => {});
106107
const configPath = join(brokerDirectory, `config-${instanceId}.json`);
107108
await atomicWriteJson(configPath, { endpoint, instanceId, brokerToken, launch: options.launch, workspace: storage.workspacePath, idleTimeoutMs: options.idleTimeoutMs, maxFrameBytes: options.maxFrameBytes, maxOutboundBytes: options.maxOutboundBytes, drainTimeoutMs: options.drainTimeoutMs, ownershipPath: join(brokerDirectory, profile ? `session-owners-${profile}.json` : 'session-owners.json'), identityPath });
108-
const child = await spawnDaemon({ command: process.execPath, args: [fileURLToPath(import.meta.url)], target: fileURLToPath(import.meta.url) }, { args: [configPath], cwd: storage.workspacePath, env: options.env });
109+
// Keep the daemon's process cwd outside the workspace. Windows holds the
110+
// cwd directory open for the lifetime of the process, which otherwise
111+
// prevents callers from removing short-lived workspace fixtures (and can
112+
// make a real workspace impossible to rename or delete).
113+
const child = await spawnDaemon({ command: process.execPath, args: [fileURLToPath(import.meta.url)], target: fileURLToPath(import.meta.url) }, { args: [configPath], cwd: tmpdir(), env: options.env });
109114
const record = await writeBrokerIdentity(identityPath, { endpoint, pid: child.pid, instanceId, brokerToken });
110115
const deadline = Date.now() + 5_000;
111116
while (Date.now() < deadline) {

tests/fixtures/lock-holder.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// @ts-nocheck
2+
import process from 'node:process';
3+
4+
import { withFileLock } from '../../scripts/lib/fs.mjs';
5+
6+
const lockPath = process.argv[2];
7+
if (!lockPath) throw new Error('lock path required');
8+
9+
try {
10+
await withFileLock(lockPath, async () => {
11+
process.stdout.write('acquired\n');
12+
await new Promise((resolve) => process.stdin.once('data', resolve));
13+
}, { pollIntervalMs: 5, timeoutMs: 1_000 });
14+
process.stdout.write('released\n');
15+
} catch (error) {
16+
process.stderr.write(`${error?.stack ?? error}\n`);
17+
process.exitCode = 1;
18+
}

tests/fixtures/stop-gate-with-timeout.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ const dataRoot = process.env.PLUGIN_DATA;
88
if (!dataRoot) throw new Error('PLUGIN_DATA required');
99
const discoveryCode = process.env.FAKE_GATE_DISCOVERY_ERROR;
1010
const discoverZCode = discoveryCode ? async () => { throw Object.assign(new Error('fixture discovery failure'), { code: discoveryCode }); } : undefined;
11-
process.stdout.write(JSON.stringify(await runStopReviewGate(input, { dataRoot, env: process.env, timeoutMs: 100, ...(discoverZCode ? { discoverZCode } : {}) })));
11+
// Windows child startup can consume most of a 100 ms budget under CI. Keep
12+
// the deliberately suppressed completion as the timeout trigger while leaving
13+
// enough time for the fake protocol process to create its session.
14+
const timeoutMs = process.platform === 'win32' ? 2_000 : 100;
15+
process.stdout.write(JSON.stringify(await runStopReviewGate(input, { dataRoot, env: process.env, timeoutMs, ...(discoverZCode ? { discoverZCode } : {}) })));

tests/integration/skills.test.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,10 @@ test('direct background invocation keeps capabilities private and production own
228228
const jobId = /Reserved background job ([a-f0-9]{64})\./.exec(launched.stdout)?.[1];
229229
assert.ok(jobId, launched.stdout); assert.doesNotMatch(`${launched.stdout}${launched.stderr}${launched.spawnargs.join(' ')}`, /executionCapability|callerContext|privateInvocation/);
230230
const store = createStateStore({ dataRoot: ctx.env.PLUGIN_DATA }); let job;
231-
const deadline = Date.now() + (process.platform === 'win32' ? 15_000 : 5_000);
231+
// Windows CI can be heavily contended while several independent fixtures
232+
// start brokers and native lock probes. Keep the worker bounded, but leave
233+
// enough room for that startup/lock contention before declaring it stuck.
234+
const deadline = Date.now() + (process.platform === 'win32' ? 30_000 : 5_000);
232235
do { job = await store.readJob(ctx.workspace, jobId); if (['succeeded', 'failed', 'cancelled'].includes(job.status)) break; await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); } while (Date.now() < deadline);
233236
await ensureWorkerStopped(job.childPid);
234237
assert.equal(job.status, 'succeeded', JSON.stringify(job.error));

tests/state.test.mjs

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from 'node:fs/promises';
1515
import { tmpdir } from 'node:os';
1616
import { join } from 'node:path';
17+
import { fileURLToPath } from 'node:url';
1718
import test from 'node:test';
1819

1920
import { PluginError } from '../scripts/lib/errors.mjs';
@@ -38,27 +39,14 @@ const jobInput = {
3839
};
3940

4041
const fsModuleUrl = new URL('../scripts/lib/fs.mjs', import.meta.url).href;
42+
const lockHolder = fileURLToPath(new URL('./fixtures/lock-holder.mjs', import.meta.url));
4143

4244
/** @param {string} lockPath */
4345
function startLockHolder(lockPath) {
44-
const source = `
45-
import { withFileLock } from ${JSON.stringify(fsModuleUrl)};
46-
const lockPath = process.argv[1];
47-
try {
48-
await withFileLock(lockPath, async () => {
49-
process.stdout.write('acquired\\n');
50-
await new Promise((resolve) => process.stdin.once('data', resolve));
51-
}, {
52-
pollIntervalMs: 5,
53-
timeoutMs: 1_000,
54-
});
55-
process.stdout.write('released\\n');
56-
} catch (error) {
57-
process.stdout.write(\`error:\${error.code}\\n\`);
58-
}
59-
`;
60-
return spawn(process.execPath, ['--input-type=module', '--eval', source, lockPath], {
46+
return spawn(process.execPath, [lockHolder, lockPath], {
6147
stdio: ['pipe', 'pipe', 'pipe'],
48+
shell: false,
49+
windowsHide: true,
6250
});
6351
}
6452

@@ -95,11 +83,11 @@ function startTimedLockAttempt(lockPath) {
9583

9684
/** @param {import('node:child_process').ChildProcess} child @param {string} expected */
9785
async function waitForOutput(child, expected) {
98-
let output = '';
86+
let output = ''; let stderr = '';
9987
await new Promise((resolve, reject) => {
10088
const timeout = setTimeout(() => {
10189
cleanup();
102-
reject(new Error(`Timed out waiting for child output: ${expected}; received: ${output}`));
90+
reject(new Error(`Timed out waiting for child output: ${expected}; received: ${output}; stderr: ${stderr}`));
10391
}, 2_000);
10492
function cleanup() {
10593
clearTimeout(timeout);
@@ -117,9 +105,10 @@ async function waitForOutput(child, expected) {
117105
/** @param {number | null} code */
118106
function onExit(code) {
119107
cleanup();
120-
reject(new Error(`Child exited with ${code}; output: ${output}`));
108+
reject(new Error(`Child exited with ${code}; output: ${output}; stderr: ${stderr}`));
121109
}
122110
child.stdout?.on('data', onData);
111+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
123112
child.once('exit', onExit);
124113
});
125114
}

tests/zcode-client.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ test('actual 0.16.1 snapshot and list required fields are enforced', async (t) =
330330
});
331331

332332
test('invented and malformed nested 0.16.1 response fields are rejected', async (t) => {
333-
for (const variant of ['invented-session-kind', 'invented-subagent-kind', 'bad-protocol', 'missing-model-label', 'string-message-model', 'bad-goal-stats', 'bad-permission-origin', 'bad-runtime-cache', 'bad-timeline-trigger', 'bad-provider-options']) await t.test(variant, () => withClient(async (client) => { await assert.rejects(client.createSession({ workspace: '/repo' }), { code: 'ZCODE_OUTPUT_INVALID' }); }, { FAKE_ZCODE_BAD_SNAPSHOT: variant }));
333+
for (const variant of ['invented-session-kind', 'invented-subagent-kind', 'bad-protocol', 'missing-model-label', 'string-message-model', 'bad-goal-stats', 'bad-permission-origin', 'bad-runtime-cache', 'bad-timeline-trigger', 'bad-provider-options']) await t.test(variant, () => withClient(async (client) => { await assert.rejects(client.createSession({ workspace: '/repo' }), { code: 'ZCODE_OUTPUT_INVALID' }); }, { FAKE_ZCODE_BAD_SNAPSHOT: variant }, process.platform === 'win32' ? { requestTimeoutMs: 2_000, completionTimeoutMs: 2_000 } : {}));
334334
});
335335

336336
test('harmless additive response fields are accepted with wire protocol version 1', async () => {

0 commit comments

Comments
 (0)