@aws-blocks/core 0.1.10 → 0.1.11

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.
@@ -0,0 +1,430 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { createServer } from 'node:net';
6
+ import {
7
+ reclaimPort,
8
+ reclaimMessage,
9
+ evaluatePortBindRetry,
10
+ createBindRetryController,
11
+ DEFAULT_PORT_BIND_RETRY_POLICY,
12
+ evaluateSingleton,
13
+ parsePidRecord,
14
+ isPidAlive,
15
+ isPortOpen,
16
+ type DevServerPidRecord,
17
+ type ReclaimResult,
18
+ } from './dev-server.js';
19
+ import { findListenerPids, killListenerTree } from './process-tree.js';
20
+
21
+ function getFreePort(): Promise<number> {
22
+ return new Promise((resolve, reject) => {
23
+ const srv = createServer();
24
+ srv.on('error', reject);
25
+ srv.listen(0, '127.0.0.1', () => {
26
+ const addr = srv.address();
27
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
28
+ srv.close(() => resolve(port));
29
+ });
30
+ });
31
+ }
32
+
33
+ // ── reclaimPort — startup / EADDRINUSE port reclaim policy ──────────────────
34
+ // Fully injected: no real processes are spawned. `probe` is scripted to model
35
+ // the port's open/closed state across the reclaim sequence, so we assert exactly
36
+ // which signals are sent and when it gives up.
37
+ describe('reclaimPort — frees a stale/orphaned port before startup', () => {
38
+ // reclaimPort probes the port up to three times: (1) initial check,
39
+ // (2) after the SIGTERM wait, (3) the final reclaimed? check.
40
+ function scriptedProbe(states: boolean[]): (port: number) => Promise<boolean> {
41
+ let i = 0;
42
+ return async () => states[Math.min(i++, states.length - 1)];
43
+ }
44
+
45
+ it('is a no-op when the port is already free (no discovery, no kills)', async () => {
46
+ const kills: Array<[number, NodeJS.Signals]> = [];
47
+ let listed = 0;
48
+ const result = await reclaimPort(3000, {
49
+ probe: scriptedProbe([false]),
50
+ listPids: () => { listed++; return [111]; },
51
+ killTree: (pid, sig) => kills.push([pid, sig]),
52
+ waitFree: async () => {},
53
+ });
54
+ assert.deepStrictEqual(result, { wasOpen: false, reclaimed: true, pids: [] });
55
+ assert.strictEqual(listed, 0, 'must not discover PIDs when the port is free');
56
+ assert.deepStrictEqual(kills, []);
57
+ });
58
+
59
+ it('SIGTERMs every listener and reports reclaimed when the port frees gracefully', async () => {
60
+ const kills: Array<[number, NodeJS.Signals]> = [];
61
+ const result = await reclaimPort(3100, {
62
+ // open, then free after SIGTERM, then still free at the final check
63
+ probe: scriptedProbe([true, false, false]),
64
+ listPids: () => [4242, 4243],
65
+ killTree: (pid, sig) => kills.push([pid, sig]),
66
+ waitFree: async () => {},
67
+ });
68
+ assert.deepStrictEqual(kills, [[4242, 'SIGTERM'], [4243, 'SIGTERM']]);
69
+ assert.deepStrictEqual(result, { wasOpen: true, reclaimed: true, pids: [4242, 4243] });
70
+ });
71
+
72
+ it('escalates to SIGKILL when the listener survives the graceful SIGTERM', async () => {
73
+ const kills: Array<[number, NodeJS.Signals]> = [];
74
+ const result = await reclaimPort(3000, {
75
+ // open, still open after SIGTERM, free after SIGKILL
76
+ probe: scriptedProbe([true, true, false]),
77
+ listPids: () => [777],
78
+ killTree: (pid, sig) => kills.push([pid, sig]),
79
+ waitFree: async () => {},
80
+ });
81
+ assert.deepStrictEqual(kills, [[777, 'SIGTERM'], [777, 'SIGKILL']]);
82
+ assert.strictEqual(result.reclaimed, true);
83
+ });
84
+
85
+ it('reports reclaimed:false when the port stays bound through SIGKILL', async () => {
86
+ const result = await reclaimPort(3000, {
87
+ probe: scriptedProbe([true, true, true]),
88
+ listPids: () => [777],
89
+ killTree: () => {},
90
+ waitFree: async () => {},
91
+ });
92
+ assert.strictEqual(result.wasOpen, true);
93
+ assert.strictEqual(result.reclaimed, false);
94
+ });
95
+
96
+ it('re-discovers listeners for the SIGKILL pass when the first discovery was empty', async () => {
97
+ const kills: Array<[number, NodeJS.Signals]> = [];
98
+ let call = 0;
99
+ const result = await reclaimPort(3000, {
100
+ probe: scriptedProbe([true, true, false]),
101
+ // First lsof pass momentarily returns nothing; second finds the owner.
102
+ listPids: () => (call++ === 0 ? [] : [999]),
103
+ killTree: (pid, sig) => kills.push([pid, sig]),
104
+ waitFree: async () => {},
105
+ });
106
+ assert.deepStrictEqual(kills, [[999, 'SIGKILL']]);
107
+ assert.strictEqual(result.reclaimed, true);
108
+ });
109
+
110
+ it('SIGKILLs the CURRENT owner, not the stale one, when the port changed hands during the wait', async () => {
111
+ // The original listener (111) is SIGTERM'd but exits, and a DIFFERENT process
112
+ // (222) grabs the port before the SIGKILL pass. The escalation must re-list
113
+ // and target the new owner (222) — never the stale pid (111) it already
114
+ // signalled — otherwise it SIGKILLs a dead pid and leaves the real owner.
115
+ const kills: Array<[number, NodeJS.Signals]> = [];
116
+ let call = 0;
117
+ const result = await reclaimPort(3000, {
118
+ // open, still open after SIGTERM (new owner now holds it), free after SIGKILL
119
+ probe: scriptedProbe([true, true, false]),
120
+ listPids: () => (call++ === 0 ? [111] : [222]),
121
+ killTree: (pid, sig) => kills.push([pid, sig]),
122
+ waitFree: async () => {},
123
+ });
124
+ assert.deepStrictEqual(kills, [[111, 'SIGTERM'], [222, 'SIGKILL']]);
125
+ assert.strictEqual(result.reclaimed, true);
126
+ // result.pids reflects the initial discovery (what we SIGTERM'd).
127
+ assert.deepStrictEqual(result.pids, [111]);
128
+ });
129
+
130
+ it('reports the port free once its real listener is reclaimed (real sockets, injected kill)', async () => {
131
+ const port = await getFreePort();
132
+ const srv = createServer((c) => c.destroy());
133
+ await new Promise<void>((res) => srv.listen(port, '127.0.0.1', () => res()));
134
+
135
+ assert.strictEqual(await isPortOpen(port, '127.0.0.1'), true, 'port should be held before reclaim');
136
+
137
+ // Inject the "kill" as closing our test server so the real probe/waitFree
138
+ // path is exercised end-to-end without spawning an OS process.
139
+ let killed = false;
140
+ const result = await reclaimPort(port, {
141
+ probe: (p) => isPortOpen(p, '127.0.0.1'),
142
+ listPids: () => [process.pid],
143
+ killTree: () => { if (!killed) { killed = true; srv.close(); } },
144
+ });
145
+
146
+ assert.strictEqual(result.wasOpen, true);
147
+ assert.strictEqual(result.reclaimed, true);
148
+ assert.strictEqual(await isPortOpen(port, '127.0.0.1'), false, 'port should be free after reclaim');
149
+ });
150
+ });
151
+
152
+ // ── reclaimMessage — accurate startup reclaim-outcome messaging ──────────────
153
+ describe('reclaimMessage — three-way reclaim outcome message', () => {
154
+ it('reports success when the port was reclaimed', () => {
155
+ const msg = reclaimMessage(3000, { wasOpen: true, reclaimed: true, pids: [] }, 'a stale/orphaned listener');
156
+ assert.match(msg, /Reclaimed port 3000 from a stale\/orphaned listener/);
157
+ });
158
+
159
+ it('names the holding pid(s) when reclaim failed but an owner is known', () => {
160
+ const msg = reclaimMessage(3100, { wasOpen: true, reclaimed: false, pids: [4242, 4243] }, 'a stale/orphaned dev server');
161
+ assert.match(msg, /Port 3100 held by pid\(s\) \[4242, 4243\]/);
162
+ assert.match(msg, /stop that process and retry/);
163
+ });
164
+
165
+ it('falls back to the generic message when reclaim failed with no owner PID', () => {
166
+ const msg = reclaimMessage(3000, { wasOpen: true, reclaimed: false, pids: [] }, 'a stale/orphaned listener');
167
+ assert.match(msg, /no owner PID found/);
168
+ });
169
+ });
170
+
171
+ // ── evaluatePortBindRetry — bounded :3000 EADDRINUSE retry ───────────────────
172
+ describe('evaluatePortBindRetry — front-door bind retry budget', () => {
173
+ it('retries early attempts with a linear backoff', () => {
174
+ assert.deepStrictEqual(evaluatePortBindRetry(1), { retry: true, delayMs: 250 });
175
+ assert.deepStrictEqual(evaluatePortBindRetry(2), { retry: true, delayMs: 500 });
176
+ });
177
+
178
+ it('gives up (no retry) once the attempt budget is reached', () => {
179
+ const d = evaluatePortBindRetry(DEFAULT_PORT_BIND_RETRY_POLICY.maxAttempts);
180
+ assert.deepStrictEqual(d, { retry: false, delayMs: 0 });
181
+ });
182
+
183
+ it('honors a custom policy', () => {
184
+ const policy = { maxAttempts: 2, backoffMs: 100 };
185
+ assert.deepStrictEqual(evaluatePortBindRetry(1, policy), { retry: true, delayMs: 100 });
186
+ assert.deepStrictEqual(evaluatePortBindRetry(2, policy), { retry: false, delayMs: 0 });
187
+ });
188
+ });
189
+
190
+ // ── evaluateSingleton — singleton guard decision ────────────────────────────
191
+ // Prevents two fighting supervisors while never blocking a `tsx watch` reload of
192
+ // the SAME supervisor (same parent pid).
193
+ describe('evaluateSingleton — one supervisor per port, hot-reload safe', () => {
194
+ const rec = (over: Partial<DevServerPidRecord> = {}): DevServerPidRecord => ({
195
+ pid: 1000,
196
+ ppid: 500,
197
+ port: 3000,
198
+ ...over,
199
+ });
200
+ const alive = () => true;
201
+ const dead = () => false;
202
+
203
+ it('proceeds when there is no (or a corrupt) pidfile', () => {
204
+ assert.deepStrictEqual(evaluateSingleton(null, { pid: 1, ppid: 2 }, true, alive), { action: 'proceed' });
205
+ });
206
+
207
+ it('proceeds on a tsx-watch relaunch of our own supervisor (same parent pid)', () => {
208
+ // New child, DIFFERENT own pid, but SAME watcher parent → not a competitor.
209
+ const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 1001, ppid: 500 }, true, alive);
210
+ assert.deepStrictEqual(d, { action: 'proceed' });
211
+ });
212
+
213
+ it('exits when a different, live supervisor is actually holding the port', () => {
214
+ const d = evaluateSingleton(rec({ pid: 1000, ppid: 500, port: 3000 }), { pid: 2000, ppid: 900 }, true, alive);
215
+ if (d.action !== 'exit') assert.fail(`expected exit, got ${d.action}`);
216
+ assert.match(d.reason, /already running on :3000/);
217
+ assert.match(d.reason, /pid 1000/);
218
+ });
219
+
220
+ it('proceeds when the recorded owner is dead (stale pidfile), even if the port looks in use', () => {
221
+ const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 2000, ppid: 900 }, true, dead);
222
+ assert.deepStrictEqual(d, { action: 'proceed' });
223
+ });
224
+
225
+ it('proceeds when a different live owner is NOT holding the port (nothing to fight over)', () => {
226
+ const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 2000, ppid: 900 }, false, alive);
227
+ assert.deepStrictEqual(d, { action: 'proceed' });
228
+ });
229
+
230
+ it('treats the owner as alive when only its parent pid is still alive', () => {
231
+ // Recorded pid gone, but its parent (the watcher) is alive → owner alive.
232
+ const isAlive = (pid: number) => pid === 500;
233
+ const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 2000, ppid: 900 }, true, isAlive);
234
+ assert.strictEqual(d.action, 'exit');
235
+ });
236
+ });
237
+
238
+ // ── parsePidRecord ──────────────────────────────────────────────────────────
239
+ describe('parsePidRecord — tolerant pidfile parsing', () => {
240
+ it('parses a well-formed record', () => {
241
+ assert.deepStrictEqual(parsePidRecord('{"pid":12,"ppid":3,"port":3000}'), { pid: 12, ppid: 3, port: 3000 });
242
+ });
243
+
244
+ it('returns null for empty / corrupt JSON', () => {
245
+ assert.strictEqual(parsePidRecord(''), null);
246
+ assert.strictEqual(parsePidRecord('not json'), null);
247
+ assert.strictEqual(parsePidRecord('{'), null);
248
+ });
249
+
250
+ it('returns null when required numeric fields are missing or wrong-typed', () => {
251
+ assert.strictEqual(parsePidRecord('{"pid":12,"ppid":3}'), null);
252
+ assert.strictEqual(parsePidRecord('{"pid":"12","ppid":3,"port":3000}'), null);
253
+ assert.strictEqual(parsePidRecord('{}'), null);
254
+ });
255
+ });
256
+
257
+ // ── isPidAlive ──────────────────────────────────────────────────────────────
258
+ describe('isPidAlive — signal-0 liveness probe', () => {
259
+ it('reports alive when the probe signal succeeds', () => {
260
+ assert.strictEqual(isPidAlive(4242, () => {}), true);
261
+ });
262
+
263
+ it('reports dead on ESRCH', () => {
264
+ assert.strictEqual(isPidAlive(4242, () => { throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); }), false);
265
+ });
266
+
267
+ it('reports alive on EPERM (exists, not ours to signal)', () => {
268
+ assert.strictEqual(isPidAlive(4242, () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); }), true);
269
+ });
270
+
271
+ it('rejects non-signalable pids (<= 1) without probing', () => {
272
+ let probed = false;
273
+ assert.strictEqual(isPidAlive(1, () => { probed = true; }), false);
274
+ assert.strictEqual(isPidAlive(0, () => { probed = true; }), false);
275
+ assert.strictEqual(probed, false);
276
+ });
277
+ });
278
+
279
+ // ── findListenerPids — port → listener PID discovery (injected runner) ───────
280
+ describe('findListenerPids — lsof/netstat listener discovery', () => {
281
+ it('parses the bare PID list from `lsof -ti tcp:<port> -sTCP:LISTEN` on POSIX', () => {
282
+ const calls: Array<[string, readonly string[]]> = [];
283
+ const pids = findListenerPids(3100, (cmd, args) => {
284
+ calls.push([cmd, args]);
285
+ return { stdout: '4242\n4243\n' };
286
+ }, 'linux');
287
+ assert.deepStrictEqual(pids, [4242, 4243]);
288
+ assert.deepStrictEqual(calls, [['lsof', ['-ti', 'tcp:3100', '-sTCP:LISTEN']]]);
289
+ });
290
+
291
+ it('dedups PIDs and drops non-signalable pids (<= 1)', () => {
292
+ const pids = findListenerPids(3000, () => ({ stdout: '5\n5\n1\n0\n7\n' }), 'linux');
293
+ assert.deepStrictEqual(pids, [5, 7]);
294
+ });
295
+
296
+ it('returns [] when nothing is listening (empty stdout / non-zero exit)', () => {
297
+ assert.deepStrictEqual(findListenerPids(3000, () => ({ stdout: '', status: 1 }), 'linux'), []);
298
+ assert.deepStrictEqual(findListenerPids(3000, () => ({ stdout: null }), 'linux'), []);
299
+ });
300
+
301
+ it('returns [] when the discovery command cannot run (never throws)', () => {
302
+ assert.deepStrictEqual(findListenerPids(3000, () => { throw new Error('ENOENT'); }, 'linux'), []);
303
+ });
304
+
305
+ it('parses LISTENING rows for the port from `netstat -ano` on Windows', () => {
306
+ const stdout = [
307
+ ' Proto Local Address Foreign Address State PID',
308
+ ' TCP 0.0.0.0:3100 0.0.0.0:0 LISTENING 4242',
309
+ ' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 9001', // different port
310
+ ' TCP 127.0.0.1:3100 127.0.0.1:55123 ESTABLISHED 8888', // not listening
311
+ ].join('\r\n');
312
+ const pids = findListenerPids(3100, () => ({ stdout }), 'win32');
313
+ assert.deepStrictEqual(pids, [4242]);
314
+ });
315
+ });
316
+
317
+ // ── killListenerTree — reuses the frontend group-kill on a discovered PID ────
318
+ describe('killListenerTree — reclaim reuses the process-group kill', () => {
319
+ it('group-signals the discovered PID on POSIX (negative pid)', () => {
320
+ const group: Array<[number, NodeJS.Signals]> = [];
321
+ killListenerTree(4242, 'SIGTERM', 'linux', (pid, sig) => group.push([pid, sig]));
322
+ assert.deepStrictEqual(group, [[-4242, 'SIGTERM']]);
323
+ });
324
+
325
+ it('reaps the tree via taskkill on Windows', () => {
326
+ const winPids: number[] = [];
327
+ let groupCalled = false;
328
+ killListenerTree(4242, 'SIGKILL', 'win32', () => { groupCalled = true; }, (pid) => { winPids.push(pid); return true; });
329
+ assert.strictEqual(groupCalled, false);
330
+ assert.deepStrictEqual(winPids, [4242]);
331
+ });
332
+ });
333
+
334
+ // ── createBindRetryController — :3000 EADDRINUSE retry wiring ─────────────────
335
+ // Locks in the retry *wiring* the front-door robustness fix hinges on — the
336
+ // 1-based attempt counter, the bounded decision, reclaim-result routing, and
337
+ // retry scheduling — without a real socket. All effects are injected seams.
338
+ describe('createBindRetryController — bounded EADDRINUSE reclaim-and-rebind', () => {
339
+ // setImmediate resolves after the microtask queue, so the controller's
340
+ // `void (async () => { await reclaim(); … scheduleRetry() })()` IIFE has run.
341
+ const flush = (): Promise<void> => new Promise((r) => setImmediate(r));
342
+
343
+ interface Recorder {
344
+ handler: () => void;
345
+ reclaimCalls: number;
346
+ relistenCalls: number;
347
+ exhaustedCalls: number;
348
+ scheduled: Array<{ fn: () => void; delayMs: number }>;
349
+ warnings: string[];
350
+ }
351
+
352
+ function makeController(
353
+ result: ReclaimResult = { wasOpen: true, reclaimed: true, pids: [] },
354
+ policy = DEFAULT_PORT_BIND_RETRY_POLICY,
355
+ ): Recorder {
356
+ const rec: Recorder = {
357
+ handler: () => {},
358
+ reclaimCalls: 0,
359
+ relistenCalls: 0,
360
+ exhaustedCalls: 0,
361
+ scheduled: [],
362
+ warnings: [],
363
+ };
364
+ rec.handler = createBindRetryController(
365
+ 3000,
366
+ {
367
+ reclaim: async () => { rec.reclaimCalls += 1; return result; },
368
+ relisten: () => { rec.relistenCalls += 1; },
369
+ scheduleRetry: (fn, delayMs) => { rec.scheduled.push({ fn, delayMs }); },
370
+ onExhausted: () => { rec.exhaustedCalls += 1; },
371
+ warn: (m) => { rec.warnings.push(m); },
372
+ },
373
+ policy,
374
+ );
375
+ return rec;
376
+ }
377
+
378
+ it('reclaims and schedules a retry with linear backoff, invoking relisten on the scheduled callback', async () => {
379
+ const c = makeController();
380
+
381
+ c.handler(); // attempt 1
382
+ await flush();
383
+ assert.strictEqual(c.reclaimCalls, 1, 'reclaim runs on a retryable attempt');
384
+ assert.strictEqual(c.exhaustedCalls, 0);
385
+ assert.strictEqual(c.scheduled.length, 1, 'one retry scheduled');
386
+ assert.strictEqual(c.scheduled[0].delayMs, 250, 'delay = backoffMs * attempt (250*1)');
387
+ assert.match(c.warnings[0], /attempt 1\/3/);
388
+
389
+ // Firing the scheduled callback must re-attempt the bind (guards against a
390
+ // refactor dropping the onListening-bound relisten on the retry path).
391
+ assert.strictEqual(c.relistenCalls, 0, 'relisten deferred until the scheduled callback fires');
392
+ c.scheduled[0].fn();
393
+ assert.strictEqual(c.relistenCalls, 1);
394
+
395
+ c.handler(); // attempt 2
396
+ await flush();
397
+ assert.strictEqual(c.scheduled.length, 2);
398
+ assert.strictEqual(c.scheduled[1].delayMs, 500, 'delay = backoffMs * attempt (250*2)');
399
+ assert.match(c.warnings.at(-1) ?? '', /attempt 2\/3/);
400
+ });
401
+
402
+ it('gives up after the attempt budget — no reclaim, no retry scheduled, actionable message', async () => {
403
+ const c = makeController({ wasOpen: true, reclaimed: true, pids: [] }, { maxAttempts: 2, backoffMs: 100 });
404
+
405
+ c.handler(); // attempt 1 → retry
406
+ await flush();
407
+ c.handler(); // attempt 2 → budget reached
408
+ await flush();
409
+
410
+ assert.strictEqual(c.exhaustedCalls, 1, 'onExhausted fired exactly once at the budget');
411
+ assert.strictEqual(c.reclaimCalls, 1, 'no reclaim on the exhausted attempt (only the retryable one)');
412
+ assert.strictEqual(c.scheduled.length, 1, 'no retry scheduled after exhaustion');
413
+ assert.match(c.warnings.at(-1) ?? '', /still in use after 2/);
414
+ });
415
+
416
+ it('surfaces the holding pid(s) when a retry reclaim fails to free the port', async () => {
417
+ const c = makeController({ wasOpen: true, reclaimed: false, pids: [4242] });
418
+
419
+ c.handler(); // attempt 1 → retry; reclaim fails
420
+ await flush();
421
+
422
+ assert.ok(
423
+ c.warnings.some((w) => /held by pid\(s\) \[4242\]/.test(w)),
424
+ 'operator sees the pid-naming reclaim message, not just the generic banner',
425
+ );
426
+ // A failed reclaim still schedules the next attempt (the budget, not reclaim
427
+ // success, bounds the loop).
428
+ assert.strictEqual(c.scheduled.length, 1);
429
+ });
430
+ });