@bli-cockpit/cli 0.2.174 → 0.2.179

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.
@@ -0,0 +1,82 @@
1
+ import { SYNC_LOCK_STALE_TAKEOVER_MS, } from "./sync-lock.js";
2
+ /**
3
+ * What the collection lock's holder is doing, in four answers (BLI-12872).
4
+ *
5
+ * free nobody holds it; whoever wants it just takes it
6
+ * running alive and, as far as the lock can tell, working: leave it alone
7
+ * stale its heartbeat stopped; the process is gone or wedged
8
+ * stuck alive but has finished no work for a long time
9
+ *
10
+ * `cockpit doctor` used to WAIT out a running sync (10 minutes, 40 progress
11
+ * lines, then a row telling the person to run doctor again). A running sync is
12
+ * the machine doing its job, so doctor now asks this module what it is looking
13
+ * at and acts: it reports `running`, repairs `stale` and `stuck`, and never
14
+ * waits. Pure, so every answer is testable off a lock record.
15
+ *
16
+ * What "making progress" means here is only what the lock file can say. The
17
+ * heartbeat proves the process is alive; it fires from a timer whether or not
18
+ * any work happens, so a hung upload keeps it fresh forever. The lock also
19
+ * carries when the holder started and when it last finished a unit of work (a
20
+ * worktree synced, an upload request answered), and that is the progress mark:
21
+ * a sync that keeps finishing work is never called stuck, however long it has
22
+ * been running.
23
+ */
24
+ /**
25
+ * How long a scheduled sync may go without finishing any work before it is
26
+ * called stuck.
27
+ *
28
+ * Measured, not guessed: a tick on a machine with a backlog runs 10 to 15
29
+ * minutes in total (BLI-11112), and the 2026-10-07 reference-Mac tick held the
30
+ * lock for more than 10 (BLI-12872). A whole tick of that length is slow, not
31
+ * stuck, so the clock here is time since the last finished unit of work, not
32
+ * time since the start: a tick works through its worktrees and uploads one
33
+ * request at a time, and a holder that has finished none of either for a full
34
+ * 15 minutes is hung on something (a network call that never answers, a
35
+ * blocked read), not slow. Fifteen minutes is also the scheduler's own period,
36
+ * so a hung tick costs at most one missed cycle before doctor or the next
37
+ * person clears it. A holder that cannot say when it started (a lock written by
38
+ * an older CLI) and any `backfill` holder are never called stuck: a catch-up
39
+ * scans for minutes between uploads by design.
40
+ */
41
+ export const SYNC_HOLDER_STUCK_MS = 15 * 60_000;
42
+ export function classifySyncHolder(holder, nowMs) {
43
+ if (!holder)
44
+ return { kind: "free" };
45
+ const startedMs = holder.started_ms;
46
+ const lastWorkMs = startedMs === null ? null : Math.max(startedMs, holder.progress_ms ?? 0);
47
+ const facts = {
48
+ pid: holder.pid > 0 ? holder.pid : null,
49
+ role: holder.role,
50
+ token: holder.token,
51
+ heartbeat_age_ms: Math.max(0, nowMs - holder.heartbeat_ms),
52
+ running_ms: startedMs === null ? null : Math.max(0, nowMs - startedMs),
53
+ idle_ms: lastWorkMs === null ? null : Math.max(0, nowMs - lastWorkMs),
54
+ };
55
+ if (facts.heartbeat_age_ms > SYNC_LOCK_STALE_TAKEOVER_MS) {
56
+ return { kind: "stale", ...facts };
57
+ }
58
+ const stuck = holder.role === "sync" &&
59
+ facts.idle_ms !== null &&
60
+ facts.idle_ms > SYNC_HOLDER_STUCK_MS;
61
+ return { kind: stuck ? "stuck" : "running", ...facts };
62
+ }
63
+ /** "under a minute", "7m", "1h05m": the span a person reads. */
64
+ export function formatSpan(ms) {
65
+ const minutes = Math.floor(ms / 60_000);
66
+ if (minutes < 1)
67
+ return "under a minute";
68
+ if (minutes < 60)
69
+ return `${minutes}m`;
70
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
71
+ }
72
+ /**
73
+ * The sentence for a healthy holder: "background sync is running (started 7m
74
+ * ago)". A catch-up holder says so instead, and a holder that cannot say when
75
+ * it started leaves the parenthesis off rather than inventing a time.
76
+ */
77
+ export function runningWords(holder) {
78
+ const started = holder.running_ms === null ? "" : ` (started ${formatSpan(holder.running_ms)} ago)`;
79
+ return holder.role === "backfill"
80
+ ? `another catch-up run is in progress${started}`
81
+ : `background sync is running${started}`;
82
+ }
package/dist/sync-lock.js CHANGED
@@ -10,8 +10,14 @@ import { describeError } from "./health-detail.js";
10
10
  * A held lock refreshes a heartbeat every 30 s. Takeover happens only when the
11
11
  * heartbeat is more than 2 minutes stale — a fixed short timeout would fire
12
12
  * during a legitimate long first sync and recreate the interleaving it
13
- * prevents (D33). The lock file stays local and stores a pid + heartbeat only;
14
- * no paths or content.
13
+ * prevents (D33). The lock file stays local and stores a pid, a heartbeat and
14
+ * (BLI-12872) when the holder started and when it last finished a unit of
15
+ * work; no paths or content.
16
+ *
17
+ * The heartbeat only proves the process is alive. `started_ms` and
18
+ * `progress_ms` are what let `sync-lock-holder.ts` tell a slow tick from a
19
+ * stuck one: a holder that keeps finishing work is never called stuck, however
20
+ * long it has been running.
15
21
  */
16
22
  const LOCK_FILENAME = "sync.lock";
17
23
  const HEARTBEAT_INTERVAL_MS = 30_000;
@@ -25,32 +31,61 @@ const HEARTBEAT_INTERVAL_MS = 30_000;
25
31
  */
26
32
  export const SYNC_LOCK_STALE_TAKEOVER_MS = 2 * 60_000;
27
33
  const STALE_TAKEOVER_MS = SYNC_LOCK_STALE_TAKEOVER_MS;
28
- export async function acquireSyncLock(paths, now = new Date()) {
34
+ /** The locks this process holds right now; `noteSyncProgress` stamps all of them. */
35
+ const heldStates = new Set();
36
+ /**
37
+ * "I just finished a unit of work": a worktree synced, an upload request
38
+ * answered. In memory only; the next 30 s heartbeat writes it down. A no-op
39
+ * when this process holds no lock, so callers never have to ask.
40
+ */
41
+ export function noteSyncProgress(now = Date.now()) {
42
+ for (const held of heldStates) {
43
+ held.progressMs = now;
44
+ held.marks += 1;
45
+ held.longestGapMs = Math.max(held.longestGapMs, now - held.lastMarkMs);
46
+ held.lastMarkMs = now;
47
+ }
48
+ }
49
+ export async function acquireSyncLock(paths, now = new Date(), options = {}) {
29
50
  const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
30
51
  await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
31
52
  const token = crypto.randomUUID();
53
+ const held = {
54
+ startedMs: now.getTime(),
55
+ progressMs: null,
56
+ role: options.role ?? "sync",
57
+ marks: 0,
58
+ lastMarkMs: now.getTime(),
59
+ longestGapMs: 0,
60
+ };
32
61
  const existing = await readLock(lockPath);
33
62
  if (existing && now.getTime() - existing.heartbeat_ms <= STALE_TAKEOVER_MS) {
63
+ logHeldByLiveOwner(existing, now);
34
64
  return { acquired: false, held_since: existing.heartbeat_at };
35
65
  }
36
66
  // Free or stale: try an exclusive create first; if another process created it
37
67
  // in the gap, re-check staleness before taking over.
38
- let owned = await tryExclusiveCreate(lockPath, token, now);
68
+ let takeover = existing ? "stale" : null;
69
+ let owned = await tryExclusiveCreate(lockPath, token, now, held);
39
70
  if (!owned) {
40
71
  const current = await readLock(lockPath);
41
72
  if (current && now.getTime() - current.heartbeat_ms <= STALE_TAKEOVER_MS) {
73
+ logHeldByLiveOwner(current, now);
42
74
  return { acquired: false, held_since: current.heartbeat_at };
43
75
  }
44
- await writeLock(lockPath, token, now); // take over the stale lock
76
+ takeover = current ? "stale" : "unreadable";
77
+ await writeLock(lockPath, token, now, held); // take over the stale lock
45
78
  owned = true;
46
79
  }
80
+ logAcquired(held, takeover, existing, now);
81
+ heldStates.add(held);
47
82
  const timer = setInterval(() => {
48
83
  // Refresh only while the lock is still ours; if we were taken over, stop
49
84
  // touching the file so we cannot steal it back from the new owner.
50
85
  void (async () => {
51
86
  const current = await readLock(lockPath);
52
87
  if (current && current.token === token) {
53
- await writeLock(lockPath, token, new Date()).catch(() => undefined);
88
+ await writeLock(lockPath, token, new Date(), held).catch(() => undefined);
54
89
  }
55
90
  else {
56
91
  clearInterval(timer);
@@ -64,6 +99,8 @@ export async function acquireSyncLock(paths, now = new Date()) {
64
99
  handle: {
65
100
  release: async () => {
66
101
  clearInterval(timer);
102
+ heldStates.delete(held);
103
+ logReleased(held);
67
104
  // Ownership-safe: only remove the lock if it is still ours. A stale
68
105
  // owner that resumes after takeover must not delete the new owner's lock.
69
106
  const current = await readLock(lockPath);
@@ -74,10 +111,46 @@ export async function acquireSyncLock(paths, now = new Date()) {
74
111
  },
75
112
  };
76
113
  }
77
- async function tryExclusiveCreate(lockPath, token, now) {
114
+ /**
115
+ * How long this holder kept the lock and how it spent it: the duration that
116
+ * makes a 10-minute tick measurable (BLI-12872). The longest gap between two
117
+ * finished units of work is what a slow tick and a stuck one differ by.
118
+ */
119
+ function logReleased(held) {
120
+ const nowMs = Date.now();
121
+ console.error("[sync-lock] lock released", JSON.stringify({
122
+ reason: "holder_finished",
123
+ role: held.role,
124
+ held_ms: Math.max(0, nowMs - held.startedMs),
125
+ progress_marks: held.marks,
126
+ longest_gap_ms: Math.max(held.longestGapMs, nowMs - held.lastMarkMs),
127
+ }));
128
+ }
129
+ /** The ordinary contended case, named so a long hold is measurable (BLI-12872). */
130
+ function logHeldByLiveOwner(owner, now) {
131
+ console.error("[sync-lock] lock is held by a live owner; standing aside", JSON.stringify({
132
+ reason: "owner_heartbeat_fresh",
133
+ owner_pid: owner.pid > 0 ? owner.pid : null,
134
+ owner_role: owner.role,
135
+ heartbeat_age_ms: Math.max(0, now.getTime() - owner.heartbeat_ms),
136
+ held_ms: owner.started_ms === null ? null : Math.max(0, now.getTime() - owner.started_ms),
137
+ }));
138
+ }
139
+ /** A takeover says why it happened; an ordinary free acquisition says so too. */
140
+ function logAcquired(held, takeover, previous, now) {
141
+ console.error(takeover === null
142
+ ? "[sync-lock] lock acquired"
143
+ : "[sync-lock] took over a lock whose owner stopped answering", JSON.stringify({
144
+ role: held.role,
145
+ reason: takeover === "stale" ? "heartbeat_stale" : takeover === "unreadable" ? "lock_file_unreadable" : "lock_free",
146
+ previous_pid: previous && previous.pid > 0 ? previous.pid : null,
147
+ previous_heartbeat_age_ms: previous ? Math.max(0, now.getTime() - previous.heartbeat_ms) : null,
148
+ }));
149
+ }
150
+ async function tryExclusiveCreate(lockPath, token, now, held) {
78
151
  try {
79
152
  const handle = await fs.open(lockPath, "wx", 0o600);
80
- await handle.writeFile(serializeLock(token, now));
153
+ await handle.writeFile(serializeLock(token, now, held));
81
154
  await handle.close();
82
155
  return true;
83
156
  }
@@ -102,15 +175,18 @@ function isLockHeldError(error) {
102
175
  error !== null &&
103
176
  error.code === "EEXIST");
104
177
  }
105
- async function writeLock(lockPath, token, now) {
106
- await fs.writeFile(lockPath, serializeLock(token, now), { mode: 0o600 });
178
+ async function writeLock(lockPath, token, now, held) {
179
+ await fs.writeFile(lockPath, serializeLock(token, now, held), { mode: 0o600 });
107
180
  }
108
- function serializeLock(token, now) {
181
+ function serializeLock(token, now, held) {
109
182
  const record = {
110
183
  pid: process.pid,
111
184
  token,
112
185
  heartbeat_at: now.toISOString(),
113
186
  heartbeat_ms: now.getTime(),
187
+ started_ms: held.startedMs,
188
+ progress_ms: held.progressMs,
189
+ role: held.role,
114
190
  };
115
191
  return `${JSON.stringify(record)}\n`;
116
192
  }
@@ -132,6 +208,11 @@ async function readLock(lockPath) {
132
208
  ? heartbeatAt
133
209
  : new Date(heartbeatMs).toISOString(),
134
210
  heartbeat_ms: heartbeatMs,
211
+ started_ms: finiteOrNull(record["started_ms"]),
212
+ progress_ms: finiteOrNull(record["progress_ms"]),
213
+ role: record["role"] === "sync" || record["role"] === "backfill"
214
+ ? record["role"]
215
+ : null,
135
216
  };
136
217
  }
137
218
  catch {
@@ -143,8 +224,25 @@ async function readLock(lockPath) {
143
224
  return null;
144
225
  }
145
226
  }
227
+ function finiteOrNull(value) {
228
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
229
+ }
146
230
  /** Read owner metadata without acquiring or disturbing the collection lock. */
147
- export async function inspectSyncLock(paths) {
231
+ export async function inspectSyncLock(paths, now = Date.now()) {
148
232
  const record = await readLock(path.join(paths.state_dir, LOCK_FILENAME));
149
- return record ? { pid: record.pid, heartbeat_at: record.heartbeat_at, held: Date.now() - record.heartbeat_ms <= STALE_TAKEOVER_MS } : null;
233
+ if (!record)
234
+ return null;
235
+ return { ...record, held: now - record.heartbeat_ms <= STALE_TAKEOVER_MS };
236
+ }
237
+ /**
238
+ * Remove the lock file only if it still carries `token`: the holder this caller
239
+ * inspected and ended. A newer owner's lock is never touched. Returns whether
240
+ * a file was removed.
241
+ */
242
+ export async function removeSyncLockIfOwnedBy(paths, token) {
243
+ const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
244
+ const current = await readLock(lockPath);
245
+ if (!current || current.token !== token)
246
+ return false;
247
+ return fs.rm(lockPath, { force: true }).then(() => true, () => false);
150
248
  }
@@ -0,0 +1,34 @@
1
+ export function startTickTimer(now = () => Date.now()) {
2
+ const started = now();
3
+ const stages = {};
4
+ const wrap = (name) => async (run) => {
5
+ const stageStarted = now();
6
+ try {
7
+ return await run();
8
+ }
9
+ finally {
10
+ stages[name] = (stages[name] ?? 0) + (now() - stageStarted);
11
+ }
12
+ };
13
+ const report = (outcome, exitCode, errorName) => {
14
+ const longest = Object.entries(stages).sort((a, b) => b[1] - a[1])[0];
15
+ console.error("[sync] tick finished", JSON.stringify({
16
+ outcome,
17
+ exit_code: exitCode,
18
+ duration_ms: now() - started,
19
+ stages,
20
+ longest_stage: longest?.[0] ?? null,
21
+ ...(errorName ? { error_name: errorName } : {}),
22
+ }));
23
+ };
24
+ return {
25
+ selfUpdate: wrap("self_update"),
26
+ collect: wrap("collect"),
27
+ heartbeat: wrap("heartbeat"),
28
+ outcomeReport: wrap("outcome_report"),
29
+ converge: wrap("converge"),
30
+ prune: wrap("prune"),
31
+ finish: (exitCode) => report(exitCode === 0 ? "ok" : "failed", exitCode, null),
32
+ threw: (error) => report("threw", null, error instanceof Error ? error.name : "unknown"),
33
+ };
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.174",
3
+ "version": "0.2.179",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.48",
31
- "@bli-cockpit/mcp": "0.1.90",
32
- "@bli-cockpit/telemetry-core": "0.1.60"
30
+ "@bli-cockpit/memory-mcp": "0.1.52",
31
+ "@bli-cockpit/mcp": "0.1.94",
32
+ "@bli-cockpit/telemetry-core": "0.1.64"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -1,55 +0,0 @@
1
- import { inspectBackfillLock } from "../backfill-lock.js";
2
- import { getCollectorRuntimePaths } from "../local-state.js";
3
- import { inspectSyncLock } from "../sync-lock.js";
4
- import { doctorLockWaitSeconds } from "./doctor-budget.js";
5
- const BUSY = new Set(["sync_already_running", "sync_lock_held", "backfill_already_running", "live_sync_paused_during_backfill"]);
6
- /** Retry the operation itself: its existing exclusive lock acquisition owns the
7
- * handoff. Merely observing a free lock never grants permission to collect. */
8
- export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
9
- const started = timing.now();
10
- const limit = doctorLockWaitSeconds(context) * 1000;
11
- let nextProgress = 0;
12
- let row = await run();
13
- while (BUSY.has(row.code)) {
14
- const paths = getCollectorRuntimePaths(context.command.homeDir);
15
- const sync = await inspectSyncLock(paths);
16
- const backfill = await inspectBackfillLock(paths);
17
- const owner = backfill.held
18
- ? { pid: backfill.pid, heartbeat_at: backfill.held_since }
19
- : sync;
20
- const elapsed = timing.now() - started;
21
- if (elapsed >= limit) {
22
- const pid = owner?.pid;
23
- let name = "unknown process";
24
- if (pid && pid > 0 && context.io.exec) {
25
- const result = process.platform === "win32"
26
- ? await context.io.exec("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"])
27
- : await context.io.exec("ps", ["-p", String(pid), "-o", "comm="]);
28
- if (result.code === 0 && result.stdout.trim())
29
- name = result.stdout.trim().split("\n")[0] ?? name;
30
- }
31
- // BLI-11101: the scheduled sync holding the lock is not a fault; it is
32
- // doing the upload this row would prove. Say so by name.
33
- const syncOwns = !backfill.held && Boolean(sync?.held);
34
- const code = syncOwns ? "sync_running" : "lock_wait_timeout";
35
- console.error(`[doctor] ${row.id} lock wait ended`, JSON.stringify({ reason: code, owner_pid: pid ?? null, waited_seconds: Math.round(elapsed / 1000), limit_seconds: Math.round(limit / 1000) }));
36
- const message = syncOwns
37
- ? `The background sync (pid ${pid ?? "unknown"}, ${name}) is running and holds the collection lock; waited ${Math.round(elapsed / 1000)}s. Run \`cockpit doctor\` after it finishes, or \`cockpit doctor --lock-wait 600\` to wait for it.`
38
- : `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.`;
39
- return { ...row, status: "needs_fix", code, nextAction: "cockpit doctor --lock-wait 600", message };
40
- }
41
- if (elapsed >= nextProgress) {
42
- const seconds = Math.floor(elapsed / 1000);
43
- console.error(`[doctor] ${row.id} waiting`, JSON.stringify({ reason: row.code, owner_pid: owner?.pid ?? null, heartbeat_at: owner?.heartbeat_at ?? null, elapsed_seconds: seconds }));
44
- context.io.stderr.write(`waiting for the background sync to finish, ${Math.floor(seconds / 60)}m${seconds % 60}s\n`);
45
- nextProgress = elapsed + 30_000;
46
- }
47
- await timing.sleep(Math.min(1000, limit - elapsed));
48
- // A live heartbeat means there is no point spawning a collector yet.
49
- const currentSync = await inspectSyncLock(paths);
50
- const currentBackfill = await inspectBackfillLock(paths);
51
- if (!currentSync?.held && !currentBackfill.held)
52
- row = await run();
53
- }
54
- return row;
55
- }