mixdog 0.9.50 → 0.9.52
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/package.json +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import http from 'node:http';
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
15
16
|
import { spawn } from 'node:child_process';
|
|
16
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
19
20
|
import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
|
|
@@ -28,6 +29,11 @@ const waitFor = async (fn, ms = 1500) => {
|
|
|
28
29
|
while (Date.now() < end) { if (fn()) return true; await delay(20); }
|
|
29
30
|
return fn();
|
|
30
31
|
};
|
|
32
|
+
const waitForAsync = async (fn, ms = 1500) => {
|
|
33
|
+
const end = Date.now() + ms;
|
|
34
|
+
while (Date.now() < end) { if (await fn()) return true; await delay(20); }
|
|
35
|
+
return await fn();
|
|
36
|
+
};
|
|
31
37
|
let failures = 0;
|
|
32
38
|
function check(label, cond) {
|
|
33
39
|
const ok = !!cond;
|
|
@@ -160,6 +166,15 @@ async function main() {
|
|
|
160
166
|
await reattachBufferTest();
|
|
161
167
|
await pointerFailoverTest();
|
|
162
168
|
await pointerReconnectFollowsTest();
|
|
169
|
+
await tokenReplacementRetirementTest();
|
|
170
|
+
await tokenReplacementReplayTest();
|
|
171
|
+
await tokenReplacementResponseLossCloseTest();
|
|
172
|
+
await registrationReplayTtlTest();
|
|
173
|
+
await registrationReplayCleanupTest();
|
|
174
|
+
await staleTokenCallTest();
|
|
175
|
+
await clientReconnectSafetyTest();
|
|
176
|
+
await clientTruncatedReplacementCloseTest();
|
|
177
|
+
await workerAttachSafetyTest();
|
|
163
178
|
|
|
164
179
|
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
|
165
180
|
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -176,7 +191,7 @@ async function flipTest() {
|
|
|
176
191
|
process.env.MIXDOG_DATA_DIR = tmp;
|
|
177
192
|
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
178
193
|
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
179
|
-
const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
|
|
194
|
+
const { readDaemonDiscovery, probeDaemonHealth } = await import('../src/standalone/channel-daemon-client.mjs');
|
|
180
195
|
const discFile = path.join(tmp, 'channel-daemon.json');
|
|
181
196
|
|
|
182
197
|
const notA = [];
|
|
@@ -199,17 +214,22 @@ async function flipTest() {
|
|
|
199
214
|
notB.some((m) => m?.params?.content === 'ping-from-stub') &&
|
|
200
215
|
!notA.some((m) => m?.params?.content === 'ping-from-stub'));
|
|
201
216
|
|
|
202
|
-
// (fix 1) daemon death mid-session:
|
|
203
|
-
//
|
|
204
|
-
//
|
|
217
|
+
// (fix 1) daemon death mid-session: stale SSE clients must immediately
|
|
218
|
+
// invalidate and re-read discovery. Both workers reach the replacement
|
|
219
|
+
// without a tool call or the old five one-second stale-port retries.
|
|
205
220
|
const beforeKill = readDaemonDiscovery(discFile);
|
|
206
221
|
if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
|
|
207
|
-
await
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
const reattached = await waitForAsync(async () => {
|
|
223
|
+
const discovery = readDaemonDiscovery(discFile);
|
|
224
|
+
if (!discovery?.pid || discovery.pid === beforeKill?.pid) return false;
|
|
225
|
+
const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: 300 });
|
|
226
|
+
return health?.clients >= 2;
|
|
227
|
+
}, 2600);
|
|
210
228
|
const afterKill = readDaemonDiscovery(discFile);
|
|
211
|
-
|
|
212
|
-
|
|
229
|
+
let recovered = false;
|
|
230
|
+
try { recovered = (await wA.execute('reply', { after: 'stale-sse-replacement' }))?.ok === true; } catch {}
|
|
231
|
+
check('flip: stale SSE immediately re-attaches to replacement (no 1..5 stale-port storm)',
|
|
232
|
+
reattached === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid && recovered === true);
|
|
213
233
|
|
|
214
234
|
await wA.stop();
|
|
215
235
|
await wB.stop();
|
|
@@ -302,6 +322,29 @@ function rawPost(port, serverToken, p, body) {
|
|
|
302
322
|
req.on('error', reject); req.write(payload); req.end();
|
|
303
323
|
});
|
|
304
324
|
}
|
|
325
|
+
function rawPostLoseResponse(port, serverToken, p, body) {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const payload = JSON.stringify(body);
|
|
328
|
+
let settled = false;
|
|
329
|
+
const done = (err) => {
|
|
330
|
+
if (settled) return;
|
|
331
|
+
settled = true;
|
|
332
|
+
if (err) reject(err); else resolve();
|
|
333
|
+
};
|
|
334
|
+
const req = http.request({
|
|
335
|
+
hostname: '127.0.0.1', port, path: p, method: 'POST',
|
|
336
|
+
headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
337
|
+
}, (res) => {
|
|
338
|
+
// The daemon has already committed before emitting response headers; drop
|
|
339
|
+
// the body exactly where a transport response-loss retry would begin.
|
|
340
|
+
try { res.destroy(); } catch {}
|
|
341
|
+
done();
|
|
342
|
+
});
|
|
343
|
+
req.on('error', (err) => { if (!settled) done(err); });
|
|
344
|
+
req.write(payload);
|
|
345
|
+
req.end();
|
|
346
|
+
});
|
|
347
|
+
}
|
|
305
348
|
function rawSse(port, serverToken, clientToken, onNotify) {
|
|
306
349
|
const req = http.request({
|
|
307
350
|
hostname: '127.0.0.1', port,
|
|
@@ -325,6 +368,583 @@ function rawSse(port, serverToken, clientToken, onNotify) {
|
|
|
325
368
|
req.end(); return req;
|
|
326
369
|
}
|
|
327
370
|
|
|
371
|
+
function readJsonBody(req) {
|
|
372
|
+
return new Promise((resolve) => {
|
|
373
|
+
let body = '';
|
|
374
|
+
req.setEncoding('utf8');
|
|
375
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
376
|
+
req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
function sendTestJson(res, statusCode, body) {
|
|
380
|
+
const json = JSON.stringify(body);
|
|
381
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json) });
|
|
382
|
+
res.end(json);
|
|
383
|
+
}
|
|
384
|
+
async function startTestServer(handle) {
|
|
385
|
+
const server = http.createServer(handle);
|
|
386
|
+
await new Promise((resolve, reject) => {
|
|
387
|
+
server.once('error', reject);
|
|
388
|
+
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
389
|
+
});
|
|
390
|
+
return server;
|
|
391
|
+
}
|
|
392
|
+
function testPort(server) { return server.address().port; }
|
|
393
|
+
async function stopTestServer(server) {
|
|
394
|
+
await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Removed client tokens must never dispatch a tool with a null ownership
|
|
398
|
+
// context, including a callId that could otherwise hit the dedup cache.
|
|
399
|
+
async function staleTokenCallTest() {
|
|
400
|
+
let dispatched = 0;
|
|
401
|
+
const transport = createChannelDaemonTransport({
|
|
402
|
+
handleCall: async () => { dispatched++; return { ok: true }; },
|
|
403
|
+
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
404
|
+
});
|
|
405
|
+
const { port, token } = await transport.start();
|
|
406
|
+
const stale = await rawPost(port, token, '/call', { token: 'removed-token', name: 'reply', callId: 'stale-call' });
|
|
407
|
+
check('transport: stale /call token is rejected before dispatch',
|
|
408
|
+
stale?.error === 'unknown client token' && dispatched === 0);
|
|
409
|
+
await transport.stop();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A reconnecting non-owner must replace its exact old token, not merely leave
|
|
413
|
+
// it behind because another client currently owns the pointer.
|
|
414
|
+
async function tokenReplacementRetirementTest() {
|
|
415
|
+
let dispatches = 0;
|
|
416
|
+
const transport = createChannelDaemonTransport({
|
|
417
|
+
handleCall: async (name, args, ctx) => {
|
|
418
|
+
dispatches++;
|
|
419
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
420
|
+
},
|
|
421
|
+
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
422
|
+
});
|
|
423
|
+
const { port, token } = await transport.start();
|
|
424
|
+
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
425
|
+
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
426
|
+
const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
427
|
+
await delay(80);
|
|
428
|
+
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
429
|
+
await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
430
|
+
const ownerC = await rawPost(port, token, '/client/register', { leadPid: c.pid, cwd: 'C' });
|
|
431
|
+
const oldBufferedBeforeReplace = transport._clientsForTest.get(oldA.token)?.pending?.length === 1;
|
|
432
|
+
const freshA = await rawPost(port, token, '/client/register', {
|
|
433
|
+
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
|
|
434
|
+
});
|
|
435
|
+
const freshNotifies = [];
|
|
436
|
+
const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
|
|
437
|
+
await waitFor(() => freshNotifies.some((message) =>
|
|
438
|
+
message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
|
|
439
|
+
const nonOwnerStateMigrated =
|
|
440
|
+
freshNotifies.some((message) => message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded') &&
|
|
441
|
+
transport._clientsForTest.get(freshA.token)?.pending?.length === 0 &&
|
|
442
|
+
transport._resolveTargetForTest()?.token === ownerC.token;
|
|
443
|
+
const stale = await rawPost(port, token, '/call', {
|
|
444
|
+
token: oldA.token, name: 'rebind_current_transcript',
|
|
445
|
+
args: { transcriptPath: '/tmp/stale-a.jsonl' }, callId: 'late-old-dedup',
|
|
446
|
+
});
|
|
447
|
+
const [live1, live2] = await Promise.all([
|
|
448
|
+
rawPost(port, token, '/call', {
|
|
449
|
+
token: freshA.token, name: 'rebind_current_transcript',
|
|
450
|
+
args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
|
|
451
|
+
}),
|
|
452
|
+
rawPost(port, token, '/call', {
|
|
453
|
+
token: freshA.token, name: 'rebind_current_transcript',
|
|
454
|
+
args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
|
|
455
|
+
}),
|
|
456
|
+
]);
|
|
457
|
+
const beforeFreshClose = transport._clientsForTest.size;
|
|
458
|
+
const pointerBeforeFreshClose = transport._resolveTargetForTest()?.token;
|
|
459
|
+
await rawPost(port, token, '/client/deregister', { token: freshA.token });
|
|
460
|
+
check('reconnect: non-owner old token is retired; stale dedup call cannot move pointer',
|
|
461
|
+
stale?.error === 'unknown client token' &&
|
|
462
|
+
oldBufferedBeforeReplace &&
|
|
463
|
+
nonOwnerStateMigrated &&
|
|
464
|
+
!transport._clientsForTest.has(oldA.token) &&
|
|
465
|
+
beforeFreshClose === 3 &&
|
|
466
|
+
pointerBeforeFreshClose === freshA.token &&
|
|
467
|
+
live1?.result?.ok === true && live2?.result?.ok === true &&
|
|
468
|
+
dispatches === 1 &&
|
|
469
|
+
!transport._clientsForTest.has(freshA.token) &&
|
|
470
|
+
transport._clientsForTest.size === 2 &&
|
|
471
|
+
ownerC.token !== freshA.token);
|
|
472
|
+
try { a.kill(); } catch {}
|
|
473
|
+
try { b.kill(); } catch {}
|
|
474
|
+
try { c.kill(); } catch {}
|
|
475
|
+
try { freshSse.destroy(); } catch {}
|
|
476
|
+
await transport.stop();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// If replacement commits but its response is lost, the same logical
|
|
480
|
+
// registration id must replay the first fresh token rather than create another.
|
|
481
|
+
async function tokenReplacementReplayTest() {
|
|
482
|
+
const transport = createChannelDaemonTransport({
|
|
483
|
+
handleCall: async () => ({ ok: true }),
|
|
484
|
+
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
485
|
+
});
|
|
486
|
+
const { port, token } = await transport.start();
|
|
487
|
+
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
488
|
+
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
489
|
+
await delay(80);
|
|
490
|
+
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
491
|
+
const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
492
|
+
const replacement = {
|
|
493
|
+
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
|
|
494
|
+
registrationId: 'response-loss-replacement-1',
|
|
495
|
+
};
|
|
496
|
+
await rawPostLoseResponse(port, token, '/client/register', replacement);
|
|
497
|
+
const freshA = await rawPost(port, token, '/client/register', replacement);
|
|
498
|
+
const countAfterReplay = transport._clientsForTest.size;
|
|
499
|
+
const freshNotifies = [];
|
|
500
|
+
const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
|
|
501
|
+
await waitFor(() => freshNotifies.some((message) =>
|
|
502
|
+
message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
|
|
503
|
+
const pendingMigrated = transport._clientsForTest.get(freshA.token)?.pending?.length === 0;
|
|
504
|
+
await rawPost(port, token, '/client/deregister', { token: freshA.token });
|
|
505
|
+
check('reconnect: response-loss replacement replays one fresh token and close leaves no orphan',
|
|
506
|
+
countAfterReplay === 2 &&
|
|
507
|
+
!transport._clientsForTest.has(oldA.token) &&
|
|
508
|
+
transport._resolveTargetForTest()?.token === ownerB.token &&
|
|
509
|
+
pendingMigrated &&
|
|
510
|
+
!transport._clientsForTest.has(freshA.token) &&
|
|
511
|
+
transport._clientsForTest.size === 1 &&
|
|
512
|
+
transport._registrationReplaysForTest.size === 0);
|
|
513
|
+
try { freshSse.destroy(); } catch {}
|
|
514
|
+
try { a.kill(); } catch {}
|
|
515
|
+
try { b.kill(); } catch {}
|
|
516
|
+
await transport.stop();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// If the replacement commit survives but its response is lost, an immediate
|
|
520
|
+
// close still knows the retired token + registration id and must cancel exactly
|
|
521
|
+
// that unknown fresh logical client, not an unrelated live owner.
|
|
522
|
+
async function tokenReplacementResponseLossCloseTest() {
|
|
523
|
+
const transport = createChannelDaemonTransport({
|
|
524
|
+
handleCall: async () => ({ ok: true }),
|
|
525
|
+
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
526
|
+
});
|
|
527
|
+
const { port, token } = await transport.start();
|
|
528
|
+
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
529
|
+
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
530
|
+
await delay(80);
|
|
531
|
+
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
532
|
+
const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
533
|
+
const cancellation = {
|
|
534
|
+
token: oldA.token, replaceToken: oldA.token, leadPid: a.pid, cwd: 'A',
|
|
535
|
+
registrationId: 'response-loss-immediate-close-1',
|
|
536
|
+
};
|
|
537
|
+
await rawPostLoseResponse(port, token, '/client/register', {
|
|
538
|
+
...cancellation, reattach: true,
|
|
539
|
+
});
|
|
540
|
+
const fresh = [...transport._clientsForTest.values()].find((client) => client.leadPid === a.pid);
|
|
541
|
+
const pendingMigrated = fresh?.pending?.length === 1;
|
|
542
|
+
const wrongClose = await rawPost(port, token, '/client/deregister', {
|
|
543
|
+
...cancellation, cwd: 'not-A',
|
|
544
|
+
});
|
|
545
|
+
const close1 = await rawPost(port, token, '/client/deregister', cancellation);
|
|
546
|
+
const close2 = await rawPost(port, token, '/client/deregister', cancellation);
|
|
547
|
+
check('reconnect: response-loss immediate close cancels unknown fresh client without stranding state',
|
|
548
|
+
wrongClose?.error === 'forbidden replacement deregister' &&
|
|
549
|
+
pendingMigrated &&
|
|
550
|
+
close1?.ok === true && close1?.cancelled === true &&
|
|
551
|
+
close2?.ok === true && close2?.cancelled === false &&
|
|
552
|
+
!transport._clientsForTest.has(oldA.token) &&
|
|
553
|
+
!transport._clientsForTest.has(fresh?.token) &&
|
|
554
|
+
transport._clientsForTest.size === 1 &&
|
|
555
|
+
transport._resolveTargetForTest()?.token === ownerB.token &&
|
|
556
|
+
transport._registrationReplaysForTest.size === 0);
|
|
557
|
+
try { a.kill(); } catch {}
|
|
558
|
+
try { b.kill(); } catch {}
|
|
559
|
+
await transport.stop();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Replay TTL bounds only cancellation metadata after a response has flushed:
|
|
563
|
+
// a valid client may delay SSE/call well beyond that TTL. A replay near expiry
|
|
564
|
+
// refreshes the metadata lifetime so its returned token remains cancellable.
|
|
565
|
+
async function registrationReplayTtlTest() {
|
|
566
|
+
const transport = createChannelDaemonTransport({
|
|
567
|
+
handleCall: async () => ({ ok: true }),
|
|
568
|
+
clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 100,
|
|
569
|
+
onClientsEmpty: () => {},
|
|
570
|
+
});
|
|
571
|
+
const { port, token } = await transport.start();
|
|
572
|
+
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
573
|
+
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
574
|
+
await delay(80);
|
|
575
|
+
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
576
|
+
await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
577
|
+
const valid = await rawPost(port, token, '/client/register', {
|
|
578
|
+
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token, registrationId: 'ttl-valid',
|
|
579
|
+
});
|
|
580
|
+
await delay(150);
|
|
581
|
+
const validSurvived = transport._clientsForTest.has(valid.token) &&
|
|
582
|
+
transport._clientsForTest.get(valid.token)?.pending?.length === 1 &&
|
|
583
|
+
transport._registrationReplaysForTest.size === 0;
|
|
584
|
+
await rawPost(port, token, '/client/deregister', { token: valid.token });
|
|
585
|
+
|
|
586
|
+
const oldA2 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A2' });
|
|
587
|
+
const replacement = {
|
|
588
|
+
leadPid: a.pid, cwd: 'A2', reattach: true, replaceToken: oldA2.token, registrationId: 'ttl-refresh',
|
|
589
|
+
};
|
|
590
|
+
const fresh = await rawPost(port, token, '/client/register', replacement);
|
|
591
|
+
await delay(70);
|
|
592
|
+
const replayed = await rawPost(port, token, '/client/register', replacement);
|
|
593
|
+
await delay(60); // Past the original deadline, before the refreshed deadline.
|
|
594
|
+
const replayRefreshed = replayed.token === fresh.token &&
|
|
595
|
+
transport._clientsForTest.has(fresh.token) &&
|
|
596
|
+
transport._registrationReplaysForTest.size === 1;
|
|
597
|
+
await delay(70); // Full refreshed interval elapsed.
|
|
598
|
+
const freshSurvivedRefreshExpiry = transport._clientsForTest.has(fresh.token) &&
|
|
599
|
+
transport._registrationReplaysForTest.size === 0;
|
|
600
|
+
await rawPost(port, token, '/client/deregister', { token: fresh.token });
|
|
601
|
+
check('reconnect: replay TTL preserves flushed clients and refreshes near-expiry replay metadata',
|
|
602
|
+
validSurvived && replayRefreshed && freshSurvivedRefreshExpiry);
|
|
603
|
+
try { a.kill(); } catch {}
|
|
604
|
+
try { b.kill(); } catch {}
|
|
605
|
+
await transport.stop();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Every explicit retirement, replacement, deregister, and stop must clear the
|
|
609
|
+
// associated replay entry/timer; no metadata may target an absent token.
|
|
610
|
+
async function registrationReplayCleanupTest() {
|
|
611
|
+
const transport = createChannelDaemonTransport({
|
|
612
|
+
handleCall: async () => ({ ok: true }),
|
|
613
|
+
clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 1_000,
|
|
614
|
+
onClientsEmpty: () => {},
|
|
615
|
+
});
|
|
616
|
+
const { port, token } = await transport.start();
|
|
617
|
+
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
618
|
+
await delay(80);
|
|
619
|
+
const old = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
620
|
+
const fresh1 = await rawPost(port, token, '/client/register', {
|
|
621
|
+
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: old.token, registrationId: 'cleanup-1',
|
|
622
|
+
});
|
|
623
|
+
const fresh2 = await rawPost(port, token, '/client/register', {
|
|
624
|
+
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: fresh1.token, registrationId: 'cleanup-2',
|
|
625
|
+
});
|
|
626
|
+
const replacementClean = !transport._clientsForTest.has(fresh1.token) &&
|
|
627
|
+
transport._registrationReplaysForTest.size === 1;
|
|
628
|
+
await rawPost(port, token, '/client/deregister', { token: fresh2.token });
|
|
629
|
+
const deregisterClean = transport._registrationReplaysForTest.size === 0;
|
|
630
|
+
const old3 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A3' });
|
|
631
|
+
await rawPost(port, token, '/client/register', {
|
|
632
|
+
leadPid: a.pid, cwd: 'A3', reattach: true, replaceToken: old3.token, registrationId: 'cleanup-stop',
|
|
633
|
+
});
|
|
634
|
+
await transport.stop();
|
|
635
|
+
check('reconnect: replacement, deregister, and stop clear all replay metadata',
|
|
636
|
+
replacementClean && deregisterClean &&
|
|
637
|
+
transport._clientsForTest.size === 0 && transport._registrationReplaysForTest.size === 0);
|
|
638
|
+
try { a.kill(); } catch {}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Client-only SSE races: immediate 200/end cycles consume the bounded budget;
|
|
642
|
+
// a late old response cannot start another token rotation; close waits for a
|
|
643
|
+
// delayed reconnect registration and removes its fresh token.
|
|
644
|
+
async function clientReconnectSafetyTest() {
|
|
645
|
+
let registerCount = 0;
|
|
646
|
+
let fatalReason = null;
|
|
647
|
+
const flapping = await startTestServer(async (req, res) => {
|
|
648
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
649
|
+
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
650
|
+
if (url.pathname === '/client/register') {
|
|
651
|
+
await readJsonBody(req);
|
|
652
|
+
registerCount++;
|
|
653
|
+
return sendTestJson(res, 200, { token: `flap-${registerCount}`, pid: process.pid });
|
|
654
|
+
}
|
|
655
|
+
if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
|
|
656
|
+
if (url.pathname === '/events') {
|
|
657
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
658
|
+
res.write(': attached\n\n');
|
|
659
|
+
setTimeout(() => res.end(), 10);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
663
|
+
});
|
|
664
|
+
const flapClient = await attachToDaemon({
|
|
665
|
+
discovery: { port: testPort(flapping), token: 'flap', pid: process.pid },
|
|
666
|
+
onFatal: (reason) => { fatalReason = reason; },
|
|
667
|
+
});
|
|
668
|
+
await waitFor(() => fatalReason !== null, 8_000);
|
|
669
|
+
check('client: repeated 200/end SSE reconnects are bounded',
|
|
670
|
+
fatalReason !== null && registerCount === 6);
|
|
671
|
+
await flapClient.close();
|
|
672
|
+
await stopTestServer(flapping);
|
|
673
|
+
|
|
674
|
+
let lateRegisters = 0;
|
|
675
|
+
const lateServer = await startTestServer(async (req, res) => {
|
|
676
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
677
|
+
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
678
|
+
if (url.pathname === '/client/register') {
|
|
679
|
+
await readJsonBody(req);
|
|
680
|
+
lateRegisters++;
|
|
681
|
+
return sendTestJson(res, 200, { token: `late-${lateRegisters}`, pid: process.pid });
|
|
682
|
+
}
|
|
683
|
+
if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
|
|
684
|
+
if (url.pathname === '/events') return sendTestJson(res, 500, { error: 'test SSE should be intercepted' });
|
|
685
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
686
|
+
});
|
|
687
|
+
const realHttpRequest = http.request;
|
|
688
|
+
const fakeStreams = [];
|
|
689
|
+
http.request = function interceptedSseRequest(options, callback) {
|
|
690
|
+
if (!String(options?.path || '').startsWith('/events')) {
|
|
691
|
+
return realHttpRequest.apply(this, arguments);
|
|
692
|
+
}
|
|
693
|
+
const fakeReq = new EventEmitter();
|
|
694
|
+
fakeReq.destroy = () => {};
|
|
695
|
+
fakeReq.end = () => {
|
|
696
|
+
const fakeRes = new EventEmitter();
|
|
697
|
+
fakeRes.statusCode = 200;
|
|
698
|
+
fakeRes.setEncoding = () => {};
|
|
699
|
+
fakeRes.resume = () => {};
|
|
700
|
+
fakeStreams.push({ req: fakeReq, res: fakeRes });
|
|
701
|
+
queueMicrotask(() => callback(fakeRes));
|
|
702
|
+
};
|
|
703
|
+
return fakeReq;
|
|
704
|
+
};
|
|
705
|
+
let lateClient = null;
|
|
706
|
+
try {
|
|
707
|
+
lateClient = await attachToDaemon({ discovery: { port: testPort(lateServer), token: 'late', pid: process.pid } });
|
|
708
|
+
await waitFor(() => fakeStreams.length === 1, 500);
|
|
709
|
+
fakeStreams[0].res.emit('end');
|
|
710
|
+
await waitFor(() => lateRegisters === 2 && fakeStreams.length === 2, 2_500);
|
|
711
|
+
fakeStreams[0].res.emit('error', new Error('late old SSE error'));
|
|
712
|
+
await delay(100);
|
|
713
|
+
check('client: late old-SSE events cannot rotate a newer token', lateRegisters === 2);
|
|
714
|
+
} finally {
|
|
715
|
+
http.request = realHttpRequest;
|
|
716
|
+
await lateClient?.close();
|
|
717
|
+
}
|
|
718
|
+
await stopTestServer(lateServer);
|
|
719
|
+
|
|
720
|
+
let closeRegisters = 0;
|
|
721
|
+
let delayedRegisterResponse = null;
|
|
722
|
+
const liveTokens = new Set();
|
|
723
|
+
const closeServer = await startTestServer(async (req, res) => {
|
|
724
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
725
|
+
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
726
|
+
if (url.pathname === '/client/register') {
|
|
727
|
+
await readJsonBody(req);
|
|
728
|
+
closeRegisters++;
|
|
729
|
+
const fresh = `close-${closeRegisters}`;
|
|
730
|
+
liveTokens.add(fresh);
|
|
731
|
+
if (closeRegisters === 2) { delayedRegisterResponse = { res, fresh }; return; }
|
|
732
|
+
return sendTestJson(res, 200, { token: fresh, pid: process.pid });
|
|
733
|
+
}
|
|
734
|
+
if (url.pathname === '/client/deregister') {
|
|
735
|
+
const body = await readJsonBody(req);
|
|
736
|
+
liveTokens.delete(body.token);
|
|
737
|
+
return sendTestJson(res, 200, { ok: true });
|
|
738
|
+
}
|
|
739
|
+
if (url.pathname === '/events') {
|
|
740
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
741
|
+
res.write(': attached\n\n');
|
|
742
|
+
if (closeRegisters === 1) setTimeout(() => res.end(), 10);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
746
|
+
});
|
|
747
|
+
const closeClient = await attachToDaemon({ discovery: { port: testPort(closeServer), token: 'close', pid: process.pid } });
|
|
748
|
+
await waitFor(() => delayedRegisterResponse !== null, 2_500);
|
|
749
|
+
const closing = closeClient.close('close during register');
|
|
750
|
+
await delay(20);
|
|
751
|
+
sendTestJson(delayedRegisterResponse.res, 200, { token: delayedRegisterResponse.fresh, pid: process.pid });
|
|
752
|
+
await closing;
|
|
753
|
+
check('client: close during re-register removes the fresh token',
|
|
754
|
+
!liveTokens.has(delayedRegisterResponse.fresh) && liveTokens.size === 0);
|
|
755
|
+
await stopTestServer(closeServer);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// End-to-end client path: the daemon commits replacement, but a proxy loses the
|
|
759
|
+
// register response after headers/partial JSON. request() must settle, and close
|
|
760
|
+
// must use the retained logical-registration identity to cancel the unknown
|
|
761
|
+
// fresh transport client before any later reconnect can revive it.
|
|
762
|
+
async function clientTruncatedReplacementCloseTest() {
|
|
763
|
+
let cancellationObserved = false;
|
|
764
|
+
const transport = createChannelDaemonTransport({
|
|
765
|
+
handleCall: async () => ({ ok: true }),
|
|
766
|
+
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
767
|
+
log: (line) => { if (String(line).includes('replacement deregister')) cancellationObserved = true; },
|
|
768
|
+
});
|
|
769
|
+
const { port, token } = await transport.start();
|
|
770
|
+
const client = await attachToDaemon({ discovery: { port, token, pid: process.pid }, cwd: 'truncated-client' });
|
|
771
|
+
const initialToken = client.clientToken;
|
|
772
|
+
const realHttpRequest = http.request;
|
|
773
|
+
let replacementRequests = 0;
|
|
774
|
+
let truncated = false;
|
|
775
|
+
http.request = function truncateCommittedRegister(options, callback) {
|
|
776
|
+
if (!String(options?.path || '').startsWith('/client/register')) {
|
|
777
|
+
return realHttpRequest.apply(this, arguments);
|
|
778
|
+
}
|
|
779
|
+
replacementRequests++;
|
|
780
|
+
const fakeReq = new EventEmitter();
|
|
781
|
+
let payload = null;
|
|
782
|
+
let inner = null;
|
|
783
|
+
fakeReq.write = (chunk) => { payload = Buffer.from(chunk); };
|
|
784
|
+
fakeReq.destroy = (error) => { try { inner?.destroy?.(error); } catch {} };
|
|
785
|
+
fakeReq.end = () => {
|
|
786
|
+
inner = realHttpRequest(options, (realRes) => {
|
|
787
|
+
realRes.resume();
|
|
788
|
+
realRes.once('end', () => {
|
|
789
|
+
const fakeRes = new EventEmitter();
|
|
790
|
+
fakeRes.statusCode = 200;
|
|
791
|
+
fakeRes.setEncoding = () => {};
|
|
792
|
+
fakeRes.resume = () => {};
|
|
793
|
+
fakeRes.destroy = () => {};
|
|
794
|
+
callback(fakeRes);
|
|
795
|
+
fakeRes.emit('data', '{"token":"committed-but-truncated');
|
|
796
|
+
truncated = true;
|
|
797
|
+
queueMicrotask(() => {
|
|
798
|
+
fakeRes.emit('aborted');
|
|
799
|
+
fakeRes.emit('close');
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
inner.on('error', (error) => fakeReq.emit('error', error));
|
|
804
|
+
if (payload) inner.write(payload);
|
|
805
|
+
inner.end();
|
|
806
|
+
};
|
|
807
|
+
return fakeReq;
|
|
808
|
+
};
|
|
809
|
+
try {
|
|
810
|
+
await waitFor(() => !!transport._clientsForTest.get(initialToken)?.sse, 1_000);
|
|
811
|
+
transport._clientsForTest.get(initialToken)?.sse?.end();
|
|
812
|
+
await waitFor(() => truncated && replacementRequests === 1 && transport._clientsForTest.size === 1, 2_500);
|
|
813
|
+
const closeStarted = Date.now();
|
|
814
|
+
await client.close('truncated replacement response');
|
|
815
|
+
const closeElapsed = Date.now() - closeStarted;
|
|
816
|
+
await delay(1_200);
|
|
817
|
+
check('client: truncated committed replacement settles and close cancels unknown fresh client',
|
|
818
|
+
closeElapsed < 1_000 &&
|
|
819
|
+
cancellationObserved &&
|
|
820
|
+
replacementRequests === 1 &&
|
|
821
|
+
transport._clientsForTest.size === 0 &&
|
|
822
|
+
transport._registrationReplaysForTest.size === 0 &&
|
|
823
|
+
transport._resolveTargetForTest() === null);
|
|
824
|
+
} finally {
|
|
825
|
+
http.request = realHttpRequest;
|
|
826
|
+
await client.close('truncated replacement cleanup');
|
|
827
|
+
await transport.stop();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Worker-only attach races: auth rejection re-reads discovery instead of
|
|
832
|
+
// rejecting start(), a wrong but alive discovery PID is refused, and stop()
|
|
833
|
+
// waits for an in-flight register so it cannot publish a client afterward.
|
|
834
|
+
async function workerAttachSafetyTest() {
|
|
835
|
+
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
836
|
+
|
|
837
|
+
const wrongPidServer = await startTestServer((req, res) => {
|
|
838
|
+
if (new URL(req.url, 'http://127.0.0.1').pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
839
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
840
|
+
});
|
|
841
|
+
let wrongPidRejected = false;
|
|
842
|
+
try {
|
|
843
|
+
await attachToDaemon({ discovery: { port: testPort(wrongPidServer), token: 'wrong-pid', pid: process.pid + 100_000 } });
|
|
844
|
+
} catch (err) { wrongPidRejected = err?.daemonDiscoveryStale === true; }
|
|
845
|
+
check('client: alive but wrong discovery PID is rejected', wrongPidRejected);
|
|
846
|
+
await stopTestServer(wrongPidServer);
|
|
847
|
+
|
|
848
|
+
const authTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-attach-'));
|
|
849
|
+
const authDiscovery = path.join(authTmp, 'channel-daemon.json');
|
|
850
|
+
const goodToken = 'good-discovery-token';
|
|
851
|
+
let rejectedRegisters = 0;
|
|
852
|
+
const authServer = await startTestServer(async (req, res) => {
|
|
853
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
854
|
+
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
855
|
+
if (url.pathname === '/client/register') {
|
|
856
|
+
await readJsonBody(req);
|
|
857
|
+
if (req.headers['x-mixdog-daemon-token'] !== goodToken) {
|
|
858
|
+
rejectedRegisters++;
|
|
859
|
+
return sendTestJson(res, 403, { error: 'forbidden' });
|
|
860
|
+
}
|
|
861
|
+
return sendTestJson(res, 200, { token: 'auth-client', pid: process.pid });
|
|
862
|
+
}
|
|
863
|
+
if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
|
|
864
|
+
if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
|
|
865
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
866
|
+
});
|
|
867
|
+
const authPort = testPort(authServer);
|
|
868
|
+
writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: 'stale-discovery-token' }));
|
|
869
|
+
process.env.MIXDOG_RUNTIME_ROOT = authTmp;
|
|
870
|
+
process.env.MIXDOG_DATA_DIR = authTmp;
|
|
871
|
+
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
872
|
+
const authWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: authTmp, dataDir: authTmp, cwd: authTmp });
|
|
873
|
+
const publishGoodDiscovery = setTimeout(() => {
|
|
874
|
+
writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: goodToken }));
|
|
875
|
+
}, 250);
|
|
876
|
+
await authWorker.start();
|
|
877
|
+
clearTimeout(publishGoodDiscovery);
|
|
878
|
+
check('worker: initial register 403 re-reads discovery and attaches', rejectedRegisters > 0);
|
|
879
|
+
await authWorker.stop();
|
|
880
|
+
await stopTestServer(authServer);
|
|
881
|
+
try { rmSync(authTmp, { recursive: true, force: true }); } catch {}
|
|
882
|
+
|
|
883
|
+
const permanentTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-permanent-'));
|
|
884
|
+
const permanentDiscovery = path.join(permanentTmp, 'channel-daemon.json');
|
|
885
|
+
let permanentRejects = 0;
|
|
886
|
+
const permanentServer = await startTestServer(async (req, res) => {
|
|
887
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
888
|
+
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
889
|
+
if (url.pathname === '/client/register') {
|
|
890
|
+
await readJsonBody(req);
|
|
891
|
+
permanentRejects++;
|
|
892
|
+
return sendTestJson(res, 403, { error: 'forbidden' });
|
|
893
|
+
}
|
|
894
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
895
|
+
});
|
|
896
|
+
writeFileSync(permanentDiscovery, JSON.stringify({
|
|
897
|
+
pid: process.pid, port: testPort(permanentServer), token: 'permanently-stale-token',
|
|
898
|
+
}));
|
|
899
|
+
process.env.MIXDOG_RUNTIME_ROOT = permanentTmp;
|
|
900
|
+
process.env.MIXDOG_DATA_DIR = permanentTmp;
|
|
901
|
+
const permanentWorker = createStandaloneChannelWorker({
|
|
902
|
+
entry: STUB_ENTRY, rootDir: permanentTmp, dataDir: permanentTmp, cwd: permanentTmp,
|
|
903
|
+
});
|
|
904
|
+
const permanentStartedAt = Date.now();
|
|
905
|
+
let permanentRejected = false;
|
|
906
|
+
try { await permanentWorker.start(); } catch (err) {
|
|
907
|
+
permanentRejected = /register rejected discovery auth 5 times/.test(err?.message || '');
|
|
908
|
+
}
|
|
909
|
+
const permanentElapsed = Date.now() - permanentStartedAt;
|
|
910
|
+
check('worker: permanent register 403 uses bounded backoff and terminates',
|
|
911
|
+
permanentRejected && permanentRejects === 5 && permanentElapsed >= 1000 && permanentElapsed < 6000);
|
|
912
|
+
await permanentWorker.stop();
|
|
913
|
+
await stopTestServer(permanentServer);
|
|
914
|
+
try { rmSync(permanentTmp, { recursive: true, force: true }); } catch {}
|
|
915
|
+
|
|
916
|
+
const stopTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-stop-attach-'));
|
|
917
|
+
const stopDiscovery = path.join(stopTmp, 'channel-daemon.json');
|
|
918
|
+
let pendingRegister = null;
|
|
919
|
+
const deregistered = new Set();
|
|
920
|
+
const stopServer = await startTestServer(async (req, res) => {
|
|
921
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
922
|
+
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
923
|
+
if (url.pathname === '/client/register') { await readJsonBody(req); pendingRegister = res; return; }
|
|
924
|
+
if (url.pathname === '/client/deregister') {
|
|
925
|
+
const body = await readJsonBody(req);
|
|
926
|
+
deregistered.add(body.token);
|
|
927
|
+
return sendTestJson(res, 200, { ok: true });
|
|
928
|
+
}
|
|
929
|
+
if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
|
|
930
|
+
sendTestJson(res, 404, { error: 'not found' });
|
|
931
|
+
});
|
|
932
|
+
const stopPort = testPort(stopServer);
|
|
933
|
+
writeFileSync(stopDiscovery, JSON.stringify({ pid: process.pid, port: stopPort, token: 'stop-token' }));
|
|
934
|
+
process.env.MIXDOG_RUNTIME_ROOT = stopTmp;
|
|
935
|
+
process.env.MIXDOG_DATA_DIR = stopTmp;
|
|
936
|
+
const stopWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: stopTmp, dataDir: stopTmp, cwd: stopTmp });
|
|
937
|
+
const startResult = stopWorker.start().then(() => 'started').catch(() => 'cancelled');
|
|
938
|
+
await waitFor(() => pendingRegister !== null, 1_500);
|
|
939
|
+
const stopping = stopWorker.stop('stop during attach');
|
|
940
|
+
sendTestJson(pendingRegister, 200, { token: 'stop-fresh', pid: process.pid });
|
|
941
|
+
const [stopped, startState] = await Promise.all([stopping, startResult]);
|
|
942
|
+
check('worker: stop during attach cannot publish a client',
|
|
943
|
+
stopped === false && startState === 'cancelled' && deregistered.has('stop-fresh'));
|
|
944
|
+
await stopTestServer(stopServer);
|
|
945
|
+
try { rmSync(stopTmp, { recursive: true, force: true }); } catch {}
|
|
946
|
+
}
|
|
947
|
+
|
|
328
948
|
// Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
|
|
329
949
|
// (2) a targeted 'superseded' emitted while the displaced client has no live
|
|
330
950
|
// stream is buffered and flushed when its stream (re)attaches.
|