rightsize 0.1.0

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 (216) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +255 -0
  4. package/dist/backend-docker/backend.d.ts +53 -0
  5. package/dist/backend-docker/backend.d.ts.map +1 -0
  6. package/dist/backend-docker/backend.js +336 -0
  7. package/dist/backend-docker/backend.js.map +1 -0
  8. package/dist/backend-docker/client.d.ts +82 -0
  9. package/dist/backend-docker/client.d.ts.map +1 -0
  10. package/dist/backend-docker/client.js +190 -0
  11. package/dist/backend-docker/client.js.map +1 -0
  12. package/dist/backend-docker/frames.d.ts +44 -0
  13. package/dist/backend-docker/frames.d.ts.map +1 -0
  14. package/dist/backend-docker/frames.js +105 -0
  15. package/dist/backend-docker/frames.js.map +1 -0
  16. package/dist/backend-docker/index.d.ts +5 -0
  17. package/dist/backend-docker/index.d.ts.map +1 -0
  18. package/dist/backend-docker/index.js +20 -0
  19. package/dist/backend-docker/index.js.map +1 -0
  20. package/dist/backend-docker/json.d.ts +13 -0
  21. package/dist/backend-docker/json.d.ts.map +1 -0
  22. package/dist/backend-docker/json.js +47 -0
  23. package/dist/backend-docker/json.js.map +1 -0
  24. package/dist/backend-docker/labels.d.ts +11 -0
  25. package/dist/backend-docker/labels.d.ts.map +1 -0
  26. package/dist/backend-docker/labels.js +13 -0
  27. package/dist/backend-docker/labels.js.map +1 -0
  28. package/dist/backend-docker/port-conflict.d.ts +9 -0
  29. package/dist/backend-docker/port-conflict.d.ts.map +1 -0
  30. package/dist/backend-docker/port-conflict.js +12 -0
  31. package/dist/backend-docker/port-conflict.js.map +1 -0
  32. package/dist/backend-docker/provider.d.ts +22 -0
  33. package/dist/backend-docker/provider.d.ts.map +1 -0
  34. package/dist/backend-docker/provider.js +36 -0
  35. package/dist/backend-docker/provider.js.map +1 -0
  36. package/dist/backend-msb/backend.d.ts +169 -0
  37. package/dist/backend-msb/backend.d.ts.map +1 -0
  38. package/dist/backend-msb/backend.js +664 -0
  39. package/dist/backend-msb/backend.js.map +1 -0
  40. package/dist/backend-msb/commands.d.ts +28 -0
  41. package/dist/backend-msb/commands.d.ts.map +1 -0
  42. package/dist/backend-msb/commands.js +67 -0
  43. package/dist/backend-msb/commands.js.map +1 -0
  44. package/dist/backend-msb/exec-tunnel.d.ts +33 -0
  45. package/dist/backend-msb/exec-tunnel.d.ts.map +1 -0
  46. package/dist/backend-msb/exec-tunnel.js +163 -0
  47. package/dist/backend-msb/exec-tunnel.js.map +1 -0
  48. package/dist/backend-msb/follow-replay.d.ts +14 -0
  49. package/dist/backend-msb/follow-replay.d.ts.map +1 -0
  50. package/dist/backend-msb/follow-replay.js +20 -0
  51. package/dist/backend-msb/follow-replay.js.map +1 -0
  52. package/dist/backend-msb/image-cache.d.ts +25 -0
  53. package/dist/backend-msb/image-cache.d.ts.map +1 -0
  54. package/dist/backend-msb/image-cache.js +27 -0
  55. package/dist/backend-msb/image-cache.js.map +1 -0
  56. package/dist/backend-msb/index.d.ts +6 -0
  57. package/dist/backend-msb/index.d.ts.map +1 -0
  58. package/dist/backend-msb/index.js +21 -0
  59. package/dist/backend-msb/index.js.map +1 -0
  60. package/dist/backend-msb/invoke.d.ts +24 -0
  61. package/dist/backend-msb/invoke.d.ts.map +1 -0
  62. package/dist/backend-msb/invoke.js +73 -0
  63. package/dist/backend-msb/invoke.js.map +1 -0
  64. package/dist/backend-msb/ls-json.d.ts +23 -0
  65. package/dist/backend-msb/ls-json.d.ts.map +1 -0
  66. package/dist/backend-msb/ls-json.js +113 -0
  67. package/dist/backend-msb/ls-json.js.map +1 -0
  68. package/dist/backend-msb/network-links.d.ts +7 -0
  69. package/dist/backend-msb/network-links.d.ts.map +1 -0
  70. package/dist/backend-msb/network-links.js +31 -0
  71. package/dist/backend-msb/network-links.js.map +1 -0
  72. package/dist/backend-msb/platform.d.ts +29 -0
  73. package/dist/backend-msb/platform.d.ts.map +1 -0
  74. package/dist/backend-msb/platform.js +128 -0
  75. package/dist/backend-msb/platform.js.map +1 -0
  76. package/dist/backend-msb/port-conflict.d.ts +10 -0
  77. package/dist/backend-msb/port-conflict.d.ts.map +1 -0
  78. package/dist/backend-msb/port-conflict.js +16 -0
  79. package/dist/backend-msb/port-conflict.js.map +1 -0
  80. package/dist/backend-msb/provider.d.ts +10 -0
  81. package/dist/backend-msb/provider.d.ts.map +1 -0
  82. package/dist/backend-msb/provider.js +40 -0
  83. package/dist/backend-msb/provider.js.map +1 -0
  84. package/dist/backend-msb/provisioner.d.ts +19 -0
  85. package/dist/backend-msb/provisioner.d.ts.map +1 -0
  86. package/dist/backend-msb/provisioner.js +332 -0
  87. package/dist/backend-msb/provisioner.js.map +1 -0
  88. package/dist/backend-msb/reaper.d.ts +10 -0
  89. package/dist/backend-msb/reaper.d.ts.map +1 -0
  90. package/dist/backend-msb/reaper.js +73 -0
  91. package/dist/backend-msb/reaper.js.map +1 -0
  92. package/dist/core/backend.d.ts +98 -0
  93. package/dist/core/backend.d.ts.map +1 -0
  94. package/dist/core/backend.js +2 -0
  95. package/dist/core/backend.js.map +1 -0
  96. package/dist/core/backends.d.ts +37 -0
  97. package/dist/core/backends.d.ts.map +1 -0
  98. package/dist/core/backends.js +127 -0
  99. package/dist/core/backends.js.map +1 -0
  100. package/dist/core/cleanup.d.ts +37 -0
  101. package/dist/core/cleanup.d.ts.map +1 -0
  102. package/dist/core/cleanup.js +86 -0
  103. package/dist/core/cleanup.js.map +1 -0
  104. package/dist/core/errors.d.ts +51 -0
  105. package/dist/core/errors.d.ts.map +1 -0
  106. package/dist/core/errors.js +66 -0
  107. package/dist/core/errors.js.map +1 -0
  108. package/dist/core/free-ports.d.ts +23 -0
  109. package/dist/core/free-ports.d.ts.map +1 -0
  110. package/dist/core/free-ports.js +63 -0
  111. package/dist/core/free-ports.js.map +1 -0
  112. package/dist/core/generic-container.d.ts +145 -0
  113. package/dist/core/generic-container.d.ts.map +1 -0
  114. package/dist/core/generic-container.js +385 -0
  115. package/dist/core/generic-container.js.map +1 -0
  116. package/dist/core/model.d.ts +60 -0
  117. package/dist/core/model.d.ts.map +1 -0
  118. package/dist/core/model.js +2 -0
  119. package/dist/core/model.js.map +1 -0
  120. package/dist/core/mountable-file.d.ts +25 -0
  121. package/dist/core/mountable-file.d.ts.map +1 -0
  122. package/dist/core/mountable-file.js +64 -0
  123. package/dist/core/mountable-file.js.map +1 -0
  124. package/dist/core/network.d.ts +58 -0
  125. package/dist/core/network.d.ts.map +1 -0
  126. package/dist/core/network.js +84 -0
  127. package/dist/core/network.js.map +1 -0
  128. package/dist/core/run-id.d.ts +18 -0
  129. package/dist/core/run-id.d.ts.map +1 -0
  130. package/dist/core/run-id.js +18 -0
  131. package/dist/core/run-id.js.map +1 -0
  132. package/dist/core/wait.d.ts +59 -0
  133. package/dist/core/wait.d.ts.map +1 -0
  134. package/dist/core/wait.js +239 -0
  135. package/dist/core/wait.js.map +1 -0
  136. package/dist/index.d.ts +26 -0
  137. package/dist/index.d.ts.map +1 -0
  138. package/dist/index.js +22 -0
  139. package/dist/index.js.map +1 -0
  140. package/dist/modules/arango.d.ts +14 -0
  141. package/dist/modules/arango.d.ts.map +1 -0
  142. package/dist/modules/arango.js +37 -0
  143. package/dist/modules/arango.js.map +1 -0
  144. package/dist/modules/clickhouse.d.ts +37 -0
  145. package/dist/modules/clickhouse.d.ts.map +1 -0
  146. package/dist/modules/clickhouse.js +69 -0
  147. package/dist/modules/clickhouse.js.map +1 -0
  148. package/dist/modules/flink.d.ts +65 -0
  149. package/dist/modules/flink.d.ts.map +1 -0
  150. package/dist/modules/flink.js +125 -0
  151. package/dist/modules/flink.js.map +1 -0
  152. package/dist/modules/floci.d.ts +46 -0
  153. package/dist/modules/floci.d.ts.map +1 -0
  154. package/dist/modules/floci.js +62 -0
  155. package/dist/modules/floci.js.map +1 -0
  156. package/dist/modules/index.d.ts +31 -0
  157. package/dist/modules/index.d.ts.map +1 -0
  158. package/dist/modules/index.js +31 -0
  159. package/dist/modules/index.js.map +1 -0
  160. package/dist/modules/kafka.d.ts +11 -0
  161. package/dist/modules/kafka.d.ts.map +1 -0
  162. package/dist/modules/kafka.js +41 -0
  163. package/dist/modules/kafka.js.map +1 -0
  164. package/dist/modules/keycloak.d.ts +40 -0
  165. package/dist/modules/keycloak.d.ts.map +1 -0
  166. package/dist/modules/keycloak.js +67 -0
  167. package/dist/modules/keycloak.js.map +1 -0
  168. package/dist/modules/mariadb.d.ts +56 -0
  169. package/dist/modules/mariadb.d.ts.map +1 -0
  170. package/dist/modules/mariadb.js +89 -0
  171. package/dist/modules/mariadb.js.map +1 -0
  172. package/dist/modules/memcached.d.ts +24 -0
  173. package/dist/modules/memcached.d.ts.map +1 -0
  174. package/dist/modules/memcached.js +105 -0
  175. package/dist/modules/memcached.js.map +1 -0
  176. package/dist/modules/mongodb.d.ts +19 -0
  177. package/dist/modules/mongodb.d.ts.map +1 -0
  178. package/dist/modules/mongodb.js +69 -0
  179. package/dist/modules/mongodb.js.map +1 -0
  180. package/dist/modules/mysql.d.ts +61 -0
  181. package/dist/modules/mysql.d.ts.map +1 -0
  182. package/dist/modules/mysql.js +101 -0
  183. package/dist/modules/mysql.js.map +1 -0
  184. package/dist/modules/neo4j.d.ts +60 -0
  185. package/dist/modules/neo4j.d.ts.map +1 -0
  186. package/dist/modules/neo4j.js +83 -0
  187. package/dist/modules/neo4j.js.map +1 -0
  188. package/dist/modules/pinot.d.ts +30 -0
  189. package/dist/modules/pinot.d.ts.map +1 -0
  190. package/dist/modules/pinot.js +49 -0
  191. package/dist/modules/pinot.js.map +1 -0
  192. package/dist/modules/postgres.d.ts +29 -0
  193. package/dist/modules/postgres.d.ts.map +1 -0
  194. package/dist/modules/postgres.js +73 -0
  195. package/dist/modules/postgres.js.map +1 -0
  196. package/dist/modules/rabbitmq.d.ts +46 -0
  197. package/dist/modules/rabbitmq.d.ts.map +1 -0
  198. package/dist/modules/rabbitmq.js +68 -0
  199. package/dist/modules/rabbitmq.js.map +1 -0
  200. package/dist/modules/redis.d.ts +15 -0
  201. package/dist/modules/redis.d.ts.map +1 -0
  202. package/dist/modules/redis.js +24 -0
  203. package/dist/modules/redis.js.map +1 -0
  204. package/dist/modules/redpanda.d.ts +27 -0
  205. package/dist/modules/redpanda.d.ts.map +1 -0
  206. package/dist/modules/redpanda.js +58 -0
  207. package/dist/modules/redpanda.js.map +1 -0
  208. package/dist/modules/spring-cloud-config.d.ts +18 -0
  209. package/dist/modules/spring-cloud-config.d.ts.map +1 -0
  210. package/dist/modules/spring-cloud-config.js +28 -0
  211. package/dist/modules/spring-cloud-config.js.map +1 -0
  212. package/dist/modules/wiremock.d.ts +21 -0
  213. package/dist/modules/wiremock.d.ts.map +1 -0
  214. package/dist/modules/wiremock.js +32 -0
  215. package/dist/modules/wiremock.js.map +1 -0
  216. package/package.json +104 -0
@@ -0,0 +1,664 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import * as readline from "node:readline";
3
+ import { RunId } from "../core/run-id.js";
4
+ import { BackendError, PortBindConflictError, UnsupportedByBackendError } from "../core/errors.js";
5
+ import { MsbCommands } from "./commands.js";
6
+ import { runningNames } from "./ls-json.js";
7
+ import { invoke, CLOSED_STDIN } from "./invoke.js";
8
+ import { isPortBindConflictOutput } from "./port-conflict.js";
9
+ import { isImageCacheCorruption } from "./image-cache.js";
10
+ import { orphanNames } from "./reaper.js";
11
+ import { undeliveredLines } from "./follow-replay.js";
12
+ import { requireNoDuplicateGuestPorts, requireAliasesAreValid, hostsAliasScript } from "./network-links.js";
13
+ import { ExecTunnel } from "./exec-tunnel.js";
14
+ const FIRST_RUN_PULL_TIMEOUT_MS = 600_000; // a cold pull can be slow
15
+ const READINESS_POLL_MS = 300;
16
+ const STOP_TIMEOUT_MS = 60_000;
17
+ const EXEC_TIMEOUT_MS = 120_000;
18
+ const LOGS_TIMEOUT_MS = 30_000;
19
+ const ATTACHED_PROC_STOP_TIMEOUT_MS = 10_000;
20
+ const TAIL_LINES = 50;
21
+ // How long the Windows polling follower's terminal fetch keeps retrying an
22
+ // `msb logs` invocation that itself keeps failing, once the sandbox is
23
+ // already confirmed no longer Running. Never a wait-for-content budget: a
24
+ // stopped sandbox's log cannot grow, so the first successful fetch is final.
25
+ const TERMINAL_FETCH_FAILURE_BUDGET_MS = 10_000;
26
+ /**
27
+ * The one boot failure `start()` heals and retries — carries the `msb run`
28
+ * child's combined output for the second-failure diagnostic. Internal to the
29
+ * boot path: never escapes `start()`, which converts a repeat failure into a
30
+ * `BackendError` naming the heal.
31
+ */
32
+ class ImageCacheCorruptionError extends Error {
33
+ output;
34
+ constructor(output) {
35
+ super(`msb image cache corruption:\n${output}`);
36
+ this.output = output;
37
+ }
38
+ }
39
+ /**
40
+ * Fetches one msb invocation's stdout byte-exact (CRLF normalized to LF, but
41
+ * the presence or absence of a trailing newline preserved), unlike `invoke`,
42
+ * whose line-based reconstruction appends a trailing newline to any non-empty
43
+ * output — erasing exactly the signal the Windows polling follower's
44
+ * mid-write holdback keys on (an unterminated tail may have been read
45
+ * mid-write; a newline-terminated one is complete). Rejects on spawn failure,
46
+ * timeout, or a non-zero exit: msb's own internal errors print to stderr and
47
+ * exit non-zero with EMPTY stdout, indistinguishable from a genuinely-empty
48
+ * response unless the exit code is checked.
49
+ */
50
+ function fetchStdoutExact(msbPath, args, timeoutMs) {
51
+ return new Promise((resolveFetch, rejectFetch) => {
52
+ const child = spawn(msbPath, args, { stdio: [CLOSED_STDIN, "pipe", "pipe"] });
53
+ let stdout = "";
54
+ let stderr = "";
55
+ child.stdout.setEncoding("utf8");
56
+ child.stderr.setEncoding("utf8");
57
+ child.stdout.on("data", (chunk) => {
58
+ stdout += chunk;
59
+ });
60
+ child.stderr.on("data", (chunk) => {
61
+ stderr += chunk;
62
+ });
63
+ let settled = false;
64
+ const timer = setTimeout(() => {
65
+ if (settled) {
66
+ return;
67
+ }
68
+ settled = true;
69
+ child.kill("SIGKILL");
70
+ rejectFetch(new BackendError(`msb ${args.join(" ")} timed out after ${timeoutMs}ms and was force-killed`));
71
+ }, timeoutMs);
72
+ child.once("error", (err) => {
73
+ if (settled) {
74
+ return;
75
+ }
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ rejectFetch(new BackendError(`failed to spawn 'msb ${args.join(" ")}': ${err.message}`));
79
+ });
80
+ // "close" (not "exit") so both stdio streams have fully flushed before
81
+ // the accumulated text is read.
82
+ child.once("close", (code) => {
83
+ if (settled) {
84
+ return;
85
+ }
86
+ settled = true;
87
+ clearTimeout(timer);
88
+ if (code !== 0) {
89
+ rejectFetch(new BackendError(`msb ${args.join(" ")} exited ${code ?? "unknown"}: ${stderr.trim()}`));
90
+ return;
91
+ }
92
+ resolveFetch(stdout.replace(/\r\n/g, "\n"));
93
+ });
94
+ });
95
+ }
96
+ /** Renders a heal attempt's outcome for the second-failure message — the
97
+ * heal's own failure (e.g. "image not found") is itself informative to
98
+ * whoever reads the error. */
99
+ function describeHeal(heal) {
100
+ if (heal !== null && typeof heal === "object" && "exitCode" in heal) {
101
+ const result = heal;
102
+ return result.exitCode === 0
103
+ ? "removed"
104
+ : `'msb image remove' exited ${result.exitCode}: ${result.stderr.trim()}`;
105
+ }
106
+ return `'msb image remove' itself failed to run: ${heal instanceof Error ? heal.message : String(heal)}`;
107
+ }
108
+ function sleep(ms) {
109
+ return new Promise((r) => setTimeout(r, ms));
110
+ }
111
+ /**
112
+ * Drains a child's merged stdout+stderr line-by-line into `onLine`, keeping
113
+ * no more than `TAIL_LINES` for diagnostics. Returns a promise that settles
114
+ * once the stream ends, so callers can await "everything this child will
115
+ * ever print has been seen" without polling.
116
+ */
117
+ function drainTail(stream, tail) {
118
+ return new Promise((resolveDrain) => {
119
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
120
+ rl.on("line", (line) => {
121
+ tail.push(line);
122
+ if (tail.length > TAIL_LINES) {
123
+ tail.shift();
124
+ }
125
+ });
126
+ rl.on("close", () => resolveDrain());
127
+ });
128
+ }
129
+ /**
130
+ * The attached-mode CLI driver: every sandbox this backend starts runs as a
131
+ * held child process (`msb run`, no `-d`) because detached mode never
132
+ * executes the image's own ENTRYPOINT/CMD — only attached mode does.
133
+ * Readiness is never inferred from that child's own output; it comes from
134
+ * polling `msb ls --format json` until the name shows `"Running"`. The
135
+ * attached child's stdout/stderr carries msb's own boot diagnostics and is
136
+ * kept only for pre-Running failure messages — it is not a dependable
137
+ * workload-log source (on Windows it does not relay guest stdout at all);
138
+ * workload logs are always fetched through the `msb logs` channel.
139
+ *
140
+ * `create()` on the `BackendProvider` interface is synchronous, but locating
141
+ * (and possibly downloading) the pinned `msb` binary is inherently async.
142
+ * The seam: this backend holds a memoized `ensureInstalled()` promise and
143
+ * every method awaits it once before doing anything else, so construction
144
+ * stays cheap and the actual provisioning happens lazily on first use.
145
+ */
146
+ export class MsbCliBackend {
147
+ msbPathPromise;
148
+ /** `"microsandbox"` — matched against `RIGHTSIZE_BACKEND` and used in `dev.rightsize.runId`-style diagnostics. */
149
+ name = "microsandbox";
150
+ supportsNativeNetworks = false;
151
+ handles = new Map();
152
+ startedNames = new Set();
153
+ // Mirrors msbPathPromise's eventual value as soon as it settles, purely so
154
+ // cleanupSync (the synchronous process-exit path, which cannot await
155
+ // anything) has a best-effort synchronous read of it. Never written to
156
+ // except by this one .then() below; never awaited anywhere else.
157
+ resolvedMsbPath;
158
+ constructor(msbPathPromise) {
159
+ this.msbPathPromise = msbPathPromise;
160
+ this.msbPathPromise.then((p) => {
161
+ this.resolvedMsbPath = p;
162
+ }, () => {
163
+ // Provisioning failed: cleanupSync has nothing to clean up with
164
+ // either, and every other method will surface the same rejection
165
+ // when it awaits msbPathPromise itself.
166
+ });
167
+ }
168
+ async msbPath() {
169
+ return this.msbPathPromise;
170
+ }
171
+ async create(spec) {
172
+ this.handles.set(spec.name, { attached: undefined, attachedExited: false, resources: [], logTail: [] });
173
+ return { id: spec.name, spec };
174
+ }
175
+ /**
176
+ * Boots via `bootOnce`; on a first failure carrying msb's image-cache-
177
+ * corruption signature (see `isImageCacheCorruption`), heals by removing
178
+ * just the affected image's cache entry (`msb image remove <image>`, result
179
+ * ignored — including "image not found", since the real signal is whether
180
+ * the retried boot succeeds, not whether removal reported success) and
181
+ * retries the boot exactly once. A second identical failure surfaces an
182
+ * error naming the image and the attempted heal instead of retrying
183
+ * further. The heal is scoped to the one image reference — never the whole
184
+ * cache directory, and never any sandbox state.
185
+ *
186
+ * The failed first attempt never reached Running, so `state.attached` and
187
+ * `startedNames` (both populated only on success, inside `bootOnce`) carry
188
+ * no state from it to double-register, and its child has already been
189
+ * reaped there.
190
+ *
191
+ * Two corruption shapes were found empirically and the same one command
192
+ * heals both: the failing image's manifest was never committed to msb's
193
+ * cache database (a concurrent pull lost the race for a shared base layer
194
+ * before its own manifest write landed) — `image remove` reports "image not
195
+ * found" and the retry succeeds anyway, because by then the concurrent
196
+ * winner has finished materializing the shared layer — or the manifest IS
197
+ * committed but the cache file backing one of its layers is gone, where
198
+ * `image remove` clears the stale entry and the retry re-pulls from
199
+ * scratch.
200
+ */
201
+ async start(handle) {
202
+ const msbPath = await this.msbPath();
203
+ const state = this.handles.get(handle.id);
204
+ if (state === undefined) {
205
+ throw new BackendError(`no handle state for sandbox '${handle.id}' — create() was never called for it`);
206
+ }
207
+ let firstOutput;
208
+ try {
209
+ await this.bootOnce(msbPath, handle, state);
210
+ return;
211
+ }
212
+ catch (first) {
213
+ if (!(first instanceof ImageCacheCorruptionError)) {
214
+ throw first;
215
+ }
216
+ firstOutput = first.output;
217
+ }
218
+ const heal = await invoke(msbPath, MsbCommands.imageRemove(handle.spec.image), STOP_TIMEOUT_MS).catch((e) => e);
219
+ try {
220
+ await this.bootOnce(msbPath, handle, state);
221
+ }
222
+ catch (second) {
223
+ if (!(second instanceof ImageCacheCorruptionError)) {
224
+ throw second;
225
+ }
226
+ throw new BackendError(`msb run for sandbox ${handle.id} hit its image cache error twice in a row for image ` +
227
+ `'${handle.spec.image}', even after removing that image's cache entry (${describeHeal(heal)}) ` +
228
+ `and retrying — this is likely a deeper cache corruption than this backend's one-shot heal ` +
229
+ `covers; try clearing the msb image cache by hand ('msb image prune' or removing the cache ` +
230
+ `directory under MSB_HOME).\nfirst attempt:\n${firstOutput}\nafter heal + retry:\n${second.output}`);
231
+ }
232
+ }
233
+ /**
234
+ * One boot attempt: spawns the attached `msb run` child and polls until the
235
+ * sandbox reaches Running. `state.attached` and `startedNames` are
236
+ * populated only on success; on any failure the child is reaped here (for
237
+ * the classified early-exit failures it has already exited; a readiness
238
+ * timeout leaves it alive and it is hard-killed) so a failed attempt leaves
239
+ * no live process or registered cleanup state behind — the caller owns
240
+ * retry policy, never cleanup. An early exit is classified from the child's
241
+ * combined output: the image-cache-corruption signature throws
242
+ * `ImageCacheCorruptionError` (the one failure `start()` heals and
243
+ * retries), a host-port bind conflict throws `PortBindConflictError`, and
244
+ * anything else surfaces the raw output.
245
+ */
246
+ async bootOnce(msbPath, handle, state) {
247
+ // Fresh per-attempt diagnostics: a retried boot must not blend its tail
248
+ // with the failed attempt's.
249
+ state.logTail = [];
250
+ state.attachedExited = false;
251
+ const child = spawn(msbPath, MsbCommands.run(handle.spec), { stdio: [CLOSED_STDIN, "pipe", "pipe"] });
252
+ // Merge stdout+stderr into one tail, kept only for the boot diagnostics
253
+ // below: this pipe is the sole carrier of msb's own output (registry/pull
254
+ // errors, crash output printed before the sandbox exists). It is not a
255
+ // dependable workload-log source — on Windows the attached process does
256
+ // not relay guest stdout at all — so logs() never reads it; workload
257
+ // output always comes from a `msb logs` invocation.
258
+ const stdoutDone = drainTail(child.stdout, state.logTail);
259
+ const stderrDone = drainTail(child.stderr, state.logTail);
260
+ let exited;
261
+ child.once("exit", (code) => {
262
+ exited = { code };
263
+ state.attachedExited = true;
264
+ });
265
+ const deadline = Date.now() + FIRST_RUN_PULL_TIMEOUT_MS;
266
+ for (;;) {
267
+ if (exited !== undefined) {
268
+ await Promise.all([stdoutDone, stderrDone]);
269
+ const output = state.logTail.join("\n");
270
+ if (isImageCacheCorruption(output)) {
271
+ throw new ImageCacheCorruptionError(output);
272
+ }
273
+ if (isPortBindConflictOutput(output)) {
274
+ throw new PortBindConflictError(`msb run for sandbox ${handle.id} could not bind a host port: ${output}`);
275
+ }
276
+ throw new BackendError(`msb run for sandbox ${handle.id} exited (code ${exited.code ?? "unknown"}) before reaching ` +
277
+ `Running — check the image entrypoint and 'msb run' output below:\n${output}`);
278
+ }
279
+ if ((await this.runningSandboxNames(msbPath)).has(handle.id)) {
280
+ state.attached = child;
281
+ this.startedNames.add(handle.id);
282
+ return;
283
+ }
284
+ if (Date.now() >= deadline) {
285
+ child.kill("SIGKILL");
286
+ throw new BackendError(`Sandbox ${handle.id} did not reach Running within ${FIRST_RUN_PULL_TIMEOUT_MS / 1000}s — this ` +
287
+ `can mean a slow image pull, a crash-looping entrypoint, or msb itself being unresponsive; ` +
288
+ `last output:\n${state.logTail.join("\n")}`);
289
+ }
290
+ await sleep(READINESS_POLL_MS);
291
+ }
292
+ }
293
+ async stop(handle) {
294
+ const msbPath = await this.msbPath();
295
+ const state = this.handles.get(handle.id);
296
+ if (state !== undefined) {
297
+ for (const tunnel of state.resources) {
298
+ await tunnel.close().catch(() => { });
299
+ }
300
+ state.resources = [];
301
+ }
302
+ await invoke(msbPath, MsbCommands.stop(handle.id), STOP_TIMEOUT_MS).catch(() => { });
303
+ const attached = state?.attached;
304
+ // The attached `msb run` child is msb's own supervisor for this sandbox:
305
+ // it stays alive for the sandbox's entire lifetime and only exits once
306
+ // the `msb stop` call just above lands, so the common path here is
307
+ // "attach a listener, then observe the exit that our own stop just
308
+ // caused." state.attachedExited exists for the other case: if the child
309
+ // had already died before this method ever ran (crashed, or killed by
310
+ // something external), start()'s own listener already flipped it, and
311
+ // Node never replays a past "exit" event to a listener attached after
312
+ // the fact — without this check, stop() would attach a listener for an
313
+ // event that will never come and wait out the full timeout before
314
+ // falling back to SIGKILL.
315
+ //
316
+ // `attached.kill("SIGKILL")` below is the escalation path, not the
317
+ // graceful one — the graceful stop is the `msb stop` invocation above,
318
+ // which already ran and quiesced the sandbox before this ever fires. On
319
+ // Windows, `ChildProcess.kill()` ignores the signal name entirely and
320
+ // always calls `TerminateProcess` (Windows has no real signal delivery
321
+ // at the Node level), so this escalation is already a hard terminate
322
+ // there regardless of which signal string is passed; nothing about the
323
+ // ordering above needs to change because the graceful step was already
324
+ // the `msb stop` call, not a signal to this child.
325
+ if (attached !== undefined && state !== undefined && !state.attachedExited) {
326
+ const exitedInTime = await new Promise((resolveWait) => {
327
+ const timer = setTimeout(() => resolveWait(false), ATTACHED_PROC_STOP_TIMEOUT_MS);
328
+ attached.once("exit", () => {
329
+ clearTimeout(timer);
330
+ resolveWait(true);
331
+ });
332
+ });
333
+ if (!exitedInTime) {
334
+ attached.kill("SIGKILL");
335
+ }
336
+ }
337
+ if (state !== undefined) {
338
+ state.attached = undefined;
339
+ }
340
+ }
341
+ async remove(handle) {
342
+ const msbPath = await this.msbPath();
343
+ await invoke(msbPath, MsbCommands.rm(handle.id), STOP_TIMEOUT_MS).catch(() => { });
344
+ this.startedNames.delete(handle.id);
345
+ this.handles.delete(handle.id);
346
+ }
347
+ async runningSandboxNames(msbPath) {
348
+ const result = await invoke(msbPath, MsbCommands.ls(), LOGS_TIMEOUT_MS);
349
+ return runningNames(result.stdout);
350
+ }
351
+ async exec(handle, cmd) {
352
+ const msbPath = await this.msbPath();
353
+ return invoke(msbPath, MsbCommands.exec(handle.id, cmd), EXEC_TIMEOUT_MS);
354
+ }
355
+ /**
356
+ * A fresh `msb logs <name> --tail 1000` invocation, same on every platform.
357
+ * This is the workload's own output, as distinct from the attached
358
+ * `msb run` child's pipe (drained in start() into a tail kept only for
359
+ * pre-Running crash diagnostics): on Windows the attached process does not
360
+ * relay guest stdout at all, while `msb logs` does everywhere, so this is
361
+ * the only channel this method can source from. Never rejects on a
362
+ * missing/removed sandbox — invoke() only rejects on spawn failure or
363
+ * timeout, never on exit code, so a failing `msb logs` call resolves with
364
+ * whatever (possibly empty) stdout it produced.
365
+ */
366
+ async logs(handle) {
367
+ const msbPath = await this.msbPath();
368
+ return (await invoke(msbPath, MsbCommands.logs(handle.id), LOGS_TIMEOUT_MS)).stdout;
369
+ }
370
+ /**
371
+ * `msb logs -f` never exits once the sandbox stops (confirmed against the
372
+ * real msb binary — it blocks on read forever instead of the documented
373
+ * clean exit). A watchdog polls `msb ls` in the background; the instant
374
+ * the sandbox leaves Running it quiesces the stuck follow child FIRST
375
+ * (kill it, wait for the reader to finish draining whatever was already
376
+ * buffered) so `delivered` reflects everything the live stream will ever
377
+ * produce, THEN does one authoritative non-follow `msb logs` fetch and
378
+ * replays only the lines after `delivered` — guarded so that replay can
379
+ * only ever happen once. An explicit `close()` never triggers a replay:
380
+ * closing means the caller asked delivery to stop, not "catch me up."
381
+ *
382
+ * On Windows hosts this routes to `followLogsByPolling` instead: there,
383
+ * `msb logs -f` stays alive for the sandbox's whole run but never relays a
384
+ * single line to its stdout pipe while the sandbox is Running (confirmed
385
+ * against the real binary on a hosted windows-2025 runner — the same lines
386
+ * are retrievable through non-follow `msb logs` the whole time), so a
387
+ * pipe-reading follow child can never deliver a live line on Windows.
388
+ */
389
+ async followLogs(handle, consumer) {
390
+ const msbPath = await this.msbPath();
391
+ if (process.platform === "win32") {
392
+ return this.followLogsByPolling(msbPath, handle, consumer);
393
+ }
394
+ const child = spawn(msbPath, MsbCommands.followLogs(handle.id), { stdio: [CLOSED_STDIN, "pipe", "pipe"] });
395
+ let delivered = 0;
396
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
397
+ const readerDone = new Promise((resolveReader) => {
398
+ rl.on("line", (line) => {
399
+ delivered++;
400
+ consumer(line);
401
+ });
402
+ rl.on("close", () => resolveReader());
403
+ });
404
+ // stderr of `logs -f` carries no separate signal this backend acts on;
405
+ // drain it so the pipe never backs up and stalls the child.
406
+ child.stderr.resume();
407
+ let flushed = false;
408
+ let closeRequested = false;
409
+ let watchdogDone;
410
+ const flushTailOnce = async () => {
411
+ child.kill();
412
+ await readerDone;
413
+ if (flushed) {
414
+ return;
415
+ }
416
+ flushed = true;
417
+ const full = (await invoke(msbPath, MsbCommands.logs(handle.id), LOGS_TIMEOUT_MS)).stdout;
418
+ for (const line of undeliveredLines(full, delivered)) {
419
+ consumer(line);
420
+ }
421
+ };
422
+ const runWatchdog = async () => {
423
+ while (!child.killed && !closeRequested) {
424
+ const names = await this.runningSandboxNames(msbPath).catch(() => new Set());
425
+ if (!names.has(handle.id)) {
426
+ await flushTailOnce();
427
+ return;
428
+ }
429
+ await sleep(READINESS_POLL_MS);
430
+ }
431
+ };
432
+ watchdogDone = runWatchdog();
433
+ return {
434
+ close: async () => {
435
+ closeRequested = true;
436
+ child.kill();
437
+ await readerDone;
438
+ await watchdogDone;
439
+ // Deliberately no flushTailOnce() call here: an explicit close means
440
+ // "stop delivery," never "catch me up." If the sandbox had already
441
+ // left Running before close() was called, the watchdog's own flush
442
+ // already ran and this is a no-op by way of the `flushed` guard.
443
+ },
444
+ };
445
+ }
446
+ /**
447
+ * Windows follow-logs path: no follow child at all. One async loop polls
448
+ * the non-follow `msb logs` fetch and delivers each fetch's not-yet-
449
+ * delivered lines, tracked by a monotonic `delivered` index — the same
450
+ * index-based diffing the POSIX watchdog's one-shot replay uses, made
451
+ * continuous. Delivery contract is identical to the POSIX path: in order,
452
+ * each line at most once, nothing after `close()`.
453
+ *
454
+ * A fetch's last line is held back only while the sandbox is Running AND
455
+ * the fetched text does not end with a newline: an unterminated tail may
456
+ * have been read mid-write, and delivering it early would split one
457
+ * workload line into two deliveries (the next fetch's index-diff would then
458
+ * skip its completed form). A newline-terminated tail is complete and
459
+ * delivers immediately — a workload that trickles complete lines while
460
+ * staying alive must see each line arrive live, not stall behind a
461
+ * holdback. Once the sandbox leaves Running, a final fetch (see
462
+ * `deliverTerminalTail`) delivers everything outstanding, including a
463
+ * trailing unterminated line.
464
+ *
465
+ * Every msb invocation this poller makes runs to completion strictly before
466
+ * the next one starts — exactly one in-flight child at a time from this
467
+ * code path. A failed `msb ls`/`msb logs` invocation — spawn error,
468
+ * timeout, or msb exiting non-zero (msb's own internal errors print to
469
+ * stderr and exit non-zero with EMPTY stdout, indistinguishable from a
470
+ * genuinely-empty response unless the exit code is checked) — is never read
471
+ * as "the sandbox stopped" or "the log is empty"; it retries.
472
+ */
473
+ followLogsByPolling(msbPath, handle, consumer) {
474
+ let closeRequested = false;
475
+ const pollerDone = (async () => {
476
+ let delivered = 0;
477
+ for (;;) {
478
+ if (closeRequested) {
479
+ return; // An explicit close never triggers delivery of anything new.
480
+ }
481
+ let running;
482
+ try {
483
+ const ls = await invoke(msbPath, MsbCommands.ls(), LOGS_TIMEOUT_MS);
484
+ if (ls.exitCode !== 0) {
485
+ throw new BackendError(`msb ls exited ${ls.exitCode}: ${ls.stderr.trim()}`);
486
+ }
487
+ running = runningNames(ls.stdout).has(handle.id);
488
+ }
489
+ catch {
490
+ await sleep(READINESS_POLL_MS);
491
+ continue;
492
+ }
493
+ if (!running) {
494
+ await this.deliverTerminalTail(msbPath, handle.id, delivered, consumer, () => closeRequested);
495
+ return;
496
+ }
497
+ let full;
498
+ try {
499
+ full = await fetchStdoutExact(msbPath, MsbCommands.logs(handle.id), LOGS_TIMEOUT_MS);
500
+ }
501
+ catch {
502
+ await sleep(READINESS_POLL_MS);
503
+ continue;
504
+ }
505
+ const lines = undeliveredLines(full, 0);
506
+ // msb's Windows log store can briefly show a single empty line before
507
+ // the workload's first real output lands, and that phantom line is
508
+ // REPLACED by the real content on a later read rather than prepended
509
+ // to it (observed against the real binary on a hosted windows-2025
510
+ // runner: delivering it permanently shifted the index-diff by one and
511
+ // swallowed the first real line in every follow). Until something
512
+ // non-empty has been delivered, an all-empty snapshot is "no content
513
+ // yet" — a workload's genuine interior blank lines are unaffected,
514
+ // since they arrive inside content-bearing snapshots.
515
+ if (delivered === 0 && lines.every((l) => l === "")) {
516
+ await sleep(READINESS_POLL_MS);
517
+ continue;
518
+ }
519
+ const lastLineMayBeMidWrite = full.length > 0 && !full.endsWith("\n");
520
+ const deliverable = lastLineMayBeMidWrite ? Math.max(delivered, lines.length - 1) : lines.length;
521
+ for (let i = delivered; i < deliverable; i++) {
522
+ if (closeRequested) {
523
+ return;
524
+ }
525
+ consumer(lines[i]);
526
+ }
527
+ delivered = Math.max(delivered, deliverable);
528
+ await sleep(READINESS_POLL_MS);
529
+ }
530
+ })();
531
+ return {
532
+ close: async () => {
533
+ closeRequested = true;
534
+ await pollerDone;
535
+ },
536
+ };
537
+ }
538
+ /**
539
+ * Delivers everything outstanding once the sandbox is confirmed no longer
540
+ * Running: retries the `msb logs` fetch only while it keeps failing to
541
+ * invoke at all (bounded by `TERMINAL_FETCH_FAILURE_BUDGET_MS`), and
542
+ * delivers from the very first successful fetch — withholding nothing,
543
+ * since a stopped sandbox's log cannot grow, so there is no more mid-write
544
+ * risk. This is the one place a trailing unterminated line reaches the
545
+ * consumer on Windows.
546
+ */
547
+ async deliverTerminalTail(msbPath, id, delivered, consumer, isCloseRequested) {
548
+ const deadline = Date.now() + TERMINAL_FETCH_FAILURE_BUDGET_MS;
549
+ let full = "";
550
+ for (;;) {
551
+ try {
552
+ full = await fetchStdoutExact(msbPath, MsbCommands.logs(id), LOGS_TIMEOUT_MS);
553
+ break;
554
+ }
555
+ catch {
556
+ if (isCloseRequested() || Date.now() >= deadline) {
557
+ break;
558
+ }
559
+ await sleep(READINESS_POLL_MS);
560
+ }
561
+ }
562
+ // The same phantom-empty-line guard the live poll loop applies (see
563
+ // `followLogsByPolling`): a store still showing only empty lines when
564
+ // nothing was ever delivered means the workload produced no output, not
565
+ // that it printed blanks.
566
+ if (delivered === 0 && undeliveredLines(full, 0).every((l) => l === "")) {
567
+ return;
568
+ }
569
+ for (const line of undeliveredLines(full, delivered)) {
570
+ if (isCloseRequested()) {
571
+ return;
572
+ }
573
+ consumer(line);
574
+ }
575
+ }
576
+ async ensureNetwork(_networkId) {
577
+ // Emulated via the host gateway; there is no native network object to create.
578
+ }
579
+ async removeNetwork(_networkId) {
580
+ // Nothing was created for ensureNetwork(); nothing to remove.
581
+ }
582
+ /**
583
+ * Networks are emulated because there is no bridge/subnet the current
584
+ * msb exposes on macOS — the only data path into a running sandbox is the
585
+ * exec channel. Four concerns, each its own guard: reject duplicate guest
586
+ * ports, validate every alias (they get shell-interpolated), probe for
587
+ * `nc`, then install `/etc/hosts` aliases and spawn one tunnel per link.
588
+ */
589
+ async installNetworkLinks(handle, links) {
590
+ if (links.length === 0) {
591
+ return;
592
+ }
593
+ const msbPath = await this.msbPath();
594
+ requireNoDuplicateGuestPorts(links);
595
+ requireAliasesAreValid(links);
596
+ const ncProbe = await this.exec(handle, ["sh", "-c", "command -v nc"]);
597
+ if (ncProbe.exitCode !== 0) {
598
+ throw new UnsupportedByBackendError(`network links (no nc/busybox in consumer image '${handle.spec.image}')`, this.name, "run this test with RIGHTSIZE_BACKEND=docker instead");
599
+ }
600
+ const hostsResult = await this.exec(handle, ["sh", "-c", hostsAliasScript(links)]);
601
+ if (hostsResult.exitCode !== 0) {
602
+ throw new BackendError(`failed to install /etc/hosts aliases in ${handle.id}: ${hostsResult.stderr}`);
603
+ }
604
+ const state = this.handles.get(handle.id);
605
+ for (const link of links) {
606
+ const tunnel = new ExecTunnel(msbPath, handle.id, link);
607
+ if (state !== undefined) {
608
+ state.resources.push(tunnel);
609
+ }
610
+ }
611
+ }
612
+ async close() {
613
+ const msbPath = await this.msbPath().catch(() => undefined);
614
+ if (msbPath === undefined) {
615
+ return;
616
+ }
617
+ for (const name of [...this.startedNames]) {
618
+ await this.silently(msbPath, name);
619
+ }
620
+ }
621
+ /**
622
+ * Synchronous, blocking teardown for the process-exit path, where the
623
+ * event loop is not available to await this backend's normal async
624
+ * stop/remove. `spawnSync` is the one primitive Node offers that actually
625
+ * blocks the process here; failures are swallowed because the process is
626
+ * exiting regardless and there is no caller left to report them to.
627
+ */
628
+ cleanupSync(id) {
629
+ // A container only ever reaches start() (and therefore only ever gets
630
+ // registered for sync cleanup) after msbPathPromise has already
631
+ // resolved, so resolvedMsbPath is populated by the time this can matter
632
+ // in practice; the undefined case below covers only the very first
633
+ // provisioning attempt racing its own constructor's .then().
634
+ const msbPath = this.resolvedMsbPath;
635
+ if (msbPath === undefined) {
636
+ return;
637
+ }
638
+ try {
639
+ spawnSync(msbPath, MsbCommands.stop(id));
640
+ }
641
+ catch {
642
+ // Best-effort.
643
+ }
644
+ try {
645
+ spawnSync(msbPath, MsbCommands.rm(id));
646
+ }
647
+ catch {
648
+ // Best-effort.
649
+ }
650
+ }
651
+ /** Remove leftover `rz-<other-runid>-*` sandboxes from a crashed prior run — never this run's own. */
652
+ async sweepOrphans() {
653
+ const msbPath = await this.msbPath();
654
+ const result = await invoke(msbPath, MsbCommands.ls(), LOGS_TIMEOUT_MS);
655
+ for (const name of orphanNames(result.stdout, RunId.value)) {
656
+ await this.silently(msbPath, name);
657
+ }
658
+ }
659
+ async silently(msbPath, name) {
660
+ await invoke(msbPath, MsbCommands.stop(name), STOP_TIMEOUT_MS).catch(() => { });
661
+ await invoke(msbPath, MsbCommands.rm(name), STOP_TIMEOUT_MS).catch(() => { });
662
+ }
663
+ }
664
+ //# sourceMappingURL=backend.js.map