flowviant 0.53.0 → 0.54.1
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 +25 -31
- package/bin/cli.mjs +54 -22
- package/bin/lib/config.mjs +0 -21
- package/bin/lib/fleet.mjs +187 -50
- package/bin/lib/git.mjs +1 -1
- package/bin/lib/instance.mjs +691 -17
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/mcp-cli.mjs +12 -11
- package/bin/lib/preflight.mjs +8 -5
- package/bin/lib/runtimes.mjs +12 -3
- package/bin/lib/stream.mjs +10 -8
- package/bin/lib/update.mjs +3 -2
- package/bin/lib/work.mjs +38 -2
- package/package.json +1 -1
package/bin/lib/instance.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ONE DAEMON PER
|
|
2
|
+
* ONE DAEMON PER REPO (and per credential), ARBITRATED at startup — a second
|
|
3
|
+
* run in the same repo takes the first one's place rather than being turned
|
|
4
|
+
* away. See "WHAT A SECOND RUN DOES" below for the whole rule.
|
|
3
5
|
*
|
|
4
|
-
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server
|
|
5
|
-
* work out by READING, never claiming: `listWorkTurnJobs`
|
|
6
|
-
* turn for the
|
|
7
|
-
* credential
|
|
8
|
-
* connected daemon socket at once, so they
|
|
6
|
+
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server USED to
|
|
7
|
+
* hand work out by READING, never claiming: `listWorkTurnJobs` selected every
|
|
8
|
+
* pending turn for the machine credential, `listShipJobs` read a flag. So two
|
|
9
|
+
* daemons on one credential were offered the SAME turn — and the ProjectRoom
|
|
10
|
+
* nudges every connected daemon socket at once, so they did not even drift out
|
|
11
|
+
* of phase.
|
|
12
|
+
*
|
|
13
|
+
* That half is fixed on the server now: since 0.53.0 each SESSION is leased to
|
|
14
|
+
* one daemon INSTANCE nonce — `di`, regenerated every start (config.mjs) and
|
|
15
|
+
* sent on every poll beside `ws`, the list of sessions this daemon holds a
|
|
16
|
+
* worktree for — so a turn is handed to the instance holding that session and
|
|
17
|
+
* to no one else. It does NOT retire this lock. The lease fails OPEN when no
|
|
18
|
+
* instance is reported (an older daemon cannot name itself), and it arbitrates
|
|
19
|
+
* only what rides a session: the wiki sweep, env materialization, previews,
|
|
20
|
+
* deploys and every worktree operation the server never sees are still first
|
|
21
|
+
* come, first served.
|
|
9
22
|
*
|
|
10
23
|
* The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
|
|
11
24
|
* AFTER the work token is minted and the attachments are fetched — a window
|
|
@@ -14,27 +27,70 @@
|
|
|
14
27
|
* (its own comment says so, work.mjs), where the holder is already live when
|
|
15
28
|
* the successor looks; it was never a concurrency primitive.
|
|
16
29
|
*
|
|
17
|
-
* What
|
|
18
|
-
* editing one worktree, two cards from one `file_card` (no
|
|
19
|
-
* the session write budget spent twice, quota spent twice —
|
|
20
|
-
* ONE answer
|
|
21
|
-
*
|
|
30
|
+
* What a duplicate run cost before the session lease, all of it invisible in
|
|
31
|
+
* the tab: two Claudes editing one worktree, two cards from one `file_card` (no
|
|
32
|
+
* idempotency key), the session write budget spent twice, quota spent twice —
|
|
33
|
+
* and then exactly ONE answer surviving, because `settleWorkTurn` is atomic.
|
|
34
|
+
* The side effects landed twice and the transcript showed one turn. Two daemons
|
|
35
|
+
* in one checkout still cost the un-leased half of that: two `git fetch`, two
|
|
36
|
+
* worktree sweeps, and the collisions listed under ONE DAEMON PER REPO below.
|
|
22
37
|
*
|
|
23
|
-
* KEYED ON THE CREDENTIAL,
|
|
38
|
+
* KEYED ON THE CREDENTIAL — and, as the next paragraph adds, on the REPO as
|
|
39
|
+
* well; both checks run, and either one is enough. The credential is stored once, at
|
|
24
40
|
* ~/.flowviant/credentials.json, so `flowviant` in two DIFFERENT checkouts is
|
|
25
41
|
* still one project served twice — and that case is strictly worse, because the
|
|
26
42
|
* two daemons have different worktree roots and the turn lock cannot even see
|
|
27
43
|
* across them. Keying on the token catches both, and still lets a second
|
|
28
44
|
* credential run a second project on the same machine.
|
|
29
45
|
*
|
|
46
|
+
* ...AND ONE DAEMON PER REPO, which is NOT the same statement. The lock above
|
|
47
|
+
* is keyed on the credential, and the two coincide only while one credential
|
|
48
|
+
* serves one project — which is the product's law but not a thing this file can
|
|
49
|
+
* assume. Two DIFFERENT credentials pointing at one checkout both acquired
|
|
50
|
+
* happily (measured), giving two daemons in one working tree: two `git fetch`,
|
|
51
|
+
* two worktree sweeps, `retireWorkSessions` in one removing directories the
|
|
52
|
+
* other is serving, and a ship in one racing a rebase in the other. No server
|
|
53
|
+
* lease can arbitrate any of that, because the server never sees a directory.
|
|
54
|
+
* So the repo is checked too, across every credential's lock.
|
|
55
|
+
*
|
|
56
|
+
* WHAT A SECOND RUN DOES, and this is the whole rule:
|
|
57
|
+
*
|
|
58
|
+
* SAME REPO -> the new run WINS. The holder is asked to stand down and
|
|
59
|
+
* this daemon takes its place. Re-running `flowviant` in a
|
|
60
|
+
* directory you are working in means "serve this repo", and
|
|
61
|
+
* the process already serving it is by definition the one
|
|
62
|
+
* you are replacing. That is a restart, and a restart
|
|
63
|
+
* should not require you to go and find a pid.
|
|
64
|
+
*
|
|
65
|
+
* DIFFERENT REPO -> REFUSED, and nothing is signalled. That daemon is serving
|
|
66
|
+
* other work; killing it because you happened to run this
|
|
67
|
+
* command elsewhere is not a restart, it is collateral.
|
|
68
|
+
* `--takeover` overrides, deliberately explicitly.
|
|
69
|
+
*
|
|
70
|
+
* One rule, and it is the invariant stated as behaviour: one daemon per repo.
|
|
71
|
+
* `--no-takeover` (or FLOWVIANT_NO_TAKEOVER=1) makes even the same-repo case
|
|
72
|
+
* refuse, for anyone who wants the old ceremony.
|
|
73
|
+
*
|
|
30
74
|
* IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
|
|
31
75
|
* to start; it is a reason to say so and carry on unguarded.
|
|
32
76
|
*/
|
|
33
77
|
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
78
|
+
import { execFileSync } from 'node:child_process';
|
|
79
|
+
import {
|
|
80
|
+
closeSync,
|
|
81
|
+
mkdirSync,
|
|
82
|
+
openSync,
|
|
83
|
+
readdirSync,
|
|
84
|
+
readFileSync,
|
|
85
|
+
realpathSync,
|
|
86
|
+
rmSync,
|
|
87
|
+
writeFileSync,
|
|
88
|
+
writeSync,
|
|
89
|
+
} from 'node:fs';
|
|
90
|
+
import { homedir, platform } from 'node:os';
|
|
36
91
|
import { join } from 'node:path';
|
|
37
92
|
import { createHash } from 'node:crypto';
|
|
93
|
+
import { VERSION } from './config.mjs';
|
|
38
94
|
|
|
39
95
|
/** Deliberately a HASH: a credential must never become a filename. */
|
|
40
96
|
export function instanceLockPath(fleetToken) {
|
|
@@ -42,6 +98,20 @@ export function instanceLockPath(fleetToken) {
|
|
|
42
98
|
return join(homedir(), '.flowviant', `daemon-${key}.lock`);
|
|
43
99
|
}
|
|
44
100
|
|
|
101
|
+
/** Numeric dotted compare, -1/0/1. Unparsable compares EQUAL, so a version we
|
|
102
|
+
* cannot read never silently authorises a downgrade. */
|
|
103
|
+
function cmpVersion(a, b) {
|
|
104
|
+
const x = String(a).split('.').map((n) => Number.parseInt(n, 10));
|
|
105
|
+
const y = String(b).split('.').map((n) => Number.parseInt(n, 10));
|
|
106
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
107
|
+
const p = x[i] ?? 0;
|
|
108
|
+
const q = y[i] ?? 0;
|
|
109
|
+
if (Number.isNaN(p) || Number.isNaN(q)) return 0;
|
|
110
|
+
if (p !== q) return p > q ? 1 : -1;
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
45
115
|
/** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
|
|
46
116
|
function alive(pid) {
|
|
47
117
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -63,7 +133,450 @@ function readHolder(path) {
|
|
|
63
133
|
}
|
|
64
134
|
|
|
65
135
|
const record = (repoRoot) =>
|
|
66
|
-
JSON.stringify({
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
pid: process.pid,
|
|
138
|
+
repoRoot,
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
// The script we were started from, and what we are. A takeover matches the
|
|
141
|
+
// live command line against `entry` before signalling anything — a lock
|
|
142
|
+
// records a PID, and a crashed daemon's PID can be reused by anything.
|
|
143
|
+
// Locks written before this field existed (0.51.2 through 0.53.0) are
|
|
144
|
+
// matched on the holder's process START TIME instead; stillTheHolder says
|
|
145
|
+
// why that is the weaker of the two claims and still strong enough.
|
|
146
|
+
entry: process.argv[1] || '',
|
|
147
|
+
version: VERSION,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** Same directory, whatever it is spelled as — symlinks and trailing slashes
|
|
151
|
+
* included. A repo compared by string would let `/repo` and `/repo/` past. */
|
|
152
|
+
function samePath(a, b) {
|
|
153
|
+
if (!a || !b) return false;
|
|
154
|
+
const norm = (v) => {
|
|
155
|
+
try {
|
|
156
|
+
return realpathSync(v);
|
|
157
|
+
} catch {
|
|
158
|
+
return String(v).replace(/\/+$/, '');
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
return norm(a) === norm(b);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A LIVE daemon in this same checkout, under a DIFFERENT credential.
|
|
166
|
+
*
|
|
167
|
+
* The lock above cannot see one: it is keyed on the credential, so a second
|
|
168
|
+
* token in the same directory opens its own file and takes it. Every other
|
|
169
|
+
* lock file on this machine is ours to read, so read them.
|
|
170
|
+
*
|
|
171
|
+
* Returns the holder, or null. A stale file never blocks — it is cleared by
|
|
172
|
+
* whichever acquire owns it, and blocking on a corpse would be worse than the
|
|
173
|
+
* thing this prevents.
|
|
174
|
+
*/
|
|
175
|
+
/** Which lock file a neighbour holder was read from — takeOverFrom waits on it. */
|
|
176
|
+
const NEIGHBOUR_PATHS = new WeakMap();
|
|
177
|
+
function neighbourLockPath(holder, fallback) {
|
|
178
|
+
return NEIGHBOUR_PATHS.get(holder) ?? fallback;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every daemon lock file on this machine, absolute, sorted for a stable read.
|
|
182
|
+
* ONE scan, because two callers walk this directory for opposite reasons —
|
|
183
|
+
* daemonInSameRepo looking for a neighbour to refuse or replace, stopAllDaemons
|
|
184
|
+
* sweeping the lot — and the FILENAME PATTERN is the thing that must not drift
|
|
185
|
+
* between them: it is what separates our locks from anything else living in
|
|
186
|
+
* ~/.flowviant. An unreadable (or absent) directory is not an error here, it is
|
|
187
|
+
* an empty machine. */
|
|
188
|
+
function lockFiles() {
|
|
189
|
+
try {
|
|
190
|
+
return readdirSync(join(homedir(), '.flowviant'))
|
|
191
|
+
.filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f))
|
|
192
|
+
.sort()
|
|
193
|
+
.map((f) => join(homedir(), '.flowviant', f));
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function daemonInSameRepo(repoRoot, ownPath) {
|
|
200
|
+
for (const path of lockFiles()) {
|
|
201
|
+
if (path === ownPath) continue; // our own credential — the lock above owns that question
|
|
202
|
+
const holder = readHolder(path);
|
|
203
|
+
if (!holder || !alive(holder.pid)) continue;
|
|
204
|
+
if (holder.pid === process.ppid) continue; // ourselves mid self-update re-exec
|
|
205
|
+
if (samePath(holder.repoRoot, repoRoot)) {
|
|
206
|
+
NEIGHBOUR_PATHS.set(holder, path);
|
|
207
|
+
return holder;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How far a holder's actual start may sit BEFORE the `startedAt` line it wrote
|
|
214
|
+
* and still be the same process. Measured on this exact path: node boot to the
|
|
215
|
+
* first line of JS is 20ms, and the whole way through importing this module,
|
|
216
|
+
* `git rev-parse --show-toplevel` and base-ref detection to record() is 29-32ms.
|
|
217
|
+
* The worst realistic run is a start that had to take over a NEIGHBOUR's lock
|
|
218
|
+
* first (a 20s grace, then 600ms) and then its own (another 20s), which lands
|
|
219
|
+
* around 41s. 120s is ~3x that worst path and ~4000x the typical one, and it is
|
|
220
|
+
* still short enough that pid reuse cannot reach into it: reuse means cycling
|
|
221
|
+
* the entire pid space (4194304 by default), which no machine does inside two
|
|
222
|
+
* minutes. */
|
|
223
|
+
const TAKEOVER_START_WINDOW_MS = 120_000;
|
|
224
|
+
|
|
225
|
+
/** ...and how far the OTHER way, which is a unit problem rather than a real
|
|
226
|
+
* possibility. `starttime` is quantised to clock ticks and `ps -o etime=` to
|
|
227
|
+
* whole seconds, so a process that genuinely started a moment before its own
|
|
228
|
+
* lock line can compute a hair after it. 5s covers that granularity and
|
|
229
|
+
* nothing else. The window is deliberately asymmetric and BACKWARD-looking: it
|
|
230
|
+
* brackets the daemon's own startup, not "recently", so a freshly forked
|
|
231
|
+
* impostor that inherited a recycled pid lands on this side and is refused. */
|
|
232
|
+
const TAKEOVER_START_SLACK_MS = 5_000;
|
|
233
|
+
|
|
234
|
+
/** USER_HZ — the unit `/proc` publishes `starttime` in. NOT the kernel's internal
|
|
235
|
+
* CONFIG_HZ (250/300/1000): the kernel converts before writing, and USER_HZ is a
|
|
236
|
+
* fixed ABI constant, 100 on every architecture that matters. So the fallback
|
|
237
|
+
* below is the ABI and not a guess, and `getconf` is only belt and braces.
|
|
238
|
+
*
|
|
239
|
+
* A wrong value here fails SAFE in BOTH directions, which is why it is allowed
|
|
240
|
+
* to be a guess at all: too low and the process computes as far older than its
|
|
241
|
+
* lock (refused by the 120s window), too high and it computes as newer than a
|
|
242
|
+
* lock it supposedly wrote (refused by the 5s slack). */
|
|
243
|
+
function userHz() {
|
|
244
|
+
try {
|
|
245
|
+
const n = Number.parseInt(
|
|
246
|
+
execFileSync('getconf', ['CLK_TCK'], {
|
|
247
|
+
encoding: 'utf8',
|
|
248
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
249
|
+
timeout: 3000,
|
|
250
|
+
}).trim(),
|
|
251
|
+
10,
|
|
252
|
+
);
|
|
253
|
+
if (Number.isInteger(n) && n > 0 && n <= 1_000_000) return n;
|
|
254
|
+
} catch {
|
|
255
|
+
/* no getconf — the constant below IS the ABI */
|
|
256
|
+
}
|
|
257
|
+
return 100;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Wall-clock milliseconds at which `pid` started, or null.
|
|
262
|
+
*
|
|
263
|
+
* NULL IS NOT "UNKNOWN, PROBABLY FINE". Every caller must read it as refuse —
|
|
264
|
+
* this feeds a check that gates a SIGTERM and then a SIGKILL, and there is no
|
|
265
|
+
* such thing as a harmless guess about which process to kill.
|
|
266
|
+
*/
|
|
267
|
+
function processStartedAt(pid) {
|
|
268
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
269
|
+
try {
|
|
270
|
+
if (platform() === 'linux') {
|
|
271
|
+
// FIELD 22 OF /proc/<pid>/stat, and the parse is the whole trap. Field 2
|
|
272
|
+
// is `comm`, which the kernel wraps in parens and which may contain BOTH
|
|
273
|
+
// spaces and parens — it is the first 15 bytes of the executable's name,
|
|
274
|
+
// and a name is not a token. A naive split on whitespace taking $22 then
|
|
275
|
+
// lands on `nice` for any such process, and `nice` is a small integer, so
|
|
276
|
+
// the answer is not a parse error but a plausible-looking "started at
|
|
277
|
+
// boot" that sails past any isFinite guard. Measured: a binary named
|
|
278
|
+
// `ev (i l) x` read 28308s too old that way.
|
|
279
|
+
//
|
|
280
|
+
// Every field after `comm` is a number or a single-character state, so no
|
|
281
|
+
// ')' can appear later: the LAST ')' in the line is always the kernel's
|
|
282
|
+
// own closing paren, even when comm itself ends in one.
|
|
283
|
+
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
284
|
+
const close = raw.lastIndexOf(')');
|
|
285
|
+
if (close < 0) return null;
|
|
286
|
+
const fields = raw.slice(close + 1).trim().split(/\s+/); // fields[0] IS field 3
|
|
287
|
+
const ticks = Number.parseInt(fields[19], 10); // field 22 => 22 - 3
|
|
288
|
+
if (!Number.isFinite(ticks) || ticks < 0) return null;
|
|
289
|
+
// `/proc/uptime` rather than `/proc/stat`'s btime, and not for precision
|
|
290
|
+
// alone (measured 6ms out against 162ms). Date.now() and uptime are read
|
|
291
|
+
// at the same instant, so a realtime clock stepped since boot cancels
|
|
292
|
+
// out of the subtraction; btime bakes in a boot-realtime estimate that a
|
|
293
|
+
// later NTP step silently invalidates. `starttime` is in ticks either
|
|
294
|
+
// way — uptime gives seconds-since-boot, so the division by USER_HZ is
|
|
295
|
+
// not optional.
|
|
296
|
+
const up = Number.parseFloat(readFileSync('/proc/uptime', 'utf8').split(/\s+/)[0]);
|
|
297
|
+
if (!Number.isFinite(up) || up < 0) return null;
|
|
298
|
+
const ageMs = (up - ticks / userHz()) * 1000;
|
|
299
|
+
if (!Number.isFinite(ageMs) || ageMs < 0) return null;
|
|
300
|
+
return Date.now() - ageMs;
|
|
301
|
+
}
|
|
302
|
+
if (platform() === 'darwin') {
|
|
303
|
+
// `etime`, not `lstart`. A DURATION has no locale, no timezone, and no
|
|
304
|
+
// ambiguous repeated hour at the DST fall-back — where `lstart` is an
|
|
305
|
+
// hour out and would refuse a genuine holder twice a year. It also dodges
|
|
306
|
+
// Date.parse being LENIENT rather than strict: a localized `lstart` can
|
|
307
|
+
// parse to a wrong-but-finite instant instead of failing honestly.
|
|
308
|
+
// (`etimes`, the seconds-only form, is procps-only and not a BSD keyword.)
|
|
309
|
+
// Both derive from the same kinfo_proc.p_starttime, so 1s resolution
|
|
310
|
+
// against a 120s window costs nothing. LC_ALL is pinned anyway, since a
|
|
311
|
+
// localized number format would be a wrong answer rather than no answer.
|
|
312
|
+
const out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], {
|
|
313
|
+
encoding: 'utf8',
|
|
314
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
315
|
+
timeout: 3000,
|
|
316
|
+
env: { ...process.env, LC_ALL: 'C', LC_TIME: 'C' },
|
|
317
|
+
});
|
|
318
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(out.trim()); // [[dd-]hh:]mm:ss
|
|
319
|
+
if (!m) return null;
|
|
320
|
+
const age =
|
|
321
|
+
Number(m[1] || 0) * 86400 + Number(m[2] || 0) * 3600 + Number(m[3]) * 60 + Number(m[4]);
|
|
322
|
+
if (!Number.isFinite(age) || age < 0) return null;
|
|
323
|
+
return Date.now() - age * 1000;
|
|
324
|
+
}
|
|
325
|
+
return null; // win32, and anything else — never signalled rather than guessed at
|
|
326
|
+
} catch {
|
|
327
|
+
return null; // no /proc (hidepid=2, a pid namespace, a stripped container), no ps
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* THE FALLBACK, for a lock that carries no `entry`.
|
|
333
|
+
*
|
|
334
|
+
* `entry` arrived after the lock did. Every daemon from 0.51.2 through 0.53.0
|
|
335
|
+
* wrote `{pid, repoRoot, startedAt}` and nothing else, and stillTheHolder used
|
|
336
|
+
* to refuse those outright. Refusing was right in spirit and useless in fact:
|
|
337
|
+
* it made takeover impossible on precisely the locks takeover exists for, since
|
|
338
|
+
* the daemon you are replacing is by definition the OLD one. `--takeover` did
|
|
339
|
+
* nothing, and re-running `flowviant` in the repo you were working in printed a
|
|
340
|
+
* refusal and sent you hunting for a pid — the exact ceremony the same-repo rule
|
|
341
|
+
* was written to abolish. (Those locks carry no `version` either, so a legacy
|
|
342
|
+
* takeover also skips takeOverFrom's downgrade guard, which short-circuits on
|
|
343
|
+
* `holder.version &&`. Separate hole, not this function's to close.)
|
|
344
|
+
*
|
|
345
|
+
* WHY START TIME PROVES IDENTITY, which is the only question worth asking here,
|
|
346
|
+
* because what this gates is a SIGTERM and then a SIGKILL. A pid alone proves
|
|
347
|
+
* nothing: pids are recycled, and a crashed daemon's number goes to whatever
|
|
348
|
+
* forks next. The PAIR (pid, start time) is the standard POSIX process
|
|
349
|
+
* identity — pidfd, systemd and procps all key on it — because the kernel
|
|
350
|
+
* stamps a start time at fork and it is immutable for the life of the process,
|
|
351
|
+
* unforgeable by anything that started later. And `startedAt` was written BY
|
|
352
|
+
* the process being identified, measured 29-32ms after its own fork, so the
|
|
353
|
+
* lock is that process's own witness to when it began.
|
|
354
|
+
*
|
|
355
|
+
* It is a WEAKER statement than `entry`, which says what the process IS rather
|
|
356
|
+
* than when it started, and that is why `entry` stays the primary path. What
|
|
357
|
+
* makes this one acceptable anyway is that a false positive needs two things at
|
|
358
|
+
* once that are close to mutually exclusive: the pid space must have wrapped
|
|
359
|
+
* back to this exact number, AND the new occupant must have started inside a
|
|
360
|
+
* 2-minute window that ENDS at the lock write. Wrapping takes millions of
|
|
361
|
+
* forks; the window ends before the impostor could have been born.
|
|
362
|
+
*
|
|
363
|
+
* EVERY ERROR MODE HERE PUSHES TOWARD REFUSAL, never toward signalling — an
|
|
364
|
+
* unreadable /proc, a pid namespace, a kernel before 5.5 whose starttime drifts
|
|
365
|
+
* across suspend, a realtime clock stepped in either direction, a locale that
|
|
366
|
+
* mangles `ps`, Windows. All of them return false, and false costs a person a
|
|
367
|
+
* manual kill. The other direction costs somebody else's process.
|
|
368
|
+
*/
|
|
369
|
+
function startedAroundLockWrite(holder) {
|
|
370
|
+
try {
|
|
371
|
+
const written = Date.parse(holder?.startedAt ?? '');
|
|
372
|
+
if (!Number.isFinite(written)) return null; // no witness — nothing was measured
|
|
373
|
+
const started = processStartedAt(holder.pid);
|
|
374
|
+
if (started === null) return null; // could not measure — see the tri-state note
|
|
375
|
+
const delta = written - started; // >0: the process predates its own lock line, as it must
|
|
376
|
+
return delta >= -TAKEOVER_START_SLACK_MS && delta <= TAKEOVER_START_WINDOW_MS;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* IS THIS PID STILL THE DAEMON THAT TOOK THE LOCK?
|
|
384
|
+
*
|
|
385
|
+
* `process.kill(pid, 0)` says "a process exists", which is not the same claim,
|
|
386
|
+
* and the difference matters the moment we are about to signal it. Matched on
|
|
387
|
+
* the holder's own recorded ENTRYPOINT, never on the word "flowviant": a
|
|
388
|
+
* command line merely CONTAINING it matches a shell, an editor, or a test
|
|
389
|
+
* runner living under a `…-flowviant/` directory. That last one is not
|
|
390
|
+
* hypothetical — a looser version of this check SIGTERMed one.
|
|
391
|
+
*
|
|
392
|
+
* A lock with no `entry` is no longer refused outright. Those locks are real
|
|
393
|
+
* and current — every daemon 0.51.2 through 0.53.0 wrote one — so refusing
|
|
394
|
+
* them meant takeover never worked on the upgrade it was built for. They fall
|
|
395
|
+
* back to the holder's PROCESS START TIME, which is the same claim made a
|
|
396
|
+
* weaker way; startedAroundLockWrite above argues why that is a proof rather
|
|
397
|
+
* than a guess, and why every way it can go wrong ends in a refusal.
|
|
398
|
+
*
|
|
399
|
+
* TRI-STATE, and the third value is the point: `true` identified, `false`
|
|
400
|
+
* measured-and-it-is-someone-else, `null` COULD NOT MEASURE. Both `false` and
|
|
401
|
+
* `null` refuse — that never changes — but they are not the same sentence, and
|
|
402
|
+
* collapsing them made the refusal assert a fact nobody had established: on a
|
|
403
|
+
* `hidepid=2` host (ordinary Debian/Ubuntu hardening) the daemon told the user
|
|
404
|
+
* their live holder "is no longer the daemon that took this lock", i.e. that
|
|
405
|
+
* the lock was stale. Acting on that — deleting the lock, or taking the
|
|
406
|
+
* ALLOW_MULTI escape printed underneath it — lands them in two daemons in one
|
|
407
|
+
* working tree, which this module's header says no server lease can arbitrate.
|
|
408
|
+
* Ignorance is not a state this product renders as fact.
|
|
409
|
+
*/
|
|
410
|
+
function stillTheHolder(holder) {
|
|
411
|
+
const want = typeof holder?.entry === 'string' ? holder.entry : null;
|
|
412
|
+
// An `entry` of '' is a field with nothing in it — record() writes
|
|
413
|
+
// `process.argv[1] || ''` — and matching on '' would match every process
|
|
414
|
+
// alive, so it takes the same road as a missing one.
|
|
415
|
+
if (!want) return startedAroundLockWrite(holder);
|
|
416
|
+
try {
|
|
417
|
+
if (platform() === 'linux') {
|
|
418
|
+
return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
|
|
419
|
+
}
|
|
420
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
421
|
+
encoding: 'utf8',
|
|
422
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
423
|
+
timeout: 3000,
|
|
424
|
+
}).includes(want);
|
|
425
|
+
} catch {
|
|
426
|
+
// NOT `false`. takeOverFrom already returned early if the pid were gone, so
|
|
427
|
+
// reaching here means the process is alive and we could not READ it —
|
|
428
|
+
// hidepid=2, a pid namespace, a stripped image with no `ps`. Saying `false`
|
|
429
|
+
// here is what made the refusal claim the pid belonged to somebody else.
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Blocking, because this runs before there is an event loop worth yielding to
|
|
435
|
+
* and the caller cannot proceed until it knows whether the holder is gone. */
|
|
436
|
+
const sleep = (ms) => {
|
|
437
|
+
try {
|
|
438
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
439
|
+
} catch {
|
|
440
|
+
/* no SharedArrayBuffer — check again immediately */
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** How long the outgoing daemon gets to stand down cleanly. Its SIGTERM handler
|
|
445
|
+
* kills the CLI children it spawned and stops its preview tunnels; both are
|
|
446
|
+
* why we ask before we insist. */
|
|
447
|
+
const TAKEOVER_GRACE_MS = 20_000;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* THE STAND-DOWN, and it is deliberately ONE copy of this.
|
|
451
|
+
*
|
|
452
|
+
* Two callers perform this identical ritual for different reasons — a takeover
|
|
453
|
+
* (a second run in the same repo replacing the daemon serving it) and
|
|
454
|
+
* `flowviant stop` (a person who does not know what is running clearing the
|
|
455
|
+
* box). What they share is a SIGTERM followed by a SIGKILL, and two hand-copies
|
|
456
|
+
* of a SIGKILL are two places to get the grace period, the zombie trap or the
|
|
457
|
+
* mid-update handover subtly wrong. The IDENTITY gate is NOT in here: this
|
|
458
|
+
* function signals whatever it is handed, so every caller must have proved what
|
|
459
|
+
* the pid is before it calls (see stillTheHolder, and read its tri-state note).
|
|
460
|
+
*
|
|
461
|
+
* SIGTERM FIRST, and not out of politeness: the daemon's handler runs its
|
|
462
|
+
* teardown — it kills the CLI children it spawned and stops its preview
|
|
463
|
+
* tunnels, which are DETACHED and would otherwise keep a public hostname
|
|
464
|
+
* serving a worktree until the box reboots.
|
|
465
|
+
*
|
|
466
|
+
* WAIT ON THE LOCK FILE, not the pid. A departing daemon's release() removes it
|
|
467
|
+
* on exit, so the file changing IS the handover. `kill(pid, 0)` cannot see it:
|
|
468
|
+
* a process that exited but has not been reaped is a ZOMBIE and answers signal
|
|
469
|
+
* 0 exactly like a living one — measured, a peer that exited cleanly still read
|
|
470
|
+
* as alive for the full grace window.
|
|
471
|
+
*
|
|
472
|
+
* And "gone" is NOT "the file stopped naming our pid". It can stop naming it
|
|
473
|
+
* because the holder SELF-UPDATED: update.mjs re-execs and the successor adopts
|
|
474
|
+
* this same lock through the ppid branch. Treating that as free steals a live
|
|
475
|
+
* daemon's lock and leaves it running unguarded — measured doing exactly that.
|
|
476
|
+
*
|
|
477
|
+
* Returns null when the holder is stopped and its lock file is cleared, or
|
|
478
|
+
* `{ failed }` with a sentence the caller can print as-is.
|
|
479
|
+
*/
|
|
480
|
+
function standDown(holder, path, log) {
|
|
481
|
+
// NEVER SIGNAL OURSELVES, and this is not a theoretical guard — it was
|
|
482
|
+
// reproduced end to end. A 0.54.0+ lock records `entry` = the daemon's
|
|
483
|
+
// argv[1], i.e. `…/bin/cli.mjs`. Run `flowviant stop` and OUR cmdline is
|
|
484
|
+
// `node …/bin/cli.mjs stop`, which CONTAINS that string. So if a stale lock's
|
|
485
|
+
// pid has been recycled to us — ordinary on a host with pid_max 32768, and
|
|
486
|
+
// stale locks are deliberately left on disk — stillTheHolder answers `true`
|
|
487
|
+
// ABOUT THE SWEEPER and this function SIGTERMs the process running it. The
|
|
488
|
+
// command then dies mid-line, every later lock goes unexamined, and the real
|
|
489
|
+
// daemons it was asked to stop keep running. It is the signal-the-wrong-
|
|
490
|
+
// process bug arriving through a POSITIVE identification, which is why the
|
|
491
|
+
// identity check cannot catch it and the guard belongs here, at the one place
|
|
492
|
+
// that signals, rather than in each caller.
|
|
493
|
+
if (holder.pid === process.pid) return { failed: 'refusing to signal this very process' };
|
|
494
|
+
log?.(`asking daemon pid ${holder.pid} to stand down…`);
|
|
495
|
+
try {
|
|
496
|
+
process.kill(holder.pid, 'SIGTERM');
|
|
497
|
+
} catch {
|
|
498
|
+
return { failed: `could not signal pid ${holder.pid}` };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const standing = () => {
|
|
502
|
+
const now = readHolder(path);
|
|
503
|
+
if (!now || !alive(now.pid)) return null;
|
|
504
|
+
return now;
|
|
505
|
+
};
|
|
506
|
+
const deadline = Date.now() + TAKEOVER_GRACE_MS;
|
|
507
|
+
for (;;) {
|
|
508
|
+
const now = standing();
|
|
509
|
+
if (!now) break;
|
|
510
|
+
if (now.pid !== holder.pid) {
|
|
511
|
+
return {
|
|
512
|
+
failed: `the daemon handed over to pid ${now.pid}${now.version ? ` (${now.version})` : ''} while we waited — it is mid-update, so try again in a moment`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (Date.now() >= deadline) {
|
|
516
|
+
log?.(`pid ${holder.pid} did not stand down within ${TAKEOVER_GRACE_MS / 1000}s — forcing it.`);
|
|
517
|
+
try {
|
|
518
|
+
process.kill(holder.pid, 'SIGKILL');
|
|
519
|
+
} catch {
|
|
520
|
+
/* exited in the gap */
|
|
521
|
+
}
|
|
522
|
+
sleep(600);
|
|
523
|
+
const after = standing();
|
|
524
|
+
if (after && after.pid !== holder.pid) {
|
|
525
|
+
return { failed: `the daemon handed over to pid ${after.pid} — try again in a moment` };
|
|
526
|
+
}
|
|
527
|
+
if (after) return { failed: `pid ${holder.pid} would not stop` };
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
sleep(400);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// A SIGKILLed daemon never ran its release(), so clear what it left.
|
|
534
|
+
try {
|
|
535
|
+
rmSync(path, { force: true });
|
|
536
|
+
} catch {
|
|
537
|
+
return { failed: 'could not clear the lock file' };
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Ask the holder to stand down, then take its place.
|
|
544
|
+
*
|
|
545
|
+
* WHAT THIS FUNCTION IS, now that the choreography lives in standDown above: the
|
|
546
|
+
* IDENTITY GATE. Nothing below signals anything until the pid has been proved to
|
|
547
|
+
* be the process that wrote this lock — a lock records a PID, pids are recycled,
|
|
548
|
+
* and a crashed daemon's number goes to whatever forks next. `stillTheHolder` is
|
|
549
|
+
* tri-state and both of its refusing values are reported, separately, because
|
|
550
|
+
* "it is someone else" and "we could not look" are different sentences and
|
|
551
|
+
* collapsing them once had the daemon telling people a live holder was stale.
|
|
552
|
+
*/
|
|
553
|
+
function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
554
|
+
if (!holder?.pid || !alive(holder.pid)) return null; // already gone
|
|
555
|
+
const identified = stillTheHolder(holder);
|
|
556
|
+
if (identified === null) {
|
|
557
|
+
// We know nothing about this pid, and said so. The remedy is a human
|
|
558
|
+
// stopping it, NOT running a second daemon alongside it.
|
|
559
|
+
return {
|
|
560
|
+
failed:
|
|
561
|
+
`cannot confirm what pid ${holder.pid} is on this host — no readable /proc or ps — ` +
|
|
562
|
+
`so it will not be signalled. Stop that process yourself and start this one again.`,
|
|
563
|
+
unidentified: true,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (identified === false) {
|
|
567
|
+
return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
|
|
568
|
+
}
|
|
569
|
+
if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
|
|
570
|
+
return {
|
|
571
|
+
failed: `the running daemon is ${holder.version} and this one is ${VERSION} — refusing to replace a newer daemon with an older one (--takeover-downgrade if you mean it)`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const bad = standDown(holder, path, log);
|
|
576
|
+
if (bad) return bad;
|
|
577
|
+
log?.(`daemon pid ${holder.pid} stopped — taking over.`);
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
67
580
|
|
|
68
581
|
/**
|
|
69
582
|
* Take the lock, or report who holds it.
|
|
@@ -75,7 +588,8 @@ const record = (repoRoot) =>
|
|
|
75
588
|
* `wx` is the whole guarantee: create-exclusive is one atomic syscall, which is
|
|
76
589
|
* the property the turn lock's check-then-write does not have.
|
|
77
590
|
*/
|
|
78
|
-
export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
591
|
+
export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
592
|
+
const { takeover: force = false, noTakeover = false, allowDowngrade = false, log } = opts;
|
|
79
593
|
if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
|
|
80
594
|
const path = instanceLockPath(fleetToken);
|
|
81
595
|
try {
|
|
@@ -84,6 +598,21 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
|
84
598
|
return { ok: true, release: () => {}, unguarded: true };
|
|
85
599
|
}
|
|
86
600
|
|
|
601
|
+
// ONE DAEMON PER REPO, checked across every credential — see the header. This
|
|
602
|
+
// runs BEFORE we take our own lock, so a refusal leaves nothing behind.
|
|
603
|
+
const neighbour = daemonInSameRepo(repoRoot, path);
|
|
604
|
+
if (neighbour) {
|
|
605
|
+
// Same working tree, another credential. Under "one daemon per repo" the
|
|
606
|
+
// new run wins here too — but it is signalling a process that belongs to a
|
|
607
|
+
// DIFFERENT project, so it is worth saying out loud rather than doing
|
|
608
|
+
// quietly.
|
|
609
|
+
if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
|
|
610
|
+
log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
|
|
611
|
+
const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
|
|
612
|
+
if (bad)
|
|
613
|
+
return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
|
|
614
|
+
}
|
|
615
|
+
|
|
87
616
|
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
88
617
|
// loop here would spin against a peer that keeps re-taking it.
|
|
89
618
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -118,7 +647,17 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
|
118
647
|
}
|
|
119
648
|
return { ok: true, release: makeRelease(path) };
|
|
120
649
|
}
|
|
121
|
-
|
|
650
|
+
// THE RULE. Same repo -> this run replaces it; different repo -> refuse
|
|
651
|
+
// and signal nothing, unless --takeover says otherwise. See the header.
|
|
652
|
+
const here = samePath(holder.repoRoot, repoRoot);
|
|
653
|
+
const wanted = force || (here && !noTakeover);
|
|
654
|
+
if (wanted) {
|
|
655
|
+
const bad = takeOverFrom(holder, path, log, { allowDowngrade });
|
|
656
|
+
if (bad)
|
|
657
|
+
return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
|
|
658
|
+
continue; // the file is gone — the next pass takes it
|
|
659
|
+
}
|
|
660
|
+
return { ok: false, holder, sameRepo: here };
|
|
122
661
|
}
|
|
123
662
|
try {
|
|
124
663
|
writeSync(fd, record(repoRoot));
|
|
@@ -150,3 +689,138 @@ function makeRelease(path) {
|
|
|
150
689
|
process.on('exit', release);
|
|
151
690
|
return release;
|
|
152
691
|
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* STOP EVERY FLOWVIANT DAEMON ON THIS MACHINE — `flowviant stop`.
|
|
695
|
+
*
|
|
696
|
+
* WHY IT IS "EVERY" AND NOT "THIS REPO'S". The friction this exists to remove is
|
|
697
|
+
* not knowing what is running. Somebody who could NAME the daemon they meant
|
|
698
|
+
* would not need this command — they would already have the pid. What actually
|
|
699
|
+
* happens is that they run `flowviant`, hit a refusal naming a pid and a
|
|
700
|
+
* directory they do not recognise, and go hunting through `ps`. So this takes no
|
|
701
|
+
* argument, asks no question, and sweeps every credential's lock file rather
|
|
702
|
+
* than the one this checkout happens to key to: a stop command with a scope is a
|
|
703
|
+
* stop command you have to be sure about before you can use it.
|
|
704
|
+
*
|
|
705
|
+
* IT IDENTIFIES BEFORE IT SIGNALS, exactly as takeover does and for the same
|
|
706
|
+
* reason — a lock records a PID, pids are recycled, and there is no such thing
|
|
707
|
+
* as a harmless guess about which process to kill. The tri-state from
|
|
708
|
+
* stillTheHolder is reported as three DIFFERENT sentences and never collapsed:
|
|
709
|
+
*
|
|
710
|
+
* true -> stopped, through the same standDown the takeover path uses.
|
|
711
|
+
* false -> measured, and that pid is somebody else now. Nothing is signalled
|
|
712
|
+
* and it is NOT a failure: the daemon that wrote the lock is gone,
|
|
713
|
+
* which is the answer the asker wanted.
|
|
714
|
+
* null -> COULD NOT CONFIRM — either the lock carries no witness to match
|
|
715
|
+
* against, or the host hides the process (hidepid=2, a pid
|
|
716
|
+
* namespace, an image with no `ps`); the line below names BOTH,
|
|
717
|
+
* since we did not measure which. Said out loud WITH THE PID,
|
|
718
|
+
* because we have just declined to touch a live process and the
|
|
719
|
+
* remedy is a human running `kill`.
|
|
720
|
+
* That one counts as a failure — something is alive and we did not
|
|
721
|
+
* stop it — which is the ONLY thing that makes this command exit
|
|
722
|
+
* non-zero.
|
|
723
|
+
*
|
|
724
|
+
* A STALE LOCK IS LEFT ON DISK. Unlinking one looks tidy and races a daemon that
|
|
725
|
+
* is starting RIGHT NOW: acquire clears a dead holder's file and then re-creates
|
|
726
|
+
* it with `wx`, so a sweep landing between those two steps deletes a LIVE
|
|
727
|
+
* daemon's lock and leaves it running unguarded — the one condition this whole
|
|
728
|
+
* module exists to prevent. Clearing stale files is acquire's job, it already
|
|
729
|
+
* does it, and it does it without the race.
|
|
730
|
+
*
|
|
731
|
+
* FINDING NOTHING IS THE FEATURE, not an error: "no flowviant daemon is running
|
|
732
|
+
* on this machine." is the sentence the person who did not know came for, and it
|
|
733
|
+
* exits 0. Reported through `log` line by line as the sweep goes — a person
|
|
734
|
+
* watching a SIGTERM wants to see which pid it went to while it is happening,
|
|
735
|
+
* not in a summary afterwards.
|
|
736
|
+
*
|
|
737
|
+
* Returns `{ stopped, unconfirmed, failed }`; the caller turns `failed` into the
|
|
738
|
+
* exit code.
|
|
739
|
+
*/
|
|
740
|
+
export function stopAllDaemons({ log = (m) => console.log(m) } = {}) {
|
|
741
|
+
let stopped = 0;
|
|
742
|
+
let unconfirmed = 0;
|
|
743
|
+
let failed = 0;
|
|
744
|
+
let running = 0; // locks naming a process we believe is, or might be, a daemon
|
|
745
|
+
|
|
746
|
+
for (const path of lockFiles()) {
|
|
747
|
+
const holder = readHolder(path);
|
|
748
|
+
if (!holder) continue; // absent, truncated, half-written — no claim to answer
|
|
749
|
+
const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
|
|
750
|
+
const what = holder.version ? ` ${holder.version}` : '';
|
|
751
|
+
|
|
752
|
+
if (!alive(holder.pid)) {
|
|
753
|
+
log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
// Us. standDown refuses this too, but reaching it would print a stand-down
|
|
757
|
+
// line and then a failure for the one pid we are certain is not a daemon.
|
|
758
|
+
if (holder.pid === process.pid) continue;
|
|
759
|
+
|
|
760
|
+
const identified = stillTheHolder(holder);
|
|
761
|
+
if (identified === false) {
|
|
762
|
+
// A live pid, but measured NOT to be the process that wrote this lock. The
|
|
763
|
+
// daemon is gone; the number was handed to something else. Not counted as
|
|
764
|
+
// running, and deliberately not counted as a failure either.
|
|
765
|
+
log(`pid ${holder.pid} is no longer the daemon that took this lock — nothing signalled.`);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
running++;
|
|
769
|
+
if (identified === null) {
|
|
770
|
+
unconfirmed++;
|
|
771
|
+
failed++;
|
|
772
|
+
// NAMES BOTH CAUSES, because null has two and we did not measure which:
|
|
773
|
+
// the lock may carry no witness to match against (neither `entry` nor
|
|
774
|
+
// `startedAt` — a hand-edited or half-written file), or this host may hide
|
|
775
|
+
// the process from us (hidepid=2, a pid namespace, an image with no `ps`).
|
|
776
|
+
// "no readable /proc" alone is what takeover says, and said HERE it would
|
|
777
|
+
// assert a diagnosis nobody established — over a live process we are about
|
|
778
|
+
// to tell someone to kill.
|
|
779
|
+
// `kill` is not a command on Windows, where platform() is 'win32' and
|
|
780
|
+
// processStartedAt has no implementation at all — so EVERY lock lands in
|
|
781
|
+
// this branch and the whole command is a no-op that exits 1. Say that
|
|
782
|
+
// once, in the platform's own vocabulary, rather than handing someone a
|
|
783
|
+
// remedy their shell does not have.
|
|
784
|
+
const byHand =
|
|
785
|
+
platform() === 'win32'
|
|
786
|
+
? `taskkill /PID ${holder.pid} /F`
|
|
787
|
+
: `kill ${holder.pid}`;
|
|
788
|
+
log(
|
|
789
|
+
`could not confirm that pid ${holder.pid} is still a flowviant daemon — its lock carries ` +
|
|
790
|
+
`nothing to match it against, or this host hides the process from us` +
|
|
791
|
+
`${platform() === 'win32' ? ' (identifying a process is not implemented on Windows)' : ''}` +
|
|
792
|
+
` — so it was NOT signalled. Stop it by hand: ${byHand}`
|
|
793
|
+
);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const bad = standDown(holder, path, log);
|
|
798
|
+
if (bad) {
|
|
799
|
+
failed++;
|
|
800
|
+
log(`could not stop daemon pid ${holder.pid}${where}: ${bad.failed}`);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
stopped++;
|
|
804
|
+
log(`stopped daemon${what} pid ${holder.pid}${where}.`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// WHAT WE ACTUALLY MEASURED IS LOCKS, so that is what this says. The old
|
|
808
|
+
// sentence — "no flowviant daemon is running on this machine" — was asserted
|
|
809
|
+
// from lock files alone, and there are several ways to run a daemon that
|
|
810
|
+
// holds no readable lock: FLOWVIANT_ALLOW_MULTI=1 returns before the
|
|
811
|
+
// filesystem is touched (and fleet.mjs PRINTS that flag as the way out of an
|
|
812
|
+
// "already running" refusal, so a stuck user is steered straight onto it), an
|
|
813
|
+
// unwritable ~/.flowviant runs `unguarded`, and every daemon before 0.51.2
|
|
814
|
+
// predates the lock entirely. Telling somebody "nothing is running" while
|
|
815
|
+
// something is, is this product's cardinal sin: it turns ignorance into a
|
|
816
|
+
// state. So the claim is scoped to what was looked at, and the ways past it
|
|
817
|
+
// are named rather than left for them to discover.
|
|
818
|
+
if (!running) {
|
|
819
|
+
log('no flowviant daemon holds a lock on this machine.');
|
|
820
|
+
log(
|
|
821
|
+
'(a daemon started with FLOWVIANT_ALLOW_MULTI=1, or one older than 0.51.2, holds no lock — ' +
|
|
822
|
+
'this cannot see those. `pgrep -af flowviant` will.)'
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
return { stopped, unconfirmed, failed };
|
|
826
|
+
}
|