@claw-link/gateway-host 0.3.5 → 0.3.6
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/bin/cli.js +2 -1
- package/package.json +1 -1
- package/scripts/install.js +88 -11
- package/src/control.js +66 -0
- package/src/worker.js +76 -21
package/bin/cli.js
CHANGED
|
@@ -106,7 +106,8 @@ async function main() {
|
|
|
106
106
|
' start Start the background service',
|
|
107
107
|
' stop Stop the background service',
|
|
108
108
|
' restart Restart the background service (apply config changes)',
|
|
109
|
-
' update Refresh the
|
|
109
|
+
' update Refresh the service to this CLI’s version — waits for in-flight jobs to finish',
|
|
110
|
+
' (clhost update [--force] [--timeout <seconds>])',
|
|
110
111
|
' setup install + add-agent in one flow',
|
|
111
112
|
' rotate How to rotate a Host Token / move to a new machine',
|
|
112
113
|
' uninstall Stop + remove the background service',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claw-link/gateway-host",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "ClawLink Host Gateway — a secure, outbound-only worker that bridges a local agent CLI (OpenClaw, Hermes, Claude, Codex, Cursor) to your ClawLink agents. No inbound ports; authenticated per-agent by a Host Token.",
|
|
5
5
|
"homepage": "https://claw-link.co",
|
|
6
6
|
"bin": {
|
package/scripts/install.js
CHANGED
|
@@ -12,6 +12,9 @@ const config = require('../src/config');
|
|
|
12
12
|
const { detectRuntimes } = require('../src/detect');
|
|
13
13
|
const { Bridge } = require('../src/bridge');
|
|
14
14
|
const scaffold = require('../src/scaffold');
|
|
15
|
+
const control = require('../src/control');
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
18
|
|
|
16
19
|
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
17
20
|
const RUNTIMES = ['openclaw', 'hermes', 'claude', 'codex', 'cursor'];
|
|
@@ -129,9 +132,15 @@ async function run() {
|
|
|
129
132
|
const existing = (cfg.agents || []).length;
|
|
130
133
|
if (existing > 0) {
|
|
131
134
|
console.log(`\n ClawLink Host Gateway v${VERSION} — already set up (${existing} agent${existing > 1 ? 's' : ''}).`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
// Graceful: let any in-flight job finish before the service restarts (same as `clhost update`).
|
|
136
|
+
const live = control.liveStatus();
|
|
137
|
+
if (live && live.busy) console.log(' ⏳ An agent is mid-job — finishing it before refreshing the service…');
|
|
138
|
+
const r = await gracefulRestart({
|
|
139
|
+
onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
|
|
140
|
+
});
|
|
141
|
+
if (live && live.busy) process.stdout.write('\n');
|
|
142
|
+
console.log(r.restarted
|
|
143
|
+
? (r.hadWorker && !r.timedOut ? ' ✓ Finished in-flight work, then refreshed to the latest version.' : ' ✓ Service refreshed to the latest version.')
|
|
135
144
|
: ' ⚠ Could not refresh the service automatically — run: clhost restart');
|
|
136
145
|
reportCli();
|
|
137
146
|
console.log('\n Commands: clhost status · clhost agents · clhost add-agent · clhost transport-test\n');
|
|
@@ -371,7 +380,11 @@ WantedBy=default.target
|
|
|
371
380
|
`;
|
|
372
381
|
fs.writeFileSync(path.join(unitDir, 'clawlink-host.service'), unit, { mode: 0o644 });
|
|
373
382
|
execSafe('systemctl --user daemon-reload');
|
|
374
|
-
|
|
383
|
+
execSafe('systemctl --user enable clawlink-host'); // start at login (idempotent)
|
|
384
|
+
// `restart` STARTS a stopped unit AND restarts a running one — so this both first-installs and
|
|
385
|
+
// UPDATES. (`enable --now` is a no-op on an already-running unit, which would leave the old
|
|
386
|
+
// code running and, mid-drain, wedge the worker with the drain flag never cleared.)
|
|
387
|
+
return execSafe('systemctl --user restart clawlink-host') !== null;
|
|
375
388
|
}
|
|
376
389
|
if (platform === 'win32') {
|
|
377
390
|
return installServiceWindows();
|
|
@@ -446,6 +459,12 @@ function status() {
|
|
|
446
459
|
if (!cfg) { console.log('Not configured. Run: clhost setup'); return; }
|
|
447
460
|
console.log(` Bridge: ${cfg.bridge_url}`);
|
|
448
461
|
console.log(` Service: ${serviceRunning() ? '● running' : '○ stopped'}`);
|
|
462
|
+
// Live worker heartbeat (from the worker's status file) — shows whether an update would wait.
|
|
463
|
+
const live = control.liveStatus();
|
|
464
|
+
if (live) {
|
|
465
|
+
console.log(` Worker: ● live (v${live.version || '?'})` +
|
|
466
|
+
(live.busy ? ` — busy: ${live.activeJobs} job(s) in flight (\`clhost update\` will wait)` : ' — idle'));
|
|
467
|
+
}
|
|
449
468
|
console.log('');
|
|
450
469
|
printAgents(cfg);
|
|
451
470
|
}
|
|
@@ -507,14 +526,72 @@ function logs() {
|
|
|
507
526
|
console.log(`\n ── last ${n} lines of ${logPath} ──`);
|
|
508
527
|
}
|
|
509
528
|
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
529
|
+
// Ask the running worker to finish its in-flight job(s) and stop claiming new ones, then wait until
|
|
530
|
+
// it reports idle — so the coming restart never kills work in progress. Returns immediately when
|
|
531
|
+
// there's no live worker to drain (first install, or a worker that predates drain support and thus
|
|
532
|
+
// never writes a status file — in that case the restart is the old, non-graceful hard swap).
|
|
533
|
+
async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait } = {}) {
|
|
534
|
+
// Never let a bad/NaN timeout collapse the wait loop into an instant (silent) hard restart.
|
|
535
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) timeoutMs = 15 * 60 * 1000;
|
|
536
|
+
const live = control.liveStatus();
|
|
537
|
+
if (!live) return { hadWorker: false, drained: true }; // nothing running to drain
|
|
538
|
+
if (force) return { hadWorker: true, drained: false, forced: true };
|
|
539
|
+
|
|
540
|
+
control.requestDrain();
|
|
541
|
+
const startedAt = Date.now();
|
|
542
|
+
let waited = false;
|
|
543
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
544
|
+
const s = control.liveStatus();
|
|
545
|
+
if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited → safe
|
|
546
|
+
// Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
|
|
547
|
+
if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
|
|
548
|
+
if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
|
|
549
|
+
await sleep(1500);
|
|
550
|
+
}
|
|
551
|
+
return { hadWorker: true, drained: false, timedOut: true };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Drain the running worker to idle (unless forced), then stage the new code + restart the service.
|
|
555
|
+
// The fresh worker clears the drain flag on boot; if the restart fails we clear it here so the old
|
|
556
|
+
// worker resumes claiming instead of sitting permanently drained.
|
|
557
|
+
async function gracefulRestart(opts = {}) {
|
|
558
|
+
const drain = await drainToIdle(opts);
|
|
559
|
+
const restarted = installService(); // syncAppDir (stage new code) + bootstrap/restart the service
|
|
560
|
+
if (!restarted) control.clearDrain();
|
|
561
|
+
return { ...drain, restarted };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// `update` — refresh the background service to the version of THIS CLI. GRACEFUL: it waits for any
|
|
565
|
+
// in-flight job to finish before restarting (override with `--force`, cap the wait with
|
|
566
|
+
// `--timeout <seconds>`). To update the CLI itself: npm i -g @claw-link/gateway-host@latest.
|
|
567
|
+
async function update() {
|
|
568
|
+
const args = process.argv.slice(3);
|
|
569
|
+
const force = args.includes('--force') || args.includes('-f');
|
|
570
|
+
const ti = args.findIndex((a) => a === '--timeout' || a === '-t');
|
|
571
|
+
let timeoutMs;
|
|
572
|
+
if (ti >= 0 && args[ti + 1]) {
|
|
573
|
+
const secs = Number(args[ti + 1]);
|
|
574
|
+
if (Number.isFinite(secs) && secs > 0) timeoutMs = secs * 1000;
|
|
575
|
+
else console.log(` ⚠ Ignoring invalid --timeout '${args[ti + 1]}' (expected seconds) — using the default.`);
|
|
576
|
+
}
|
|
577
|
+
|
|
513
578
|
console.log(` Refreshing the background service to v${VERSION}…`);
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
579
|
+
const live = control.liveStatus();
|
|
580
|
+
if (live && live.busy && !force) {
|
|
581
|
+
console.log(' ⏳ An agent is mid-job — waiting for it to finish before restarting.');
|
|
582
|
+
console.log(' (Ctrl-C to keep the current version · `clhost update --force` to restart now.)');
|
|
583
|
+
}
|
|
584
|
+
const r = await gracefulRestart({
|
|
585
|
+
force, timeoutMs,
|
|
586
|
+
onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight${s.currentJobId ? ` (${String(s.currentJobId).slice(0, 8)}…)` : ''} `),
|
|
587
|
+
});
|
|
588
|
+
if (live && live.busy && !force) process.stdout.write('\n');
|
|
589
|
+
|
|
590
|
+
if (!r.restarted) { console.log(' ⚠ Could not refresh automatically — run: clhost restart'); return; }
|
|
591
|
+
if (r.forced) console.log(` ✓ Restarted now (--force). Service runs v${VERSION} from ${appDir()}.`);
|
|
592
|
+
else if (r.timedOut) console.log(` ⚠ A job was still running after the wait — restarted anyway (it may have been interrupted). Service runs v${VERSION}.`);
|
|
593
|
+
else if (r.hadWorker) console.log(` ✓ Finished in-flight work, then refreshed. Service runs v${VERSION} from ${appDir()}.`);
|
|
594
|
+
else console.log(` ✓ Service now runs v${VERSION} from ${appDir()}.`);
|
|
518
595
|
console.log(' (To upgrade the `clhost` CLI you type: npm i -g @claw-link/gateway-host@latest)\n');
|
|
519
596
|
}
|
|
520
597
|
|
package/src/control.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Local, file-based control channel between a running worker and the CLI (`clhost update`/
|
|
3
|
+
// `restart`). Everything lives under ~/.clawlink-host so it works with zero network/DB access:
|
|
4
|
+
// • worker-status.json — the worker's heartbeat: pid, version, in-flight job count, drain ack.
|
|
5
|
+
// • drain — a flag file; while present the worker STOPS claiming new jobs but keeps
|
|
6
|
+
// finishing in-flight ones. The CLI uses it for graceful, no-job-killed
|
|
7
|
+
// restarts: request drain → wait for the worker to report idle → restart.
|
|
8
|
+
// The worker clears a stale `drain` flag on boot, so a crashed update can never wedge a fresh worker.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const config = require('./config');
|
|
13
|
+
|
|
14
|
+
const STATUS_PATH = path.join(config.HOME_DIR, 'worker-status.json');
|
|
15
|
+
const DRAIN_PATH = path.join(config.HOME_DIR, 'drain');
|
|
16
|
+
|
|
17
|
+
// A worker is considered "live" only if its status was written within this window — so the CLI
|
|
18
|
+
// treats a crashed/stopped worker (stale file) as "nothing to drain" and proceeds immediately.
|
|
19
|
+
const STATUS_FRESH_MS = 30 * 1000;
|
|
20
|
+
|
|
21
|
+
function writeStatus(s) {
|
|
22
|
+
try {
|
|
23
|
+
config.ensureHomeDir();
|
|
24
|
+
fs.writeFileSync(STATUS_PATH, JSON.stringify({ ...s, ts: Date.now() }), { mode: 0o600 });
|
|
25
|
+
} catch { /* best effort — status is advisory */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readStatus() {
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8')); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The last status IF a worker is actively writing it (fresh), else null.
|
|
33
|
+
function liveStatus() {
|
|
34
|
+
const s = readStatus();
|
|
35
|
+
if (!s || typeof s.ts !== 'number') return null;
|
|
36
|
+
return Date.now() - s.ts < STATUS_FRESH_MS ? s : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requestDrain() {
|
|
40
|
+
try { config.ensureHomeDir(); fs.writeFileSync(DRAIN_PATH, String(Date.now()), { mode: 0o600 }); return true; }
|
|
41
|
+
catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clearDrain() {
|
|
45
|
+
try { fs.rmSync(DRAIN_PATH, { force: true }); } catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function drainRequested() {
|
|
49
|
+
try { return fs.existsSync(DRAIN_PATH); } catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True only if a drain flag exists AND was written at/after `sinceMs`. The worker passes its boot
|
|
53
|
+
// time here so a STALE flag left over from before it booted (e.g. a crashed update, or a clearDrain()
|
|
54
|
+
// that failed) is IGNORED — the worker can never wedge itself refusing to claim. A present-but-
|
|
55
|
+
// unparseable flag is honored (fail toward draining) since that's the job-safe direction.
|
|
56
|
+
function drainRequestedSince(sinceMs) {
|
|
57
|
+
let raw;
|
|
58
|
+
try { raw = fs.readFileSync(DRAIN_PATH, 'utf8'); } catch { return false; } // no flag → not draining
|
|
59
|
+
const ts = Number(raw);
|
|
60
|
+
return Number.isFinite(ts) ? ts >= sinceMs : true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
STATUS_PATH, DRAIN_PATH, STATUS_FRESH_MS,
|
|
65
|
+
writeStatus, readStatus, liveStatus, requestDrain, clearDrain, drainRequested, drainRequestedSince,
|
|
66
|
+
};
|
package/src/worker.js
CHANGED
|
@@ -12,6 +12,7 @@ const { Bridge, withRetry } = require('./bridge');
|
|
|
12
12
|
const { getAdapter } = require('./adapters');
|
|
13
13
|
const { detectRuntimes } = require('./detect');
|
|
14
14
|
const { P2P } = require('./transport/p2p');
|
|
15
|
+
const control = require('./control');
|
|
15
16
|
|
|
16
17
|
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
17
18
|
|
|
@@ -22,9 +23,23 @@ const JOB_MAX_MS = Number(process.env.CLAWHOST_JOB_MAX_MS) || 15 * 60 * 1000;
|
|
|
22
23
|
// Offer outputs larger than this over P2P (must match the engine's TRANSPORT_MIN_BYTES).
|
|
23
24
|
// Lowered to 1 KiB for validation so a normal reply triggers P2P; raise for production.
|
|
24
25
|
const TRANSPORT_MIN_BYTES = Number(process.env.CLAWHOST_TRANSPORT_MIN) || 1024;
|
|
26
|
+
// On a stop signal, how long to let in-flight jobs finish before exiting anyway (a 2nd signal
|
|
27
|
+
// forces immediate exit). The graceful-update path pre-drains to idle, so a restart's signal
|
|
28
|
+
// finds the worker idle and exits at once; this only bounds a direct stop/restart/Ctrl-C.
|
|
29
|
+
const SHUTDOWN_DRAIN_MS = Number(process.env.CLAWHOST_SHUTDOWN_DRAIN_MS) || JOB_MAX_MS;
|
|
25
30
|
|
|
26
31
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
32
|
let stopping = false;
|
|
33
|
+
// In-flight job ids across ALL agent loops — the single source of truth for "is this host busy?".
|
|
34
|
+
// Drives the status file (so `clhost update` can wait for idle) and the graceful-shutdown drain.
|
|
35
|
+
const activeJobIds = new Set();
|
|
36
|
+
// Claim RPCs currently outstanding (across all loops). Counted as "busy" for their whole round-trip
|
|
37
|
+
// so a drain that lands MID-claim can't see a false-idle window and let the CLI restart on top of a
|
|
38
|
+
// job that's about to start running. Kept in lockstep with activeJobIds (no await between add+decr).
|
|
39
|
+
let claimsInFlight = 0;
|
|
40
|
+
// When a drain is in effect (SIGTERM, or the CLI's drain flag) the loops stop claiming NEW jobs
|
|
41
|
+
// but let in-flight ones finish. Cache the flag check so many agents don't stat the file every poll.
|
|
42
|
+
let drainFlag = false;
|
|
28
43
|
|
|
29
44
|
// Replace ⟦clawlink:transport:CID⟧ markers in an inbound job's content with the actual upstream
|
|
30
45
|
// bytes — fetched P2P from the producing host, falling back to the inline DB copy. Mutates job.content.
|
|
@@ -146,26 +161,35 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
|
|
|
146
161
|
const now = Date.now();
|
|
147
162
|
while (starts.length && now - starts[0] > 60000) starts.shift();
|
|
148
163
|
|
|
149
|
-
|
|
164
|
+
// Draining (graceful update/shutdown): keep finishing in-flight jobs, but don't claim new ones.
|
|
165
|
+
const draining = stopping || drainFlag;
|
|
166
|
+
const canRun = !draining && inFlight < agentCfg.concurrency && starts.length < agentCfg.rate_limit_per_min;
|
|
150
167
|
if (canRun) {
|
|
151
168
|
let job = null;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
claimsInFlight++; // "busy" for the whole claim round-trip — closes the mid-claim drain race
|
|
170
|
+
try {
|
|
171
|
+
try { const r = await bridge.claim(); job = r && r.job; }
|
|
172
|
+
catch (e) { logger.warn(`claim failed (${agentCfg.agent_id}): ${e.message}`); }
|
|
173
|
+
if (job) {
|
|
174
|
+
inFlight++; starts.push(Date.now()); activeJobIds.add(job.id);
|
|
175
|
+
// Watchdog: guarantees the slot is reclaimed and the job is failed back even
|
|
176
|
+
// if the adapter never settles (no per-adapter timeout by default).
|
|
177
|
+
const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), JOB_MAX_MS).unref());
|
|
178
|
+
Promise.race([processJob(bridge, agentCfg, job, p2p), watchdog])
|
|
179
|
+
.then(async (r) => {
|
|
180
|
+
if (r === '__watchdog__') {
|
|
181
|
+
logger.error(`job ${job.id} exceeded ${JOB_MAX_MS}ms — reclaiming slot`);
|
|
182
|
+
try { await bridge.fail(job.id, `host watchdog: job exceeded ${JOB_MAX_MS}ms`); } catch { /* ignore */ }
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
.finally(() => { inFlight--; activeJobIds.delete(job.id); });
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
// Decrement AFTER activeJobIds.add above (no await between them), so a claimed job hands off
|
|
189
|
+
// from claimsInFlight to activeJobIds without the busy signal ever dipping to false.
|
|
190
|
+
claimsInFlight--;
|
|
168
191
|
}
|
|
192
|
+
if (job) continue; // filled a slot — try to fill remaining concurrency immediately
|
|
169
193
|
}
|
|
170
194
|
await sleep(cfg.poll_interval_ms || 1500);
|
|
171
195
|
}
|
|
@@ -193,12 +217,43 @@ async function startWorker() {
|
|
|
193
217
|
? `H2H transport: native P2P active (${p2pAddr})`
|
|
194
218
|
: 'H2H transport: central relay (no native core for this platform — install acceleration later)');
|
|
195
219
|
|
|
220
|
+
// Advertise liveness + in-flight count so `clhost update`/`restart` can wait for this host to go
|
|
221
|
+
// idle before restarting, and pick up the CLI's drain flag (stop claiming, finish current work).
|
|
222
|
+
// Clear any stale flag first; and only ever honor a flag written AFTER this boot, so a leftover
|
|
223
|
+
// flag from a crashed update (or a clearDrain that failed) can never wedge a fresh worker.
|
|
224
|
+
control.clearDrain(); // drop any stale flag left by a previous life FIRST…
|
|
225
|
+
const bootAt = Date.now(); // …then anchor "now": only a drain requested after this point is honored.
|
|
226
|
+
const pushStatus = () => {
|
|
227
|
+
drainFlag = control.drainRequestedSince(bootAt);
|
|
228
|
+
const busy = activeJobIds.size > 0 || claimsInFlight > 0;
|
|
229
|
+
control.writeStatus({
|
|
230
|
+
pid: process.pid, version: VERSION, agents: agents.length,
|
|
231
|
+
activeJobs: activeJobIds.size, busy, drain: drainFlag,
|
|
232
|
+
currentJobId: activeJobIds.size ? [...activeJobIds][0] : null,
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
pushStatus();
|
|
236
|
+
const statusTimer = setInterval(pushStatus, 2000);
|
|
237
|
+
statusTimer.unref();
|
|
238
|
+
|
|
239
|
+
// Graceful shutdown: stop claiming, let in-flight jobs finish (keeping P2P alive for them), then
|
|
240
|
+
// exit. A second signal forces an immediate exit; SHUTDOWN_DRAIN_MS bounds the wait.
|
|
241
|
+
let stopSignals = 0;
|
|
196
242
|
const onStop = (sig) => {
|
|
197
|
-
|
|
243
|
+
stopSignals += 1;
|
|
244
|
+
if (stopSignals >= 2) { logger.info(`${sig} again — exiting now (in-flight work abandoned).`); process.exit(0); }
|
|
198
245
|
stopping = true;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
246
|
+
const n = activeJobIds.size;
|
|
247
|
+
logger.info(n
|
|
248
|
+
? `${sig} received — finishing ${n} in-flight job(s) before exit (send again to force)…`
|
|
249
|
+
: `${sig} received — exiting.`);
|
|
250
|
+
const startedAt = Date.now();
|
|
251
|
+
const finish = () => { try { p2p.close(); } catch { /* ignore */ } process.exit(0); };
|
|
252
|
+
const tick = setInterval(() => {
|
|
253
|
+
if (activeJobIds.size === 0) { clearInterval(tick); logger.info('drained — exiting.'); finish(); }
|
|
254
|
+
else if (Date.now() - startedAt > SHUTDOWN_DRAIN_MS) { clearInterval(tick); logger.info('drain timed out — exiting.'); finish(); }
|
|
255
|
+
}, 500);
|
|
256
|
+
tick.unref();
|
|
202
257
|
};
|
|
203
258
|
process.on('SIGINT', () => onStop('SIGINT'));
|
|
204
259
|
process.on('SIGTERM', () => onStop('SIGTERM'));
|