@blaxel/core 0.2.85-preview.159 → 0.2.86-preview.161
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 +102 -22
- package/dist/cjs/common/settings.js +28 -2
- package/dist/cjs/sandbox/filesystem/filesystem.js +89 -1
- package/dist/cjs/types/common/settings.d.ts +12 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/settings.js +28 -2
- package/dist/cjs-browser/sandbox/filesystem/filesystem.js +89 -1
- package/dist/cjs-browser/types/common/settings.d.ts +12 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/h2fetch.js +102 -22
- package/dist/esm/common/settings.js +28 -2
- package/dist/esm/sandbox/filesystem/filesystem.js +89 -1
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/settings.js +28 -2
- package/dist/esm-browser/sandbox/filesystem/filesystem.js +89 -1
- package/package.json +1 -1
|
@@ -4,9 +4,77 @@ exports.createH2Fetch = createH2Fetch;
|
|
|
4
4
|
exports.createPoolBackedH2Fetch = createPoolBackedH2Fetch;
|
|
5
5
|
exports.h2RequestDirectFromPool = h2RequestDirectFromPool;
|
|
6
6
|
exports.h2RequestDirect = h2RequestDirect;
|
|
7
|
+
const settings_js_1 = require("./settings.js");
|
|
7
8
|
const h2ref_js_1 = require("./h2ref.js");
|
|
8
9
|
const MIN_H2_SESSION_MAX_LISTENERS = 64;
|
|
9
10
|
const sessionsWithListenerBudget = new WeakSet();
|
|
11
|
+
/**
|
|
12
|
+
* Per-domain async semaphore that bounds the number of in-flight HTTP/2
|
|
13
|
+
* requests against a single edge domain (one H2 session). The cap is keyed
|
|
14
|
+
* by domain so throttling one sandbox's uploads never blocks requests to an
|
|
15
|
+
* unrelated sandbox served by a different edge.
|
|
16
|
+
*
|
|
17
|
+
* When `settings.maxConcurrentH2Requests` is `0`/unset the gate is a no-op
|
|
18
|
+
* (current default behavior: unlimited concurrency).
|
|
19
|
+
*/
|
|
20
|
+
// Safety timeout for a held slot. A request that never resolves (no response,
|
|
21
|
+
// no error, no abort) would otherwise hold its slot forever and starve every
|
|
22
|
+
// queued request for the same domain. After this window we release the slot
|
|
23
|
+
// and let the underlying request continue or reject on its own; we never
|
|
24
|
+
// cancel the in-flight request here, we only stop it from blocking the queue.
|
|
25
|
+
// Kept deliberately conservative (well above any realistic part-upload time)
|
|
26
|
+
// so it does not interfere with legitimately slow but healthy requests.
|
|
27
|
+
const H2_SLOT_TIMEOUT_MS = 120_000;
|
|
28
|
+
const h2GatesByDomain = new Map();
|
|
29
|
+
function getH2Gate(domain) {
|
|
30
|
+
let gate = h2GatesByDomain.get(domain);
|
|
31
|
+
if (!gate) {
|
|
32
|
+
gate = { active: 0, queue: [] };
|
|
33
|
+
h2GatesByDomain.set(domain, gate);
|
|
34
|
+
}
|
|
35
|
+
return gate;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Acquire an in-flight slot for `domain`. Resolves with a release function
|
|
39
|
+
* that is idempotent and FIFO-fair: releasing wakes the longest-waiting
|
|
40
|
+
* queued caller for the same domain. A safety timer also releases the slot
|
|
41
|
+
* if the caller never does, preventing per-domain starvation.
|
|
42
|
+
*/
|
|
43
|
+
async function acquireH2Slot(domain) {
|
|
44
|
+
const max = settings_js_1.settings.maxConcurrentH2Requests; // 0/undefined = unlimited
|
|
45
|
+
if (!max || max <= 0)
|
|
46
|
+
return () => { };
|
|
47
|
+
const gate = getH2Gate(domain);
|
|
48
|
+
while (gate.active >= max) {
|
|
49
|
+
await new Promise((resolve) => gate.queue.push(resolve));
|
|
50
|
+
}
|
|
51
|
+
gate.active++;
|
|
52
|
+
let released = false;
|
|
53
|
+
// Holder so `release` can clear the safety timer that is created after it.
|
|
54
|
+
const timer = {};
|
|
55
|
+
const release = () => {
|
|
56
|
+
if (released)
|
|
57
|
+
return;
|
|
58
|
+
released = true;
|
|
59
|
+
if (timer.handle !== undefined)
|
|
60
|
+
clearTimeout(timer.handle);
|
|
61
|
+
gate.active--;
|
|
62
|
+
const next = gate.queue.shift();
|
|
63
|
+
if (next) {
|
|
64
|
+
next();
|
|
65
|
+
}
|
|
66
|
+
else if (gate.active === 0 && gate.queue.length === 0) {
|
|
67
|
+
// No active requests and nothing waiting: drop the empty gate so the
|
|
68
|
+
// Map does not grow unbounded across many short-lived domains.
|
|
69
|
+
h2GatesByDomain.delete(domain);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
timer.handle = setTimeout(release, H2_SLOT_TIMEOUT_MS);
|
|
73
|
+
// unref() so a pending safety timer never keeps the process alive. Guarded
|
|
74
|
+
// because not every runtime's timer handle exposes unref().
|
|
75
|
+
timer.handle.unref?.();
|
|
76
|
+
return release;
|
|
77
|
+
}
|
|
10
78
|
/**
|
|
11
79
|
* Creates a fetch()-compatible function that sends requests over an existing
|
|
12
80
|
* HTTP/2 session. Falls back to globalThis.fetch() only when the session is
|
|
@@ -32,11 +100,40 @@ function createH2Fetch(session) {
|
|
|
32
100
|
*/
|
|
33
101
|
function createPoolBackedH2Fetch(pool, domain) {
|
|
34
102
|
return async (input) => {
|
|
103
|
+
const rel = await acquireH2Slot(domain);
|
|
104
|
+
try {
|
|
105
|
+
const session = await pool.get(domain);
|
|
106
|
+
if (session) {
|
|
107
|
+
let h2RequestCreated = false;
|
|
108
|
+
try {
|
|
109
|
+
return await _h2Request(session, input, {
|
|
110
|
+
onH2RequestCreated: () => {
|
|
111
|
+
h2RequestCreated = true;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (h2RequestCreated) {
|
|
117
|
+
pool.evictSession(domain, session);
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return await globalThis.fetch(input);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
rel();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function h2RequestDirectFromPool(pool, domain, url, init) {
|
|
130
|
+
const rel = await acquireH2Slot(domain);
|
|
131
|
+
try {
|
|
35
132
|
const session = await pool.get(domain);
|
|
36
133
|
if (session) {
|
|
37
134
|
let h2RequestCreated = false;
|
|
38
135
|
try {
|
|
39
|
-
return await
|
|
136
|
+
return await h2RequestDirectInternal(session, url, init, {
|
|
40
137
|
onH2RequestCreated: () => {
|
|
41
138
|
h2RequestCreated = true;
|
|
42
139
|
},
|
|
@@ -49,28 +146,11 @@ function createPoolBackedH2Fetch(pool, domain) {
|
|
|
49
146
|
throw err;
|
|
50
147
|
}
|
|
51
148
|
}
|
|
52
|
-
return globalThis.fetch(
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const session = await pool.get(domain);
|
|
57
|
-
if (session) {
|
|
58
|
-
let h2RequestCreated = false;
|
|
59
|
-
try {
|
|
60
|
-
return await h2RequestDirectInternal(session, url, init, {
|
|
61
|
-
onH2RequestCreated: () => {
|
|
62
|
-
h2RequestCreated = true;
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
if (h2RequestCreated) {
|
|
68
|
-
pool.evictSession(domain, session);
|
|
69
|
-
}
|
|
70
|
-
throw err;
|
|
71
|
-
}
|
|
149
|
+
return await globalThis.fetch(url, init);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rel();
|
|
72
153
|
}
|
|
73
|
-
return globalThis.fetch(url, init);
|
|
74
154
|
}
|
|
75
155
|
/**
|
|
76
156
|
* Low-level H2 request that takes raw URL + init, skipping Request construction.
|
|
@@ -11,8 +11,8 @@ const index_js_1 = require("../authentication/index.js");
|
|
|
11
11
|
const env_js_1 = require("../common/env.js");
|
|
12
12
|
const node_js_1 = require("../common/node.js");
|
|
13
13
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
14
|
-
const BUILD_VERSION = "0.2.
|
|
15
|
-
const BUILD_COMMIT = "
|
|
14
|
+
const BUILD_VERSION = "0.2.86-preview.161";
|
|
15
|
+
const BUILD_COMMIT = "a5bdac720290868983bb67563ff7068c1cf8c67f";
|
|
16
16
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
17
17
|
const BLAXEL_API_VERSION = "2026-04-16";
|
|
18
18
|
// Cache for config.yaml tracking value
|
|
@@ -232,6 +232,32 @@ class Settings {
|
|
|
232
232
|
}
|
|
233
233
|
return isDenoRuntime();
|
|
234
234
|
}
|
|
235
|
+
get maxConcurrentH2Requests() {
|
|
236
|
+
if (typeof this.config.maxConcurrentH2Requests === "number") {
|
|
237
|
+
return this.config.maxConcurrentH2Requests;
|
|
238
|
+
}
|
|
239
|
+
const value = env_js_1.env.BL_MAX_H2_INFLIGHT;
|
|
240
|
+
if (value) {
|
|
241
|
+
const parsed = parseInt(value, 10);
|
|
242
|
+
if (!Number.isNaN(parsed)) {
|
|
243
|
+
return parsed;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
get fsPartRetries() {
|
|
249
|
+
if (typeof this.config.fsPartRetries === "number") {
|
|
250
|
+
return this.config.fsPartRetries;
|
|
251
|
+
}
|
|
252
|
+
const value = env_js_1.env.BL_FS_PART_RETRIES;
|
|
253
|
+
if (value) {
|
|
254
|
+
const parsed = parseInt(value, 10);
|
|
255
|
+
if (!Number.isNaN(parsed)) {
|
|
256
|
+
return parsed;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
235
261
|
async authenticate() {
|
|
236
262
|
await this.credentials.authenticate();
|
|
237
263
|
}
|
|
@@ -9,6 +9,94 @@ const index_js_1 = require("../client/index.js");
|
|
|
9
9
|
const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // 5MB
|
|
10
10
|
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB per part
|
|
11
11
|
const MAX_PARALLEL_UPLOADS = 3; // Number of parallel part uploads
|
|
12
|
+
// Base backoff between part-upload retries, in milliseconds. Grows linearly
|
|
13
|
+
// per attempt and is jittered to avoid synchronized retries (thundering herd)
|
|
14
|
+
// when several parallel parts fail against the same edge at the same time.
|
|
15
|
+
const RETRY_BASE_DELAY_MS = 200;
|
|
16
|
+
// Markers that, when present anywhere in the error chain, are unambiguous
|
|
17
|
+
// signals of a transient HTTP/2 stream reset or connection drop. These are
|
|
18
|
+
// protocol/transport level codes, not application payloads, so substring
|
|
19
|
+
// matching them does not over-match a server-sent error body. Each entry is
|
|
20
|
+
// matched case-sensitively against the error message and its cause.
|
|
21
|
+
//
|
|
22
|
+
// Deliberately excluded: bare "INTERNAL_ERROR" and "fetch failed". Both are
|
|
23
|
+
// too generic on their own (an application 500 body or any failed fetch would
|
|
24
|
+
// match), so we only treat them as transient when paired with a transport
|
|
25
|
+
// error code on the cause (see isTransientUploadError).
|
|
26
|
+
const TRANSIENT_RESET_MARKERS = [
|
|
27
|
+
"ENHANCE_YOUR_CALM", // H2 flow-control backpressure reset
|
|
28
|
+
"NGHTTP2_INTERNAL_ERROR", // H2 internal stream error (qualified form)
|
|
29
|
+
"ERR_HTTP2", // node http2 error code family
|
|
30
|
+
"GOAWAY", // peer is draining the connection
|
|
31
|
+
"HTTP/2 session closed before response", // thrown by our own h2 transport
|
|
32
|
+
"HTTP/2 session sent GOAWAY before response",
|
|
33
|
+
];
|
|
34
|
+
// Node-level error codes (from `error.code` / `error.cause.code`) that mean
|
|
35
|
+
// the connection itself dropped mid-flight and the request never completed.
|
|
36
|
+
// These are safe to retry for an idempotent part upload.
|
|
37
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
38
|
+
"ECONNRESET",
|
|
39
|
+
"ECONNREFUSED",
|
|
40
|
+
"ETIMEDOUT",
|
|
41
|
+
"EPIPE",
|
|
42
|
+
"ERR_HTTP2_STREAM_ERROR",
|
|
43
|
+
"ERR_HTTP2_GOAWAY_SESSION",
|
|
44
|
+
"ERR_HTTP2_SESSION_ERROR",
|
|
45
|
+
]);
|
|
46
|
+
function collectErrorText(error) {
|
|
47
|
+
const messages = [];
|
|
48
|
+
const codes = [];
|
|
49
|
+
// Walk the error -> cause chain (bounded) so a transport error wrapped by a
|
|
50
|
+
// higher-level "fetch failed" is still classified correctly.
|
|
51
|
+
let current = error;
|
|
52
|
+
for (let depth = 0; depth < 5 && current && typeof current === "object"; depth++) {
|
|
53
|
+
const node = current;
|
|
54
|
+
if (typeof node.message === "string")
|
|
55
|
+
messages.push(node.message);
|
|
56
|
+
if (typeof node.code === "string")
|
|
57
|
+
codes.push(node.code);
|
|
58
|
+
current = node.cause;
|
|
59
|
+
}
|
|
60
|
+
return { messages, codes };
|
|
61
|
+
}
|
|
62
|
+
function isTransientUploadError(error) {
|
|
63
|
+
if (!error || typeof error !== "object") {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const { messages, codes } = collectErrorText(error);
|
|
67
|
+
// 1. An explicit transient transport error code anywhere in the chain.
|
|
68
|
+
if (codes.some((code) => TRANSIENT_ERROR_CODES.has(code))) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// 2. An unambiguous protocol-level reset marker in any message.
|
|
72
|
+
if (messages.some((text) => TRANSIENT_RESET_MARKERS.some((marker) => text.includes(marker)))) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
function nextRetryDelayMs(attempt) {
|
|
78
|
+
// Linear backoff (200ms, 400ms, ...) plus up to one extra base delay of
|
|
79
|
+
// random jitter so concurrent part retries do not all fire on the same tick.
|
|
80
|
+
const base = RETRY_BASE_DELAY_MS * attempt;
|
|
81
|
+
const jitter = Math.floor(Math.random() * RETRY_BASE_DELAY_MS);
|
|
82
|
+
return base + jitter;
|
|
83
|
+
}
|
|
84
|
+
async function retryOnTransient(fn) {
|
|
85
|
+
const retries = settings_js_1.settings.fsPartRetries; // 0 = off (current default behavior)
|
|
86
|
+
let attempt = 0;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try {
|
|
89
|
+
return await fn();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
attempt++;
|
|
93
|
+
if (retries <= 0 || attempt > retries || !isTransientUploadError(error)) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, nextRetryDelayMs(attempt)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
12
100
|
class SandboxFileSystem extends action_js_1.SandboxAction {
|
|
13
101
|
process;
|
|
14
102
|
constructor(sandbox, process) {
|
|
@@ -426,7 +514,7 @@ class SandboxFileSystem extends action_js_1.SandboxAction {
|
|
|
426
514
|
const start = (partNumber - 1) * CHUNK_SIZE;
|
|
427
515
|
const end = Math.min(start + CHUNK_SIZE, size);
|
|
428
516
|
const chunk = blob.slice(start, end);
|
|
429
|
-
batch.push(this.uploadPart(uploadId, partNumber, chunk));
|
|
517
|
+
batch.push(retryOnTransient(() => this.uploadPart(uploadId, partNumber, chunk)));
|
|
430
518
|
}
|
|
431
519
|
// Wait for batch to complete
|
|
432
520
|
const batchResults = await Promise.all(batch);
|
|
@@ -12,6 +12,16 @@ export type Config = {
|
|
|
12
12
|
apikey?: string;
|
|
13
13
|
workspace?: string;
|
|
14
14
|
disableH2?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Maximum number of concurrent in-flight HTTP/2 requests across the shared
|
|
17
|
+
* H2 session pool. `0` or `undefined` means unlimited (current behavior).
|
|
18
|
+
*/
|
|
19
|
+
maxConcurrentH2Requests?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Number of retry attempts for transient resets on multipart part uploads.
|
|
22
|
+
* `0` or `undefined` disables retry (current behavior).
|
|
23
|
+
*/
|
|
24
|
+
fsPartRetries?: number;
|
|
15
25
|
/**
|
|
16
26
|
* Client credentials for OAuth2 client_credentials flow.
|
|
17
27
|
*
|
|
@@ -51,6 +61,8 @@ declare class Settings {
|
|
|
51
61
|
get tracking(): boolean;
|
|
52
62
|
get region(): string | undefined;
|
|
53
63
|
get disableH2(): boolean;
|
|
64
|
+
get maxConcurrentH2Requests(): number;
|
|
65
|
+
get fsPartRetries(): number;
|
|
54
66
|
authenticate(): Promise<void>;
|
|
55
67
|
}
|
|
56
68
|
export declare const settings: Settings;
|