@agent-relay/sandbox 0.1.2 → 0.1.3
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/README.md +226 -5
- package/dist/agent37/client.d.ts +115 -0
- package/dist/agent37/client.d.ts.map +1 -0
- package/dist/agent37/client.js +270 -0
- package/dist/agent37/client.js.map +1 -0
- package/dist/agent37/runtime.d.ts +415 -0
- package/dist/agent37/runtime.d.ts.map +1 -0
- package/dist/agent37/runtime.js +855 -0
- package/dist/agent37/runtime.js.map +1 -0
- package/dist/agentcore/bench.d.ts +93 -0
- package/dist/agentcore/bench.d.ts.map +1 -0
- package/dist/agentcore/bench.js +125 -0
- package/dist/agentcore/bench.js.map +1 -0
- package/dist/agentcore/capabilities.d.ts +169 -0
- package/dist/agentcore/capabilities.d.ts.map +1 -0
- package/dist/agentcore/capabilities.js +153 -0
- package/dist/agentcore/capabilities.js.map +1 -0
- package/dist/agentcore/config.d.ts +156 -0
- package/dist/agentcore/config.d.ts.map +1 -0
- package/dist/agentcore/config.js +12 -0
- package/dist/agentcore/config.js.map +1 -0
- package/dist/agentcore/internal/sdk.d.ts +181 -0
- package/dist/agentcore/internal/sdk.d.ts.map +1 -0
- package/dist/agentcore/internal/sdk.js +223 -0
- package/dist/agentcore/internal/sdk.js.map +1 -0
- package/dist/agentcore/runtime.d.ts +177 -0
- package/dist/agentcore/runtime.d.ts.map +1 -0
- package/dist/agentcore/runtime.js +706 -0
- package/dist/agentcore/runtime.js.map +1 -0
- package/dist/daytona/runtime.d.ts +23 -0
- package/dist/daytona/runtime.d.ts.map +1 -1
- package/dist/daytona/runtime.js +167 -5
- package/dist/daytona/runtime.js.map +1 -1
- package/dist/daytona/wire-supplement.d.ts +25 -0
- package/dist/daytona/wire-supplement.d.ts.map +1 -0
- package/dist/daytona/wire-supplement.js +16 -0
- package/dist/daytona/wire-supplement.js.map +1 -0
- package/dist/e2b/runtime.d.ts +107 -72
- package/dist/e2b/runtime.d.ts.map +1 -1
- package/dist/e2b/runtime.js +675 -190
- package/dist/e2b/runtime.js.map +1 -1
- package/dist/freestyle/capabilities.d.ts +28 -0
- package/dist/freestyle/capabilities.d.ts.map +1 -0
- package/dist/freestyle/capabilities.js +42 -0
- package/dist/freestyle/capabilities.js.map +1 -0
- package/dist/freestyle/config.d.ts +36 -0
- package/dist/freestyle/config.d.ts.map +1 -0
- package/dist/freestyle/config.js +2 -0
- package/dist/freestyle/config.js.map +1 -0
- package/dist/freestyle/internal/sdk.d.ts +69 -0
- package/dist/freestyle/internal/sdk.d.ts.map +1 -0
- package/dist/freestyle/internal/sdk.js +19 -0
- package/dist/freestyle/internal/sdk.js.map +1 -0
- package/dist/freestyle/runtime.d.ts +163 -0
- package/dist/freestyle/runtime.d.ts.map +1 -0
- package/dist/freestyle/runtime.js +798 -0
- package/dist/freestyle/runtime.js.map +1 -0
- package/dist/index.d.ts +35 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/microsandbox/runtime.d.ts +793 -0
- package/dist/microsandbox/runtime.d.ts.map +1 -0
- package/dist/microsandbox/runtime.js +2303 -0
- package/dist/microsandbox/runtime.js.map +1 -0
- package/dist/modal/bench.d.ts +199 -0
- package/dist/modal/bench.d.ts.map +1 -0
- package/dist/modal/bench.js +450 -0
- package/dist/modal/bench.js.map +1 -0
- package/dist/modal/capabilities.d.ts +180 -0
- package/dist/modal/capabilities.d.ts.map +1 -0
- package/dist/modal/capabilities.js +159 -0
- package/dist/modal/capabilities.js.map +1 -0
- package/dist/modal/config.d.ts +192 -0
- package/dist/modal/config.d.ts.map +1 -0
- package/dist/modal/config.js +169 -0
- package/dist/modal/config.js.map +1 -0
- package/dist/modal/internal/sdk.d.ts +141 -0
- package/dist/modal/internal/sdk.d.ts.map +1 -0
- package/dist/modal/internal/sdk.js +51 -0
- package/dist/modal/internal/sdk.js.map +1 -0
- package/dist/modal/runtime.d.ts +271 -0
- package/dist/modal/runtime.d.ts.map +1 -0
- package/dist/modal/runtime.js +871 -0
- package/dist/modal/runtime.js.map +1 -0
- package/dist/mount-script.d.ts +16 -1
- package/dist/mount-script.d.ts.map +1 -1
- package/dist/mount-script.js +76 -15
- package/dist/mount-script.js.map +1 -1
- package/dist/orchestrator.d.ts +15 -0
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +13 -1
- package/dist/orchestrator.js.map +1 -1
- package/dist/port.d.ts +144 -6
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +27 -5
- package/dist/port.js.map +1 -1
- package/dist/types.d.ts +50 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vercel/bench.d.ts +169 -0
- package/dist/vercel/bench.d.ts.map +1 -0
- package/dist/vercel/bench.js +337 -0
- package/dist/vercel/bench.js.map +1 -0
- package/dist/vercel/capabilities.d.ts +106 -0
- package/dist/vercel/capabilities.d.ts.map +1 -0
- package/dist/vercel/capabilities.js +128 -0
- package/dist/vercel/capabilities.js.map +1 -0
- package/dist/vercel/config.d.ts +119 -0
- package/dist/vercel/config.d.ts.map +1 -0
- package/dist/vercel/config.js +8 -0
- package/dist/vercel/config.js.map +1 -0
- package/dist/vercel/internal/sdk.d.ts +173 -0
- package/dist/vercel/internal/sdk.d.ts.map +1 -0
- package/dist/vercel/internal/sdk.js +117 -0
- package/dist/vercel/internal/sdk.js.map +1 -0
- package/dist/vercel/runtime.d.ts +305 -0
- package/dist/vercel/runtime.d.ts.map +1 -0
- package/dist/vercel/runtime.js +1169 -0
- package/dist/vercel/runtime.js.map +1 -0
- package/docs/freestyle.md +110 -0
- package/package.json +35 -4
|
@@ -0,0 +1,2303 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Microsandbox provider for the SandboxRuntime + WorkflowRuntime contracts.
|
|
5
|
+
//
|
|
6
|
+
// The `microsandbox` npm package is imported LAZILY (`await import(...)`),
|
|
7
|
+
// never at module top level, so consumers on another provider neither bundle it
|
|
8
|
+
// nor need its platform-specific native addon installed. The SDK surface is
|
|
9
|
+
// modeled structurally below (read from microsandbox@0.6.11 `dist/*.d.ts`) so
|
|
10
|
+
// typecheck and unit tests do not hard-couple to the SDK type graph, and a fake
|
|
11
|
+
// SDK can drive every path without a live backend or an API key.
|
|
12
|
+
//
|
|
13
|
+
// Five provider facts shape this adapter and are worth stating up front,
|
|
14
|
+
// because each one breaks an assumption the other adapters in this package are
|
|
15
|
+
// allowed to make:
|
|
16
|
+
//
|
|
17
|
+
// 1. IDENTITY IS A NAME, NOT A SERVER-ASSIGNED ID. `Sandbox.builder(name)`,
|
|
18
|
+
// `Sandbox.get(name)` and `SandboxHandle.remove()` all address a sandbox by
|
|
19
|
+
// a caller-chosen name capped at 128 UTF-8 bytes. `RuntimeHandle.id`
|
|
20
|
+
// therefore carries that name, and an over-long name is rejected rather
|
|
21
|
+
// than truncated — truncating would silently alias two distinct sandboxes
|
|
22
|
+
// onto one identity.
|
|
23
|
+
//
|
|
24
|
+
// 2. BACKEND SELECTION IS PROCESS-WIDE GLOBAL STATE. The SDK exposes
|
|
25
|
+
// `setDefaultBackend(backend)` (permanent) and `withDefaultBackend(backend,
|
|
26
|
+
// fn)` (scoped, restored in a `finally`). The SDK documents that the scoped
|
|
27
|
+
// form is NOT task-local: concurrent work in the same process observes the
|
|
28
|
+
// temporary backend while the callback runs, so two overlapping calls on
|
|
29
|
+
// different backends would silently send one of them to the wrong place.
|
|
30
|
+
// This adapter therefore never calls `setDefaultBackend`, and routes the
|
|
31
|
+
// calls that DO read that slot — the SDK's default-dependent statics —
|
|
32
|
+
// through one PROCESS-GLOBAL gate (see `withBackendScope`): calls that want
|
|
33
|
+
// the same backend share a single open scope and still run concurrently; a
|
|
34
|
+
// call that wants a different backend waits until the current scope has
|
|
35
|
+
// closed. A resolved `Sandbox` or `SandboxHandle` is bound to the backend it
|
|
36
|
+
// was resolved on, so its exec, filesystem and lifecycle calls read no
|
|
37
|
+
// global state and are issued off the gate. That is a real mutual-exclusion
|
|
38
|
+
// guarantee for this adapter's own default-dependent calls, and the honest
|
|
39
|
+
// limit of it is stated on the gate: SDK calls made elsewhere in the
|
|
40
|
+
// process, outside this adapter, are not covered by it and can still
|
|
41
|
+
// observe the scoped backend.
|
|
42
|
+
//
|
|
43
|
+
// 3. THERE IS NO CREATE-TIMEOUT SETTER ON THE BUILDER. `maxDuration` and
|
|
44
|
+
// `idleTimeout` are sandbox LIFETIME budgets. Mapping the caller's
|
|
45
|
+
// `createTimeoutSeconds` onto either would kill every long-lived sandbox
|
|
46
|
+
// the moment the boot deadline elapsed, so the create deadline is enforced
|
|
47
|
+
// client-side instead — and a create that lands AFTER that deadline is
|
|
48
|
+
// reclaimed rather than leaked (see `launch`).
|
|
49
|
+
//
|
|
50
|
+
// 4. THE SDK REQUIRES NODE 22+ AND A VIRTUALIZATION-CAPABLE HOST.
|
|
51
|
+
// `microsandbox@0.6.x` declares `engines.node >= 22` and ships a
|
|
52
|
+
// platform-specific native addon (macOS arm64, Linux x64/arm64, Windows
|
|
53
|
+
// x64/arm64). Its published requirements are Linux with KVM, macOS on
|
|
54
|
+
// Apple Silicon, or Windows 10+ with WHP — that hardware requirement is
|
|
55
|
+
// what the LOCAL backend boots microVMs on; the cloud backend boots them
|
|
56
|
+
// remotely but still loads the same native client addon. This package's own
|
|
57
|
+
// floor stays Node 20, because every other adapter here runs there and the
|
|
58
|
+
// SDK is an OPTIONAL peer dependency — so the constraint is surfaced where
|
|
59
|
+
// it actually bites: the lazy import wraps a load failure with it.
|
|
60
|
+
//
|
|
61
|
+
// 5. AN ASYNC RUN HAS NO SERVER-SIDE COMMAND RECORD. The SDK's streaming
|
|
62
|
+
// `ExecHandle` is process-local and carries no id a later process could
|
|
63
|
+
// poll, so async runs are tracked by durable files in the guest. Everything
|
|
64
|
+
// that makes that safe — one-shot admission, adoption of a run whose
|
|
65
|
+
// submit response was lost, refusal to overwrite another run's state, and
|
|
66
|
+
// detection of a run whose process died without recording an exit code —
|
|
67
|
+
// lives in the two POSIX shell scripts below, not in the caller.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/** Max sandbox name length the SDK accepts, in UTF-8 bytes. */
|
|
70
|
+
const MAX_SANDBOX_NAME_BYTES = 128;
|
|
71
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
72
|
+
const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;
|
|
73
|
+
const DEFAULT_LIST_PAGE_SIZE = 100;
|
|
74
|
+
/**
|
|
75
|
+
* Hard bound on pages drained for one lookup. Reaching it is a failure, never
|
|
76
|
+
* a result — see the throw at the end of `collectByLabels`.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_LIST_PAGES = 1_000;
|
|
79
|
+
/**
|
|
80
|
+
* How long a call may wait for the process-global backend gate before it fails
|
|
81
|
+
* instead of waiting forever. See {@link MicrosandboxBackendBusyError}.
|
|
82
|
+
*/
|
|
83
|
+
const DEFAULT_BACKEND_QUEUE_TIMEOUT_MS = 30_000;
|
|
84
|
+
const SCRIPT_LOG_READ_MAX_BYTES = 200_000;
|
|
85
|
+
/**
|
|
86
|
+
* Longest encoded run-state path segment before it is replaced by a digest.
|
|
87
|
+
* Well under the 255-byte filename limit every mainstream guest filesystem
|
|
88
|
+
* imposes, with room for the `out`/`exit`/`pid` leaves underneath it.
|
|
89
|
+
*/
|
|
90
|
+
const MAX_RUN_SEGMENT_BYTES = 120;
|
|
91
|
+
/**
|
|
92
|
+
* Where the async-run wrapper parks its `cmd`/`pid`/`boot`/`out`/`exit` files
|
|
93
|
+
* inside the guest.
|
|
94
|
+
*
|
|
95
|
+
* `/tmp` is a POSIX guarantee of the guest filesystem, not a fact about any
|
|
96
|
+
* particular deployment, so it is a safe default rather than baked-in
|
|
97
|
+
* infrastructure. Consumers whose image mounts `/tmp` read-only override it
|
|
98
|
+
* with `runStateDir`.
|
|
99
|
+
*/
|
|
100
|
+
const DEFAULT_RUN_STATE_DIR = "/tmp/microsandbox-run";
|
|
101
|
+
/** POSIX shell used to interpret a `runScript`/`startScript` command string. */
|
|
102
|
+
const DEFAULT_SHELL = "/bin/sh";
|
|
103
|
+
/**
|
|
104
|
+
* Where the guest's process table lives. Passed to the run scripts as an
|
|
105
|
+
* argument rather than written into them, so the same scripts can be executed
|
|
106
|
+
* against a synthetic procfs in tests; the value the adapter sends is never
|
|
107
|
+
* anything else, and is never taken from caller-supplied environment.
|
|
108
|
+
*/
|
|
109
|
+
const GUEST_PROC_ROOT = "/proc";
|
|
110
|
+
// --- guest-side async run protocol -----------------------------------------
|
|
111
|
+
//
|
|
112
|
+
// Both scripts below are pure POSIX `sh` and take every value — including the
|
|
113
|
+
// caller's command — as a positional ARGUMENT, never as interpolated text. So
|
|
114
|
+
// no quoting of caller data happens anywhere on the host, and the exact bytes
|
|
115
|
+
// of the command reach the guest unmodified.
|
|
116
|
+
//
|
|
117
|
+
// The run directory doubles as the admission record. `mkdir` of a single
|
|
118
|
+
// directory is atomic on every POSIX filesystem, so it either claims the
|
|
119
|
+
// session or proves someone else already did — which is what makes a resubmit
|
|
120
|
+
// of the same session id incapable of starting a second process or of
|
|
121
|
+
// overwriting the first one's state.
|
|
122
|
+
/**
|
|
123
|
+
* Read a pid's START TIME (field 22 of `/proc/<pid>/stat`, in clock ticks
|
|
124
|
+
* since boot) into stdout, or fail with a non-zero status when procfs cannot
|
|
125
|
+
* answer.
|
|
126
|
+
*
|
|
127
|
+
* WHY IT IS PART OF RUN IDENTITY. A pid alone does not identify a process: the
|
|
128
|
+
* guest can recycle it, and a recycled pid answers `kill -0` exactly like the
|
|
129
|
+
* original, so a run whose process died would keep reporting RUNNING forever
|
|
130
|
+
* as soon as something else landed on its number. Start time is the field that
|
|
131
|
+
* makes the pair unique for the lifetime of a boot — two processes on the same
|
|
132
|
+
* pid cannot share it.
|
|
133
|
+
*
|
|
134
|
+
* The parse is byte-careful for one specific reason: field 2 is the executable
|
|
135
|
+
* name in parentheses and MAY CONTAIN SPACES AND PARENTHESES, so splitting the
|
|
136
|
+
* line on whitespace from the left is wrong. Every field after it is numeric or
|
|
137
|
+
* a single flag character, so the LAST `") "` in the line is always the end of
|
|
138
|
+
* that field — which is what `##*") "` finds. Start time is then the 20th field
|
|
139
|
+
* of the remainder (22 overall, less the pid and the name).
|
|
140
|
+
*
|
|
141
|
+
* @internal Shared by both scripts below; not part of the public API.
|
|
142
|
+
*/
|
|
143
|
+
const MSB_STARTTIME_FN = [
|
|
144
|
+
// `$1` procfs root, `$2` pid. The root is a PARAMETER rather than a literal
|
|
145
|
+
// so the identity check is executable against a synthetic procfs in tests —
|
|
146
|
+
// on every platform, not only on hosts that have a real one. It is passed by
|
|
147
|
+
// the adapter as a positional argument and is never read from the
|
|
148
|
+
// environment, which the caller controls: a run's liveness verdict must not
|
|
149
|
+
// be redirectable by whoever submitted it.
|
|
150
|
+
"msb_starttime() {",
|
|
151
|
+
' msb_st=$(cat "$1/$2/stat" 2>/dev/null) || return 1',
|
|
152
|
+
' msb_rest=${msb_st##*") "}',
|
|
153
|
+
' if [ "$msb_rest" = "$msb_st" ]; then return 1; fi',
|
|
154
|
+
" set -- $msb_rest",
|
|
155
|
+
' if [ "$#" -lt 20 ]; then return 1; fi',
|
|
156
|
+
' printf %s "${20}"',
|
|
157
|
+
"}",
|
|
158
|
+
].join("\n");
|
|
159
|
+
/**
|
|
160
|
+
* Admit one async run.
|
|
161
|
+
*
|
|
162
|
+
* Arguments: `$1` command, `$2` run directory, `$3` its parent, `$4` shell.
|
|
163
|
+
*
|
|
164
|
+
* Prints exactly one of:
|
|
165
|
+
* - `ADMITTED <pid>` — this call created the run.
|
|
166
|
+
* - `CLAIMED <pid>` — the session was already admitted for THIS EXACT
|
|
167
|
+
* command, so its existing run is adopted. This is the outcome-unknown
|
|
168
|
+
* case: a submit whose response was lost is retried by the caller and
|
|
169
|
+
* resolves here, without ever starting a second process.
|
|
170
|
+
* - `CONFLICT` — the session is already admitted for a DIFFERENT
|
|
171
|
+
* command (or its record is unreadable). Nothing is started and nothing is
|
|
172
|
+
* overwritten; the caller gets a typed error.
|
|
173
|
+
*
|
|
174
|
+
* @internal Exported only so the protocol tests can execute it under a real
|
|
175
|
+
* `/bin/sh`. Not part of the package's public API.
|
|
176
|
+
*/
|
|
177
|
+
export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [
|
|
178
|
+
"set -u",
|
|
179
|
+
"cmd=$1",
|
|
180
|
+
"dir=$2",
|
|
181
|
+
"parent=$3",
|
|
182
|
+
"shell_path=$4",
|
|
183
|
+
"proc_root=$5",
|
|
184
|
+
MSB_STARTTIME_FN,
|
|
185
|
+
'mkdir -p "$parent" 2>/dev/null || true',
|
|
186
|
+
'if mkdir "$dir" 2>/dev/null; then',
|
|
187
|
+
// Record the command BEFORE starting anything: a crash between the two
|
|
188
|
+
// leaves a claimed-but-dead session, which the status probe reports as lost,
|
|
189
|
+
// rather than an unattributable running process.
|
|
190
|
+
' printf %s "$cmd" > "$dir/cmd"',
|
|
191
|
+
// Boot identity, used by the status probe to tell "still running" from "the
|
|
192
|
+
// sandbox restarted and this pid now belongs to someone else". Absent on a
|
|
193
|
+
// guest without procfs, in which case the probe falls back to pid liveness.
|
|
194
|
+
' cat "$proc_root/sys/kernel/random/boot_id" > "$dir/boot" 2>/dev/null || true',
|
|
195
|
+
// The command runs in a CHILD shell, so an `exit 7` inside it cannot skip
|
|
196
|
+
// the exit-code record: the child exits, the wrapper writes its status.
|
|
197
|
+
' nohup "$shell_path" -c \'"$3" -c "$1" > "$2/out" 2>&1; printf %s "$?" > "$2/exit"\' msb-run "$cmd" "$dir" "$shell_path" > /dev/null 2>&1 &',
|
|
198
|
+
" run_pid=$!",
|
|
199
|
+
' printf %s "$run_pid" > "$dir/pid"',
|
|
200
|
+
// Pid + start time is the run's identity. Written only when procfs actually
|
|
201
|
+
// answered: an EMPTY `start` file would be indistinguishable from "recorded
|
|
202
|
+
// a start time of nothing", and the probe would then compare against it.
|
|
203
|
+
" start_ticks=$(msb_starttime \"$proc_root\" \"$run_pid\") || start_ticks=''",
|
|
204
|
+
' if [ -n "$start_ticks" ]; then printf %s "$start_ticks" > "$dir/start"; fi',
|
|
205
|
+
' printf "ADMITTED %s\\n" "$run_pid"',
|
|
206
|
+
" exit 0",
|
|
207
|
+
"fi",
|
|
208
|
+
// `$(cat f)` strips EVERY trailing newline, so the sentinel-and-strip form
|
|
209
|
+
// is what makes the comparison byte-exact: a command that ends in a newline
|
|
210
|
+
// must still be recognised as the same command on an outcome-unknown retry.
|
|
211
|
+
'existing=$(cat "$dir/cmd" 2>/dev/null; printf X) || existing=X',
|
|
212
|
+
'existing=${existing%X}',
|
|
213
|
+
'run_pid=$(cat "$dir/pid" 2>/dev/null) || run_pid=""',
|
|
214
|
+
'if [ "$existing" = "$cmd" ] && [ -n "$run_pid" ]; then',
|
|
215
|
+
' printf "CLAIMED %s\\n" "$run_pid"',
|
|
216
|
+
" exit 0",
|
|
217
|
+
"fi",
|
|
218
|
+
'printf "CONFLICT\\n"',
|
|
219
|
+
].join("\n");
|
|
220
|
+
/**
|
|
221
|
+
* Report one async run's outcome.
|
|
222
|
+
*
|
|
223
|
+
* Argument: `$1` run directory. Prints exactly one of `EXIT <code>`,
|
|
224
|
+
* `RUNNING`, `MISSING`, `UNKNOWN <reason>`, or `LOST <reason>`.
|
|
225
|
+
*
|
|
226
|
+
* The exit file is checked first and again last: the wrapper writes it as its
|
|
227
|
+
* final act, so re-reading after the liveness probe closes the window where a
|
|
228
|
+
* run finishes mid-probe and would otherwise read as lost.
|
|
229
|
+
*
|
|
230
|
+
* LIVENESS IS THREE CHECKS, not one, because each answers a different way of
|
|
231
|
+
* losing a run:
|
|
232
|
+
* - boot id, for "the sandbox restarted underneath it";
|
|
233
|
+
* - `kill -0`, for "the process is gone";
|
|
234
|
+
* - START TIME, for "the pid is alive but it is somebody else's now". Without
|
|
235
|
+
* the third, a recycled pid reports RUNNING forever, which is the one
|
|
236
|
+
* failure a poll loop cannot end on.
|
|
237
|
+
*
|
|
238
|
+
* FALLBACK, stated because it is a real reduction in what the probe can tell
|
|
239
|
+
* apart: when the guest has no procfs, admission records no start time and the
|
|
240
|
+
* probe degrades to boot id + pid liveness — exactly the behaviour before start
|
|
241
|
+
* time existed. The fallback is chosen by the ABSENCE of a recorded start time,
|
|
242
|
+
* never by a failure to read the current one: if a start time was recorded and
|
|
243
|
+
* the current read fails while the pid is alive, the probe emits an explicit
|
|
244
|
+
* UNKNOWN marker. That is neither proof of continued life nor proof of pid
|
|
245
|
+
* reuse, so the adapter turns it into a retryable status-probe error.
|
|
246
|
+
*
|
|
247
|
+
* @internal Exported only for the protocol tests (see above).
|
|
248
|
+
*/
|
|
249
|
+
export const MICROSANDBOX_RUN_STATUS_SCRIPT = [
|
|
250
|
+
"set -u",
|
|
251
|
+
"dir=$1",
|
|
252
|
+
"proc_root=$2",
|
|
253
|
+
MSB_STARTTIME_FN,
|
|
254
|
+
'if [ -f "$dir/exit" ]; then',
|
|
255
|
+
' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"',
|
|
256
|
+
" exit 0",
|
|
257
|
+
"fi",
|
|
258
|
+
'if [ ! -d "$dir" ]; then',
|
|
259
|
+
' printf "MISSING\\n"',
|
|
260
|
+
" exit 0",
|
|
261
|
+
"fi",
|
|
262
|
+
'boot=$(cat "$dir/boot" 2>/dev/null) || boot=""',
|
|
263
|
+
'now=$(cat "$proc_root/sys/kernel/random/boot_id" 2>/dev/null) || now=""',
|
|
264
|
+
'if [ -n "$boot" ] && [ -n "$now" ] && [ "$boot" != "$now" ]; then',
|
|
265
|
+
' printf "LOST sandbox-restarted\\n"',
|
|
266
|
+
" exit 0",
|
|
267
|
+
"fi",
|
|
268
|
+
'run_pid=$(cat "$dir/pid" 2>/dev/null) || run_pid=""',
|
|
269
|
+
'if [ -z "$run_pid" ]; then',
|
|
270
|
+
// Admitted, pid not recorded yet: the admission call is still in flight.
|
|
271
|
+
' printf "RUNNING\\n"',
|
|
272
|
+
" exit 0",
|
|
273
|
+
"fi",
|
|
274
|
+
'if kill -0 "$run_pid" 2>/dev/null; then',
|
|
275
|
+
// The pid is alive. Is it still OUR process? Only a recorded start time can
|
|
276
|
+
// answer; without one the probe says RUNNING, as it did before.
|
|
277
|
+
' recorded=$(cat "$dir/start" 2>/dev/null) || recorded=""',
|
|
278
|
+
' if [ -n "$recorded" ]; then',
|
|
279
|
+
' if ! current=$(msb_starttime "$proc_root" "$run_pid"); then',
|
|
280
|
+
' printf "UNKNOWN starttime-unreadable\\n"',
|
|
281
|
+
" exit 0",
|
|
282
|
+
" fi",
|
|
283
|
+
' if [ "$current" != "$recorded" ]; then',
|
|
284
|
+
' printf "LOST pid-reused\\n"',
|
|
285
|
+
" exit 0",
|
|
286
|
+
" fi",
|
|
287
|
+
" fi",
|
|
288
|
+
' printf "RUNNING\\n"',
|
|
289
|
+
" exit 0",
|
|
290
|
+
"fi",
|
|
291
|
+
'if [ -f "$dir/exit" ]; then',
|
|
292
|
+
' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"',
|
|
293
|
+
" exit 0",
|
|
294
|
+
"fi",
|
|
295
|
+
'printf "LOST process-gone\\n"',
|
|
296
|
+
].join("\n");
|
|
297
|
+
/**
|
|
298
|
+
* Read one run's captured output.
|
|
299
|
+
*
|
|
300
|
+
* Arguments: `$1` log path, `$2` byte cap. Exits 0 with the last `$2` bytes of
|
|
301
|
+
* the file, or 0 with NO output when the file is genuinely absent; any other
|
|
302
|
+
* failure exits non-zero.
|
|
303
|
+
*
|
|
304
|
+
* THE EXIT CODE IS THE WHOLE POINT. The previous form of this read was
|
|
305
|
+
* `tail -c N path 2>/dev/null || true`, which flattened three different
|
|
306
|
+
* situations — "the run has not written anything yet", "the log is
|
|
307
|
+
* unreadable", and "the guest call failed" — onto the same empty string. Empty
|
|
308
|
+
* output is a legitimate answer for a run that printed nothing, so a caller
|
|
309
|
+
* cannot tell that reading from an unreadable one. Absence is now the ONLY
|
|
310
|
+
* condition that yields empty-and-successful; everything else fails loudly and
|
|
311
|
+
* the adapter raises {@link MicrosandboxLogReadError}.
|
|
312
|
+
*
|
|
313
|
+
* The cap is read as one byte MORE than the caller's limit, so the adapter can
|
|
314
|
+
* see that a longer file exists and report `truncated` rather than handing back
|
|
315
|
+
* a tail that reads like a complete log.
|
|
316
|
+
*
|
|
317
|
+
* @internal Exported only for the protocol tests (see above).
|
|
318
|
+
*/
|
|
319
|
+
export const MICROSANDBOX_RUN_LOG_SCRIPT = [
|
|
320
|
+
"set -u",
|
|
321
|
+
"path=$1",
|
|
322
|
+
"cap=$2",
|
|
323
|
+
// Absent is not an error: a run that has not yet written its first byte, and
|
|
324
|
+
// a run that printed nothing at all, both legitimately have no log.
|
|
325
|
+
// `-f` alone cannot make that distinction: it is false for BOTH a missing
|
|
326
|
+
// path and an existing directory/device/socket. A symlink is rejected too;
|
|
327
|
+
// the run protocol writes a regular file at this exact path.
|
|
328
|
+
'if [ -L "$path" ]; then exit 1; fi',
|
|
329
|
+
'if [ -f "$path" ]; then exec tail -c "$cap" "$path"; fi',
|
|
330
|
+
'if [ -e "$path" ]; then exit 1; fi',
|
|
331
|
+
"exit 0",
|
|
332
|
+
].join("\n");
|
|
333
|
+
/**
|
|
334
|
+
* A sandbox name exceeded the SDK's 128 UTF-8 byte cap.
|
|
335
|
+
*
|
|
336
|
+
* Raised instead of truncating: the name IS the identity, so a truncated name
|
|
337
|
+
* would collide two distinct sandboxes onto one addressable handle.
|
|
338
|
+
*/
|
|
339
|
+
export class MicrosandboxNameTooLongError extends Error {
|
|
340
|
+
name = "MicrosandboxNameTooLongError";
|
|
341
|
+
sandboxName;
|
|
342
|
+
byteLength;
|
|
343
|
+
maxByteLength = MAX_SANDBOX_NAME_BYTES;
|
|
344
|
+
constructor(sandboxName, byteLength) {
|
|
345
|
+
super(`Microsandbox sandbox name is ${byteLength} UTF-8 bytes, exceeding the ${MAX_SANDBOX_NAME_BYTES}-byte limit`);
|
|
346
|
+
this.sandboxName = sandboxName;
|
|
347
|
+
this.byteLength = byteLength;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The create deadline supplied by the caller elapsed before the sandbox
|
|
352
|
+
* finished booting.
|
|
353
|
+
*
|
|
354
|
+
* The create itself is NOT abandoned: the SDK exposes no cancellation for it,
|
|
355
|
+
* so the adapter keeps watching that promise and, if the sandbox does finish
|
|
356
|
+
* booting afterwards, reclaims it (kill + remove) rather than leaving a
|
|
357
|
+
* running microVM nobody is waiting for and a name nobody can reuse.
|
|
358
|
+
*/
|
|
359
|
+
export class MicrosandboxCreateTimeoutError extends Error {
|
|
360
|
+
name = "MicrosandboxCreateTimeoutError";
|
|
361
|
+
sandboxName;
|
|
362
|
+
timeoutMs;
|
|
363
|
+
constructor(sandboxName, timeoutMs) {
|
|
364
|
+
super(`Microsandbox sandbox "${sandboxName}" did not finish creating within ${timeoutMs}ms; a late create is reclaimed`);
|
|
365
|
+
this.sandboxName = sandboxName;
|
|
366
|
+
this.timeoutMs = timeoutMs;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* A label lookup did not finish within its deadline.
|
|
371
|
+
*
|
|
372
|
+
* Raised rather than returning a partial page: a warm-lease decision made from
|
|
373
|
+
* a truncated listing is a decision made from data the caller cannot tell apart
|
|
374
|
+
* from "there is nothing else".
|
|
375
|
+
*/
|
|
376
|
+
export class MicrosandboxLookupTimeoutError extends Error {
|
|
377
|
+
name = "MicrosandboxLookupTimeoutError";
|
|
378
|
+
timeoutMs;
|
|
379
|
+
constructor(timeoutMs, description) {
|
|
380
|
+
super(`Microsandbox sandbox lookup exceeded ${timeoutMs}ms while ${description}`);
|
|
381
|
+
this.timeoutMs = timeoutMs;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* A label listing could not be drained to a trustworthy end.
|
|
386
|
+
*
|
|
387
|
+
* Raised rather than returning what was collected so far. A short list and a
|
|
388
|
+
* complete one are indistinguishable to the caller, so a drain that gave up on
|
|
389
|
+
* a cursor that never advances, or on a page body it cannot read, would answer
|
|
390
|
+
* "there is nothing else" — and a warm-lease decision or a quota count made
|
|
391
|
+
* from that answer is made from data the provider never actually supplied.
|
|
392
|
+
*/
|
|
393
|
+
export class MicrosandboxPaginationError extends Error {
|
|
394
|
+
name = "MicrosandboxPaginationError";
|
|
395
|
+
pages;
|
|
396
|
+
constructor(pages, detail) {
|
|
397
|
+
super(`Microsandbox sandbox listing could not be drained after ${pages} page(s): ${detail}`);
|
|
398
|
+
this.pages = pages;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* A call gave up waiting for the process-global backend gate.
|
|
403
|
+
*
|
|
404
|
+
* The gate exists because `withDefaultBackend` mutates ONE process-wide slot,
|
|
405
|
+
* so a call on backend A cannot run while backend B holds the scope. Normally
|
|
406
|
+
* the wait is short. It is NOT short when the scope holder has been abandoned:
|
|
407
|
+
* a create or a lookup that outlived its own client-side deadline returned a
|
|
408
|
+
* typed error to ITS caller, but the SDK exposes no cancellation, so the
|
|
409
|
+
* request is still in flight and the process-wide backend still has to be its
|
|
410
|
+
* own until it settles.
|
|
411
|
+
*
|
|
412
|
+
* Waiting forever in that situation deadlocks every other backend in the
|
|
413
|
+
* process. Running anyway would send this call to whatever backend the process
|
|
414
|
+
* default happens to hold, which is the one outcome the gate exists to prevent.
|
|
415
|
+
* So the queued call FAILS, loudly and with a typed error the caller can retry
|
|
416
|
+
* on — the honest third option.
|
|
417
|
+
*/
|
|
418
|
+
export class MicrosandboxBackendBusyError extends Error {
|
|
419
|
+
name = "MicrosandboxBackendBusyError";
|
|
420
|
+
waitedMs;
|
|
421
|
+
constructor(waitedMs) {
|
|
422
|
+
super(`Microsandbox backend gate was held by another backend for ${waitedMs}ms; this call was not sent `
|
|
423
|
+
+ "rather than being sent to the wrong backend. A scope held this long usually means an SDK call "
|
|
424
|
+
+ "outlived its client-side deadline and is still in flight.");
|
|
425
|
+
this.waitedMs = waitedMs;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* `startScript` was called for a session id that is already admitted for a
|
|
430
|
+
* DIFFERENT command.
|
|
431
|
+
*
|
|
432
|
+
* Nothing was started and nothing was overwritten. A session id is the identity
|
|
433
|
+
* of one run's durable state; reusing it for another command would either
|
|
434
|
+
* strand the first run or report its exit code as the second one's.
|
|
435
|
+
*/
|
|
436
|
+
export class MicrosandboxSessionConflictError extends Error {
|
|
437
|
+
name = "MicrosandboxSessionConflictError";
|
|
438
|
+
sessionId;
|
|
439
|
+
constructor(sessionId) {
|
|
440
|
+
super(`Microsandbox run session "${sessionId}" is already admitted for a different command; nothing was submitted`);
|
|
441
|
+
this.sessionId = sessionId;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* An admitted run can no longer produce an exit code.
|
|
446
|
+
*
|
|
447
|
+
* The wrapper writes the exit file as its final act, so a run whose process is
|
|
448
|
+
* gone without one — killed, out of memory, or interrupted by a sandbox
|
|
449
|
+
* restart — will never complete. Raised so a poll loop ends with a terminal
|
|
450
|
+
* outcome instead of asking forever.
|
|
451
|
+
*/
|
|
452
|
+
export class MicrosandboxRunLostError extends Error {
|
|
453
|
+
name = "MicrosandboxRunLostError";
|
|
454
|
+
sessionId;
|
|
455
|
+
commandId;
|
|
456
|
+
reason;
|
|
457
|
+
constructor(sessionId, commandId, reason) {
|
|
458
|
+
super(`Microsandbox run "${sessionId}" is no longer completable: ${reason}`);
|
|
459
|
+
this.sessionId = sessionId;
|
|
460
|
+
this.commandId = commandId;
|
|
461
|
+
this.reason = reason;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* `getExecLogs` was asked for a terminal result while the run is still going.
|
|
466
|
+
*
|
|
467
|
+
* The bootstrap-plane `ExecResult` has no "unfinished" value — its `exitCode`
|
|
468
|
+
* is a number — so returning one here would have to invent a code. Callers poll
|
|
469
|
+
* `getExecStatus` and read logs once it reports terminal.
|
|
470
|
+
*/
|
|
471
|
+
export class MicrosandboxRunNotFinishedError extends Error {
|
|
472
|
+
name = "MicrosandboxRunNotFinishedError";
|
|
473
|
+
sessionId;
|
|
474
|
+
commandId;
|
|
475
|
+
constructor(sessionId, commandId) {
|
|
476
|
+
super(`Microsandbox run "${sessionId}" has not finished; its exit code is not known yet`);
|
|
477
|
+
this.sessionId = sessionId;
|
|
478
|
+
this.commandId = commandId;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* The process-wide default backend could not be restored, so no later
|
|
483
|
+
* default-dependent SDK static may be issued from this process.
|
|
484
|
+
*
|
|
485
|
+
* `withDefaultBackend` sets one process-global slot and restores it on the way
|
|
486
|
+
* out. When the RESTORE is what failed, the slot holds an unknown value: not
|
|
487
|
+
* necessarily this runtime's backend, not necessarily the previous one. Every
|
|
488
|
+
* static the adapter calls reads that slot, so the only two honest options are
|
|
489
|
+
* to guess or to stop. This adapter stops — permanently, for the life of the
|
|
490
|
+
* process, because nothing it is willing to do can re-establish the truth.
|
|
491
|
+
* (Calling `setDefaultBackend` to force a known value would mutate the host
|
|
492
|
+
* process on behalf of a library, which this adapter never does.)
|
|
493
|
+
*
|
|
494
|
+
* Sandbox and handle instances resolved BEFORE the failure are unaffected and
|
|
495
|
+
* still usable: the SDK binds each one to the backend it was resolved on (its
|
|
496
|
+
* typings call this "backend retained by this sandbox"), so their exec,
|
|
497
|
+
* filesystem and lifecycle calls read no global state.
|
|
498
|
+
*/
|
|
499
|
+
export class MicrosandboxBackendPoisonedError extends Error {
|
|
500
|
+
name = "MicrosandboxBackendPoisonedError";
|
|
501
|
+
constructor(cause) {
|
|
502
|
+
super("Microsandbox cannot issue any further backend-dependent SDK call from this process: restoring the "
|
|
503
|
+
+ "process-wide default backend failed, so its current value is unknown and a call issued now could "
|
|
504
|
+
+ `run against the wrong backend. Restart the process. Underlying error: ${errorMessage(cause)}`, { cause });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* A command finished, but the SDK reported no exit code for it.
|
|
509
|
+
*
|
|
510
|
+
* The bootstrap-plane `ExecResult.exitCode` is a number, so this adapter would
|
|
511
|
+
* have to INVENT one — and the only plausible invention, `0`, is the value that
|
|
512
|
+
* says "this succeeded". A command whose outcome the provider did not report is
|
|
513
|
+
* not a command that succeeded, so the caller is told the outcome is unknown
|
|
514
|
+
* instead of being told a comfortable lie it cannot detect.
|
|
515
|
+
*/
|
|
516
|
+
export class MicrosandboxUnknownOutcomeError extends Error {
|
|
517
|
+
name = "MicrosandboxUnknownOutcomeError";
|
|
518
|
+
sandboxName;
|
|
519
|
+
constructor(sandboxName) {
|
|
520
|
+
super(`Microsandbox exec on sandbox "${sandboxName}" completed without an exit code, so its outcome is `
|
|
521
|
+
+ "unknown; it is reported as unknown rather than defaulted to success");
|
|
522
|
+
this.sandboxName = sandboxName;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The status probe for an async run did not produce a verdict.
|
|
527
|
+
*
|
|
528
|
+
* Distinct from {@link MicrosandboxRunLostError}, which IS a verdict — the run
|
|
529
|
+
* is over and cannot complete. This error says the adapter learned NOTHING:
|
|
530
|
+
* the guest call failed, a required guest-state read was unavailable, or it
|
|
531
|
+
* answered something this protocol does not define. Returning "still running"
|
|
532
|
+
* for any of those would be a positive claim the probe never made, and a poll
|
|
533
|
+
* loop reading it would wait for an outcome that may already have happened.
|
|
534
|
+
*
|
|
535
|
+
* It is safe to retry: nothing about the run was changed by asking.
|
|
536
|
+
*/
|
|
537
|
+
export class MicrosandboxStatusProbeError extends Error {
|
|
538
|
+
name = "MicrosandboxStatusProbeError";
|
|
539
|
+
sessionId;
|
|
540
|
+
commandId;
|
|
541
|
+
/**
|
|
542
|
+
* `"transport"` — the probe call or a required guest-state read failed.
|
|
543
|
+
* `"unrecognized"` — it answered off-protocol.
|
|
544
|
+
*/
|
|
545
|
+
reason;
|
|
546
|
+
/** Retrying is harmless; the probe has no side effects on the run. */
|
|
547
|
+
retryable = true;
|
|
548
|
+
constructor(sessionId, commandId, reason, detail, cause) {
|
|
549
|
+
super(`Microsandbox could not determine the status of run "${sessionId}" (${reason}): ${detail}. `
|
|
550
|
+
+ "The run's state is unchanged and the probe may be retried; it is NOT reported as still running, "
|
|
551
|
+
+ "because that would be an observation this probe did not make.", cause === undefined ? undefined : { cause });
|
|
552
|
+
this.sessionId = sessionId;
|
|
553
|
+
this.commandId = commandId;
|
|
554
|
+
this.reason = reason;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* A run's captured output could not be read.
|
|
559
|
+
*
|
|
560
|
+
* Raised instead of returning `""`. An empty log is a legitimate outcome — a
|
|
561
|
+
* command that printed nothing has one — so a failed read that answered `""`
|
|
562
|
+
* would be indistinguishable from a real result, and the caller would record
|
|
563
|
+
* "the command produced no output" as a fact about a read that never happened.
|
|
564
|
+
* A genuinely ABSENT log file still yields `""`, which is the one case where
|
|
565
|
+
* empty is the truth.
|
|
566
|
+
*/
|
|
567
|
+
export class MicrosandboxLogReadError extends Error {
|
|
568
|
+
name = "MicrosandboxLogReadError";
|
|
569
|
+
sessionId;
|
|
570
|
+
path;
|
|
571
|
+
constructor(sessionId, path, detail, cause) {
|
|
572
|
+
super(`Microsandbox could not read the log for run "${sessionId}" at ${path}: ${detail}. `
|
|
573
|
+
+ "Reported as a failure rather than as empty output, which would be indistinguishable from a run "
|
|
574
|
+
+ "that printed nothing.", cause === undefined ? undefined : { cause });
|
|
575
|
+
this.sessionId = sessionId;
|
|
576
|
+
this.path = path;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* `startScript`/`startExec` was given a `timeoutMs`, which this adapter cannot
|
|
581
|
+
* honour as the port defines it.
|
|
582
|
+
*
|
|
583
|
+
* The port's `timeoutMs` is the COMMAND's lifetime. For a synchronous
|
|
584
|
+
* `runScript` that is exactly what the SDK's `ExecOptionsBuilder.timeout` gives,
|
|
585
|
+
* so the sync path honours it. An async run is different: it is detached inside
|
|
586
|
+
* the guest by the durable wrapper and outlives the submit call, so the submit
|
|
587
|
+
* call's timeout bounds nothing about the command.
|
|
588
|
+
*
|
|
589
|
+
* The adapter previously applied it to the submit call anyway. That is the
|
|
590
|
+
* failure mode this error exists to remove — a caller that asked for a 30s
|
|
591
|
+
* command budget got a 30s SUBMIT budget and a command that runs forever, with
|
|
592
|
+
* nothing in the result to say so.
|
|
593
|
+
*
|
|
594
|
+
* It is refused rather than approximated because the honest enforcement is not
|
|
595
|
+
* available here: killing the run's shell on expiry would leave that shell's
|
|
596
|
+
* own descendants running, so the adapter would report a terminated run while
|
|
597
|
+
* the work continued — a fabricated outcome, which is worse than a refusal. Use
|
|
598
|
+
* `maxDurationSeconds` for a sandbox-lifetime bound, or put the bound in the
|
|
599
|
+
* command itself (`timeout 30 ...`), where the guest can enforce it properly.
|
|
600
|
+
*/
|
|
601
|
+
export class MicrosandboxRunTimeoutUnsupportedError extends Error {
|
|
602
|
+
name = "MicrosandboxRunTimeoutUnsupportedError";
|
|
603
|
+
sessionId;
|
|
604
|
+
timeoutMs;
|
|
605
|
+
constructor(sessionId, timeoutMs) {
|
|
606
|
+
super(`Microsandbox cannot apply a ${timeoutMs}ms command timeout to the async run "${sessionId}": the port `
|
|
607
|
+
+ "defines `timeoutMs` as the command's lifetime, and an async run is detached in the guest, so the "
|
|
608
|
+
+ "submit call's timeout would bound nothing. Nothing was submitted. Bound the sandbox with "
|
|
609
|
+
+ "`maxDurationSeconds`, or put the timeout inside the command.");
|
|
610
|
+
this.sessionId = sessionId;
|
|
611
|
+
this.timeoutMs = timeoutMs;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
let currentBackendScope = null;
|
|
615
|
+
const backendScopeWaiters = [];
|
|
616
|
+
/**
|
|
617
|
+
* The waiter the gate is currently RESERVED for, if any.
|
|
618
|
+
*
|
|
619
|
+
* Waking the head of the queue is not by itself FIFO: between the wake and the
|
|
620
|
+
* woken caller re-checking the gate, a brand-new arrival can observe a free
|
|
621
|
+
* gate and take it, so the queue's order decides nothing and a waiter can be
|
|
622
|
+
* skipped repeatedly. While this is set the gate is spoken for, and every
|
|
623
|
+
* caller other than this one must queue — which is what actually makes the
|
|
624
|
+
* handoff ordered.
|
|
625
|
+
*/
|
|
626
|
+
let backendGateHandoffTo = null;
|
|
627
|
+
/**
|
|
628
|
+
* Set once the SDK fails to RESTORE the process-wide default backend, after
|
|
629
|
+
* which no default-dependent static may be issued from this process again.
|
|
630
|
+
*
|
|
631
|
+
* Deliberately permanent and deliberately module-scoped: the damage is to the
|
|
632
|
+
* SDK's process-global slot, so it is not a property of any one runtime
|
|
633
|
+
* instance, and no action this adapter is willing to take can re-establish
|
|
634
|
+
* what that slot now holds. See {@link MicrosandboxBackendPoisonedError}.
|
|
635
|
+
*
|
|
636
|
+
* The FLAG carries whether the gate is poisoned; the cause is kept beside it.
|
|
637
|
+
* Using the cause as its own sentinel would lose exactly the rejections that
|
|
638
|
+
* carry no value — `Promise.reject()`, `reject(null)` — and those poison the
|
|
639
|
+
* process default just as thoroughly as a rejection with an `Error` does.
|
|
640
|
+
*/
|
|
641
|
+
let backendGatePoisoned = false;
|
|
642
|
+
let backendGatePoisonCause;
|
|
643
|
+
/**
|
|
644
|
+
* Reset the module-global gate. TEST-ONLY.
|
|
645
|
+
*
|
|
646
|
+
* The gate is process-global on purpose, and poisoning it is permanent on
|
|
647
|
+
* purpose — which makes the poison path untestable in-process without a way
|
|
648
|
+
* back. Exported from the module but NOT from the package barrel, so it is
|
|
649
|
+
* reachable from this file's tests and from nowhere a consumer imports.
|
|
650
|
+
*
|
|
651
|
+
* @internal
|
|
652
|
+
*/
|
|
653
|
+
export function __resetBackendGateForTests() {
|
|
654
|
+
currentBackendScope = null;
|
|
655
|
+
backendScopeWaiters.splice(0);
|
|
656
|
+
backendGateHandoffTo = null;
|
|
657
|
+
backendGatePoisoned = false;
|
|
658
|
+
backendGatePoisonCause = undefined;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* How many callers are queued on the gate right now. TEST-ONLY.
|
|
662
|
+
*
|
|
663
|
+
* The leak this exists to catch is INVISIBLE from the outside: a waiter that
|
|
664
|
+
* timed out still reports the same typed `MicrosandboxBackendBusyError` to its
|
|
665
|
+
* caller whether or not it deregistered itself, so a test written against
|
|
666
|
+
* observable behaviour alone passes against the bug. The only discriminating
|
|
667
|
+
* signal is the length of the queue itself, so the queue is what the test
|
|
668
|
+
* asserts on.
|
|
669
|
+
*
|
|
670
|
+
* @internal
|
|
671
|
+
*/
|
|
672
|
+
export function __backendGateWaiterCountForTests() {
|
|
673
|
+
return backendScopeWaiters.length;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Is this runtime bound to the LOCAL backend?
|
|
677
|
+
*
|
|
678
|
+
* Two capability claims hinge on it — snapshot support and isolation — so it is
|
|
679
|
+
* a named predicate rather than an inline `=== "local"` repeated at each site.
|
|
680
|
+
*/
|
|
681
|
+
function isLocalBackend(backend) {
|
|
682
|
+
return backend === "local";
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Stable identity for a backend value. The API key is hashed rather than
|
|
686
|
+
* stored, so a long-lived comparison key never holds the secret itself.
|
|
687
|
+
*/
|
|
688
|
+
function backendKey(backend) {
|
|
689
|
+
if (backend === "local") {
|
|
690
|
+
return "local";
|
|
691
|
+
}
|
|
692
|
+
if ("profile" in backend) {
|
|
693
|
+
return `cloud:profile:${backend.profile}`;
|
|
694
|
+
}
|
|
695
|
+
const digest = createHash("sha256").update(backend.apiKey, "utf8").digest("hex").slice(0, 16);
|
|
696
|
+
return `cloud:url:${backend.url ?? ""}:key:${digest}`;
|
|
697
|
+
}
|
|
698
|
+
async function withBackendScope(sdk, backend, fn, queueTimeoutMs, signal) {
|
|
699
|
+
const key = backendKey(backend);
|
|
700
|
+
const queueDeadline = Date.now() + queueTimeoutMs;
|
|
701
|
+
// Set once this caller has been handed the gate as the FIFO head. It may
|
|
702
|
+
// then take the gate even though callers are still queued BEHIND it — which
|
|
703
|
+
// is the whole point of the handoff — but it owes the queue a promotion if
|
|
704
|
+
// it then leaves without taking the gate.
|
|
705
|
+
let holdsTurn = false;
|
|
706
|
+
const yieldTurn = () => {
|
|
707
|
+
if (holdsTurn) {
|
|
708
|
+
holdsTurn = false;
|
|
709
|
+
promoteNextBackendWaiter();
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
try {
|
|
713
|
+
for (;;) {
|
|
714
|
+
if (backendGatePoisoned) {
|
|
715
|
+
throw new MicrosandboxBackendPoisonedError(backendGatePoisonCause);
|
|
716
|
+
}
|
|
717
|
+
if (signal?.aborted) {
|
|
718
|
+
// ADMISSION CANCELLED. The caller's overall deadline has already
|
|
719
|
+
// expired, so `fn` must never run: a static admitted after its own
|
|
720
|
+
// deadline is a call the caller has stopped waiting for and will
|
|
721
|
+
// never read the result of.
|
|
722
|
+
throw signal.reason;
|
|
723
|
+
}
|
|
724
|
+
const open = currentBackendScope;
|
|
725
|
+
// JOINING IS THE STARVATION RISK, so it is conditional on an empty queue.
|
|
726
|
+
// A same-backend call may share an open scope only when NOBODY is waiting
|
|
727
|
+
// and the gate is not already promised to a waiter; the moment a call on
|
|
728
|
+
// another backend has queued, later same-backend arrivals queue behind it
|
|
729
|
+
// too. Without that condition a steady stream of same-backend work keeps
|
|
730
|
+
// the scope permanently occupied and the other backend never runs — which
|
|
731
|
+
// is the failure this gate's own comment claimed it prevented.
|
|
732
|
+
if (open &&
|
|
733
|
+
!open.closing &&
|
|
734
|
+
open.key === key &&
|
|
735
|
+
(holdsTurn || (backendScopeWaiters.length === 0 && backendGateHandoffTo === null))) {
|
|
736
|
+
open.active += 1;
|
|
737
|
+
// Joining does not hold the gate — the open scope does — so a turn
|
|
738
|
+
// taken here is handed straight back to the queue.
|
|
739
|
+
yieldTurn();
|
|
740
|
+
try {
|
|
741
|
+
// The scope may still be opening; running before it is entered would
|
|
742
|
+
// run against whatever backend the process last had.
|
|
743
|
+
await open.entered;
|
|
744
|
+
return await fn();
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
await leaveBackendScope(open);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (open ||
|
|
751
|
+
(!holdsTurn && (backendScopeWaiters.length > 0 || backendGateHandoffTo !== null))) {
|
|
752
|
+
// BOUNDED. An abandoned scope holder (a create or lookup that outlived
|
|
753
|
+
// its client-side deadline) is still in flight and still owns the
|
|
754
|
+
// process-wide backend, so this wait can otherwise never end.
|
|
755
|
+
const remainingMs = queueDeadline - Date.now();
|
|
756
|
+
if (remainingMs <= 0) {
|
|
757
|
+
throw new MicrosandboxBackendBusyError(queueTimeoutMs);
|
|
758
|
+
}
|
|
759
|
+
let timer;
|
|
760
|
+
let wake;
|
|
761
|
+
const woken = new Promise((resolve) => {
|
|
762
|
+
wake = () => resolve("woken");
|
|
763
|
+
});
|
|
764
|
+
const waiter = { key, wake };
|
|
765
|
+
backendScopeWaiters.push(waiter);
|
|
766
|
+
let onAbort;
|
|
767
|
+
const outcomes = [
|
|
768
|
+
woken,
|
|
769
|
+
new Promise((resolve) => {
|
|
770
|
+
timer = setTimeout(() => resolve("timeout"), remainingMs);
|
|
771
|
+
}),
|
|
772
|
+
];
|
|
773
|
+
if (signal) {
|
|
774
|
+
outcomes.push(new Promise((resolve) => {
|
|
775
|
+
onAbort = () => resolve("aborted");
|
|
776
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
779
|
+
const outcome = await Promise.race(outcomes);
|
|
780
|
+
if (timer) {
|
|
781
|
+
clearTimeout(timer);
|
|
782
|
+
}
|
|
783
|
+
if (signal && onAbort) {
|
|
784
|
+
signal.removeEventListener("abort", onAbort);
|
|
785
|
+
}
|
|
786
|
+
if (outcome === "woken") {
|
|
787
|
+
// The gate was reserved for THIS waiter by name, so nothing else can
|
|
788
|
+
// have taken it in between. Consume the reservation and re-check.
|
|
789
|
+
backendGateHandoffTo = null;
|
|
790
|
+
holdsTurn = true;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
// Deregister. The waiter list is no longer spliced wholesale on
|
|
794
|
+
// release, but a timed-out or cancelled waiter left behind would still
|
|
795
|
+
// be woken later and, worse, would hold a handoff the gate then waits
|
|
796
|
+
// on forever — so it both leaves the queue and passes on any turn it
|
|
797
|
+
// was given in the same tick.
|
|
798
|
+
dropBackendWaiter(waiter);
|
|
799
|
+
if (outcome === "aborted") {
|
|
800
|
+
throw signal.reason;
|
|
801
|
+
}
|
|
802
|
+
throw new MicrosandboxBackendBusyError(queueTimeoutMs);
|
|
803
|
+
}
|
|
804
|
+
return await openBackendScope(sdk, backend, key, fn, () => {
|
|
805
|
+
// Taking the gate consumes the turn: ownership now lives in the scope,
|
|
806
|
+
// and the queue is promoted when that scope releases.
|
|
807
|
+
holdsTurn = false;
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
yieldTurn();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Open a NEW scope on a free gate and run `fn` inside it.
|
|
817
|
+
*
|
|
818
|
+
* Split out from {@link withBackendScope} only so the queueing loop above stays
|
|
819
|
+
* readable; it is never called on a gate that is already held.
|
|
820
|
+
*/
|
|
821
|
+
async function openBackendScope(sdk, backend, key, fn, onTaken) {
|
|
822
|
+
{
|
|
823
|
+
let release;
|
|
824
|
+
const drained = new Promise((resolve) => {
|
|
825
|
+
release = resolve;
|
|
826
|
+
});
|
|
827
|
+
let markEntered;
|
|
828
|
+
let failEntry;
|
|
829
|
+
const entered = new Promise((resolve, reject) => {
|
|
830
|
+
markEntered = resolve;
|
|
831
|
+
failEntry = reject;
|
|
832
|
+
});
|
|
833
|
+
// Nobody may await `entered` before its rejection handler is attached
|
|
834
|
+
// below, and an unobserved rejection would otherwise surface as a process
|
|
835
|
+
// warning; the catch here is the handler of record.
|
|
836
|
+
entered.catch(() => undefined);
|
|
837
|
+
const scope = {
|
|
838
|
+
key,
|
|
839
|
+
active: 1,
|
|
840
|
+
closing: false,
|
|
841
|
+
entered,
|
|
842
|
+
release,
|
|
843
|
+
exited: Promise.resolve(),
|
|
844
|
+
};
|
|
845
|
+
currentBackendScope = scope;
|
|
846
|
+
onTaken();
|
|
847
|
+
// Distinguishes the two ways `withDefaultBackend` can reject, which need
|
|
848
|
+
// opposite handling: before the callback ran, nothing of ours executed and
|
|
849
|
+
// the process default was never changed; after it ran, the rejection can
|
|
850
|
+
// only be the SDK failing to RESTORE, and the slot is then unknown.
|
|
851
|
+
let callbackRan = false;
|
|
852
|
+
let scopeCall;
|
|
853
|
+
try {
|
|
854
|
+
// The scope stays open for as long as anyone is inside it: the SDK's
|
|
855
|
+
// callback resolves only once the last participant has left.
|
|
856
|
+
scopeCall = Promise.resolve(sdk.withDefaultBackend(backend, () => {
|
|
857
|
+
callbackRan = true;
|
|
858
|
+
markEntered();
|
|
859
|
+
return drained;
|
|
860
|
+
}));
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
if (callbackRan) {
|
|
864
|
+
// The SDK entered our callback and then threw on the SAME stack while
|
|
865
|
+
// restoring the previous process default. `Promise.resolve(...)`
|
|
866
|
+
// never receives a value in this form, so the asynchronous rejection
|
|
867
|
+
// handler below cannot observe it; callback entry is nevertheless the
|
|
868
|
+
// decisive proof that the global slot was changed and is now unknown.
|
|
869
|
+
backendGatePoisoned = true;
|
|
870
|
+
backendGatePoisonCause = error;
|
|
871
|
+
}
|
|
872
|
+
// Pre-callback, this is a harmless synchronous failure to push the scope.
|
|
873
|
+
// Post-callback, freeing the now-poisoned gate wakes queued callers so
|
|
874
|
+
// they fail immediately with MicrosandboxBackendPoisonedError.
|
|
875
|
+
abandonBackendScope(scope);
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
scope.exited = scopeCall.then(() => undefined, (error) => {
|
|
879
|
+
if (!callbackRan) {
|
|
880
|
+
// Rejected BEFORE the callback ran: the scope was never entered, so
|
|
881
|
+
// the process default was never changed and nothing ran under it.
|
|
882
|
+
// `entered` rejects and this call fails closed, below.
|
|
883
|
+
failEntry(error);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
// Rejected AFTER the callback ran. The callback returns a promise that
|
|
887
|
+
// resolves only once the last participant has left, so the SDK had
|
|
888
|
+
// already re-entered its own teardown: this rejection is the RESTORE
|
|
889
|
+
// failing. The process-wide slot now holds an unknown value.
|
|
890
|
+
//
|
|
891
|
+
// Swallowing it — which is what returning here would do — hands the
|
|
892
|
+
// gate to the next backend as if the previous one had been cleanly
|
|
893
|
+
// restored, and that call then runs against whatever the slot actually
|
|
894
|
+
// holds. So it is recorded as poison for every later static, and
|
|
895
|
+
// rethrown so the participant that closed the scope is TOLD rather
|
|
896
|
+
// than left believing the call completed cleanly.
|
|
897
|
+
backendGatePoisoned = true;
|
|
898
|
+
backendGatePoisonCause = error;
|
|
899
|
+
throw error;
|
|
900
|
+
});
|
|
901
|
+
try {
|
|
902
|
+
await entered;
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
// FAIL CLOSED. Running the call anyway would send this runtime's work to
|
|
906
|
+
// whatever backend the process default happens to hold.
|
|
907
|
+
abandonBackendScope(scope);
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
return await fn();
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
await leaveBackendScope(scope);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async function leaveBackendScope(scope) {
|
|
919
|
+
scope.active -= 1;
|
|
920
|
+
if (scope.active > 0 || scope.closing) {
|
|
921
|
+
// EARLY LEAVER. Its own call finished, but the restore it depends on has
|
|
922
|
+
// not happened yet: the scope closes only when the LAST participant
|
|
923
|
+
// leaves. Returning here would let a joined caller report success while
|
|
924
|
+
// the very same scope goes on to fail its restore — two callers running
|
|
925
|
+
// concurrently against one backend, one told the truth and one not.
|
|
926
|
+
//
|
|
927
|
+
// Awaiting cannot deadlock: this participant has already decremented, so
|
|
928
|
+
// the count it is waiting on no longer includes itself, and `exited`
|
|
929
|
+
// settles as soon as the last one leaves.
|
|
930
|
+
await scope.exited;
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
scope.closing = true;
|
|
934
|
+
scope.release();
|
|
935
|
+
try {
|
|
936
|
+
// Wait for the SDK to restore the previous backend BEFORE any queued call
|
|
937
|
+
// on a different backend is allowed to open its own scope.
|
|
938
|
+
await scope.exited;
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
// Released even when the restore FAILED. The gate is poisoned by then, so
|
|
942
|
+
// waking the queue does not let anyone through — it converts a wait that
|
|
943
|
+
// would otherwise run to its timeout into an immediate, typed refusal.
|
|
944
|
+
releaseBackendGate(scope);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/** Free the gate for a scope that never opened. */
|
|
948
|
+
function abandonBackendScope(scope) {
|
|
949
|
+
scope.closing = true;
|
|
950
|
+
scope.release();
|
|
951
|
+
releaseBackendGate(scope);
|
|
952
|
+
}
|
|
953
|
+
function releaseBackendGate(scope) {
|
|
954
|
+
if (currentBackendScope === scope) {
|
|
955
|
+
currentBackendScope = null;
|
|
956
|
+
}
|
|
957
|
+
promoteNextBackendWaiter();
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Hand the free gate to the OLDEST waiter, and to that waiter alone.
|
|
961
|
+
*
|
|
962
|
+
* Waking every waiter at once and letting them re-race is not FIFO by any
|
|
963
|
+
* definition: all of them find the gate free, and which one actually takes it
|
|
964
|
+
* is decided by microtask scheduling order, so a waiter can lose that race
|
|
965
|
+
* arbitrarily many times while later arrivals win it. Reserving the gate for
|
|
966
|
+
* one named waiter is what makes the queue's order mean something.
|
|
967
|
+
*/
|
|
968
|
+
function promoteNextBackendWaiter() {
|
|
969
|
+
if (currentBackendScope !== null || backendGateHandoffTo !== null) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const next = backendScopeWaiters.shift();
|
|
973
|
+
if (!next) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
backendGateHandoffTo = next;
|
|
977
|
+
next.wake();
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Remove a waiter that will never take the gate, and pass on any reservation
|
|
981
|
+
* it was holding.
|
|
982
|
+
*
|
|
983
|
+
* The reservation matters more than the queue slot: a waiter that timed out in
|
|
984
|
+
* the same tick it was promoted still owns `backendGateHandoffTo`, and every
|
|
985
|
+
* other caller defers to that reservation — so dropping it without promoting a
|
|
986
|
+
* successor wedges the gate permanently on a caller that has already left.
|
|
987
|
+
*/
|
|
988
|
+
function dropBackendWaiter(waiter) {
|
|
989
|
+
const queued = backendScopeWaiters.indexOf(waiter);
|
|
990
|
+
if (queued !== -1) {
|
|
991
|
+
backendScopeWaiters.splice(queued, 1);
|
|
992
|
+
}
|
|
993
|
+
if (backendGateHandoffTo === waiter) {
|
|
994
|
+
backendGateHandoffTo = null;
|
|
995
|
+
promoteNextBackendWaiter();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* One page of a label listing, validated before anything is read out of it.
|
|
1000
|
+
*
|
|
1001
|
+
* A listing is the ANSWER a warm-lease decision is made from, so a page this
|
|
1002
|
+
* adapter cannot read is a failure, never an empty result: quietly returning a
|
|
1003
|
+
* short list is indistinguishable to the caller from "there is nothing else",
|
|
1004
|
+
* and the caller then launches a sandbox it did not need or under-counts a
|
|
1005
|
+
* quota it is enforcing.
|
|
1006
|
+
*/
|
|
1007
|
+
function readSandboxPage(page, seenCursors, pageNumber) {
|
|
1008
|
+
if (typeof page !== "object" || page === null) {
|
|
1009
|
+
throw new MicrosandboxPaginationError(pageNumber, "the provider returned a page that is not an object");
|
|
1010
|
+
}
|
|
1011
|
+
const record = page;
|
|
1012
|
+
if (!Array.isArray(record.sandboxes)) {
|
|
1013
|
+
throw new MicrosandboxPaginationError(pageNumber, "the provider returned a page whose sandbox list is unreadable");
|
|
1014
|
+
}
|
|
1015
|
+
// THE ENVELOPE IS NOT THE DATA. An array of unusable entries is exactly as
|
|
1016
|
+
// unreadable as a missing array, and it fails the same way: every entry is
|
|
1017
|
+
// dropped by the state filter, the drain ends, and the caller is told there
|
|
1018
|
+
// is nothing warm — from a page the provider did in fact return sandboxes on.
|
|
1019
|
+
// A handle is only usable if it has an addressable name, because the name IS
|
|
1020
|
+
// the identity here, and a status, because the state filter reads it.
|
|
1021
|
+
const sandboxes = record.sandboxes.map((entry, index) => {
|
|
1022
|
+
if (typeof entry !== "object" || entry === null) {
|
|
1023
|
+
throw new MicrosandboxPaginationError(pageNumber, `entry ${index} of the page is not a sandbox object`);
|
|
1024
|
+
}
|
|
1025
|
+
const { name, status } = entry;
|
|
1026
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
1027
|
+
throw new MicrosandboxPaginationError(pageNumber, `entry ${index} of the page has no usable sandbox name, so it cannot be addressed`);
|
|
1028
|
+
}
|
|
1029
|
+
if (typeof status !== "string" || status.trim() === "") {
|
|
1030
|
+
throw new MicrosandboxPaginationError(pageNumber, `sandbox "${name}" was returned without a usable status, so it cannot be filtered by state`);
|
|
1031
|
+
}
|
|
1032
|
+
return entry;
|
|
1033
|
+
});
|
|
1034
|
+
const cursor = record.nextCursor;
|
|
1035
|
+
if (cursor === undefined || cursor === null || cursor === "") {
|
|
1036
|
+
return { sandboxes };
|
|
1037
|
+
}
|
|
1038
|
+
if (typeof cursor !== "string") {
|
|
1039
|
+
throw new MicrosandboxPaginationError(pageNumber, "the provider returned a next-page cursor that is not a string");
|
|
1040
|
+
}
|
|
1041
|
+
// EVERY cursor already used, not just the previous one. A backend that walks
|
|
1042
|
+
// A → B → A advances on each individual step, so comparing against only the
|
|
1043
|
+
// last cursor sees progress forever while re-serving the same two pages —
|
|
1044
|
+
// the drain then ends on the 1000-page bound at best, and duplicates every
|
|
1045
|
+
// sandbox it collected on the way.
|
|
1046
|
+
if (seenCursors.has(cursor)) {
|
|
1047
|
+
throw new MicrosandboxPaginationError(pageNumber, `the provider returned a next-page cursor it has already served, so the listing is cycling rather than advancing`);
|
|
1048
|
+
}
|
|
1049
|
+
return { sandboxes, nextCursor: cursor };
|
|
1050
|
+
}
|
|
1051
|
+
export class MicrosandboxRuntime {
|
|
1052
|
+
id = "microsandbox";
|
|
1053
|
+
/**
|
|
1054
|
+
* Bootstrap-plane capabilities. These describe what THIS ADAPTER exposes
|
|
1055
|
+
* through the port, not everything the SDK can do — the same convention the
|
|
1056
|
+
* Daytona adapter follows.
|
|
1057
|
+
*
|
|
1058
|
+
* TWO OF THESE ARE BACKEND-SENSITIVE, and both were previously reported as
|
|
1059
|
+
* flat process-wide constants — which published a claim about the cloud
|
|
1060
|
+
* backend that this package cannot stand behind. They are now derived from
|
|
1061
|
+
* the backend this instance is bound to.
|
|
1062
|
+
*
|
|
1063
|
+
* - `pty: false` — the SDK's `ExecOptionsBuilder.tty(true)` and
|
|
1064
|
+
* `ExecHandle.resize()` are real, but the port has no pty method and this
|
|
1065
|
+
* adapter never allocates one.
|
|
1066
|
+
* - `snapshots` — LOCAL only, and the claim is about THIS ADAPTER's boot
|
|
1067
|
+
* path, not about the provider's hosted service. `launch` sources a
|
|
1068
|
+
* sandbox through the builder's `fromSnapshot`, which consumes a
|
|
1069
|
+
* host-local artifact: the installed SDK's typings describe `Snapshot` as
|
|
1070
|
+
* "an artifact on disk" and resolve one under
|
|
1071
|
+
* `~/.microsandbox/snapshots/<name>/`. This adapter never transfers that
|
|
1072
|
+
* artifact anywhere, so on a remote backend there is nothing for a create
|
|
1073
|
+
* to resolve — hence `false`, and a constructor that refuses the pairing.
|
|
1074
|
+
* Note the adapter only ever CONSUMES a snapshot; it never creates one, so
|
|
1075
|
+
* `SandboxHandle.snapshot()` is not on this path.
|
|
1076
|
+
* - `isolation` — `'strong'` on LOCAL, `'unknown'` on CLOUD, and both values
|
|
1077
|
+
* describe what this package has ESTABLISHED rather than what any provider
|
|
1078
|
+
* documentation says. Locally the SDK boots each sandbox as a microVM with
|
|
1079
|
+
* its own guest kernel on a virtualization-capable host, and the installed
|
|
1080
|
+
* package states that requirement itself (Node 22+, a native addon, KVM /
|
|
1081
|
+
* Apple Silicon / WHP), so `'strong'` rests on something checkable here.
|
|
1082
|
+
* For the cloud backend this adapter observes nothing about isolation,
|
|
1083
|
+
* region placement or resource enforcement and measures nothing against
|
|
1084
|
+
* them, so it reports `'unknown'` — which is a statement about this
|
|
1085
|
+
* package's evidence, not an assertion that the guarantee is absent. See
|
|
1086
|
+
* {@link IsolationLevel}.
|
|
1087
|
+
* - `persistentHandle: true` — a sandbox is re-resolvable by name from a
|
|
1088
|
+
* fresh process via `Sandbox.get(name)` + `connect()`, and
|
|
1089
|
+
* `launchDetached` sets `detached(true)` so it outlives this process.
|
|
1090
|
+
* - `streamingLogs: false` — the SDK ships `logStream({follow:true})` and
|
|
1091
|
+
* `execStream`, but this adapter's log path is a durable file read, so
|
|
1092
|
+
* claiming a streaming capability here would be claiming a code path that
|
|
1093
|
+
* does not exist.
|
|
1094
|
+
*
|
|
1095
|
+
* NOT SUPPORTED, and deliberately absent rather than silently ignored: custom
|
|
1096
|
+
* or published PORTS. The SDK builder exposes `port()`/`portBind()`/`portUdp()`,
|
|
1097
|
+
* but the ports this package targets have no public-port surface to express
|
|
1098
|
+
* them, so this adapter never calls them and never implies a reachable port.
|
|
1099
|
+
*/
|
|
1100
|
+
capabilities;
|
|
1101
|
+
/**
|
|
1102
|
+
* Both true, and declared rather than left to default so the reasoning is on
|
|
1103
|
+
* the record. `warmLease`: `Sandbox.listWith(b => b.labels(...))` is a real
|
|
1104
|
+
* server-side label query with cursor pagination, so a warm-lease lookup is
|
|
1105
|
+
* meaningful. `lifecycle`: `start`/`stop` map onto `SandboxHandle.start()` /
|
|
1106
|
+
* `SandboxHandle.stop()`, which genuinely resume and halt a microVM — unlike
|
|
1107
|
+
* the E2B adapter, where both are no-ops. Both apply to sandboxes this
|
|
1108
|
+
* runtime OWNS; an attached sandbox is deliberately left alone.
|
|
1109
|
+
*/
|
|
1110
|
+
declaredCapabilities = { warmLease: true, lifecycle: true };
|
|
1111
|
+
backend;
|
|
1112
|
+
image;
|
|
1113
|
+
snapshot;
|
|
1114
|
+
homeDir;
|
|
1115
|
+
defaultWorkdir;
|
|
1116
|
+
cpus;
|
|
1117
|
+
memoryMiB;
|
|
1118
|
+
idleTimeoutSeconds;
|
|
1119
|
+
maxDurationSeconds;
|
|
1120
|
+
replaceExisting;
|
|
1121
|
+
namePrefix;
|
|
1122
|
+
runStateDir;
|
|
1123
|
+
shell;
|
|
1124
|
+
connectTimeoutMs;
|
|
1125
|
+
lookupTimeoutMs;
|
|
1126
|
+
listPageSize;
|
|
1127
|
+
backendQueueTimeoutMs;
|
|
1128
|
+
injectedSdk;
|
|
1129
|
+
onReclaimFailure;
|
|
1130
|
+
sdkPromise;
|
|
1131
|
+
// What this runtime knows about each sandbox name: the live instance
|
|
1132
|
+
// resolved in this process (if any) and whether this runtime owns it. A
|
|
1133
|
+
// cross-request access (an async-run poll tick) that misses the instance
|
|
1134
|
+
// re-resolves by name via `Sandbox.get(name).connect()` — the reattach that
|
|
1135
|
+
// lets a run outlive the request that started it.
|
|
1136
|
+
registry = new Map();
|
|
1137
|
+
// Reclamations of creates that landed after their deadline, keyed by name.
|
|
1138
|
+
// A later launch of the same name waits for one so the two cannot race.
|
|
1139
|
+
pendingReclaims = new Map();
|
|
1140
|
+
constructor(options) {
|
|
1141
|
+
if (!options.image && !options.snapshot) {
|
|
1142
|
+
throw new Error("MicrosandboxRuntime requires either `image` or `snapshot`: neither has a default that is correct for another consumer");
|
|
1143
|
+
}
|
|
1144
|
+
if (options.image && options.snapshot) {
|
|
1145
|
+
throw new Error("MicrosandboxRuntime accepts `image` or `snapshot`, not both: a sandbox has exactly one rootfs source");
|
|
1146
|
+
}
|
|
1147
|
+
// Refused HERE — before the lazy `import("microsandbox")`, before any SDK
|
|
1148
|
+
// call, before a single byte leaves the process. Deferring it to `launch`
|
|
1149
|
+
// would surface it as an opaque remote failure on a path the caller was
|
|
1150
|
+
// told existed.
|
|
1151
|
+
//
|
|
1152
|
+
// WHAT THIS REFUSAL RESTS ON, stated exactly, because a capability claim is
|
|
1153
|
+
// only worth what its evidence is. It is NOT a claim about what the vendor's
|
|
1154
|
+
// hosted service does or does not implement — this package has no way to
|
|
1155
|
+
// observe that. It is a statement about the ADAPTER'S OWN CONTRACT plus one
|
|
1156
|
+
// fact that is checkable in the installed package:
|
|
1157
|
+
//
|
|
1158
|
+
// - checkable: a snapshot is a HOST-LOCAL ARTIFACT. `microsandbox@0.6.11`'s
|
|
1159
|
+
// `native/index.d.ts` declares `Snapshot` as "A snapshot artifact on
|
|
1160
|
+
// disk", resolves `SandboxHandle.snapshot(name)` "under
|
|
1161
|
+
// `~/.microsandbox/snapshots/<name>/`", and offers `Snapshot.listDir(dir)`
|
|
1162
|
+
// to walk a directory of them. `builder.fromSnapshot(pathOrName)` consumes
|
|
1163
|
+
// that artifact.
|
|
1164
|
+
// - contract: this adapter only ever CONSUMES a snapshot by path or name
|
|
1165
|
+
// from the calling host, and it does not transfer one anywhere. A create
|
|
1166
|
+
// issued against a remote backend therefore has nothing this adapter has
|
|
1167
|
+
// put within its reach.
|
|
1168
|
+
//
|
|
1169
|
+
// So the pairing is refused because THIS ADAPTER cannot make it work, which
|
|
1170
|
+
// is a fact about code in this repository. `capabilities.snapshots` reports
|
|
1171
|
+
// the same thing per backend, and neither claims anything about the hosted
|
|
1172
|
+
// service's own capabilities.
|
|
1173
|
+
if (options.snapshot && !isLocalBackend(options.backend)) {
|
|
1174
|
+
throw new Error("MicrosandboxRuntime cannot boot from a `snapshot` on the cloud backend: a snapshot is a host-local "
|
|
1175
|
+
+ "artifact (the SDK's own typings resolve one under ~/.microsandbox/snapshots/ and describe it as an "
|
|
1176
|
+
+ "artifact on disk), and this adapter consumes it from the calling host without transferring it, so a "
|
|
1177
|
+
+ "create issued against a remote backend has nothing to resolve. "
|
|
1178
|
+
+ "Use `image` on the cloud backend, or set `backend: \"local\"` to boot from a snapshot.");
|
|
1179
|
+
}
|
|
1180
|
+
this.backend = options.backend;
|
|
1181
|
+
if (options.image !== undefined) {
|
|
1182
|
+
this.image = options.image;
|
|
1183
|
+
}
|
|
1184
|
+
if (options.snapshot !== undefined) {
|
|
1185
|
+
this.snapshot = options.snapshot;
|
|
1186
|
+
}
|
|
1187
|
+
this.homeDir = options.homeDir;
|
|
1188
|
+
if (options.workdir !== undefined) {
|
|
1189
|
+
this.defaultWorkdir = options.workdir;
|
|
1190
|
+
}
|
|
1191
|
+
if (options.cpus !== undefined) {
|
|
1192
|
+
this.cpus = options.cpus;
|
|
1193
|
+
}
|
|
1194
|
+
if (options.memoryMiB !== undefined) {
|
|
1195
|
+
this.memoryMiB = options.memoryMiB;
|
|
1196
|
+
}
|
|
1197
|
+
if (options.idleTimeoutSeconds !== undefined) {
|
|
1198
|
+
this.idleTimeoutSeconds = options.idleTimeoutSeconds;
|
|
1199
|
+
}
|
|
1200
|
+
if (options.maxDurationSeconds !== undefined) {
|
|
1201
|
+
this.maxDurationSeconds = options.maxDurationSeconds;
|
|
1202
|
+
}
|
|
1203
|
+
this.capabilities = {
|
|
1204
|
+
pty: false,
|
|
1205
|
+
snapshots: isLocalBackend(options.backend),
|
|
1206
|
+
isolation: isLocalBackend(options.backend) ? "strong" : "unknown",
|
|
1207
|
+
persistentHandle: true,
|
|
1208
|
+
streamingLogs: false,
|
|
1209
|
+
};
|
|
1210
|
+
this.replaceExisting = options.replaceExisting ?? false;
|
|
1211
|
+
this.namePrefix = options.namePrefix ?? "";
|
|
1212
|
+
this.runStateDir = (options.runStateDir ?? DEFAULT_RUN_STATE_DIR).replace(/\/+$/, "");
|
|
1213
|
+
this.shell = options.shell ?? DEFAULT_SHELL;
|
|
1214
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1215
|
+
this.lookupTimeoutMs = options.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
|
|
1216
|
+
this.listPageSize = options.listPageSize ?? DEFAULT_LIST_PAGE_SIZE;
|
|
1217
|
+
this.backendQueueTimeoutMs = options.backendQueueTimeoutMs ?? DEFAULT_BACKEND_QUEUE_TIMEOUT_MS;
|
|
1218
|
+
if (options.sdk !== undefined) {
|
|
1219
|
+
this.injectedSdk = options.sdk;
|
|
1220
|
+
}
|
|
1221
|
+
if (options.onReclaimFailure !== undefined) {
|
|
1222
|
+
this.onReclaimFailure = options.onReclaimFailure;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
// --- lookup --------------------------------------------------------------
|
|
1226
|
+
async findByLabels(labels, options = {}) {
|
|
1227
|
+
// Exclusions are applied DURING the drain, never to the collected page:
|
|
1228
|
+
// filtering afterwards would let a page full of already-claimed sandboxes
|
|
1229
|
+
// answer "nothing warm available" while the next page held a free one.
|
|
1230
|
+
const handles = await this.collectByLabels(labels, {
|
|
1231
|
+
states: options.states === undefined ? ["STARTED"] : options.states,
|
|
1232
|
+
cap: 1,
|
|
1233
|
+
requestSize: options.limit ?? options.pageSize,
|
|
1234
|
+
excludeIds: options.excludeIds,
|
|
1235
|
+
timeoutMs: options.timeoutMs,
|
|
1236
|
+
claim: options.owned ?? false,
|
|
1237
|
+
description: "listing matching sandboxes",
|
|
1238
|
+
});
|
|
1239
|
+
return handles[0] ?? null;
|
|
1240
|
+
}
|
|
1241
|
+
async findAllByLabels(labels, options = {}) {
|
|
1242
|
+
return this.collectByLabels(labels, {
|
|
1243
|
+
states: options.states === undefined ? ["STARTED"] : options.states,
|
|
1244
|
+
// `limit` caps RESULTS as well as sizing the request; `pageSize` only
|
|
1245
|
+
// sizes the request. Treating a page size as a result cap would silently
|
|
1246
|
+
// truncate a listing the caller asked to receive in full. A non-integral
|
|
1247
|
+
// or negative cap is floored onto zero, which `collectByLabels` answers
|
|
1248
|
+
// without a call — the same rule `countByLabels` applies to `maxCount`.
|
|
1249
|
+
...resultCap(options.limit),
|
|
1250
|
+
requestSize: options.limit ?? options.pageSize,
|
|
1251
|
+
excludeIds: options.excludeIds,
|
|
1252
|
+
timeoutMs: options.timeoutMs,
|
|
1253
|
+
claim: options.owned ?? false,
|
|
1254
|
+
description: "listing matching sandboxes",
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
async countByLabels(labels, options = {}) {
|
|
1258
|
+
const handles = await this.collectByLabels(labels, {
|
|
1259
|
+
states: options.states === undefined ? ["STARTED"] : options.states,
|
|
1260
|
+
...resultCap(options.maxCount),
|
|
1261
|
+
requestSize: options.limit ?? options.pageSize,
|
|
1262
|
+
timeoutMs: options.timeoutMs,
|
|
1263
|
+
description: "counting matching sandboxes",
|
|
1264
|
+
});
|
|
1265
|
+
return handles.length;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Drain the cursor-paginated, server-side label listing, keeping the entries
|
|
1269
|
+
* that match `states` and are not excluded, and stopping as soon as `cap`
|
|
1270
|
+
* such handles are collected or the lookup deadline elapses.
|
|
1271
|
+
*/
|
|
1272
|
+
async collectByLabels(labels, options) {
|
|
1273
|
+
// A cap of zero is answerable without a network call, and answering it
|
|
1274
|
+
// WITH one is how a cap of zero used to return a result: the cap was
|
|
1275
|
+
// checked only after an entry had already been collected. Negative and
|
|
1276
|
+
// fractional caps normalize onto it rather than meaning something
|
|
1277
|
+
// accidental.
|
|
1278
|
+
if (options.cap !== undefined && options.cap <= 0) {
|
|
1279
|
+
return [];
|
|
1280
|
+
}
|
|
1281
|
+
// Request size, resolved exactly as Daytona/E2B/local resolve it, so a
|
|
1282
|
+
// caller that tuned one provider's lookup gets the same request shape here
|
|
1283
|
+
// — except that a zero or negative size is not a request shape at all, so
|
|
1284
|
+
// it falls back to the configured page size instead of being sent.
|
|
1285
|
+
const limit = positivePageSize(options.requestSize) ?? this.listPageSize;
|
|
1286
|
+
const excluded = new Set(options.excludeIds ?? []);
|
|
1287
|
+
const deadline = this.lookupDeadline(options.timeoutMs);
|
|
1288
|
+
const handles = [];
|
|
1289
|
+
const seenCursors = new Set();
|
|
1290
|
+
let cursor;
|
|
1291
|
+
// Bounded so a backend that keeps handing back fresh cursors cannot spin
|
|
1292
|
+
// this loop forever. The deadline bounds it in wall-clock terms too.
|
|
1293
|
+
for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
|
|
1294
|
+
const cursorForPage = cursor;
|
|
1295
|
+
const raw = await this.awaitWithinCancelling((signal) => this.withBackendStatic((sdk) => sdk.Sandbox.listWith((builder) => {
|
|
1296
|
+
let configured = builder.limit(limit);
|
|
1297
|
+
if (hasEntries(labels)) {
|
|
1298
|
+
configured = configured.labels(labels);
|
|
1299
|
+
}
|
|
1300
|
+
if (cursorForPage) {
|
|
1301
|
+
configured = configured.cursor(cursorForPage);
|
|
1302
|
+
}
|
|
1303
|
+
return configured;
|
|
1304
|
+
}), signal), deadline, options.description);
|
|
1305
|
+
// Recorded BEFORE the page is validated, so the cursor that fetched this
|
|
1306
|
+
// page counts as seen: a provider that hands back the cursor it was just
|
|
1307
|
+
// given is the degenerate cycle, and it has to fail on the same check as
|
|
1308
|
+
// the longer A → B → A one.
|
|
1309
|
+
if (cursorForPage !== undefined) {
|
|
1310
|
+
seenCursors.add(cursorForPage);
|
|
1311
|
+
}
|
|
1312
|
+
const result = readSandboxPage(raw, seenCursors, page + 1);
|
|
1313
|
+
for (const entry of result.sandboxes) {
|
|
1314
|
+
if (!matchesState(entry.status, options.states) || excluded.has(entry.name)) {
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (options.claim !== undefined) {
|
|
1318
|
+
this.register(entry.name, { owned: options.claim });
|
|
1319
|
+
}
|
|
1320
|
+
handles.push(handleFromSandboxHandle(entry));
|
|
1321
|
+
if (options.cap !== undefined && handles.length >= options.cap) {
|
|
1322
|
+
return handles;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (!result.nextCursor) {
|
|
1326
|
+
return handles;
|
|
1327
|
+
}
|
|
1328
|
+
cursor = result.nextCursor;
|
|
1329
|
+
}
|
|
1330
|
+
// THE BOUND IS A FAILURE, NOT AN ANSWER. Falling out of this loop meant
|
|
1331
|
+
// returning what had been collected so far, which is the exact shape of a
|
|
1332
|
+
// complete listing — the caller cannot tell a drain that gave up after a
|
|
1333
|
+
// thousand pages from one that genuinely ended, so it under-counts a quota
|
|
1334
|
+
// or launches a sandbox it already had. Every other way this drain can end
|
|
1335
|
+
// badly already throws; this one now does too.
|
|
1336
|
+
throw new MicrosandboxPaginationError(MAX_LIST_PAGES, `the listing still had more pages at the ${MAX_LIST_PAGES}-page safety bound, so it cannot be drained `
|
|
1337
|
+
+ "to a trustworthy end; no partial result is returned");
|
|
1338
|
+
}
|
|
1339
|
+
async getById(id, options = {}) {
|
|
1340
|
+
const entry = await this.lookupHandle(id);
|
|
1341
|
+
if (!entry) {
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
// `undefined` means "the caller did not filter"; `null` means "any state".
|
|
1345
|
+
const states = options.states === undefined ? null : options.states;
|
|
1346
|
+
if (!matchesState(entry.status, states)) {
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
// Ownership defaults to FALSE, exactly as it does on the Daytona adapter:
|
|
1350
|
+
// resolving a sandbox by name is an attach, and an attach is not a claim.
|
|
1351
|
+
// `stop`, `start` and `destroy` refuse to touch a sandbox that was never
|
|
1352
|
+
// claimed, so a lease-reattach path cannot delete a sandbox it borrowed.
|
|
1353
|
+
this.register(id, { owned: options.owned ?? false });
|
|
1354
|
+
const handle = handleFromSandboxHandle(entry);
|
|
1355
|
+
if (options.homeDir !== undefined) {
|
|
1356
|
+
handle.homeDir = options.homeDir;
|
|
1357
|
+
}
|
|
1358
|
+
if (options.workdir !== undefined) {
|
|
1359
|
+
handle.workdir = options.workdir;
|
|
1360
|
+
}
|
|
1361
|
+
return handle;
|
|
1362
|
+
}
|
|
1363
|
+
// --- launch --------------------------------------------------------------
|
|
1364
|
+
async launch(options = {}) {
|
|
1365
|
+
return this.createSandbox(options, false);
|
|
1366
|
+
}
|
|
1367
|
+
async launchDetached(options = {}) {
|
|
1368
|
+
// A detached sandbox keeps running after this Node process exits, so a
|
|
1369
|
+
// short-lived request context can return before the workload finishes and a
|
|
1370
|
+
// later tick can reattach by name.
|
|
1371
|
+
return this.createSandbox(options, true);
|
|
1372
|
+
}
|
|
1373
|
+
async createSandbox(options, detached) {
|
|
1374
|
+
const name = this.resolveName(options.name);
|
|
1375
|
+
// A previous create under this name may still be being reclaimed. Waiting
|
|
1376
|
+
// keeps the reclamation from killing the sandbox this call is about to
|
|
1377
|
+
// create under the same name.
|
|
1378
|
+
const pending = this.pendingReclaims.get(name);
|
|
1379
|
+
if (pending) {
|
|
1380
|
+
await pending;
|
|
1381
|
+
}
|
|
1382
|
+
const workdir = options.workdir ?? this.defaultWorkdir;
|
|
1383
|
+
const timeoutMs = options.createTimeoutSeconds
|
|
1384
|
+
? options.createTimeoutSeconds * 1000
|
|
1385
|
+
: undefined;
|
|
1386
|
+
const controller = timeoutMs === undefined ? undefined : new AbortController();
|
|
1387
|
+
let createStarted = false;
|
|
1388
|
+
const create = this.withBackendStatic(async (sdk) => {
|
|
1389
|
+
// Once this flips, `builder.create()` is reached in the same synchronous
|
|
1390
|
+
// turn: every preceding builder method is synchronous. A deadline after
|
|
1391
|
+
// this point cannot cancel provider work, so the late-create watcher
|
|
1392
|
+
// below must retain responsibility for its eventual outcome.
|
|
1393
|
+
createStarted = true;
|
|
1394
|
+
let builder = sdk.Sandbox.builder(name);
|
|
1395
|
+
builder = this.snapshot
|
|
1396
|
+
? builder.fromSnapshot(this.snapshot)
|
|
1397
|
+
: builder.image(this.image);
|
|
1398
|
+
if (this.cpus !== undefined) {
|
|
1399
|
+
builder = builder.cpus(this.cpus);
|
|
1400
|
+
}
|
|
1401
|
+
if (this.memoryMiB !== undefined) {
|
|
1402
|
+
builder = builder.memory(this.memoryMiB);
|
|
1403
|
+
}
|
|
1404
|
+
if (this.idleTimeoutSeconds !== undefined) {
|
|
1405
|
+
builder = builder.idleTimeout(this.idleTimeoutSeconds);
|
|
1406
|
+
}
|
|
1407
|
+
if (this.maxDurationSeconds !== undefined) {
|
|
1408
|
+
builder = builder.maxDuration(this.maxDurationSeconds);
|
|
1409
|
+
}
|
|
1410
|
+
if (workdir !== undefined) {
|
|
1411
|
+
builder = builder.workdir(workdir);
|
|
1412
|
+
}
|
|
1413
|
+
if (hasEntries(options.env)) {
|
|
1414
|
+
builder = builder.envs(options.env);
|
|
1415
|
+
}
|
|
1416
|
+
const labels = mergeLabels(options.labels, options.label);
|
|
1417
|
+
if (hasEntries(labels)) {
|
|
1418
|
+
builder = builder.labels(labels);
|
|
1419
|
+
}
|
|
1420
|
+
if (detached) {
|
|
1421
|
+
builder = builder.detached(true);
|
|
1422
|
+
}
|
|
1423
|
+
if (this.replaceExisting) {
|
|
1424
|
+
builder = builder.replace();
|
|
1425
|
+
}
|
|
1426
|
+
return builder.create();
|
|
1427
|
+
}, controller?.signal);
|
|
1428
|
+
// The builder has no create-timeout setter, and `maxDuration` is a sandbox
|
|
1429
|
+
// LIFETIME budget — mapping the caller's boot deadline onto it would kill
|
|
1430
|
+
// every long-lived sandbox the moment that deadline elapsed. So the create
|
|
1431
|
+
// deadline is enforced here instead.
|
|
1432
|
+
let sandbox;
|
|
1433
|
+
if (timeoutMs !== undefined) {
|
|
1434
|
+
try {
|
|
1435
|
+
sandbox = await withDeadline(create, timeoutMs, name, controller);
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
if (error instanceof MicrosandboxCreateTimeoutError && createStarted) {
|
|
1439
|
+
this.reclaimLateCreate(name, create);
|
|
1440
|
+
}
|
|
1441
|
+
throw error;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
sandbox = await create;
|
|
1446
|
+
}
|
|
1447
|
+
this.register(name, { sandbox, owned: true });
|
|
1448
|
+
const handle = { id: name, state: "STARTED", homeDir: this.homeDir };
|
|
1449
|
+
if (workdir !== undefined) {
|
|
1450
|
+
handle.workdir = workdir;
|
|
1451
|
+
}
|
|
1452
|
+
return handle;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Take responsibility for a create that lost the race with its deadline.
|
|
1456
|
+
*
|
|
1457
|
+
* The SDK offers no way to cancel an in-flight create, so the promise is
|
|
1458
|
+
* watched instead of dropped. Two things follow from that, and both matter:
|
|
1459
|
+
* the eventual rejection is consumed here (an abandoned rejected promise is
|
|
1460
|
+
* an unhandled rejection, which crashes a Node process configured to treat
|
|
1461
|
+
* them as fatal), and an eventual SUCCESS is reclaimed — the caller already
|
|
1462
|
+
* saw a failure, so a sandbox nobody is waiting for would otherwise burn
|
|
1463
|
+
* provider resources and hold its name against the next launch.
|
|
1464
|
+
*/
|
|
1465
|
+
reclaimLateCreate(name, create) {
|
|
1466
|
+
const reclaimed = create.then(async () => {
|
|
1467
|
+
try {
|
|
1468
|
+
await this.forceDestroy(name);
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
// The reclamation cannot re-throw here — the promise it lives on is
|
|
1472
|
+
// detached and no caller is awaiting it, so a rejection becomes an
|
|
1473
|
+
// unhandled rejection instead of ever reaching a `.catch`. But
|
|
1474
|
+
// "cannot re-throw" was previously implemented as SILENTLY DROP,
|
|
1475
|
+
// which is what let the cloud-backend destroy leak land: the caller
|
|
1476
|
+
// saw a clean timeout error, the sandbox stayed running, and no
|
|
1477
|
+
// observer was told. The hook makes the failure recoverable in the
|
|
1478
|
+
// sense that actually matters — a downstream logger or pager sees
|
|
1479
|
+
// it — while a later `launch` of the same name still waits on
|
|
1480
|
+
// `pendingReclaims` and gets a shot at reclaiming it.
|
|
1481
|
+
//
|
|
1482
|
+
// Called INSIDE this catch so a throwing hook still crashes here
|
|
1483
|
+
// rather than in the caller — a runtime option's misbehaviour must
|
|
1484
|
+
// not surprise an unrelated caller of `launch`.
|
|
1485
|
+
if (this.onReclaimFailure) {
|
|
1486
|
+
try {
|
|
1487
|
+
this.onReclaimFailure(name, error);
|
|
1488
|
+
}
|
|
1489
|
+
catch {
|
|
1490
|
+
// Deliberately swallowed: a hook that throws its own error must
|
|
1491
|
+
// not be able to escalate a background reclamation failure into
|
|
1492
|
+
// an unhandled rejection. The original teardown failure is
|
|
1493
|
+
// already visible via any prior hook call and via the sandbox
|
|
1494
|
+
// still being addressable by name on the provider.
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}, () => {
|
|
1499
|
+
// The create failed on its own: there is nothing to reclaim, and the
|
|
1500
|
+
// caller already has the timeout error.
|
|
1501
|
+
});
|
|
1502
|
+
const tracked = reclaimed.finally(() => {
|
|
1503
|
+
if (this.pendingReclaims.get(name) === tracked) {
|
|
1504
|
+
this.pendingReclaims.delete(name);
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
this.pendingReclaims.set(name, tracked);
|
|
1508
|
+
}
|
|
1509
|
+
resolveName(requested) {
|
|
1510
|
+
const name = requested ?? `${this.namePrefix}${randomUUID()}`;
|
|
1511
|
+
const byteLength = Buffer.byteLength(name, "utf8");
|
|
1512
|
+
if (byteLength > MAX_SANDBOX_NAME_BYTES) {
|
|
1513
|
+
throw new MicrosandboxNameTooLongError(name, byteLength);
|
|
1514
|
+
}
|
|
1515
|
+
return name;
|
|
1516
|
+
}
|
|
1517
|
+
// --- exec ----------------------------------------------------------------
|
|
1518
|
+
/**
|
|
1519
|
+
* Bootstrap-plane exec. Same call as `runScript`, narrower result shape.
|
|
1520
|
+
*
|
|
1521
|
+
* `ExecResult.exitCode` is a `number`, and `RunScriptResult.exitCode` is
|
|
1522
|
+
* `number | null`, so this is where a missing outcome would have to be
|
|
1523
|
+
* invented. It is not: a `null` becomes a typed error rather than the `0`
|
|
1524
|
+
* that would report an unobserved command as a successful one.
|
|
1525
|
+
*/
|
|
1526
|
+
async exec(handle, command, options = {}) {
|
|
1527
|
+
const result = await this.runScript(handle, {
|
|
1528
|
+
command,
|
|
1529
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
1530
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
1531
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
1532
|
+
});
|
|
1533
|
+
if (result.exitCode === null) {
|
|
1534
|
+
throw new MicrosandboxUnknownOutcomeError(handle.id);
|
|
1535
|
+
}
|
|
1536
|
+
return {
|
|
1537
|
+
output: result.output,
|
|
1538
|
+
exitCode: result.exitCode,
|
|
1539
|
+
...(result.truncated ? { truncated: true } : {}),
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
async runScript(handle, options) {
|
|
1543
|
+
const sandbox = await this.requireSandbox(handle);
|
|
1544
|
+
const cwd = options.cwd ?? handle.workdir;
|
|
1545
|
+
// `shell(script)` takes no options, so a configurable POSIX shell with
|
|
1546
|
+
// `-c` is what carries cwd / env / timeout onto a command string. The
|
|
1547
|
+
// command travels as an argv element, so nothing quotes or rewrites it.
|
|
1548
|
+
// Off the gate: `sandbox` is bound to the backend it was resolved on, so
|
|
1549
|
+
// this reads no process-wide state — and holding the gate for the whole
|
|
1550
|
+
// command would block every other backend in the process meanwhile.
|
|
1551
|
+
const output = await sandbox.execWith(this.shell, (builder) => {
|
|
1552
|
+
let configured = builder.args(["-c", options.command]);
|
|
1553
|
+
if (cwd !== undefined) {
|
|
1554
|
+
configured = configured.cwd(cwd);
|
|
1555
|
+
}
|
|
1556
|
+
if (hasEntries(options.env)) {
|
|
1557
|
+
configured = configured.envs(options.env);
|
|
1558
|
+
}
|
|
1559
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
1560
|
+
configured = configured.timeout(options.timeoutMs);
|
|
1561
|
+
}
|
|
1562
|
+
return configured;
|
|
1563
|
+
});
|
|
1564
|
+
const stdout = output.stdout();
|
|
1565
|
+
const stderr = output.stderr();
|
|
1566
|
+
// A non-zero exit is a RESULT here, not an exception: the SDK resolves
|
|
1567
|
+
// `ExecOutput` with `code`/`success` rather than throwing.
|
|
1568
|
+
return {
|
|
1569
|
+
output: combineOutput(stdout, stderr),
|
|
1570
|
+
...(stdout ? { stdout } : {}),
|
|
1571
|
+
...(stderr ? { stderr } : {}),
|
|
1572
|
+
exitCode: typeof output.code === "number" ? output.code : null,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Submit one durable async run.
|
|
1577
|
+
*
|
|
1578
|
+
* The guest wrapper (see {@link MICROSANDBOX_RUN_ADMIT_SCRIPT}) claims the
|
|
1579
|
+
* session's run directory with an atomic `mkdir`, so this is idempotent in
|
|
1580
|
+
* the way that actually matters for an outcome-unknown submit: a retry of
|
|
1581
|
+
* the SAME command adopts the run that is already there (`reconciled: true`)
|
|
1582
|
+
* instead of starting a second one, and a retry with a DIFFERENT command is
|
|
1583
|
+
* refused instead of overwriting the first run's state.
|
|
1584
|
+
*/
|
|
1585
|
+
async startScript(handle, options) {
|
|
1586
|
+
const sessionId = options.sessionId ?? `run-${handle.id}-${randomUUID()}`;
|
|
1587
|
+
// REFUSED BEFORE ANYTHING IS SUBMITTED, so a caller that asked for a
|
|
1588
|
+
// command budget it will not get is told before a process exists rather
|
|
1589
|
+
// than after one is running unbounded. See the error's own docs for why it
|
|
1590
|
+
// is refused instead of approximated.
|
|
1591
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
1592
|
+
throw new MicrosandboxRunTimeoutUnsupportedError(sessionId, options.timeoutMs);
|
|
1593
|
+
}
|
|
1594
|
+
const sandbox = await this.requireSandbox(handle);
|
|
1595
|
+
const dir = this.scriptRunDir(sessionId);
|
|
1596
|
+
const cwd = options.cwd ?? handle.workdir;
|
|
1597
|
+
const output = await sandbox.execWith(this.shell, (builder) => {
|
|
1598
|
+
let configured = builder.args([
|
|
1599
|
+
"-c",
|
|
1600
|
+
MICROSANDBOX_RUN_ADMIT_SCRIPT,
|
|
1601
|
+
"msb-admit",
|
|
1602
|
+
options.command,
|
|
1603
|
+
dir,
|
|
1604
|
+
this.runStateDir,
|
|
1605
|
+
this.shell,
|
|
1606
|
+
GUEST_PROC_ROOT,
|
|
1607
|
+
]);
|
|
1608
|
+
if (cwd !== undefined) {
|
|
1609
|
+
configured = configured.cwd(cwd);
|
|
1610
|
+
}
|
|
1611
|
+
if (hasEntries(options.env)) {
|
|
1612
|
+
configured = configured.envs(options.env);
|
|
1613
|
+
}
|
|
1614
|
+
// No `timeout(...)` here, and that is the point: the only timeout this
|
|
1615
|
+
// call could set is the SUBMIT call's, and the port's `timeoutMs` means
|
|
1616
|
+
// the COMMAND's lifetime. Setting it here would satisfy the type and
|
|
1617
|
+
// silently mean something else, so a `timeoutMs` is refused above
|
|
1618
|
+
// instead.
|
|
1619
|
+
return configured;
|
|
1620
|
+
});
|
|
1621
|
+
const marker = output.stdout().trim();
|
|
1622
|
+
if (marker.startsWith("ADMITTED ")) {
|
|
1623
|
+
return { sessionId, commandId: marker.slice("ADMITTED ".length).trim() };
|
|
1624
|
+
}
|
|
1625
|
+
if (marker.startsWith("CLAIMED ")) {
|
|
1626
|
+
return {
|
|
1627
|
+
sessionId,
|
|
1628
|
+
commandId: marker.slice("CLAIMED ".length).trim(),
|
|
1629
|
+
reconciled: true,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
if (marker === "CONFLICT") {
|
|
1633
|
+
throw new MicrosandboxSessionConflictError(sessionId);
|
|
1634
|
+
}
|
|
1635
|
+
throw new Error(`Microsandbox async run admission for session "${sessionId}" returned no verdict (exit ${output.code}): ${summarize(output.stderr() || marker)}`);
|
|
1636
|
+
}
|
|
1637
|
+
async getScriptStatus(handle, sessionId, commandId) {
|
|
1638
|
+
const sandbox = await this.requireSandbox(handle);
|
|
1639
|
+
const dir = this.scriptRunDir(sessionId);
|
|
1640
|
+
let marker;
|
|
1641
|
+
let probeExit;
|
|
1642
|
+
let probeStderr = "";
|
|
1643
|
+
try {
|
|
1644
|
+
const output = await sandbox.execWith(this.shell, (builder) => builder.args(["-c", MICROSANDBOX_RUN_STATUS_SCRIPT, "msb-status", dir, GUEST_PROC_ROOT]));
|
|
1645
|
+
marker = (output.stdout() ?? "").trim();
|
|
1646
|
+
probeExit = typeof output.code === "number" ? output.code : undefined;
|
|
1647
|
+
probeStderr = output.stderr() ?? "";
|
|
1648
|
+
}
|
|
1649
|
+
catch (error) {
|
|
1650
|
+
// A FAILED PROBE SAYS NOTHING ABOUT THE RUN, which is exactly why it can
|
|
1651
|
+
// no longer be reported as `{ exitCode: null }`. That value means "asked,
|
|
1652
|
+
// and it is still running" — a positive observation this call did not
|
|
1653
|
+
// make. A caller polling on it treats a broken transport as a healthy
|
|
1654
|
+
// long-running command and waits out an outcome that may already exist.
|
|
1655
|
+
throw new MicrosandboxStatusProbeError(sessionId, commandId, "transport", `the probe call failed: ${errorMessage(error)}`, error);
|
|
1656
|
+
}
|
|
1657
|
+
if (probeExit !== undefined && probeExit !== 0) {
|
|
1658
|
+
// The call was delivered but the probe script itself failed — an
|
|
1659
|
+
// unreadable run directory, a guest without `/bin/sh`. Same reasoning.
|
|
1660
|
+
throw new MicrosandboxStatusProbeError(sessionId, commandId, "transport", `the probe exited ${probeExit}: ${summarize(probeStderr || marker)}`);
|
|
1661
|
+
}
|
|
1662
|
+
if (marker.startsWith("EXIT ")) {
|
|
1663
|
+
const parsed = Number.parseInt(marker.slice("EXIT ".length).trim(), 10);
|
|
1664
|
+
if (Number.isFinite(parsed)) {
|
|
1665
|
+
return { exitCode: parsed };
|
|
1666
|
+
}
|
|
1667
|
+
// The wrapper finished and recorded something unreadable. Inventing a
|
|
1668
|
+
// code would be a lie and reporting "running" would poll forever.
|
|
1669
|
+
throw new MicrosandboxRunLostError(sessionId, commandId, "its recorded exit code is unreadable");
|
|
1670
|
+
}
|
|
1671
|
+
if (marker === "RUNNING") {
|
|
1672
|
+
return { exitCode: null };
|
|
1673
|
+
}
|
|
1674
|
+
if (marker === "UNKNOWN starttime-unreadable") {
|
|
1675
|
+
throw new MicrosandboxStatusProbeError(sessionId, commandId, "transport", "the run recorded a process start time, but its current start time could not be read from procfs");
|
|
1676
|
+
}
|
|
1677
|
+
if (marker === "MISSING") {
|
|
1678
|
+
throw new MicrosandboxRunLostError(sessionId, commandId, "its run-state directory is gone");
|
|
1679
|
+
}
|
|
1680
|
+
if (marker.startsWith("LOST")) {
|
|
1681
|
+
const reason = marker.slice("LOST".length).trim();
|
|
1682
|
+
throw new MicrosandboxRunLostError(sessionId, commandId, describeLostReason(reason));
|
|
1683
|
+
}
|
|
1684
|
+
// Unrecognized output is an unreadable probe, NOT a verdict — and least of
|
|
1685
|
+
// all the verdict "still running". Reporting one here is how a poll loop
|
|
1686
|
+
// runs forever against a guest whose probe is answering something this
|
|
1687
|
+
// protocol never defined.
|
|
1688
|
+
throw new MicrosandboxStatusProbeError(sessionId, commandId, "unrecognized", `the probe answered ${summarize(marker)}, which is not a verdict this protocol defines`);
|
|
1689
|
+
}
|
|
1690
|
+
async getScriptLogs(handle, sessionId, commandId) {
|
|
1691
|
+
const sandbox = await this.requireSandbox(handle);
|
|
1692
|
+
const outPath = `${this.scriptRunDir(sessionId)}/out`;
|
|
1693
|
+
const log = await this.readRunLog(sandbox, sessionId, outPath, SCRIPT_LOG_READ_MAX_BYTES);
|
|
1694
|
+
// exitCode stays null: `getScriptStatus` is the single source of truth for
|
|
1695
|
+
// the exit code, matching the Daytona, E2B and local adapters.
|
|
1696
|
+
return {
|
|
1697
|
+
output: log.output,
|
|
1698
|
+
exitCode: null,
|
|
1699
|
+
cmdId: commandId,
|
|
1700
|
+
// Present only when the read actually bounded something. An absent
|
|
1701
|
+
// `truncated` means the log is complete — never "unknown".
|
|
1702
|
+
...(log.truncated ? { truncated: true } : {}),
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
// --- bootstrap-plane async exec aliases ----------------------------------
|
|
1706
|
+
async startExec(handle, command, options = {}) {
|
|
1707
|
+
return this.startScript(handle, { command, ...options });
|
|
1708
|
+
}
|
|
1709
|
+
async getExecStatus(handle, sessionId, commandId) {
|
|
1710
|
+
return this.getScriptStatus(handle, sessionId, commandId);
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Terminal result of an async run: its captured output AND the exit code the
|
|
1714
|
+
* run actually recorded.
|
|
1715
|
+
*
|
|
1716
|
+
* The exit code comes from `getScriptStatus`, never from the log read.
|
|
1717
|
+
* `getScriptLogs` reports `exitCode: null` by design, and defaulting that to
|
|
1718
|
+
* `0` here would report every unfinished — and every lost — run as a success.
|
|
1719
|
+
*/
|
|
1720
|
+
async getExecLogs(handle, sessionId, commandId) {
|
|
1721
|
+
const status = await this.getScriptStatus(handle, sessionId, commandId);
|
|
1722
|
+
if (status.exitCode === null) {
|
|
1723
|
+
throw new MicrosandboxRunNotFinishedError(sessionId, commandId);
|
|
1724
|
+
}
|
|
1725
|
+
const logs = await this.getScriptLogs(handle, sessionId, commandId);
|
|
1726
|
+
return {
|
|
1727
|
+
output: logs.output,
|
|
1728
|
+
exitCode: status.exitCode,
|
|
1729
|
+
// Carried through rather than dropped: the bootstrap plane's consumer is
|
|
1730
|
+
// the one that would otherwise read a tail as the whole output.
|
|
1731
|
+
...(logs.truncated ? { truncated: true } : {}),
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
// --- files ---------------------------------------------------------------
|
|
1735
|
+
/**
|
|
1736
|
+
* Put one file into the guest.
|
|
1737
|
+
*
|
|
1738
|
+
* A `string` source is a HOST PATH and a `Buffer` source is file CONTENT —
|
|
1739
|
+
* the same split the Daytona adapter uses, so this class's `uploadFile` and
|
|
1740
|
+
* `uploadBundle` cannot disagree with each other.
|
|
1741
|
+
*/
|
|
1742
|
+
async uploadFile(handle, source, destination) {
|
|
1743
|
+
const sandbox = await this.requireSandbox(handle);
|
|
1744
|
+
const fs = sandbox.fs();
|
|
1745
|
+
await this.ensureParentDir(fs, destination);
|
|
1746
|
+
if (typeof source === "string") {
|
|
1747
|
+
await fs.copyFromHost(source, destination);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
await fs.write(destination, toUint8Array(source));
|
|
1751
|
+
}
|
|
1752
|
+
async uploadBundle(handle, options) {
|
|
1753
|
+
for (const file of options.files) {
|
|
1754
|
+
await this.uploadFile(handle, file.source, file.destination);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
async downloadFile(handle, source, destination) {
|
|
1758
|
+
const sandbox = await this.requireSandbox(handle);
|
|
1759
|
+
const fs = sandbox.fs();
|
|
1760
|
+
if (destination) {
|
|
1761
|
+
await fs.copyToHost(source, destination);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const bytes = await fs.read(source);
|
|
1765
|
+
return Buffer.from(bytes);
|
|
1766
|
+
}
|
|
1767
|
+
async getHomeDir(handle) {
|
|
1768
|
+
// The guest home directory is a property of the image, which this package
|
|
1769
|
+
// does not choose, so it is injected rather than probed.
|
|
1770
|
+
return handle.homeDir ?? this.homeDir;
|
|
1771
|
+
}
|
|
1772
|
+
// --- lifecycle -----------------------------------------------------------
|
|
1773
|
+
//
|
|
1774
|
+
// Every method below changes remote state, so every one of them first asks
|
|
1775
|
+
// whether this runtime OWNS the sandbox. A sandbox is owned when this runtime
|
|
1776
|
+
// launched it, or when the caller said so explicitly via
|
|
1777
|
+
// `getById(id, { owned: true })`. Anything else was borrowed — a warm lease
|
|
1778
|
+
// found by label, a sandbox another process launched — and borrowing does not
|
|
1779
|
+
// confer the right to halt, boot, or delete it.
|
|
1780
|
+
async start(handle) {
|
|
1781
|
+
if (!this.isOwned(handle.id)) {
|
|
1782
|
+
// Not ours: booting someone else's stopped sandbox would charge them for
|
|
1783
|
+
// a microVM they chose to have stopped.
|
|
1784
|
+
return handle;
|
|
1785
|
+
}
|
|
1786
|
+
const entry = await this.lookupHandle(handle.id);
|
|
1787
|
+
if (!entry) {
|
|
1788
|
+
throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`);
|
|
1789
|
+
}
|
|
1790
|
+
const sandbox = await entry.start();
|
|
1791
|
+
this.register(handle.id, { sandbox });
|
|
1792
|
+
return { ...handle, state: "STARTED" };
|
|
1793
|
+
}
|
|
1794
|
+
async stop(handle) {
|
|
1795
|
+
if (!this.isOwned(handle.id)) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const entry = await this.lookupHandle(handle.id);
|
|
1799
|
+
if (!entry) {
|
|
1800
|
+
// Already gone: stopping is idempotent, so this is success, not an error.
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
this.forgetInstance(handle.id);
|
|
1804
|
+
await entry.stop();
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Halt the sandbox AND drop its database record — but only if this runtime
|
|
1808
|
+
* owns it.
|
|
1809
|
+
*
|
|
1810
|
+
* Both halves of the teardown matter: the name is the identity, so leaving a
|
|
1811
|
+
* stopped record behind would make the next `launch` under that name collide.
|
|
1812
|
+
* The ownership check matters more: `destroy` is the one call here that
|
|
1813
|
+
* cannot be undone, and a lease-reattach path that resolved a borrowed
|
|
1814
|
+
* sandbox by name must not be able to delete it. An unowned (or unknown)
|
|
1815
|
+
* handle drops this runtime's local state and makes no remote call at all.
|
|
1816
|
+
*/
|
|
1817
|
+
async destroy(handle) {
|
|
1818
|
+
if (!this.isOwned(handle.id)) {
|
|
1819
|
+
this.registry.delete(handle.id);
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
this.forgetInstance(handle.id);
|
|
1823
|
+
await this.forceDestroy(handle.id);
|
|
1824
|
+
// Dropped only after the remote teardown succeeded: keeping the ownership
|
|
1825
|
+
// record through a failure is what lets the caller retry it.
|
|
1826
|
+
this.registry.delete(handle.id);
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Stop-if-running + remove by name, with no ownership check.
|
|
1830
|
+
*
|
|
1831
|
+
* Private on purpose: the only callers are `destroy` (which has already
|
|
1832
|
+
* checked) and the reclamation of a create that landed after its deadline
|
|
1833
|
+
* (which is reclaiming a sandbox this runtime itself asked for).
|
|
1834
|
+
*
|
|
1835
|
+
* Backend-safe order — this is what makes the destroy path work on the
|
|
1836
|
+
* hosted cloud backend. `SandboxHandle.kill()` is not implemented there:
|
|
1837
|
+
* the SDK answers it with `UnsupportedError` (code `"unsupported"`,
|
|
1838
|
+
* message `"Sandbox::kill is not supported by this backend: use
|
|
1839
|
+
* Sandbox::stop"`), and the previous code re-threw that error before
|
|
1840
|
+
* `remove()` was ever reached, so every cloud destroy left a running
|
|
1841
|
+
* sandbox behind. `stop()` works on both backends, so the sequence is:
|
|
1842
|
+
*
|
|
1843
|
+
* 1. Try `kill()` — a stronger, faster teardown that local supports.
|
|
1844
|
+
* 2. If the backend answers `Unsupported`, fall back to `stop()` — the
|
|
1845
|
+
* call the hosted backend documents.
|
|
1846
|
+
* 3. Either way, `remove()` clears the record (which requires the
|
|
1847
|
+
* sandbox to be stopped, and cannot delete a still-running one).
|
|
1848
|
+
*
|
|
1849
|
+
* `already-stopped` and `not-found` on the first-step call are both fine
|
|
1850
|
+
* because they mean the same thing for the caller: the sandbox is
|
|
1851
|
+
* quiescent by the time `remove()` runs. Any other error is fatal to the
|
|
1852
|
+
* teardown and re-thrown, so the caller retains responsibility.
|
|
1853
|
+
*/
|
|
1854
|
+
async forceDestroy(name) {
|
|
1855
|
+
const entry = await this.lookupHandle(name);
|
|
1856
|
+
if (!entry) {
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
try {
|
|
1860
|
+
await entry.kill();
|
|
1861
|
+
}
|
|
1862
|
+
catch (error) {
|
|
1863
|
+
if (isSandboxNotFound(error) || isAlreadyStopped(error)) {
|
|
1864
|
+
// Already quiescent; drop to remove.
|
|
1865
|
+
}
|
|
1866
|
+
else if (isUnsupportedByBackend(error)) {
|
|
1867
|
+
// Hosted backend path: kill is not implemented, so `stop` is the
|
|
1868
|
+
// documented equivalent. A `not-found`/`already-stopped` here is
|
|
1869
|
+
// still fine — same reasoning as the kill catch above. Any other
|
|
1870
|
+
// error means the sandbox is NOT quiescent and remove would fail
|
|
1871
|
+
// anyway, so we surface it.
|
|
1872
|
+
try {
|
|
1873
|
+
await entry.stop();
|
|
1874
|
+
}
|
|
1875
|
+
catch (stopError) {
|
|
1876
|
+
if (!isSandboxNotFound(stopError) && !isAlreadyStopped(stopError)) {
|
|
1877
|
+
throw stopError;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
else {
|
|
1882
|
+
throw error;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
try {
|
|
1886
|
+
await entry.remove();
|
|
1887
|
+
}
|
|
1888
|
+
catch (error) {
|
|
1889
|
+
if (!isSandboxNotFound(error)) {
|
|
1890
|
+
throw error;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
// --- internals -----------------------------------------------------------
|
|
1895
|
+
async sdk() {
|
|
1896
|
+
if (this.injectedSdk) {
|
|
1897
|
+
return this.injectedSdk;
|
|
1898
|
+
}
|
|
1899
|
+
if (!this.sdkPromise) {
|
|
1900
|
+
// Lazy — keeps `microsandbox` and its platform-specific native addon out
|
|
1901
|
+
// of every non-microsandbox consumer.
|
|
1902
|
+
const loading = import("microsandbox").then((mod) => mod, (error) => {
|
|
1903
|
+
// A cached rejection would fail every later call for the life of the
|
|
1904
|
+
// process, so the slot is cleared and the constraint that most often
|
|
1905
|
+
// explains the failure is stated instead of buried.
|
|
1906
|
+
if (this.sdkPromise === loading) {
|
|
1907
|
+
delete this.sdkPromise;
|
|
1908
|
+
}
|
|
1909
|
+
throw new Error("MicrosandboxRuntime could not load its optional peer dependency \"microsandbox\" (>=0.6.11 <0.7.0). "
|
|
1910
|
+
+ "That package declares Node.js >= 22 and ships a platform-specific native addon "
|
|
1911
|
+
+ "(macOS arm64, Linux x64/arm64, Windows x64/arm64); its local backend additionally requires "
|
|
1912
|
+
+ `hardware virtualization (KVM, Apple Silicon, or WHP). Underlying error: ${errorMessage(error)}`, { cause: error });
|
|
1913
|
+
});
|
|
1914
|
+
this.sdkPromise = loading;
|
|
1915
|
+
}
|
|
1916
|
+
return this.sdkPromise;
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Run one DEFAULT-DEPENDENT SDK static with this runtime's backend in scope.
|
|
1920
|
+
*
|
|
1921
|
+
* `setDefaultBackend` is never called from this adapter: constructing a
|
|
1922
|
+
* runtime must not mutate the host process. The scoped form IS process-wide
|
|
1923
|
+
* while it is open, which is why every static goes through the module's
|
|
1924
|
+
* backend gate (see `withBackendScope`) rather than opening its own scope.
|
|
1925
|
+
*
|
|
1926
|
+
* ONLY the three statics this adapter calls belong here — `Sandbox.builder`
|
|
1927
|
+
* (through its terminal `create()`), `Sandbox.get` and `Sandbox.listWith`.
|
|
1928
|
+
* Operations on a resolved `Sandbox` or `SandboxHandle` are bound to the
|
|
1929
|
+
* backend they were resolved on and are issued directly, off the gate.
|
|
1930
|
+
*/
|
|
1931
|
+
async withBackendStatic(fn, signal) {
|
|
1932
|
+
const sdk = await this.sdk();
|
|
1933
|
+
return withBackendScope(sdk, this.backend, () => fn(sdk), this.backendQueueTimeoutMs, signal);
|
|
1934
|
+
}
|
|
1935
|
+
async lookupHandle(name) {
|
|
1936
|
+
try {
|
|
1937
|
+
return (await this.withBackendStatic((sdk) => sdk.Sandbox.get(name))) ?? null;
|
|
1938
|
+
}
|
|
1939
|
+
catch (error) {
|
|
1940
|
+
if (isSandboxNotFound(error)) {
|
|
1941
|
+
return null;
|
|
1942
|
+
}
|
|
1943
|
+
throw error;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
register(name, patch) {
|
|
1947
|
+
const existing = this.registry.get(name);
|
|
1948
|
+
const sandbox = patch.sandbox ?? existing?.sandbox;
|
|
1949
|
+
const entry = {
|
|
1950
|
+
// Sticky: an attach that does not claim ownership cannot demote a
|
|
1951
|
+
// sandbox this runtime launched, because demoting it would leave a
|
|
1952
|
+
// microVM this process is responsible for with nothing able to reclaim
|
|
1953
|
+
// it.
|
|
1954
|
+
owned: (existing?.owned ?? false) || (patch.owned ?? false),
|
|
1955
|
+
...(sandbox ? { sandbox } : {}),
|
|
1956
|
+
};
|
|
1957
|
+
this.registry.set(name, entry);
|
|
1958
|
+
return entry;
|
|
1959
|
+
}
|
|
1960
|
+
isOwned(name) {
|
|
1961
|
+
return this.registry.get(name)?.owned ?? false;
|
|
1962
|
+
}
|
|
1963
|
+
/** Drop the live connection but keep what this runtime knows about the name. */
|
|
1964
|
+
forgetInstance(name) {
|
|
1965
|
+
const existing = this.registry.get(name);
|
|
1966
|
+
if (!existing) {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
this.registry.set(name, { owned: existing.owned });
|
|
1970
|
+
}
|
|
1971
|
+
async requireSandbox(handle) {
|
|
1972
|
+
const cached = this.registry.get(handle.id)?.sandbox;
|
|
1973
|
+
if (cached) {
|
|
1974
|
+
return cached;
|
|
1975
|
+
}
|
|
1976
|
+
const entry = await this.lookupHandle(handle.id);
|
|
1977
|
+
if (!entry) {
|
|
1978
|
+
throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`);
|
|
1979
|
+
}
|
|
1980
|
+
// `connect` attaches WITHOUT taking lifecycle ownership, so a poll tick
|
|
1981
|
+
// that reattaches cannot accidentally stop a sandbox it did not launch.
|
|
1982
|
+
const sandbox = await entry.connectWithTimeout(this.connectTimeoutMs);
|
|
1983
|
+
this.register(handle.id, { sandbox });
|
|
1984
|
+
return sandbox;
|
|
1985
|
+
}
|
|
1986
|
+
async ensureParentDir(fs, destination) {
|
|
1987
|
+
const parent = parentDir(destination);
|
|
1988
|
+
if (!parent) {
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
try {
|
|
1992
|
+
await fs.mkdir(parent);
|
|
1993
|
+
}
|
|
1994
|
+
catch {
|
|
1995
|
+
// Best effort: an already-present directory is the common case and must
|
|
1996
|
+
// not fail the upload that follows.
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Read one run's captured output, bounded, without turning a failure into an
|
|
2001
|
+
* empty log.
|
|
2002
|
+
*
|
|
2003
|
+
* The read goes through {@link MICROSANDBOX_RUN_LOG_SCRIPT} rather than
|
|
2004
|
+
* `fs.readToString` for two reasons that both matter to the caller: an
|
|
2005
|
+
* ABSENT log is a success with no output (a run that has printed nothing has
|
|
2006
|
+
* one), and everything else — an unreadable file, a failed guest call — is a
|
|
2007
|
+
* failure. The previous form could not tell those apart, because it answered
|
|
2008
|
+
* `""` to all of them.
|
|
2009
|
+
*
|
|
2010
|
+
* One byte MORE than the cap is requested, so a longer log is detectable
|
|
2011
|
+
* rather than silently tailed: the extra byte is what turns "here is the
|
|
2012
|
+
* output" into "here is the last `maxBytes` of it".
|
|
2013
|
+
*/
|
|
2014
|
+
async readRunLog(sandbox, sessionId, path, maxBytes) {
|
|
2015
|
+
let output;
|
|
2016
|
+
try {
|
|
2017
|
+
output = await sandbox.execWith(this.shell, (builder) => builder.args([
|
|
2018
|
+
"-c",
|
|
2019
|
+
MICROSANDBOX_RUN_LOG_SCRIPT,
|
|
2020
|
+
"msb-log",
|
|
2021
|
+
path,
|
|
2022
|
+
String(maxBytes + 1),
|
|
2023
|
+
]));
|
|
2024
|
+
}
|
|
2025
|
+
catch (error) {
|
|
2026
|
+
throw new MicrosandboxLogReadError(sessionId, path, `the guest call failed: ${errorMessage(error)}`, error);
|
|
2027
|
+
}
|
|
2028
|
+
if (typeof output.code !== "number") {
|
|
2029
|
+
throw new MicrosandboxLogReadError(sessionId, path, `the read completed without an exit code, so its outcome is unknown: ${summarize(output.stderr() ?? "")}`);
|
|
2030
|
+
}
|
|
2031
|
+
if (output.code !== 0) {
|
|
2032
|
+
throw new MicrosandboxLogReadError(sessionId, path, `the read exited ${output.code}: ${summarize(output.stderr() ?? "")}`);
|
|
2033
|
+
}
|
|
2034
|
+
const text = output.stdout() ?? "";
|
|
2035
|
+
const bytes = Buffer.from(text, "utf8");
|
|
2036
|
+
if (bytes.byteLength <= maxBytes) {
|
|
2037
|
+
return { output: text, truncated: false };
|
|
2038
|
+
}
|
|
2039
|
+
// More than the cap came back, so the log is longer than what is being
|
|
2040
|
+
// returned. The caller is handed the TAIL and told it is one.
|
|
2041
|
+
return {
|
|
2042
|
+
output: bytes.subarray(bytes.byteLength - maxBytes).toString("utf8"),
|
|
2043
|
+
truncated: true,
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
lookupDeadline(timeoutMs) {
|
|
2047
|
+
const requested = timeoutMs ?? this.lookupTimeoutMs;
|
|
2048
|
+
const normalized = Number.isFinite(requested) && requested > 0
|
|
2049
|
+
? Math.max(1, Math.ceil(requested))
|
|
2050
|
+
: this.lookupTimeoutMs;
|
|
2051
|
+
return { endsAt: Date.now() + normalized, timeoutMs: normalized };
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Run `build` under the ONE overall deadline, cancelling its admission when
|
|
2055
|
+
* that deadline expires.
|
|
2056
|
+
*
|
|
2057
|
+
* Racing a timer against the operation is not enough on its own. The gate is
|
|
2058
|
+
* a queue, so a lookup that gives up while queued is still queued: it can be
|
|
2059
|
+
* admitted later and issue a static against the process default long after
|
|
2060
|
+
* the caller stopped waiting for it. The signal is what actually withdraws
|
|
2061
|
+
* it from the queue.
|
|
2062
|
+
*/
|
|
2063
|
+
async awaitWithinCancelling(build, deadline, description) {
|
|
2064
|
+
const controller = new AbortController();
|
|
2065
|
+
try {
|
|
2066
|
+
return await this.awaitWithin(build(controller.signal), deadline, description);
|
|
2067
|
+
}
|
|
2068
|
+
catch (error) {
|
|
2069
|
+
controller.abort(error instanceof MicrosandboxLookupTimeoutError
|
|
2070
|
+
? error
|
|
2071
|
+
: new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description));
|
|
2072
|
+
throw error;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
async awaitWithin(operation, deadline, description) {
|
|
2076
|
+
const remainingMs = deadline.endsAt - Date.now();
|
|
2077
|
+
if (remainingMs <= 0) {
|
|
2078
|
+
throw new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description);
|
|
2079
|
+
}
|
|
2080
|
+
let timer;
|
|
2081
|
+
try {
|
|
2082
|
+
return await Promise.race([
|
|
2083
|
+
operation,
|
|
2084
|
+
new Promise((_resolve, reject) => {
|
|
2085
|
+
timer = setTimeout(() => reject(new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description)), remainingMs);
|
|
2086
|
+
}),
|
|
2087
|
+
]);
|
|
2088
|
+
}
|
|
2089
|
+
finally {
|
|
2090
|
+
if (timer) {
|
|
2091
|
+
clearTimeout(timer);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Guest directory holding one session's run state.
|
|
2097
|
+
*
|
|
2098
|
+
* The encoding is reversible, so two different session ids can never land on
|
|
2099
|
+
* one directory. A sanitizing replacement cannot promise that: it maps `a/b`
|
|
2100
|
+
* and `a_b` onto the same path, which is enough to hand one run's exit code
|
|
2101
|
+
* to the other.
|
|
2102
|
+
*/
|
|
2103
|
+
scriptRunDir(sessionId) {
|
|
2104
|
+
return `${this.runStateDir}/${encodeRunSegment(sessionId)}`;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
// --- helpers ---------------------------------------------------------------
|
|
2108
|
+
/**
|
|
2109
|
+
* Normalize an SDK status onto the `STARTED`/`STOPPED` vocabulary the delivery
|
|
2110
|
+
* path reasons about.
|
|
2111
|
+
*
|
|
2112
|
+
* `draining` reads as STOPPED on purpose: a draining sandbox is on its way
|
|
2113
|
+
* down, so handing it back as a warm lease would hand a caller a sandbox that
|
|
2114
|
+
* is about to disappear underneath it.
|
|
2115
|
+
*/
|
|
2116
|
+
function normalizeStatus(status) {
|
|
2117
|
+
return status === "running" ? "STARTED" : "STOPPED";
|
|
2118
|
+
}
|
|
2119
|
+
function matchesState(status, states) {
|
|
2120
|
+
if (states === null) {
|
|
2121
|
+
return true;
|
|
2122
|
+
}
|
|
2123
|
+
return states.includes(normalizeStatus(status));
|
|
2124
|
+
}
|
|
2125
|
+
function handleFromSandboxHandle(entry) {
|
|
2126
|
+
return {
|
|
2127
|
+
id: entry.name,
|
|
2128
|
+
state: normalizeStatus(entry.status),
|
|
2129
|
+
...(entry.createdAt ? { createdAt: entry.createdAt.toISOString() } : {}),
|
|
2130
|
+
...(entry.updatedAt ? { updatedAt: entry.updatedAt.toISOString() } : {}),
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
function mergeLabels(labels, label) {
|
|
2134
|
+
return {
|
|
2135
|
+
...(labels ?? {}),
|
|
2136
|
+
...(label ? { label } : {}),
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
function combineOutput(stdout, stderr) {
|
|
2140
|
+
if (stdout && stderr) {
|
|
2141
|
+
return stdout.endsWith("\n") || stderr.startsWith("\n")
|
|
2142
|
+
? `${stdout}${stderr}`
|
|
2143
|
+
: `${stdout}\n${stderr}`;
|
|
2144
|
+
}
|
|
2145
|
+
return stdout || stderr || "";
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Reversible path-segment encoding for a session id.
|
|
2149
|
+
*
|
|
2150
|
+
* Every byte outside `[A-Za-z0-9_-]` becomes `%XX`, including `%` itself, so
|
|
2151
|
+
* distinct ids always produce distinct segments. An id long enough to threaten
|
|
2152
|
+
* the guest filesystem's 255-byte filename limit collapses to a digest instead;
|
|
2153
|
+
* the leading `.` cannot be produced by the encoder, so a digest segment can
|
|
2154
|
+
* never be confused with an encoded one.
|
|
2155
|
+
*/
|
|
2156
|
+
function encodeRunSegment(sessionId) {
|
|
2157
|
+
const encoded = sessionId.replace(/[^A-Za-z0-9_-]/gu, (character) => [...Buffer.from(character, "utf8")]
|
|
2158
|
+
.map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`)
|
|
2159
|
+
.join(""));
|
|
2160
|
+
if (Buffer.byteLength(encoded, "utf8") <= MAX_RUN_SEGMENT_BYTES) {
|
|
2161
|
+
return encoded;
|
|
2162
|
+
}
|
|
2163
|
+
return `.${createHash("sha256").update(sessionId, "utf8").digest("hex")}`;
|
|
2164
|
+
}
|
|
2165
|
+
/** Exact when the SDK's typed error code is present; textual only as a fallback. */
|
|
2166
|
+
function isSandboxNotFound(error) {
|
|
2167
|
+
if (typeof error !== "object" || error === null) {
|
|
2168
|
+
return false;
|
|
2169
|
+
}
|
|
2170
|
+
const code = error.code;
|
|
2171
|
+
if (typeof code === "string") {
|
|
2172
|
+
// The SDK tags every error with a `MicrosandboxErrorCode`, so when a code is
|
|
2173
|
+
// present it is authoritative and no message sniffing happens at all.
|
|
2174
|
+
return code === "sandboxNotFound";
|
|
2175
|
+
}
|
|
2176
|
+
const name = error.name;
|
|
2177
|
+
if (name === "SandboxNotFoundError") {
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
const message = error.message;
|
|
2181
|
+
return typeof message === "string" && /sandbox\s+not\s+found/i.test(message);
|
|
2182
|
+
}
|
|
2183
|
+
function isAlreadyStopped(error) {
|
|
2184
|
+
if (typeof error !== "object" || error === null) {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
const message = error.message;
|
|
2188
|
+
return typeof message === "string" && /already\s+stopped|not\s+running/i.test(message);
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* True when the SDK signals the backend does not implement a lifecycle call
|
|
2192
|
+
* the adapter tried to use, e.g. `SandboxHandle.kill` on the hosted
|
|
2193
|
+
* (cloud) backend which throws `UnsupportedError` with code `"unsupported"`.
|
|
2194
|
+
*
|
|
2195
|
+
* Uses the SDK's typed code first (authoritative when present, per
|
|
2196
|
+
* `MicrosandboxErrorCode` in `errors.d.ts`), and only falls back to the
|
|
2197
|
+
* error name when a producer forwarded the exception without preserving the
|
|
2198
|
+
* code — the message text is deliberately not inspected because the
|
|
2199
|
+
* caller-visible sentence "use Sandbox::stop" is a formatting detail of one
|
|
2200
|
+
* SDK version, not an interoperable contract.
|
|
2201
|
+
*/
|
|
2202
|
+
function isUnsupportedByBackend(error) {
|
|
2203
|
+
if (typeof error !== "object" || error === null) {
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
const code = error.code;
|
|
2207
|
+
if (typeof code === "string") {
|
|
2208
|
+
return code === "unsupported" || code === "unsupportedOperation";
|
|
2209
|
+
}
|
|
2210
|
+
const name = error.name;
|
|
2211
|
+
return name === "UnsupportedError" || name === "UnsupportedOperationError";
|
|
2212
|
+
}
|
|
2213
|
+
/** Turn the status script's `LOST <reason>` token into something a human reads. */
|
|
2214
|
+
function describeLostReason(reason) {
|
|
2215
|
+
if (reason === "sandbox-restarted") {
|
|
2216
|
+
return "the sandbox restarted while it was running";
|
|
2217
|
+
}
|
|
2218
|
+
if (reason === "pid-reused") {
|
|
2219
|
+
return "its process is gone and the guest has since reused its pid for something else";
|
|
2220
|
+
}
|
|
2221
|
+
return "its process is gone and it never recorded an exit code";
|
|
2222
|
+
}
|
|
2223
|
+
function errorMessage(error) {
|
|
2224
|
+
return error instanceof Error ? error.message : String(error);
|
|
2225
|
+
}
|
|
2226
|
+
/** Bound a provider string before it is interpolated into an error message. */
|
|
2227
|
+
function summarize(value) {
|
|
2228
|
+
const trimmed = value.trim();
|
|
2229
|
+
return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed || "(no output)";
|
|
2230
|
+
}
|
|
2231
|
+
async function withDeadline(promise, timeoutMs, sandboxName, controller) {
|
|
2232
|
+
let timer;
|
|
2233
|
+
try {
|
|
2234
|
+
return await Promise.race([
|
|
2235
|
+
promise,
|
|
2236
|
+
new Promise((_resolve, reject) => {
|
|
2237
|
+
timer = setTimeout(() => {
|
|
2238
|
+
const error = new MicrosandboxCreateTimeoutError(sandboxName, timeoutMs);
|
|
2239
|
+
// A caller still waiting for the backend gate has not issued any
|
|
2240
|
+
// provider work, so aborting withdraws it from admission entirely.
|
|
2241
|
+
// Once its callback started, AbortSignal intentionally cannot cancel
|
|
2242
|
+
// the provider promise; reclaimLateCreate keeps watching that path.
|
|
2243
|
+
controller?.abort(error);
|
|
2244
|
+
reject(error);
|
|
2245
|
+
}, timeoutMs);
|
|
2246
|
+
}),
|
|
2247
|
+
]);
|
|
2248
|
+
}
|
|
2249
|
+
finally {
|
|
2250
|
+
if (timer) {
|
|
2251
|
+
clearTimeout(timer);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function parentDir(path) {
|
|
2256
|
+
const index = path.lastIndexOf("/");
|
|
2257
|
+
if (index <= 0) {
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
return path.slice(0, index);
|
|
2261
|
+
}
|
|
2262
|
+
function toUint8Array(buffer) {
|
|
2263
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* A page-request size the provider can actually be asked for, or `undefined`
|
|
2267
|
+
* when the caller's value is not one.
|
|
2268
|
+
*
|
|
2269
|
+
* `limit: 0` and `limit: -1` are not page sizes; sending either would ask the
|
|
2270
|
+
* backend to interpret them, and the two mainstream interpretations —
|
|
2271
|
+
* "everything" and "nothing" — are opposite. The caller's configured default
|
|
2272
|
+
* is used instead, and the RESULT cap (a separate concern, resolved by the
|
|
2273
|
+
* caller of this helper) is what honours a zero.
|
|
2274
|
+
*/
|
|
2275
|
+
/**
|
|
2276
|
+
* Normalize a caller's RESULT cap onto `collectByLabels`'s `cap` option.
|
|
2277
|
+
*
|
|
2278
|
+
* Returns a spreadable fragment so "no cap" is the absence of the key rather
|
|
2279
|
+
* than a sentinel. The three edges are decided, not accidental:
|
|
2280
|
+
* - `undefined` — no cap; drain the whole listing.
|
|
2281
|
+
* - `Infinity` — no cap; it is the explicit spelling of the same thing.
|
|
2282
|
+
* - anything else, INCLUDING `NaN`, negatives and fractions — floored onto a
|
|
2283
|
+
* non-negative integer, with `NaN` becoming `0`. A cap nobody can interpret
|
|
2284
|
+
* resolves to "return nothing", never to "return everything": the first is
|
|
2285
|
+
* visibly wrong to the caller, the second silently drains a listing it asked
|
|
2286
|
+
* to bound.
|
|
2287
|
+
*/
|
|
2288
|
+
function resultCap(requested) {
|
|
2289
|
+
if (requested === undefined || requested === Number.POSITIVE_INFINITY) {
|
|
2290
|
+
return {};
|
|
2291
|
+
}
|
|
2292
|
+
return { cap: Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : 0 };
|
|
2293
|
+
}
|
|
2294
|
+
function positivePageSize(requested) {
|
|
2295
|
+
if (requested === undefined || !Number.isFinite(requested) || requested < 1) {
|
|
2296
|
+
return undefined;
|
|
2297
|
+
}
|
|
2298
|
+
return Math.floor(requested);
|
|
2299
|
+
}
|
|
2300
|
+
function hasEntries(record) {
|
|
2301
|
+
return !!record && Object.keys(record).length > 0;
|
|
2302
|
+
}
|
|
2303
|
+
//# sourceMappingURL=runtime.js.map
|