skydive-cli 0.5.0-beta.9 → 0.6.0-beta.3
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/CHANGELOG.md +13 -0
- package/README.md +61 -13
- package/dist/js/api-BFQ4PQDA.mjs +315 -0
- package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
- package/dist/js/bin.mjs +674 -307
- package/dist/js/{boot-Q-Kh3nn5.mjs → boot-CleHADz9.mjs} +4558 -930
- package/dist/js/chunk-BbwQpWto.mjs +33 -0
- package/dist/js/{client-c4c5MmgN.mjs → client-Btq6bMzX.mjs} +108 -2
- package/dist/js/{client-DabRpc_T.mjs → client-CErCpXl9.mjs} +437 -39
- package/dist/js/client-qY4nTj2Y.mjs +5 -0
- package/dist/js/daemon-BuTGs-Ns.mjs +7 -0
- package/dist/js/{daemon-D21wQ7DI.mjs → daemon-Cuy4S6Rw.mjs} +238 -50
- package/dist/js/daemon-client-BVX-rWfX.mjs +8 -0
- package/dist/js/{daemon-client-DPUNjhBB.mjs → daemon-client-b10uck-j.mjs} +1 -1
- package/dist/js/dist-CRtjM7ba.mjs +1750 -0
- package/dist/js/forward-C-f04uyE.mjs +208 -0
- package/dist/js/{profiler-BkCV__ao.mjs → install-D7MwRv0n.mjs} +545 -215
- package/dist/js/launcher.mjs +49 -0
- package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
- package/dist/js/{print-BpuyEfWX.mjs → print-B3yhPm0N.mjs} +257 -35
- package/dist/js/{print-Wakr3GJd.mjs → print-VulYMc7x.mjs} +3 -3
- package/dist/js/{print-share-CKLPmsg0.mjs → print-share-DfTHn1HL.mjs} +9 -3
- package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
- package/dist/js/{raw-pty-DY4KelZW.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
- package/dist/js/rest-CRRpVyot.mjs +6 -0
- package/dist/js/{rest-I3imNduB.mjs → rest-ChXt8YGA.mjs} +144 -19
- package/dist/js/tls-cert-BpCaD5AT.mjs +4 -0
- package/dist/js/tls-cert-Rua2oV7n.mjs +67 -0
- package/package.json +14 -6
- package/dist/js/api-DG5W6iwx.mjs +0 -131
- package/dist/js/client-BuU34IVE.mjs +0 -5
- package/dist/js/daemon-LSDSvMaC.mjs +0 -6
- package/dist/js/daemon-client-CUSq-Wuh.mjs +0 -7
- package/dist/js/forward-18QoL5dO.mjs +0 -68
- package/dist/js/raw-pty-DmdUf4_w.mjs +0 -5
- package/dist/js/rest-D29qNkto.mjs +0 -6
- /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
- /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
- /package/dist/js/{output-DYzzdXYV.mjs → output-C9mb3sUB.mjs} +0 -0
|
@@ -1,50 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { i as isRecord, n as isNewerBuild, r as portalDaemonBuild, t as PortalClient } from "./client-CErCpXl9.mjs";
|
|
3
|
+
import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { spawn } from "node:child_process";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
8
9
|
import { connect, createServer } from "node:net";
|
|
9
|
-
import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { access, appendFile, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
10
11
|
|
|
11
|
-
//#region ../portal-daemon/src/build-stamp.ts
|
|
12
|
-
/**
|
|
13
|
-
* This build's stamp: the unix commit time (seconds) of the source it was
|
|
14
|
-
* compiled from. Empty string when unstamped (a dev source run without the
|
|
15
|
-
* env override).
|
|
16
|
-
*
|
|
17
|
-
* Why a commit time and not a package version: the daemon ships inside two
|
|
18
|
-
* independently-versioned installers (the skydive CLI and the desktop app),
|
|
19
|
-
* so their semvers are not comparable — but both build from this repo, so
|
|
20
|
-
* the commit time of the built tree is one monotonic clock they share.
|
|
21
|
-
*/
|
|
22
|
-
function portalDaemonBuild() {
|
|
23
|
-
return "1786597531";
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Whether a client carrying `mine` should replace a running daemon carrying
|
|
27
|
-
* `theirs` (newest build wins):
|
|
28
|
-
*
|
|
29
|
-
* - an unstamped client never takes over — it can't prove it's newer;
|
|
30
|
-
* - a stamped client replaces an unstamped daemon — every stamped build
|
|
31
|
-
* postdates stamping, so the unstamped daemon is older by construction;
|
|
32
|
-
* - otherwise strictly greater wins; equal keeps the incumbent, so two
|
|
33
|
-
* identical builds never bounce the daemon between them.
|
|
34
|
-
*/
|
|
35
|
-
function isNewerBuild(mine, theirs) {
|
|
36
|
-
const mineAt = parseStamp(mine);
|
|
37
|
-
if (mineAt === null) return false;
|
|
38
|
-
const theirsAt = parseStamp(theirs);
|
|
39
|
-
if (theirsAt === null) return true;
|
|
40
|
-
return mineAt > theirsAt;
|
|
41
|
-
}
|
|
42
|
-
function parseStamp(stamp) {
|
|
43
|
-
if (!/^[0-9]+$/.test(stamp)) return null;
|
|
44
|
-
return Number(stamp);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
//#endregion
|
|
48
12
|
//#region ../portal-daemon/src/local-protocol.ts
|
|
49
13
|
/**
|
|
50
14
|
* Local IPC between the portal DAEMON and the `skydive` CLI processes attached
|
|
@@ -219,6 +183,20 @@ const clientStatusSchema = z.object({ t: z.literal("status") });
|
|
|
219
183
|
* exits — cutting live exec/tunnel connections, not just refusing new ones.
|
|
220
184
|
*/
|
|
221
185
|
const clientShutdownSchema = z.object({ t: z.literal("shutdown") });
|
|
186
|
+
/**
|
|
187
|
+
* `shutdown_handoff` — a newer daemon taking over (self-update / newest-build-
|
|
188
|
+
* wins) asks the incumbent to step down WITHOUT dropping the portal connection
|
|
189
|
+
* first. The incumbent frees the control socket and disconnects attached CLIs
|
|
190
|
+
* (they reconnect to the new daemon in ~500ms, unchanged), but KEEPS its
|
|
191
|
+
* outbound portal WebSocket — and thus its presence claim — alive until the
|
|
192
|
+
* new daemon has dialled out and claimed the machine's presence slot, at which
|
|
193
|
+
* point the server supersedes the incumbent's socket and it exits. This is a
|
|
194
|
+
* make-before-break handoff: the presence key is owned by one daemon or the
|
|
195
|
+
* other for the entire takeover, so `exec`/`file write` never sees a
|
|
196
|
+
* `registered but not connected` gap mid-upgrade. A bounded fallback timeout
|
|
197
|
+
* ensures the incumbent still exits if the successor never comes up.
|
|
198
|
+
*/
|
|
199
|
+
const clientShutdownHandoffSchema = z.object({ t: z.literal("shutdown_handoff") });
|
|
222
200
|
const clientMessageSchema = z.discriminatedUnion("t", [
|
|
223
201
|
clientHelloSchema,
|
|
224
202
|
clientBindSchema,
|
|
@@ -229,7 +207,8 @@ const clientMessageSchema = z.discriminatedUnion("t", [
|
|
|
229
207
|
clientDeclineSchema,
|
|
230
208
|
clientByeSchema,
|
|
231
209
|
clientStatusSchema,
|
|
232
|
-
clientShutdownSchema
|
|
210
|
+
clientShutdownSchema,
|
|
211
|
+
clientShutdownHandoffSchema
|
|
233
212
|
]);
|
|
234
213
|
/**
|
|
235
214
|
* `state` — the shared portal status, pushed to every attached client so each
|
|
@@ -356,6 +335,22 @@ function parseDaemonMessage(line) {
|
|
|
356
335
|
*/
|
|
357
336
|
/** Grace period after the last client detaches before the daemon exits. */
|
|
358
337
|
const IDLE_SHUTDOWN_MS = 3e4;
|
|
338
|
+
/**
|
|
339
|
+
* Cadence of the socket-ownership watch (see checkSocketOwnership). The
|
|
340
|
+
* singleton is enforced ONLY through the socket path, so this bounds how long
|
|
341
|
+
* a daemon can keep dialing after losing the path — the window in which a
|
|
342
|
+
* spawner can create a duplicate dialer. The server-side connect flap guard
|
|
343
|
+
* caps the damage inside that window.
|
|
344
|
+
*/
|
|
345
|
+
const SOCKET_WATCH_TICK_MS = 3e4;
|
|
346
|
+
/**
|
|
347
|
+
* Upper bound an incumbent daemon waits, during a graceful handoff, for the
|
|
348
|
+
* successor to dial out and claim presence before giving up and releasing the
|
|
349
|
+
* slot itself. Comfortably above a normal successor boot + WS dial (a few
|
|
350
|
+
* hundred ms to a couple of seconds), and short enough that a failed successor
|
|
351
|
+
* doesn't strand the machine as reachable-but-dead for long.
|
|
352
|
+
*/
|
|
353
|
+
const HANDOFF_MAX_WAIT_MS = 1e4;
|
|
359
354
|
var PortalDaemon = class {
|
|
360
355
|
appUrl;
|
|
361
356
|
paths;
|
|
@@ -365,21 +360,30 @@ var PortalDaemon = class {
|
|
|
365
360
|
conns = /* @__PURE__ */ new Set();
|
|
366
361
|
cwds = /* @__PURE__ */ new Map();
|
|
367
362
|
declined = /* @__PURE__ */ new Set();
|
|
363
|
+
persistedMachineName = null;
|
|
368
364
|
fallbackCwd;
|
|
369
365
|
idleTimer = null;
|
|
366
|
+
handingOff = false;
|
|
370
367
|
sessionToken = null;
|
|
371
368
|
deviceToken = null;
|
|
372
|
-
|
|
369
|
+
socketWatch = null;
|
|
370
|
+
socketId = null;
|
|
371
|
+
socketCheckRunning = false;
|
|
372
|
+
socketWatchTickMs;
|
|
373
|
+
exit;
|
|
374
|
+
constructor(appUrl, options) {
|
|
373
375
|
this.appUrl = canonicalPortalAppUrl(appUrl);
|
|
374
376
|
this.paths = daemonPaths(this.appUrl);
|
|
375
377
|
this.fallbackCwd = process.env.HOME ?? process.cwd();
|
|
378
|
+
this.socketWatchTickMs = options?.socketWatchTickMs ?? SOCKET_WATCH_TICK_MS;
|
|
379
|
+
this.exit = options?.exit ?? (() => process.exit(0));
|
|
376
380
|
}
|
|
377
381
|
/** Start listening. Rejects if the socket is already held by another daemon. */
|
|
378
382
|
async listen() {
|
|
379
383
|
await mkdir(this.paths.dir, { recursive: true });
|
|
380
384
|
await this.loadState();
|
|
381
385
|
await this.clearStaleSocket();
|
|
382
|
-
|
|
386
|
+
await new Promise((resolve, reject) => {
|
|
383
387
|
const server = createServer((socket) => this.onClientConnect(socket));
|
|
384
388
|
server.on("error", reject);
|
|
385
389
|
server.listen(this.paths.socketPath, () => {
|
|
@@ -388,6 +392,102 @@ var PortalDaemon = class {
|
|
|
388
392
|
resolve();
|
|
389
393
|
});
|
|
390
394
|
});
|
|
395
|
+
await this.recordSocketIdentity();
|
|
396
|
+
this.startSocketWatch();
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Remember which socket FILE we bound (dev+ino), so the watch can tell "our
|
|
400
|
+
* file is still there" from "someone re-created the path" — the path alone
|
|
401
|
+
* can't: a duplicate daemon's fresh socket lives at the identical path.
|
|
402
|
+
*/
|
|
403
|
+
async recordSocketIdentity() {
|
|
404
|
+
const s = await stat(this.paths.socketPath);
|
|
405
|
+
this.socketId = {
|
|
406
|
+
dev: s.dev,
|
|
407
|
+
ino: s.ino
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The singleton is enforced ONLY through the socket path — status, stop, and
|
|
412
|
+
* newest-build takeover all reach a daemon by connecting to it. If the file
|
|
413
|
+
* vanishes under us (the OS tmp cleaner purges /var/folders temp items that
|
|
414
|
+
* haven't been touched in days; the daemon holds the bound fd and never
|
|
415
|
+
* notices), this daemon keeps its portal connection and its attached clients
|
|
416
|
+
* but becomes unreachable and unmanageable: every new `skydive` spawn finds
|
|
417
|
+
* no socket and starts a DUPLICATE daemon, and the two fight a supersede war
|
|
418
|
+
* over the machine's presence slot (the 2026-08-25 flap loop). So the daemon
|
|
419
|
+
* re-verifies its claim on a timer: re-bind if the path is free, stand down
|
|
420
|
+
* if another daemon beat us to it.
|
|
421
|
+
*/
|
|
422
|
+
startSocketWatch() {
|
|
423
|
+
this.socketWatch = setInterval(() => {
|
|
424
|
+
if (this.socketCheckRunning) return;
|
|
425
|
+
this.socketCheckRunning = true;
|
|
426
|
+
this.checkSocketOwnership().catch((error) => this.logError("socket ownership check failed", error)).finally(() => {
|
|
427
|
+
this.socketCheckRunning = false;
|
|
428
|
+
});
|
|
429
|
+
}, this.socketWatchTickMs);
|
|
430
|
+
this.socketWatch.unref();
|
|
431
|
+
}
|
|
432
|
+
stopSocketWatch() {
|
|
433
|
+
if (this.socketWatch) {
|
|
434
|
+
clearInterval(this.socketWatch);
|
|
435
|
+
this.socketWatch = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async checkSocketOwnership() {
|
|
439
|
+
if (!this.server || this.handingOff) return;
|
|
440
|
+
const current = await stat(this.paths.socketPath).catch(() => null);
|
|
441
|
+
if (current && this.socketId && current.dev === this.socketId.dev && current.ino === this.socketId.ino) return;
|
|
442
|
+
if (current) {
|
|
443
|
+
this.logInfo("control socket path is owned by another daemon; standing down");
|
|
444
|
+
this.standDown();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
await this.rebindSocket();
|
|
449
|
+
this.logInfo("control socket file vanished (tmp cleaner?); re-bound");
|
|
450
|
+
} catch (error) {
|
|
451
|
+
this.logError("control socket re-bind failed; standing down", error);
|
|
452
|
+
this.standDown();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Re-create the socket file at our path. The old server object keeps serving
|
|
457
|
+
* its ESTABLISHED connections (close() only stops accepting), so attached
|
|
458
|
+
* clients ride through the swap; only the listener moves to the new file.
|
|
459
|
+
*/
|
|
460
|
+
async rebindSocket() {
|
|
461
|
+
const old = this.server;
|
|
462
|
+
this.server = null;
|
|
463
|
+
old?.close();
|
|
464
|
+
await mkdir(this.paths.dir, { recursive: true });
|
|
465
|
+
await new Promise((resolve, reject) => {
|
|
466
|
+
const server = createServer((socket) => this.onClientConnect(socket));
|
|
467
|
+
server.on("error", reject);
|
|
468
|
+
server.listen(this.paths.socketPath, () => {
|
|
469
|
+
this.server = server;
|
|
470
|
+
resolve();
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
await this.recordSocketIdentity();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* This daemon lost the singleton claim: tear everything down and exit.
|
|
477
|
+
* Attached clients see their control socket close and re-attach through the
|
|
478
|
+
* path — reaching whichever daemon owns it now (or spawning a fresh one) —
|
|
479
|
+
* so the machine converges on a single dialer instead of a supersede war.
|
|
480
|
+
*/
|
|
481
|
+
standDown() {
|
|
482
|
+
this.stopSocketWatch();
|
|
483
|
+
for (const conn of this.conns) try {
|
|
484
|
+
conn.socket.destroy();
|
|
485
|
+
} catch (_error) {}
|
|
486
|
+
this.conns.clear();
|
|
487
|
+
this.client?.dispose();
|
|
488
|
+
this.server?.close();
|
|
489
|
+
this.server = null;
|
|
490
|
+
this.exit();
|
|
391
491
|
}
|
|
392
492
|
async clearStaleSocket() {
|
|
393
493
|
if (!await pathExists(this.paths.socketPath)) return;
|
|
@@ -476,6 +576,9 @@ var PortalDaemon = class {
|
|
|
476
576
|
case "shutdown":
|
|
477
577
|
this.forceShutdown();
|
|
478
578
|
return;
|
|
579
|
+
case "shutdown_handoff":
|
|
580
|
+
this.gracefulHandoffShutdown();
|
|
581
|
+
return;
|
|
479
582
|
default: return msg;
|
|
480
583
|
}
|
|
481
584
|
}
|
|
@@ -499,12 +602,29 @@ var PortalDaemon = class {
|
|
|
499
602
|
deviceToken: this.deviceToken
|
|
500
603
|
}),
|
|
501
604
|
resolveCwd: (conversationId) => this.resolveCwd(conversationId),
|
|
605
|
+
tlsCertSource: defaultTlsCertSource(process.env, (msg) => this.logInfo(msg)),
|
|
606
|
+
persistedMachineName: this.persistedMachineName,
|
|
607
|
+
onMachineName: (name, source) => this.onMachineName(name, source),
|
|
502
608
|
onState: (state) => {
|
|
503
609
|
this.lastState = state;
|
|
504
610
|
this.broadcastState();
|
|
505
611
|
}
|
|
506
612
|
});
|
|
507
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* The identity resolved on connect. Persist the name so a later scutil
|
|
616
|
+
* failure reuses it instead of adopting the volatile hostname, and log the
|
|
617
|
+
* source — a `hostname-fallback` after we'd previously registered under
|
|
618
|
+
* scutil is the tell that a flap just orphaned this machine's grants.
|
|
619
|
+
*/
|
|
620
|
+
onMachineName(name, source) {
|
|
621
|
+
if (source === "hostname-fallback" && this.persistedMachineName) this.logInfo(`portal identity WARNING: fell back to hostname "${name}" but had previously registered as "${this.persistedMachineName}" — grants may be orphaned`);
|
|
622
|
+
else this.logInfo(`portal identity: "${name}" (source=${source})`);
|
|
623
|
+
if (name && name !== this.persistedMachineName) {
|
|
624
|
+
this.persistedMachineName = name;
|
|
625
|
+
this.persistState();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
508
628
|
/** The cwd an exec for `conversationId` runs in. */
|
|
509
629
|
resolveCwd(conversationId) {
|
|
510
630
|
if (conversationId) {
|
|
@@ -538,21 +658,30 @@ var PortalDaemon = class {
|
|
|
538
658
|
/** Append a line to the daemon log file (best-effort, for post-hoc debugging). */
|
|
539
659
|
logError(context, error) {
|
|
540
660
|
const message = error instanceof Error ? error.message : String(error);
|
|
541
|
-
|
|
661
|
+
this.logLine(`${context}: ${message}`);
|
|
662
|
+
}
|
|
663
|
+
/** Append an informational line to the daemon log file (best-effort). */
|
|
664
|
+
logInfo(message) {
|
|
665
|
+
this.logLine(message);
|
|
666
|
+
}
|
|
667
|
+
logLine(message) {
|
|
668
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${message}\n`;
|
|
542
669
|
appendFile(this.paths.logPath, line).catch((_error) => {});
|
|
543
670
|
}
|
|
544
671
|
/** Close the listener without exiting the process (tests own the process). */
|
|
545
672
|
stopForTest() {
|
|
546
673
|
this.clearIdleTimer();
|
|
674
|
+
this.stopSocketWatch();
|
|
547
675
|
this.client?.dispose();
|
|
548
676
|
this.server?.close();
|
|
549
677
|
this.server = null;
|
|
550
678
|
}
|
|
551
679
|
shutdown() {
|
|
552
680
|
if (this.conns.size > 0) return;
|
|
681
|
+
this.stopSocketWatch();
|
|
553
682
|
this.client?.dispose();
|
|
554
683
|
this.server?.close();
|
|
555
|
-
|
|
684
|
+
this.exit();
|
|
556
685
|
}
|
|
557
686
|
/** Snapshot for a `status` control request. */
|
|
558
687
|
statusResult() {
|
|
@@ -581,13 +710,39 @@ var PortalDaemon = class {
|
|
|
581
710
|
* their live exec/tunnel relays), drops the portal connection, and exits.
|
|
582
711
|
*/
|
|
583
712
|
forceShutdown() {
|
|
713
|
+
this.standDown();
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Graceful takeover step-down (a newer daemon is replacing us). Unlike
|
|
717
|
+
* `forceShutdown`, this does NOT drop the portal connection up front: doing so
|
|
718
|
+
* would let this machine's presence key expire (10s TTL) before the successor
|
|
719
|
+
* dials out and re-claims it, which is the `registered but not connected` gap
|
|
720
|
+
* that made `exec`/`file write` fail mid-self-update.
|
|
721
|
+
*
|
|
722
|
+
* Order matters. We free the control socket and disconnect attached CLIs
|
|
723
|
+
* first, because the successor cannot bind the singleton socket (and thus
|
|
724
|
+
* cannot start its own portal client) until we release it. But we KEEP our
|
|
725
|
+
* outbound portal WebSocket — and its presence claim — alive across that
|
|
726
|
+
* window. When the successor connects and `claim()`s the slot, the server
|
|
727
|
+
* supersedes us and closes our socket; `beginHandoff` resolves on that close
|
|
728
|
+
* and we exit. A bounded fallback inside `beginHandoff` still exits us if the
|
|
729
|
+
* successor never comes up, so we never hold a dead slot forever.
|
|
730
|
+
*/
|
|
731
|
+
async gracefulHandoffShutdown() {
|
|
732
|
+
if (this.handingOff) return;
|
|
733
|
+
this.handingOff = true;
|
|
734
|
+
this.stopSocketWatch();
|
|
584
735
|
for (const conn of this.conns) try {
|
|
585
736
|
conn.socket.destroy();
|
|
586
737
|
} catch (_error) {}
|
|
587
738
|
this.conns.clear();
|
|
588
|
-
this.client?.dispose();
|
|
589
739
|
this.server?.close();
|
|
590
|
-
|
|
740
|
+
this.server = null;
|
|
741
|
+
if (this.client) {
|
|
742
|
+
await this.client.beginHandoff(HANDOFF_MAX_WAIT_MS);
|
|
743
|
+
this.client.dispose();
|
|
744
|
+
}
|
|
745
|
+
this.exit();
|
|
591
746
|
}
|
|
592
747
|
async loadState() {
|
|
593
748
|
try {
|
|
@@ -598,6 +753,7 @@ var PortalDaemon = class {
|
|
|
598
753
|
if (Array.isArray(parsed.declined)) {
|
|
599
754
|
for (const id of parsed.declined) if (typeof id === "string") this.declined.add(id);
|
|
600
755
|
}
|
|
756
|
+
if (typeof parsed.machineName === "string" && parsed.machineName) this.persistedMachineName = parsed.machineName;
|
|
601
757
|
}
|
|
602
758
|
} catch (_error) {}
|
|
603
759
|
}
|
|
@@ -605,7 +761,8 @@ var PortalDaemon = class {
|
|
|
605
761
|
const state = {
|
|
606
762
|
version: LOCAL_PROTOCOL_VERSION,
|
|
607
763
|
cwds: Object.fromEntries(this.cwds),
|
|
608
|
-
declined: [...this.declined]
|
|
764
|
+
declined: [...this.declined],
|
|
765
|
+
machineName: this.persistedMachineName ?? void 0
|
|
609
766
|
};
|
|
610
767
|
writeFile(this.paths.statePath, JSON.stringify(state)).catch((error) => {
|
|
611
768
|
this.logError("persistState failed", error);
|
|
@@ -635,7 +792,7 @@ async function ensureDaemonRunning(appUrl) {
|
|
|
635
792
|
if (await isDaemonListening(socketPath)) {
|
|
636
793
|
const status = await queryDaemonStatus(appUrl);
|
|
637
794
|
if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
|
|
638
|
-
if (await
|
|
795
|
+
if (await stopDaemonForHandoff(appUrl) === "failed") return;
|
|
639
796
|
}
|
|
640
797
|
const entry = process.argv[1];
|
|
641
798
|
const args = entry ? [
|
|
@@ -669,7 +826,7 @@ function runPortalDaemon(argv) {
|
|
|
669
826
|
* CLI's re-exec entry above and the standalone `main.ts` entry.
|
|
670
827
|
*/
|
|
671
828
|
function startPortalDaemon(appUrl) {
|
|
672
|
-
return new PortalDaemon(appUrl).listen();
|
|
829
|
+
return new PortalDaemon(appUrl, null).listen();
|
|
673
830
|
}
|
|
674
831
|
async function isDaemonListening(socketPath) {
|
|
675
832
|
if (!await pathExists(socketPath)) return false;
|
|
@@ -730,6 +887,37 @@ async function queryDaemonStatus(appUrl) {
|
|
|
730
887
|
});
|
|
731
888
|
}
|
|
732
889
|
/**
|
|
890
|
+
* Ask a running incumbent daemon to step down for a make-before-break handoff
|
|
891
|
+
* (see the daemon's `gracefulHandoffShutdown`). Sends `shutdown_handoff` and
|
|
892
|
+
* waits for the incumbent to release the CONTROL SOCKET — at which point the
|
|
893
|
+
* successor can bind it and start its own portal client. Crucially, the
|
|
894
|
+
* incumbent keeps its portal connection (and presence) alive past this point,
|
|
895
|
+
* so the successor's subsequent `claim()` supersedes it with no presence gap.
|
|
896
|
+
*
|
|
897
|
+
* Returns `stopped` once the socket is free, `failed` if it never freed (the
|
|
898
|
+
* caller should then leave the incumbent alone rather than fight it — same
|
|
899
|
+
* fallback semantics as `stopDaemon`), or `not-running` if nothing was there.
|
|
900
|
+
* Unlike `stopDaemon` this never SIGKILLs: the incumbent is intentionally still
|
|
901
|
+
* alive (holding presence) after it frees the socket, so killing it by pid is
|
|
902
|
+
* exactly the gap this path exists to avoid.
|
|
903
|
+
*/
|
|
904
|
+
async function stopDaemonForHandoff(appUrl) {
|
|
905
|
+
const { socketPath } = daemonPaths(appUrl);
|
|
906
|
+
if (!await isDaemonListening(socketPath)) return "not-running";
|
|
907
|
+
const sock = await connectControl(socketPath);
|
|
908
|
+
if (!sock) return "failed";
|
|
909
|
+
sock.write(encodeLine({ t: "shutdown_handoff" }));
|
|
910
|
+
for (let i = 0; i < 50; i += 1) {
|
|
911
|
+
await sleep(100);
|
|
912
|
+
if (!await isDaemonListening(socketPath)) {
|
|
913
|
+
sock.destroy();
|
|
914
|
+
return "stopped";
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
sock.destroy();
|
|
918
|
+
return "failed";
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
733
921
|
* Hard-stop a running daemon. Preferred path: send `shutdown` over the control
|
|
734
922
|
* socket so it drains clients and exits cleanly. If the socket is unresponsive
|
|
735
923
|
* (a wedged daemon), fall back to SIGTERM then SIGKILL by the pid the status
|
|
@@ -781,4 +969,4 @@ function sleep(ms) {
|
|
|
781
969
|
}
|
|
782
970
|
|
|
783
971
|
//#endregion
|
|
784
|
-
export { runPortalDaemon as a,
|
|
972
|
+
export { runPortalDaemon as a, stopDaemonForHandoff as c, daemonPaths as d, encodeLine as f, queryDaemonStatus as i, LOCAL_PROTOCOL_VERSION as l, parseDaemonMessage as m, ensureDaemonRunning as n, startPortalDaemon as o, makeLineParser as p, isDaemonListening as r, stopDaemon as s, PortalDaemon as t, PORTAL_DAEMON_FLAG as u };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-Cuy4S6Rw.mjs";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
|