@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.
- package/dist/scripts/dev-server-reclaim.test.d.ts +2 -0
- package/dist/scripts/dev-server-reclaim.test.d.ts.map +1 -0
- package/dist/scripts/dev-server-reclaim.test.js +352 -0
- package/dist/scripts/dev-server.d.ts +168 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +357 -25
- package/dist/scripts/process-tree.d.ts +41 -2
- package/dist/scripts/process-tree.d.ts.map +1 -1
- package/dist/scripts/process-tree.js +83 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/scripts/dev-server-reclaim.test.ts +430 -0
- package/src/scripts/dev-server.ts +428 -25
- package/src/scripts/process-tree.ts +101 -3
- package/src/version.ts +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-server-reclaim.test.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server-reclaim.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,352 @@
|
|
|
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 { reclaimPort, reclaimMessage, evaluatePortBindRetry, createBindRetryController, DEFAULT_PORT_BIND_RETRY_POLICY, evaluateSingleton, parsePidRecord, isPidAlive, isPortOpen, } from './dev-server.js';
|
|
7
|
+
import { findListenerPids, killListenerTree } from './process-tree.js';
|
|
8
|
+
function getFreePort() {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const srv = createServer();
|
|
11
|
+
srv.on('error', reject);
|
|
12
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
13
|
+
const addr = srv.address();
|
|
14
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
15
|
+
srv.close(() => resolve(port));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// ── reclaimPort — startup / EADDRINUSE port reclaim policy ──────────────────
|
|
20
|
+
// Fully injected: no real processes are spawned. `probe` is scripted to model
|
|
21
|
+
// the port's open/closed state across the reclaim sequence, so we assert exactly
|
|
22
|
+
// which signals are sent and when it gives up.
|
|
23
|
+
describe('reclaimPort — frees a stale/orphaned port before startup', () => {
|
|
24
|
+
// reclaimPort probes the port up to three times: (1) initial check,
|
|
25
|
+
// (2) after the SIGTERM wait, (3) the final reclaimed? check.
|
|
26
|
+
function scriptedProbe(states) {
|
|
27
|
+
let i = 0;
|
|
28
|
+
return async () => states[Math.min(i++, states.length - 1)];
|
|
29
|
+
}
|
|
30
|
+
it('is a no-op when the port is already free (no discovery, no kills)', async () => {
|
|
31
|
+
const kills = [];
|
|
32
|
+
let listed = 0;
|
|
33
|
+
const result = await reclaimPort(3000, {
|
|
34
|
+
probe: scriptedProbe([false]),
|
|
35
|
+
listPids: () => { listed++; return [111]; },
|
|
36
|
+
killTree: (pid, sig) => kills.push([pid, sig]),
|
|
37
|
+
waitFree: async () => { },
|
|
38
|
+
});
|
|
39
|
+
assert.deepStrictEqual(result, { wasOpen: false, reclaimed: true, pids: [] });
|
|
40
|
+
assert.strictEqual(listed, 0, 'must not discover PIDs when the port is free');
|
|
41
|
+
assert.deepStrictEqual(kills, []);
|
|
42
|
+
});
|
|
43
|
+
it('SIGTERMs every listener and reports reclaimed when the port frees gracefully', async () => {
|
|
44
|
+
const kills = [];
|
|
45
|
+
const result = await reclaimPort(3100, {
|
|
46
|
+
// open, then free after SIGTERM, then still free at the final check
|
|
47
|
+
probe: scriptedProbe([true, false, false]),
|
|
48
|
+
listPids: () => [4242, 4243],
|
|
49
|
+
killTree: (pid, sig) => kills.push([pid, sig]),
|
|
50
|
+
waitFree: async () => { },
|
|
51
|
+
});
|
|
52
|
+
assert.deepStrictEqual(kills, [[4242, 'SIGTERM'], [4243, 'SIGTERM']]);
|
|
53
|
+
assert.deepStrictEqual(result, { wasOpen: true, reclaimed: true, pids: [4242, 4243] });
|
|
54
|
+
});
|
|
55
|
+
it('escalates to SIGKILL when the listener survives the graceful SIGTERM', async () => {
|
|
56
|
+
const kills = [];
|
|
57
|
+
const result = await reclaimPort(3000, {
|
|
58
|
+
// open, still open after SIGTERM, free after SIGKILL
|
|
59
|
+
probe: scriptedProbe([true, true, false]),
|
|
60
|
+
listPids: () => [777],
|
|
61
|
+
killTree: (pid, sig) => kills.push([pid, sig]),
|
|
62
|
+
waitFree: async () => { },
|
|
63
|
+
});
|
|
64
|
+
assert.deepStrictEqual(kills, [[777, 'SIGTERM'], [777, 'SIGKILL']]);
|
|
65
|
+
assert.strictEqual(result.reclaimed, true);
|
|
66
|
+
});
|
|
67
|
+
it('reports reclaimed:false when the port stays bound through SIGKILL', async () => {
|
|
68
|
+
const result = await reclaimPort(3000, {
|
|
69
|
+
probe: scriptedProbe([true, true, true]),
|
|
70
|
+
listPids: () => [777],
|
|
71
|
+
killTree: () => { },
|
|
72
|
+
waitFree: async () => { },
|
|
73
|
+
});
|
|
74
|
+
assert.strictEqual(result.wasOpen, true);
|
|
75
|
+
assert.strictEqual(result.reclaimed, false);
|
|
76
|
+
});
|
|
77
|
+
it('re-discovers listeners for the SIGKILL pass when the first discovery was empty', async () => {
|
|
78
|
+
const kills = [];
|
|
79
|
+
let call = 0;
|
|
80
|
+
const result = await reclaimPort(3000, {
|
|
81
|
+
probe: scriptedProbe([true, true, false]),
|
|
82
|
+
// First lsof pass momentarily returns nothing; second finds the owner.
|
|
83
|
+
listPids: () => (call++ === 0 ? [] : [999]),
|
|
84
|
+
killTree: (pid, sig) => kills.push([pid, sig]),
|
|
85
|
+
waitFree: async () => { },
|
|
86
|
+
});
|
|
87
|
+
assert.deepStrictEqual(kills, [[999, 'SIGKILL']]);
|
|
88
|
+
assert.strictEqual(result.reclaimed, true);
|
|
89
|
+
});
|
|
90
|
+
it('SIGKILLs the CURRENT owner, not the stale one, when the port changed hands during the wait', async () => {
|
|
91
|
+
// The original listener (111) is SIGTERM'd but exits, and a DIFFERENT process
|
|
92
|
+
// (222) grabs the port before the SIGKILL pass. The escalation must re-list
|
|
93
|
+
// and target the new owner (222) — never the stale pid (111) it already
|
|
94
|
+
// signalled — otherwise it SIGKILLs a dead pid and leaves the real owner.
|
|
95
|
+
const kills = [];
|
|
96
|
+
let call = 0;
|
|
97
|
+
const result = await reclaimPort(3000, {
|
|
98
|
+
// open, still open after SIGTERM (new owner now holds it), free after SIGKILL
|
|
99
|
+
probe: scriptedProbe([true, true, false]),
|
|
100
|
+
listPids: () => (call++ === 0 ? [111] : [222]),
|
|
101
|
+
killTree: (pid, sig) => kills.push([pid, sig]),
|
|
102
|
+
waitFree: async () => { },
|
|
103
|
+
});
|
|
104
|
+
assert.deepStrictEqual(kills, [[111, 'SIGTERM'], [222, 'SIGKILL']]);
|
|
105
|
+
assert.strictEqual(result.reclaimed, true);
|
|
106
|
+
// result.pids reflects the initial discovery (what we SIGTERM'd).
|
|
107
|
+
assert.deepStrictEqual(result.pids, [111]);
|
|
108
|
+
});
|
|
109
|
+
it('reports the port free once its real listener is reclaimed (real sockets, injected kill)', async () => {
|
|
110
|
+
const port = await getFreePort();
|
|
111
|
+
const srv = createServer((c) => c.destroy());
|
|
112
|
+
await new Promise((res) => srv.listen(port, '127.0.0.1', () => res()));
|
|
113
|
+
assert.strictEqual(await isPortOpen(port, '127.0.0.1'), true, 'port should be held before reclaim');
|
|
114
|
+
// Inject the "kill" as closing our test server so the real probe/waitFree
|
|
115
|
+
// path is exercised end-to-end without spawning an OS process.
|
|
116
|
+
let killed = false;
|
|
117
|
+
const result = await reclaimPort(port, {
|
|
118
|
+
probe: (p) => isPortOpen(p, '127.0.0.1'),
|
|
119
|
+
listPids: () => [process.pid],
|
|
120
|
+
killTree: () => { if (!killed) {
|
|
121
|
+
killed = true;
|
|
122
|
+
srv.close();
|
|
123
|
+
} },
|
|
124
|
+
});
|
|
125
|
+
assert.strictEqual(result.wasOpen, true);
|
|
126
|
+
assert.strictEqual(result.reclaimed, true);
|
|
127
|
+
assert.strictEqual(await isPortOpen(port, '127.0.0.1'), false, 'port should be free after reclaim');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// ── reclaimMessage — accurate startup reclaim-outcome messaging ──────────────
|
|
131
|
+
describe('reclaimMessage — three-way reclaim outcome message', () => {
|
|
132
|
+
it('reports success when the port was reclaimed', () => {
|
|
133
|
+
const msg = reclaimMessage(3000, { wasOpen: true, reclaimed: true, pids: [] }, 'a stale/orphaned listener');
|
|
134
|
+
assert.match(msg, /Reclaimed port 3000 from a stale\/orphaned listener/);
|
|
135
|
+
});
|
|
136
|
+
it('names the holding pid(s) when reclaim failed but an owner is known', () => {
|
|
137
|
+
const msg = reclaimMessage(3100, { wasOpen: true, reclaimed: false, pids: [4242, 4243] }, 'a stale/orphaned dev server');
|
|
138
|
+
assert.match(msg, /Port 3100 held by pid\(s\) \[4242, 4243\]/);
|
|
139
|
+
assert.match(msg, /stop that process and retry/);
|
|
140
|
+
});
|
|
141
|
+
it('falls back to the generic message when reclaim failed with no owner PID', () => {
|
|
142
|
+
const msg = reclaimMessage(3000, { wasOpen: true, reclaimed: false, pids: [] }, 'a stale/orphaned listener');
|
|
143
|
+
assert.match(msg, /no owner PID found/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// ── evaluatePortBindRetry — bounded :3000 EADDRINUSE retry ───────────────────
|
|
147
|
+
describe('evaluatePortBindRetry — front-door bind retry budget', () => {
|
|
148
|
+
it('retries early attempts with a linear backoff', () => {
|
|
149
|
+
assert.deepStrictEqual(evaluatePortBindRetry(1), { retry: true, delayMs: 250 });
|
|
150
|
+
assert.deepStrictEqual(evaluatePortBindRetry(2), { retry: true, delayMs: 500 });
|
|
151
|
+
});
|
|
152
|
+
it('gives up (no retry) once the attempt budget is reached', () => {
|
|
153
|
+
const d = evaluatePortBindRetry(DEFAULT_PORT_BIND_RETRY_POLICY.maxAttempts);
|
|
154
|
+
assert.deepStrictEqual(d, { retry: false, delayMs: 0 });
|
|
155
|
+
});
|
|
156
|
+
it('honors a custom policy', () => {
|
|
157
|
+
const policy = { maxAttempts: 2, backoffMs: 100 };
|
|
158
|
+
assert.deepStrictEqual(evaluatePortBindRetry(1, policy), { retry: true, delayMs: 100 });
|
|
159
|
+
assert.deepStrictEqual(evaluatePortBindRetry(2, policy), { retry: false, delayMs: 0 });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
// ── evaluateSingleton — singleton guard decision ────────────────────────────
|
|
163
|
+
// Prevents two fighting supervisors while never blocking a `tsx watch` reload of
|
|
164
|
+
// the SAME supervisor (same parent pid).
|
|
165
|
+
describe('evaluateSingleton — one supervisor per port, hot-reload safe', () => {
|
|
166
|
+
const rec = (over = {}) => ({
|
|
167
|
+
pid: 1000,
|
|
168
|
+
ppid: 500,
|
|
169
|
+
port: 3000,
|
|
170
|
+
...over,
|
|
171
|
+
});
|
|
172
|
+
const alive = () => true;
|
|
173
|
+
const dead = () => false;
|
|
174
|
+
it('proceeds when there is no (or a corrupt) pidfile', () => {
|
|
175
|
+
assert.deepStrictEqual(evaluateSingleton(null, { pid: 1, ppid: 2 }, true, alive), { action: 'proceed' });
|
|
176
|
+
});
|
|
177
|
+
it('proceeds on a tsx-watch relaunch of our own supervisor (same parent pid)', () => {
|
|
178
|
+
// New child, DIFFERENT own pid, but SAME watcher parent → not a competitor.
|
|
179
|
+
const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 1001, ppid: 500 }, true, alive);
|
|
180
|
+
assert.deepStrictEqual(d, { action: 'proceed' });
|
|
181
|
+
});
|
|
182
|
+
it('exits when a different, live supervisor is actually holding the port', () => {
|
|
183
|
+
const d = evaluateSingleton(rec({ pid: 1000, ppid: 500, port: 3000 }), { pid: 2000, ppid: 900 }, true, alive);
|
|
184
|
+
if (d.action !== 'exit')
|
|
185
|
+
assert.fail(`expected exit, got ${d.action}`);
|
|
186
|
+
assert.match(d.reason, /already running on :3000/);
|
|
187
|
+
assert.match(d.reason, /pid 1000/);
|
|
188
|
+
});
|
|
189
|
+
it('proceeds when the recorded owner is dead (stale pidfile), even if the port looks in use', () => {
|
|
190
|
+
const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 2000, ppid: 900 }, true, dead);
|
|
191
|
+
assert.deepStrictEqual(d, { action: 'proceed' });
|
|
192
|
+
});
|
|
193
|
+
it('proceeds when a different live owner is NOT holding the port (nothing to fight over)', () => {
|
|
194
|
+
const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 2000, ppid: 900 }, false, alive);
|
|
195
|
+
assert.deepStrictEqual(d, { action: 'proceed' });
|
|
196
|
+
});
|
|
197
|
+
it('treats the owner as alive when only its parent pid is still alive', () => {
|
|
198
|
+
// Recorded pid gone, but its parent (the watcher) is alive → owner alive.
|
|
199
|
+
const isAlive = (pid) => pid === 500;
|
|
200
|
+
const d = evaluateSingleton(rec({ pid: 1000, ppid: 500 }), { pid: 2000, ppid: 900 }, true, isAlive);
|
|
201
|
+
assert.strictEqual(d.action, 'exit');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// ── parsePidRecord ──────────────────────────────────────────────────────────
|
|
205
|
+
describe('parsePidRecord — tolerant pidfile parsing', () => {
|
|
206
|
+
it('parses a well-formed record', () => {
|
|
207
|
+
assert.deepStrictEqual(parsePidRecord('{"pid":12,"ppid":3,"port":3000}'), { pid: 12, ppid: 3, port: 3000 });
|
|
208
|
+
});
|
|
209
|
+
it('returns null for empty / corrupt JSON', () => {
|
|
210
|
+
assert.strictEqual(parsePidRecord(''), null);
|
|
211
|
+
assert.strictEqual(parsePidRecord('not json'), null);
|
|
212
|
+
assert.strictEqual(parsePidRecord('{'), null);
|
|
213
|
+
});
|
|
214
|
+
it('returns null when required numeric fields are missing or wrong-typed', () => {
|
|
215
|
+
assert.strictEqual(parsePidRecord('{"pid":12,"ppid":3}'), null);
|
|
216
|
+
assert.strictEqual(parsePidRecord('{"pid":"12","ppid":3,"port":3000}'), null);
|
|
217
|
+
assert.strictEqual(parsePidRecord('{}'), null);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
// ── isPidAlive ──────────────────────────────────────────────────────────────
|
|
221
|
+
describe('isPidAlive — signal-0 liveness probe', () => {
|
|
222
|
+
it('reports alive when the probe signal succeeds', () => {
|
|
223
|
+
assert.strictEqual(isPidAlive(4242, () => { }), true);
|
|
224
|
+
});
|
|
225
|
+
it('reports dead on ESRCH', () => {
|
|
226
|
+
assert.strictEqual(isPidAlive(4242, () => { throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); }), false);
|
|
227
|
+
});
|
|
228
|
+
it('reports alive on EPERM (exists, not ours to signal)', () => {
|
|
229
|
+
assert.strictEqual(isPidAlive(4242, () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); }), true);
|
|
230
|
+
});
|
|
231
|
+
it('rejects non-signalable pids (<= 1) without probing', () => {
|
|
232
|
+
let probed = false;
|
|
233
|
+
assert.strictEqual(isPidAlive(1, () => { probed = true; }), false);
|
|
234
|
+
assert.strictEqual(isPidAlive(0, () => { probed = true; }), false);
|
|
235
|
+
assert.strictEqual(probed, false);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
// ── findListenerPids — port → listener PID discovery (injected runner) ───────
|
|
239
|
+
describe('findListenerPids — lsof/netstat listener discovery', () => {
|
|
240
|
+
it('parses the bare PID list from `lsof -ti tcp:<port> -sTCP:LISTEN` on POSIX', () => {
|
|
241
|
+
const calls = [];
|
|
242
|
+
const pids = findListenerPids(3100, (cmd, args) => {
|
|
243
|
+
calls.push([cmd, args]);
|
|
244
|
+
return { stdout: '4242\n4243\n' };
|
|
245
|
+
}, 'linux');
|
|
246
|
+
assert.deepStrictEqual(pids, [4242, 4243]);
|
|
247
|
+
assert.deepStrictEqual(calls, [['lsof', ['-ti', 'tcp:3100', '-sTCP:LISTEN']]]);
|
|
248
|
+
});
|
|
249
|
+
it('dedups PIDs and drops non-signalable pids (<= 1)', () => {
|
|
250
|
+
const pids = findListenerPids(3000, () => ({ stdout: '5\n5\n1\n0\n7\n' }), 'linux');
|
|
251
|
+
assert.deepStrictEqual(pids, [5, 7]);
|
|
252
|
+
});
|
|
253
|
+
it('returns [] when nothing is listening (empty stdout / non-zero exit)', () => {
|
|
254
|
+
assert.deepStrictEqual(findListenerPids(3000, () => ({ stdout: '', status: 1 }), 'linux'), []);
|
|
255
|
+
assert.deepStrictEqual(findListenerPids(3000, () => ({ stdout: null }), 'linux'), []);
|
|
256
|
+
});
|
|
257
|
+
it('returns [] when the discovery command cannot run (never throws)', () => {
|
|
258
|
+
assert.deepStrictEqual(findListenerPids(3000, () => { throw new Error('ENOENT'); }, 'linux'), []);
|
|
259
|
+
});
|
|
260
|
+
it('parses LISTENING rows for the port from `netstat -ano` on Windows', () => {
|
|
261
|
+
const stdout = [
|
|
262
|
+
' Proto Local Address Foreign Address State PID',
|
|
263
|
+
' TCP 0.0.0.0:3100 0.0.0.0:0 LISTENING 4242',
|
|
264
|
+
' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 9001', // different port
|
|
265
|
+
' TCP 127.0.0.1:3100 127.0.0.1:55123 ESTABLISHED 8888', // not listening
|
|
266
|
+
].join('\r\n');
|
|
267
|
+
const pids = findListenerPids(3100, () => ({ stdout }), 'win32');
|
|
268
|
+
assert.deepStrictEqual(pids, [4242]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
// ── killListenerTree — reuses the frontend group-kill on a discovered PID ────
|
|
272
|
+
describe('killListenerTree — reclaim reuses the process-group kill', () => {
|
|
273
|
+
it('group-signals the discovered PID on POSIX (negative pid)', () => {
|
|
274
|
+
const group = [];
|
|
275
|
+
killListenerTree(4242, 'SIGTERM', 'linux', (pid, sig) => group.push([pid, sig]));
|
|
276
|
+
assert.deepStrictEqual(group, [[-4242, 'SIGTERM']]);
|
|
277
|
+
});
|
|
278
|
+
it('reaps the tree via taskkill on Windows', () => {
|
|
279
|
+
const winPids = [];
|
|
280
|
+
let groupCalled = false;
|
|
281
|
+
killListenerTree(4242, 'SIGKILL', 'win32', () => { groupCalled = true; }, (pid) => { winPids.push(pid); return true; });
|
|
282
|
+
assert.strictEqual(groupCalled, false);
|
|
283
|
+
assert.deepStrictEqual(winPids, [4242]);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
// ── createBindRetryController — :3000 EADDRINUSE retry wiring ─────────────────
|
|
287
|
+
// Locks in the retry *wiring* the front-door robustness fix hinges on — the
|
|
288
|
+
// 1-based attempt counter, the bounded decision, reclaim-result routing, and
|
|
289
|
+
// retry scheduling — without a real socket. All effects are injected seams.
|
|
290
|
+
describe('createBindRetryController — bounded EADDRINUSE reclaim-and-rebind', () => {
|
|
291
|
+
// setImmediate resolves after the microtask queue, so the controller's
|
|
292
|
+
// `void (async () => { await reclaim(); … scheduleRetry() })()` IIFE has run.
|
|
293
|
+
const flush = () => new Promise((r) => setImmediate(r));
|
|
294
|
+
function makeController(result = { wasOpen: true, reclaimed: true, pids: [] }, policy = DEFAULT_PORT_BIND_RETRY_POLICY) {
|
|
295
|
+
const rec = {
|
|
296
|
+
handler: () => { },
|
|
297
|
+
reclaimCalls: 0,
|
|
298
|
+
relistenCalls: 0,
|
|
299
|
+
exhaustedCalls: 0,
|
|
300
|
+
scheduled: [],
|
|
301
|
+
warnings: [],
|
|
302
|
+
};
|
|
303
|
+
rec.handler = createBindRetryController(3000, {
|
|
304
|
+
reclaim: async () => { rec.reclaimCalls += 1; return result; },
|
|
305
|
+
relisten: () => { rec.relistenCalls += 1; },
|
|
306
|
+
scheduleRetry: (fn, delayMs) => { rec.scheduled.push({ fn, delayMs }); },
|
|
307
|
+
onExhausted: () => { rec.exhaustedCalls += 1; },
|
|
308
|
+
warn: (m) => { rec.warnings.push(m); },
|
|
309
|
+
}, policy);
|
|
310
|
+
return rec;
|
|
311
|
+
}
|
|
312
|
+
it('reclaims and schedules a retry with linear backoff, invoking relisten on the scheduled callback', async () => {
|
|
313
|
+
const c = makeController();
|
|
314
|
+
c.handler(); // attempt 1
|
|
315
|
+
await flush();
|
|
316
|
+
assert.strictEqual(c.reclaimCalls, 1, 'reclaim runs on a retryable attempt');
|
|
317
|
+
assert.strictEqual(c.exhaustedCalls, 0);
|
|
318
|
+
assert.strictEqual(c.scheduled.length, 1, 'one retry scheduled');
|
|
319
|
+
assert.strictEqual(c.scheduled[0].delayMs, 250, 'delay = backoffMs * attempt (250*1)');
|
|
320
|
+
assert.match(c.warnings[0], /attempt 1\/3/);
|
|
321
|
+
// Firing the scheduled callback must re-attempt the bind (guards against a
|
|
322
|
+
// refactor dropping the onListening-bound relisten on the retry path).
|
|
323
|
+
assert.strictEqual(c.relistenCalls, 0, 'relisten deferred until the scheduled callback fires');
|
|
324
|
+
c.scheduled[0].fn();
|
|
325
|
+
assert.strictEqual(c.relistenCalls, 1);
|
|
326
|
+
c.handler(); // attempt 2
|
|
327
|
+
await flush();
|
|
328
|
+
assert.strictEqual(c.scheduled.length, 2);
|
|
329
|
+
assert.strictEqual(c.scheduled[1].delayMs, 500, 'delay = backoffMs * attempt (250*2)');
|
|
330
|
+
assert.match(c.warnings.at(-1) ?? '', /attempt 2\/3/);
|
|
331
|
+
});
|
|
332
|
+
it('gives up after the attempt budget — no reclaim, no retry scheduled, actionable message', async () => {
|
|
333
|
+
const c = makeController({ wasOpen: true, reclaimed: true, pids: [] }, { maxAttempts: 2, backoffMs: 100 });
|
|
334
|
+
c.handler(); // attempt 1 → retry
|
|
335
|
+
await flush();
|
|
336
|
+
c.handler(); // attempt 2 → budget reached
|
|
337
|
+
await flush();
|
|
338
|
+
assert.strictEqual(c.exhaustedCalls, 1, 'onExhausted fired exactly once at the budget');
|
|
339
|
+
assert.strictEqual(c.reclaimCalls, 1, 'no reclaim on the exhausted attempt (only the retryable one)');
|
|
340
|
+
assert.strictEqual(c.scheduled.length, 1, 'no retry scheduled after exhaustion');
|
|
341
|
+
assert.match(c.warnings.at(-1) ?? '', /still in use after 2/);
|
|
342
|
+
});
|
|
343
|
+
it('surfaces the holding pid(s) when a retry reclaim fails to free the port', async () => {
|
|
344
|
+
const c = makeController({ wasOpen: true, reclaimed: false, pids: [4242] });
|
|
345
|
+
c.handler(); // attempt 1 → retry; reclaim fails
|
|
346
|
+
await flush();
|
|
347
|
+
assert.ok(c.warnings.some((w) => /held by pid\(s\) \[4242\]/.test(w)), 'operator sees the pid-naming reclaim message, not just the generic banner');
|
|
348
|
+
// A failed reclaim still schedules the next attempt (the budget, not reclaim
|
|
349
|
+
// success, bounds the loop).
|
|
350
|
+
assert.strictEqual(c.scheduled.length, 1);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -36,6 +36,26 @@ export interface DevServerOptions {
|
|
|
36
36
|
/** Port the frontend dev server listens on. Default: 3100. */
|
|
37
37
|
frontendPort?: number;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Single-shot TCP probe: resolves `true` iff a connection to `port` succeeds
|
|
41
|
+
* within `timeoutMs`, else `false` (connection error or timeout). Never rejects.
|
|
42
|
+
*
|
|
43
|
+
* When `host` is omitted the port is probed on BOTH loopback families —
|
|
44
|
+
* `127.0.0.1` (IPv4) and `::1` (IPv6) — and reported open if EITHER answers.
|
|
45
|
+
* This matters because `server.listen(port, …)` (below) passes no host, so Node
|
|
46
|
+
* binds dual-stack on `::` (all interfaces, both families). A single `localhost`
|
|
47
|
+
* probe resolves to only one of `::1`/`127.0.0.1` on a given host, so an orphan
|
|
48
|
+
* holding the port on the *other* family would be invisible — leaving the
|
|
49
|
+
* startup/EADDRINUSE reclaim path blind to a port that `listen` will still
|
|
50
|
+
* reject. Probing both families keeps every "is the port bound" check in
|
|
51
|
+
* agreement with what `listen` actually contends for. Pass an explicit `host`
|
|
52
|
+
* to probe only that address.
|
|
53
|
+
*
|
|
54
|
+
* Shared by {@link waitForPort} (wait until open), {@link waitForPortFree} (wait
|
|
55
|
+
* until closed) and the startup/EADDRINUSE reclaim path so all three agree on
|
|
56
|
+
* exactly what "the port is bound" means.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isPortOpen(port: number, host?: string, timeoutMs?: number): Promise<boolean>;
|
|
39
59
|
/** Bounded auto-respawn policy for the frontend dev server. */
|
|
40
60
|
export interface FrontendRespawnPolicy {
|
|
41
61
|
/** Max restarts allowed within `windowMs` before giving up (prevents hot loops). */
|
|
@@ -109,5 +129,153 @@ export declare function shouldCreditFrontendReady(child: {
|
|
|
109
129
|
exitCode: number | null;
|
|
110
130
|
signalCode: NodeJS.Signals | null;
|
|
111
131
|
} | null, current: unknown): boolean;
|
|
132
|
+
/** Outcome of {@link reclaimPort}. */
|
|
133
|
+
export interface ReclaimResult {
|
|
134
|
+
/** Whether the port was bound when reclaim started. */
|
|
135
|
+
wasOpen: boolean;
|
|
136
|
+
/** Whether the port is free once reclaim finishes (true when it was never open). */
|
|
137
|
+
reclaimed: boolean;
|
|
138
|
+
/** Listener PIDs discovered (and signalled). Empty when the port was free, or no owner PID was found. */
|
|
139
|
+
pids: number[];
|
|
140
|
+
}
|
|
141
|
+
/** Injectable seams for {@link reclaimPort} (real implementations by default). */
|
|
142
|
+
export interface ReclaimPortDeps {
|
|
143
|
+
/** True iff the port is currently bound. */
|
|
144
|
+
probe: (port: number) => Promise<boolean>;
|
|
145
|
+
/** PIDs of the listener(s) holding the port. */
|
|
146
|
+
listPids: (port: number) => number[];
|
|
147
|
+
/** Terminate a listener PID's tree (POSIX group kill / Windows taskkill). */
|
|
148
|
+
killTree: (pid: number, signal: NodeJS.Signals) => void;
|
|
149
|
+
/** Wait (bounded) for the port to be released. */
|
|
150
|
+
waitFree: (port: number, timeoutMs?: number) => Promise<void>;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Free a port left bound by a crashed / SIGKILL'd predecessor so a fresh dev
|
|
154
|
+
* server can bind the `:3000` front door — or spawn its `--strictPort` frontend
|
|
155
|
+
* on `:3100` — instead of colliding on it and crashing.
|
|
156
|
+
*
|
|
157
|
+
* No-op when the port is already free. Otherwise it discovers the listener PID(s)
|
|
158
|
+
* ({@link findListenerPids}, an `lsof`/`netstat` probe — the same fuser-style
|
|
159
|
+
* mechanism `cleanup` uses), SIGTERMs each ({@link killListenerTree}, which
|
|
160
|
+
* reuses the frontend process-group kill), waits (bounded) for release
|
|
161
|
+
* ({@link waitForPortFree}), then escalates to SIGKILL if the port is still held.
|
|
162
|
+
* This deliberately mirrors the respawn path (process-group kill + port-free
|
|
163
|
+
* wait) rather than inventing a new teardown mechanism.
|
|
164
|
+
*
|
|
165
|
+
* The caller is responsible for NOT reclaiming a *healthy peer* dev server — the
|
|
166
|
+
* singleton guard ({@link evaluateSingleton}) runs first and bows out when a live
|
|
167
|
+
* peer owns the front door, so anything still holding these ports here is an
|
|
168
|
+
* orphan. Dependencies are injected for tests; returns what it did for
|
|
169
|
+
* logging/assertions. Best-effort: never throws.
|
|
170
|
+
*
|
|
171
|
+
* Probe contract: the default `probe` is {@link isPortOpen} with no host, which
|
|
172
|
+
* checks BOTH `127.0.0.1` and `::1`. `server.listen` binds dual-stack on `::`,
|
|
173
|
+
* so an orphan holding the port on either loopback family is detected (and thus
|
|
174
|
+
* reclaimed) — a single-family `localhost` probe could miss it and no-op.
|
|
175
|
+
*/
|
|
176
|
+
export declare function reclaimPort(port: number, deps?: Partial<ReclaimPortDeps>): Promise<ReclaimResult>;
|
|
177
|
+
/**
|
|
178
|
+
* Build the console message for a startup reclaim of a port that was in use,
|
|
179
|
+
* distinguishing the three meaningfully different outcomes so the operator knows
|
|
180
|
+
* what (if anything) to do next:
|
|
181
|
+
* - reclaimed → we freed it; startup continues.
|
|
182
|
+
* - not reclaimed but owner PID(s) known → tell the operator exactly which
|
|
183
|
+
* process(es) to stop and retry.
|
|
184
|
+
* - not reclaimed and no owner PID found → nothing to point at (lsof/netstat
|
|
185
|
+
* found no listener), so surface the generic "in use" message.
|
|
186
|
+
* `subject` describes what was reclaimed (e.g. 'a stale/orphaned listener'). Pure.
|
|
187
|
+
*/
|
|
188
|
+
export declare function reclaimMessage(port: number, result: ReclaimResult, subject: string): string;
|
|
189
|
+
/** Bounded retry policy for binding the `:3000` front door under EADDRINUSE. */
|
|
190
|
+
export interface PortBindRetryPolicy {
|
|
191
|
+
/** Total bind attempts tolerated before giving up (exit non-zero). */
|
|
192
|
+
maxAttempts: number;
|
|
193
|
+
/** Base backoff (ms); scaled by the attempt number between retries. */
|
|
194
|
+
backoffMs: number;
|
|
195
|
+
}
|
|
196
|
+
/** Default front-door bind retry budget: 3 attempts, 250ms→750ms linear backoff. */
|
|
197
|
+
export declare const DEFAULT_PORT_BIND_RETRY_POLICY: PortBindRetryPolicy;
|
|
198
|
+
/**
|
|
199
|
+
* Decide whether an EADDRINUSE on the `:3000` front door should trigger another
|
|
200
|
+
* reclaim-and-rebind attempt. `attempt` is the number of failures so far
|
|
201
|
+
* (1-based). Returns `retry: false` once the budget is exhausted so the caller
|
|
202
|
+
* exits non-zero with a clear message rather than looping forever. Pure.
|
|
203
|
+
*/
|
|
204
|
+
export declare function evaluatePortBindRetry(attempt: number, policy?: PortBindRetryPolicy): {
|
|
205
|
+
retry: boolean;
|
|
206
|
+
delayMs: number;
|
|
207
|
+
};
|
|
208
|
+
/** Injectable seams for {@link createBindRetryController} (real implementations wired at the call site). */
|
|
209
|
+
export interface BindRetryDeps {
|
|
210
|
+
/** Reclaim the contended port; its result drives the operator message. */
|
|
211
|
+
reclaim: (port: number) => Promise<ReclaimResult>;
|
|
212
|
+
/** Re-attempt the bind (`server.listen(port, onListening)` in prod). */
|
|
213
|
+
relisten: () => void;
|
|
214
|
+
/** Schedule the next attempt after a backoff (`setTimeout` in prod). */
|
|
215
|
+
scheduleRetry: (fn: () => void, delayMs: number) => void;
|
|
216
|
+
/** Called once the attempt budget is exhausted (`process.exit(1)` in prod). */
|
|
217
|
+
onExhausted: () => void;
|
|
218
|
+
/** Warning/error sink (`console.error` in prod). */
|
|
219
|
+
warn: (msg: string) => void;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Build the `:3000` front-door EADDRINUSE bind-retry handler. Extracted from the
|
|
223
|
+
* `server.on('error')` closure so the retry *wiring* — the 1-based attempt
|
|
224
|
+
* counter, the bounded {@link evaluatePortBindRetry} decision, reclaim-result
|
|
225
|
+
* routing, and retry scheduling — is unit-testable without a real socket.
|
|
226
|
+
*
|
|
227
|
+
* Returns a function to invoke on each EADDRINUSE. Per invocation it:
|
|
228
|
+
* - increments the attempt counter and consults {@link evaluatePortBindRetry};
|
|
229
|
+
* - on exhaustion: warns with an actionable message and calls `onExhausted`
|
|
230
|
+
* (no further retry is scheduled);
|
|
231
|
+
* - otherwise: warns it is retrying, then (async) reclaims the port and — when
|
|
232
|
+
* reclaim did NOT free it — surfaces the {@link reclaimMessage} naming the
|
|
233
|
+
* holding pid(s) so the operator isn't left with only the generic banner,
|
|
234
|
+
* then schedules the next `relisten` after the decided backoff.
|
|
235
|
+
* Never throws.
|
|
236
|
+
*/
|
|
237
|
+
export declare function createBindRetryController(port: number, deps: BindRetryDeps, policy?: PortBindRetryPolicy): () => void;
|
|
238
|
+
/** Persisted identity of the dev server that owns a given front-door port. */
|
|
239
|
+
export interface DevServerPidRecord {
|
|
240
|
+
/** The supervisor process's own pid. */
|
|
241
|
+
pid: number;
|
|
242
|
+
/** The supervisor's parent pid — the stable `tsx watch` watcher across reloads. */
|
|
243
|
+
ppid: number;
|
|
244
|
+
/** The front-door port this record guards. */
|
|
245
|
+
port: number;
|
|
246
|
+
}
|
|
247
|
+
/** Parse a pidfile body into a {@link DevServerPidRecord}; `null` if absent/corrupt/incomplete. */
|
|
248
|
+
export declare function parsePidRecord(text: string): DevServerPidRecord | null;
|
|
249
|
+
/** True iff a signal can be delivered to `pid` (exists). `EPERM` (exists, not ours) counts as alive. */
|
|
250
|
+
export declare function isPidAlive(pid: number, kill?: (pid: number, signal: number) => void): boolean;
|
|
251
|
+
/** Result of {@link evaluateSingleton}: proceed with startup, or exit cleanly (a live peer owns the port). */
|
|
252
|
+
export type SingletonDecision = {
|
|
253
|
+
action: 'proceed';
|
|
254
|
+
} | {
|
|
255
|
+
action: 'exit';
|
|
256
|
+
reason: string;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Decide whether a *new* dev-server invocation should start, or bow out because
|
|
260
|
+
* another supervisor already owns `port`. This is the singleton guard that stops
|
|
261
|
+
* the "two fighting supervisors" restart loop — a second `npm run dev` racing the
|
|
262
|
+
* first on `:3000`/`:3100` — WITHOUT breaking `tsx watch`'s own restart of the
|
|
263
|
+
* *same* supervisor on a file change.
|
|
264
|
+
*
|
|
265
|
+
* - **No / corrupt pidfile** → proceed (first start; startup reclaim covers any orphan socket).
|
|
266
|
+
* - **Same pid** → proceed (defensive; the record is our own).
|
|
267
|
+
* - **Same parent (`ppid`)** → proceed. `tsx watch` is the stable parent across
|
|
268
|
+
* reloads, so a matching parent means the watcher is relaunching OUR OWN script
|
|
269
|
+
* — not a competitor. A second `npm run dev` runs under a *different* watcher,
|
|
270
|
+
* so it never matches here. This carve-out is what preserves hot reload.
|
|
271
|
+
* - **Different, still-live owner actually holding the port** → exit cleanly with
|
|
272
|
+
* a clear message (do not spawn a competing supervisor).
|
|
273
|
+
* - **Otherwise** (recorded owner is dead → stale pidfile, or the port is free)
|
|
274
|
+
* → proceed; startup reclaim frees any orphaned socket.
|
|
275
|
+
*/
|
|
276
|
+
export declare function evaluateSingleton(existing: DevServerPidRecord | null, self: {
|
|
277
|
+
pid: number;
|
|
278
|
+
ppid: number;
|
|
279
|
+
}, portInUse: boolean, isAlive: (pid: number) => boolean): SingletonDecision;
|
|
112
280
|
export declare function startDevServer(options: DevServerOptions): Promise<void>;
|
|
113
281
|
//# sourceMappingURL=dev-server.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"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"}
|