rightsize 0.7.9 → 0.7.12

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.
Files changed (103) hide show
  1. package/README.md +6 -4
  2. package/dist/backend-docker/backend.d.ts +29 -3
  3. package/dist/backend-docker/backend.d.ts.map +1 -1
  4. package/dist/backend-docker/backend.js +30 -3
  5. package/dist/backend-docker/backend.js.map +1 -1
  6. package/dist/backend-msb/backend.d.ts +657 -83
  7. package/dist/backend-msb/backend.d.ts.map +1 -1
  8. package/dist/backend-msb/backend.js +1319 -149
  9. package/dist/backend-msb/backend.js.map +1 -1
  10. package/dist/backend-msb/commands.d.ts +162 -16
  11. package/dist/backend-msb/commands.d.ts.map +1 -1
  12. package/dist/backend-msb/commands.js +228 -32
  13. package/dist/backend-msb/commands.js.map +1 -1
  14. package/dist/backend-msb/network-links.d.ts +86 -1
  15. package/dist/backend-msb/network-links.d.ts.map +1 -1
  16. package/dist/backend-msb/network-links.js +119 -4
  17. package/dist/backend-msb/network-links.js.map +1 -1
  18. package/dist/backend-msb/provisioner.d.ts +1 -1
  19. package/dist/backend-msb/provisioner.d.ts.map +1 -1
  20. package/dist/backend-msb/provisioner.js +1 -1
  21. package/dist/backend-msb/provisioner.js.map +1 -1
  22. package/dist/backend-msb/restore-access-denied.d.ts +50 -0
  23. package/dist/backend-msb/restore-access-denied.d.ts.map +1 -0
  24. package/dist/backend-msb/restore-access-denied.js +52 -0
  25. package/dist/backend-msb/restore-access-denied.js.map +1 -0
  26. package/dist/backend-msb/restore-broker.d.ts +181 -0
  27. package/dist/backend-msb/restore-broker.d.ts.map +1 -0
  28. package/dist/backend-msb/restore-broker.js +264 -0
  29. package/dist/backend-msb/restore-broker.js.map +1 -0
  30. package/dist/backend-msb/sandbox-already-exists.d.ts +32 -0
  31. package/dist/backend-msb/sandbox-already-exists.d.ts.map +1 -0
  32. package/dist/backend-msb/sandbox-already-exists.js +35 -0
  33. package/dist/backend-msb/sandbox-already-exists.js.map +1 -0
  34. package/dist/backend-msb/snapshot-create.d.ts +22 -0
  35. package/dist/backend-msb/snapshot-create.d.ts.map +1 -0
  36. package/dist/backend-msb/snapshot-create.js +33 -0
  37. package/dist/backend-msb/snapshot-create.js.map +1 -0
  38. package/dist/backend-msb/snapshot-import.d.ts +30 -14
  39. package/dist/backend-msb/snapshot-import.d.ts.map +1 -1
  40. package/dist/backend-msb/snapshot-import.js +35 -17
  41. package/dist/backend-msb/snapshot-import.js.map +1 -1
  42. package/dist/backend-msb/snapshot-rm.d.ts +28 -0
  43. package/dist/backend-msb/snapshot-rm.d.ts.map +1 -0
  44. package/dist/backend-msb/snapshot-rm.js +30 -0
  45. package/dist/backend-msb/snapshot-rm.js.map +1 -0
  46. package/dist/core/backend.d.ts +68 -23
  47. package/dist/core/backend.d.ts.map +1 -1
  48. package/dist/core/checkpoint/api.d.ts +2 -2
  49. package/dist/core/checkpoint/api.js +2 -2
  50. package/dist/core/checkpoint/registry.d.ts +46 -0
  51. package/dist/core/checkpoint/registry.d.ts.map +1 -1
  52. package/dist/core/checkpoint/registry.js +44 -3
  53. package/dist/core/checkpoint/registry.js.map +1 -1
  54. package/dist/core/errors.d.ts +49 -0
  55. package/dist/core/errors.d.ts.map +1 -1
  56. package/dist/core/errors.js +61 -0
  57. package/dist/core/errors.js.map +1 -1
  58. package/dist/core/free-ports.d.ts +18 -0
  59. package/dist/core/free-ports.d.ts.map +1 -1
  60. package/dist/core/free-ports.js +61 -0
  61. package/dist/core/free-ports.js.map +1 -1
  62. package/dist/core/generic-container.d.ts +101 -12
  63. package/dist/core/generic-container.d.ts.map +1 -1
  64. package/dist/core/generic-container.js +399 -71
  65. package/dist/core/generic-container.js.map +1 -1
  66. package/dist/core/model.d.ts +40 -3
  67. package/dist/core/model.d.ts.map +1 -1
  68. package/dist/core/network.d.ts +6 -2
  69. package/dist/core/network.d.ts.map +1 -1
  70. package/dist/core/network.js +4 -1
  71. package/dist/core/network.js.map +1 -1
  72. package/dist/core/reuse/hash.d.ts +12 -0
  73. package/dist/core/reuse/hash.d.ts.map +1 -1
  74. package/dist/core/reuse/hash.js +1 -0
  75. package/dist/core/reuse/hash.js.map +1 -1
  76. package/dist/core/reuse/registry.d.ts +10 -1
  77. package/dist/core/reuse/registry.d.ts.map +1 -1
  78. package/dist/core/reuse/registry.js +15 -1
  79. package/dist/core/reuse/registry.js.map +1 -1
  80. package/dist/core/sandbox-name.d.ts +3 -0
  81. package/dist/core/sandbox-name.d.ts.map +1 -0
  82. package/dist/core/sandbox-name.js +19 -0
  83. package/dist/core/sandbox-name.js.map +1 -0
  84. package/dist/index.d.ts +2 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/modules/cassandra.d.ts +12 -12
  89. package/dist/modules/cassandra.js +13 -13
  90. package/dist/modules/cassandra.js.map +1 -1
  91. package/dist/modules/minio.d.ts +5 -3
  92. package/dist/modules/minio.d.ts.map +1 -1
  93. package/dist/modules/minio.js +14 -4
  94. package/dist/modules/minio.js.map +1 -1
  95. package/dist/modules/postgres.d.ts +2 -2
  96. package/dist/modules/postgres.d.ts.map +1 -1
  97. package/dist/modules/postgres.js +11 -9
  98. package/dist/modules/postgres.js.map +1 -1
  99. package/package.json +1 -1
  100. package/dist/backend-msb/snapshot-list.d.ts +0 -43
  101. package/dist/backend-msb/snapshot-list.d.ts.map +0 -1
  102. package/dist/backend-msb/snapshot-list.js +0 -53
  103. package/dist/backend-msb/snapshot-list.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import type { SandboxBackend, SandboxHandle, FollowHandle, NetworkLink, ReaperKillCommand, BackendCapabilities } from "../core/backend.js";
2
2
  import type { ContainerSpec, ExecResult } from "../core/model.js";
3
+ import { type RestoreBrokerLauncher } from "./restore-broker.js";
3
4
  /**
4
5
  * True if `output` (an `msb run` invocation's combined output) is msb
5
6
  * refusing to run anything while its internal install lock is held. Captured
@@ -23,10 +24,32 @@ import type { ContainerSpec, ExecResult } from "../core/model.js";
23
24
  * timestamp varies per occurrence.
24
25
  */
25
26
  export declare function isMsbInstallLockActive(output: string): boolean;
27
+ /**
28
+ * Constructor options for {@link MsbCliBackend}. Every field is optional so
29
+ * every existing `new MsbCliBackend(msbPathPromise)` call site keeps
30
+ * compiling and behaving unchanged.
31
+ */
32
+ export interface MsbCliBackendOptions {
33
+ /**
34
+ * The launcher POLICY v2's broker escalation calls through for every
35
+ * brokered restore attempt (see `RestoreLaunchMode`/`restore-broker.ts`).
36
+ * Defaults to `realRestoreBroker`, the real `powershell.exe`/WMI
37
+ * implementation — production code never needs to pass this. Tests inject
38
+ * a fake here instead of driving a real `powershell.exe`, the same role
39
+ * `checkpointRebootAlreadyExistsRetryBudgetMs` already plays for the
40
+ * retry budget itself, just as a constructor option rather than an
41
+ * unsafe-cast field, since production code (not only tests) needs a way to
42
+ * supply it.
43
+ */
44
+ restoreBroker?: RestoreBrokerLauncher;
45
+ }
26
46
  /**
27
47
  * The attached-mode CLI driver: every sandbox this backend starts runs as a
28
- * held child process (`msb run`, no `-d`) because detached mode never
29
- * executes the image's own ENTRYPOINT/CMD — only attached mode does.
48
+ * held child process (`msb run`, no `-d`). Detached mode (`msb run -d`) runs
49
+ * the image's own ENTRYPOINT/CMD too, so this isn't about getting the
50
+ * workload to start — it's supervision: the held child gives this backend
51
+ * child-exit-based death detection and a place to capture pre-Running boot
52
+ * diagnostics, neither of which a detached boot provides on its own.
30
53
  * Readiness is never inferred from that child's own output; it comes from
31
54
  * polling `msb ls --format json` until the name shows `"Running"`. The
32
55
  * attached child's stdout/stderr carries msb's own boot diagnostics and is
@@ -50,7 +73,24 @@ export declare class MsbCliBackend implements SandboxBackend {
50
73
  private readonly handles;
51
74
  private readonly startedNames;
52
75
  private resolvedMsbPath;
53
- constructor(msbPathPromise: Promise<string>);
76
+ private checkpointRebootAlreadyExistsRetryBudgetMs;
77
+ private checkpointRebootAlreadyExistsRetryDelayMs;
78
+ private readonly restoreBroker;
79
+ private platformOverrideForTests;
80
+ private udpLinkReadinessPollMs;
81
+ private udpLinkReadinessTimeoutMs;
82
+ constructor(msbPathPromise: Promise<string>, options?: MsbCliBackendOptions);
83
+ /**
84
+ * Whether this process should treat itself as running on Windows for
85
+ * POLICY v2's broker-escalation gate — `process.platform === "win32"` in
86
+ * production, or `platformOverrideForTests` when a test has set it. The
87
+ * ONLY thing this gates is whether `rebootUnderFreshName`/
88
+ * `retryRestoreAfterAccessDenied` ever set `RestoreLaunchMode` to
89
+ * `"broker"`; `bootOnce`/`bootRestoreOnce` themselves stay platform-
90
+ * agnostic dispatchers on whatever mode they're handed, so the broker path
91
+ * itself is exercisable on any host once a test picks it explicitly.
92
+ */
93
+ private isWindowsPlatform;
54
94
  private msbPath;
55
95
  create(spec: ContainerSpec): Promise<SandboxHandle>;
56
96
  /**
@@ -88,34 +128,288 @@ export declare class MsbCliBackend implements SandboxBackend {
88
128
  * knows: the install-lock poll, the one-shot state-database retry, and the
89
129
  * one-shot image-cache heal. Both the ordinary `start()` path and the
90
130
  * checkpoint cycle's post-snapshot reboot come through here — a reboot
91
- * from a snapshot is as exposed to msb's transients as any other boot,
92
- * and skipping the classification there turned a passing install-lock
93
- * poll into an immediate checkpoint failure on a live Windows run.
131
+ * from a snapshot (now `msb restore ...`, see `bootRestoreOnce`) is
132
+ * as exposed to msb's transients as any other boot, and skipping the
133
+ * classification there turned a passing install-lock poll into an
134
+ * immediate checkpoint failure on a live Windows run. The image-cache heal
135
+ * targets `handle.spec.image`, which for a restore is the checkpoint ref
136
+ * GenericContainer.fromCheckpoint() threaded through as the builder's
137
+ * "image" — never a real OCI reference `msb image remove` can act on
138
+ * meaningfully; this predates the 0.7.1 migration (the same was true of
139
+ * `--from-snapshot` boots) and is unchanged here.
140
+ *
141
+ * `retryAccessDenied` (default `true`) gates ONLY the
142
+ * `RestoreAccessDeniedError` branch below — every other classified retry
143
+ * always runs regardless. The ordinary `start()` path (including a plain
144
+ * `GenericContainer.fromCheckpoint(cp).start()` restore) leaves it at the
145
+ * default, delegating to `retryRestoreAfterAccessDenied` — see that
146
+ * method's own doc for why it, like `createCheckpoint`'s own reboot, must
147
+ * mint a fresh name for every retry rather than reusing the one that just
148
+ * failed: the live-verified dossier this backend's fresh-naming behavior
149
+ * is built from says a restore that fails PAST msb's own artifact
150
+ * validation (this access-denied signature included) leaves the attempted
151
+ * name behind as a stopped sandbox record, so a same-name retry only ever
152
+ * collides with that record instead of retrying the actual transient.
153
+ * `createCheckpoint`'s own reboot (`rebootUnderFreshName`) passes `false`:
154
+ * it owns this error class itself, one layer up, via its own fresh-naming
155
+ * loop, and needs this error to propagate on the first hit rather than
156
+ * being retried here at all.
157
+ *
158
+ * `restoreLaunchMode` (default `"direct"`) is threaded straight down to
159
+ * every `bootOnce` call this method makes, including its own internal
160
+ * install-lock/state-db/image-cache retries — POLICY v2's broker
161
+ * escalation, once triggered by the caller (`rebootUnderFreshName`/
162
+ * `retryRestoreAfterAccessDenied`), applies to EVERY remaining attempt of
163
+ * that reboot, not just the ones those callers themselves loop over, so an
164
+ * install-lock hit mid-brokered-attempt must retry brokered too rather
165
+ * than silently reverting to a direct spawn. See `RestoreLaunchMode`'s own
166
+ * doc.
94
167
  */
95
168
  private bootClassified;
96
169
  /**
97
- * One boot attempt: spawns the attached `msb run` child and polls until the
98
- * sandbox reaches Running. `state.attached` and (for non-keepAlive specs)
99
- * `startedNames` are populated only on success; on any failure the child is reaped here (for
170
+ * One boot attempt, dispatched by shape: an ordinary spec (no
171
+ * `checkpointRef`) drives `run()`'s ATTACHED supervision model
172
+ * (`bootRunOnce`); a checkpoint-restore spec drives `msb restore`'s
173
+ * fundamentally different DETACHED shape (`bootRestoreOnce`) — see each
174
+ * method's own doc. Resets the per-attempt diagnostics tail before either:
175
+ * a retried boot must not blend its tail with the failed attempt's.
176
+ * `restoreLaunchMode` is passed straight through to `bootRestoreOnce`
177
+ * (irrelevant to `bootRunOnce`'s own ordinary `run` path, which never
178
+ * brokers — see `RestoreLaunchMode`'s own doc).
179
+ */
180
+ private bootOnce;
181
+ /**
182
+ * One RUN boot attempt: spawns ATTACHED `msb run`, which stays alive as
183
+ * the sandbox's own supervisor for its whole lifetime, and polls
184
+ * `msb ls --format json` until the name shows Running.
185
+ *
186
+ * `state.attached` and (for non-keepAlive specs) `startedNames` are
187
+ * populated only on success; on any failure the child is reaped here (for
100
188
  * the classified early-exit failures it has already exited; a readiness
101
189
  * timeout leaves it alive and it is hard-killed) so a failed attempt leaves
102
190
  * no live process or registered cleanup state behind — the caller owns
103
- * retry policy, never cleanup (an exit-0 fast-exit success is the one
104
- * exception: it IS success, so `state.attached`/`startedNames` ARE
105
- * populated for it — see below). An early exit is classified from the
106
- * child's combined output: the image-cache-corruption signature throws
107
- * `ImageCacheCorruptionError` (the one failure `start()` heals and
108
- * retries), a host-port bind conflict throws `PortBindConflictError`, and
109
- * otherwise — if the exit code was 0 — `isCompletedFastExit` gets a chance
110
- * to reclassify the exit as a workload that ran to completion before this
111
- * poll loop could ever observe Running (msb 0.6.16's convergent-lifecycle
112
- * rework; see that method's own doc); anything else surfaces the raw
113
- * output as an ordinary boot failure, unchanged.
191
+ * retry policy, never cleanup (an exit-0 success without a live child is
192
+ * the one exception: the fast-exit case below, see `isCompletedFastExit`'s
193
+ * own doc). An early exit is classified from the child's combined output:
194
+ * the image-cache-corruption signature throws `ImageCacheCorruptionError`
195
+ * (the one failure `start()` heals and retries), a host-port bind conflict
196
+ * throws `PortBindConflictError`, and otherwise — if the exit code was 0 —
197
+ * `isCompletedFastExit` gets a chance to reclassify the exit as a workload
198
+ * that ran to completion before this poll loop could ever observe Running
199
+ * (msb 0.6.16's convergent-lifecycle rework; see that method's own doc);
200
+ * anything else surfaces the raw output as an ordinary boot failure,
201
+ * unchanged.
114
202
  */
115
- private bootOnce;
203
+ private bootRunOnce;
204
+ /**
205
+ * One RESTORE boot attempt. `msb restore` is not `run`'s attached
206
+ * supervision model at all — EMPIRICALLY VERIFIED against msb 0.7.1 (its
207
+ * own `restore.rs` doc: "Restore a snapshot into a new detached
208
+ * sandbox"), the restore CLI process exits — typically within seconds,
209
+ * often with little or no stdout — the INSTANT activation succeeds, while
210
+ * the sandbox keeps booting in the background and only reaches Running
211
+ * some time after that (live-confirmed: `msb ls` shows Running, and exec
212
+ * works, only once the restore process has already exited). A clean exit
213
+ * is therefore not itself a completed boot, and a nonzero exit is msb's
214
+ * own failure signal with the reason on stdout/stderr — the inverse of
215
+ * `run`'s attached child, whose exit before Running always means failure.
216
+ *
217
+ * Two phases follow directly from those two facts:
218
+ * 1. Spawn `msb restore ...` and wait for IT to exit, bounded by
219
+ * `FIRST_RUN_PULL_TIMEOUT_MS` — the same boot budget `bootRunOnce`
220
+ * polls against; a restore's own activation is exposed to the same
221
+ * "this could be a slow cold operation" uncertainty a pull is.
222
+ * Classified from the combined output the same way any boot's early
223
+ * exit is (install-lock, state-db, image-cache-corruption,
224
+ * port-bind-conflict — see `bootClassified`'s own doc on why a
225
+ * restore boot needs the identical classification an ordinary one
226
+ * gets) — a nonzero, unclassified exit throws an ordinary
227
+ * `BackendError` carrying the output, same shape as `bootRunOnce`'s.
228
+ * 2. Once it exits 0, poll `msb ls` for the sandbox to reach Running —
229
+ * the same `READINESS_POLL_MS` cadence and a fresh instance of the
230
+ * identical `FIRST_RUN_PULL_TIMEOUT_MS` budget the attached path's
231
+ * own poll uses (so a restore boot gets the identical readiness
232
+ * patience an ordinary one does, counted from when there is
233
+ * actually something to poll for rather than shaved out of phase
234
+ * 1's own budget). The sandbox settling on exactly `"Stopped"`, or
235
+ * its name disappearing from a `msb ls` listing that itself came
236
+ * back, is a definite failure the moment it is observed — never
237
+ * worth waiting out the rest of the budget for, unlike a status
238
+ * that simply hasn't reached Running yet.
239
+ * 3. The `ls` probe itself failing to run or return in time (spawn
240
+ * error, or the same `LOGS_TIMEOUT_MS` `invoke` enforces on every
241
+ * other probe) is a different thing entirely from the sandbox's name
242
+ * being missing from a listing that DID come back, even though both
243
+ * collapse to the same `undefined` once the probe's own promise is
244
+ * caught — see `isCompletedFastExit`'s own doc, whose sibling
245
+ * contract this mirrors: a probe failure must never be upgraded into
246
+ * a false, specific diagnosis ("disappeared from msb ls entirely" is
247
+ * exactly that kind of false claim when the truth is just that `ls`
248
+ * itself didn't answer in time). A failed probe is therefore treated
249
+ * as "not yet confirmed either way" and retried within budget —
250
+ * install-lock races and an overloaded msb daemon are the same
251
+ * transient shapes this file already documents for `run`/`restore`/
252
+ * `stop`, and one `ls` hiccup during the readiness window must not
253
+ * turn into a spurious restore failure.
254
+ *
255
+ * The `msb restore` CLI process itself is never held onto — it has already
256
+ * exited by the time phase 2 even starts, and msb itself (out-of-process)
257
+ * is the sandbox's own supervisor from here on, the same as it always was.
258
+ * But a restored sandbox reaches Running with ONLY its guest agent inside
259
+ * — the captured workload never re-executes on its own (EMPIRICALLY
260
+ * VERIFIED against msb 0.7.1) — so once Running is confirmed, this method
261
+ * calls `reviveWorkload` to start it itself BEFORE returning: THAT call is
262
+ * what populates `state.attached` (a workload-revival `msb exec` child,
263
+ * not the restore CLI process), and `stop()`'s ordinary attached-child
264
+ * handling (exit-based death detection, the SIGKILL escalation) applies to
265
+ * IT exactly as it always did for `bootRunOnce`'s own attached `msb run`
266
+ * child. See `reviveWorkload`'s own doc for the full revival contract,
267
+ * including the typed error a checkpoint predating workload-cmdline
268
+ * capture throws instead of booting silently idle.
269
+ *
270
+ * Phase 1 (spawning the restore itself and reading back its own exit code
271
+ * + combined output) is dispatched by `launchMode` to either
272
+ * `launchRestoreDirect` (the unconditional spawn this method has always
273
+ * used) or `launchRestoreViaBroker` (POLICY v2's Windows job-object
274
+ * escape hatch — see `RestoreLaunchMode`/`restore-broker.ts`'s own doc).
275
+ * Both return the exact same shape, classified identically right below —
276
+ * a brokered attempt's output goes through the SAME predicates
277
+ * (`isImageCacheCorruption`, `isRestoreAccessDeniedFailure`, ...) a direct
278
+ * attempt's always has, since the underlying `msb restore` invocation and
279
+ * its failure signatures are identical either way; only how its stdout/
280
+ * stderr and exit code got back to this process differs. A broker launch
281
+ * can also come back `"unconfirmed"` (its own `ecFile` never appeared
282
+ * within its bound) — see `RestoreBrokerUnconfirmed`'s own doc — which
283
+ * skips classification entirely and falls straight through to phase 2
284
+ * exactly as a confirmed exit 0 does, since that phase's own `msb ls` poll
285
+ * is activation-gated and settles the question on its own regardless.
286
+ */
287
+ private bootRestoreOnce;
288
+ /**
289
+ * `bootRestoreOnce`'s own DIRECT launch — the unconditional child-process
290
+ * spawn every restore attempt used before POLICY v2's broker escalation
291
+ * existed, extracted unchanged from that method's own former body so the
292
+ * two `RestoreLaunchMode`s share one classification point (see
293
+ * `bootRestoreOnce`'s own doc). Bounded by the same `FIRST_RUN_PULL_TIMEOUT_MS`
294
+ * budget `bootRunOnce` polls against, and always resolves `"completed"` —
295
+ * a direct spawn has no analog of the broker's own `"unconfirmed"` outcome,
296
+ * since this process reads the child's real exit code directly.
297
+ */
298
+ private launchRestoreDirect;
299
+ /**
300
+ * `bootRestoreOnce`'s own BROKERED launch — POLICY v2's Windows job-object
301
+ * escape hatch (see `RestoreLaunchMode`/`restore-broker.ts`'s own module
302
+ * doc on the root cause and the live-validated WMI mitigation). Calls the
303
+ * injected `restoreBroker` seam with the exact same argv a direct attempt
304
+ * would spawn (`MsbCommands.restore`, unmodified) and the same
305
+ * `FIRST_RUN_PULL_TIMEOUT_MS` budget.
306
+ *
307
+ * The broker's own result is either passed straight through
308
+ * (`"completed"`/`"unconfirmed"`, both appended to `state.logTail` for the
309
+ * same pre-Running diagnostics purpose `drainTail` serves on the direct
310
+ * path) or, if the broker call itself REJECTS — broker infrastructure
311
+ * failing (a missing `powershell.exe`, a script-file write failure, WMI
312
+ * refusing to even create the process; see `RestoreBrokerLauncher`'s own
313
+ * doc on what counts as this vs. an ordinary brokered-restore failure) —
314
+ * caught here and turned into a one-attempt fallback to
315
+ * `launchRestoreDirect` instead (POLICY v2 point 5: the broker must never
316
+ * become a new single point of failure). That fallback is scoped to THIS
317
+ * attempt only; it does not touch the caller's own `RestoreLaunchMode` for
318
+ * whatever attempt comes after it.
319
+ */
320
+ private launchRestoreViaBroker;
321
+ /**
322
+ * Renders a restore boot's post-mortem message once its poll for Running
323
+ * has definitely failed (a settled `"Stopped"`, the name disappearing
324
+ * from `msb ls` outright, or the readiness budget itself running out).
325
+ * `msb restore` itself already exited 0 by this point, so the CLI
326
+ * process's own output (already surfaced for a nonzero exit, above) has
327
+ * nothing left to add — `msb logs --source system` is the one channel
328
+ * that can still show what happened to the sandbox's own background boot
329
+ * (see `MsbCommands.systemLog`'s own doc and `isCompletedFastExit`, its
330
+ * other caller). Best-effort: a failed probe falls back to a plain "no
331
+ * output" note rather than letting a diagnostics-gathering failure itself
332
+ * throw and mask the real one.
333
+ */
334
+ private restoreBootFailureMessage;
335
+ /**
336
+ * Starts the workload a restore itself never re-runs. EMPIRICALLY VERIFIED
337
+ * against msb 0.7.1: a restored sandbox reaches Running with ONLY its
338
+ * guest agent inside (`guest ps` shows `/init.krun` and kernel threads —
339
+ * the captured workload command does not re-execute; `msb start`/`msb
340
+ * logs` on such a sandbox are equally idle/empty). Only ever called from
341
+ * `bootRestoreOnce`, once it has confirmed Running — see this class's own
342
+ * doc on `bootRestoreOnce`.
343
+ *
344
+ * Spawns a LONG-LIVED, attached `msb exec [-e K=V]... <name> -- <argv>`
345
+ * session (`MsbCommands.execWithEnv`, env from `handle.spec.env` — a
346
+ * restore's own `msb restore` has no `-e`/`--env` flag at all, so this exec
347
+ * is the one place a restored sandbox's guest ever sees it again) — this
348
+ * becomes the sandbox's own workload from here on, and exec sessions are
349
+ * exactly what msb's own log capture records (the primary session's
350
+ * stdout/stderr land in `exec.log`, served by `msb logs`/`-f` — see
351
+ * `MsbCommands.exec`'s own doc). This exec child slots into the EXACT SAME
352
+ * `state.attached` role `bootRunOnce`'s own attached `msb run` child fills
353
+ * for an ordinary boot: child-exit-based death detection, reap-on-stop
354
+ * (`stop()`'s SIGKILL escalation), and every other attached-child teardown
355
+ * semantic apply to it unchanged — the detached-restore round left that
356
+ * slot merely optional, never removed it.
357
+ *
358
+ * `handle.spec.command` is ALREADY the fully-resolved workload argv by the
359
+ * time this runs, in priority order: an explicit command the source
360
+ * container carried, or — when it had none — the guest cmdline
361
+ * `createCheckpoint` captured at checkpoint time, merged in by
362
+ * `createCheckpoint` itself (for its own immediate reboot) or by
363
+ * `fromCheckpointRegistryEntry` (for a registry-mediated restore, same or
364
+ * later process — see both functions' own docs). `undefined` here means
365
+ * NEITHER source exists — an old registry entry predating capture, or one
366
+ * whose capture attempt itself failed — so this throws
367
+ * `CheckpointWorkloadCommandMissingError` itself rather than depending on
368
+ * a caller to have checked first: never boot a restored sandbox silently
369
+ * idle.
370
+ *
371
+ * The exec child gets a brief settle window (`EXEC_REVIVE_SETTLE_MS`) to
372
+ * prove it isn't an immediate boot failure before this returns success —
373
+ * mirroring `bootRunOnce`'s own exit-vs-Running race, except a restore's
374
+ * exec session has no separate "Running" signal of its own to poll for
375
+ * (the SANDBOX already reports Running regardless of whether this exec
376
+ * succeeds), so a settle window is what that race collapses to here. An
377
+ * exit observed within the window is classified exactly like
378
+ * `bootRunOnce`'s own early exit: exit 0 counts as success only when
379
+ * `isCompletedFastExit` ALSO confirms it (the sandbox itself settled
380
+ * Stopped with the boot-completion marker — the repo's existing fast-exit-
381
+ * completion semantics, reused verbatim); any other exit — nonzero, or
382
+ * exit 0 without that confirmation — throws a `BackendError` carrying the
383
+ * exec child's own output, the same failure shape an attached run's early
384
+ * exit already has.
385
+ */
386
+ private reviveWorkload;
387
+ /**
388
+ * Best-effort: execs `CAPTURE_WORKLOAD_CMDLINE_SCRIPT` in `handle`'s guest
389
+ * and parses its stdout via `parseCapturedWorkloadCmdline`. Only ever
390
+ * called from `createCheckpoint`, BEFORE it stops the sandbox (see that
391
+ * method's own doc), and only when `handle.spec.command` is undefined — an
392
+ * explicit command needs no capture. Never throws: an exec failure
393
+ * (nonzero exit, the exec channel itself erroring) and unparseable output
394
+ * both resolve `undefined` rather than failing the checkpoint — the
395
+ * captured cmdline is a best-effort fallback, and its absence is fully
396
+ * handled later, at restore time (`reviveWorkload` throws
397
+ * `CheckpointWorkloadCommandMissingError` when neither an explicit nor a
398
+ * captured command exists).
399
+ */
400
+ private captureGuestWorkloadCmdline;
401
+ /**
402
+ * SPI implementation of `SandboxBackend.capturedWorkloadCommand` — see its
403
+ * own doc. Reads back whatever `createCheckpoint` most recently stashed on
404
+ * this sandbox's `HandleState` (keyed by name, so it survives the
405
+ * stop/snapshot/reboot cycle's own handle-object churn); `undefined` if
406
+ * this sandbox was never checkpointed, or its checkpoint needed no
407
+ * capture, or the capture attempt failed.
408
+ */
409
+ capturedWorkloadCommand(handle: SandboxHandle): ReadonlyArray<string> | undefined;
116
410
  /**
117
411
  * The fast-exit post-mortem classification: only ever consulted from
118
- * `bootOnce` above, and only once the attached `msb run` child has already
412
+ * `bootRunOnce` above, and only once the attached `msb run` child has already
119
413
  * exited with code 0 before Running was observed. msb 0.6.16's
120
414
  * convergent-lifecycle rework means a workload that finishes quickly is
121
415
  * never observed `"Running"` at all — only `"Starting"`, then the attached
@@ -139,30 +433,237 @@ export declare class MsbCliBackend implements SandboxBackend {
139
433
  private isCompletedFastExit;
140
434
  stop(handle: SandboxHandle): Promise<void>;
141
435
  remove(handle: SandboxHandle): Promise<void>;
436
+ /**
437
+ * `bootClassified`'s own retry for a `RestoreAccessDeniedError` hit on the
438
+ * ordinary `start()`/`fromCheckpoint().start()` path (its default
439
+ * `retryAccessDenied: true`) — the sibling of `rebootUnderFreshName`
440
+ * (`createCheckpoint`'s own reboot retry) for the SAME classified failure,
441
+ * built from the SAME live-verified policy: `msb restore --name X`
442
+ * validates the artifact FIRST — an integrity failure exits 1 and leaves
443
+ * NO sandbox record — but a failure AFTER validation (this access-denied
444
+ * signature included) leaves `X` behind as a STOPPED SANDBOX RECORD, and
445
+ * any retry of `restore --name X` then fails outright with msb's own
446
+ * "already exists" refusal rather than hitting the transient a second
447
+ * time. A same-name retry (what this method replaces) therefore only ever
448
+ * proves that collision — never the actual transient — so this mints a
449
+ * NEW name from `nextSandboxName()` for every retry instead, the same
450
+ * generator `GenericContainer.start()`'s own boot loop and
451
+ * `rebootUnderFreshName` both use, tracking it in the reaper ledger BEFORE
452
+ * that attempt's restore runs and best-effort `msb rm`-ing (then
453
+ * untracking) a failed attempt's own name before advancing — identical
454
+ * per-attempt bookkeeping to `rebootUnderFreshName`, just owned one layer
455
+ * lower.
456
+ *
457
+ * The difference from `rebootUnderFreshName` is entirely about WHO owns
458
+ * the re-keying, not the retry policy itself: `createCheckpoint` calls
459
+ * `rebootUnderFreshName` with no live sandbox left at all (the source was
460
+ * already stopped and removed as part of the snapshot step), so EVERY
461
+ * attempt — including the first — mints a fresh name, and the winning
462
+ * handle is handed back for `createCheckpoint` itself to re-key. Here,
463
+ * `handle` is already live under its own name when `first` (the initial
464
+ * `RestoreAccessDeniedError`) is caught, and `bootClassified`'s caller
465
+ * (`start()`) has no re-keying step of its own — it just awaits
466
+ * `bootClassified` and returns — so this method re-keys
467
+ * `this.handles`/`this.startedNames` AND mutates the caller's own `handle`
468
+ * object IN PLACE itself on success, the same "one carve-out"
469
+ * `SandboxHandle`'s own interface doc allows for `createCheckpoint`'s
470
+ * reboot (see that method's own doc on `handle.id`/`handle.spec`
471
+ * mutation).
472
+ *
473
+ * `handle`'s own original name was already tracked in the reaper ledger by
474
+ * `GenericContainer.start()`'s own boot loop before `create()`/`start()`
475
+ * ever ran (mirroring `rebootUnderFreshName`'s treatment of the checkpoint
476
+ * reboot's own `originalName`) — this method best-effort `msb rm`'s it
477
+ * once `first` is confirmed retryable, but deliberately leaves its own
478
+ * ledger entry alone, for the same not-found-tolerant sweep to find,
479
+ * rather than untracking a name this method never tracked itself.
480
+ *
481
+ * Retries up to `RESTORE_ACCESS_DENIED_RETRY_LIMIT` times,
482
+ * `RESTORE_ACCESS_DENIED_RETRY_DELAY_MS` apart — unchanged from the
483
+ * same-name retry this replaces. Each attempt goes through
484
+ * `bootClassified` itself with `retryAccessDenied: false` (so a
485
+ * `StateDbError`/`InstallLockActiveError`/`ImageCacheCorruptionError` hit
486
+ * mid-retry still gets its own ordinary classified handling; only
487
+ * `RestoreAccessDeniedError`'s own retry is owned here) and treats either
488
+ * `RestoreAccessDeniedError` or `SandboxAlreadyExistsError` as retryable —
489
+ * a freshly minted name should never collide, but `rebootUnderFreshName`'s
490
+ * own live-CI evidence says a loaded host can still refuse one, and the
491
+ * SAME fresh-naming response (rm the failed name, mint another) covers
492
+ * both signatures identically. Anything else propagates immediately,
493
+ * unretried.
494
+ *
495
+ * POLICY v2's broker escalation applies here too, on the exact same terms
496
+ * as `rebootUnderFreshName`'s own (see that method's own doc): this
497
+ * method is only ever entered once the reboot's first, always-direct
498
+ * attempt has already hit `RestoreAccessDeniedError` (`first`), so on a
499
+ * Windows host (`isWindowsPlatform()`) every retry THIS method itself
500
+ * performs is, by construction, an attempt after that trigger — there is
501
+ * no "first attempt still direct" sub-case to preserve inside this loop
502
+ * the way `rebootUnderFreshName` has to across its own. `launchMode` is
503
+ * therefore decided once, before the loop, not re-evaluated per attempt.
504
+ */
505
+ private retryRestoreAfterAccessDenied;
506
+ /**
507
+ * `createCheckpoint`'s own reboot step. Mints a NEW sandbox name from
508
+ * `nextSandboxName()` (the same generator `GenericContainer.start()`'s own
509
+ * boot loop uses) for EVERY attempt, tracks it in the reaper ledger BEFORE
510
+ * that attempt's restore runs (mirroring `GenericContainer.start()`'s own
511
+ * `trackSandbox` call, keepAlive-excluded the same way), and retries two
512
+ * classified failure signatures on that basis instead of surfacing either
513
+ * immediately: msb's "sandbox already exists" refusal
514
+ * (`SandboxAlreadyExistsError`, see `isSandboxAlreadyExistsFailure`'s own
515
+ * doc) and its Windows access-denied refusal on the just-written snapshot
516
+ * artifact (`RestoreAccessDeniedError`, see `isRestoreAccessDeniedFailure`'s
517
+ * own doc — retried here rather than inside `bootClassified` itself, via
518
+ * that method's own `retryAccessDenied: false`, see its doc).
519
+ *
520
+ * LIVE-VERIFIED POLICY this retry is built from: `msb restore --name X`
521
+ * validates the artifact FIRST — an integrity failure exits 1 and leaves NO
522
+ * sandbox record — but a failure AFTER validation (a block-device open
523
+ * PermissionDenied on unix; `RestoreAccessDeniedError`'s own signature on
524
+ * Windows) leaves `X` behind as a STOPPED SANDBOX RECORD visible in `msb
525
+ * ls`, and any retry of `restore --name X` then fails with msb's own
526
+ * "already exists" refusal — so a same-name retry of EITHER classified
527
+ * failure only ever collides with itself. Reusing a failed attempt's name
528
+ * is therefore never safe (`msb rm` alone is not reliably enough either: on
529
+ * Windows it frees only the DB record, not the on-disk directory, which can
530
+ * outlive it well past this budget — see the fresh-name reboot's own doc on
531
+ * `createCheckpoint`) — the only universally safe policy is a NEW name per
532
+ * attempt, same as this backend's ordinary `start()` boot loop already uses
533
+ * for its own, unrelated port-conflict retries. On a classified failure
534
+ * this best-effort `msb rm`'s the failed attempt's own name anyway (result
535
+ * ignored — cheap cleanup, and correctness no longer depends on it now
536
+ * that the next attempt never reuses that name) and untracks it from the
537
+ * ledger, before advancing to a freshly minted name for the next attempt.
538
+ *
539
+ * `this.checkpointRebootAlreadyExistsRetryBudgetMs`/`_RetryDelayMs` back
540
+ * this loop's overall deadline/delay rather than the bare
541
+ * `CHECKPOINT_REBOOT_ALREADY_EXISTS_RETRY_BUDGET_MS`/`_DELAY_MS` constants
542
+ * directly, so a budget-exhaustion test can shrink them to milliseconds
543
+ * instead of actually blocking for the real ~30s — see those fields' own
544
+ * doc. Only `SandboxAlreadyExistsError`/`RestoreAccessDeniedError` are
545
+ * retried here; any other failure `bootClassified` throws (on the first
546
+ * attempt or a later one) propagates immediately, unretried, WITHOUT
547
+ * advancing to a new name — `createCheckpoint`'s own caller untracks that
548
+ * attempt's name on this path (see its own catch block). Not reached by
549
+ * the ordinary `start()` path: a `GenericContainer.fromCheckpoint(cp)
550
+ * .start()` restore's own `RestoreAccessDeniedError` is retried by its own
551
+ * sibling, `retryRestoreAfterAccessDenied` (see that method's own doc) —
552
+ * same fresh-naming policy, applied one layer lower since that path has no
553
+ * separate caller left to hand a replacement handle to for re-keying.
554
+ *
555
+ * Returns the WINNING attempt's own `SandboxHandle` (a fresh object, never
556
+ * `undefined`) — `createCheckpoint` re-keys its runtime registries and the
557
+ * caller's live handle from this, not from a name minted up front.
558
+ *
559
+ * POLICY v2's broker escalation (see `RestoreLaunchMode`/`restore-broker.ts`'s
560
+ * own module doc on the Windows job-object root cause and the live-
561
+ * validated WMI mitigation) lives here: `launchMode` starts `"direct"` —
562
+ * the first attempt of ANY reboot is always a direct spawn, unconditionally,
563
+ * even on Windows — and flips to `"broker"` the moment an attempt hits
564
+ * `RestoreAccessDeniedError` on a Windows host (`isWindowsPlatform()`);
565
+ * every attempt from then on, for the rest of THIS reboot, launches
566
+ * brokered instead, never downgrading back to direct even if a later
567
+ * attempt's own failure is only `SandboxAlreadyExistsError` (still
568
+ * retried, but no longer the trigger — only access-denied escalates).
569
+ * Off Windows, `launchMode` never leaves `"direct"` at all — this reboot's
570
+ * whole retry loop behaves exactly as it did before this policy existed.
571
+ */
572
+ private rebootUnderFreshName;
142
573
  /**
143
574
  * The stop/snapshot/reboot cycle: `msb stop <name>` (reusing this
144
575
  * backend's own `stop()`, which also quiesces the attached child and any
145
- * network-link tunnels), `msb snapshot create --from <name> <ref>`, then
146
- * `msb rm <name>` followed by a fresh ATTACHED boot of the SAME name from
147
- * that snapshot — never `msb start`. Upstream's detached-start path
148
- * (`Sandbox::start_detached`) passes `CREATE_BREAKAWAY_FROM_JOB` on
149
- * Windows, which `ERROR_ACCESS_DENIED`s outright whenever the msb CLI runs
150
- * inside a job object that doesn't grant breakaway rights — a Gradle/
151
- * cargo/node test runner on a hosted Windows runner, or any process that
152
- * embeds this library inside its own restrictive job object — and that
153
- * denial is deterministic, not transient, so no retry shape fixes it.
154
- * Attached `msb run` (this backend's normal boot, including `--from-snapshot`
155
- * boots) never hits it, which is why the reboot reuses `bootOnce` instead
156
- * of resuming in place: the stopped sandbox's disk state is already IN the
157
- * snapshot, so `rm`-ing it first and booting a fresh attached sandbox
158
- * under the same name/ports/env/memory (via a spec identical to
159
- * `handle.spec` except `checkpointRef` set to the new `ref`) reproduces
160
- * the exact same observable contract. `bootOnce` swaps `state.attached` to
161
- * the freshly spawned child itself; nothing about `this.handles`/
162
- * `startedNames` or the reaping ledger changes, since the name never
163
- * changed. Its workload restarts from scratch (the VM reboots), which is
164
- * why `capabilities.checkpointRestartsWorkload` is `true` here and the
165
- * generic layer re-runs the wait strategy after this returns.
576
+ * network-link tunnels), `msb snapshot create --from-sandbox <name> <ref>`
577
+ * (whose printed artifact path — never `ref` itself — becomes the
578
+ * EFFECTIVE checkpoint ref this method returns; see
579
+ * `parseSnapshotCreateArtifactPath`), then `msb rm <name>` followed by
580
+ * `msb restore <effective-ref> --name <fresh-name>` of a FRESH name from
581
+ * that snapshot (via `rebootUnderFreshName`/`bootRestoreOnce`, see
582
+ * `MsbCommands.restore`'s own doc — no `--disk-only`, which a disk-scope
583
+ * snapshot rejects) — never `msb start`. The fresh name — never the
584
+ * original — is minted by `nextSandboxName()` (the same generator
585
+ * `GenericContainer.start()`'s own ordinary boot loop uses; see
586
+ * `core/sandbox-name.ts`), and a NEW one is minted for EVERY attempt, never
587
+ * just once for the whole reboot: EMPIRICALLY VERIFIED against a real msb
588
+ * 0.7.1 binary, a restore that fails PAST msb's own artifact validation
589
+ * (its Windows access-denied signature, `RestoreAccessDeniedError`) leaves
590
+ * its `--name` behind as a STOPPED SANDBOX RECORD, and any retry under that
591
+ * SAME name then collides with msb's own restore-time collision check
592
+ * (`existing.is_some() || dir_exists`) immediately — CONFIRMED on Windows
593
+ * CI, where five checkpoint tests collided on their fresh reboot names for
594
+ * the retry loop's ENTIRE budget once the first attempt hit that
595
+ * access-denied failure (see `CHECKPOINT_REBOOT_ALREADY_EXISTS_RETRY_BUDGET_MS`'s
596
+ * own doc). A same-name retry therefore only ever retries through a race of
597
+ * its own making; a NEW name per attempt sidesteps it structurally — a name
598
+ * nothing else has ever used can never collide with a record or a lingering
599
+ * directory left by a name nothing will ever restore under again (see
600
+ * `rebootUnderFreshName`'s own doc for the full retry policy, including its
601
+ * best-effort `msb rm` of each failed attempt's own name). On success, this method
602
+ * mutates `handle.id` and `handle.spec` (name only — see below) IN PLACE
603
+ * on the caller's own `handle`, so every subsequent operation against it
604
+ * (exec/logs/stop/rm, and — one layer up, via the SAME `SandboxHandle`
605
+ * reference — `GenericContainer.checkpoint()`'s own post-reboot
606
+ * `installNetworkLinks`/wait-strategy re-run and `capturedWorkloadCommand()`
607
+ * call) targets the sandbox actually running now. `SandboxBackend`'s own
608
+ * interface doc calls `SandboxHandle` "immutable" — this is the one
609
+ * carve-out, and only this method makes it: a checkpoint reboot is the one
610
+ * operation that changes what sandbox a handle even refers to. `ports`,
611
+ * `env`, and `memoryLimitMb` are untouched by the rename — same ports, env,
612
+ * and memory ceiling as before the reboot, only the name differs.
613
+ * Upstream's
614
+ * detached-start path (`Sandbox::start_detached`) passes
615
+ * `CREATE_BREAKAWAY_FROM_JOB` on Windows, which `ERROR_ACCESS_DENIED`s
616
+ * outright whenever the msb CLI runs inside a job object that doesn't
617
+ * grant breakaway rights — a Gradle/cargo/node test runner on a hosted
618
+ * Windows runner, or any process that embeds this library inside its own
619
+ * restrictive job object — and that denial is deterministic, not
620
+ * transient, so no retry shape fixes it. Before 0.7.1, attached `msb run`
621
+ * (including its `--from-snapshot` boots) never hit this, which is why the
622
+ * reboot has always gone through the classified boot path (`bootClassified`)
623
+ * rather than `msb start`. `msb restore` has no attached/detached
624
+ * distinction of its own — its CLI process always calls `sandbox.detach()`
625
+ * internally and exits once activation is confirmed (see
626
+ * `bootRestoreOnce`'s own doc on why its exit is a SUCCESS signal, not
627
+ * something to reap) — so whether it shares upstream's
628
+ * `CREATE_BREAKAWAY_FROM_JOB` path on Windows is NOT verified here: this
629
+ * backend never runs the real msb binary (see this repo's hard "never boot
630
+ * a sandbox" rule), so this is a real open question for CI, not something
631
+ * this migration could confirm either way. If it recurs, it will surface
632
+ * as an ordinary unclassified `BackendError` out of `bootRestoreOnce`
633
+ * (deterministic exit-code failure, not one of the classified transients
634
+ * above), the same shape a genuine breakaway denial always took.
635
+ * `rm`-ing the sandbox first and restoring a fresh one under a fresh
636
+ * name, same ports/memory (via a spec identical to `handle.spec` except
637
+ * `name`/`checkpointRef` set to the fresh name and the EFFECTIVE ref this
638
+ * method discovers, below — env is no longer threaded through at all, see
639
+ * `MsbCommands.restore`) reproduces the exact same observable contract
640
+ * MODULO the name itself, which was always an implementation detail, not
641
+ * part of what a checkpoint promises to preserve. This backend's own
642
+ * `handles`/`startedNames` registries ARE re-keyed from the original name
643
+ * to the WINNING attempt's name (see below), and each attempt's own fresh
644
+ * name is tracked in the reaping ledger before that attempt's restore is
645
+ * even attempted, exactly like an ordinary `create()` — a failed attempt's
646
+ * name is untracked again by `rebootUnderFreshName` itself, while the
647
+ * original name's own ledger entry is deliberately left alone throughout,
648
+ * for the ledger's existing not-found-tolerant sweep to find (it was
649
+ * already `msb rm`-ed above). Its workload restarts from scratch (the VM
650
+ * reboots), which is why `capabilities.checkpointRestartsWorkload` is
651
+ * `true` here and the generic layer re-runs the wait strategy after this
652
+ * returns — AFTER `bootRestoreOnce` has already revived the workload
653
+ * itself via `reviveWorkload` (see that method's own doc): `msb restore`
654
+ * boots the reboot idle, only the guest agent inside, so `bootRestoreOnce`
655
+ * no longer leaves `state.attached` untouched the way it did before this
656
+ * revival step existed — it now carries the workload-revival exec child,
657
+ * the same attached-child slot `bootRunOnce`'s own `msb run` child fills
658
+ * for an ordinary boot.
659
+ *
660
+ * Before stopping the source sandbox, this also best-effort captures its
661
+ * guest workload cmdline (`captureGuestWorkloadCmdline`) when
662
+ * `handle.spec.command` is undefined — the image's own default entrypoint
663
+ * was running, so there is no explicit command for `reviveWorkload` to
664
+ * fall back on at either this method's own immediate reboot or a later,
665
+ * registry-mediated restore. See that method's own doc for why a capture
666
+ * failure never fails the checkpoint itself.
166
667
  *
167
668
  * If the snapshot step fails, the sandbox is left stopped — no
168
669
  * best-effort restart, since that restart would itself be the broken `msb
@@ -174,19 +675,52 @@ export declare class MsbCliBackend implements SandboxBackend {
174
675
  *
175
676
  * Refuses outright, before any of the above, when `handle.spec.tmpfsRootMb`
176
677
  * is set: a tmpfs root has nothing on disk for a snapshot to capture.
678
+ *
679
+ * `ref` is the WORKING ref this method is asked to checkpoint under — its
680
+ * basename becomes the snapshot create call's `<name>` argument (still
681
+ * meaningful: it lands in msb's own index, see `MsbCommands.snapshotCreate`'s
682
+ * own doc) and, for a path ref, its dirname becomes `--dest-dir`. It is
683
+ * NOT necessarily where the artifact ends up: EMPIRICALLY VERIFIED against
684
+ * a real msb 0.7.1 binary, `snapshot create` always writes under
685
+ * `<destDir-or-default>/<sandbox>/snap_<32-hex-digest>`, a path `name`
686
+ * never determines. This method therefore parses that real artifact path
687
+ * back out of the command's own stdout (last non-empty line, required to
688
+ * be absolute — see `parseSnapshotCreateArtifactPath`) and returns THAT as
689
+ * the EFFECTIVE ref — the one used for the reboot below and the one the
690
+ * caller (`GenericContainer.checkpoint()`) stores in the registry and
691
+ * hands back on the `Checkpoint` it returns. Malformed or unrecognizable
692
+ * output (empty, no absolute last line) throws a `BackendError` quoting
693
+ * the raw, unparsed output verbatim rather than guessing a ref.
177
694
  */
178
- createCheckpoint(handle: SandboxHandle, ref: string): Promise<void>;
695
+ createCheckpoint(handle: SandboxHandle, ref: string): Promise<string>;
179
696
  /**
180
- * Best-effort `msb snapshot rm <basename(ref)>` — "not found" is success,
181
- * the same contract as `removeByName`. msb's own removal deletes both its
182
- * index entry and the dest-dir artifact for a path ref, but afterwards this
183
- * also best-effort recursively deletes the ref path itself: if msb's index
184
- * ever loses track of an artifact without deleting it, the directory would
185
- * otherwise linger under the cache dir forever. That recursive delete is
186
- * gated on `looksLikeCheckpointArtifactDir` first — a `ref` is caller-
187
- * supplied (a corrupt registry entry, a hand-edited env var, …), and a
188
- * `fs.rm(ref, { recursive: true })` on an unverified path would happily
189
- * wipe out an arbitrary directory that merely happens to share its name.
697
+ * Best-effort `msb snapshot rm <ref> -f` — "not found" is success, the
698
+ * same contract as `removeByName`. `ref` is passed FULL, never reduced to
699
+ * `path.basename(ref)`: EMPIRICALLY VERIFIED against a real msb 0.7.1
700
+ * binary, name-based removal does not resolve at all — the artifact PATH
701
+ * is the only address that reliably works (see `MsbCommands.snapshotRemove`'s
702
+ * own doc). Every real caller already hands this the EFFECTIVE ref
703
+ * `createCheckpoint` returned (an absolute path since 0.7.1), so this is
704
+ * also simply correct for the common case, not just a defensive choice.
705
+ *
706
+ * One failure shape is deliberately NOT swallowed alongside "not found":
707
+ * msb refuses to remove a snapshot that is still the current HEAD of
708
+ * older siblings from the same source sandbox (see
709
+ * `isSnapshotHeadRemovalRefused`) — that refusal propagates as a
710
+ * `BackendError` naming msb's own remedy, rather than silently doing
711
+ * nothing, since automatic head rotation is out of scope for this method
712
+ * (see the checkpoints guide's cleanup section for the documented
713
+ * limitation).
714
+ *
715
+ * msb's own removal deletes both its index entry and the dest-dir
716
+ * artifact for a path ref, but afterwards this also best-effort
717
+ * recursively deletes the ref path itself: if msb's index ever loses
718
+ * track of an artifact without deleting it, the directory would otherwise
719
+ * linger under the cache dir forever. That recursive delete is gated on
720
+ * `looksLikeCheckpointArtifactDir` first — a `ref` is caller-supplied (a
721
+ * corrupt registry entry, a hand-edited env var, …), and a `fs.rm(ref, {
722
+ * recursive: true })` on an unverified path would happily wipe out an
723
+ * arbitrary directory that merely happens to share its name.
190
724
  */
191
725
  removeCheckpoint(ref: string): Promise<void>;
192
726
  /**
@@ -213,10 +747,10 @@ export declare class MsbCliBackend implements SandboxBackend {
213
747
  /**
214
748
  * `msb snapshot save <ref> <destFile>` — writes the `.tar.zst` artifact
215
749
  * `Checkpoints.exportTo` bundles into its own archive container. Never
216
- * `--with-image` (see the checkpoints guide's own note on why: its import
217
- * fails an integrity check in 0.6.6, so archives never bundle the OCI
218
- * image — the destination machine pulls it on the restored container's
219
- * first boot).
750
+ * `--with-image` (see the checkpoints guide's own note: this library
751
+ * doesn't bundle the OCI image into the archive, so the destination
752
+ * machine pulls it on the restored container's first boot — upstream
753
+ * supports bundling it, this library just doesn't use that yet).
220
754
  *
221
755
  * On Windows, msb 0.6.7 and 0.6.8 fail this call every single time: they
222
756
  * finish writing the archive to a staging file beside the destination and
@@ -233,24 +767,45 @@ export declare class MsbCliBackend implements SandboxBackend {
233
767
  */
234
768
  exportCheckpoint(ref: string, destFile: string): Promise<void>;
235
769
  /**
236
- * `msb snapshot load <archive>`, then resolves the EFFECTIVE ref: msb
237
- * writes the import under a digest-derived directory name it never lets
238
- * the caller choose (never the archive's own recorded `ref`), parsed from
239
- * the printed artifact path's basename (see `parseImportedDigestDirName`).
770
+ * `msb snapshot load <archive> --dest <checkpointsDir>`, then resolves the
771
+ * EFFECTIVE ref: EMPIRICALLY VERIFIED against a real msb 0.7.1 binary, a
772
+ * successful load prints a `group msb-<hex>: head snap_<digest>
773
+ * (Initialized)` line, a digest line, and finally the loaded artifact's
774
+ * own absolute path as its LAST line — `<checkpointsDir>/<generated-
775
+ * group>/snap_<digest>`, never the archive's own recorded `ref` — parsed
776
+ * back out by `parseImportedArtifactPath` (the same last-non-empty-line,
777
+ * must-be-absolute defensive pattern `parseSnapshotCreateArtifactPath`
778
+ * uses for `snapshot create`). `checkpointsDir` is always this backend's
779
+ * own checkpoints cache directory (`<cacheDir>/checkpoints`, the same
780
+ * directory `checkpointRef()` mints created-checkpoint refs under) —
781
+ * omitting `--dest` would import into msb's own global default snapshot
782
+ * store instead, outside anywhere this library looks for or cleans up
783
+ * checkpoints. Created fresh (mkdir -p) before the load, since nothing
784
+ * guarantees a caller ever created a checkpoint locally first (an
785
+ * imported-only machine has no reason to have this directory yet).
786
+ *
787
+ * This replaces the pre-0.7.1 shape entirely: `snapshot load` used to
788
+ * write under a digest-derived directory name with no `--dest` of its
789
+ * own, so this method had to parse out just that bare NAME and then
790
+ * separately CONFIRM it via `msb snapshot list --format json` before
791
+ * handing it back (the only way to get a usable ref at all, since the
792
+ * bare name alone still had to resolve against msb's default store). 0.7.1
793
+ * prints the full, directly-usable, already-`--dest`-scoped path as part
794
+ * of `load`'s own output, so that whole list-and-confirm round trip is
795
+ * gone — dead code that this migration deletes outright, not merely
796
+ * bypassed.
797
+ *
240
798
  * An already-exists failure — msb's own content-addressed dedup — is
241
799
  * treated as success, since the artifact is already present under that
242
- * digest either way; any OTHER import failure surfaces msb's own stderr.
243
- * The digest-dir basename is then CONFIRMED present via `msb snapshot
244
- * list --format json` (matching it against each entry's
245
- * `name`/`artifact_path`) and returned as-is — never the entry's `digest`
246
- * field. Live-verified against msb 0.6.8: the full `sha256:<64hex>`
247
- * digest does not resolve as a snapshot ref at all (`msb snapshot inspect
248
- * sha256:<full>` fails "snapshot not found"); only the digest-dir name
249
- * resolves for `inspect`/`rm`/`run --from-snapshot`, so it — not the full
250
- * digest — is the ref this must hand back for `hasCheckpoint` and every
251
- * other snapshot-ref call to keep working. `_ref` (the archive's own
252
- * recorded ref) is unused here — msb's importer never takes one, unlike
253
- * docker's, where the effective ref really is the ref passed in.
800
+ * digest either way, and its stderr is parsed the same way as a success's
801
+ * stdout; any OTHER import failure surfaces msb's own stderr in a
802
+ * `BackendError`, and unparseable output (no recognizable absolute last
803
+ * line — e.g. only the "group ... (Initialized)" line, msb's OWN wording
804
+ * for a state that is not itself a path) throws a `BackendError` quoting
805
+ * the raw output verbatim rather than misreading a status line as a ref.
806
+ * `_ref` (the archive's own recorded ref) is unused here — msb's importer
807
+ * never takes one, unlike docker's, where the effective ref really is the
808
+ * ref passed in.
254
809
  */
255
810
  importCheckpoint(srcFile: string, _ref: string): Promise<string>;
256
811
  private runningSandboxNames;
@@ -278,10 +833,10 @@ export declare class MsbCliBackend implements SandboxBackend {
278
833
  */
279
834
  logs(handle: SandboxHandle): Promise<string>;
280
835
  /**
281
- * `msb logs -f` never exits once the sandbox stops (confirmed against the
282
- * real msb binary — it blocks on read forever instead of the documented
283
- * clean exit). A watchdog polls `msb ls` in the background; the instant
284
- * the sandbox leaves Running it quiesces the stuck follow child FIRST
836
+ * `msb logs -f` never exits on its own once the sandbox stops (confirmed
837
+ * against the real msb 0.7.1 binary — it just never exits, instead of the
838
+ * documented clean exit). A watchdog polls `msb ls` in the background; the
839
+ * instant the sandbox leaves Running it quiesces the stuck follow child FIRST
285
840
  * (kill it, wait for the reader to finish draining whatever was already
286
841
  * buffered) so `delivered` reflects everything the live stream will ever
287
842
  * produce, THEN does one authoritative non-follow `msb logs` fetch and
@@ -338,13 +893,32 @@ export declare class MsbCliBackend implements SandboxBackend {
338
893
  ensureNetwork(_networkId: string): Promise<void>;
339
894
  removeNetwork(_networkId: string): Promise<void>;
340
895
  /**
341
- * Networks are emulated because there is no bridge/subnet the current
342
- * msb exposes on macOS — the only data path into a running sandbox is the
343
- * exec channel. Four concerns, each its own guard: reject duplicate guest
344
- * ports, validate every alias (they get shell-interpolated), probe for
345
- * `nc`, then install `/etc/hosts` aliases and spawn one tunnel per link.
896
+ * Networks are emulated because msb gives sandboxes no network shared with
897
+ * each other, and TCP and UDP links take DIFFERENT routes: a TCP link is
898
+ * relayed over the exec channel, a UDP link's in-guest forwarder sends
899
+ * through the sandbox's host gateway to the target's published port.
900
+ * Validate first (duplicate guest ports, alias charset — both get
901
+ * shell-interpolated below), then probe for `nc` (every link needs it)
902
+ * and, only when a UDP link is present, for the forwarder's stricter
903
+ * dependencies (`-u`/`-e`, `timeout`). `/etc/hosts` aliases install once,
904
+ * shared by both protocols. From there each link takes its own path: a
905
+ * TCP link gets an `ExecTunnel`, unchanged; a UDP link gets
906
+ * `installUdpLink`'s forwarder script.
346
907
  */
347
908
  installNetworkLinks(handle: SandboxHandle, links: ReadonlyArray<NetworkLink>): Promise<void>;
909
+ /**
910
+ * One UDP link: installs and detached-launches its forwarder script, then
911
+ * polls `/proc/net/udp{,6}` until the guest port is actually bound — the
912
+ * launch exec succeeding only proves the script started, not that its
913
+ * listener has bound yet, and `nc -u -l` itself prints nothing to signal
914
+ * that. No teardown counterpart, unlike `ExecTunnel`: the forwarder is a
915
+ * plain guest process with no host-side resource of its own, and guest
916
+ * processes die with the sandbox. A checkpoint reboot's link replay (see
917
+ * `GenericContainer.checkpoint`) reruns this exactly like a fresh install
918
+ * — `/tmp` is tmpfs, so the rebooted guest has no forwarder script left
919
+ * to find, only a fresh one this rewrites.
920
+ */
921
+ private installUdpLink;
348
922
  /**
349
923
  * `msb copy -q <hostPath> <name>:<containerPath>` — the transfer only;
350
924
  * `GenericContainer.copyFileToContainer()` already confirmed the sandbox