@bli-cockpit/cli 0.2.173 → 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.
@@ -5,7 +5,10 @@ const POLL_EVERY_MS = 1_000;
5
5
  const DEFAULT_DEPS = {
6
6
  now: () => Date.now(),
7
7
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
8
- acquire: acquireSyncLock,
8
+ // BLI-12872: a catch-up is long by design and scans for minutes without an
9
+ // upload, so it holds the lock under its own role; doctor never calls a
10
+ // `backfill` holder stuck, only a scheduled sync that stopped finishing work.
11
+ acquire: (paths, now) => acquireSyncLock(paths, now, { role: "backfill" }),
9
12
  inspect: inspectSyncLock,
10
13
  };
11
14
  /**
@@ -0,0 +1,160 @@
1
+ import { inspectBackfillLock } from "../backfill-lock.js";
2
+ import { readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
3
+ import { getCollectorRuntimePaths } from "../local-state.js";
4
+ import { runBackfillCommand } from "./backfill.js";
5
+ import { doctorBackfillBudgetSeconds } from "./doctor-budget.js";
6
+ import { doctorRoots } from "./doctor-access.js";
7
+ import { withCollectionGate } from "./doctor-collection-lock.js";
8
+ import { backfillProgressLine, clock, startProgress, } from "./doctor-progress.js";
9
+ import { backfillCompletionStepState, backfillFixVerdict, jsonField, parseDoctorBackfillJson, } from "./doctor-pipeline-verdicts.js";
10
+ import { asRecord, needsFix, ok } from "./doctor-report.js";
11
+ /**
12
+ * The `backfill-complete` row: has the catch-up over old sessions finished.
13
+ * Split out of `doctor-pipeline.ts` (BLI-12872) so that file stays under the
14
+ * line ratchet; `doctor-pipeline.ts` still re-exports both functions.
15
+ */
16
+ export async function checkBackfillState(context) {
17
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
18
+ const roots = await doctorRoots(context);
19
+ const marker = await readBackfillCompletionMarker(paths);
20
+ const covered = backfillCompletionStepState(marker, roots);
21
+ if (covered)
22
+ return covered;
23
+ const lock = await inspectBackfillLock(paths);
24
+ if (lock.held) {
25
+ return needsFix("backfill-complete", "backfill_already_running", `another catch-up run is still going, and this one has not finished yet (running since ${lock.held_since ?? "unknown"})`);
26
+ }
27
+ const cursor = await readBackfillCursor(paths);
28
+ return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
29
+ }
30
+ /**
31
+ * Catch up on old sessions, a chunk at a time, inside the run's budget.
32
+ *
33
+ * Every chunk asks the collection lock first (`withCollectionGate`): a healthy
34
+ * sync holding it ends this run with a plain "background sync is running" row,
35
+ * not a wait, and a stuck one is ended and the chunk runs.
36
+ */
37
+ export async function fixBackfillState(context) {
38
+ const started = Date.now();
39
+ const deadline = started + doctorBackfillBudgetSeconds(context, started) * 1000;
40
+ let caughtUp = 0;
41
+ let chunks = 0;
42
+ let live = null;
43
+ let remaining = null;
44
+ const receipt = () => `caught up on ${caughtUp} old sessions in ${chunks} chunk${chunks === 1 ? "" : "s"} over ${clock(Date.now() - started)}`;
45
+ const unfinished = (code, reason) => ({
46
+ ...needsFix("backfill-complete", code, `${receipt()}, ${remaining ?? "unknown"} still to go; ${reason}; run \`cockpit doctor\` again to continue`),
47
+ nextAction: "cockpit doctor",
48
+ });
49
+ let lastLine = null;
50
+ const stopProgress = startProgress((text) => context.io.stderr.write(text), (elapsedMs) => {
51
+ const snapshot = {
52
+ caughtUp: live === null ? caughtUp : caughtUp + live.uploaded,
53
+ remaining: live?.remaining ?? remaining,
54
+ };
55
+ console.error("[doctor] backfill-complete progress", JSON.stringify({
56
+ status: live === null ? "between_chunks" : "running", processed: live?.processed ?? null,
57
+ uploaded: live === null ? null : caughtUp + live.uploaded,
58
+ caught_up: snapshot.caughtUp, remaining: snapshot.remaining, chunks, elapsed_seconds: Math.floor(elapsedMs / 1000),
59
+ }));
60
+ const line = backfillProgressLine(snapshot, lastLine, elapsedMs);
61
+ lastLine = snapshot;
62
+ return line;
63
+ });
64
+ try {
65
+ while (Date.now() < deadline) {
66
+ const row = await withCollectionGate(context, "backfill-complete", async () => {
67
+ if (Date.now() >= deadline)
68
+ return unfinished("backfill_budget_timeout", "backfill time budget reached");
69
+ const capture = capturedIo(context.io, false);
70
+ live = null;
71
+ chunks += 1;
72
+ const code = await runBackfillCommand({
73
+ homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
74
+ all: true, dryRun: false, yes: true, json: true,
75
+ deadlineMs: deadline,
76
+ // Doctor never waits for the collection lock (BLI-12872): the gate
77
+ // above looked at it, and a busy answer here is reported, not retried.
78
+ syncLockWaitMs: 0,
79
+ onProgress: (progress) => { live = progress; },
80
+ }, capture.io);
81
+ const parsed = parseDoctorBackfillJson(capture.stdout());
82
+ const counts = asRecord(parsed?.counts);
83
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
84
+ caughtUp += count(counts?.["backfilled"]) ?? 0;
85
+ remaining = count(counts?.["remaining"]) ?? remaining;
86
+ live = null;
87
+ if (code === 0 && parsed?.status === "complete") {
88
+ // Reporting totals include permanent caps. Preserve their marker
89
+ // note instead of retrying history the collector says is complete.
90
+ const checked = await checkBackfillState(context);
91
+ return ok("backfill-complete", checked.status === "ok" ? checked.code : "complete", receipt() + (checked.status === "ok" ? `; ${checked.message}` : ""));
92
+ }
93
+ const verdict = backfillFixVerdict(parsed, jsonField(capture.stderr(), "failure_reason"));
94
+ if (parsed?.status === "partial" &&
95
+ (parsed.failure_reason === "deferred_budget_exhausted" || !parsed.failure_reason)) {
96
+ return needsFix("backfill-complete", "backfill_chunk_pending", "more history remains");
97
+ }
98
+ return { ...verdict, message: typeof parsed?.failure_reason === "string" ? parsed.failure_reason : "backfill failed without a valid completion receipt" };
99
+ });
100
+ if (row.status === "ok") {
101
+ console.error("[doctor] backfill-complete finished", JSON.stringify({ caught_up: caughtUp, remaining, chunks, elapsed_seconds: Math.floor((Date.now() - started) / 1000) }));
102
+ return row;
103
+ }
104
+ if (row.status === "skipped") {
105
+ // A running sync (or another catch-up) owns the lock. Doctor does not
106
+ // wait; it says what this run managed before it stood aside.
107
+ console.error("[doctor] backfill-complete stood aside", JSON.stringify({ reason: row.code, caught_up: caughtUp, remaining, chunks }));
108
+ return caughtUp > 0 ? { ...row, message: `${receipt()}; ${row.message}` } : row;
109
+ }
110
+ if (row.code === "sync_stuck_not_ended")
111
+ return row;
112
+ if (row.code !== "backfill_chunk_pending")
113
+ return unfinished(row.code, row.message);
114
+ }
115
+ return unfinished("backfill_budget_timeout", "backfill time budget reached");
116
+ }
117
+ catch (error) {
118
+ console.error("[doctor] backfill-complete failed", JSON.stringify({ reason: "backfill_threw", error_name: error instanceof Error ? error.name : "unknown" }));
119
+ return unfinished("backfill_threw", "backfill failed");
120
+ }
121
+ finally {
122
+ stopProgress();
123
+ }
124
+ }
125
+ function capturedIo(io, forward) {
126
+ const stdoutChunks = [];
127
+ const stderrChunks = [];
128
+ return {
129
+ io: {
130
+ ...io,
131
+ stdout: captureStream(io.stdout, stdoutChunks, forward),
132
+ stderr: captureStream(io.stderr, stderrChunks, forward),
133
+ },
134
+ stdout: () => stdoutChunks.join(""),
135
+ stderr: () => stderrChunks.join(""),
136
+ };
137
+ }
138
+ function captureStream(target, chunks, forward) {
139
+ return {
140
+ write(chunk, encoding, callback) {
141
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
142
+ chunks.push(text);
143
+ if (forward) {
144
+ if (typeof encoding === "function") {
145
+ target.write(chunk, encoding);
146
+ }
147
+ else {
148
+ target.write(chunk, encoding, callback);
149
+ }
150
+ }
151
+ else if (typeof encoding === "function") {
152
+ encoding();
153
+ }
154
+ else {
155
+ callback?.();
156
+ }
157
+ return true;
158
+ },
159
+ };
160
+ }
@@ -1,17 +1,21 @@
1
1
  /**
2
2
  * BLI-11101: `cockpit doctor --json` answers within one bounded budget.
3
3
  *
4
- * A person at a terminal gets the long defaults (a 900-second catch-up and a
5
- * 600-second wait for the collection lock) with progress every 30 seconds. An
6
- * agent or script calling `--json` gets one answer: on Edward's Mac the long
7
- * defaults ran past 9.5 minutes behind the 15-minute scheduled sync, and a
8
- * caller that times out learns nothing. So `--json` without `--lock-wait` or
9
- * `--backfill-budget` runs every repair under one deadline measured from the
10
- * start of the run, and a repair that does not fit says so by name instead of
11
- * queueing. Passing either flag opts back into the long run.
4
+ * A person at a terminal gets the long default (a 900-second catch-up) with
5
+ * progress every 30 seconds. An agent or script calling `--json` gets one
6
+ * answer: on Edward's Mac the long defaults ran past 9.5 minutes behind the
7
+ * 15-minute scheduled sync, and a caller that times out learns nothing. So
8
+ * `--json` without `--lock-wait` or `--backfill-budget` runs every repair under
9
+ * one deadline measured from the start of the run, and a repair that does not
10
+ * fit says so by name instead of queueing. Passing either flag opts back into
11
+ * the long run.
12
+ *
13
+ * BLI-12872: doctor no longer WAITS for the collection lock at all (it looks,
14
+ * and a running sync is reported, `doctor-collection-lock.ts`), so
15
+ * `--lock-wait` is accepted for the scripts that still pass it and changes
16
+ * nothing except opting out of the `--json` deadline.
12
17
  */
13
18
  export const DOCTOR_JSON_BUDGET_SECONDS = 45;
14
- export const DEFAULT_LOCK_WAIT_SECONDS = 600;
15
19
  export const DEFAULT_BACKFILL_BUDGET_SECONDS = 900;
16
20
  /** A repair that execs a whole sync or catch-up chunk is not started with less than this left. */
17
21
  export const SLOW_FIX_MIN_SECONDS = {
@@ -31,14 +35,7 @@ function remainingSeconds(context, now) {
31
35
  return null;
32
36
  return Math.max(0, (context.deadlineMs - now) / 1000);
33
37
  }
34
- /** How long a repair may wait for the collection lock. */
35
- export function doctorLockWaitSeconds(context, now = Date.now()) {
36
- if (context.command.lockWaitSeconds !== undefined)
37
- return context.command.lockWaitSeconds;
38
- const remaining = remainingSeconds(context, now);
39
- return remaining === null ? DEFAULT_LOCK_WAIT_SECONDS : Math.min(DEFAULT_LOCK_WAIT_SECONDS, remaining);
40
- }
41
- /** How long the catch-up repair may run, lock waiting included. */
38
+ /** How long the catch-up repair may run. */
42
39
  export function doctorBackfillBudgetSeconds(context, now = Date.now()) {
43
40
  if (context.command.backfillBudgetSeconds !== undefined)
44
41
  return context.command.backfillBudgetSeconds;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Doctor and the collection lock (BLI-12872): look, never wait.
3
+ *
4
+ * The 15-minute sync holds the collection lock for as long as it is working,
5
+ * and on a machine with a backlog that is most of the time. Doctor used to wait
6
+ * for it (up to 10 minutes, 40 progress lines) and then end with two rows that
7
+ * told the person to run `cockpit doctor`, which is not a human action. A
8
+ * running sync is the machine doing its job, so the rule now is:
9
+ *
10
+ * free run the repair; the operation's own exclusive acquire owns the
11
+ * handoff, so merely seeing a free lock never grants anything
12
+ * running report "background sync is running (started 7m ago)" and finish
13
+ * stale the heartbeat stopped: end the wedged process if one is left,
14
+ * clear its lock, run the repair
15
+ * stuck alive but finished no work for 15 minutes (`sync-lock-holder.ts`
16
+ * says what progress means): end it, clear its lock, run the repair
17
+ *
18
+ * Every branch logs its verdict and reason, ids and durations only. Ending a
19
+ * process is the one destructive act here, so it is verified three ways first:
20
+ * the pid must still exist, it must be a `node` or `cockpit` process (a pid the
21
+ * OS recycled for something else is left alone), and the lock being cleared
22
+ * must still carry the token that was inspected.
23
+ */
24
+ import { inspectBackfillLock } from "../backfill-lock.js";
25
+ import { getCollectorRuntimePaths } from "../local-state.js";
26
+ import { inspectSyncLock, removeSyncLockIfOwnedBy } from "../sync-lock.js";
27
+ import { classifySyncHolder, formatSpan, runningWords, } from "../sync-lock-holder.js";
28
+ import { isCollectionBusyReason } from "./doctor-pipeline-verdicts.js";
29
+ import { needsFix, skipped } from "./doctor-report.js";
30
+ const REAL_TIMING = {
31
+ now: () => Date.now(),
32
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
33
+ };
34
+ /** How long a polite end request gets before the process is forced. */
35
+ const GRACE_MS = 5_000;
36
+ const FORCE_WAIT_MS = 3_000;
37
+ const POLL_MS = 500;
38
+ /**
39
+ * Look at who holds the collection lock and act on it; never waits.
40
+ * `mayEnd: false` is a read-only peek: a stale or stuck holder is reported as
41
+ * open and left exactly as it was.
42
+ */
43
+ export async function readCollectionGate(context, timing = REAL_TIMING, options = {}) {
44
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
45
+ const backfill = await inspectBackfillLock(paths, new Date(timing.now()));
46
+ if (backfill.held) {
47
+ console.error("[doctor] another catch-up run holds its lock; doctor does not wait", JSON.stringify({ reason: "backfill_lock_held", owner_pid: backfill.pid ?? null }));
48
+ return { kind: "running", words: runningWords({ role: "backfill", running_ms: null }) };
49
+ }
50
+ const verdict = classifySyncHolder(await inspectSyncLock(paths, timing.now()), timing.now());
51
+ if (verdict.kind === "free") {
52
+ console.error("[doctor] collection lock is free", JSON.stringify({ reason: "lock_free" }));
53
+ return { kind: "open" };
54
+ }
55
+ if (verdict.kind === "running") {
56
+ console.error("[doctor] background sync is healthy; doctor does not wait", JSON.stringify({ reason: "holder_running", ...holderFields(verdict) }));
57
+ return { kind: "running", words: runningWords(verdict) };
58
+ }
59
+ const reason = verdict.kind === "stale" ? "heartbeat_stale" : "no_progress_for_15m";
60
+ if (options.mayEnd === false) {
61
+ console.error("[doctor] collection lock holder is not running well; a peek leaves it alone", JSON.stringify({ reason, ...holderFields(verdict) }));
62
+ return { kind: "open" };
63
+ }
64
+ const outcome = await endHolder(context, verdict, reason, timing);
65
+ if (outcome !== "not_ended")
66
+ return { kind: "open" };
67
+ return { kind: "stuck_unended", pid: verdict.pid, idleMs: verdict.idle_ms };
68
+ }
69
+ /**
70
+ * Run `run` once the lock says it may, and turn a busy answer into a plain
71
+ * "background sync is running" row instead of a wait. At most two runs: one
72
+ * for a tick that started between the read and the run, never a loop.
73
+ */
74
+ export async function withCollectionGate(context, id, run, timing = REAL_TIMING) {
75
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
76
+ const gate = await readCollectionGate(context, timing);
77
+ if (gate.kind !== "open")
78
+ return gateRow(id, gate);
79
+ const row = await run();
80
+ if (!isCollectionBusyReason(row.code))
81
+ return row;
82
+ console.error("[doctor] the repair stood aside for a lock that was open a moment ago", JSON.stringify({ row: id, reason: row.code, attempt }));
83
+ }
84
+ const last = await readCollectionGate(context, timing);
85
+ return last.kind === "open"
86
+ ? skipped(id, "sync_running", `${runningWords({ role: "sync", running_ms: null })}${waitsNote(id)}`)
87
+ : gateRow(id, last);
88
+ }
89
+ /** The row a gate that is not open becomes. Never a row that needs doctor run again. */
90
+ export function gateRow(id, gate) {
91
+ if (gate.kind === "running") {
92
+ return skipped(id, "sync_running", `${gate.words}${waitsNote(id)}`);
93
+ }
94
+ const who = gate.pid === null ? "a background sync" : `a background sync (process ${gate.pid})`;
95
+ const idle = gate.idleMs === null ? "" : ` for ${formatSpan(gate.idleMs)}`;
96
+ return {
97
+ ...needsFix(id, "sync_stuck_not_ended", `${who} has made no progress${idle} and could not be ended`),
98
+ nextAction: `End ${gate.pid === null ? "the stuck node process" : `process ${gate.pid}`} in Activity Monitor (Task Manager on Windows); the next background sync starts fresh.`,
99
+ };
100
+ }
101
+ /** The catch-up row also says its own work is parked, not lost. */
102
+ function waitsNote(id) {
103
+ return id === "backfill-complete" ? "; the old-session catch-up waits until it is idle" : "";
104
+ }
105
+ /**
106
+ * End a holder doctor has decided is stale or stuck, then clear its lock.
107
+ * `ended` and `gone` and `reused_pid` all leave the lock cleared; only
108
+ * `not_ended` (the process would not die, or could not be verified) leaves it.
109
+ */
110
+ async function endHolder(context, holder, reason, timing) {
111
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
112
+ const refuse = (why) => {
113
+ console.error("[doctor] could not end the lock holder; leaving its lock alone", JSON.stringify({ reason: why, takeover_reason: reason, ...holderFields(holder) }));
114
+ return "not_ended";
115
+ };
116
+ const clear = async (outcome, signal) => {
117
+ const removed = await removeSyncLockIfOwnedBy(paths, holder.token);
118
+ console.error("[doctor] took over the collection lock", JSON.stringify({ reason, outcome, signal, lock_removed: removed, ...holderFields(holder) }));
119
+ context.io.stderr.write(holder.kind === "stuck"
120
+ ? `ended a background sync that made no progress for ${formatSpan(holder.idle_ms ?? 0)}\n`
121
+ : "cleared a background sync that had stopped responding\n");
122
+ return outcome;
123
+ };
124
+ if (holder.pid === null) {
125
+ return holder.kind === "stale" ? clear("gone", null) : refuse("holder_pid_unknown");
126
+ }
127
+ const exec = context.io.exec;
128
+ if (!exec)
129
+ return refuse("no_process_runner");
130
+ const probe = await probeProcess(exec, holder.pid);
131
+ if (!probe.alive)
132
+ return clear("gone", null);
133
+ if (!probe.isCockpit) {
134
+ // The OS recycled the pid: the holder is gone and this is someone else's.
135
+ console.error("[doctor] lock holder pid now belongs to another program; leaving it running", JSON.stringify({ reason: "holder_pid_reused", takeover_reason: reason, ...holderFields(holder) }));
136
+ await removeSyncLockIfOwnedBy(paths, holder.token);
137
+ return "reused_pid";
138
+ }
139
+ const signal = await endProcess(exec, holder.pid, timing);
140
+ return signal === null ? refuse("process_would_not_end") : clear("ended", signal);
141
+ }
142
+ /** The log fields for a holder: a pid, a role and durations; never the token. */
143
+ function holderFields(holder) {
144
+ return {
145
+ owner_pid: holder.pid,
146
+ owner_role: holder.role,
147
+ heartbeat_age_ms: holder.heartbeat_age_ms,
148
+ running_ms: holder.running_ms,
149
+ idle_ms: holder.idle_ms,
150
+ };
151
+ }
152
+ /** Ask politely (POSIX), wait, then force. Windows has no polite signal for a console app. */
153
+ async function endProcess(exec, pid, timing) {
154
+ const windows = process.platform === "win32";
155
+ if (!windows) {
156
+ await exec("kill", ["-TERM", String(pid)]);
157
+ if (await goneWithin(exec, pid, GRACE_MS, timing))
158
+ return "SIGTERM";
159
+ await exec("kill", ["-KILL", String(pid)]);
160
+ return (await goneWithin(exec, pid, FORCE_WAIT_MS, timing)) ? "SIGKILL" : null;
161
+ }
162
+ await exec("taskkill", ["/PID", String(pid), "/T", "/F"]);
163
+ return (await goneWithin(exec, pid, FORCE_WAIT_MS, timing)) ? "taskkill" : null;
164
+ }
165
+ async function goneWithin(exec, pid, ms, timing) {
166
+ const deadline = timing.now() + ms;
167
+ for (;;) {
168
+ if (!(await probeProcess(exec, pid)).alive)
169
+ return true;
170
+ if (timing.now() >= deadline)
171
+ return false;
172
+ await timing.sleep(POLL_MS);
173
+ }
174
+ }
175
+ /** Is the pid alive, and is it a node or cockpit process? Unverifiable counts as alive and not ours. */
176
+ async function probeProcess(exec, pid) {
177
+ try {
178
+ if (process.platform === "win32") {
179
+ const result = await exec("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
180
+ const row = result.stdout.split("\n").map((line) => line.trim()).find((line) => line.startsWith('"'));
181
+ const fields = row?.split('","').map((field) => field.replaceAll('"', "")) ?? [];
182
+ if (fields[1] !== String(pid))
183
+ return { alive: false, isCockpit: false };
184
+ return { alive: true, isCockpit: isCockpitProcessName(fields[0] ?? "") };
185
+ }
186
+ const result = await exec("ps", ["-p", String(pid), "-o", "comm="]);
187
+ const name = result.stdout.trim().split("\n")[0] ?? "";
188
+ if (result.code !== 0 || !name)
189
+ return { alive: false, isCockpit: false };
190
+ return { alive: true, isCockpit: isCockpitProcessName(name) };
191
+ }
192
+ catch (error) {
193
+ console.error("[doctor] could not look up the lock holder's process", JSON.stringify({ reason: "process_probe_failed", error_name: error instanceof Error ? error.name : "unknown" }));
194
+ return { alive: true, isCockpit: false };
195
+ }
196
+ }
197
+ /** `node`, `node.exe`, `cockpit`, `cockpit.cmd`, with or without a directory in front. */
198
+ export function isCockpitProcessName(name) {
199
+ const base = name.trim().split(/[\\/]/u).pop() ?? "";
200
+ return /^(node|cockpit)(\.exe|\.cmd)?$/iu.test(base);
201
+ }
@@ -1,6 +1,34 @@
1
1
  export function days(ms) {
2
2
  return (ms / 86_400_000).toFixed(1);
3
3
  }
4
+ /**
5
+ * Why evidence on this disk has not landed, in words (BLI-12872). The labels
6
+ * stay in `--json`, the log and the heartbeat; a person reads the sentence.
7
+ */
8
+ export function uncommittedReasonWords(reason) {
9
+ switch (reason) {
10
+ case "reconciled_unknown_to_server":
11
+ return "the server was asked and has never received them";
12
+ case "reconciled_not_committed":
13
+ return "the server knows of them but has not accepted them";
14
+ case "absent_from_ledger":
15
+ return "this laptop has no record that the server accepted them";
16
+ default:
17
+ return reason.replaceAll("_", " ");
18
+ }
19
+ }
20
+ /** The same for objects the server refused outright (terminal). */
21
+ export function terminalReasonWords(reason) {
22
+ if (reason === "storage_rejected_object_too_large" || /_failed_http_413/u.test(reason))
23
+ return "too large for storage";
24
+ if (reason === "storage_rejected_media_type")
25
+ return "a file type storage refuses";
26
+ if (reason === "secret_guard_rejected")
27
+ return "held back by the secret guard";
28
+ if (reason === "staging_missing_after_restage" || reason.startsWith("staging_restage_failed"))
29
+ return "the staged copy could not be rebuilt";
30
+ return reason.replaceAll("_", " ").replaceAll(":", " ");
31
+ }
4
32
  /**
5
33
  * What the drain did, in the sentence a person reads (BLI-3797).
6
34
  *
@@ -93,7 +121,10 @@ export function footprintLine(footprint) {
93
121
  `state ${mib(footprint.state.state_bytes + footprint.spool_bytes)} MB`,
94
122
  ];
95
123
  if (footprint.state.other_bytes > 0) {
96
- parts.push(`unrecognised ${mib(footprint.state.other_bytes)} MB`);
124
+ // BLI-12872: 108 MB sat here unnamed. Tower never deletes these and never
125
+ // leaves them anonymous: the biggest are named so somebody can register them.
126
+ const named = footprint.state.other_largest.map((entry) => `${entry.name} ${mib(entry.bytes)} MB`).join(", ");
127
+ parts.push(`unrecognised ${mib(footprint.state.other_bytes)} MB${named ? ` (largest: ${named}; Tower never deletes these)` : ""}`);
97
128
  }
98
129
  parts.push(lastPrunePhrase(footprint));
99
130
  return parts.join(", ");
@@ -135,11 +166,11 @@ export function diskRowMessage(footprint, capBytes) {
135
166
  // `reconciled_unknown_to_server` on 234 objects whose oldest was 45 days old,
136
167
  // which is a different problem from a delivery that failed this morning.
137
168
  if (staging.terminal_count) {
138
- parts.push(`${staging.terminal_count} terminal object(s), ${mib(staging.terminal_bytes ?? 0)} MB excluded from cap; reclaimable with \`cockpit clean\`: ${Object.keys(staging.terminal_reasons ?? {}).join(", ")}`);
169
+ parts.push(`${staging.terminal_count} terminal object(s), ${mib(staging.terminal_bytes ?? 0)} MB excluded from cap; reclaimable with \`cockpit clean\`: ${Object.keys(staging.terminal_reasons ?? {}).map(terminalReasonWords).join(", ")}`);
139
170
  }
140
171
  const dominant = staging.dominant_uncommitted_reason;
141
172
  if (dominant) {
142
- parts.push(`oldest undelivered ${days(dominant.oldest_disk_age_ms)}d; biggest reason ${dominant.reason} on ${dominant.count} object(s), ${mib(dominant.bytes)} MB`);
173
+ parts.push(`oldest undelivered ${days(dominant.oldest_disk_age_ms)}d; biggest reason: ${uncommittedReasonWords(dominant.reason)} (${dominant.count} object(s), ${mib(dominant.bytes)} MB)`);
143
174
  }
144
175
  // BLI-3619's second half: "unknown" means the local ledger's own capped
145
176
  // memory cannot say, never that delivery failed — and the one command that
@@ -238,13 +238,23 @@ function positiveNumberOrZero(value) {
238
238
  ? value
239
239
  : 0;
240
240
  }
241
- /** Names only the sync receipt's recorded causes. Missing evidence names itself. */
242
- export function syncFailureMessage(parsed) {
241
+ /**
242
+ * Names only the sync receipt's recorded causes. Missing evidence is said in
243
+ * words, never as a label (BLI-12872): on 2026-10-07 a person read
244
+ * `sync_failure_receipt_missing` as the whole answer. A sync that exited with
245
+ * no recorded reason did not tell us why, so the sentence says that and names
246
+ * the one thing a person can do to see it: run the sync themselves.
247
+ */
248
+ export function syncFailureMessage(parsed, exitCode) {
243
249
  const reasons = typeof parsed?.failure_reason === "string"
244
250
  ? [parsed.failure_reason]
245
251
  : Array.isArray(parsed?.failure_reasons)
246
252
  ? parsed.failure_reasons.filter((value) => typeof value === "string" && value.length > 0)
247
253
  : [];
248
- const reason = reasons.join("; ") || "sync_failure_receipt_missing";
254
+ if (reasons.length === 0) {
255
+ console.error("[cockpit-doctor] sync failed and left no recorded reason", JSON.stringify({ reason: "sync_failure_receipt_missing", exit_code: exitCode ?? null, parsed_receipt: parsed !== null }));
256
+ return `the sync stopped${exitCode === undefined ? "" : ` (exit code ${exitCode})`} without saying why; run \`cockpit sync\` to watch it and read what it prints`;
257
+ }
258
+ const reason = reasons.join("; ");
249
259
  return /staging_cap/u.test(reason) ? `${reason}; see disk-bounded and run \`cockpit clean --dry-run\`` : reason;
250
260
  }