@molecule/api-code-sandbox-flyio 1.3.0 → 1.3.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAGzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAW5C;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,QAAkB,CAAA;AAQxD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAA;AAY5C;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,0FAA0F;AAC1F,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,UAAU,CAiBlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;AAmDD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,CAAC,CAqBrB"}
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAGzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAW5C;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,QAAkB,CAAA;AA6BxD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAA;AAY5C;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,0FAA0F;AAC1F,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,UAAU,CAiBlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;AAmDD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,CAAC,CAqBrB"}
package/dist/exec.js CHANGED
@@ -49,6 +49,24 @@ const DEFAULT_TIMEOUT_MS = 600_000;
49
49
  * would try to materialize the whole thing in one response body.
50
50
  */
51
51
  export const MAX_CAPTURED_OUTPUT_BYTES = 5 * 1024 * 1024;
52
+ /**
53
+ * Decoded bytes read per exec call when collecting a detached command's output.
54
+ *
55
+ * Fly documents NO output ceiling for the Machines API: the exec response's
56
+ * `flydv1.ExecResponse` schema types `stdout` and `stderr` as plain,
57
+ * unconstrained strings (https://docs.machines.dev/openapi.json). The live API
58
+ * still returns SHORT output with `exit_code: 0` once a response is large —
59
+ * measured 2026-10-18 in production (X1 flash-m175): one read of a
60
+ * 1,421,432-byte database file came back 897,144 bytes long, exactly one
61
+ * 512 KiB slice missing, with no error at all. So the collector asks for no
62
+ * more than this per response and checks every response's decoded length
63
+ * against what it asked for, shrinking the request when the transport cuts it.
64
+ */
65
+ const COLLECT_CHUNK_BYTES = 128 * 1024;
66
+ /** Smallest chunk a shortened response is re-read with, before giving up. */
67
+ const MIN_COLLECT_CHUNK_BYTES = 16 * 1024;
68
+ /** Read attempts per chunk before the collection is reported as truncated. */
69
+ const COLLECT_ATTEMPTS = 4;
52
70
  /**
53
71
  * Base64 chunk size for spilling a large script to a file, comfortably under
54
72
  * {@link FLY_EXEC_MAX_ARG_BYTES} once the `printf … | base64 -d` wrapper is added.
@@ -188,6 +206,96 @@ export async function execCommand(rawExec, command, opts, defaultCwd) {
188
206
  }
189
207
  return await execDetached(rawExec, script, budgetMs);
190
208
  }
209
+ /**
210
+ * Sizes of a detached run's two output spool files, in bytes. A file the
211
+ * command never created reads as 0.
212
+ * @param rawExec - Transport callback issuing one Fly exec call.
213
+ * @param outPath - Absolute path of the stdout spool.
214
+ * @param errPath - Absolute path of the stderr spool.
215
+ * @returns Both byte counts.
216
+ * @throws {Error} When the sizes cannot be read at all.
217
+ */
218
+ async function spoolSizes(rawExec, outPath, errPath) {
219
+ const sizes = toExecResult(await rawExec([
220
+ 'sh',
221
+ '-c',
222
+ `stat -c %s ${outPath} 2>/dev/null || echo 0; stat -c %s ${errPath} 2>/dev/null || echo 0`,
223
+ ], CONTROL_EXEC_TIMEOUT_SECONDS));
224
+ if (sizes.exitCode !== 0) {
225
+ throw new Error(`Failed to inspect the detached command's output files: ${sizes.stderr || sizes.stdout}`);
226
+ }
227
+ const [out, err] = sizes.stdout
228
+ .trim()
229
+ .split('\n')
230
+ .map((value) => Number(value.trim()));
231
+ return {
232
+ stdout: Number.isFinite(out) ? out : 0,
233
+ stderr: Number.isFinite(err) ? err : 0,
234
+ };
235
+ }
236
+ /**
237
+ * Reads `size` bytes of a spool file back through exec in LENGTH-CHECKED
238
+ * chunks, shrinking the chunk whenever a response comes back short.
239
+ *
240
+ * One raw `head -c 5 MiB` of the spool is the shape that lost a whole slice of
241
+ * a database in production (see {@link COLLECT_CHUNK_BYTES}): Fly truncated
242
+ * the response and still reported success. Every chunk here is verified against
243
+ * the number of bytes asked for, retried at half the size when it is short (a
244
+ * truncating transport cuts at a response size, so a smaller request survives
245
+ * it), and a chunk that will not arrive whole fails the collection by name
246
+ * instead of returning silently shortened output.
247
+ *
248
+ * The bytes are base64-encoded on the Machine and decoded here as BYTES, so a
249
+ * chunk boundary falling inside a multi-byte UTF-8 character cannot corrupt it.
250
+ * @param rawExec - Transport callback issuing one Fly exec call.
251
+ * @param path - Absolute path of the spool file.
252
+ * @param size - Bytes to collect (already capped by the caller).
253
+ * @returns The collected text.
254
+ * @throws {Error} When a chunk cannot be read whole from the transport.
255
+ */
256
+ async function collectSpool(rawExec, path, size) {
257
+ const parts = [];
258
+ let chunkBytes = COLLECT_CHUNK_BYTES;
259
+ for (let offset = 0; offset < size;) {
260
+ let received = null;
261
+ let want = 0;
262
+ let receivedBytes = 0;
263
+ for (let attempt = 1; attempt <= COLLECT_ATTEMPTS; attempt++) {
264
+ want = Math.min(chunkBytes, size - offset);
265
+ const read = toExecResult(await rawExec(['sh', '-c', `tail -c +${offset + 1} ${path} 2>/dev/null | head -c ${want} | base64 -w0`], CONTROL_EXEC_TIMEOUT_SECONDS));
266
+ if (read.exitCode !== 0) {
267
+ throw new Error(`Failed to read the detached command's output back: ${read.stderr || read.stdout}`);
268
+ }
269
+ const decoded = Buffer.from(read.stdout.trim(), 'base64');
270
+ receivedBytes = decoded.length;
271
+ if (decoded.length === want) {
272
+ received = decoded;
273
+ break;
274
+ }
275
+ // Short: the transport cut this response. Ask for less, so the next
276
+ // attempt fits under whatever its ceiling turns out to be.
277
+ const nextChunkBytes = Math.max(MIN_COLLECT_CHUNK_BYTES, Math.floor(want / 2));
278
+ logger.warn('Fly returned a short output read; retrying in a smaller chunk', {
279
+ path,
280
+ offset,
281
+ want,
282
+ received: decoded.length,
283
+ nextChunkBytes,
284
+ attempt,
285
+ });
286
+ chunkBytes = nextChunkBytes;
287
+ }
288
+ if (received === null) {
289
+ throw new Error(`The detached command's output could not be collected: the Fly exec transport ` +
290
+ `returned ${receivedBytes} of ${want} bytes at offset ${offset} of ${path}, over ` +
291
+ `${COLLECT_ATTEMPTS} reads with the chunk shrunk to ${chunkBytes} bytes. The command ` +
292
+ 'itself finished; this is an output-collection (transport) failure — run it again.');
293
+ }
294
+ parts.push(received);
295
+ offset += want;
296
+ }
297
+ return Buffer.concat(parts).toString('utf8');
298
+ }
191
299
  /**
192
300
  * Runs a script detached inside the Machine and polls for its completion.
193
301
  *
@@ -235,14 +343,12 @@ async function execDetached(rawExec, script, budgetMs) {
235
343
  break;
236
344
  }
237
345
  }
238
- const collect = toExecResult(await rawExec([
239
- 'sh',
240
- '-c',
241
- `head -c ${MAX_CAPTURED_OUTPUT_BYTES} ${outPath} 2>/dev/null; printf '\\n__MOL_STDERR__\\n'; head -c ${MAX_CAPTURED_OUTPUT_BYTES} ${errPath} 2>/dev/null`,
242
- ], CONTROL_EXEC_TIMEOUT_SECONDS));
243
- const separator = collect.stdout.indexOf('\n__MOL_STDERR__\n');
244
- const stdout = separator === -1 ? collect.stdout : collect.stdout.slice(0, separator);
245
- const stderr = separator === -1 ? '' : collect.stdout.slice(separator + '\n__MOL_STDERR__\n'.length);
346
+ // Collect the spools in length-checked chunks BEFORE any cleanup: a single
347
+ // raw read of a large stdout is exactly what came back shortened (exit 0)
348
+ // in production, and the spool is the only copy there is.
349
+ const sizes = await spoolSizes(rawExec, outPath, errPath);
350
+ const stdout = await collectSpool(rawExec, outPath, Math.min(sizes.stdout, MAX_CAPTURED_OUTPUT_BYTES));
351
+ const stderr = await collectSpool(rawExec, errPath, Math.min(sizes.stderr, MAX_CAPTURED_OUTPUT_BYTES));
246
352
  if (rc === null) {
247
353
  // Still running at the deadline. The bookkeeping files are deliberately NOT
248
354
  // removed — the process owns them and will keep writing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",