@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 +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +68 -14
- package/package.json +1 -1
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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
|
package/dist/provider.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1841
|
-
// is one
|
|
1842
|
-
//
|
|
1843
|
-
//
|
|
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
|
-
|
|
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(
|
|
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);
|