ccdeck 3.22.8 → 3.22.9
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/README.md +26 -26
- package/bin/agent-dag.js +8 -2
- package/bin/deck.js +90 -62
- package/dist/web/assets/{index-BJnVANQi.css → index-CSxMSfQm.css} +1 -1
- package/dist/web/assets/index-ZxHH_c65.js +274 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -3
- package/src/server/boot-lock.mjs +137 -0
- package/src/server/installer.mjs +2 -2
- package/src/server/invoked-as.mjs +4 -2
- package/src/server/lan-sync.mjs +21 -1
- package/src/server/login-service.mjs +24 -0
- package/src/server/running-deck.mjs +110 -101
- package/src/server/self-update.mjs +70 -36
- package/dist/web/assets/index-BjQA8le6.js +0 -274
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-ZxHH_c65.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CSxMSfQm.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.22.
|
|
3
|
+
"version": "3.22.9",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"agents-deck": "bin/agent-dag.js",
|
|
8
|
-
"agent-dag": "bin/agent-dag.js",
|
|
9
7
|
"ccdeck": "bin/agent-dag.js"
|
|
10
8
|
},
|
|
11
9
|
"files": [
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Two starts at the same moment used to become two decks.
|
|
2
|
+
//
|
|
3
|
+
// THE GAP. A start asks the registry "is one of my decks already up?", and a
|
|
4
|
+
// deck can answer that only once it has written <config>/agent-dag/<pid>.json —
|
|
5
|
+
// which happens after its server has bound and its startup report has run, up
|
|
6
|
+
// to eight seconds later (BOOT_DEADLINE_MS). Two starts inside that window both
|
|
7
|
+
// read an empty registry, both start, and the second finds 4317 taken and takes
|
|
8
|
+
// a random port. The login item and a terminal opened at login are exactly two
|
|
9
|
+
// starts inside one window, and that is how one machine came to show up twice
|
|
10
|
+
// on a colleague's Local network list.
|
|
11
|
+
//
|
|
12
|
+
// So asking and starting become one step: a start takes this lock before it
|
|
13
|
+
// asks, and gives it back only once its own record is on disk. A second start
|
|
14
|
+
// inside the window waits for the first to register, and then finds it.
|
|
15
|
+
//
|
|
16
|
+
// A FILE, CREATED EXCLUSIVELY. `open(…, "wx")` is O_CREAT|O_EXCL on POSIX and
|
|
17
|
+
// CREATE_NEW on Windows: one process wins and everybody else gets EEXIST, on all
|
|
18
|
+
// three platforms and without a native module. It is held for seconds, not for
|
|
19
|
+
// the life of the deck — once a deck is registered the registry and its token
|
|
20
|
+
// handshake already answer "is a deck up", and a lock held forever would need
|
|
21
|
+
// that liveness proof all over again.
|
|
22
|
+
//
|
|
23
|
+
// A HOLDER THAT DIED IS NOT A HOLDER. A start killed inside its boot window
|
|
24
|
+
// leaves the file behind, and the next start must not wait on it forever. Two
|
|
25
|
+
// ways out, and each covers the other's blind spot: the holder's pid no longer
|
|
26
|
+
// answers (a crash), or the lock is older than any boot takes (a pid the OS has
|
|
27
|
+
// recycled into something else, which answers signal 0 forever — #695).
|
|
28
|
+
//
|
|
29
|
+
// One race is left and it is named rather than hidden: two starts that both
|
|
30
|
+
// judge the SAME dead lock stale can interleave so that the second removes the
|
|
31
|
+
// lock the first has just taken. That needs three starts inside one boot window
|
|
32
|
+
// right after a crash, and what it costs is the behaviour every version before
|
|
33
|
+
// this one had.
|
|
34
|
+
//
|
|
35
|
+
// UNGUARDED RATHER THAN STUCK. A directory this process cannot write — a
|
|
36
|
+
// read-only home, a permissions mistake — is answered by starting without the
|
|
37
|
+
// lock, which is also what every earlier version did. Refusing to start a deck
|
|
38
|
+
// over a lock file would be a worse bug than the one this fixes.
|
|
39
|
+
import * as nodeFs from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { randomBytes } from "node:crypto";
|
|
42
|
+
import { isProcessAlive } from "./deck-probe.mjs";
|
|
43
|
+
|
|
44
|
+
/** Beside the registry records, and not a `.json`: hook/hook.js and the stale
|
|
45
|
+
* sweep read only `*.json` in that directory, so neither ever sees it. */
|
|
46
|
+
export const BOOT_LOCK_FILE = "boot.lock";
|
|
47
|
+
|
|
48
|
+
/** Longer than any boot. BOOT_DEADLINE_MS bounds the report at 8s, and a start
|
|
49
|
+
* that replaces a running deck spends up to three stop rungs on top of it. */
|
|
50
|
+
export const BOOT_LOCK_STALE_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
/** An empty lock is one being written this instant — unless it has looked like
|
|
53
|
+
* that for longer than a write of forty bytes can take. */
|
|
54
|
+
const HALF_WRITTEN_MS = 2_000;
|
|
55
|
+
|
|
56
|
+
function readHolder(file, fs) {
|
|
57
|
+
try {
|
|
58
|
+
const d = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
59
|
+
return Number.isInteger(d?.pid) && Number.isFinite(d?.at) ? d : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function modifiedAt(file, fs) {
|
|
66
|
+
try { return fs.statSync(file).mtimeMs; } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Has whoever holds this lock stopped being able to let go of it?
|
|
71
|
+
*
|
|
72
|
+
* `self` is here for the recycled-pid case pointed the other way: a lock left by
|
|
73
|
+
* a dead process whose pid this one now has answers signal 0 — it is us — and
|
|
74
|
+
* would be waited on until it went stale.
|
|
75
|
+
*/
|
|
76
|
+
export function holderIsGone(holder, { now, alive, staleMs = BOOT_LOCK_STALE_MS, mtime = null, self = process.pid }) {
|
|
77
|
+
if (!holder) return mtime == null || now - mtime > HALF_WRITTEN_MS;
|
|
78
|
+
if (holder.pid === self) return true;
|
|
79
|
+
if (now - holder.at > staleMs) return true;
|
|
80
|
+
return !alive(holder.pid);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Give the lock back, if it is still ours. A lock judged stale and taken by
|
|
84
|
+
* somebody else carries their nonce now, and removing it would hand the gap
|
|
85
|
+
* straight back to a third start. Idempotent: the second call finds nothing. */
|
|
86
|
+
function release(file, nonce, fs) {
|
|
87
|
+
try {
|
|
88
|
+
if (JSON.parse(fs.readFileSync(file, "utf8"))?.nonce !== nonce) return false;
|
|
89
|
+
fs.unlinkSync(file);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Take the boot lock for this registry directory, waiting for a live holder to
|
|
98
|
+
* finish booting.
|
|
99
|
+
*
|
|
100
|
+
* Resolves `{ held, waited, release }`. `held` is false only on the unguarded
|
|
101
|
+
* path; `waited` says another start was in its boot window when this one began,
|
|
102
|
+
* which the caller has no use for beyond the tests. `release` is synchronous so
|
|
103
|
+
* it can run from a process `exit` handler.
|
|
104
|
+
*/
|
|
105
|
+
export async function takeBootLock({
|
|
106
|
+
dir,
|
|
107
|
+
pid = process.pid,
|
|
108
|
+
now = Date.now,
|
|
109
|
+
alive = isProcessAlive,
|
|
110
|
+
staleMs = BOOT_LOCK_STALE_MS,
|
|
111
|
+
pollMs = 100,
|
|
112
|
+
fs = nodeFs,
|
|
113
|
+
sleep = (ms) => new Promise(r => setTimeout(r, ms)),
|
|
114
|
+
} = {}) {
|
|
115
|
+
const file = join(dir, BOOT_LOCK_FILE);
|
|
116
|
+
const nonce = randomBytes(8).toString("hex");
|
|
117
|
+
let waited = false;
|
|
118
|
+
// 0700 like the registry beside it — installer.mjs asserts the same mode on
|
|
119
|
+
// the same directory, and whichever runs first creates it.
|
|
120
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* the open below decides */ }
|
|
121
|
+
for (;;) {
|
|
122
|
+
try {
|
|
123
|
+
const fd = fs.openSync(file, "wx", 0o600);
|
|
124
|
+
try { fs.writeSync(fd, JSON.stringify({ pid, at: now(), nonce })); } finally { fs.closeSync(fd); }
|
|
125
|
+
return { held: true, waited, release: () => release(file, nonce, fs) };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err?.code !== "EEXIST") return { held: false, waited, reason: err?.code ?? "open_failed", release: () => false };
|
|
128
|
+
}
|
|
129
|
+
const holder = readHolder(file, fs);
|
|
130
|
+
if (holderIsGone(holder, { now: now(), alive, staleMs, mtime: modifiedAt(file, fs), self: pid })) {
|
|
131
|
+
try { fs.unlinkSync(file); } catch { /* another start cleared it first */ }
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
waited = true;
|
|
135
|
+
await sleep(pollMs);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/server/installer.mjs
CHANGED
|
@@ -750,8 +750,8 @@ export async function writeDiscovery({ port, workspace, token, persist = null, c
|
|
|
750
750
|
// that when the deck on the port is the deck it would itself have built. A
|
|
751
751
|
// `--no-claude` deck has no hooks, no accounts panel and no switcher, and
|
|
752
752
|
// until this field existed it was indistinguishable from one that has all
|
|
753
|
-
// three. An older deck has no such field, so it
|
|
754
|
-
//
|
|
753
|
+
// three. An older deck has no such field, so it never passes for one and is
|
|
754
|
+
// replaced instead — see secondStart.
|
|
755
755
|
claude: claude !== false,
|
|
756
756
|
// What this deck IS, so a launcher that attaches can say whether the deck
|
|
757
757
|
// it found is the version the user just asked for. Never a decision: a
|
|
@@ -59,8 +59,10 @@ import { bareSpecName, isNpxInstall, npxRestartSpec } from "./self-update.mjs";
|
|
|
59
59
|
* invoked-as.test.ts pins them against each other rather than assuming it. */
|
|
60
60
|
export const PREFERRED = "ccdeck";
|
|
61
61
|
|
|
62
|
-
/** Every
|
|
63
|
-
*
|
|
62
|
+
/** Every name somebody can have typed to reach this build. A global install
|
|
63
|
+
* provides only `ccdeck` now — package.json's `bin` block — but `npx
|
|
64
|
+
* agents-deck` and `npx agent-dag` still reach it through the old packages,
|
|
65
|
+
* which depend on it, and those are the runs the notice below is for. */
|
|
64
66
|
export const COMMANDS = [PREFERRED, "agents-deck", "agent-dag"];
|
|
65
67
|
|
|
66
68
|
/** `value` when it is one of those three, and null for everything else — a shim
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -441,7 +441,27 @@ export function inviteProof(code, transcript) {
|
|
|
441
441
|
* still reads as two machines when the hostnames differ, which they do.
|
|
442
442
|
*/
|
|
443
443
|
export function hostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
|
|
444
|
-
return createHash("sha256").update(`${hostname}\u0000${home}`).digest("hex").slice(0, 12);
|
|
444
|
+
return createHash("sha256").update(`${machineName(hostname)}\u0000${home}`).digest("hex").slice(0, 12);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The part of a hostname that names the machine, rather than the network it is
|
|
449
|
+
* on at the moment.
|
|
450
|
+
*
|
|
451
|
+
* macOS answers `os.hostname()` with `Petrus-MacBook-Pro.local` at one moment
|
|
452
|
+
* and `Petrus-MacBook-Pro` at another, and a DHCP server can hang its own
|
|
453
|
+
* domain on the end — `.lan`, `.home`, `.fritz.box`. hostId hashed whatever it
|
|
454
|
+
* was handed, so two decks on one computer started either side of such a change
|
|
455
|
+
* disagreed about which machine they were on. Sharing a key, each read the other
|
|
456
|
+
* as an `id-clash` — the copied-~/.claude case beaconVerdict heals — and one of
|
|
457
|
+
* them took a new key: one machine, two fingerprints, two rows on every
|
|
458
|
+
* colleague's panel. The first label, case folded, is the name the machine
|
|
459
|
+
* keeps through all of that. An address used as a hostname is kept whole,
|
|
460
|
+
* since its first label is only the first octet.
|
|
461
|
+
*/
|
|
462
|
+
export function machineName(hostname) {
|
|
463
|
+
const h = String(hostname ?? "").trim().toLowerCase().replace(/\.$/, "");
|
|
464
|
+
return /^\d+(\.\d+){3}$/.test(h) ? h : h.split(".")[0];
|
|
445
465
|
}
|
|
446
466
|
|
|
447
467
|
export function beaconPayload({ name, fp, port, instance, host }) {
|
|
@@ -314,6 +314,30 @@ export function lingerState({ platform = process.platform, run = spawnSync, user
|
|
|
314
314
|
} catch { return "unknown"; }
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The variables that decide WHICH deck a start is, for the login item's job.
|
|
319
|
+
*
|
|
320
|
+
* A login item runs in the environment the service manager builds, not the
|
|
321
|
+
* shell's. Somebody who exports CLAUDE_CONFIG_DIR, CCDECK_HOME or CODEX_HOME got
|
|
322
|
+
* a deck keyed to those directories from the terminal and one keyed to the
|
|
323
|
+
* defaults at login: two registries that cannot see each other, so two decks
|
|
324
|
+
* and two LAN keys on one machine — the one-deck rule in running-deck.mjs holds
|
|
325
|
+
* per registry and cannot reach across. Carried into the job so both starts are
|
|
326
|
+
* the same deck. Only the ones that are set: an unset variable written into the
|
|
327
|
+
* job as "" would be a different setting, not the same absent one.
|
|
328
|
+
*
|
|
329
|
+
* Windows is not given these. A Task Scheduler job has no environment block in
|
|
330
|
+
* its XML; it inherits the user's persistent variables, which is where a
|
|
331
|
+
* Windows user sets them (`setx`) for them to outlive the shell anyway.
|
|
332
|
+
*/
|
|
333
|
+
export const SCOPE_VARS = Object.freeze(["CLAUDE_CONFIG_DIR", "CCDECK_HOME", "CODEX_HOME"]);
|
|
334
|
+
|
|
335
|
+
export function scopeEnv(env = process.env) {
|
|
336
|
+
return Object.fromEntries(
|
|
337
|
+
SCOPE_VARS.filter(k => typeof env?.[k] === "string" && env[k].trim() !== "").map(k => [k, env[k]]),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
317
341
|
/**
|
|
318
342
|
* Write the login item and register it.
|
|
319
343
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Why a second `ccdeck` must
|
|
1
|
+
// Why a second `ccdeck` must never leave a second deck behind it.
|
|
2
2
|
//
|
|
3
3
|
// THE DEFECT. `startServer` is handed `portRange: [4318, 4400]` and answers a
|
|
4
4
|
// refused bind on 4317 by taking a random port out of it. That fallback is
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// THE QUESTION IS NOT "IS THE PORT FREE". It is "is one of MY decks already
|
|
17
17
|
// running", and the registry answers that without touching a port: every deck
|
|
18
|
-
// writes
|
|
18
|
+
// writes <config>/agent-dag/<pid>.json with its pid, port, token and shape.
|
|
19
19
|
//
|
|
20
20
|
// A PID IS NOT EVIDENCE, and that is #695's whole lesson. A record left behind
|
|
21
21
|
// by a deck that is gone — SIGKILL, an OOM kill, a power cut, a console window
|
|
@@ -28,12 +28,26 @@
|
|
|
28
28
|
// seen. A collector, a container, a stranger on a recycled port cannot answer.
|
|
29
29
|
// The deck that wrote the file can.
|
|
30
30
|
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
31
|
+
// ONE DECK, WHATEVER THE SHAPE. The first answer to "a deck is up" attached
|
|
32
|
+
// only to a deck that was exactly the one this start would have built, and
|
|
33
|
+
// built a second one beside anything else. So a flag, an older version, or an
|
|
34
|
+
// environment that decided `codex` or the log path differently — a login item
|
|
35
|
+
// runs under the service manager's environment, not the shell's — each left
|
|
36
|
+
// two decks running on one machine, with two LAN keys and two rows on every
|
|
37
|
+
// colleague's panel. It was reported from the field exactly that way. Now a
|
|
38
|
+
// start keeps at most one:
|
|
39
|
+
//
|
|
40
|
+
// the deck found serves what this start asked for, and is not older → attach
|
|
41
|
+
// anything else → stop it, start
|
|
42
|
+
//
|
|
43
|
+
// The newest start wins, because it is the one somebody just asked for. The
|
|
44
|
+
// rule is secondStart, a function of what is running and what was asked, so it
|
|
45
|
+
// can be pinned without starting a process.
|
|
46
|
+
//
|
|
47
|
+
// PER CLAUDE CONFIG DIRECTORY, which is where the registry lives. For nearly
|
|
48
|
+
// everybody that is one per user; somebody who points CLAUDE_CONFIG_DIR at a
|
|
49
|
+
// second directory has a second registry, and has been getting a second deck
|
|
50
|
+
// with its own identity on purpose since deck-home.mjs was written.
|
|
37
51
|
//
|
|
38
52
|
// WHAT THIS FILE DOES NOT IMPORT: src/server/index.mjs. That module is the whole
|
|
39
53
|
// server, and it arms its timers the moment it is loaded — but this one is read
|
|
@@ -63,67 +77,86 @@ export function deckRegistryDir(env = process.env, home = undefined) {
|
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|
|
66
|
-
*
|
|
67
|
-
* deck".
|
|
68
|
-
*
|
|
69
|
-
* Every one of them changes what the deck IS — which sessions it captures,
|
|
70
|
-
* which log it appends to, which of the two CLIs it serves, which port it
|
|
71
|
-
* binds. A command line carrying any of them is a request that an existing deck
|
|
72
|
-
* cannot satisfy, so it is never answered by attaching to one.
|
|
80
|
+
* Would this record's deck serve what we were about to build?
|
|
73
81
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
82
|
+
* Compared field for field rather than by a version or a heuristic. `claude`
|
|
83
|
+
* and `codex` are strict identity against a boolean, so a record written before
|
|
84
|
+
* either field existed carries `undefined` and fails — and a deck that old is
|
|
85
|
+
* replaced rather than attached to, which is what a start does with any deck
|
|
86
|
+
* it cannot vouch for.
|
|
77
87
|
*/
|
|
78
|
-
export
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
export function sameShape(record, want = {}) {
|
|
89
|
+
if (!record) return false;
|
|
90
|
+
return (record.workspace ?? "") === (want.workspace ?? "")
|
|
91
|
+
&& (record.persist ?? null) === (want.persist ?? null)
|
|
92
|
+
&& record.codex === (want.codex !== false)
|
|
93
|
+
&& record.claude === (want.claude !== false);
|
|
94
|
+
}
|
|
82
95
|
|
|
83
96
|
/**
|
|
84
|
-
* Is
|
|
97
|
+
* Is `running` a version older than `ours`?
|
|
85
98
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
* Numeric, field by field, because "3.9.0" sorts after "3.22.0" as a string. A
|
|
100
|
+
* running deck that publishes no version at all predates the field, which makes
|
|
101
|
+
* it older than anything that does. Our own version unreadable is the one case
|
|
102
|
+
* with nothing to compare, and it answers no: a start that cannot say what it
|
|
103
|
+
* is has no grounds to replace anything.
|
|
104
|
+
*/
|
|
105
|
+
export function olderVersion(running, ours) {
|
|
106
|
+
const parse = (v) => {
|
|
107
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? ""));
|
|
108
|
+
return m ? m.slice(1).map(Number) : null;
|
|
109
|
+
};
|
|
110
|
+
const o = parse(ours);
|
|
111
|
+
if (!o) return false;
|
|
112
|
+
const r = parse(running);
|
|
113
|
+
if (!r) return true;
|
|
114
|
+
for (let i = 0; i < 3; i++) if (r[i] !== o[i]) return r[i] < o[i];
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does this running deck already serve what this start asked for?
|
|
96
120
|
*
|
|
97
|
-
* The
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* on a random port behind it.
|
|
121
|
+
* The shape, the port when one was named, and a version no older than ours.
|
|
122
|
+
* `port` is the one `--port` asked for, or null when none was; 0 is "any free
|
|
123
|
+
* port", which every running deck satisfies.
|
|
101
124
|
*/
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
export function serves(record, { want = {}, port = null, ours = "" } = {}) {
|
|
126
|
+
return sameShape(record, want)
|
|
127
|
+
&& (port == null || port === 0 || record.port === port)
|
|
128
|
+
&& !olderVersion(record.version, ours);
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
/**
|
|
108
|
-
*
|
|
132
|
+
* What a start does about the decks already running.
|
|
109
133
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
134
|
+
* { act: "start", stop: [] } nothing is running
|
|
135
|
+
* { act: "attach", deck, stop } open `deck`; stop the rest — leftovers
|
|
136
|
+
* from before this rule
|
|
137
|
+
* { act: "replace", stop } stop every one of them, then start
|
|
138
|
+
* { act: "yield", deck, stop: [] } a respawn found somebody else's deck
|
|
139
|
+
*
|
|
140
|
+
* `fresh` is `--new`: replace even a deck that would have served. `respawn` is
|
|
141
|
+
* the supervisor bringing a crashed or restarted worker back — that is THIS
|
|
142
|
+
* deck returning, so anything already running got there in the gap, was asked
|
|
143
|
+
* for more recently, and keeps its place.
|
|
144
|
+
*
|
|
145
|
+
* The deck kept on an attach is the first that serves, in the registry's port
|
|
146
|
+
* order, so the answer is the same every time it is asked.
|
|
115
147
|
*/
|
|
116
|
-
export function
|
|
117
|
-
if (!
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
export function secondStart({ live = [], want = {}, port = null, ours = "", fresh = false, respawn = false } = {}) {
|
|
149
|
+
if (!live.length) return { act: "start", stop: [] };
|
|
150
|
+
if (respawn) return { act: "yield", deck: live[0], stop: [] };
|
|
151
|
+
const keep = fresh ? null : live.find(d => serves(d, { want, port, ours })) ?? null;
|
|
152
|
+
if (keep) return { act: "attach", deck: keep, stop: live.filter(d => d !== keep) };
|
|
153
|
+
return { act: "replace", stop: [...live] };
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
/** A record complete enough to be worth a challenge. A missing token is a deck
|
|
125
|
-
* older than the handshake: it cannot prove anything, so it
|
|
126
|
-
* to, and it keeps the behaviour it has always
|
|
157
|
+
* older than the handshake: it cannot prove anything, so it can be neither
|
|
158
|
+
* attached to nor stopped by token, and it keeps the behaviour it has always
|
|
159
|
+
* had. */
|
|
127
160
|
function usable(d) {
|
|
128
161
|
return Boolean(d)
|
|
129
162
|
&& Number.isInteger(d.pid)
|
|
@@ -134,13 +167,13 @@ function usable(d) {
|
|
|
134
167
|
/**
|
|
135
168
|
* Every registered deck whose pid is still there, ordered.
|
|
136
169
|
*
|
|
137
|
-
* NOT PROVED — this is the cheap half, a directory listing and a signal-0 each
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* electWriters uses for the log and is here for the same reason: several
|
|
142
|
-
* can qualify, and the answer has to be the same one every time it is
|
|
143
|
-
* rather than whatever `readdir` happened to return first.
|
|
170
|
+
* NOT PROVED — this is the cheap half, a directory listing and a signal-0 each.
|
|
171
|
+
* `detach.mjs` wants only the count, to decide whether deck.log belongs to a
|
|
172
|
+
* deck that is still running or to nobody; a round trip per record to answer
|
|
173
|
+
* that would be absurd. Ordered by port with pid breaking the tie, which is the
|
|
174
|
+
* rule electWriters uses for the log and is here for the same reason: several
|
|
175
|
+
* decks can qualify, and the answer has to be the same one every time it is
|
|
176
|
+
* asked rather than whatever `readdir` happened to return first.
|
|
144
177
|
*
|
|
145
178
|
* A failure to read the directory is "no decks", not an error. This runs on the
|
|
146
179
|
* boot path of a program whose job is to start, and there is no reading of that
|
|
@@ -167,39 +200,16 @@ export async function registeredDecks({
|
|
|
167
200
|
return out.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
168
201
|
}
|
|
169
202
|
|
|
170
|
-
/**
|
|
171
|
-
* The deck already serving what this process was about to serve, or null.
|
|
172
|
-
*
|
|
173
|
-
* Only records that already match the shape are challenged, and the walk stops
|
|
174
|
-
* at the first that proves itself. On the ordinary machine that is one loopback
|
|
175
|
-
* round trip; on a machine with no deck running it is a directory listing and
|
|
176
|
-
* nothing else. That is why this is not `liveDecks().find(…)`: the boot path
|
|
177
|
-
* must not pay a round trip per deck to answer a question the first one settles.
|
|
178
|
-
*/
|
|
179
|
-
export async function runningDeck({
|
|
180
|
-
want = {},
|
|
181
|
-
dir = deckRegistryDir(),
|
|
182
|
-
fs = { readdir, readFile },
|
|
183
|
-
self = process.pid,
|
|
184
|
-
alive = isProcessAlive,
|
|
185
|
-
prove = challengeDeck,
|
|
186
|
-
} = {}) {
|
|
187
|
-
for (const d of await registeredDecks({ dir, fs, self, alive })) {
|
|
188
|
-
if (!sameShape(d, want)) continue;
|
|
189
|
-
if (await prove(d.port, d.token)) return d;
|
|
190
|
-
}
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
203
|
/**
|
|
195
204
|
* Every deck on this machine that answered a challenge, in port order.
|
|
196
205
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
206
|
+
* What a start decides by, the list `--status` prints and the list `--stop`
|
|
207
|
+
* ends. Everything is challenged: a start that is about to stop a deck must
|
|
208
|
+
* know it is one, and a list that quietly omitted a deck it could not be
|
|
209
|
+
* bothered to ask about would be worse than no list, because the whole reason
|
|
210
|
+
* to run `--status` is to find the process you did not know was there. The
|
|
211
|
+
* round trips go out together — one deadline for the lot, not one after
|
|
212
|
+
* another — since they are independent and each is bounded at 400ms.
|
|
203
213
|
*/
|
|
204
214
|
export async function liveDecks({
|
|
205
215
|
dir = deckRegistryDir(),
|
|
@@ -216,19 +226,18 @@ export async function liveDecks({
|
|
|
216
226
|
/**
|
|
217
227
|
* What to say about the version of the deck we attached to.
|
|
218
228
|
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* they are attached and TOLD, with the one sentence that gets them the version
|
|
224
|
-
* they asked for.
|
|
229
|
+
* An attach only ever keeps a deck at least as new as the one launched — an
|
|
230
|
+
* older one is replaced — so a difference here is somebody running an older
|
|
231
|
+
* copy, an npx cache or a second install, beside a newer deck. That deck is
|
|
232
|
+
* kept and they are told, because the newer one is what they would have wanted.
|
|
225
233
|
*
|
|
226
|
-
* Empty when the versions agree, and empty when
|
|
227
|
-
* report one: "unknown" beside a number is noise
|
|
228
|
-
* to do about it either way.
|
|
234
|
+
* Empty when the versions agree, and empty when either side is too old to
|
|
235
|
+
* report one: "unknown" beside a number is noise.
|
|
229
236
|
*/
|
|
230
237
|
export function versionNote(running, ours) {
|
|
231
238
|
if (typeof running !== "string" || running === "" || typeof ours !== "string" || ours === "") return "";
|
|
232
239
|
if (running === ours) return "";
|
|
233
|
-
return
|
|
240
|
+
return olderVersion(running, ours)
|
|
241
|
+
? `running v${running}, older than the v${ours} you launched`
|
|
242
|
+
: `running v${running}, newer than the v${ours} you launched — kept it`;
|
|
234
243
|
}
|