@molecule/api-code-sandbox-flyio 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -86,6 +86,17 @@
86
86
  * setuid/setgid dropped by `--no-same-permissions` — were verified by running
87
87
  * GNU tar 1.35 against crafted archives rather than inferred from its manual.
88
88
  *
89
+ * **Absence vs failure: `get()` only answers `null` for a REAL absence.**
90
+ * `get()` (and each read shaped like it — `list()`, `find()`, `getTemplate()`)
91
+ * reserves `null`/`[]` for a positive not-found answer — Fly answered 404 for
92
+ * the app or the Machine — and THROWS for every other failure: 5xx, 429 past
93
+ * the client's retries, timeouts, network errors. A caller that reconciles
94
+ * from `null` (clearing sandbox ids, reaping Machines) must never read a
95
+ * transient Fly API blip as "the sandbox does not exist"; treat a rejection
96
+ * from these methods as "unknown", never as "gone". The thrown error carries
97
+ * the underlying `FlyApiError` (HTTP status, or 0 for a transport failure) as
98
+ * its `cause`.
99
+ *
89
100
  * **`sleep()` is suspend, not stop — that is the point of this bond.**
90
101
  * `sleep()` calls `POST /v1/apps/{app}/machines/{id}/suspend`, which "uses
91
102
  * Firecracker snapshots to capture the entire VM state: CPU registers, memory
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0UG;AAEH,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,UAAU,CAAA;AACxB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA;AAE9B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqVG;AAEH,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,UAAU,CAAA;AACxB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA;AAE9B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
package/dist/index.js CHANGED
@@ -86,6 +86,17 @@
86
86
  * setuid/setgid dropped by `--no-same-permissions` — were verified by running
87
87
  * GNU tar 1.35 against crafted archives rather than inferred from its manual.
88
88
  *
89
+ * **Absence vs failure: `get()` only answers `null` for a REAL absence.**
90
+ * `get()` (and each read shaped like it — `list()`, `find()`, `getTemplate()`)
91
+ * reserves `null`/`[]` for a positive not-found answer — Fly answered 404 for
92
+ * the app or the Machine — and THROWS for every other failure: 5xx, 429 past
93
+ * the client's retries, timeouts, network errors. A caller that reconciles
94
+ * from `null` (clearing sandbox ids, reaping Machines) must never read a
95
+ * transient Fly API blip as "the sandbox does not exist"; treat a rejection
96
+ * from these methods as "unknown", never as "gone". The thrown error carries
97
+ * the underlying `FlyApiError` (HTTP status, or 0 for a transport failure) as
98
+ * its `cause`.
99
+ *
89
100
  * **`sleep()` is suspend, not stop — that is the point of this bond.**
90
101
  * `sleep()` calls `POST /v1/apps/{app}/machines/{id}/suspend`, which "uses
91
102
  * Firecracker snapshots to capture the entire VM state: CPU registers, memory
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AA8yEnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AA+2EnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
package/dist/provider.js CHANGED
@@ -1043,8 +1043,22 @@ class FlyioSandboxProvider {
1043
1043
  }
1044
1044
  /**
1045
1045
  * Retrieves an existing sandbox by its composite `<app>:<machineId>` id.
1046
+ *
1047
+ * `null` is reserved for a POSITIVE answer that the sandbox is gone: Fly
1048
+ * answered 404 for the app or the Machine (the client's `nullOn: [404]`
1049
+ * resolves that before this method sees an error), or the id is not in
1050
+ * `<app>:<machineId>` form at all. Every OTHER failure — 5xx, 429, a timeout,
1051
+ * a network error, anything the API call can still throw after its retries —
1052
+ * THROWS. That split is the core's absence contract, and it is load-bearing:
1053
+ * a caller that reconciles from `null` (clearing sandbox ids, reaping
1054
+ * Machines) must never read a transient Fly blip as "the sandbox does not
1055
+ * exist" — on 2026-10-07 exactly that orphaned live Machines that kept
1056
+ * billing with no row left to name them.
1046
1057
  * @param id - The sandbox id returned by `create`.
1047
- * @returns The sandbox, or `null` when the id is malformed or the Machine is gone.
1058
+ * @returns The sandbox, or `null` when the id is malformed or Fly positively
1059
+ * answered that the app or Machine does not exist.
1060
+ * @throws {Error} When the lookup itself failed — with `cause` carrying the
1061
+ * `FlyApiError`. The Machine may still exist; this is not an absence answer.
1048
1062
  */
1049
1063
  async get(id) {
1050
1064
  let app;
@@ -1066,8 +1080,15 @@ class FlyioSandboxProvider {
1066
1080
  return this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created'));
1067
1081
  }
1068
1082
  catch (error) {
1069
- logger.debug('Failed to get Fly Machine', { id, error });
1070
- return null;
1083
+ // NOT an absence. Returning null here made molecule.dev's loss-recorder
1084
+ // clear the sandbox ids of projects whose Machines were still running
1085
+ // (2026-10-07, two leaked 2 vCPU / 4 GB Machines). Say the lookup failed
1086
+ // and keep the cause, so the caller retries or surfaces it instead.
1087
+ throw new Error(t('codeSandbox.flyio.error.getFailed', { id }, {
1088
+ defaultValue: `Cannot read Fly sandbox "${id}": the Fly API lookup failed, so whether it exists ` +
1089
+ 'is UNKNOWN — the Machine may still be running. This is not an absence answer; ' +
1090
+ 'retry or surface the error rather than treating the sandbox as gone.',
1091
+ }), { cause: error });
1071
1092
  }
1072
1093
  }
1073
1094
  /**
@@ -1692,7 +1713,12 @@ class FlyioSandboxProvider {
1692
1713
  *
1693
1714
  * A Machine that is already `started` may reject a redundant start. Rather
1694
1715
  * than pattern-matching an error string, the state is re-read and the call
1695
- * treated as successful when the Machine is in fact running.
1716
+ * treated as successful when the Machine is in fact running. A Machine still
1717
+ * `starting` (its own start lost to a concurrent one — the proxy's autostart
1718
+ * racing a `wake()`) is NOT that case: only `waitForStarted` may end this
1719
+ * call, or it resolves against a Machine that is still booting and the
1720
+ * caller's first `exec` fails with 412 "machine not running" — the exact
1721
+ * failure this method's wait exists to prevent.
1696
1722
  * @param app - The Fly app name.
1697
1723
  * @param machineId - The Fly Machine id.
1698
1724
  */
@@ -1704,7 +1730,7 @@ class FlyioSandboxProvider {
1704
1730
  const machine = await this.client
1705
1731
  .request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
1706
1732
  .catch(() => null);
1707
- if (machine && (machine.state === 'started' || machine.state === 'starting')) {
1733
+ if (machine?.state === 'started') {
1708
1734
  logger.debug('Fly Machine start rejected but the Machine is already running', {
1709
1735
  app,
1710
1736
  machineId,
@@ -1712,7 +1738,9 @@ class FlyioSandboxProvider {
1712
1738
  });
1713
1739
  return;
1714
1740
  }
1715
- throw error;
1741
+ if (machine?.state !== 'starting')
1742
+ throw error;
1743
+ logger.debug('Fly Machine start rejected but the Machine is already starting — waiting for started', { app, machineId });
1716
1744
  }
1717
1745
  await this.waitForStarted(app, machineId);
1718
1746
  }
@@ -1837,10 +1865,12 @@ class FlyioSandboxProvider {
1837
1865
  throw new Error(t('codeSandbox.flyio.error.writeFailed', { path, error: result.stderr }, { defaultValue: `Failed to write ${path}: ${result.stderr}` }));
1838
1866
  }
1839
1867
  };
1840
- // Chunked for the same reason as the Docker provider: the whole command
1841
- // is one `sh -c` argument, capped at MAX_ARG_STRLEN (128 KB). Each chunk
1842
- // is a multiple of 4 base64 characters (= 3 decoded bytes) so it decodes
1843
- // standalone and the bytes concatenate exactly.
1868
+ // Chunked for the same reason as WRITE_CHUNK_BASE64 above: the whole
1869
+ // command is one argument to Fly's exec API, which silently rejects
1870
+ // anything over ~15 KB (FLY_EXEC_MAX_ARG_BYTES) — NOT the OS
1871
+ // MAX_ARG_STRLEN (128 KB) a Docker-based provider is bounded by. Each
1872
+ // chunk is a multiple of 4 base64 characters (= 3 decoded bytes) so it
1873
+ // decodes standalone and the bytes concatenate exactly.
1844
1874
  failIfError(await this.exec(`mkdir -p "$(dirname ${quoted})" && printf %s ${shellQuote(base64.slice(0, WRITE_CHUNK_BASE64))} | base64 -d > ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
1845
1875
  for (let i = WRITE_CHUNK_BASE64; i < base64.length; i += WRITE_CHUNK_BASE64) {
1846
1876
  failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + WRITE_CHUNK_BASE64))} | base64 -d >> ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
@@ -1937,6 +1967,14 @@ class FlyioSandboxProvider {
1937
1967
  try {
1938
1968
  const putUrl = await store.presignPut(key, IMPORT_PRESIGN_EXPIRY_SECONDS);
1939
1969
  const putRes = await fetch(putUrl, { method: 'PUT', body: gz });
1970
+ // The PUT's body is never read (a presigned store answers an empty
1971
+ // 2xx or a short error document): release it on EVERY path, or the
1972
+ // connection stays stranded in the pool until GC reclaims it — the
1973
+ // same release every other discarded response body here gets.
1974
+ await putRes.body?.cancel().catch((_error) => {
1975
+ // Best-effort release only; the fallback decision below runs on
1976
+ // the already-known status.
1977
+ });
1940
1978
  if (!putRes.ok) {
1941
1979
  throw new Error(`presigned PUT to the object store failed: ${putRes.status}`);
1942
1980
  }
@@ -1946,7 +1984,12 @@ class FlyioSandboxProvider {
1946
1984
  `mkdir -p ${quotedPath}`,
1947
1985
  'archive=$(mktemp /tmp/mol-import-XXXXXX.tar.gz)',
1948
1986
  `curl --fail-with-body -sS --retry 3 --retry-connrefused -o "$archive" ${shellQuote(getUrl)}`,
1949
- `tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive"`,
1987
+ // The archive is removed whether the extract SUCCEEDS or fails:
1988
+ // `&& rm` after tar (under `set -e`) left every failed extract's
1989
+ // download behind in the sandbox's /tmp until the Machine died.
1990
+ // The `|| { }` guard keeps `set -e` from aborting before the
1991
+ // cleanup and re-raises the real exit code.
1992
+ `tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive" || { rc=$?; rm -f "$archive"; exit $rc; }`,
1950
1993
  'rm -f "$archive"',
1951
1994
  ].join('\n'), { timeout: IMPORT_EXTRACT_TIMEOUT_MS });
1952
1995
  // Best-effort: the temp object expires anyway, but delete it promptly.
@@ -1968,15 +2011,26 @@ class FlyioSandboxProvider {
1968
2011
  // configured. The temp tar lives on the WORKSPACE VOLUME, not /tmp — the
1969
2012
  // chunked write and the extract are separate exec calls and a Machine
1970
2013
  // with autostop can cold-start between them, wiping /tmp (rootfs) while
1971
- // the ext4 volume at /workspace persists.
2014
+ // the ext4 volume at /workspace persists. The name is unique per call
2015
+ // (same shape as the store path's key): a fixed, machineId-only name
2016
+ // made two concurrent imports to one sandbox interleave their `>>`
2017
+ // appends into ONE file, so each extracted a mix of both archives.
1972
2018
  const base64 = gz.toString('base64');
1973
- const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}.tgz`;
2019
+ const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tgz`;
1974
2020
  const quotedTmp = shellQuote(tmp);
1975
2021
  failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
1976
2022
  for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
1977
2023
  failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
1978
2024
  }
1979
- failIfError(await this.exec(`mkdir -p ${quotedPath} && tar -xzf ${quotedTmp} -C ${quotedPath} --no-same-owner --no-same-permissions && rm -f ${quotedTmp}`, { timeout: IMPORT_EXTRACT_TIMEOUT_MS }), 'extract');
2025
+ failIfError(await this.exec(
2026
+ // The temp archive is removed whether the extract SUCCEEDS or fails:
2027
+ // `&& rm` after tar left a failed extract's multi-MB archive behind
2028
+ // on the WORKSPACE VOLUME — the durable path this fallback
2029
+ // deliberately writes to — until the next import overwrote it.
2030
+ // `; rc=$?; rm …; exit $rc` cleans up unconditionally and still
2031
+ // reports tar's real exit code (the same shape exec.ts's script
2032
+ // spill uses).
2033
+ `mkdir -p ${quotedPath} && tar -xzf ${quotedTmp} -C ${quotedPath} --no-same-owner --no-same-permissions; rc=$?; rm -f ${quotedTmp}; exit $rc`, { timeout: IMPORT_EXTRACT_TIMEOUT_MS }), 'extract');
1980
2034
  },
1981
2035
  getPreviewUrl(port) {
1982
2036
  return provider.renderPreviewUrl(app, machineId, port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",