@serfab/cadre-host 1.9.0 → 1.11.0

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.
@@ -1,1063 +1,1063 @@
1
- /**
2
- * HostProcessOrchestrator unit tests.
3
- *
4
- * These tests spawn a tiny fake child script — NOT the real cadre-cli — so
5
- * they stay fast and don't require cadre-cli to be built. The orchestrator
6
- * exposes a `spawn.entrypoint` override for exactly this purpose.
7
- */
8
-
9
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
- import { spawnSync } from 'node:child_process';
11
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { dirname, join, resolve as resolvePath } from 'node:path';
14
- import { fileURLToPath, pathToFileURL } from 'node:url';
15
-
16
- import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
17
- import { rotateOnDisk } from '../orchestrator/log-rotator.js';
18
- import { decodeDockerId, encodeDockerId, type NodePorts } from '../orchestrator/types.js';
19
- import { StateStore } from '../orchestrator/state-store.js';
20
- import { isPidAlive } from '../orchestrator/pid-liveness.js';
21
- import { loadIdentity } from '../installer/identity.js';
22
- import { describeHandleContract } from './orchestrator-handle-contract.js';
23
- import { removeAllNodes } from './orchestrator-teardown.js';
24
-
25
- const FAKE_CHILD = `
26
- import fs from 'node:fs';
27
- import path from 'node:path';
28
-
29
- const args = process.argv.slice(2);
30
- const configIdx = args.indexOf('-c');
31
- const tokenIdx = args.indexOf('--startup-token-file');
32
- const tokenPath = tokenIdx >= 0 ? args[tokenIdx + 1] : null;
33
- const ignoreSigterm = process.env.IGNORE_SIGTERM === '1';
34
- const initialMessage = process.env.CHILD_INITIAL_MESSAGE ?? 'tick';
35
- const tickIntervalMs = parseInt(process.env.CHILD_TICK_MS ?? '50', 10);
36
- const tickPayload = process.env.CHILD_TICK_PAYLOAD ?? 'tick';
37
-
38
- if (tokenPath && process.env.CADRE_STARTUP_TOKEN) {
39
- try {
40
- fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN, 'utf8');
41
- } catch (err) {
42
- console.error('failed to write token:', err);
43
- }
44
- }
45
-
46
- let alive = true;
47
- process.on('SIGTERM', () => {
48
- if (ignoreSigterm) {
49
- console.log('ignoring SIGTERM');
50
- return;
51
- }
52
- alive = false;
53
- console.log('exiting on SIGTERM');
54
- process.exit(0);
55
- });
56
-
57
- console.log(initialMessage);
58
-
59
- const interval = setInterval(() => {
60
- if (alive) {
61
- process.stdout.write(tickPayload + '\\n');
62
- } else {
63
- clearInterval(interval);
64
- }
65
- }, tickIntervalMs);
66
-
67
- setInterval(() => {}, 1 << 30);
68
- `;
69
-
70
- let tmpRoot: string;
71
- let scriptPath: string;
72
- const orchestrators: HostProcessOrchestrator[] = [];
73
-
74
- beforeEach(() => {
75
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-orch-'));
76
- scriptPath = join(tmpRoot, 'fake-child.mjs');
77
- writeFileSync(scriptPath, FAKE_CHILD, 'utf8');
78
- });
79
-
80
- afterEach(async () => {
81
- try {
82
- await removeAllNodes(orchestrators);
83
- } finally {
84
- await sleep(50);
85
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
86
- }
87
- });
88
-
89
- function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
90
- const rootDir = overrides.rootDir ?? join(tmpRoot, 'orch');
91
- mkdirSync(rootDir, { recursive: true });
92
- const orch = new HostProcessOrchestrator({
93
- rootDir,
94
- portRange: { start: 18000, end: 18999 },
95
- stopTimeoutMs: 1500,
96
- logMaxBytes: overrides.logMaxBytes ?? 10 * 1024 * 1024,
97
- logMaxFiles: overrides.logMaxFiles ?? 5,
98
- spawn: { entrypoint: overrides.spawn?.entrypoint ?? scriptPath },
99
- ...overrides,
100
- });
101
- orchestrators.push(orch);
102
- return orch;
103
- }
104
-
105
- async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
106
- const deadline = Date.now() + timeoutMs;
107
- while (Date.now() < deadline) {
108
- if (await predicate()) return;
109
- await sleep(intervalMs);
110
- }
111
- throw new Error('waitFor: timed out');
112
- }
113
-
114
- function sleep(ms: number): Promise<void> {
115
- return new Promise((res) => setTimeout(res, ms));
116
- }
117
-
118
- function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transaction' } = {}) {
119
- return {
120
- containerId,
121
- partyId: 'party-' + containerId,
122
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
123
- profile: opts.profile ?? 'transaction' as 'storage' | 'transaction',
124
- };
125
- }
126
-
127
- /** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
128
- function sabotageWorkdir(workdir: string): string {
129
- const storage = join(workdir, 'storage');
130
- rmSync(storage, { recursive: true, force: true });
131
- writeFileSync(storage, 'not-a-directory', 'utf8');
132
- return storage;
133
- }
134
-
135
- describeHandleContract<HostProcessOrchestrator>('HostProcessOrchestrator', {
136
- make: () => makeOrchestrator(),
137
- request: (containerId) => makeRequest(containerId),
138
- started: (orch, dockerId) => waitFor(() => orch.isRunning(dockerId)),
139
- failNextCreate: (orch, containerId) => { sabotageWorkdir(orch.getNode(containerId)!.workdir); },
140
- });
141
-
142
- describe('HostProcessOrchestrator.createContainer', () => {
143
- it('spawns a live child and persists handle, ports, and endpoints', async () => {
144
- const orch = makeOrchestrator();
145
- const result = await orch.createContainer(makeRequest('c1'));
146
-
147
- // Endpoints derive from allocated ports
148
- expect(result.healthEndpoint).toMatch(/^http:\/\/localhost:\d+\/health$/);
149
- expect(result.metricsEndpoint).toMatch(/^http:\/\/localhost:\d+\/metrics$/);
150
- expect(result.p2pPort).toBeGreaterThanOrEqual(18000);
151
-
152
- const { pid } = decodeDockerId(result.dockerId);
153
- await waitFor(() => isPidAlive(pid));
154
- expect(isPidAlive(pid)).toBe(true);
155
-
156
- // Wait until the child has written its startup token file
157
- await waitFor(() => orch.isRunning(result.dockerId));
158
- expect(await orch.isRunning(result.dockerId)).toBe(true);
159
-
160
- // Workdir scaffolded
161
- const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
162
- expect(readFileSync(join(workdir, 'cadre.json'), 'utf8')).toContain('party-c1');
163
-
164
- // State persisted
165
- const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
166
- expect(persisted.handles).toHaveLength(1);
167
- expect(persisted.handles[0]!.dockerId).toBe(result.dockerId);
168
- });
169
- });
170
-
171
- describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
172
- // The donated-node respawn path calls createContainer again with the id of a
173
- // container the host still holds a (now dead) handle for. Handles are keyed by
174
- // the per-spawn dockerId, so without an explicit drop the old one would linger
175
- // forever and its ports would never come back.
176
- it('drops the stale handle, releases its ports, and keeps the identity', async () => {
177
- const orch = makeOrchestrator();
178
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
179
-
180
- const first = await orch.createContainer(makeRequest('c1'));
181
- await waitFor(() => orch.isRunning(first.dockerId));
182
- const identityPath = join(rootDir, 'c1', 'identity.key');
183
- const peerBefore = loadIdentity(identityPath).peerId;
184
-
185
- // Kill the child the way a crash would — the handle survives, still marked
186
- // alive until something notices.
187
- const { pid: firstPid } = decodeDockerId(first.dockerId);
188
- process.kill(firstPid, 'SIGKILL');
189
- await waitFor(() => !isPidAlive(firstPid));
190
-
191
- const second = await orch.createContainer(makeRequest('c1'));
192
-
193
- // Exactly one handle for c1, and it is the new spawn.
194
- const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
195
- expect(handles).toHaveLength(1);
196
- expect(handles[0]!.dockerId).toBe(second.dockerId);
197
-
198
- // The allocator hands back the lowest free port, so re-using the first
199
- // spawn's exact ports is what proves they were released.
200
- expect(second.p2pPort).toBe(first.p2pPort);
201
- expect(second.healthEndpoint).toBe(first.healthEndpoint);
202
-
203
- // Same workdir, same key → same peer id. This is what makes a respawn the
204
- // same node rather than a new one the borrower's cadre has never approved.
205
- expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
206
- });
207
-
208
- // A requester that dials the node itself (a phone) knows it only by the address it
209
- // was given, so a re-spawn must come back on the SAME ports even when the allocator
210
- // has lower ones free. The test above cannot tell reuse from lowest-free: there the
211
- // old ports are also the lowest free ones.
212
- it('comes back on its own ports even when lower ones are free', async () => {
213
- const orch = makeOrchestrator();
214
-
215
- const lower = await orch.createContainer(makeRequest('c0'));
216
- const first = await orch.createContainer(makeRequest('c1'));
217
- await waitFor(() => orch.isRunning(first.dockerId));
218
- const portsBefore = orch.getNode('c1')!.ports;
219
-
220
- await orch.removeContainer(lower.dockerId);
221
- const { pid } = decodeDockerId(first.dockerId);
222
- process.kill(pid, 'SIGKILL');
223
- await waitFor(() => !isPidAlive(pid));
224
-
225
- await orch.createContainer(makeRequest('c1'));
226
- expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
227
- });
228
-
229
- // A handle written to state.json before the WebSocket port existed has no `ws`.
230
- // Rehydrating it must hold the ports it does have, and its re-spawn must keep those
231
- // and gain a WebSocket port rather than trip over the missing key.
232
- it('re-spawns a handle persisted without a ws port on its old ports plus a fresh ws', async () => {
233
- const rootDir = join(tmpRoot, 'legacy');
234
- const legacyPorts = { health: 18010, metrics: 18011, p2p: 18012, admin: 18013 };
235
- new StateStore(rootDir).save({
236
- version: 1,
237
- handles: [{
238
- containerId: 'c1',
239
- dockerId: encodeDockerId(999999, 'deadbeef'),
240
- pid: 999999,
241
- startupToken: 'deadbeef',
242
- workdir: join(rootDir, 'c1'),
243
- ports: legacyPorts as NodePorts,
244
- spawnedAt: new Date(0).toISOString(),
245
- partyId: 'party-c1',
246
- profile: 'transaction',
247
- }],
248
- });
249
- const orch = makeOrchestrator({ rootDir });
250
- await orch.init();
251
-
252
- await orch.createContainer(makeRequest('c1'));
253
-
254
- const ports = orch.getNode('c1')!.ports;
255
- expect(ports).toMatchObject(legacyPorts);
256
- // The lowest free port: nothing below 18010 was held, `undefined` included.
257
- expect(ports.ws).toBe(18000);
258
- });
259
- });
260
-
261
- /**
262
- * A launch that fails must leave host state exactly as it found it. The drop of
263
- * the previous handle happens before the child is launched, so without an
264
- * explicit restore a failed re-spawn would leave the donation record naming a
265
- * dockerId the orchestrator can no longer resolve — and the node's workdir
266
- * (identity key, node-local stores, storage) stranded on disk with nothing able
267
- * to delete it.
268
- *
269
- * Inducing a launch failure portably: replace the workdir's `storage`
270
- * directory with a plain file. `mkdirSync(path, { recursive: true })` throws
271
- * EEXIST when the path exists as a non-directory on every platform, and it is
272
- * the second statement in `launchChild` — after the handle drop, before the
273
- * spawn.
274
- */
275
- describe('HostProcessOrchestrator failed launch', () => {
276
- it('keeps the prior handle addressable, so a later terminate still reclaims the workdir', async () => {
277
- const orch = makeOrchestrator();
278
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
279
-
280
- const first = await orch.createContainer(makeRequest('c1'));
281
- await waitFor(() => orch.isRunning(first.dockerId));
282
- await orch.stopContainer(first.dockerId);
283
-
284
- const workdir = join(rootDir, 'c1');
285
- sabotageWorkdir(workdir);
286
-
287
- await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/EEXIST|exists/i);
288
-
289
- // The record's dockerId still resolves, and no ghost handle was added.
290
- expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
291
- expect(orch.resolveDockerId('c1')).toBe(first.dockerId);
292
- expect(orch.listNodes()).toHaveLength(1);
293
-
294
- // Which is the whole point: cleanup can still find and delete the node.
295
- await orch.removeContainer(first.dockerId);
296
- expect(existsSync(workdir)).toBe(false);
297
- expect(orch.listNodes()).toHaveLength(0);
298
- });
299
-
300
- it('does not shift the ports a successful retry comes back on', async () => {
301
- const orch = makeOrchestrator();
302
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
303
-
304
- const first = await orch.createContainer(makeRequest('c1'));
305
- await waitFor(() => orch.isRunning(first.dockerId));
306
- await orch.stopContainer(first.dockerId);
307
-
308
- const workdir = join(rootDir, 'c1');
309
- const storage = sabotageWorkdir(workdir);
310
- await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
311
-
312
- rmSync(storage, { force: true });
313
- const second = await orch.createContainer(makeRequest('c1'));
314
-
315
- // The restored handle held its ports across the failure, and the
316
- // allocator hands back the lowest free port — so identical ports prove the
317
- // failed attempt neither leaked nor stole them.
318
- expect(second.p2pPort).toBe(first.p2pPort);
319
- expect(second.healthEndpoint).toBe(first.healthEndpoint);
320
- expect(second.metricsEndpoint).toBe(first.metricsEndpoint);
321
-
322
- // And the successful spawn's own drop still stands: exactly one handle.
323
- const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
324
- expect(handles).toHaveLength(1);
325
- expect(handles[0]!.dockerId).toBe(second.dockerId);
326
- });
327
-
328
- it('releases every port a failed FIRST spawn reserved', async () => {
329
- const orch = makeOrchestrator({ portRange: { start: 12000, end: 12100 } });
330
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
331
-
332
- await orch.createContainer(makeRequest('c1'));
333
- expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003, ws: 12004 });
334
-
335
- // No prior handle to restore here — the five ports are simply released.
336
- mkdirSync(join(rootDir, 'c2'), { recursive: true });
337
- writeFileSync(join(rootDir, 'c2', 'storage'), 'not-a-directory', 'utf8');
338
- await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow();
339
-
340
- await orch.createContainer(makeRequest('c3'));
341
- // With the leak these would start at 12010.
342
- expect(orch.getNode('c3')!.ports).toEqual({ health: 12005, metrics: 12006, p2p: 12007, admin: 12008, ws: 12009 });
343
- });
344
-
345
- it('leaves the owner node addressable on its original ports', async () => {
346
- const orch = makeOrchestrator();
347
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
348
- const ownerConfig = {
349
- identityPath: join(tmpRoot, 'identity.key'),
350
- partyId: 'party-owner',
351
- libp2pPort: 14001,
352
- };
353
-
354
- const owner = await orch.ensureOwnerNode(ownerConfig);
355
- await waitFor(() => orch.isRunning(owner.dockerId));
356
- const endpointBefore = orch.getOwnerAdminEndpoint();
357
-
358
- // ensureOwnerNode short-circuits on a live child, so kill it first.
359
- const { pid } = decodeDockerId(owner.dockerId);
360
- process.kill(pid, 'SIGKILL');
361
- await waitFor(() => !isPidAlive(pid));
362
-
363
- const storage = sabotageWorkdir(join(rootDir, 'owner'));
364
- await expect(orch.ensureOwnerNode()).rejects.toThrow();
365
-
366
- // The admin channel still resolves — a manager that lost this endpoint
367
- // could no longer delegate to its own node.
368
- expect(orch.getOwnerAdminEndpoint()).toEqual(endpointBefore);
369
- expect(orch.listNodes().some((n) => n.dockerId === owner.dockerId)).toBe(true);
370
-
371
- rmSync(storage, { force: true });
372
- const respawned = await orch.ensureOwnerNode();
373
- expect(respawned.ports).toEqual(owner.ports);
374
- expect(orch.listNodes().filter((n) => n.owner)).toHaveLength(1);
375
- });
376
-
377
- // The caller's unwind is only correct for failures that happen BEFORE the
378
- // child exists. A state.json write failure happens after it — if that were
379
- // allowed to propagate, the unwind would release the ports the live child is
380
- // bound to and put the replaced handle back beside it.
381
- it('does not unwind a launch that already spawned when the state write fails', async () => {
382
- const orch = makeOrchestrator();
383
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
384
- const first = await orch.createContainer(makeRequest('c1'));
385
- await waitFor(() => orch.isRunning(first.dockerId));
386
- await orch.stopContainer(first.dockerId);
387
-
388
- const stateStore = (orch as unknown as { stateStore: StateStore }).stateStore;
389
- const save = stateStore.save.bind(stateStore);
390
- stateStore.save = () => { throw new Error('ENOSPC: no space left on device'); };
391
- const second = await orch.createContainer(makeRequest('c1'));
392
- stateStore.save = save;
393
-
394
- // One handle for the container, and it is the new one.
395
- expect(orch.listNodes()).toHaveLength(1);
396
- expect(orch.getNode('c1')?.dockerId).toBe(second.dockerId);
397
-
398
- // And the workdir the live child is running in is untouched — a swallowed
399
- // persist must not reach the unwind that would delete it.
400
- expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
401
-
402
- // Its ports are still held: a fresh container gets different ones.
403
- const other = await orch.createContainer(makeRequest('c2'));
404
- expect(other.p2pPort).not.toBe(second.p2pPort);
405
- expect(orch.getNode('c2')!.ports).not.toEqual(orch.getNode('c1')!.ports);
406
- });
407
-
408
- // A spawn that CREATED the workdir must take it back down on the way out —
409
- // the record it was provisioning never got a dockerId, so nothing else can
410
- // ever name that directory. The storage-file sabotage above is unusable here:
411
- // pre-creating `<workdir>/storage` makes the directory pre-existing, and the
412
- // unwind then correctly declines to delete it. Exhausting the port range
413
- // fails the very next step instead, with the workdir genuinely fresh.
414
- it('discards the workdir a failed first spawn created', async () => {
415
- // Exactly five ports — enough for one node and no more.
416
- const orch = makeOrchestrator({ portRange: { start: 13000, end: 13004 } });
417
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
418
-
419
- await orch.createContainer(makeRequest('c1'));
420
- await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow(/No available ports/);
421
-
422
- // `ensureNodeIdentity` had already created it and written the key.
423
- expect(existsSync(join(rootDir, 'c2'))).toBe(false);
424
- // The first node is untouched.
425
- expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
426
- expect(orch.listNodes()).toHaveLength(1);
427
- });
428
-
429
- // The load-bearing case in the other direction: a re-spawn is REUSING the
430
- // directory, and the identity key inside it is the whole reason the node
431
- // comes back as the same peer the borrower's cadre approved.
432
- it('keeps the workdir when a re-spawn launch fails', async () => {
433
- const orch = makeOrchestrator();
434
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
435
-
436
- const first = await orch.createContainer(makeRequest('c1'));
437
- await waitFor(() => orch.isRunning(first.dockerId));
438
- await orch.stopContainer(first.dockerId);
439
-
440
- const workdir = join(rootDir, 'c1');
441
- const identityPath = join(workdir, 'identity.key');
442
- const peerBefore = loadIdentity(identityPath).peerId;
443
-
444
- const storage = sabotageWorkdir(workdir);
445
- await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
446
-
447
- expect(existsSync(workdir)).toBe(true);
448
- expect(existsSync(identityPath)).toBe(true);
449
-
450
- // Which is what makes the next attempt the SAME node, not a stranger.
451
- rmSync(storage, { force: true });
452
- await orch.createContainer(makeRequest('c1'));
453
- expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
454
- });
455
-
456
- // `ensureNodeIdentity` now runs inside the same `try`, so its own failures
457
- // unwind too — and this one must NOT delete anything. The directory was
458
- // pre-existing, so the undecodable key is an operator-visible fault to
459
- // investigate, not this attempt's mess to clear up.
460
- it('keeps a pre-existing workdir whose identity key is undecodable', async () => {
461
- const orch = makeOrchestrator();
462
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
463
- const workdir = join(rootDir, 'c1');
464
-
465
- mkdirSync(workdir, { recursive: true });
466
- writeFileSync(join(workdir, 'identity.key'), Buffer.from([0xff, 0xff, 0xff, 0xff]));
467
-
468
- await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
469
-
470
- expect(existsSync(workdir)).toBe(true);
471
- expect(existsSync(join(workdir, 'identity.key'))).toBe(true);
472
- expect(orch.listNodes()).toHaveLength(0);
473
- });
474
- });
475
-
476
- /**
477
- * The cleanup of last resort: a donation record that never got a `dockerId`
478
- * because the host died mid-spawn. `createContainer`'s own unwind cannot help —
479
- * it was never reached — so the reap falls back to reclaiming by container name.
480
- */
481
- describe('HostProcessOrchestrator.reclaimWorkdir', () => {
482
- it('removes an orphaned workdir no handle owns', async () => {
483
- const orch = makeOrchestrator();
484
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
485
- const workdir = join(rootDir, 'grn_orphan');
486
- mkdirSync(workdir, { recursive: true });
487
- writeFileSync(join(workdir, 'identity.key'), 'key-bytes', 'utf8');
488
-
489
- expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
490
- expect(existsSync(workdir)).toBe(false);
491
- });
492
-
493
- it('refuses a container a live handle still owns', async () => {
494
- const orch = makeOrchestrator();
495
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
496
- const created = await orch.createContainer(makeRequest('c1'));
497
-
498
- // That directory is the live child's cwd; only `removeContainer`, which
499
- // stops the child first, may delete it.
500
- expect(orch.reclaimWorkdir('c1')).toBe(false);
501
- expect(existsSync(join(rootDir, 'c1'))).toBe(true);
502
- expect(orch.resolveDockerId('c1')).toBe(created.dockerId);
503
- });
504
-
505
- it('refuses the owner node', async () => {
506
- const orch = makeOrchestrator();
507
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
508
- // No owner handle exists, so only the id check can save this directory —
509
- // which holds the host's own control-DB storage.
510
- const workdir = join(rootDir, 'owner');
511
- mkdirSync(workdir, { recursive: true });
512
-
513
- expect(orch.reclaimWorkdir('owner')).toBe(false);
514
- expect(existsSync(workdir)).toBe(true);
515
- });
516
-
517
- it('reports false for a workdir that is already gone', () => {
518
- const orch = makeOrchestrator();
519
- expect(orch.reclaimWorkdir('grn_never_existed')).toBe(false);
520
- });
521
-
522
- it('refuses an id that names a file rather than a workdir', () => {
523
- const orch = makeOrchestrator();
524
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
525
- // `rootDir` is not only container directories — `state.json` is the
526
- // orchestrator's own persisted handle map, and `rmSync` would take it.
527
- const statePath = join(rootDir, 'state.json');
528
- writeFileSync(statePath, '{"handles":[]}', 'utf8');
529
-
530
- expect(orch.reclaimWorkdir('state.json')).toBe(false);
531
- expect(existsSync(statePath)).toBe(true);
532
- });
533
-
534
- it('refuses an id that would escape rootDir', () => {
535
- const orch = makeOrchestrator();
536
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
537
- const sibling = join(rootDir, '..', 'sibling');
538
- mkdirSync(sibling, { recursive: true });
539
-
540
- // Unreachable with real `grn_<base64url>` ids; the guard is defence for a
541
- // future caller that passes something else.
542
- expect(orch.reclaimWorkdir('../sibling')).toBe(false);
543
- expect(existsSync(sibling)).toBe(true);
544
- });
545
- });
546
-
547
- describe('HostProcessOrchestrator.isRunning', () => {
548
- it('is true after spawn and false after kill for a child this orchestrator spawned', async () => {
549
- const orch = makeOrchestrator();
550
- const { dockerId } = await orch.createContainer(makeRequest('c1'));
551
- const { pid } = decodeDockerId(dockerId);
552
-
553
- await waitFor(() => orch.isRunning(dockerId));
554
- expect(await orch.isRunning(dockerId)).toBe(true);
555
-
556
- // The token file is not consulted for a child this process spawned — its
557
- // ChildProcess is exact, and the file has a start-up window it does not.
558
- const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
559
- writeFileSync(join(workdir, '.startup-token'), 'tampered', 'utf8');
560
- expect(await orch.isRunning(dockerId)).toBe(true);
561
-
562
- process.kill(pid, 'SIGKILL');
563
- await waitFor(() => !isPidAlive(pid));
564
- expect(await orch.isRunning(dockerId)).toBe(false);
565
- });
566
-
567
- it('checks a re-attached handle by pid plus startup token', async () => {
568
- const rootDir = join(tmpRoot, 'reattach');
569
- const a = makeOrchestrator({ rootDir });
570
- const { dockerId } = await a.createContainer(makeRequest('c1'));
571
- const { pid } = decodeDockerId(dockerId);
572
- await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
573
-
574
- // A new orchestrator has no ChildProcess for it — the host-restart case.
575
- const b = makeOrchestrator({ rootDir });
576
- await b.init();
577
- expect(await b.isRunning(dockerId)).toBe(true);
578
-
579
- // A live pid whose token does not match may be an unrelated process that
580
- // inherited the pid, so it does not count as ours.
581
- const tokenPath = join(rootDir, 'c1', '.startup-token');
582
- const token = readFileSync(tokenPath, 'utf8');
583
- writeFileSync(tokenPath, 'tampered', 'utf8');
584
- expect(await b.isRunning(dockerId)).toBe(false);
585
-
586
- writeFileSync(tokenPath, token, 'utf8');
587
- expect(await b.isRunning(dockerId)).toBe(true);
588
-
589
- process.kill(pid, 'SIGKILL');
590
- await waitFor(() => !isPidAlive(pid));
591
- expect(await b.isRunning(dockerId)).toBe(false);
592
- });
593
- });
594
-
595
- /**
596
- * A child that binds its health port at once but writes its startup token only
597
- * after `TOKEN_DELAY_MS` — the shape of a real `cadre-cli start` built before the
598
- * token write moved to the front, and of any child that is slow to reach it. Dies
599
- * on a port clash the way `cadre-cli start` does.
600
- *
601
- * The incident this guards against: the donation supervisor asked `isRunning`
602
- * during that window, got `false`, and re-spawned the container. The re-spawn
603
- * released and reused the same ports, rotated the seed token, and returned
604
- * success; the second child then died on the port the first still held, leaving
605
- * the host's records naming a dead child and the live one refusing every seed.
606
- */
607
- describe('HostProcessOrchestrator re-spawn over a child that is still starting', () => {
608
- const TOKEN_DELAY_MS = 3000;
609
- const SLOW_TOKEN_CHILD = `
610
- import fs from 'node:fs';
611
- import net from 'node:net';
612
- const args = process.argv.slice(2);
613
- const tokenPath = args[args.indexOf('--startup-token-file') + 1];
614
- const server = net.createServer();
615
- server.on('error', (err) => { console.error('Failed to start cadre node: ' + err.message); process.exit(1); });
616
- server.listen(Number(process.env.CADRE_HEALTH_PORT), '0.0.0.0', () => {
617
- console.log('listening');
618
- setTimeout(() => fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN), ${TOKEN_DELAY_MS});
619
- });
620
- process.on('SIGTERM', () => process.exit(0));
621
- setInterval(() => {}, 1 << 30);
622
- `;
623
- const TEST_TIMEOUT_MS = 20_000;
624
-
625
- let slowChildPath: string;
626
- beforeEach(() => {
627
- slowChildPath = join(tmpRoot, 'slow-token-child.mjs');
628
- writeFileSync(slowChildPath, SLOW_TOKEN_CHILD, 'utf8');
629
- });
630
-
631
- it('reports the child running before its token is written, and refuses to launch over it', async () => {
632
- const orch = makeOrchestrator({ spawn: { entrypoint: slowChildPath } });
633
- const rootDir = (orch as unknown as { rootDir: string }).rootDir;
634
- const tokenPath = join(rootDir, 'c1', '.startup-token');
635
-
636
- const first = await orch.createContainer(makeRequest('c1'));
637
- await waitFor(async () => (await orch.getLogs(first.dockerId)).includes('listening'));
638
- const portsBefore = orch.getNode('c1')!.ports;
639
-
640
- // Inside the start-up window: port bound, token not yet written.
641
- expect(existsSync(tokenPath)).toBe(false);
642
- expect(await orch.isRunning(first.dockerId)).toBe(true);
643
-
644
- // Exactly what `DonationService.respawn` would do next.
645
- await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/c1 is still running/);
646
-
647
- // Nothing was dropped, released or rotated.
648
- expect(orch.listNodes()).toHaveLength(1);
649
- expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
650
- expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
651
- const other = await orch.createContainer(makeRequest('c2'));
652
- expect(other.p2pPort).not.toBe(first.p2pPort);
653
- expect(other.healthEndpoint).not.toBe(first.healthEndpoint);
654
-
655
- // The first child finishes starting undisturbed, with the token it was given.
656
- const { pid, token } = decodeDockerId(first.dockerId);
657
- await waitFor(() => existsSync(tokenPath), TOKEN_DELAY_MS + 5000);
658
- expect(readFileSync(tokenPath, 'utf8')).toBe(token);
659
- expect(isPidAlive(pid)).toBe(true);
660
- expect(await orch.getLogs(first.dockerId)).not.toContain('EADDRINUSE');
661
- }, TEST_TIMEOUT_MS);
662
-
663
- it('refuses to launch over a re-attached child whose ports are still bound', async () => {
664
- const rootDir = join(tmpRoot, 'reattach-starting');
665
- const a = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
666
- const first = await a.createContainer(makeRequest('c1'));
667
- await waitFor(async () => (await a.getLogs(first.dockerId)).includes('listening'));
668
-
669
- // A host restart while the child is mid-start: no ChildProcess, no token yet.
670
- const b = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
671
- await b.init();
672
- expect(await b.isRunning(first.dockerId)).toBe(false);
673
-
674
- await expect(b.createContainer(makeRequest('c1')))
675
- .rejects.toThrow(/c1 may still be running: port \d+ on 0\.0\.0\.0 is unavailable/);
676
-
677
- // Nothing dropped: the record's dockerId still resolves on its ports, and
678
- // the live child was not killed on unverified evidence.
679
- expect(b.listNodes()).toHaveLength(1);
680
- expect(b.getNode('c1')?.dockerId).toBe(first.dockerId);
681
- const { pid } = decodeDockerId(first.dockerId);
682
- expect(isPidAlive(pid)).toBe(true);
683
-
684
- // Once it has written its token it is recognised as running.
685
- await waitFor(() => b.isRunning(first.dockerId), TOKEN_DELAY_MS + 5000);
686
- }, TEST_TIMEOUT_MS);
687
- });
688
-
689
- describe('HostProcessOrchestrator.stopContainer', () => {
690
- it('SIGTERM stops a cooperative child without needing SIGKILL', async () => {
691
- const orch = makeOrchestrator();
692
- const { dockerId } = await orch.createContainer(makeRequest('c1'));
693
- const { pid } = decodeDockerId(dockerId);
694
-
695
- await waitFor(() => orch.isRunning(dockerId));
696
- await orch.stopContainer(dockerId);
697
-
698
- expect(isPidAlive(pid)).toBe(false);
699
- expect(await orch.isRunning(dockerId)).toBe(false);
700
- });
701
-
702
- // On Windows, process.kill ignores the signal name and always force-kills,
703
- // so a child cannot ignore SIGTERM at the Node level. Skip there.
704
- it.skipIf(process.platform === 'win32')(
705
- 'escalates to SIGKILL when the child ignores SIGTERM',
706
- async () => {
707
- const orch = makeOrchestrator();
708
- process.env.IGNORE_SIGTERM = '1';
709
- let dockerId: string;
710
- try {
711
- ({ dockerId } = await orch.createContainer(makeRequest('c2')));
712
- } finally {
713
- delete process.env.IGNORE_SIGTERM;
714
- }
715
- const { pid } = decodeDockerId(dockerId);
716
- await waitFor(() => isPidAlive(pid));
717
-
718
- const start = Date.now();
719
- await orch.stopContainer(dockerId);
720
- const elapsed = Date.now() - start;
721
-
722
- // Should have waited the full stopTimeoutMs (1500) before SIGKILL.
723
- expect(elapsed).toBeGreaterThanOrEqual(1000);
724
- expect(isPidAlive(pid)).toBe(false);
725
- },
726
- );
727
- });
728
-
729
- describe('HostProcessOrchestrator.removeContainer', () => {
730
- it('releases ports, deletes workdir, drops state', async () => {
731
- const orch = makeOrchestrator();
732
- const { dockerId, healthEndpoint } = await orch.createContainer(makeRequest('c1'));
733
- const port = Number(healthEndpoint.match(/:(\d+)/)![1]);
734
-
735
- await waitFor(() => orch.isRunning(dockerId));
736
- await orch.removeContainer(dockerId);
737
-
738
- // Workdir is gone
739
- const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
740
- expect(() => readFileSync(join(workdir, 'cadre.json'), 'utf8')).toThrow();
741
-
742
- // State no longer references it
743
- const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
744
- expect(persisted.handles).toHaveLength(0);
745
-
746
- // Subsequent getStats rejects
747
- await expect(orch.getStats(dockerId)).rejects.toThrow(/not found/i);
748
-
749
- // Port was released — reallocate it via a fresh container; pick the next id.
750
- const next = await orch.createContainer(makeRequest('c2'));
751
- // (we don't assert the same port — just that allocation works post-release)
752
- expect(next.healthEndpoint).toMatch(/:\d+\/health/);
753
- expect(port).toBeGreaterThan(0);
754
- });
755
- });
756
-
757
- /**
758
- * `getStats` goes through `pidusage`, which on Windows shells out: modern
759
- * builds no longer ship `wmic`, so it falls back to spawning `powershell.exe`
760
- * to run `gwmi win32_process` — and the first sample for a fresh pid costs
761
- * *two* such spawns, because CPU percent needs a prior baseline. Measured
762
- * ~2.8 s idle and ~4.9 s on a loaded machine, so the 5 s default test timeout
763
- * is not a real budget for this one. Give it explicit headroom.
764
- */
765
- const STATS_TIMEOUT_MS = 30_000;
766
-
767
- describe('HostProcessOrchestrator.getStats', () => {
768
- it('returns plausible numbers with zero network counters', async () => {
769
- const orch = makeOrchestrator();
770
- const { dockerId } = await orch.createContainer(makeRequest('c1'));
771
- await waitFor(() => orch.isRunning(dockerId));
772
- // Give the child a moment to use some CPU/mem
773
- await sleep(200);
774
-
775
- const stats = await orch.getStats(dockerId);
776
- expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
777
- expect(stats.memoryBytes).toBeGreaterThan(0);
778
- expect(stats.networkRxBytes).toBe(0);
779
- expect(stats.networkTxBytes).toBe(0);
780
- }, STATS_TIMEOUT_MS);
781
- });
782
-
783
- describe('HostProcessOrchestrator log rotation at spawn', () => {
784
- it('rotates an oversized node.log before the new child opens it', async () => {
785
- const rootDir = join(tmpRoot, 'rot');
786
- mkdirSync(rootDir, { recursive: true });
787
- const containerWorkdir = join(rootDir, 'rotc1');
788
- mkdirSync(containerWorkdir, { recursive: true });
789
- // Pre-seed an oversized active log so maybeRotateAtSpawn fires.
790
- const seed = 'X'.repeat(2048);
791
- writeFileSync(join(containerWorkdir, 'node.log'), seed, 'utf8');
792
-
793
- const orch = makeOrchestrator({
794
- rootDir,
795
- logMaxBytes: 1024,
796
- logMaxFiles: 3,
797
- });
798
- const { dockerId } = await orch.createContainer(makeRequest('rotc1'));
799
- await waitFor(() => orch.isRunning(dockerId));
800
-
801
- // The rotation cascade should have moved the seeded contents to .1
802
- // and the fresh child should be writing to a brand-new node.log.
803
- expect(readFileSync(join(containerWorkdir, 'node.log.1'), 'utf8')).toBe(seed);
804
- expect(statSync(join(containerWorkdir, 'node.log')).size).toBeLessThan(seed.length);
805
- });
806
- });
807
-
808
- describe('HostProcessOrchestrator.getLogs', () => {
809
- it('returns recent stdout lines from the child', async () => {
810
- const orch = makeOrchestrator();
811
- process.env.CHILD_INITIAL_MESSAGE = 'hello-from-fake';
812
- let dockerId: string;
813
- try {
814
- ({ dockerId } = await orch.createContainer(makeRequest('c1')));
815
- } finally {
816
- delete process.env.CHILD_INITIAL_MESSAGE;
817
- }
818
-
819
- await waitFor(async () => {
820
- const logs = await orch.getLogs(dockerId, 200);
821
- return logs.includes('hello-from-fake');
822
- });
823
-
824
- const logs = await orch.getLogs(dockerId, 200);
825
- expect(logs).toContain('hello-from-fake');
826
- });
827
- });
828
-
829
- describe('rotateOnDisk', () => {
830
- it('cascades base -> .1 -> ... and caps at maxFiles', async () => {
831
- const fs = await import('node:fs');
832
- const dir = join(tmpRoot, 'logs');
833
- mkdirSync(dir, { recursive: true });
834
- const path = join(dir, 'node.log');
835
-
836
- // Seed: base + .1 + .2 + .3 each with distinguishable content.
837
- writeFileSync(path, 'base', 'utf8');
838
- writeFileSync(`${path}.1`, 'one', 'utf8');
839
- writeFileSync(`${path}.2`, 'two', 'utf8');
840
- writeFileSync(`${path}.3`, 'three', 'utf8');
841
-
842
- rotateOnDisk(path, 3);
843
-
844
- // Active path is gone (rotated away). .1 has former base, .2 has former
845
- // .1, .3 has former .2; former .3 is dropped because maxFiles=3.
846
- expect(fs.existsSync(path)).toBe(false);
847
- expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('base');
848
- expect(fs.readFileSync(`${path}.2`, 'utf8')).toBe('one');
849
- expect(fs.readFileSync(`${path}.3`, 'utf8')).toBe('two');
850
- expect(fs.existsSync(`${path}.4`)).toBe(false);
851
- });
852
-
853
- it('is a no-op when the active file does not exist', () => {
854
- const dir = join(tmpRoot, 'logs-empty');
855
- mkdirSync(dir, { recursive: true });
856
- const path = join(dir, 'node.log');
857
- expect(() => rotateOnDisk(path, 3)).not.toThrow();
858
- });
859
-
860
- it('handles a partial cascade (only base present)', async () => {
861
- const fs = await import('node:fs');
862
- const dir = join(tmpRoot, 'logs-partial');
863
- mkdirSync(dir, { recursive: true });
864
- const path = join(dir, 'node.log');
865
- writeFileSync(path, 'only', 'utf8');
866
-
867
- rotateOnDisk(path, 5);
868
-
869
- expect(fs.existsSync(path)).toBe(false);
870
- expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('only');
871
- expect(fs.existsSync(`${path}.2`)).toBe(false);
872
- });
873
- });
874
-
875
- describe('Restart recovery (init)', () => {
876
- it('re-attaches to surviving children and reports dead ones', async () => {
877
- const rootDir = join(tmpRoot, 'restart');
878
- const a = makeOrchestrator({ rootDir });
879
- const r1 = await a.createContainer(makeRequest('c1'));
880
- const r2 = await a.createContainer(makeRequest('c2'));
881
- // Not `a.isRunning`: that answers from the ChildProcess at once, but `b`
882
- // below can only recognise c1 once c1 has written its startup token.
883
- await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
884
- await waitFor(() => existsSync(join(rootDir, 'c2', '.startup-token')));
885
-
886
- // Kill c2 externally
887
- const { pid: pid2 } = decodeDockerId(r2.dockerId);
888
- process.kill(pid2, 'SIGKILL');
889
- await waitFor(() => !isPidAlive(pid2));
890
-
891
- // New orchestrator over the same rootDir, init()
892
- const b = makeOrchestrator({ rootDir });
893
- await b.init();
894
-
895
- expect(await b.isRunning(r1.dockerId)).toBe(true);
896
- expect(await b.isRunning(r2.dockerId)).toBe(false);
897
-
898
- // Ports from both should be reserved (no overlapping allocations from `b`)
899
- // Allocate three more containers via `b` and confirm none collide with
900
- // c1/c2's existing ports.
901
- const reservedPorts = new Set([
902
- r1.p2pPort,
903
- r2.p2pPort,
904
- Number(r1.healthEndpoint.match(/:(\d+)/)![1]),
905
- Number(r1.metricsEndpoint.match(/:(\d+)/)![1]),
906
- Number(r2.healthEndpoint.match(/:(\d+)/)![1]),
907
- Number(r2.metricsEndpoint.match(/:(\d+)/)![1]),
908
- ]);
909
- const r3 = await b.createContainer(makeRequest('c3'));
910
- const r3Ports = new Set([
911
- r3.p2pPort,
912
- Number(r3.healthEndpoint.match(/:(\d+)/)![1]),
913
- Number(r3.metricsEndpoint.match(/:(\d+)/)![1]),
914
- ]);
915
- for (const p of r3Ports) {
916
- expect(reservedPorts.has(p)).toBe(false);
917
- }
918
- });
919
- });
920
-
921
- /**
922
- * The orchestrator must spawn children that survive the spawning process
923
- * itself exiting. The same-process restart test below does not exercise
924
- * this — both orchestrators live in the same vitest worker, so the
925
- * spawning process's pipe-read fds never close. This test genuinely exits
926
- * the spawning process and asserts that the grandchild (a) stays alive
927
- * past the parent's death and (b) keeps writing to its log file.
928
- *
929
- * Requires `yarn workspace @serfab/cadre-host build` to have been run —
930
- * the helper script imports the built dist so it can run as a plain Node
931
- * process without a TS loader.
932
- */
933
- describe('child survives orchestrator exit', () => {
934
- const orchestratorDistUrl = (() => {
935
- const here = dirname(fileURLToPath(import.meta.url));
936
- const distDir = resolvePath(here, '..', '..', 'dist', 'orchestrator');
937
- const distPath = join(distDir, 'host-process-orchestrator.js');
938
- return {
939
- distPath,
940
- orchestratorUrl: pathToFileURL(distPath).href,
941
- logRotatorUrl: pathToFileURL(join(distDir, 'log-rotator.js')).href,
942
- };
943
- })();
944
-
945
- it.skipIf(!existsSync(orchestratorDistUrl.distPath))(
946
- 'grandchild stays alive and keeps logging after spawner process exits',
947
- async () => {
948
- const { orchestratorUrl, logRotatorUrl } = orchestratorDistUrl;
949
- const rootDir = join(tmpRoot, 'cross');
950
- mkdirSync(rootDir, { recursive: true });
951
-
952
- const helperPath = join(tmpRoot, 'spawner.mjs');
953
- writeFileSync(helperPath, buildSpawnerScript(orchestratorUrl, logRotatorUrl), 'utf8');
954
-
955
- const request = {
956
- containerId: 'cross1',
957
- partyId: 'party-cross1',
958
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
959
- profile: 'transaction',
960
- };
961
- const payload = {
962
- rootDir,
963
- entrypoint: scriptPath,
964
- request,
965
- // Ensure the fake child keeps ticking quickly so logs grow visibly
966
- // in the ~300 ms observation window.
967
- childEnv: { CHILD_TICK_MS: '20', CHILD_TICK_PAYLOAD: 'cross-tick' },
968
- };
969
-
970
- // Run the helper as a normal (non-detached) Node child and wait for
971
- // it to exit. The orchestrator's grandchild should survive.
972
- const result = spawnSync(process.execPath, [helperPath, JSON.stringify(payload)], {
973
- encoding: 'utf8',
974
- timeout: 15000,
975
- });
976
- expect(result.status, `helper exited non-zero: stderr=${result.stderr}`).toBe(0);
977
-
978
- const jsonLine = result.stdout
979
- .split(/\r?\n/)
980
- .reverse()
981
- .find((line) => line.trim().startsWith('{'));
982
- expect(jsonLine, `helper produced no JSON line: stdout=${result.stdout}`).toBeTruthy();
983
- const handle = JSON.parse(jsonLine!) as {
984
- dockerId: string;
985
- pid: number;
986
- workdir: string;
987
- logPath: string;
988
- };
989
-
990
- try {
991
- // Immediate liveness
992
- expect(isPidAlive(handle.pid)).toBe(true);
993
-
994
- // POSIX delivers SIGPIPE on the child's NEXT write — give it time
995
- // to attempt one (the fake child ticks every 20 ms).
996
- await sleep(500);
997
- expect(isPidAlive(handle.pid), 'grandchild died after spawner exit (likely SIGPIPE)').toBe(true);
998
-
999
- // Logs must keep growing — proves stdio still works, not just PID.
1000
- const sizeA = statSync(handle.logPath).size;
1001
- await sleep(300);
1002
- const sizeB = statSync(handle.logPath).size;
1003
- expect(sizeB).toBeGreaterThan(sizeA);
1004
- } finally {
1005
- // Started by the helper process, so no orchestrator here holds it: the one child removeAllNodes cannot see.
1006
- try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
1007
- try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
1008
- }
1009
- },
1010
- // The helper spawn alone budgets 15 s; the surrounding waits add ~0.8 s
1011
- // more. Under the 5 s default the test could never reach its own timeout.
1012
- 30_000,
1013
- );
1014
- });
1015
-
1016
- function buildSpawnerScript(orchestratorUrl: string, logRotatorUrl: string): string {
1017
- return `
1018
- import { HostProcessOrchestrator } from ${JSON.stringify(orchestratorUrl)};
1019
- import { defaultLogPath } from ${JSON.stringify(logRotatorUrl)};
1020
-
1021
- const payload = JSON.parse(process.argv[2]);
1022
- const { rootDir, entrypoint, request, childEnv } = payload;
1023
-
1024
- for (const [k, v] of Object.entries(childEnv ?? {})) {
1025
- process.env[k] = String(v);
1026
- }
1027
-
1028
- const orch = new HostProcessOrchestrator({
1029
- rootDir,
1030
- portRange: { start: 19000, end: 19999 },
1031
- stopTimeoutMs: 1500,
1032
- spawn: { entrypoint },
1033
- });
1034
-
1035
- const res = await orch.createContainer(request);
1036
- const pid = Number(res.dockerId.split(':')[0]);
1037
- const workdir = (await import('node:path')).join(rootDir, request.containerId);
1038
- const logPath = defaultLogPath(workdir);
1039
-
1040
- process.stdout.write(JSON.stringify({
1041
- dockerId: res.dockerId,
1042
- pid,
1043
- workdir,
1044
- logPath,
1045
- }) + '\\n');
1046
-
1047
- // Exit cleanly — the detached/unref'd child should outlive us.
1048
- process.exit(0);
1049
- `;
1050
- }
1051
-
1052
- describe('dockerId encoding', () => {
1053
- it('encode/decode round-trips pid and token', () => {
1054
- const id = encodeDockerId(12345, 'abcdef');
1055
- expect(decodeDockerId(id)).toEqual({ pid: 12345, token: 'abcdef' });
1056
- });
1057
-
1058
- it('rejects malformed ids', () => {
1059
- expect(() => decodeDockerId('notvalid')).toThrow();
1060
- expect(() => decodeDockerId(':oops')).toThrow();
1061
- expect(() => decodeDockerId('0:tok')).toThrow();
1062
- });
1063
- });
1
+ /**
2
+ * HostProcessOrchestrator unit tests.
3
+ *
4
+ * These tests spawn a tiny fake child script — NOT the real cadre-cli — so
5
+ * they stay fast and don't require cadre-cli to be built. The orchestrator
6
+ * exposes a `spawn.entrypoint` override for exactly this purpose.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join, resolve as resolvePath } from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+
16
+ import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
17
+ import { rotateOnDisk } from '../orchestrator/log-rotator.js';
18
+ import { decodeDockerId, encodeDockerId, type NodePorts } from '../orchestrator/types.js';
19
+ import { StateStore } from '../orchestrator/state-store.js';
20
+ import { isPidAlive } from '../orchestrator/pid-liveness.js';
21
+ import { loadIdentity } from '../installer/identity.js';
22
+ import { describeHandleContract } from './orchestrator-handle-contract.js';
23
+ import { removeAllNodes } from './orchestrator-teardown.js';
24
+
25
+ const FAKE_CHILD = `
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+
29
+ const args = process.argv.slice(2);
30
+ const configIdx = args.indexOf('-c');
31
+ const tokenIdx = args.indexOf('--startup-token-file');
32
+ const tokenPath = tokenIdx >= 0 ? args[tokenIdx + 1] : null;
33
+ const ignoreSigterm = process.env.IGNORE_SIGTERM === '1';
34
+ const initialMessage = process.env.CHILD_INITIAL_MESSAGE ?? 'tick';
35
+ const tickIntervalMs = parseInt(process.env.CHILD_TICK_MS ?? '50', 10);
36
+ const tickPayload = process.env.CHILD_TICK_PAYLOAD ?? 'tick';
37
+
38
+ if (tokenPath && process.env.CADRE_STARTUP_TOKEN) {
39
+ try {
40
+ fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN, 'utf8');
41
+ } catch (err) {
42
+ console.error('failed to write token:', err);
43
+ }
44
+ }
45
+
46
+ let alive = true;
47
+ process.on('SIGTERM', () => {
48
+ if (ignoreSigterm) {
49
+ console.log('ignoring SIGTERM');
50
+ return;
51
+ }
52
+ alive = false;
53
+ console.log('exiting on SIGTERM');
54
+ process.exit(0);
55
+ });
56
+
57
+ console.log(initialMessage);
58
+
59
+ const interval = setInterval(() => {
60
+ if (alive) {
61
+ process.stdout.write(tickPayload + '\\n');
62
+ } else {
63
+ clearInterval(interval);
64
+ }
65
+ }, tickIntervalMs);
66
+
67
+ setInterval(() => {}, 1 << 30);
68
+ `;
69
+
70
+ let tmpRoot: string;
71
+ let scriptPath: string;
72
+ const orchestrators: HostProcessOrchestrator[] = [];
73
+
74
+ beforeEach(() => {
75
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-orch-'));
76
+ scriptPath = join(tmpRoot, 'fake-child.mjs');
77
+ writeFileSync(scriptPath, FAKE_CHILD, 'utf8');
78
+ });
79
+
80
+ afterEach(async () => {
81
+ try {
82
+ await removeAllNodes(orchestrators);
83
+ } finally {
84
+ await sleep(50);
85
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
86
+ }
87
+ });
88
+
89
+ function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
90
+ const rootDir = overrides.rootDir ?? join(tmpRoot, 'orch');
91
+ mkdirSync(rootDir, { recursive: true });
92
+ const orch = new HostProcessOrchestrator({
93
+ rootDir,
94
+ portRange: { start: 18000, end: 18999 },
95
+ stopTimeoutMs: 1500,
96
+ logMaxBytes: overrides.logMaxBytes ?? 10 * 1024 * 1024,
97
+ logMaxFiles: overrides.logMaxFiles ?? 5,
98
+ spawn: { entrypoint: overrides.spawn?.entrypoint ?? scriptPath },
99
+ ...overrides,
100
+ });
101
+ orchestrators.push(orch);
102
+ return orch;
103
+ }
104
+
105
+ async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
106
+ const deadline = Date.now() + timeoutMs;
107
+ while (Date.now() < deadline) {
108
+ if (await predicate()) return;
109
+ await sleep(intervalMs);
110
+ }
111
+ throw new Error('waitFor: timed out');
112
+ }
113
+
114
+ function sleep(ms: number): Promise<void> {
115
+ return new Promise((res) => setTimeout(res, ms));
116
+ }
117
+
118
+ function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transaction' } = {}) {
119
+ return {
120
+ containerId,
121
+ partyId: 'party-' + containerId,
122
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
123
+ profile: opts.profile ?? 'transaction' as 'storage' | 'transaction',
124
+ };
125
+ }
126
+
127
+ /** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
128
+ function sabotageWorkdir(workdir: string): string {
129
+ const storage = join(workdir, 'storage');
130
+ rmSync(storage, { recursive: true, force: true });
131
+ writeFileSync(storage, 'not-a-directory', 'utf8');
132
+ return storage;
133
+ }
134
+
135
+ describeHandleContract<HostProcessOrchestrator>('HostProcessOrchestrator', {
136
+ make: () => makeOrchestrator(),
137
+ request: (containerId) => makeRequest(containerId),
138
+ started: (orch, dockerId) => waitFor(() => orch.isRunning(dockerId)),
139
+ failNextCreate: (orch, containerId) => { sabotageWorkdir(orch.getNode(containerId)!.workdir); },
140
+ });
141
+
142
+ describe('HostProcessOrchestrator.createContainer', () => {
143
+ it('spawns a live child and persists handle, ports, and endpoints', async () => {
144
+ const orch = makeOrchestrator();
145
+ const result = await orch.createContainer(makeRequest('c1'));
146
+
147
+ // Endpoints derive from allocated ports
148
+ expect(result.healthEndpoint).toMatch(/^http:\/\/localhost:\d+\/health$/);
149
+ expect(result.metricsEndpoint).toMatch(/^http:\/\/localhost:\d+\/metrics$/);
150
+ expect(result.p2pPort).toBeGreaterThanOrEqual(18000);
151
+
152
+ const { pid } = decodeDockerId(result.dockerId);
153
+ await waitFor(() => isPidAlive(pid));
154
+ expect(isPidAlive(pid)).toBe(true);
155
+
156
+ // Wait until the child has written its startup token file
157
+ await waitFor(() => orch.isRunning(result.dockerId));
158
+ expect(await orch.isRunning(result.dockerId)).toBe(true);
159
+
160
+ // Workdir scaffolded
161
+ const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
162
+ expect(readFileSync(join(workdir, 'cadre.json'), 'utf8')).toContain('party-c1');
163
+
164
+ // State persisted
165
+ const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
166
+ expect(persisted.handles).toHaveLength(1);
167
+ expect(persisted.handles[0]!.dockerId).toBe(result.dockerId);
168
+ });
169
+ });
170
+
171
+ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
172
+ // The donated-node respawn path calls createContainer again with the id of a
173
+ // container the host still holds a (now dead) handle for. Handles are keyed by
174
+ // the per-spawn dockerId, so without an explicit drop the old one would linger
175
+ // forever and its ports would never come back.
176
+ it('drops the stale handle, releases its ports, and keeps the identity', async () => {
177
+ const orch = makeOrchestrator();
178
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
179
+
180
+ const first = await orch.createContainer(makeRequest('c1'));
181
+ await waitFor(() => orch.isRunning(first.dockerId));
182
+ const identityPath = join(rootDir, 'c1', 'identity.key');
183
+ const peerBefore = loadIdentity(identityPath).peerId;
184
+
185
+ // Kill the child the way a crash would — the handle survives, still marked
186
+ // alive until something notices.
187
+ const { pid: firstPid } = decodeDockerId(first.dockerId);
188
+ process.kill(firstPid, 'SIGKILL');
189
+ await waitFor(() => !isPidAlive(firstPid));
190
+
191
+ const second = await orch.createContainer(makeRequest('c1'));
192
+
193
+ // Exactly one handle for c1, and it is the new spawn.
194
+ const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
195
+ expect(handles).toHaveLength(1);
196
+ expect(handles[0]!.dockerId).toBe(second.dockerId);
197
+
198
+ // The allocator hands back the lowest free port, so re-using the first
199
+ // spawn's exact ports is what proves they were released.
200
+ expect(second.p2pPort).toBe(first.p2pPort);
201
+ expect(second.healthEndpoint).toBe(first.healthEndpoint);
202
+
203
+ // Same workdir, same key → same peer id. This is what makes a respawn the
204
+ // same node rather than a new one the borrower's cadre has never approved.
205
+ expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
206
+ });
207
+
208
+ // A requester that dials the node itself (a phone) knows it only by the address it
209
+ // was given, so a re-spawn must come back on the SAME ports even when the allocator
210
+ // has lower ones free. The test above cannot tell reuse from lowest-free: there the
211
+ // old ports are also the lowest free ones.
212
+ it('comes back on its own ports even when lower ones are free', async () => {
213
+ const orch = makeOrchestrator();
214
+
215
+ const lower = await orch.createContainer(makeRequest('c0'));
216
+ const first = await orch.createContainer(makeRequest('c1'));
217
+ await waitFor(() => orch.isRunning(first.dockerId));
218
+ const portsBefore = orch.getNode('c1')!.ports;
219
+
220
+ await orch.removeContainer(lower.dockerId);
221
+ const { pid } = decodeDockerId(first.dockerId);
222
+ process.kill(pid, 'SIGKILL');
223
+ await waitFor(() => !isPidAlive(pid));
224
+
225
+ await orch.createContainer(makeRequest('c1'));
226
+ expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
227
+ });
228
+
229
+ // A handle written to state.json before the WebSocket port existed has no `ws`.
230
+ // Rehydrating it must hold the ports it does have, and its re-spawn must keep those
231
+ // and gain a WebSocket port rather than trip over the missing key.
232
+ it('re-spawns a handle persisted without a ws port on its old ports plus a fresh ws', async () => {
233
+ const rootDir = join(tmpRoot, 'legacy');
234
+ const legacyPorts = { health: 18010, metrics: 18011, p2p: 18012, admin: 18013 };
235
+ new StateStore(rootDir).save({
236
+ version: 1,
237
+ handles: [{
238
+ containerId: 'c1',
239
+ dockerId: encodeDockerId(999999, 'deadbeef'),
240
+ pid: 999999,
241
+ startupToken: 'deadbeef',
242
+ workdir: join(rootDir, 'c1'),
243
+ ports: legacyPorts as NodePorts,
244
+ spawnedAt: new Date(0).toISOString(),
245
+ partyId: 'party-c1',
246
+ profile: 'transaction',
247
+ }],
248
+ });
249
+ const orch = makeOrchestrator({ rootDir });
250
+ await orch.init();
251
+
252
+ await orch.createContainer(makeRequest('c1'));
253
+
254
+ const ports = orch.getNode('c1')!.ports;
255
+ expect(ports).toMatchObject(legacyPorts);
256
+ // The lowest free port: nothing below 18010 was held, `undefined` included.
257
+ expect(ports.ws).toBe(18000);
258
+ });
259
+ });
260
+
261
+ /**
262
+ * A launch that fails must leave host state exactly as it found it. The drop of
263
+ * the previous handle happens before the child is launched, so without an
264
+ * explicit restore a failed re-spawn would leave the donation record naming a
265
+ * dockerId the orchestrator can no longer resolve — and the node's workdir
266
+ * (identity key, node-local stores, storage) stranded on disk with nothing able
267
+ * to delete it.
268
+ *
269
+ * Inducing a launch failure portably: replace the workdir's `storage`
270
+ * directory with a plain file. `mkdirSync(path, { recursive: true })` throws
271
+ * EEXIST when the path exists as a non-directory on every platform, and it is
272
+ * the second statement in `launchChild` — after the handle drop, before the
273
+ * spawn.
274
+ */
275
+ describe('HostProcessOrchestrator failed launch', () => {
276
+ it('keeps the prior handle addressable, so a later terminate still reclaims the workdir', async () => {
277
+ const orch = makeOrchestrator();
278
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
279
+
280
+ const first = await orch.createContainer(makeRequest('c1'));
281
+ await waitFor(() => orch.isRunning(first.dockerId));
282
+ await orch.stopContainer(first.dockerId);
283
+
284
+ const workdir = join(rootDir, 'c1');
285
+ sabotageWorkdir(workdir);
286
+
287
+ await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/EEXIST|exists/i);
288
+
289
+ // The record's dockerId still resolves, and no ghost handle was added.
290
+ expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
291
+ expect(orch.resolveDockerId('c1')).toBe(first.dockerId);
292
+ expect(orch.listNodes()).toHaveLength(1);
293
+
294
+ // Which is the whole point: cleanup can still find and delete the node.
295
+ await orch.removeContainer(first.dockerId);
296
+ expect(existsSync(workdir)).toBe(false);
297
+ expect(orch.listNodes()).toHaveLength(0);
298
+ });
299
+
300
+ it('does not shift the ports a successful retry comes back on', async () => {
301
+ const orch = makeOrchestrator();
302
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
303
+
304
+ const first = await orch.createContainer(makeRequest('c1'));
305
+ await waitFor(() => orch.isRunning(first.dockerId));
306
+ await orch.stopContainer(first.dockerId);
307
+
308
+ const workdir = join(rootDir, 'c1');
309
+ const storage = sabotageWorkdir(workdir);
310
+ await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
311
+
312
+ rmSync(storage, { force: true });
313
+ const second = await orch.createContainer(makeRequest('c1'));
314
+
315
+ // The restored handle held its ports across the failure, and the
316
+ // allocator hands back the lowest free port — so identical ports prove the
317
+ // failed attempt neither leaked nor stole them.
318
+ expect(second.p2pPort).toBe(first.p2pPort);
319
+ expect(second.healthEndpoint).toBe(first.healthEndpoint);
320
+ expect(second.metricsEndpoint).toBe(first.metricsEndpoint);
321
+
322
+ // And the successful spawn's own drop still stands: exactly one handle.
323
+ const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
324
+ expect(handles).toHaveLength(1);
325
+ expect(handles[0]!.dockerId).toBe(second.dockerId);
326
+ });
327
+
328
+ it('releases every port a failed FIRST spawn reserved', async () => {
329
+ const orch = makeOrchestrator({ portRange: { start: 12000, end: 12100 } });
330
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
331
+
332
+ await orch.createContainer(makeRequest('c1'));
333
+ expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003, ws: 12004 });
334
+
335
+ // No prior handle to restore here — the five ports are simply released.
336
+ mkdirSync(join(rootDir, 'c2'), { recursive: true });
337
+ writeFileSync(join(rootDir, 'c2', 'storage'), 'not-a-directory', 'utf8');
338
+ await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow();
339
+
340
+ await orch.createContainer(makeRequest('c3'));
341
+ // With the leak these would start at 12010.
342
+ expect(orch.getNode('c3')!.ports).toEqual({ health: 12005, metrics: 12006, p2p: 12007, admin: 12008, ws: 12009 });
343
+ });
344
+
345
+ it('leaves the owner node addressable on its original ports', async () => {
346
+ const orch = makeOrchestrator();
347
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
348
+ const ownerConfig = {
349
+ identityPath: join(tmpRoot, 'identity.key'),
350
+ partyId: 'party-owner',
351
+ libp2pPort: 14001,
352
+ };
353
+
354
+ const owner = await orch.ensureOwnerNode(ownerConfig);
355
+ await waitFor(() => orch.isRunning(owner.dockerId));
356
+ const endpointBefore = orch.getOwnerAdminEndpoint();
357
+
358
+ // ensureOwnerNode short-circuits on a live child, so kill it first.
359
+ const { pid } = decodeDockerId(owner.dockerId);
360
+ process.kill(pid, 'SIGKILL');
361
+ await waitFor(() => !isPidAlive(pid));
362
+
363
+ const storage = sabotageWorkdir(join(rootDir, 'owner'));
364
+ await expect(orch.ensureOwnerNode()).rejects.toThrow();
365
+
366
+ // The admin channel still resolves — a manager that lost this endpoint
367
+ // could no longer delegate to its own node.
368
+ expect(orch.getOwnerAdminEndpoint()).toEqual(endpointBefore);
369
+ expect(orch.listNodes().some((n) => n.dockerId === owner.dockerId)).toBe(true);
370
+
371
+ rmSync(storage, { force: true });
372
+ const respawned = await orch.ensureOwnerNode();
373
+ expect(respawned.ports).toEqual(owner.ports);
374
+ expect(orch.listNodes().filter((n) => n.owner)).toHaveLength(1);
375
+ });
376
+
377
+ // The caller's unwind is only correct for failures that happen BEFORE the
378
+ // child exists. A state.json write failure happens after it — if that were
379
+ // allowed to propagate, the unwind would release the ports the live child is
380
+ // bound to and put the replaced handle back beside it.
381
+ it('does not unwind a launch that already spawned when the state write fails', async () => {
382
+ const orch = makeOrchestrator();
383
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
384
+ const first = await orch.createContainer(makeRequest('c1'));
385
+ await waitFor(() => orch.isRunning(first.dockerId));
386
+ await orch.stopContainer(first.dockerId);
387
+
388
+ const stateStore = (orch as unknown as { stateStore: StateStore }).stateStore;
389
+ const save = stateStore.save.bind(stateStore);
390
+ stateStore.save = () => { throw new Error('ENOSPC: no space left on device'); };
391
+ const second = await orch.createContainer(makeRequest('c1'));
392
+ stateStore.save = save;
393
+
394
+ // One handle for the container, and it is the new one.
395
+ expect(orch.listNodes()).toHaveLength(1);
396
+ expect(orch.getNode('c1')?.dockerId).toBe(second.dockerId);
397
+
398
+ // And the workdir the live child is running in is untouched — a swallowed
399
+ // persist must not reach the unwind that would delete it.
400
+ expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
401
+
402
+ // Its ports are still held: a fresh container gets different ones.
403
+ const other = await orch.createContainer(makeRequest('c2'));
404
+ expect(other.p2pPort).not.toBe(second.p2pPort);
405
+ expect(orch.getNode('c2')!.ports).not.toEqual(orch.getNode('c1')!.ports);
406
+ });
407
+
408
+ // A spawn that CREATED the workdir must take it back down on the way out —
409
+ // the record it was provisioning never got a dockerId, so nothing else can
410
+ // ever name that directory. The storage-file sabotage above is unusable here:
411
+ // pre-creating `<workdir>/storage` makes the directory pre-existing, and the
412
+ // unwind then correctly declines to delete it. Exhausting the port range
413
+ // fails the very next step instead, with the workdir genuinely fresh.
414
+ it('discards the workdir a failed first spawn created', async () => {
415
+ // Exactly five ports — enough for one node and no more.
416
+ const orch = makeOrchestrator({ portRange: { start: 13000, end: 13004 } });
417
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
418
+
419
+ await orch.createContainer(makeRequest('c1'));
420
+ await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow(/No available ports/);
421
+
422
+ // `ensureNodeIdentity` had already created it and written the key.
423
+ expect(existsSync(join(rootDir, 'c2'))).toBe(false);
424
+ // The first node is untouched.
425
+ expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
426
+ expect(orch.listNodes()).toHaveLength(1);
427
+ });
428
+
429
+ // The load-bearing case in the other direction: a re-spawn is REUSING the
430
+ // directory, and the identity key inside it is the whole reason the node
431
+ // comes back as the same peer the borrower's cadre approved.
432
+ it('keeps the workdir when a re-spawn launch fails', async () => {
433
+ const orch = makeOrchestrator();
434
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
435
+
436
+ const first = await orch.createContainer(makeRequest('c1'));
437
+ await waitFor(() => orch.isRunning(first.dockerId));
438
+ await orch.stopContainer(first.dockerId);
439
+
440
+ const workdir = join(rootDir, 'c1');
441
+ const identityPath = join(workdir, 'identity.key');
442
+ const peerBefore = loadIdentity(identityPath).peerId;
443
+
444
+ const storage = sabotageWorkdir(workdir);
445
+ await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
446
+
447
+ expect(existsSync(workdir)).toBe(true);
448
+ expect(existsSync(identityPath)).toBe(true);
449
+
450
+ // Which is what makes the next attempt the SAME node, not a stranger.
451
+ rmSync(storage, { force: true });
452
+ await orch.createContainer(makeRequest('c1'));
453
+ expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
454
+ });
455
+
456
+ // `ensureNodeIdentity` now runs inside the same `try`, so its own failures
457
+ // unwind too — and this one must NOT delete anything. The directory was
458
+ // pre-existing, so the undecodable key is an operator-visible fault to
459
+ // investigate, not this attempt's mess to clear up.
460
+ it('keeps a pre-existing workdir whose identity key is undecodable', async () => {
461
+ const orch = makeOrchestrator();
462
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
463
+ const workdir = join(rootDir, 'c1');
464
+
465
+ mkdirSync(workdir, { recursive: true });
466
+ writeFileSync(join(workdir, 'identity.key'), Buffer.from([0xff, 0xff, 0xff, 0xff]));
467
+
468
+ await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
469
+
470
+ expect(existsSync(workdir)).toBe(true);
471
+ expect(existsSync(join(workdir, 'identity.key'))).toBe(true);
472
+ expect(orch.listNodes()).toHaveLength(0);
473
+ });
474
+ });
475
+
476
+ /**
477
+ * The cleanup of last resort: a donation record that never got a `dockerId`
478
+ * because the host died mid-spawn. `createContainer`'s own unwind cannot help —
479
+ * it was never reached — so the reap falls back to reclaiming by container name.
480
+ */
481
+ describe('HostProcessOrchestrator.reclaimWorkdir', () => {
482
+ it('removes an orphaned workdir no handle owns', async () => {
483
+ const orch = makeOrchestrator();
484
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
485
+ const workdir = join(rootDir, 'grn_orphan');
486
+ mkdirSync(workdir, { recursive: true });
487
+ writeFileSync(join(workdir, 'identity.key'), 'key-bytes', 'utf8');
488
+
489
+ expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
490
+ expect(existsSync(workdir)).toBe(false);
491
+ });
492
+
493
+ it('refuses a container a live handle still owns', async () => {
494
+ const orch = makeOrchestrator();
495
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
496
+ const created = await orch.createContainer(makeRequest('c1'));
497
+
498
+ // That directory is the live child's cwd; only `removeContainer`, which
499
+ // stops the child first, may delete it.
500
+ expect(orch.reclaimWorkdir('c1')).toBe(false);
501
+ expect(existsSync(join(rootDir, 'c1'))).toBe(true);
502
+ expect(orch.resolveDockerId('c1')).toBe(created.dockerId);
503
+ });
504
+
505
+ it('refuses the owner node', async () => {
506
+ const orch = makeOrchestrator();
507
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
508
+ // No owner handle exists, so only the id check can save this directory —
509
+ // which holds the host's own control-DB storage.
510
+ const workdir = join(rootDir, 'owner');
511
+ mkdirSync(workdir, { recursive: true });
512
+
513
+ expect(orch.reclaimWorkdir('owner')).toBe(false);
514
+ expect(existsSync(workdir)).toBe(true);
515
+ });
516
+
517
+ it('reports false for a workdir that is already gone', () => {
518
+ const orch = makeOrchestrator();
519
+ expect(orch.reclaimWorkdir('grn_never_existed')).toBe(false);
520
+ });
521
+
522
+ it('refuses an id that names a file rather than a workdir', () => {
523
+ const orch = makeOrchestrator();
524
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
525
+ // `rootDir` is not only container directories — `state.json` is the
526
+ // orchestrator's own persisted handle map, and `rmSync` would take it.
527
+ const statePath = join(rootDir, 'state.json');
528
+ writeFileSync(statePath, '{"handles":[]}', 'utf8');
529
+
530
+ expect(orch.reclaimWorkdir('state.json')).toBe(false);
531
+ expect(existsSync(statePath)).toBe(true);
532
+ });
533
+
534
+ it('refuses an id that would escape rootDir', () => {
535
+ const orch = makeOrchestrator();
536
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
537
+ const sibling = join(rootDir, '..', 'sibling');
538
+ mkdirSync(sibling, { recursive: true });
539
+
540
+ // Unreachable with real `grn_<base64url>` ids; the guard is defence for a
541
+ // future caller that passes something else.
542
+ expect(orch.reclaimWorkdir('../sibling')).toBe(false);
543
+ expect(existsSync(sibling)).toBe(true);
544
+ });
545
+ });
546
+
547
+ describe('HostProcessOrchestrator.isRunning', () => {
548
+ it('is true after spawn and false after kill for a child this orchestrator spawned', async () => {
549
+ const orch = makeOrchestrator();
550
+ const { dockerId } = await orch.createContainer(makeRequest('c1'));
551
+ const { pid } = decodeDockerId(dockerId);
552
+
553
+ await waitFor(() => orch.isRunning(dockerId));
554
+ expect(await orch.isRunning(dockerId)).toBe(true);
555
+
556
+ // The token file is not consulted for a child this process spawned — its
557
+ // ChildProcess is exact, and the file has a start-up window it does not.
558
+ const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
559
+ writeFileSync(join(workdir, '.startup-token'), 'tampered', 'utf8');
560
+ expect(await orch.isRunning(dockerId)).toBe(true);
561
+
562
+ process.kill(pid, 'SIGKILL');
563
+ await waitFor(() => !isPidAlive(pid));
564
+ expect(await orch.isRunning(dockerId)).toBe(false);
565
+ });
566
+
567
+ it('checks a re-attached handle by pid plus startup token', async () => {
568
+ const rootDir = join(tmpRoot, 'reattach');
569
+ const a = makeOrchestrator({ rootDir });
570
+ const { dockerId } = await a.createContainer(makeRequest('c1'));
571
+ const { pid } = decodeDockerId(dockerId);
572
+ await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
573
+
574
+ // A new orchestrator has no ChildProcess for it — the host-restart case.
575
+ const b = makeOrchestrator({ rootDir });
576
+ await b.init();
577
+ expect(await b.isRunning(dockerId)).toBe(true);
578
+
579
+ // A live pid whose token does not match may be an unrelated process that
580
+ // inherited the pid, so it does not count as ours.
581
+ const tokenPath = join(rootDir, 'c1', '.startup-token');
582
+ const token = readFileSync(tokenPath, 'utf8');
583
+ writeFileSync(tokenPath, 'tampered', 'utf8');
584
+ expect(await b.isRunning(dockerId)).toBe(false);
585
+
586
+ writeFileSync(tokenPath, token, 'utf8');
587
+ expect(await b.isRunning(dockerId)).toBe(true);
588
+
589
+ process.kill(pid, 'SIGKILL');
590
+ await waitFor(() => !isPidAlive(pid));
591
+ expect(await b.isRunning(dockerId)).toBe(false);
592
+ });
593
+ });
594
+
595
+ /**
596
+ * A child that binds its health port at once but writes its startup token only
597
+ * after `TOKEN_DELAY_MS` — the shape of a real `cadre-cli start` built before the
598
+ * token write moved to the front, and of any child that is slow to reach it. Dies
599
+ * on a port clash the way `cadre-cli start` does.
600
+ *
601
+ * The incident this guards against: the donation supervisor asked `isRunning`
602
+ * during that window, got `false`, and re-spawned the container. The re-spawn
603
+ * released and reused the same ports, rotated the seed token, and returned
604
+ * success; the second child then died on the port the first still held, leaving
605
+ * the host's records naming a dead child and the live one refusing every seed.
606
+ */
607
+ describe('HostProcessOrchestrator re-spawn over a child that is still starting', () => {
608
+ const TOKEN_DELAY_MS = 3000;
609
+ const SLOW_TOKEN_CHILD = `
610
+ import fs from 'node:fs';
611
+ import net from 'node:net';
612
+ const args = process.argv.slice(2);
613
+ const tokenPath = args[args.indexOf('--startup-token-file') + 1];
614
+ const server = net.createServer();
615
+ server.on('error', (err) => { console.error('Failed to start cadre node: ' + err.message); process.exit(1); });
616
+ server.listen(Number(process.env.CADRE_HEALTH_PORT), '0.0.0.0', () => {
617
+ console.log('listening');
618
+ setTimeout(() => fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN), ${TOKEN_DELAY_MS});
619
+ });
620
+ process.on('SIGTERM', () => process.exit(0));
621
+ setInterval(() => {}, 1 << 30);
622
+ `;
623
+ const TEST_TIMEOUT_MS = 20_000;
624
+
625
+ let slowChildPath: string;
626
+ beforeEach(() => {
627
+ slowChildPath = join(tmpRoot, 'slow-token-child.mjs');
628
+ writeFileSync(slowChildPath, SLOW_TOKEN_CHILD, 'utf8');
629
+ });
630
+
631
+ it('reports the child running before its token is written, and refuses to launch over it', async () => {
632
+ const orch = makeOrchestrator({ spawn: { entrypoint: slowChildPath } });
633
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
634
+ const tokenPath = join(rootDir, 'c1', '.startup-token');
635
+
636
+ const first = await orch.createContainer(makeRequest('c1'));
637
+ await waitFor(async () => (await orch.getLogs(first.dockerId)).includes('listening'));
638
+ const portsBefore = orch.getNode('c1')!.ports;
639
+
640
+ // Inside the start-up window: port bound, token not yet written.
641
+ expect(existsSync(tokenPath)).toBe(false);
642
+ expect(await orch.isRunning(first.dockerId)).toBe(true);
643
+
644
+ // Exactly what `DonationService.respawn` would do next.
645
+ await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/c1 is still running/);
646
+
647
+ // Nothing was dropped, released or rotated.
648
+ expect(orch.listNodes()).toHaveLength(1);
649
+ expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
650
+ expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
651
+ const other = await orch.createContainer(makeRequest('c2'));
652
+ expect(other.p2pPort).not.toBe(first.p2pPort);
653
+ expect(other.healthEndpoint).not.toBe(first.healthEndpoint);
654
+
655
+ // The first child finishes starting undisturbed, with the token it was given.
656
+ const { pid, token } = decodeDockerId(first.dockerId);
657
+ await waitFor(() => existsSync(tokenPath), TOKEN_DELAY_MS + 5000);
658
+ expect(readFileSync(tokenPath, 'utf8')).toBe(token);
659
+ expect(isPidAlive(pid)).toBe(true);
660
+ expect(await orch.getLogs(first.dockerId)).not.toContain('EADDRINUSE');
661
+ }, TEST_TIMEOUT_MS);
662
+
663
+ it('refuses to launch over a re-attached child whose ports are still bound', async () => {
664
+ const rootDir = join(tmpRoot, 'reattach-starting');
665
+ const a = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
666
+ const first = await a.createContainer(makeRequest('c1'));
667
+ await waitFor(async () => (await a.getLogs(first.dockerId)).includes('listening'));
668
+
669
+ // A host restart while the child is mid-start: no ChildProcess, no token yet.
670
+ const b = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
671
+ await b.init();
672
+ expect(await b.isRunning(first.dockerId)).toBe(false);
673
+
674
+ await expect(b.createContainer(makeRequest('c1')))
675
+ .rejects.toThrow(/c1 may still be running: port \d+ on 0\.0\.0\.0 is unavailable/);
676
+
677
+ // Nothing dropped: the record's dockerId still resolves on its ports, and
678
+ // the live child was not killed on unverified evidence.
679
+ expect(b.listNodes()).toHaveLength(1);
680
+ expect(b.getNode('c1')?.dockerId).toBe(first.dockerId);
681
+ const { pid } = decodeDockerId(first.dockerId);
682
+ expect(isPidAlive(pid)).toBe(true);
683
+
684
+ // Once it has written its token it is recognised as running.
685
+ await waitFor(() => b.isRunning(first.dockerId), TOKEN_DELAY_MS + 5000);
686
+ }, TEST_TIMEOUT_MS);
687
+ });
688
+
689
+ describe('HostProcessOrchestrator.stopContainer', () => {
690
+ it('SIGTERM stops a cooperative child without needing SIGKILL', async () => {
691
+ const orch = makeOrchestrator();
692
+ const { dockerId } = await orch.createContainer(makeRequest('c1'));
693
+ const { pid } = decodeDockerId(dockerId);
694
+
695
+ await waitFor(() => orch.isRunning(dockerId));
696
+ await orch.stopContainer(dockerId);
697
+
698
+ expect(isPidAlive(pid)).toBe(false);
699
+ expect(await orch.isRunning(dockerId)).toBe(false);
700
+ });
701
+
702
+ // On Windows, process.kill ignores the signal name and always force-kills,
703
+ // so a child cannot ignore SIGTERM at the Node level. Skip there.
704
+ it.skipIf(process.platform === 'win32')(
705
+ 'escalates to SIGKILL when the child ignores SIGTERM',
706
+ async () => {
707
+ const orch = makeOrchestrator();
708
+ process.env.IGNORE_SIGTERM = '1';
709
+ let dockerId: string;
710
+ try {
711
+ ({ dockerId } = await orch.createContainer(makeRequest('c2')));
712
+ } finally {
713
+ delete process.env.IGNORE_SIGTERM;
714
+ }
715
+ const { pid } = decodeDockerId(dockerId);
716
+ await waitFor(() => isPidAlive(pid));
717
+
718
+ const start = Date.now();
719
+ await orch.stopContainer(dockerId);
720
+ const elapsed = Date.now() - start;
721
+
722
+ // Should have waited the full stopTimeoutMs (1500) before SIGKILL.
723
+ expect(elapsed).toBeGreaterThanOrEqual(1000);
724
+ expect(isPidAlive(pid)).toBe(false);
725
+ },
726
+ );
727
+ });
728
+
729
+ describe('HostProcessOrchestrator.removeContainer', () => {
730
+ it('releases ports, deletes workdir, drops state', async () => {
731
+ const orch = makeOrchestrator();
732
+ const { dockerId, healthEndpoint } = await orch.createContainer(makeRequest('c1'));
733
+ const port = Number(healthEndpoint.match(/:(\d+)/)![1]);
734
+
735
+ await waitFor(() => orch.isRunning(dockerId));
736
+ await orch.removeContainer(dockerId);
737
+
738
+ // Workdir is gone
739
+ const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
740
+ expect(() => readFileSync(join(workdir, 'cadre.json'), 'utf8')).toThrow();
741
+
742
+ // State no longer references it
743
+ const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
744
+ expect(persisted.handles).toHaveLength(0);
745
+
746
+ // Subsequent getStats rejects
747
+ await expect(orch.getStats(dockerId)).rejects.toThrow(/not found/i);
748
+
749
+ // Port was released — reallocate it via a fresh container; pick the next id.
750
+ const next = await orch.createContainer(makeRequest('c2'));
751
+ // (we don't assert the same port — just that allocation works post-release)
752
+ expect(next.healthEndpoint).toMatch(/:\d+\/health/);
753
+ expect(port).toBeGreaterThan(0);
754
+ });
755
+ });
756
+
757
+ /**
758
+ * `getStats` goes through `pidusage`, which on Windows shells out: modern
759
+ * builds no longer ship `wmic`, so it falls back to spawning `powershell.exe`
760
+ * to run `gwmi win32_process` — and the first sample for a fresh pid costs
761
+ * *two* such spawns, because CPU percent needs a prior baseline. Measured
762
+ * ~2.8 s idle and ~4.9 s on a loaded machine, so the 5 s default test timeout
763
+ * is not a real budget for this one. Give it explicit headroom.
764
+ */
765
+ const STATS_TIMEOUT_MS = 30_000;
766
+
767
+ describe('HostProcessOrchestrator.getStats', () => {
768
+ it('returns plausible numbers with zero network counters', async () => {
769
+ const orch = makeOrchestrator();
770
+ const { dockerId } = await orch.createContainer(makeRequest('c1'));
771
+ await waitFor(() => orch.isRunning(dockerId));
772
+ // Give the child a moment to use some CPU/mem
773
+ await sleep(200);
774
+
775
+ const stats = await orch.getStats(dockerId);
776
+ expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
777
+ expect(stats.memoryBytes).toBeGreaterThan(0);
778
+ expect(stats.networkRxBytes).toBe(0);
779
+ expect(stats.networkTxBytes).toBe(0);
780
+ }, STATS_TIMEOUT_MS);
781
+ });
782
+
783
+ describe('HostProcessOrchestrator log rotation at spawn', () => {
784
+ it('rotates an oversized node.log before the new child opens it', async () => {
785
+ const rootDir = join(tmpRoot, 'rot');
786
+ mkdirSync(rootDir, { recursive: true });
787
+ const containerWorkdir = join(rootDir, 'rotc1');
788
+ mkdirSync(containerWorkdir, { recursive: true });
789
+ // Pre-seed an oversized active log so maybeRotateAtSpawn fires.
790
+ const seed = 'X'.repeat(2048);
791
+ writeFileSync(join(containerWorkdir, 'node.log'), seed, 'utf8');
792
+
793
+ const orch = makeOrchestrator({
794
+ rootDir,
795
+ logMaxBytes: 1024,
796
+ logMaxFiles: 3,
797
+ });
798
+ const { dockerId } = await orch.createContainer(makeRequest('rotc1'));
799
+ await waitFor(() => orch.isRunning(dockerId));
800
+
801
+ // The rotation cascade should have moved the seeded contents to .1
802
+ // and the fresh child should be writing to a brand-new node.log.
803
+ expect(readFileSync(join(containerWorkdir, 'node.log.1'), 'utf8')).toBe(seed);
804
+ expect(statSync(join(containerWorkdir, 'node.log')).size).toBeLessThan(seed.length);
805
+ });
806
+ });
807
+
808
+ describe('HostProcessOrchestrator.getLogs', () => {
809
+ it('returns recent stdout lines from the child', async () => {
810
+ const orch = makeOrchestrator();
811
+ process.env.CHILD_INITIAL_MESSAGE = 'hello-from-fake';
812
+ let dockerId: string;
813
+ try {
814
+ ({ dockerId } = await orch.createContainer(makeRequest('c1')));
815
+ } finally {
816
+ delete process.env.CHILD_INITIAL_MESSAGE;
817
+ }
818
+
819
+ await waitFor(async () => {
820
+ const logs = await orch.getLogs(dockerId, 200);
821
+ return logs.includes('hello-from-fake');
822
+ });
823
+
824
+ const logs = await orch.getLogs(dockerId, 200);
825
+ expect(logs).toContain('hello-from-fake');
826
+ });
827
+ });
828
+
829
+ describe('rotateOnDisk', () => {
830
+ it('cascades base -> .1 -> ... and caps at maxFiles', async () => {
831
+ const fs = await import('node:fs');
832
+ const dir = join(tmpRoot, 'logs');
833
+ mkdirSync(dir, { recursive: true });
834
+ const path = join(dir, 'node.log');
835
+
836
+ // Seed: base + .1 + .2 + .3 each with distinguishable content.
837
+ writeFileSync(path, 'base', 'utf8');
838
+ writeFileSync(`${path}.1`, 'one', 'utf8');
839
+ writeFileSync(`${path}.2`, 'two', 'utf8');
840
+ writeFileSync(`${path}.3`, 'three', 'utf8');
841
+
842
+ rotateOnDisk(path, 3);
843
+
844
+ // Active path is gone (rotated away). .1 has former base, .2 has former
845
+ // .1, .3 has former .2; former .3 is dropped because maxFiles=3.
846
+ expect(fs.existsSync(path)).toBe(false);
847
+ expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('base');
848
+ expect(fs.readFileSync(`${path}.2`, 'utf8')).toBe('one');
849
+ expect(fs.readFileSync(`${path}.3`, 'utf8')).toBe('two');
850
+ expect(fs.existsSync(`${path}.4`)).toBe(false);
851
+ });
852
+
853
+ it('is a no-op when the active file does not exist', () => {
854
+ const dir = join(tmpRoot, 'logs-empty');
855
+ mkdirSync(dir, { recursive: true });
856
+ const path = join(dir, 'node.log');
857
+ expect(() => rotateOnDisk(path, 3)).not.toThrow();
858
+ });
859
+
860
+ it('handles a partial cascade (only base present)', async () => {
861
+ const fs = await import('node:fs');
862
+ const dir = join(tmpRoot, 'logs-partial');
863
+ mkdirSync(dir, { recursive: true });
864
+ const path = join(dir, 'node.log');
865
+ writeFileSync(path, 'only', 'utf8');
866
+
867
+ rotateOnDisk(path, 5);
868
+
869
+ expect(fs.existsSync(path)).toBe(false);
870
+ expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('only');
871
+ expect(fs.existsSync(`${path}.2`)).toBe(false);
872
+ });
873
+ });
874
+
875
+ describe('Restart recovery (init)', () => {
876
+ it('re-attaches to surviving children and reports dead ones', async () => {
877
+ const rootDir = join(tmpRoot, 'restart');
878
+ const a = makeOrchestrator({ rootDir });
879
+ const r1 = await a.createContainer(makeRequest('c1'));
880
+ const r2 = await a.createContainer(makeRequest('c2'));
881
+ // Not `a.isRunning`: that answers from the ChildProcess at once, but `b`
882
+ // below can only recognise c1 once c1 has written its startup token.
883
+ await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
884
+ await waitFor(() => existsSync(join(rootDir, 'c2', '.startup-token')));
885
+
886
+ // Kill c2 externally
887
+ const { pid: pid2 } = decodeDockerId(r2.dockerId);
888
+ process.kill(pid2, 'SIGKILL');
889
+ await waitFor(() => !isPidAlive(pid2));
890
+
891
+ // New orchestrator over the same rootDir, init()
892
+ const b = makeOrchestrator({ rootDir });
893
+ await b.init();
894
+
895
+ expect(await b.isRunning(r1.dockerId)).toBe(true);
896
+ expect(await b.isRunning(r2.dockerId)).toBe(false);
897
+
898
+ // Ports from both should be reserved (no overlapping allocations from `b`)
899
+ // Allocate three more containers via `b` and confirm none collide with
900
+ // c1/c2's existing ports.
901
+ const reservedPorts = new Set([
902
+ r1.p2pPort,
903
+ r2.p2pPort,
904
+ Number(r1.healthEndpoint.match(/:(\d+)/)![1]),
905
+ Number(r1.metricsEndpoint.match(/:(\d+)/)![1]),
906
+ Number(r2.healthEndpoint.match(/:(\d+)/)![1]),
907
+ Number(r2.metricsEndpoint.match(/:(\d+)/)![1]),
908
+ ]);
909
+ const r3 = await b.createContainer(makeRequest('c3'));
910
+ const r3Ports = new Set([
911
+ r3.p2pPort,
912
+ Number(r3.healthEndpoint.match(/:(\d+)/)![1]),
913
+ Number(r3.metricsEndpoint.match(/:(\d+)/)![1]),
914
+ ]);
915
+ for (const p of r3Ports) {
916
+ expect(reservedPorts.has(p)).toBe(false);
917
+ }
918
+ });
919
+ });
920
+
921
+ /**
922
+ * The orchestrator must spawn children that survive the spawning process
923
+ * itself exiting. The same-process restart test below does not exercise
924
+ * this — both orchestrators live in the same vitest worker, so the
925
+ * spawning process's pipe-read fds never close. This test genuinely exits
926
+ * the spawning process and asserts that the grandchild (a) stays alive
927
+ * past the parent's death and (b) keeps writing to its log file.
928
+ *
929
+ * Requires `yarn workspace @serfab/cadre-host build` to have been run —
930
+ * the helper script imports the built dist so it can run as a plain Node
931
+ * process without a TS loader.
932
+ */
933
+ describe('child survives orchestrator exit', () => {
934
+ const orchestratorDistUrl = (() => {
935
+ const here = dirname(fileURLToPath(import.meta.url));
936
+ const distDir = resolvePath(here, '..', '..', 'dist', 'orchestrator');
937
+ const distPath = join(distDir, 'host-process-orchestrator.js');
938
+ return {
939
+ distPath,
940
+ orchestratorUrl: pathToFileURL(distPath).href,
941
+ logRotatorUrl: pathToFileURL(join(distDir, 'log-rotator.js')).href,
942
+ };
943
+ })();
944
+
945
+ it.skipIf(!existsSync(orchestratorDistUrl.distPath))(
946
+ 'grandchild stays alive and keeps logging after spawner process exits',
947
+ async () => {
948
+ const { orchestratorUrl, logRotatorUrl } = orchestratorDistUrl;
949
+ const rootDir = join(tmpRoot, 'cross');
950
+ mkdirSync(rootDir, { recursive: true });
951
+
952
+ const helperPath = join(tmpRoot, 'spawner.mjs');
953
+ writeFileSync(helperPath, buildSpawnerScript(orchestratorUrl, logRotatorUrl), 'utf8');
954
+
955
+ const request = {
956
+ containerId: 'cross1',
957
+ partyId: 'party-cross1',
958
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
959
+ profile: 'transaction',
960
+ };
961
+ const payload = {
962
+ rootDir,
963
+ entrypoint: scriptPath,
964
+ request,
965
+ // Ensure the fake child keeps ticking quickly so logs grow visibly
966
+ // in the ~300 ms observation window.
967
+ childEnv: { CHILD_TICK_MS: '20', CHILD_TICK_PAYLOAD: 'cross-tick' },
968
+ };
969
+
970
+ // Run the helper as a normal (non-detached) Node child and wait for
971
+ // it to exit. The orchestrator's grandchild should survive.
972
+ const result = spawnSync(process.execPath, [helperPath, JSON.stringify(payload)], {
973
+ encoding: 'utf8',
974
+ timeout: 15000,
975
+ });
976
+ expect(result.status, `helper exited non-zero: stderr=${result.stderr}`).toBe(0);
977
+
978
+ const jsonLine = result.stdout
979
+ .split(/\r?\n/)
980
+ .reverse()
981
+ .find((line) => line.trim().startsWith('{'));
982
+ expect(jsonLine, `helper produced no JSON line: stdout=${result.stdout}`).toBeTruthy();
983
+ const handle = JSON.parse(jsonLine!) as {
984
+ dockerId: string;
985
+ pid: number;
986
+ workdir: string;
987
+ logPath: string;
988
+ };
989
+
990
+ try {
991
+ // Immediate liveness
992
+ expect(isPidAlive(handle.pid)).toBe(true);
993
+
994
+ // POSIX delivers SIGPIPE on the child's NEXT write — give it time
995
+ // to attempt one (the fake child ticks every 20 ms).
996
+ await sleep(500);
997
+ expect(isPidAlive(handle.pid), 'grandchild died after spawner exit (likely SIGPIPE)').toBe(true);
998
+
999
+ // Logs must keep growing — proves stdio still works, not just PID.
1000
+ const sizeA = statSync(handle.logPath).size;
1001
+ await sleep(300);
1002
+ const sizeB = statSync(handle.logPath).size;
1003
+ expect(sizeB).toBeGreaterThan(sizeA);
1004
+ } finally {
1005
+ // Started by the helper process, so no orchestrator here holds it: the one child removeAllNodes cannot see.
1006
+ try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
1007
+ try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
1008
+ }
1009
+ },
1010
+ // The helper spawn alone budgets 15 s; the surrounding waits add ~0.8 s
1011
+ // more. Under the 5 s default the test could never reach its own timeout.
1012
+ 30_000,
1013
+ );
1014
+ });
1015
+
1016
+ function buildSpawnerScript(orchestratorUrl: string, logRotatorUrl: string): string {
1017
+ return `
1018
+ import { HostProcessOrchestrator } from ${JSON.stringify(orchestratorUrl)};
1019
+ import { defaultLogPath } from ${JSON.stringify(logRotatorUrl)};
1020
+
1021
+ const payload = JSON.parse(process.argv[2]);
1022
+ const { rootDir, entrypoint, request, childEnv } = payload;
1023
+
1024
+ for (const [k, v] of Object.entries(childEnv ?? {})) {
1025
+ process.env[k] = String(v);
1026
+ }
1027
+
1028
+ const orch = new HostProcessOrchestrator({
1029
+ rootDir,
1030
+ portRange: { start: 19000, end: 19999 },
1031
+ stopTimeoutMs: 1500,
1032
+ spawn: { entrypoint },
1033
+ });
1034
+
1035
+ const res = await orch.createContainer(request);
1036
+ const pid = Number(res.dockerId.split(':')[0]);
1037
+ const workdir = (await import('node:path')).join(rootDir, request.containerId);
1038
+ const logPath = defaultLogPath(workdir);
1039
+
1040
+ process.stdout.write(JSON.stringify({
1041
+ dockerId: res.dockerId,
1042
+ pid,
1043
+ workdir,
1044
+ logPath,
1045
+ }) + '\\n');
1046
+
1047
+ // Exit cleanly — the detached/unref'd child should outlive us.
1048
+ process.exit(0);
1049
+ `;
1050
+ }
1051
+
1052
+ describe('dockerId encoding', () => {
1053
+ it('encode/decode round-trips pid and token', () => {
1054
+ const id = encodeDockerId(12345, 'abcdef');
1055
+ expect(decodeDockerId(id)).toEqual({ pid: 12345, token: 'abcdef' });
1056
+ });
1057
+
1058
+ it('rejects malformed ids', () => {
1059
+ expect(() => decodeDockerId('notvalid')).toThrow();
1060
+ expect(() => decodeDockerId(':oops')).toThrow();
1061
+ expect(() => decodeDockerId('0:tok')).toThrow();
1062
+ });
1063
+ });