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
@@ -4,11 +4,12 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { FreePorts } from "./free-ports.js";
6
6
  import { RunId } from "./run-id.js";
7
+ import { nextSandboxName } from "./sandbox-name.js";
7
8
  import { Network } from "./network.js";
8
- import { PortBindConflictError, IsolationRequiredError, CheckpointUnsupportedError, CheckpointBackendMismatchError, ReuseFromCheckpointError, RelativeContainerPathError, BackendError, RootDiskConflictError, TmpfsRootExceedsMemoryError, NetworkDisabledConflictError, TmpfsRootCheckpointError, } from "./errors.js";
9
+ import { PortBindConflictError, IsolationRequiredError, CheckpointUnsupportedError, CheckpointBackendMismatchError, ReuseFromCheckpointError, CheckpointRestoreEnvOverrideError, RelativeContainerPathError, BackendError, RootDiskConflictError, TmpfsRootExceedsMemoryError, NetworkDisabledConflictError, TmpfsRootCheckpointError, } from "./errors.js";
9
10
  import { requireValidCheckpointName } from "./checkpoint/name.js";
10
11
  import { checkpointRef } from "./checkpoint/ref.js";
11
- import { toCheckpointRegistrySpec, writeCheckpointRegistryAtomic } from "./checkpoint/registry.js";
12
+ import { toCheckpointRegistrySpec, writeCheckpointRegistryAtomic, readCheckpointRegistry } from "./checkpoint/registry.js";
12
13
  import { Wait } from "./wait.js";
13
14
  import { registerSyncCleanup, unregisterSyncCleanup } from "./cleanup.js";
14
15
  import { Backends } from "./backends.js";
@@ -24,12 +25,29 @@ function requireAbsoluteContainerPath(containerPath) {
24
25
  throw new RelativeContainerPathError(containerPath);
25
26
  }
26
27
  }
27
- const MAX_START_ATTEMPTS = 5;
28
- let sequence = 0;
29
- function nextSequence() {
30
- sequence += 1;
31
- return sequence;
28
+ /**
29
+ * Order-insensitive equality for env pairs — `start()`'s only use is telling
30
+ * "the checkpoint's own captured env, re-applied unchanged" from "the caller
31
+ * actually changed it" (see `CheckpointRestoreEnvOverrideError`). Compared as
32
+ * key/value sets rather than positionally: `withEnv()` re-setting an
33
+ * existing key moves it to the end of `envPairs` (last-write-wins,
34
+ * insertion-ordered — see `withEnv()`'s own doc), which must not by itself
35
+ * read as a change.
36
+ */
37
+ function envPairsEqual(a, b) {
38
+ const aMap = new Map(a);
39
+ const bMap = new Map(b);
40
+ if (aMap.size !== bMap.size) {
41
+ return false;
42
+ }
43
+ for (const [key, value] of aMap) {
44
+ if (bMap.get(key) !== value) {
45
+ return false;
46
+ }
47
+ }
48
+ return true;
32
49
  }
50
+ const MAX_START_ATTEMPTS = 5;
33
51
  /** Typed-first, string-fallback classification: walk the cause chain for a typed conflict, else match the daemon's own wording. */
34
52
  function isPortBindConflict(err) {
35
53
  let current = err;
@@ -75,6 +93,10 @@ function registryRecordToPorts(record) {
75
93
  }
76
94
  return ports;
77
95
  }
96
+ /** `ContainerSpec.hostUdpEgressPorts`'s own computation: the distinct, ascending-sorted target host ports of `links`' UDP-protocol entries — shared by every spec-construction site a networked boot can reach. */
97
+ function hostUdpEgressPortsFor(links) {
98
+ return [...new Set(links.filter((link) => link.protocol === "udp").map((link) => link.targetHostPort))].sort((a, b) => a - b);
99
+ }
78
100
  /**
79
101
  * The builder, launcher, and lifecycle guard for a single container — the
80
102
  * one class every module (`RedisContainer`, `PostgresContainer`, …)
@@ -97,6 +119,8 @@ export class GenericContainer {
97
119
  image;
98
120
  envPairs = [];
99
121
  exposedPorts = [];
122
+ /** UDP counterpart of `exposedPorts` (`withExposedUdpPorts`) — a separate field, never merged with the TCP list; see `PortBinding`'s own doc on why the same guest port can appear in both. */
123
+ exposedUdpPorts = [];
100
124
  command;
101
125
  network;
102
126
  aliasNames = [];
@@ -111,9 +135,20 @@ export class GenericContainer {
111
135
  requireIsolationRequested = false;
112
136
  checkpointRef;
113
137
  checkpointSourceBackend;
138
+ /**
139
+ * `cp.spec.env` exactly as captured, set once by `fromCheckpoint()` and
140
+ * never touched again — kept separate from the mutable `envPairs` so
141
+ * `start()` can tell "the caller re-applied the checkpoint's own env
142
+ * unchanged" from "the caller actually overrode or extended it" (see
143
+ * `CheckpointRestoreEnvOverrideError`). `undefined` for every container
144
+ * not built via `fromCheckpoint()`.
145
+ */
146
+ checkpointCapturedEnv;
114
147
  handle;
115
148
  backend;
116
149
  mappedPorts = new Map();
150
+ /** UDP counterpart of `mappedPorts` — a SEPARATE per-protocol store, never a shared bare-int-keyed map (see `getMappedUdpPort`). */
151
+ mappedUdpPorts = new Map();
117
152
  running = false;
118
153
  installedNetworkLinks = [];
119
154
  /** Builds against `image` (e.g. `"redis:8.6-alpine"`); no I/O happens until `start()`. */
@@ -136,7 +171,14 @@ export class GenericContainer {
136
171
  * captures. `start()` throws `CheckpointBackendMismatchError` before any
137
172
  * backend call if the active backend isn't the one that created `cp`, and
138
173
  * `ReuseFromCheckpointError` if this container is also marked
139
- * `withReuse()` — reuse's identity hash never covers a checkpoint ref.
174
+ * `withReuse()` — reuse's identity hash never covers a checkpoint ref. On
175
+ * msb specifically, `start()` also throws `CheckpointRestoreEnvOverrideError`
176
+ * before any backend call if a further `withEnv()` call actually changes
177
+ * the env beyond what `cp.spec.env` captured — msb's restore command has
178
+ * no way to inject env at boot time (see that error's own doc); re-calling
179
+ * `withEnv()` with the checkpoint's own captured values, unchanged, never
180
+ * throws. docker is unaffected — an overridden/extended env reaches a
181
+ * docker restore normally.
140
182
  * Once started, a restored container is ordinary in every other respect:
141
183
  * fresh host ports, normal reaping-ledger tracking, normal stop.
142
184
  */
@@ -144,14 +186,26 @@ export class GenericContainer {
144
186
  const container = new GenericContainer(cp.ref);
145
187
  container.checkpointRef = cp.ref;
146
188
  container.checkpointSourceBackend = cp.backend;
189
+ container.checkpointCapturedEnv = cp.spec.env;
147
190
  for (const [key, value] of cp.spec.env) {
148
191
  container.withEnv(key, value);
149
192
  }
150
193
  if (cp.spec.command !== undefined) {
151
194
  container.withCommand(...cp.spec.command);
152
195
  }
153
- if (cp.spec.ports.length > 0) {
154
- container.withExposedPorts(...cp.spec.ports.map((p) => p.guestPort));
196
+ // Split by protocol into the right builder field — cp.spec.ports mixes
197
+ // both transports (see PortBinding's own doc on why they're tagged
198
+ // rather than kept in separate lists at the ContainerSpec level), but
199
+ // the builder itself keeps two independent fields, exactly the way a
200
+ // fresh (non-restored) container's own withExposedPorts()/
201
+ // withExposedUdpPorts() calls would populate them.
202
+ const tcpGuestPorts = cp.spec.ports.filter((p) => p.protocol === "tcp").map((p) => p.guestPort);
203
+ const udpGuestPorts = cp.spec.ports.filter((p) => p.protocol === "udp").map((p) => p.guestPort);
204
+ if (tcpGuestPorts.length > 0) {
205
+ container.withExposedPorts(...tcpGuestPorts);
206
+ }
207
+ if (udpGuestPorts.length > 0) {
208
+ container.withExposedUdpPorts(...udpGuestPorts);
155
209
  }
156
210
  if (cp.spec.memoryLimitMb !== undefined) {
157
211
  container.withMemoryLimit(cp.spec.memoryLimitMb);
@@ -167,11 +221,34 @@ export class GenericContainer {
167
221
  this.envPairs.push([key, value]);
168
222
  return this;
169
223
  }
170
- /** Publishes these guest ports to pre-allocated host ports (see `FreePorts`); read back with `getMappedPort`. */
224
+ /** Publishes these guest ports to pre-allocated host ports (see `FreePorts`); read back with `getMappedPort`. TCP only — see `withExposedUdpPorts` for UDP. */
171
225
  withExposedPorts(...ports) {
172
226
  this.exposedPorts.push(...ports);
173
227
  return this;
174
228
  }
229
+ /**
230
+ * Publishes these guest ports over UDP to pre-allocated host ports (see
231
+ * `FreePorts.allocateUdp`); read back with `getMappedUdpPort`. A separate
232
+ * list from `withExposedPorts` — the same numeric guest port may be
233
+ * exposed on both protocols (DNS's 53, say), and each gets its own
234
+ * independent host-port mapping.
235
+ *
236
+ * UDP exposure is deliberately invisible to the default wait strategies:
237
+ * `Wait.forListeningPort()`/`Wait.forHttp()` only ever probe
238
+ * `withExposedPorts`' TCP guest ports, so a container exposing ONLY UDP
239
+ * ports is vacuously ready under the default wait — prefer
240
+ * `Wait.forLogMessage(...)` for a UDP-only service.
241
+ *
242
+ * A UDP-exposed container can be a link target on a `Network` on both
243
+ * backends. On microsandbox the consumer gets an in-guest forwarder per UDP
244
+ * link, which needs a busybox-style `nc` (with `-u`/`-e`) and `timeout` in
245
+ * the consumer image; a datagram over 1472 bytes of payload breaks the
246
+ * receiving sandbox's inbound networking there (see the networking guide).
247
+ */
248
+ withExposedUdpPorts(...ports) {
249
+ this.exposedUdpPorts.push(...ports);
250
+ return this;
251
+ }
175
252
  /** Overrides the image's default ENTRYPOINT/CMD. Omit this call entirely to run the image unmodified. */
176
253
  withCommand(...cmd) {
177
254
  this.command = cmd;
@@ -310,18 +387,14 @@ export class GenericContainer {
310
387
  resolveBackend() {
311
388
  return this.backendOverride ?? Backends.active();
312
389
  }
313
- buildSpec(name, ports) {
390
+ /** `hostUdpEgressPorts` is precomputed by `start()` from the SAME `linksForNewMember()` call `installNetworkLinks` is later given — never recomputed here, so the argv a networked boot builds from and the links actually installed after it always agree. */
391
+ buildSpec(name, ports, hostUdpEgressPorts) {
314
392
  const spec = {
315
393
  name,
316
394
  image: this.image,
317
395
  env: this.envPairs.map(([k, v]) => [k, v]),
318
396
  command: this.command,
319
- ports: this.exposedPorts.map((guestPort) => ({
320
- hostPort: ports.get(guestPort) ?? (() => {
321
- throw new Error(`no allocated host port for guest port ${guestPort}`);
322
- })(),
323
- guestPort,
324
- })),
397
+ ports: this.buildPortBindings(ports),
325
398
  mounts: this.mounts,
326
399
  networkId: this.network?.id,
327
400
  aliases: this.aliasNames,
@@ -336,9 +409,10 @@ export class GenericContainer {
336
409
  diskLimitMb: this.diskLimitMb,
337
410
  tmpfsRootMb: this.tmpfsRootMb,
338
411
  networkDisabled: this.networkDisabled,
412
+ hostUdpEgressPorts,
339
413
  };
340
414
  return GenericContainer.validateSpecConflicts(this.customizeSpec(spec, (guest) => {
341
- const p = ports.get(guest);
415
+ const p = ports.tcp.get(guest);
342
416
  if (p === undefined) {
343
417
  throw new Error(`no allocated host port for guest port ${guest}`);
344
418
  }
@@ -352,12 +426,7 @@ export class GenericContainer {
352
426
  image: this.image,
353
427
  env: this.envPairs.map(([k, v]) => [k, v]),
354
428
  command: this.command,
355
- ports: this.exposedPorts.map((guestPort) => ({
356
- hostPort: ports.get(guestPort) ?? (() => {
357
- throw new Error(`no allocated host port for guest port ${guestPort}`);
358
- })(),
359
- guestPort,
360
- })),
429
+ ports: this.buildPortBindings(ports),
361
430
  mounts: this.mounts,
362
431
  networkId: undefined,
363
432
  aliases: this.aliasNames,
@@ -371,9 +440,13 @@ export class GenericContainer {
371
440
  diskLimitMb: this.diskLimitMb,
372
441
  tmpfsRootMb: this.tmpfsRootMb,
373
442
  networkDisabled: this.networkDisabled,
443
+ // A reuse container never joins a Network (ReuseWithNetworkError,
444
+ // checked before this is ever reached) — no links, ever, to derive a
445
+ // host-UDP-egress list from.
446
+ hostUdpEgressPorts: [],
374
447
  };
375
448
  return GenericContainer.validateSpecConflicts(this.customizeSpec(spec, (guest) => {
376
- const p = ports.get(guest);
449
+ const p = ports.tcp.get(guest);
377
450
  if (p === undefined) {
378
451
  throw new Error(`no allocated host port for guest port ${guest}`);
379
452
  }
@@ -406,6 +479,7 @@ export class GenericContainer {
406
479
  env: this.envPairs.map(([k, v]) => [k, v]),
407
480
  command: this.command,
408
481
  exposedPorts: this.exposedPorts,
482
+ exposedUdpPorts: this.exposedUdpPorts,
409
483
  memoryLimitMb: this.memoryLimitMb,
410
484
  copies: this.mounts.map((m) => ({ guestPath: m.guestPath, hostPath: m.hostPath })),
411
485
  diskLimitMb: this.diskLimitMb,
@@ -414,16 +488,44 @@ export class GenericContainer {
414
488
  });
415
489
  }
416
490
  async allocatePorts() {
417
- const ports = new Map();
491
+ const tcp = new Map();
418
492
  for (const guestPort of this.exposedPorts) {
419
- ports.set(guestPort, await FreePorts.allocate());
493
+ tcp.set(guestPort, await FreePorts.allocate());
420
494
  }
421
- return ports;
495
+ const udp = new Map();
496
+ for (const guestPort of this.exposedUdpPorts) {
497
+ udp.set(guestPort, await FreePorts.allocateUdp());
498
+ }
499
+ return { tcp, udp };
422
500
  }
423
501
  releasePorts(ports) {
424
- for (const hostPort of ports.values()) {
502
+ for (const hostPort of ports.tcp.values()) {
425
503
  FreePorts.release(hostPort);
426
504
  }
505
+ for (const hostPort of ports.udp.values()) {
506
+ FreePorts.releaseUdp(hostPort);
507
+ }
508
+ }
509
+ /** Builds the `ContainerSpec.ports` array from this builder's TCP and UDP exposed-port lists plus their allocated host ports — shared by `buildSpec` and `buildReuseSpec`. */
510
+ buildPortBindings(ports) {
511
+ return [
512
+ ...this.exposedPorts.map((guestPort) => ({
513
+ hostPort: ports.tcp.get(guestPort) ??
514
+ (() => {
515
+ throw new Error(`no allocated host port for guest port ${guestPort}`);
516
+ })(),
517
+ guestPort,
518
+ protocol: "tcp",
519
+ })),
520
+ ...this.exposedUdpPorts.map((guestPort) => ({
521
+ hostPort: ports.udp.get(guestPort) ??
522
+ (() => {
523
+ throw new Error(`no allocated host port for guest port ${guestPort}/udp`);
524
+ })(),
525
+ guestPort,
526
+ protocol: "udp",
527
+ })),
528
+ ];
427
529
  }
428
530
  /**
429
531
  * Fails fast — before any backend call — on the combinations
@@ -466,6 +568,20 @@ export class GenericContainer {
466
568
  if (this.checkpointSourceBackend !== undefined && this.checkpointSourceBackend !== backend.name) {
467
569
  throw new CheckpointBackendMismatchError(this.checkpointSourceBackend, backend.name);
468
570
  }
571
+ // Also before any backend work: msb's restore command has no -e/--env
572
+ // flag at all (see CheckpointRestoreEnvOverrideError's own doc) — an env
573
+ // the caller has genuinely changed beyond the checkpoint's own captured
574
+ // values can never reach a restored msb sandbox, so this refuses rather
575
+ // than silently booting it without the env the caller asked for. Only
576
+ // msb is checked: docker's restore is an ordinary create/run against the
577
+ // committed image, so an overridden env reaches it normally. Re-applying
578
+ // the checkpoint's own captured env unchanged never throws — only a call
579
+ // that actually changes what would be sent does.
580
+ if (this.checkpointRef !== undefined &&
581
+ backend.name === "microsandbox" &&
582
+ !envPairsEqual(this.checkpointCapturedEnv ?? [], this.envPairs)) {
583
+ throw new CheckpointRestoreEnvOverrideError(backend.name);
584
+ }
469
585
  // Only the env-resolved path (never an explicit withBackend() override)
470
586
  // drives the reaper: writes this process's run record, sweeps dead runs
471
587
  // on first use, and spawns the watchdog. Skipping it for overrides is
@@ -504,6 +620,15 @@ export class GenericContainer {
504
620
  await backend.ensureNetwork(this.network.id);
505
621
  await trackNetwork(this.network.id);
506
622
  }
623
+ // Computed once, before create() — never recomputed inside the retry
624
+ // loop below or ahead of the installNetworkLinks call after it: a
625
+ // backend that routes links through the host (msb) needs this
626
+ // container's own UDP-egress policy baked into the SAME argv that boots
627
+ // it, and the links actually installed once it's up must be the exact
628
+ // list that policy was built from, not a freshly recomputed one that
629
+ // could in principle disagree with it.
630
+ const links = this.network?.linksForNewMember() ?? [];
631
+ const hostUdpEgressPorts = hostUdpEgressPortsFor(links);
507
632
  let handle;
508
633
  let allocatedPorts;
509
634
  let lastConflict;
@@ -511,13 +636,16 @@ export class GenericContainer {
511
636
  // set) until the retry loop exits: releasing them immediately would let the
512
637
  // next attempt legally re-pick the very port that just conflicted — the OS
513
638
  // frequently reissues a just-freed ephemeral port — wasting an attempt on a
514
- // proven-contended port.
515
- const conflictedPorts = [];
639
+ // proven-contended port. Tracked per protocol so the finally block below
640
+ // releases each back into the RIGHT pool (TCP vs UDP have independent OS
641
+ // port tables — see FreePorts.allocateUdp's own doc).
642
+ const conflictedTcpPorts = [];
643
+ const conflictedUdpPorts = [];
516
644
  try {
517
645
  for (let attempt = 0; attempt < MAX_START_ATTEMPTS; attempt++) {
518
646
  const ports = await this.allocatePorts();
519
- const name = `rz-${RunId.value}-${nextSequence()}`;
520
- const spec = this.buildSpec(name, ports);
647
+ const name = nextSandboxName();
648
+ const spec = this.buildSpec(name, ports, hostUdpEgressPorts);
521
649
  // Appended BEFORE create() — the ledger's `.sandboxes` file is always
522
650
  // a superset of this run's live sandboxes, never a subset. `keepAlive`
523
651
  // containers are never listed at all, so a sweep can never reach one
@@ -544,8 +672,9 @@ export class GenericContainer {
544
672
  await untrackSandbox(name);
545
673
  }
546
674
  if (isPortBindConflict(err)) {
547
- // Quarantined, not released: see conflictedPorts above.
548
- conflictedPorts.push(...ports.values());
675
+ // Quarantined, not released: see conflictedTcpPorts/conflictedUdpPorts above.
676
+ conflictedTcpPorts.push(...ports.tcp.values());
677
+ conflictedUdpPorts.push(...ports.udp.values());
549
678
  lastConflict = err;
550
679
  continue;
551
680
  }
@@ -555,9 +684,12 @@ export class GenericContainer {
555
684
  }
556
685
  }
557
686
  finally {
558
- for (const hostPort of conflictedPorts) {
687
+ for (const hostPort of conflictedTcpPorts) {
559
688
  FreePorts.release(hostPort);
560
689
  }
690
+ for (const hostPort of conflictedUdpPorts) {
691
+ FreePorts.releaseUdp(hostPort);
692
+ }
561
693
  }
562
694
  if (handle === undefined || allocatedPorts === undefined) {
563
695
  throw new Error(`Failed to start '${this.image}' after ${MAX_START_ATTEMPTS} attempts: every attempt hit a host port already in use by another process.`, { cause: lastConflict });
@@ -566,7 +698,8 @@ export class GenericContainer {
566
698
  // even though readiness (below) hasn't been confirmed yet.
567
699
  this.handle = handle;
568
700
  this.backend = backend;
569
- this.mappedPorts = allocatedPorts;
701
+ this.mappedPorts = allocatedPorts.tcp;
702
+ this.mappedUdpPorts = allocatedPorts.udp;
570
703
  this.running = true;
571
704
  // Real synchronous teardown, not a placeholder: if this process dies
572
705
  // before stop() ever runs (process.exit / SIGINT / SIGTERM), the exit
@@ -582,7 +715,10 @@ export class GenericContainer {
582
715
  registerSyncCleanup(handle, backend, () => backend.cleanupSync(handle.id));
583
716
  }
584
717
  try {
585
- const links = this.network?.linksForNewMember() ?? [];
718
+ // The SAME links computed above, before create() — never recomputed
719
+ // here (see the comment where they were computed): the policy already
720
+ // baked into this container's own argv must be exactly what gets
721
+ // installed, not a freshly recomputed list.
586
722
  await backend.installNetworkLinks(handle, links);
587
723
  this.installedNetworkLinks = links;
588
724
  // Register AFTER links are computed/installed — a container must
@@ -614,10 +750,11 @@ export class GenericContainer {
614
750
  const dir = cacheDir();
615
751
  const hash = await this.computeReuseHash();
616
752
  const name = reuseName(hash);
617
- const { handle, mappedPorts } = await this.adoptOrCreate(backend, dir, hash, name, false);
753
+ const { handle, mappedPorts, mappedUdpPorts } = await this.adoptOrCreate(backend, dir, hash, name, false);
618
754
  this.handle = handle;
619
755
  this.backend = backend;
620
756
  this.mappedPorts = mappedPorts;
757
+ this.mappedUdpPorts = mappedUdpPorts;
621
758
  this.running = true;
622
759
  // Deliberately no registerSyncCleanup and no ledger trackSandbox call —
623
760
  // a keepAlive sandbox must outlive this process, which is exactly what
@@ -676,8 +813,10 @@ export class GenericContainer {
676
813
  let lastConflict;
677
814
  // Same conflicted-port quarantine as the ordinary start path: a port that
678
815
  // just failed to bind stays out of the allocator until this retry loop
679
- // exits, so no later attempt can re-pick it.
680
- const conflictedPorts = [];
816
+ // exits, so no later attempt can re-pick it. Split by protocol for the
817
+ // same reason start()'s own loop is — see its comment.
818
+ const conflictedTcpPorts = [];
819
+ const conflictedUdpPorts = [];
681
820
  try {
682
821
  for (let attempt = 0; attempt < MAX_START_ATTEMPTS; attempt++) {
683
822
  const ports = await this.allocatePorts();
@@ -696,8 +835,9 @@ export class GenericContainer {
696
835
  await swallow(() => backend.remove(createdHandle));
697
836
  }
698
837
  if (isPortBindConflict(err)) {
699
- // Quarantined, not released: see conflictedPorts above.
700
- conflictedPorts.push(...ports.values());
838
+ // Quarantined, not released: see conflictedTcpPorts/conflictedUdpPorts above.
839
+ conflictedTcpPorts.push(...ports.tcp.values());
840
+ conflictedUdpPorts.push(...ports.udp.values());
701
841
  lastConflict = err;
702
842
  continue;
703
843
  }
@@ -719,16 +859,19 @@ export class GenericContainer {
719
859
  }
720
860
  }
721
861
  finally {
722
- for (const hostPort of conflictedPorts) {
862
+ for (const hostPort of conflictedTcpPorts) {
723
863
  FreePorts.release(hostPort);
724
864
  }
865
+ for (const hostPort of conflictedUdpPorts) {
866
+ FreePorts.releaseUdp(hostPort);
867
+ }
725
868
  }
726
869
  if (handle === undefined || allocatedPorts === undefined) {
727
870
  throw new Error(`Failed to start reuse container '${this.image}' after ${MAX_START_ATTEMPTS} attempts: every attempt hit a host port already in use by another process.`, { cause: lastConflict });
728
871
  }
729
872
  const ports = allocatedPorts;
730
873
  try {
731
- await this.waitStrategy.waitUntilReady(this.waitTargetFor(backend, handle, ports));
874
+ await this.waitStrategy.waitUntilReady(this.waitTargetFor(backend, handle, ports.tcp));
732
875
  }
733
876
  catch (err) {
734
877
  await swallow(() => backend.stop(handle));
@@ -736,15 +879,19 @@ export class GenericContainer {
736
879
  this.releasePorts(ports);
737
880
  throw err;
738
881
  }
882
+ const udpPortsRecord = portsToRegistryRecord(ports.udp);
739
883
  const entry = {
740
884
  name,
741
885
  image: this.image,
742
- ports: portsToRegistryRecord(ports),
886
+ ports: portsToRegistryRecord(ports.tcp),
743
887
  createdIso: new Date().toISOString(),
744
888
  backend: backend.name,
889
+ // Omitted when there are none, mirroring every other additive-optional
890
+ // field on this registry's sibling shapes.
891
+ ...(Object.keys(udpPortsRecord).length > 0 ? { udpPorts: udpPortsRecord } : {}),
745
892
  };
746
893
  await writeRegistryAtomic(dir, hash, entry);
747
- return { handle, mappedPorts: ports };
894
+ return { handle, mappedPorts: ports.tcp, mappedUdpPorts: ports.udp };
748
895
  }
749
896
  /**
750
897
  * Registry hit → verify: confirm the name is actually running (backend
@@ -757,7 +904,10 @@ export class GenericContainer {
757
904
  */
758
905
  async tryAdopt(backend, dir, hash, name, entry) {
759
906
  const mappedPorts = registryRecordToPorts(entry.ports);
760
- const spec = this.buildReuseSpec(name, mappedPorts);
907
+ // Absent on an entry written before UDP exposure existed — normalizes to
908
+ // no UDP ports, never a corrupt read (see isRegistryEntry in reuse/registry.ts).
909
+ const mappedUdpPorts = registryRecordToPorts(entry.udpPorts ?? {});
910
+ const spec = this.buildReuseSpec(name, { tcp: mappedPorts, udp: mappedUdpPorts });
761
911
  const handle = await backend.findRunning(spec).catch(() => undefined);
762
912
  if (handle === undefined) {
763
913
  await this.cleanupStaleReuse(backend, dir, hash, name);
@@ -770,7 +920,7 @@ export class GenericContainer {
770
920
  await this.cleanupStaleReuse(backend, dir, hash, name);
771
921
  return undefined;
772
922
  }
773
- return { handle, mappedPorts };
923
+ return { handle, mappedPorts, mappedUdpPorts };
774
924
  }
775
925
  /** Best-effort: remove whatever's running under `name` (by name — this call never held a handle for it) and delete the registry file. Never throws. */
776
926
  async cleanupStaleReuse(backend, dir, hash, name) {
@@ -795,7 +945,10 @@ export class GenericContainer {
795
945
  * `findRunning` matches on `spec.name` alone.
796
946
  */
797
947
  async removeOrphanedRunningReuse(backend, name) {
798
- const probePorts = new Map(this.exposedPorts.map((guestPort) => [guestPort, 0]));
948
+ const probePorts = {
949
+ tcp: new Map(this.exposedPorts.map((guestPort) => [guestPort, 0])),
950
+ udp: new Map(this.exposedUdpPorts.map((guestPort) => [guestPort, 0])),
951
+ };
799
952
  const probeSpec = this.buildReuseSpec(name, probePorts);
800
953
  const running = await backend.findRunning(probeSpec).catch(() => undefined);
801
954
  if (running !== undefined) {
@@ -846,14 +999,16 @@ export class GenericContainer {
846
999
  // simply stay marked issued for the rest of this process's lifetime;
847
1000
  // only this instance's own view of them is cleared below.
848
1001
  this.mappedPorts = new Map();
1002
+ this.mappedUdpPorts = new Map();
849
1003
  return;
850
1004
  }
851
1005
  await swallow(() => backend.stop(handle));
852
1006
  await swallow(() => backend.remove(handle));
853
1007
  unregisterSyncCleanup(handle.id);
854
1008
  await untrackSandbox(handle.spec.name);
855
- this.releasePorts(this.mappedPorts);
1009
+ this.releasePorts({ tcp: this.mappedPorts, udp: this.mappedUdpPorts });
856
1010
  this.mappedPorts = new Map();
1011
+ this.mappedUdpPorts = new Map();
857
1012
  }
858
1013
  /** `= stop()`. What `await using c = await new GenericContainer(img).start()` calls at scope exit; never throws. */
859
1014
  async [Symbol.asyncDispose]() {
@@ -871,10 +1026,14 @@ export class GenericContainer {
871
1026
  get aliases() {
872
1027
  return this.aliasNames;
873
1028
  }
874
- /** Every guest port this container published (set via `withExposedPorts`). Part of the `NetworkMember` contract `Network` uses to compute links. */
1029
+ /** Every guest port this container published over TCP (set via `withExposedPorts`). Part of the `NetworkMember` contract `Network` uses to compute links; also what the default wait strategies probe — see `withExposedUdpPorts`' own doc on why UDP ports never appear here. */
875
1030
  get exposedGuestPorts() {
876
1031
  return this.exposedPorts;
877
1032
  }
1033
+ /** Every guest port this container published over UDP (set via `withExposedUdpPorts`) — a separate list from `exposedGuestPorts`, never merged with it. Part of the `NetworkMember` contract `Network` uses to compute links. */
1034
+ get exposedUdpGuestPorts() {
1035
+ return this.exposedUdpPorts;
1036
+ }
878
1037
  /**
879
1038
  * The host port bound to `guestPort`. Throws distinct messages for the
880
1039
  * two ways this can be wrong: not running at all ("call start() first"),
@@ -895,6 +1054,29 @@ export class GenericContainer {
895
1054
  mappedPort(guestPort) {
896
1055
  return this.getMappedPort(guestPort);
897
1056
  }
1057
+ /**
1058
+ * The UDP counterpart of `getMappedPort` — reads from a SEPARATE
1059
+ * per-protocol mapping store (`withExposedUdpPorts`), so exposing the same
1060
+ * numeric guest port on both protocols (DNS's 53, say) never collides:
1061
+ * `getMappedPort(53)` and `getMappedUdpPort(53)` can return two entirely
1062
+ * different host ports. Throws the same two distinct-message shape as
1063
+ * `getMappedPort`: not running at all, versus running but never exposed
1064
+ * via `withExposedUdpPorts`.
1065
+ */
1066
+ getMappedUdpPort(guestPort) {
1067
+ if (!this.running) {
1068
+ throw new Error(`Container is not running — call start() first.`);
1069
+ }
1070
+ const port = this.mappedUdpPorts.get(guestPort);
1071
+ if (port === undefined) {
1072
+ throw new Error(`Port ${guestPort}/udp is not exposed — call withExposedUdpPorts(${guestPort}).`);
1073
+ }
1074
+ return port;
1075
+ }
1076
+ /** Alias for `getMappedUdpPort`, satisfying the `NetworkMember` shape (which names this method `mappedUdpPort`). */
1077
+ mappedUdpPort(guestPort) {
1078
+ return this.getMappedUdpPort(guestPort);
1079
+ }
898
1080
  requireHandle() {
899
1081
  if (!this.running || this.handle === undefined || this.backend === undefined) {
900
1082
  throw new Error(`Container is not running — call start() first.`);
@@ -912,21 +1094,48 @@ export class GenericContainer {
912
1094
  * state error (same shape as `exec`/`logs`) if this container isn't
913
1095
  * currently running.
914
1096
  *
915
- * Passing `name` makes this checkpoint NAMED and durable: the ref becomes
916
- * deterministic (an absolute `<cacheDir>/checkpoints/rz-ckpt-<name>` path
917
- * on microsandbox, `rightsize/checkpoint:<name>` on docker — see
1097
+ * Passing `name` makes this checkpoint NAMED and durable: on docker the ref
1098
+ * becomes deterministic (`rightsize/checkpoint:<name>` — see
918
1099
  * `checkpointRef` — instead of a random 12-hex suffix), and — only once
919
1100
  * the backend call below has actually succeeded — a registry entry is written under
920
1101
  * `<cacheDir>/checkpoints/<name>.json` that `Checkpoints.find`/`list`/
921
1102
  * `remove` can rediscover later, in this process or a different one. `name`
922
1103
  * must match `^[a-z0-9][a-z0-9-]{0,40}$`; an invalid name throws
923
1104
  * `InvalidCheckpointNameError` before any backend call. Checkpointing under
924
- * a name that already has a registry entry REPLACES it: the ref is the
925
- * same deterministic value either way, so this best-effort clears the old
926
- * artifact under that ref before creating the new one, then overwrites the
927
- * registry entry — the latest checkpoint under a name always wins.
928
- * Omitting `name` keeps the original behavior byte-for-byte: a random ref,
929
- * no registry entry, ephemeral.
1105
+ * a name that already has a registry entry REPLACES it: this best-effort
1106
+ * clears whatever artifact the PRIOR checkpoint under `name` actually left
1107
+ * behind, then overwrites the registry entry with the BACKEND'S OWN
1108
+ * EFFECTIVE ref for the fresh checkpoint — the latest checkpoint under a
1109
+ * name always wins. The prior artifact's real location comes from the
1110
+ * existing registry entry itself (when one exists for a backend matching
1111
+ * the currently active one) rather than a recomputed nominal ref: on
1112
+ * docker the two are always the same deterministic value anyway, but on
1113
+ * microsandbox (since 0.7.1) the effective ref is a content-addressed
1114
+ * artifact path the nominal one never predicts (see
1115
+ * `SandboxBackend.createCheckpoint`'s own doc), so reading it back from the
1116
+ * registry is what makes this reliable there too — the same lookup
1117
+ * `Checkpoints.remove(name)` itself uses.
1118
+ *
1119
+ * The PRIOR entry's real ref is only ever removed once the fresh
1120
+ * checkpoint below has actually been created successfully (and, on a
1121
+ * backend whose checkpoint reboots the workload, once the post-checkpoint
1122
+ * re-wait below has succeeded too) — never before. When the prior ref and
1123
+ * the freshly-minted nominal one are the SAME deterministic value (always
1124
+ * true on docker), removing it earlier would just be removing the nominal
1125
+ * ref that `createCheckpoint` is about to reuse anyway, so this method
1126
+ * best-effort clears the nominal ref up front instead (a harmless no-op on
1127
+ * microsandbox, where a name never predicts a real artifact); when the two
1128
+ * differ (microsandbox's content-addressed case, or the rare coincidence
1129
+ * of the two colliding on any backend), the prior ref is left completely
1130
+ * untouched until the new one is confirmed both real and reachable. That
1131
+ * ordering is what keeps a FAILED same-name re-checkpoint from destroying
1132
+ * an otherwise-good, previously-restorable checkpoint: on any backend, a
1133
+ * `createCheckpoint()` (or post-checkpoint re-wait) that throws leaves both
1134
+ * the prior artifact and its registry entry completely untouched, exactly
1135
+ * as if the failed call had never been attempted. Omitting `name` keeps
1136
+ * the original behavior byte-for-byte: an ephemeral checkpoint with no
1137
+ * registry entry, whose `Checkpoint.ref` is always the backend's own
1138
+ * effective ref.
930
1139
  *
931
1140
  * On a backend whose `capabilities.checkpointRestartsWorkload` is `true`
932
1141
  * (microsandbox: the stop/snapshot/reboot cycle boots a fresh microVM),
@@ -943,6 +1152,11 @@ export class GenericContainer {
943
1152
  * BEFORE the wait-strategy re-run — the wait must gate a container that is
944
1153
  * fully re-linked, not just booted.
945
1154
  *
1155
+ * If that re-wait (or the link re-install before it) fails, this container
1156
+ * is torn down to completion via `stop()` — same as a failed `start()` —
1157
+ * before the rejection propagates, so a revived-but-never-ready workload
1158
+ * never sits around as an orphan.
1159
+ *
946
1160
  * Checkpoints are never auto-reaped (a committed image or a disk snapshot
947
1161
  * is not a container) — see the checkpoints guide for the manual cleanup
948
1162
  * one-liners and the `Checkpoints.remove` cleanup affordance.
@@ -968,33 +1182,147 @@ export class GenericContainer {
968
1182
  throw new TmpfsRootCheckpointError();
969
1183
  }
970
1184
  const ref = checkpointRef(backend.name, name);
1185
+ // Replace semantics: a prior checkpoint under this same name — if any —
1186
+ // is cleared once the new one is confirmed to actually exist (see the
1187
+ // deferred removal after `createCheckpoint`/the post-checkpoint re-wait
1188
+ // below), never before — a failed re-checkpoint must never cost this
1189
+ // container its previously-good, restorable checkpoint. The RELIABLE way
1190
+ // to find where the prior checkpoint actually lives is the registry's
1191
+ // own recorded entry (the same lookup `Checkpoints.remove(name)` uses),
1192
+ // not a freshly-minted nominal ref: on docker the ref is deterministic
1193
+ // from `name`, so the registry entry's `ref` and the nominal one below
1194
+ // are always the same value anyway, but on microsandbox (since 0.7.1)
1195
+ // the EFFECTIVE ref `createCheckpoint` returns is a content-addressed
1196
+ // artifact path the nominal one never determines (see
1197
+ // `SandboxBackend.createCheckpoint`'s own doc) — only the registry's
1198
+ // recorded ref points at the real artifact there. Only consulted when
1199
+ // the entry's own recorded backend matches the CURRENTLY active one — a
1200
+ // foreign-backend ref means nothing to this backend's `removeCheckpoint`,
1201
+ // same gate `find`/`remove` apply to the same mismatch. A missing or
1202
+ // corrupt registry read is treated the same as "no entry" (best-effort:
1203
+ // this must never fail the checkpoint itself), leaving only the
1204
+ // nominal-ref removal below to run.
1205
+ let priorRealRef;
971
1206
  if (name !== undefined) {
972
- // Replace semantics: the ref is deterministic from `name`, so a prior
973
- // checkpoint under this same name — if any — sits under this exact
974
- // ref. Best-effort clear it before creating the new one.
1207
+ try {
1208
+ const registryRead = await readCheckpointRegistry(cacheDir(), name);
1209
+ if (registryRead.kind === "found" && registryRead.entry.backend === backend.name) {
1210
+ priorRealRef = registryRead.entry.ref;
1211
+ }
1212
+ }
1213
+ catch {
1214
+ // Swallowed deliberately — see the comment above.
1215
+ }
1216
+ // Best-effort clear the freshly-minted NOMINAL ref up front, before
1217
+ // creating the new checkpoint — exactly what this method did before
1218
+ // the registry lookup above existed. On docker this nominal ref IS the
1219
+ // prior checkpoint's real recorded ref (deterministic from `name`), so
1220
+ // this is the only removal that ever runs there, and it must run here:
1221
+ // `createCheckpoint` is about to reuse this exact ref, so there is no
1222
+ // "after" at which removing it wouldn't also remove the checkpoint
1223
+ // just created. On microsandbox this is a no-op against a path that
1224
+ // was never real, same as before this method ever read the registry —
1225
+ // so nothing new is destroyed here before the new checkpoint's success
1226
+ // is confirmed.
975
1227
  await swallow(() => backend.removeCheckpoint(ref));
976
1228
  }
977
- await backend.createCheckpoint(handle, ref);
1229
+ // The EFFECTIVE ref: on docker this is always `ref` itself; on
1230
+ // microsandbox (since 0.7.1) it is whatever real artifact path
1231
+ // `createCheckpoint` discovered from `msb snapshot create`'s own stdout
1232
+ // — never necessarily `ref`. Everything downstream (the registry entry,
1233
+ // the returned `Checkpoint`) uses this, not the working `ref`.
1234
+ //
1235
+ // Captured BEFORE the call: a backend whose checkpoint reboots the
1236
+ // workload under a fresh identity (msb's `createCheckpoint`, see its own
1237
+ // doc) mutates `handle.id` IN PLACE once that reboot succeeds — the same
1238
+ // carve-out `SandboxBackend.createCheckpoint`'s own interface doc
1239
+ // documents. `registerSyncCleanup` (see start(), above) keyed its exit-path
1240
+ // teardown entry by the OLD id; if the id just changed, that registration
1241
+ // must be re-keyed to the new one too, or the old entry leaks in
1242
+ // `cleanup.ts`'s registry forever (stop() below only ever unregisters by
1243
+ // the CURRENT `handle.id`, which after a rename no longer matches what
1244
+ // was registered) — silently breaking both the exit-path teardown for
1245
+ // this container AND `liveContainers()`/diagnostics, which read that same
1246
+ // registry.
1247
+ const preCheckpointHandleId = handle.id;
1248
+ const effectiveRef = await backend.createCheckpoint(handle, ref);
1249
+ if (handle.id !== preCheckpointHandleId && !handle.spec.keepAlive) {
1250
+ // Mirrors start()'s own keepAlive exclusion: a keepAlive container was
1251
+ // never registered in the first place, so there is nothing to re-key.
1252
+ // The cleanup closure below reads `handle.id` dynamically (see
1253
+ // start()'s own registerSyncCleanup call), so re-registering it under
1254
+ // the new id is automatically correct without capturing anything else.
1255
+ unregisterSyncCleanup(preCheckpointHandleId);
1256
+ registerSyncCleanup(handle, backend, () => backend.cleanupSync(handle.id));
1257
+ }
978
1258
  if (backend.capabilities.checkpointRestartsWorkload) {
979
- if (this.installedNetworkLinks.length > 0) {
980
- await backend.installNetworkLinks(handle, this.installedNetworkLinks);
1259
+ // The backend checkpoint call above already succeeded — the artifact
1260
+ // exists — but the reboot it triggered can still leave this container
1261
+ // unusable: a revived workload that never becomes ready. Same shape as
1262
+ // start()'s own post-boot wait (line ~722): a failure here must not
1263
+ // leak the freshly-spawned workload (msb's revived `exec` child sits
1264
+ // in `handle`'s attached slot from the moment the reboot reaches
1265
+ // Running, well before this wait ever resolves). Tear this container
1266
+ // down to completion via the same this.stop() start() uses, so the
1267
+ // handle's attached child — and the sandbox itself — never outlive
1268
+ // this rejection, before rethrowing.
1269
+ try {
1270
+ if (this.installedNetworkLinks.length > 0) {
1271
+ await backend.installNetworkLinks(handle, this.installedNetworkLinks);
1272
+ }
1273
+ await this.waitStrategy.waitUntilReady(this.asWaitTarget());
1274
+ }
1275
+ catch (err) {
1276
+ await this.stop();
1277
+ throw err;
981
1278
  }
982
- await this.waitStrategy.waitUntilReady(this.asWaitTarget());
983
1279
  }
1280
+ // The workload argv the backend captured from the guest during the
1281
+ // createCheckpoint() call just above, when `handle.spec.command` was
1282
+ // undefined — see `SandboxBackend.capturedWorkloadCommand`'s own doc.
1283
+ // `undefined` on every backend that never captures one (docker, or msb
1284
+ // when the source container already carried an explicit command).
1285
+ const capturedCommand = backend.capturedWorkloadCommand?.(handle);
984
1286
  if (name !== undefined) {
1287
+ // Only reached once the fresh checkpoint above is confirmed both real
1288
+ // (createCheckpoint succeeded) and reachable (the post-checkpoint
1289
+ // re-wait above, when applicable, succeeded too) — a failure at either
1290
+ // point already threw and returned above, before any of this ran. The
1291
+ // PRIOR checkpoint's real recorded ref (see the registry lookup
1292
+ // before createCheckpoint) is best-effort removed here, now that it is
1293
+ // safe to: never when it's the SAME ref as the nominal one already
1294
+ // cleared above (docker — clearing it again here would target the
1295
+ // fresh checkpoint `createCheckpoint` just reused that exact ref for),
1296
+ // and never when it happens to equal the fresh checkpoint's own
1297
+ // EFFECTIVE ref (the rare case of two checkpoints content-addressing
1298
+ // to the very same artifact on microsandbox — removing it would delete
1299
+ // the checkpoint this call just created, not a prior one).
1300
+ if (priorRealRef !== undefined && priorRealRef !== ref && priorRealRef !== effectiveRef) {
1301
+ const realRef = priorRealRef;
1302
+ await swallow(() => backend.removeCheckpoint(realRef));
1303
+ }
985
1304
  // Only after the backend checkpoint above has actually succeeded — a
986
1305
  // failed createCheckpoint() already threw, so a registry entry is
987
1306
  // never written for a checkpoint that doesn't exist.
988
1307
  const entry = {
989
1308
  name,
990
- ref,
1309
+ ref: effectiveRef,
991
1310
  backend: backend.name,
992
1311
  createdIso: new Date().toISOString(),
993
1312
  spec: toCheckpointRegistrySpec(handle.spec),
1313
+ ...(capturedCommand !== undefined ? { capturedCommand } : {}),
994
1314
  };
995
1315
  await writeCheckpointRegistryAtomic(cacheDir(), name, entry);
996
1316
  }
997
- return { ref, backend: backend.name, spec: handle.spec };
1317
+ // `handle.spec.command` already covers an explicit command; only
1318
+ // substitute the captured fallback when the source had none, so a later
1319
+ // `fromCheckpoint(cp).start()` on THIS in-memory `Checkpoint` — unnamed
1320
+ // or named, same process or not — already carries the fully-resolved
1321
+ // workload argv a restore-side backend needs to revive it, the same
1322
+ // merge `fromCheckpointRegistryEntry` applies for a registry-mediated
1323
+ // restore.
1324
+ const effectiveSpec = handle.spec.command !== undefined || capturedCommand === undefined ? handle.spec : { ...handle.spec, command: capturedCommand };
1325
+ return { ref: effectiveRef, backend: backend.name, spec: effectiveSpec };
998
1326
  }
999
1327
  /**
1000
1328
  * Copies a host file or directory into this running container at