@blaxel/core 0.2.87-preview.174 → 0.2.87-preview.175
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/common/h2fetch.js +54 -17
- package/dist/cjs/common/settings.js +29 -3
- package/dist/cjs/common/transient-retry.js +145 -0
- package/dist/cjs/sandbox/drive/drive.js +11 -6
- package/dist/cjs/sandbox/filesystem/filesystem.js +126 -158
- package/dist/cjs/sandbox/process/process.js +29 -16
- package/dist/cjs/types/common/h2fetch.d.ts +7 -0
- package/dist/cjs/types/common/settings.d.ts +22 -2
- package/dist/cjs/types/common/transient-retry.d.ts +30 -0
- package/dist/cjs/types/sandbox/filesystem/filesystem.d.ts +3 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/h2fetch.js +1 -0
- package/dist/cjs-browser/common/settings.js +29 -3
- package/dist/cjs-browser/common/transient-retry.js +145 -0
- package/dist/cjs-browser/sandbox/drive/drive.js +11 -6
- package/dist/cjs-browser/sandbox/filesystem/filesystem.js +126 -158
- package/dist/cjs-browser/sandbox/process/process.js +29 -16
- package/dist/cjs-browser/types/common/h2fetch.d.ts +7 -0
- package/dist/cjs-browser/types/common/settings.d.ts +22 -2
- package/dist/cjs-browser/types/common/transient-retry.d.ts +30 -0
- package/dist/cjs-browser/types/sandbox/filesystem/filesystem.d.ts +3 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/h2fetch.js +53 -17
- package/dist/esm/common/settings.js +29 -3
- package/dist/esm/common/transient-retry.js +141 -0
- package/dist/esm/sandbox/drive/drive.js +11 -6
- package/dist/esm/sandbox/filesystem/filesystem.js +124 -157
- package/dist/esm/sandbox/process/process.js +29 -16
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/h2fetch.js +1 -0
- package/dist/esm-browser/common/settings.js +29 -3
- package/dist/esm-browser/common/transient-retry.js +141 -0
- package/dist/esm-browser/sandbox/drive/drive.js +11 -6
- package/dist/esm-browser/sandbox/filesystem/filesystem.js +124 -157
- package/dist/esm-browser/sandbox/process/process.js +29 -16
- package/package.json +1 -1
|
@@ -26,30 +26,31 @@ const sessionsWithListenerBudget = new WeakSet();
|
|
|
26
26
|
// freed early (the queue could then admit one extra stream); acceptable as a
|
|
27
27
|
// pure backstop, since the slot is otherwise tied to the true stream terminal.
|
|
28
28
|
const H2_SLOT_TIMEOUT_MS = 1_800_000; // 30 minutes
|
|
29
|
+
// Global per-domain gate (the opt-in maxConcurrentH2Requests cap).
|
|
29
30
|
const h2GatesByDomain = new Map();
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
// Upload-scoped per-domain gate (the default-on multipart upload cap, ENG-2680).
|
|
32
|
+
// Kept separate from the global gate so it only ever throttles upload parts.
|
|
33
|
+
const h2UploadGatesByDomain = new Map();
|
|
34
|
+
function getGate(gates, domain) {
|
|
35
|
+
let gate = gates.get(domain);
|
|
32
36
|
if (!gate) {
|
|
33
37
|
gate = { active: 0, queue: [] };
|
|
34
|
-
|
|
38
|
+
gates.set(domain, gate);
|
|
35
39
|
}
|
|
36
40
|
return gate;
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
|
-
*
|
|
40
|
-
* that is idempotent and FIFO-fair: releasing
|
|
41
|
-
* caller for the same domain.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* neither opens a stream nor ever settles, preventing per-domain starvation;
|
|
46
|
-
* it never aborts the underlying request (see `H2_SLOT_TIMEOUT_MS`).
|
|
43
|
+
* Core per-domain async semaphore, shared by the global and upload gates.
|
|
44
|
+
* Resolves with a release function that is idempotent and FIFO-fair: releasing
|
|
45
|
+
* wakes the longest-waiting queued caller for the same domain. When `max` is
|
|
46
|
+
* `0`/unset the gate is a no-op (unlimited). A backstop timer releases the slot
|
|
47
|
+
* if a holder never releases, preventing per-domain starvation; it never aborts
|
|
48
|
+
* the underlying request (see `H2_SLOT_TIMEOUT_MS`).
|
|
47
49
|
*/
|
|
48
|
-
async function
|
|
49
|
-
const max = settings.maxConcurrentH2Requests; // 0/undefined = unlimited
|
|
50
|
+
async function acquireGateSlot(gates, domain, max) {
|
|
50
51
|
if (!max || max <= 0)
|
|
51
52
|
return () => { };
|
|
52
|
-
const gate =
|
|
53
|
+
const gate = getGate(gates, domain);
|
|
53
54
|
while (gate.active >= max) {
|
|
54
55
|
await new Promise((resolve) => gate.queue.push(resolve));
|
|
55
56
|
}
|
|
@@ -69,9 +70,9 @@ async function acquireH2Slot(domain) {
|
|
|
69
70
|
next();
|
|
70
71
|
}
|
|
71
72
|
else if (gate.active === 0 && gate.queue.length === 0) {
|
|
72
|
-
// No active
|
|
73
|
-
//
|
|
74
|
-
|
|
73
|
+
// No active holders and nothing waiting: drop the empty gate so the Map
|
|
74
|
+
// does not grow unbounded across many short-lived domains.
|
|
75
|
+
gates.delete(domain);
|
|
75
76
|
}
|
|
76
77
|
};
|
|
77
78
|
timer.handle = setTimeout(release, H2_SLOT_TIMEOUT_MS);
|
|
@@ -80,6 +81,41 @@ async function acquireH2Slot(domain) {
|
|
|
80
81
|
timer.handle.unref?.();
|
|
81
82
|
return release;
|
|
82
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Acquire the global OPEN-STREAM slot for `domain` (the opt-in
|
|
86
|
+
* `maxConcurrentH2Requests` cap; `0`/unset = unlimited, the default). Held for
|
|
87
|
+
* the lifetime of an OPEN H2 stream — the send path releases it on the stream
|
|
88
|
+
* terminal — so the gate bounds true concurrent streams on the one shared
|
|
89
|
+
* session, not merely requests awaiting headers (ENG-2678).
|
|
90
|
+
*/
|
|
91
|
+
function acquireH2Slot(domain) {
|
|
92
|
+
return acquireGateSlot(h2GatesByDomain, domain, settings.maxConcurrentH2Requests);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Acquire an upload-scoped slot for `domain`, bounding concurrent multipart
|
|
96
|
+
* upload-part requests on the shared H2 connection (ENG-2680). Separate from the
|
|
97
|
+
* global gate so it only throttles uploads. Defaults to 2 — the measured value
|
|
98
|
+
* that stops concurrent large uploads tripping ENHANCE_YOUR_CALM — making it the
|
|
99
|
+
* one reliability mitigation on by default, scoped to the upload path.
|
|
100
|
+
*/
|
|
101
|
+
function acquireUploadSlot(domain) {
|
|
102
|
+
return acquireGateSlot(h2UploadGatesByDomain, domain, settings.maxConcurrentUploadH2Requests);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Run `fn` while holding an upload slot for `domain`, releasing it when `fn`
|
|
106
|
+
* settles (the part PUT completes or fails). Bounds concurrent in-flight upload
|
|
107
|
+
* parts per domain across all files sharing the connection. Used by the
|
|
108
|
+
* multipart upload path; a no-op wrapper when the upload cap is unset.
|
|
109
|
+
*/
|
|
110
|
+
export async function withUploadSlot(domain, fn) {
|
|
111
|
+
const release = await acquireUploadSlot(domain);
|
|
112
|
+
try {
|
|
113
|
+
return await fn();
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
83
119
|
/**
|
|
84
120
|
* Creates a fetch()-compatible function that sends requests over an existing
|
|
85
121
|
* HTTP/2 session. Falls back to globalThis.fetch() only when the session is
|
|
@@ -22,8 +22,8 @@ function missingCredentialsMessage() {
|
|
|
22
22
|
return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
|
|
23
23
|
}
|
|
24
24
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
25
|
-
const BUILD_VERSION = "0.2.87-preview.
|
|
26
|
-
const BUILD_COMMIT = "
|
|
25
|
+
const BUILD_VERSION = "0.2.87-preview.175";
|
|
26
|
+
const BUILD_COMMIT = "31085636cd9c2557198ee7399e7794e945c21687";
|
|
27
27
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
28
28
|
const BLAXEL_API_VERSION = "2026-04-16";
|
|
29
29
|
// Cache for config.yaml tracking value
|
|
@@ -257,6 +257,19 @@ class Settings {
|
|
|
257
257
|
}
|
|
258
258
|
return 0;
|
|
259
259
|
}
|
|
260
|
+
get maxConcurrentUploadH2Requests() {
|
|
261
|
+
if (typeof this.config.maxConcurrentUploadH2Requests === "number") {
|
|
262
|
+
return this.config.maxConcurrentUploadH2Requests;
|
|
263
|
+
}
|
|
264
|
+
const value = env.BL_MAX_UPLOAD_H2_INFLIGHT;
|
|
265
|
+
if (value) {
|
|
266
|
+
const parsed = parseInt(value, 10);
|
|
267
|
+
if (!Number.isNaN(parsed)) {
|
|
268
|
+
return parsed;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return 2;
|
|
272
|
+
}
|
|
260
273
|
get fsPartRetries() {
|
|
261
274
|
if (typeof this.config.fsPartRetries === "number") {
|
|
262
275
|
return this.config.fsPartRetries;
|
|
@@ -268,7 +281,20 @@ class Settings {
|
|
|
268
281
|
return parsed;
|
|
269
282
|
}
|
|
270
283
|
}
|
|
271
|
-
return
|
|
284
|
+
return 3;
|
|
285
|
+
}
|
|
286
|
+
get sandboxReadRetries() {
|
|
287
|
+
if (typeof this.config.sandboxReadRetries === "number") {
|
|
288
|
+
return this.config.sandboxReadRetries;
|
|
289
|
+
}
|
|
290
|
+
const value = env.BL_SANDBOX_READ_RETRIES;
|
|
291
|
+
if (value) {
|
|
292
|
+
const parsed = parseInt(value, 10);
|
|
293
|
+
if (!Number.isNaN(parsed)) {
|
|
294
|
+
return parsed;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return 5;
|
|
272
298
|
}
|
|
273
299
|
/**
|
|
274
300
|
* Fail fast with a clear, actionable error when credentials are missing or
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { settings } from "./settings.js";
|
|
2
|
+
// Markers that, when present anywhere in the error chain, are unambiguous
|
|
3
|
+
// signals of a transient HTTP/2 stream reset or connection drop. These are
|
|
4
|
+
// protocol/transport level codes, not application payloads, so substring
|
|
5
|
+
// matching them does not over-match a server-sent error body. Each entry is
|
|
6
|
+
// matched case-sensitively against the error message and its cause.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately excluded: bare "INTERNAL_ERROR" and "fetch failed". Both are
|
|
9
|
+
// too generic on their own (an application 500 body or any failed fetch would
|
|
10
|
+
// match), so we only treat them as transient when paired with a transport
|
|
11
|
+
// error code on the cause (see isTransientResetError).
|
|
12
|
+
const TRANSIENT_RESET_MARKERS = [
|
|
13
|
+
"ENHANCE_YOUR_CALM", // H2 flow-control backpressure reset
|
|
14
|
+
"NGHTTP2_INTERNAL_ERROR", // H2 internal stream error (qualified form)
|
|
15
|
+
"ERR_HTTP2", // node http2 error code family
|
|
16
|
+
"GOAWAY", // peer is draining the connection
|
|
17
|
+
"HTTP/2 session closed before response", // thrown by our own h2 transport
|
|
18
|
+
"HTTP/2 session sent GOAWAY before response",
|
|
19
|
+
];
|
|
20
|
+
// Node-level error codes (from `error.code` / `error.cause.code`) that mean
|
|
21
|
+
// the connection itself dropped mid-flight and the request never completed.
|
|
22
|
+
// These are safe to retry for an idempotent request.
|
|
23
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
24
|
+
"ECONNRESET",
|
|
25
|
+
"ECONNREFUSED",
|
|
26
|
+
"ETIMEDOUT",
|
|
27
|
+
"EPIPE",
|
|
28
|
+
"ERR_HTTP2_STREAM_ERROR",
|
|
29
|
+
"ERR_HTTP2_GOAWAY_SESSION",
|
|
30
|
+
"ERR_HTTP2_SESSION_ERROR",
|
|
31
|
+
]);
|
|
32
|
+
function collectErrorText(error) {
|
|
33
|
+
const messages = [];
|
|
34
|
+
const codes = [];
|
|
35
|
+
// Walk the error -> cause chain (bounded) so a transport error wrapped by a
|
|
36
|
+
// higher-level "fetch failed" is still classified correctly.
|
|
37
|
+
let current = error;
|
|
38
|
+
for (let depth = 0; depth < 5 && current && typeof current === "object"; depth++) {
|
|
39
|
+
const node = current;
|
|
40
|
+
if (typeof node.message === "string")
|
|
41
|
+
messages.push(node.message);
|
|
42
|
+
if (typeof node.code === "string")
|
|
43
|
+
codes.push(node.code);
|
|
44
|
+
current = node.cause;
|
|
45
|
+
}
|
|
46
|
+
return { messages, codes };
|
|
47
|
+
}
|
|
48
|
+
// True when the error chain carries a real HTTP response (a status came back
|
|
49
|
+
// from the server). Such an error is an APPLICATION response — never a transport
|
|
50
|
+
// reset — even if the server's error body text happens to contain a reset marker
|
|
51
|
+
// like "GOAWAY" or "ERR_HTTP2". Guarding on this stops a marker-bearing 4xx/5xx
|
|
52
|
+
// body from being misread as transient and retried (the over-match Codex flagged
|
|
53
|
+
// for the now default-on idempotent-read retry).
|
|
54
|
+
function hasHttpResponseStatus(error) {
|
|
55
|
+
let current = error;
|
|
56
|
+
for (let depth = 0; depth < 5 && current && typeof current === "object"; depth++) {
|
|
57
|
+
const node = current;
|
|
58
|
+
if (typeof node.status === "number")
|
|
59
|
+
return true;
|
|
60
|
+
if (node.response && typeof node.response === "object" &&
|
|
61
|
+
typeof node.response.status === "number") {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
current = node.cause;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* True only for transport-level resets/drops that are safe to retry on an
|
|
70
|
+
* IDEMPOTENT request (transient HTTP/2 stream reset, GOAWAY, or a dropped
|
|
71
|
+
* connection). Application errors (4xx/5xx — even when their body text contains
|
|
72
|
+
* a marker word) and a bare "fetch failed" with no transport code are
|
|
73
|
+
* deliberately NOT transient, so auto-retry never masks a real server error or
|
|
74
|
+
* duplicates a non-idempotent call.
|
|
75
|
+
*
|
|
76
|
+
* This is the single classifier shared by the upload-part retry (ENG-2680) and
|
|
77
|
+
* the idempotent sandbox-op retry (read/list/etc.), so both judge "transient"
|
|
78
|
+
* identically.
|
|
79
|
+
*/
|
|
80
|
+
export function isTransientResetError(error) {
|
|
81
|
+
if (!error || typeof error !== "object") {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// An error that carries an HTTP response is an application error, not a
|
|
85
|
+
// transport reset — never retry it on the strength of a marker in its body.
|
|
86
|
+
if (hasHttpResponseStatus(error)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const { messages, codes } = collectErrorText(error);
|
|
90
|
+
// 1. An explicit transient transport error code anywhere in the chain.
|
|
91
|
+
if (codes.some((code) => TRANSIENT_ERROR_CODES.has(code))) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
// 2. An unambiguous protocol-level reset marker in any message.
|
|
95
|
+
if (messages.some((text) => TRANSIENT_RESET_MARKERS.some((marker) => text.includes(marker)))) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const DEFAULT_BASE_DELAY_MS = 200;
|
|
101
|
+
const DEFAULT_MAX_DELAY_MS = 2000;
|
|
102
|
+
// Exponential backoff with full-jitter on top of one base delay, capped so a
|
|
103
|
+
// single wait never blocks unreasonably long. Exponential (rather than linear)
|
|
104
|
+
// gives a later attempt room to span a multi-second sandbox cold-start/standby
|
|
105
|
+
// wake, which is the window a first-call reset falls into, while early attempts
|
|
106
|
+
// stay fast for the common quick-reset case.
|
|
107
|
+
function backoffDelayMs(attempt, baseDelayMs, maxDelayMs) {
|
|
108
|
+
const exponential = baseDelayMs * Math.pow(2, attempt - 1);
|
|
109
|
+
const capped = Math.min(exponential, maxDelayMs);
|
|
110
|
+
const jitter = Math.floor(Math.random() * baseDelayMs);
|
|
111
|
+
return capped + jitter;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Run `fn`, retrying only on transient transport resets (see
|
|
115
|
+
* isTransientResetError) with exponential backoff. Caller owns idempotency:
|
|
116
|
+
* ONLY wrap idempotent operations (GET-shaped reads/lists, or an idempotent
|
|
117
|
+
* PUT of the same bytes) — never a non-idempotent POST such as process.exec,
|
|
118
|
+
* which would duplicate the side effect (ENG-2340).
|
|
119
|
+
*
|
|
120
|
+
* Defaults to `settings.sandboxReadRetries` (the higher idempotent-read budget,
|
|
121
|
+
* sized for a multi-second standby wake). The upload path passes
|
|
122
|
+
* `{ retries: settings.fsPartRetries }` to keep its own (lower) budget.
|
|
123
|
+
*/
|
|
124
|
+
export async function retryOnTransientReset(fn, options = {}) {
|
|
125
|
+
const retries = options.retries ?? settings.sandboxReadRetries;
|
|
126
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
127
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
128
|
+
let attempt = 0;
|
|
129
|
+
for (;;) {
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
attempt++;
|
|
135
|
+
if (retries <= 0 || attempt > retries || !isTransientResetError(error)) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, backoffDelayMs(attempt, baseDelayMs, maxDelayMs)));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SandboxAction } from "../action.js";
|
|
2
|
+
import { retryOnTransientReset } from "../../common/transient-retry.js";
|
|
2
3
|
import { postDrivesMount, deleteDrivesMountByMountPath, getDrivesMount, } from "../client/index.js";
|
|
3
4
|
export class SandboxDrive extends SandboxAction {
|
|
4
5
|
constructor(sandbox) {
|
|
@@ -33,11 +34,15 @@ export class SandboxDrive extends SandboxAction {
|
|
|
33
34
|
* List all mounted drives in the sandbox
|
|
34
35
|
*/
|
|
35
36
|
async list() {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// Idempotent GET: self-heal a transient connection reset after sandbox resume.
|
|
38
|
+
// mount/unmount stay un-retried (non-idempotent POST/DELETE).
|
|
39
|
+
return retryOnTransientReset(async () => {
|
|
40
|
+
const { response, data, error } = await getDrivesMount(this.withClient({
|
|
41
|
+
baseUrl: this.url,
|
|
42
|
+
}));
|
|
43
|
+
this.handleResponseError(response, data, error);
|
|
44
|
+
const result = data;
|
|
45
|
+
return result.mounts ?? [];
|
|
46
|
+
});
|
|
42
47
|
}
|
|
43
48
|
}
|