@tikoci/quickchr 0.4.6 → 0.4.8
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/DESIGN.md +604 -3
- package/MANUAL.md +222 -19
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +11 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/completions.ts +17 -3
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +56 -61
- package/src/lib/names.ts +72 -0
- package/src/lib/net.ts +59 -3
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +85 -17
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +62 -16
- package/src/lib/quickchr.ts +504 -64
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +44 -6
- package/src/lib/versions.ts +99 -22
package/src/lib/provision.ts
CHANGED
|
@@ -67,12 +67,82 @@ async function waitForRest(
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Wait until RouterOS actually accepts `auth`, returning how long that took.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately *not* `waitForBoot()`: that one counts 401/403 as ready, because
|
|
73
|
+
* for a liveness probe an auth rejection still proves `www` is answering. Here
|
|
74
|
+
* a 401 is the thing being waited out, so it needs the opposite predicate.
|
|
75
|
+
*
|
|
76
|
+
* **This is hardening and an instrument, not a proven fix for #69.** The known
|
|
77
|
+
* symptom is a 401 on the first request made with freshly created credentials —
|
|
78
|
+
* four Windows CI legs across runs 35135008624 and 35386692051, always on the
|
|
79
|
+
* path that does no work between `createUser()` and that request. A local
|
|
80
|
+
* attempt to reproduce it found no window to close: on an Intel host against
|
|
81
|
+
* CHR 7.24.4 (x86 guest), a new user authenticated on attempt #1, 6/6, under
|
|
82
|
+
* both TCG and HVF. So the cause of the CI 401 is still open, and this wait
|
|
83
|
+
* does not claim to explain it.
|
|
84
|
+
*
|
|
85
|
+
* What it does buy: `createUser()` can no longer resolve while a `full`-group
|
|
86
|
+
* user's own credentials are rejected, and the outcome is measured either way.
|
|
87
|
+
* (Limited groups are exempt — see the table at the call site: a valid user
|
|
88
|
+
* without `rest-api` answers 401, which is indistinguishable from the symptom.) If the CI
|
|
89
|
+
* 401 is any kind of timing window, this closes it *and* the returned figure
|
|
90
|
+
* says how wide it was on that platform — which is the quantification #69 asks
|
|
91
|
+
* for. If it is not a timing window, the wait exhausts its budget and fails
|
|
92
|
+
* saying so, which is a far stronger signal than today's bare 401.
|
|
93
|
+
*
|
|
94
|
+
* Reports `attempts` alongside `elapsedMs`, and **`attempts` is the load-bearing
|
|
95
|
+
* number**. Elapsed time cannot tell a retry from a slow request: a first
|
|
96
|
+
* authentication with a new password costs a few hundred ms of ordinary request
|
|
97
|
+
* latency on an emulated guest, so an elapsed-only figure reads as a wait when
|
|
98
|
+
* nothing waited. `attempts === 1` means there was no window, whatever the
|
|
99
|
+
* clock says. (This is not hypothetical — it is the error the first pass at
|
|
100
|
+
* this investigation made; see `provisioning.instructions.md`.) */
|
|
101
|
+
export async function waitForAuth(
|
|
102
|
+
httpPort: number,
|
|
103
|
+
auth: string,
|
|
104
|
+
timeoutMs: number = 30_000,
|
|
105
|
+
): Promise<{ attempts: number; elapsedMs: number }> {
|
|
106
|
+
const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
|
|
107
|
+
const start = Date.now();
|
|
108
|
+
const deadline = start + timeoutMs;
|
|
109
|
+
let lastStatus: number | undefined;
|
|
110
|
+
let attempts = 0;
|
|
111
|
+
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
attempts++;
|
|
114
|
+
// Bound each request by what is left of the budget, so a stalled request
|
|
115
|
+
// cannot push the whole wait past the deadline it advertises.
|
|
116
|
+
const remaining = deadline - Date.now();
|
|
117
|
+
try {
|
|
118
|
+
const { status } = await restGet(url, auth, Math.min(5_000, remaining));
|
|
119
|
+
if (status >= 200 && status < 300) return { attempts, elapsedMs: Date.now() - start };
|
|
120
|
+
// 401/403 is the propagation window. 5xx also occurs briefly after a
|
|
121
|
+
// user-database change, and is equally transient, so both are polled
|
|
122
|
+
// out rather than one being singled out as "the" expected status.
|
|
123
|
+
lastStatus = status;
|
|
124
|
+
} catch { /* transport error mid-transition — keep polling */ }
|
|
125
|
+
await Bun.sleep(250);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new QuickCHRError(
|
|
129
|
+
"PROCESS_FAILED",
|
|
130
|
+
`Credentials were not accepted within ${timeoutMs}ms over ${attempts} attempt(s) ` +
|
|
131
|
+
`(last HTTP status: ${lastStatus ?? "no response"}) — ` +
|
|
132
|
+
"the user record exists but RouterOS is not authenticating it",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
70
136
|
/** Create a user via the REST API. */
|
|
71
137
|
export async function createUser(
|
|
72
138
|
httpPort: number,
|
|
73
139
|
name: string,
|
|
74
140
|
password: string,
|
|
75
141
|
group: string = "full",
|
|
142
|
+
logger?: ProgressLogger,
|
|
143
|
+
/** Budget for the post-visibility authentication wait. Injectable so tests
|
|
144
|
+
* can exercise the failure path without burning the 30 s default. */
|
|
145
|
+
authTimeoutMs: number = 30_000,
|
|
76
146
|
): Promise<void> {
|
|
77
147
|
await waitForRest(httpPort);
|
|
78
148
|
|
|
@@ -95,6 +165,7 @@ export async function createUser(
|
|
|
95
165
|
// Verify visibility (and group) so callers get deterministic behavior.
|
|
96
166
|
const expectedGroup = group.trim().toLowerCase();
|
|
97
167
|
const deadline = Date.now() + 30_000;
|
|
168
|
+
let visible = false;
|
|
98
169
|
while (Date.now() < deadline) {
|
|
99
170
|
try {
|
|
100
171
|
const user = await readUser(httpPort, auth, name);
|
|
@@ -110,7 +181,8 @@ export async function createUser(
|
|
|
110
181
|
`User "${name}" created with unexpected group (expected=${expectedGroup}, actual=${actualGroup})`,
|
|
111
182
|
);
|
|
112
183
|
}
|
|
113
|
-
|
|
184
|
+
visible = true;
|
|
185
|
+
break;
|
|
114
186
|
}
|
|
115
187
|
} catch (e) {
|
|
116
188
|
// Rethrow deliberate group mismatch errors; retry transient HTTP failures
|
|
@@ -120,9 +192,58 @@ export async function createUser(
|
|
|
120
192
|
await Bun.sleep(500);
|
|
121
193
|
}
|
|
122
194
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
195
|
+
if (!visible) {
|
|
196
|
+
throw new QuickCHRError(
|
|
197
|
+
"PROCESS_FAILED",
|
|
198
|
+
`User "${name}" creation was acknowledged but did not become visible in RouterOS`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Only the `full` group is gated, because only there does "the probe answers
|
|
203
|
+
// 2xx" mean "the credentials work". RouterOS separates authentication from
|
|
204
|
+
// authorization, and a correctly created user in a limited group cannot
|
|
205
|
+
// answer this probe at all. Measured on CHR 7.24.4 by creating one user per
|
|
206
|
+
// group and issuing GET /rest/system/resource as that user:
|
|
207
|
+
//
|
|
208
|
+
// group policy | status
|
|
209
|
+
// ------------------------------------------|---------------------------
|
|
210
|
+
// local,ssh,winbox,read (no rest-api) | 401 Unauthorized
|
|
211
|
+
// local,ssh,winbox,rest-api,write (no read) | 500 std failure: not allowed (9)
|
|
212
|
+
// local,winbox,rest-api,read (no web) | 500 std failure: not allowed (9)
|
|
213
|
+
// read / write / full (defaults) | 200
|
|
214
|
+
//
|
|
215
|
+
// The 401 is byte-identical to #69's symptom, so no amount of polling can
|
|
216
|
+
// tell an unauthorized user from a credential that has not propagated, and the 500 is
|
|
217
|
+
// a permanent condition this wait would poll through to its deadline. Gating
|
|
218
|
+
// those would turn a correctly created user into a 30 s stall and a false
|
|
219
|
+
// "not authenticating" failure. `provision()` only ever creates `full`.
|
|
220
|
+
if (expectedGroup !== "full") {
|
|
221
|
+
logger?.debug(
|
|
222
|
+
`user "${name}" created in group "${group}" — authentication gate skipped: ` +
|
|
223
|
+
"only the full group is known to be REST-verifiable",
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Deliberately outside the loop above. waitForAuth() throws a QuickCHRError,
|
|
229
|
+
// and that catch swallows every QuickCHRError except the group mismatch — so
|
|
230
|
+
// running it inside would discard the one diagnostic this wait exists to
|
|
231
|
+
// produce and report "did not become visible" for a user that plainly is.
|
|
232
|
+
//
|
|
233
|
+
// "Visible in /rest/user" was the whole done-when here, which is a weaker
|
|
234
|
+
// promise than every caller relies on: they use the credentials next.
|
|
235
|
+
const { attempts, elapsedMs } = await waitForAuth(
|
|
236
|
+
httpPort,
|
|
237
|
+
`Basic ${btoa(`${name}:${password}`)}`,
|
|
238
|
+
authTimeoutMs,
|
|
239
|
+
);
|
|
240
|
+
// attempts is the signal; elapsedMs alone cannot separate a retry from a slow
|
|
241
|
+
// first authentication on an emulated guest. The earlier attempts are only
|
|
242
|
+
// known to have not-succeeded — waitForAuth also polls through 5xx and
|
|
243
|
+
// transport errors — so they are not reported as rejections.
|
|
244
|
+
logger?.debug(
|
|
245
|
+
`user "${name}" accepted after ${attempts} attempt(s), ${elapsedMs}ms` +
|
|
246
|
+
(attempts > 1 ? ` — ${attempts - 1} earlier probe(s) did not succeed (#69)` : ""),
|
|
126
247
|
);
|
|
127
248
|
}
|
|
128
249
|
|
|
@@ -609,7 +730,7 @@ export async function provision(
|
|
|
609
730
|
// REST provisioning path. The console fallback above always returns early,
|
|
610
731
|
// so reaching here means REST came up and we provision over it.
|
|
611
732
|
if (effectiveUser) {
|
|
612
|
-
await createUser(httpPort, effectiveUser.name, effectiveUser.password);
|
|
733
|
+
await createUser(httpPort, effectiveUser.name, effectiveUser.password, "full", log);
|
|
613
734
|
// Persist to secret store so resolveAuth() picks it up
|
|
614
735
|
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
615
736
|
}
|
package/src/lib/qemu.ts
CHANGED
|
@@ -10,9 +10,10 @@ import { join } from "node:path";
|
|
|
10
10
|
import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
|
|
11
11
|
import { QuickCHRError } from "./types.ts";
|
|
12
12
|
import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
|
|
13
|
-
import { buildHostfwdString } from "./network.ts";
|
|
13
|
+
import { buildHostfwdString, deviceArgs } from "./network.ts";
|
|
14
14
|
import { ensureDir } from "./state.ts";
|
|
15
15
|
import { restGet } from "./rest.ts";
|
|
16
|
+
import { classifyProbeError, recordBootProbe, serialLogEnabled, type BootProbeStats } from "./diagnostics.ts";
|
|
16
17
|
|
|
17
18
|
export interface QemuLaunchConfig {
|
|
18
19
|
arch: Arch;
|
|
@@ -136,8 +137,15 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
|
|
|
136
137
|
return args;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
/** Build networking arguments from NetworkConfig array.
|
|
140
|
-
|
|
140
|
+
/** Build networking arguments from a `NetworkConfig` array.
|
|
141
|
+
*
|
|
142
|
+
* Exported because it is one of the two places a NIC reaches QEMU (the other is
|
|
143
|
+
* `resolveAllNetworks()` in `network.ts`, whose output the `net.resolved` branch
|
|
144
|
+
* below passes straight through). Testing it through {@link buildQemuArgs} would
|
|
145
|
+
* require a QEMU binary on the host, which the unit tier does not have — and a
|
|
146
|
+
* test that skips itself when QEMU is missing would assert nothing on CI, which
|
|
147
|
+
* is how a MAC-less NIC got shipped in the first place (#154). */
|
|
148
|
+
export function buildNetworkArgs(
|
|
141
149
|
args: string[],
|
|
142
150
|
ports: Record<string, PortMapping>,
|
|
143
151
|
networks: NetworkConfig[],
|
|
@@ -156,39 +164,39 @@ function buildNetworkArgs(
|
|
|
156
164
|
if (spec === "vmnet-shared") {
|
|
157
165
|
args.push(
|
|
158
166
|
"-netdev", `vmnet-shared,id=${id}`,
|
|
159
|
-
|
|
167
|
+
...deviceArgs(net),
|
|
160
168
|
);
|
|
161
169
|
} else if (typeof spec === "object" && spec.type === "vmnet-bridged") {
|
|
162
170
|
args.push(
|
|
163
171
|
"-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`,
|
|
164
|
-
|
|
172
|
+
...deviceArgs(net),
|
|
165
173
|
);
|
|
166
174
|
} else if (typeof spec === "object" && spec.type === "socket-listen") {
|
|
167
175
|
args.push(
|
|
168
176
|
"-netdev", `socket,id=${id},listen=:${spec.port}`,
|
|
169
|
-
|
|
177
|
+
...deviceArgs(net),
|
|
170
178
|
);
|
|
171
179
|
} else if (typeof spec === "object" && spec.type === "socket-connect") {
|
|
172
180
|
args.push(
|
|
173
181
|
"-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`,
|
|
174
|
-
|
|
182
|
+
...deviceArgs(net),
|
|
175
183
|
);
|
|
176
184
|
} else if (typeof spec === "object" && spec.type === "socket-mcast") {
|
|
177
185
|
args.push(
|
|
178
186
|
"-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`,
|
|
179
|
-
|
|
187
|
+
...deviceArgs(net),
|
|
180
188
|
);
|
|
181
189
|
} else if (typeof spec === "object" && spec.type === "tap") {
|
|
182
190
|
args.push(
|
|
183
191
|
"-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`,
|
|
184
|
-
|
|
192
|
+
...deviceArgs(net),
|
|
185
193
|
);
|
|
186
194
|
} else {
|
|
187
195
|
// Default: user-mode networking with port forwarding (only on the first user NIC)
|
|
188
196
|
const hostfwd = buildHostfwdString(ports);
|
|
189
197
|
args.push(
|
|
190
198
|
"-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`,
|
|
191
|
-
|
|
199
|
+
...deviceArgs(net),
|
|
192
200
|
);
|
|
193
201
|
}
|
|
194
202
|
}
|
|
@@ -221,12 +229,20 @@ function buildChannelArgs(
|
|
|
221
229
|
qgaSock = `path=${join(machineDir, "qga.sock")}`;
|
|
222
230
|
}
|
|
223
231
|
|
|
232
|
+
// QUICKCHR_SERIAL_LOG=1 tees the serial console to <machineDir>/serial.log via
|
|
233
|
+
// the QEMU chardev `logfile=` option. Opt-in, never default: serial-console
|
|
234
|
+
// provisioning types the generated user password in cleartext (provision.ts
|
|
235
|
+
// provisionViaConsole), so this log is secret-bearing. It is the only record of
|
|
236
|
+
// what RouterOS printed when a boot never reaches REST — the socket chardev
|
|
237
|
+
// keeps no history, so connecting after the failure shows nothing (#79).
|
|
238
|
+
const serialLog = serialLogEnabled() ? `,logfile=${join(machineDir, "serial.log")},logappend=on` : "";
|
|
239
|
+
|
|
224
240
|
if (background) {
|
|
225
241
|
// Background: all channels on sockets/pipes
|
|
226
242
|
args.push(
|
|
227
243
|
"-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
|
|
228
244
|
"-mon", "chardev=monitor0,mode=readline",
|
|
229
|
-
"-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`,
|
|
245
|
+
"-chardev", `socket,id=serial0,${serialSock},server=on,wait=off${serialLog}`,
|
|
230
246
|
"-serial", "chardev:serial0",
|
|
231
247
|
);
|
|
232
248
|
} else {
|
|
@@ -370,6 +386,20 @@ export async function spawnQemu(
|
|
|
370
386
|
// After spawn, close parent's copy; child process inherits its own copy.
|
|
371
387
|
const logFd = openSync(logPath, "a");
|
|
372
388
|
|
|
389
|
+
// A backgrounded QEMU outlives the CLI invocation that started it, so it must
|
|
390
|
+
// leave the caller's process group. `detached: true` is `setsid()` on POSIX;
|
|
391
|
+
// `unref()` alone is not enough, because it only covers a *voluntary* parent
|
|
392
|
+
// exit. It leaves QEMU in the caller's process group, where a group signal
|
|
393
|
+
// reaches it: Ctrl-C in the terminal, a shell `timeout`, a CI step teardown,
|
|
394
|
+
// an agent harness killing a stuck command (#159).
|
|
395
|
+
//
|
|
396
|
+
// Killing a run's VMs deliberately is by QEMU process name, not by group
|
|
397
|
+
// (`scripts/ci-file-watchdog.ts`, and the integration workflow's cleanup
|
|
398
|
+
// steps), so detaching costs no cleanup reach.
|
|
399
|
+
//
|
|
400
|
+
// Windows keeps its own spawn below for a different failure — a Job Object
|
|
401
|
+
// takes everything in it when the parent goes — and the two are not merged
|
|
402
|
+
// because only this one can be verified here.
|
|
373
403
|
let spawnedPid: number;
|
|
374
404
|
|
|
375
405
|
if (process.platform === "win32") {
|
|
@@ -385,6 +415,11 @@ export async function spawnQemu(
|
|
|
385
415
|
stdio: ["ignore", logFd, logFd],
|
|
386
416
|
windowsHide: true,
|
|
387
417
|
});
|
|
418
|
+
// A failed exec surfaces asynchronously here, as an `error` event with no
|
|
419
|
+
// pid — and an unhandled `error` event is a crash, not a thrown
|
|
420
|
+
// QuickCHRError. Nothing awaits the child, so this listener is what keeps
|
|
421
|
+
// a qemu binary that is missing, or cannot be executed, reportable.
|
|
422
|
+
child.on("error", () => { /* surfaced by the liveness check below */ });
|
|
388
423
|
closeSync(logFd);
|
|
389
424
|
if (child.pid === undefined) {
|
|
390
425
|
throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows");
|
|
@@ -393,13 +428,14 @@ export async function spawnQemu(
|
|
|
393
428
|
spawnedPid = child.pid;
|
|
394
429
|
} else {
|
|
395
430
|
const proc = Bun.spawn(spawnCmd, {
|
|
431
|
+
detached: true,
|
|
396
432
|
stdout: logFd,
|
|
397
433
|
stderr: logFd,
|
|
398
434
|
stdin: "ignore",
|
|
399
435
|
});
|
|
400
436
|
closeSync(logFd);
|
|
401
|
-
// unref() lets the parent
|
|
402
|
-
// QEMU
|
|
437
|
+
// unref() lets the parent exit without waiting for QEMU; detached keeps
|
|
438
|
+
// QEMU alive when the parent is signalled rather than exiting.
|
|
403
439
|
proc.unref();
|
|
404
440
|
spawnedPid = proc.pid;
|
|
405
441
|
}
|
|
@@ -517,9 +553,11 @@ export async function waitForBoot(
|
|
|
517
553
|
httpPort: number,
|
|
518
554
|
timeoutMs: number = 120_000,
|
|
519
555
|
auth: string = `Basic ${btoa("admin:")}`,
|
|
556
|
+
stats?: BootProbeStats,
|
|
520
557
|
): Promise<boolean> {
|
|
521
558
|
const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
|
|
522
|
-
const
|
|
559
|
+
const start = Date.now();
|
|
560
|
+
const deadline = start + timeoutMs;
|
|
523
561
|
let consecutiveReady = 0;
|
|
524
562
|
|
|
525
563
|
while (Date.now() < deadline) {
|
|
@@ -530,6 +568,7 @@ export async function waitForBoot(
|
|
|
530
568
|
// Auth rejected — the REST layer is up and responded, even though
|
|
531
569
|
// we cannot validate the body. Count as ready.
|
|
532
570
|
consecutiveReady++;
|
|
571
|
+
recordBootProbe(stats, Date.now() - start, "unauthorized", String(status));
|
|
533
572
|
} else if (status >= 200 && status < 300) {
|
|
534
573
|
// RouterOS REST may return wrong data (array body) briefly after boot —
|
|
535
574
|
// a startup race. Only accept once the body is the expected singleton object.
|
|
@@ -537,17 +576,24 @@ export async function waitForBoot(
|
|
|
537
576
|
try { parsed = JSON.parse(body); } catch { /* ignore */ }
|
|
538
577
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) {
|
|
539
578
|
consecutiveReady++;
|
|
579
|
+
recordBootProbe(stats, Date.now() - start, "ready");
|
|
540
580
|
} else {
|
|
541
581
|
consecutiveReady = 0; // wrong body — REST not fully initialized yet
|
|
582
|
+
recordBootProbe(stats, Date.now() - start, "wrong-body", body.slice(0, 80));
|
|
542
583
|
}
|
|
543
584
|
} else {
|
|
544
585
|
consecutiveReady = 0;
|
|
586
|
+
recordBootProbe(stats, Date.now() - start, "http-error", String(status));
|
|
545
587
|
}
|
|
546
588
|
|
|
547
589
|
if (consecutiveReady >= 2) return true;
|
|
548
|
-
} catch {
|
|
549
|
-
// ECONNRESET, connection refused, timeout — not ready yet
|
|
590
|
+
} catch (e) {
|
|
591
|
+
// ECONNRESET, connection refused, timeout — not ready yet. Which one it
|
|
592
|
+
// was is the whole diagnosis when a boot never completes (#79), so it is
|
|
593
|
+
// classified rather than swallowed.
|
|
550
594
|
consecutiveReady = 0;
|
|
595
|
+
const { outcome, detail } = classifyProbeError(e);
|
|
596
|
+
recordBootProbe(stats, Date.now() - start, outcome, detail);
|
|
551
597
|
}
|
|
552
598
|
await Bun.sleep(2000);
|
|
553
599
|
}
|