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