@blaxel/core 0.2.87-preview.171 → 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.
Files changed (37) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/common/h2fetch.js +54 -17
  3. package/dist/cjs/common/settings.js +29 -3
  4. package/dist/cjs/common/transient-retry.js +145 -0
  5. package/dist/cjs/sandbox/drive/drive.js +11 -6
  6. package/dist/cjs/sandbox/filesystem/filesystem.js +126 -158
  7. package/dist/cjs/sandbox/process/process.js +29 -16
  8. package/dist/cjs/types/common/h2fetch.d.ts +7 -0
  9. package/dist/cjs/types/common/settings.d.ts +22 -2
  10. package/dist/cjs/types/common/transient-retry.d.ts +30 -0
  11. package/dist/cjs/types/sandbox/filesystem/filesystem.d.ts +3 -0
  12. package/dist/cjs-browser/.tsbuildinfo +1 -1
  13. package/dist/cjs-browser/common/h2fetch.js +1 -0
  14. package/dist/cjs-browser/common/settings.js +29 -3
  15. package/dist/cjs-browser/common/transient-retry.js +145 -0
  16. package/dist/cjs-browser/sandbox/drive/drive.js +11 -6
  17. package/dist/cjs-browser/sandbox/filesystem/filesystem.js +126 -158
  18. package/dist/cjs-browser/sandbox/process/process.js +29 -16
  19. package/dist/cjs-browser/types/common/h2fetch.d.ts +7 -0
  20. package/dist/cjs-browser/types/common/settings.d.ts +22 -2
  21. package/dist/cjs-browser/types/common/transient-retry.d.ts +30 -0
  22. package/dist/cjs-browser/types/sandbox/filesystem/filesystem.d.ts +3 -0
  23. package/dist/esm/.tsbuildinfo +1 -1
  24. package/dist/esm/common/h2fetch.js +53 -17
  25. package/dist/esm/common/settings.js +29 -3
  26. package/dist/esm/common/transient-retry.js +141 -0
  27. package/dist/esm/sandbox/drive/drive.js +11 -6
  28. package/dist/esm/sandbox/filesystem/filesystem.js +124 -157
  29. package/dist/esm/sandbox/process/process.js +29 -16
  30. package/dist/esm-browser/.tsbuildinfo +1 -1
  31. package/dist/esm-browser/common/h2fetch.js +1 -0
  32. package/dist/esm-browser/common/settings.js +29 -3
  33. package/dist/esm-browser/common/transient-retry.js +141 -0
  34. package/dist/esm-browser/sandbox/drive/drive.js +11 -6
  35. package/dist/esm-browser/sandbox/filesystem/filesystem.js +124 -157
  36. package/dist/esm-browser/sandbox/process/process.js +29 -16
  37. package/package.json +1 -1
@@ -3,3 +3,4 @@ export function createH2Fetch(session) { return (input) => globalThis.fetch(inpu
3
3
  export function createPoolBackedH2Fetch(pool, domain) { return (input) => globalThis.fetch(input); }
4
4
  export function h2RequestDirect(session, url, init) { return globalThis.fetch(url, init); }
5
5
  export function h2RequestDirectFromPool(pool, domain, url, init) { return globalThis.fetch(url, init); }
6
+ export async function withUploadSlot(domain, fn) { return fn(); }
@@ -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.171";
26
- const BUILD_COMMIT = "06530fbea3b8ff89d51d0b834de6725ad5ad80ee";
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 0;
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
- const { response, data, error } = await getDrivesMount(this.withClient({
37
- baseUrl: this.url,
38
- }));
39
- this.handleResponseError(response, data, error);
40
- const result = data;
41
- return result.mounts ?? [];
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
  }
@@ -1,98 +1,22 @@
1
1
  import { fs } from "../../common/node.js";
2
2
  import { settings } from "../../common/settings.js";
3
+ import { withUploadSlot } from "../../common/h2fetch.js";
4
+ import { isTransientResetError, retryOnTransientReset } from "../../common/transient-retry.js";
3
5
  import { SandboxAction } from "../action.js";
4
6
  import { deleteFilesystemByPath, deleteFilesystemMultipartByUploadIdAbort, getFilesystemByPath, getFilesystemContentSearchByPath, getFilesystemFindByPath, getFilesystemSearchByPath, getWatchFilesystemByPath, postFilesystemMultipartByUploadIdComplete, postFilesystemMultipartInitiateByPath, putFilesystemByPath, putFilesystemMultipartByUploadIdPart } from "../client/index.js";
5
7
  // Multipart upload constants
6
8
  const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // 5MB
7
9
  const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB per part
8
10
  const MAX_PARALLEL_UPLOADS = 3; // Number of parallel part uploads
9
- // Base backoff between part-upload retries, in milliseconds. Grows linearly
10
- // per attempt and is jittered to avoid synchronized retries (thundering herd)
11
- // when several parallel parts fail against the same edge at the same time.
12
- const RETRY_BASE_DELAY_MS = 200;
13
- // Markers that, when present anywhere in the error chain, are unambiguous
14
- // signals of a transient HTTP/2 stream reset or connection drop. These are
15
- // protocol/transport level codes, not application payloads, so substring
16
- // matching them does not over-match a server-sent error body. Each entry is
17
- // matched case-sensitively against the error message and its cause.
18
- //
19
- // Deliberately excluded: bare "INTERNAL_ERROR" and "fetch failed". Both are
20
- // too generic on their own (an application 500 body or any failed fetch would
21
- // match), so we only treat them as transient when paired with a transport
22
- // error code on the cause (see isTransientUploadError).
23
- const TRANSIENT_RESET_MARKERS = [
24
- "ENHANCE_YOUR_CALM", // H2 flow-control backpressure reset
25
- "NGHTTP2_INTERNAL_ERROR", // H2 internal stream error (qualified form)
26
- "ERR_HTTP2", // node http2 error code family
27
- "GOAWAY", // peer is draining the connection
28
- "HTTP/2 session closed before response", // thrown by our own h2 transport
29
- "HTTP/2 session sent GOAWAY before response",
30
- ];
31
- // Node-level error codes (from `error.code` / `error.cause.code`) that mean
32
- // the connection itself dropped mid-flight and the request never completed.
33
- // These are safe to retry for an idempotent part upload.
34
- const TRANSIENT_ERROR_CODES = new Set([
35
- "ECONNRESET",
36
- "ECONNREFUSED",
37
- "ETIMEDOUT",
38
- "EPIPE",
39
- "ERR_HTTP2_STREAM_ERROR",
40
- "ERR_HTTP2_GOAWAY_SESSION",
41
- "ERR_HTTP2_SESSION_ERROR",
42
- ]);
43
- function collectErrorText(error) {
44
- const messages = [];
45
- const codes = [];
46
- // Walk the error -> cause chain (bounded) so a transport error wrapped by a
47
- // higher-level "fetch failed" is still classified correctly.
48
- let current = error;
49
- for (let depth = 0; depth < 5 && current && typeof current === "object"; depth++) {
50
- const node = current;
51
- if (typeof node.message === "string")
52
- messages.push(node.message);
53
- if (typeof node.code === "string")
54
- codes.push(node.code);
55
- current = node.cause;
56
- }
57
- return { messages, codes };
58
- }
59
- function isTransientUploadError(error) {
60
- if (!error || typeof error !== "object") {
61
- return false;
62
- }
63
- const { messages, codes } = collectErrorText(error);
64
- // 1. An explicit transient transport error code anywhere in the chain.
65
- if (codes.some((code) => TRANSIENT_ERROR_CODES.has(code))) {
66
- return true;
67
- }
68
- // 2. An unambiguous protocol-level reset marker in any message.
69
- if (messages.some((text) => TRANSIENT_RESET_MARKERS.some((marker) => text.includes(marker)))) {
70
- return true;
71
- }
72
- return false;
73
- }
74
- function nextRetryDelayMs(attempt) {
75
- // Linear backoff (200ms, 400ms, ...) plus up to one extra base delay of
76
- // random jitter so concurrent part retries do not all fire on the same tick.
77
- const base = RETRY_BASE_DELAY_MS * attempt;
78
- const jitter = Math.floor(Math.random() * RETRY_BASE_DELAY_MS);
79
- return base + jitter;
80
- }
81
- async function retryOnTransient(fn) {
82
- const retries = settings.fsPartRetries; // 0 = off (current default behavior)
83
- let attempt = 0;
84
- for (;;) {
85
- try {
86
- return await fn();
87
- }
88
- catch (error) {
89
- attempt++;
90
- if (retries <= 0 || attempt > retries || !isTransientUploadError(error)) {
91
- throw error;
92
- }
93
- await new Promise((resolve) => setTimeout(resolve, nextRetryDelayMs(attempt)));
94
- }
95
- }
11
+ // The transient-reset classifier and retry loop live in
12
+ // common/transient-retry.ts, shared with the idempotent sandbox-op retry
13
+ // (read/list/etc.) so every path judges "transient" identically. These aliases
14
+ // preserve the import surface the ENG-2680 fault tests already depend on.
15
+ export const isTransientUploadError = isTransientResetError;
16
+ export function retryOnTransient(fn) {
17
+ // Upload path keeps its own (lower) fsPartRetries budget; the shared helper
18
+ // otherwise defaults to the higher idempotent-read budget (sandboxReadRetries).
19
+ return retryOnTransientReset(fn, { retries: settings.fsPartRetries });
96
20
  }
97
21
  export class SandboxFileSystem extends SandboxAction {
98
22
  process;
@@ -174,28 +98,44 @@ export class SandboxFileSystem extends SandboxAction {
174
98
  if (fileBlob.size > MULTIPART_THRESHOLD) {
175
99
  return await this.uploadWithMultipart(path, fileBlob, "0644");
176
100
  }
177
- // Use regular upload for small files
178
- const formData = new FormData();
179
- formData.append("file", fileBlob, "test-binary.bin");
180
- formData.append("permissions", "0644");
181
- formData.append("path", path);
182
- // Build URL
101
+ // Use regular upload for small files. Run the PUT under the same upload
102
+ // reliability wrapper as multipart parts: bound concurrency (ENG-2680) and
103
+ // retry transient connection resets (ECONNRESET/GOAWAY/ENHANCE_YOUR_CALM). A
104
+ // PUT of the same bytes to the same path is idempotent, so retry is safe.
105
+ // The FormData is rebuilt per attempt so a retried request has a fresh body.
183
106
  let url = `${this.url}/filesystem/${path}`;
184
107
  if (this.forcedUrl) {
185
108
  url = `${this.forcedUrl.toString()}/filesystem/${path}`;
186
109
  }
187
- const response = await this.h2Fetch(url, {
188
- method: 'PUT',
189
- headers: {
190
- ...settings.headers,
191
- },
192
- body: formData,
193
- });
194
- if (!response.ok) {
195
- const errorText = await response.text();
196
- throw new Error(`Failed to write binary: ${response.status} ${errorText}`);
197
- }
198
- return await response.json();
110
+ const h2Domain = this.sandbox?.h2Domain;
111
+ const putOnce = async () => {
112
+ const formData = new FormData();
113
+ formData.append("file", fileBlob, "test-binary.bin");
114
+ formData.append("permissions", "0644");
115
+ formData.append("path", path);
116
+ // A forceUrl (session-token) sandbox carries its own headers and must not
117
+ // require global credentials; settings.headers now throws without them
118
+ // (ENG-2698). Mirror streamLogs/execWithStreaming, which already pick
119
+ // sandbox.headers for forceUrl sessions.
120
+ const headers = this.sandbox.forceUrl ? this.sandbox.headers : settings.headers;
121
+ const response = await this.h2Fetch(url, {
122
+ method: 'PUT',
123
+ headers: {
124
+ ...headers,
125
+ },
126
+ body: formData,
127
+ });
128
+ if (!response.ok) {
129
+ const errorText = await response.text();
130
+ throw new Error(`Failed to write binary: ${response.status} ${errorText}`);
131
+ }
132
+ return await response.json();
133
+ };
134
+ // Acquire the upload slot per-attempt INSIDE the retry so the per-domain cap
135
+ // bounds in-flight streams, not retry sequences: the slot is released during
136
+ // backoff, so a failing PUT never pins a slot while it sleeps (Mendral review).
137
+ const putWithSlot = h2Domain ? () => withUploadSlot(h2Domain, putOnce) : putOnce;
138
+ return retryOnTransient(putWithSlot);
199
139
  }
200
140
  async writeTree(files, destinationPath = null) {
201
141
  const options = {
@@ -220,30 +160,37 @@ export class SandboxFileSystem extends SandboxAction {
220
160
  }
221
161
  async read(path) {
222
162
  path = this.formatPath(path);
223
- const { response, data, error } = await getFilesystemByPath(this.withClient({
224
- path: { path },
225
- baseUrl: this.url,
226
- }));
227
- this.handleResponseError(response, data, error);
228
- if (data && 'content' in data) {
229
- return data.content;
230
- }
231
- throw new Error("Unsupported file type");
163
+ // Idempotent GET: self-heal a transient connection reset (e.g. first call
164
+ // after sandbox resume). Non-transient errors (404, etc.) are not retried.
165
+ return retryOnTransientReset(async () => {
166
+ const { response, data, error } = await getFilesystemByPath(this.withClient({
167
+ path: { path },
168
+ baseUrl: this.url,
169
+ }));
170
+ this.handleResponseError(response, data, error);
171
+ if (data && 'content' in data) {
172
+ return data.content;
173
+ }
174
+ throw new Error("Unsupported file type");
175
+ });
232
176
  }
233
177
  async readBinary(path) {
234
178
  path = this.formatPath(path);
235
- const { response, data, error } = await getFilesystemByPath(this.withClient({
236
- path: { path },
237
- baseUrl: this.url,
238
- headers: {
239
- 'Accept': 'application/octet-stream',
240
- },
241
- }));
242
- this.handleResponseError(response, data, error);
243
- if (typeof data === 'string') {
244
- return new Blob([data]);
245
- }
246
- return data;
179
+ // Idempotent GET: self-heal a transient connection reset.
180
+ return retryOnTransientReset(async () => {
181
+ const { response, data, error } = await getFilesystemByPath(this.withClient({
182
+ path: { path },
183
+ baseUrl: this.url,
184
+ headers: {
185
+ 'Accept': 'application/octet-stream',
186
+ },
187
+ }));
188
+ this.handleResponseError(response, data, error);
189
+ if (typeof data === 'string') {
190
+ return new Blob([data]);
191
+ }
192
+ return data;
193
+ });
247
194
  }
248
195
  async download(src, destinationPath, { mode = 0o644 } = {}) {
249
196
  if (!fs) {
@@ -266,15 +213,19 @@ export class SandboxFileSystem extends SandboxAction {
266
213
  }
267
214
  async ls(path) {
268
215
  path = this.formatPath(path);
269
- const { response, data, error } = await getFilesystemByPath(this.withClient({
270
- path: { path },
271
- baseUrl: this.url,
272
- }));
273
- this.handleResponseError(response, data, error);
274
- if (!data || !('files' in data || 'subdirectories' in data)) {
275
- throw new Error(JSON.stringify({ error: "Directory not found" }));
276
- }
277
- return data;
216
+ // Idempotent GET: self-heal a transient connection reset (the file-tree
217
+ // refresh that customers hit as intermittent 500s after sandbox resume).
218
+ return retryOnTransientReset(async () => {
219
+ const { response, data, error } = await getFilesystemByPath(this.withClient({
220
+ path: { path },
221
+ baseUrl: this.url,
222
+ }));
223
+ this.handleResponseError(response, data, error);
224
+ if (!data || !('files' in data || 'subdirectories' in data)) {
225
+ throw new Error(JSON.stringify({ error: "Directory not found" }));
226
+ }
227
+ return data;
228
+ });
278
229
  }
279
230
  async search(query, path = "/", options) {
280
231
  const formattedPath = this.formatPath(path);
@@ -291,13 +242,15 @@ export class SandboxFileSystem extends SandboxAction {
291
242
  if (options?.excludeHidden !== undefined) {
292
243
  queryParams.excludeHidden = options.excludeHidden;
293
244
  }
294
- const result = await getFilesystemSearchByPath(this.withClient({
295
- path: { path: formattedPath },
296
- query: queryParams,
297
- baseUrl: this.url,
298
- }));
299
- this.handleResponseError(result.response, result.data, result.error);
300
- return result.data;
245
+ return retryOnTransientReset(async () => {
246
+ const result = await getFilesystemSearchByPath(this.withClient({
247
+ path: { path: formattedPath },
248
+ query: queryParams,
249
+ baseUrl: this.url,
250
+ }));
251
+ this.handleResponseError(result.response, result.data, result.error);
252
+ return result.data;
253
+ });
301
254
  }
302
255
  async find(path, options) {
303
256
  const formattedPath = this.formatPath(path);
@@ -317,13 +270,15 @@ export class SandboxFileSystem extends SandboxAction {
317
270
  if (options?.excludeHidden !== undefined) {
318
271
  queryParams.excludeHidden = options.excludeHidden;
319
272
  }
320
- const result = await getFilesystemFindByPath(this.withClient({
321
- path: { path: formattedPath },
322
- query: queryParams,
323
- baseUrl: this.url,
324
- }));
325
- this.handleResponseError(result.response, result.data, result.error);
326
- return result.data;
273
+ return retryOnTransientReset(async () => {
274
+ const result = await getFilesystemFindByPath(this.withClient({
275
+ path: { path: formattedPath },
276
+ query: queryParams,
277
+ baseUrl: this.url,
278
+ }));
279
+ this.handleResponseError(result.response, result.data, result.error);
280
+ return result.data;
281
+ });
327
282
  }
328
283
  async grep(query, path = "/", options) {
329
284
  const formattedPath = this.formatPath(path);
@@ -345,13 +300,15 @@ export class SandboxFileSystem extends SandboxAction {
345
300
  if (options?.excludeDirs && options.excludeDirs.length > 0) {
346
301
  queryParams.excludeDirs = options.excludeDirs.join(',');
347
302
  }
348
- const result = await getFilesystemContentSearchByPath(this.withClient({
349
- path: { path: formattedPath },
350
- query: queryParams,
351
- baseUrl: this.url,
352
- }));
353
- this.handleResponseError(result.response, result.data, result.error);
354
- return result.data;
303
+ return retryOnTransientReset(async () => {
304
+ const result = await getFilesystemContentSearchByPath(this.withClient({
305
+ path: { path: formattedPath },
306
+ query: queryParams,
307
+ baseUrl: this.url,
308
+ }));
309
+ this.handleResponseError(result.response, result.data, result.error);
310
+ return result.data;
311
+ });
355
312
  }
356
313
  async cp(source, destination, { maxWait = 180000 } = {}) {
357
314
  let process = await this.process.exec({
@@ -503,6 +460,12 @@ export class SandboxFileSystem extends SandboxAction {
503
460
  const size = blob.size;
504
461
  const numParts = Math.ceil(size / CHUNK_SIZE);
505
462
  const parts = [];
463
+ // Bound concurrent upload-part streams on the shared H2 connection so many
464
+ // parts (within and across files) cannot burst past the server's
465
+ // rapid-reset limit (ENG-2680). Scoped to uploads; default cap is 2. With
466
+ // no h2Domain the parts go over globalThis.fetch on separate connections,
467
+ // so the shared-connection cap does not apply.
468
+ const h2Domain = this.sandbox?.h2Domain;
506
469
  // Upload parts in batches for parallel processing
507
470
  for (let i = 0; i < numParts; i += MAX_PARALLEL_UPLOADS) {
508
471
  const batch = [];
@@ -511,7 +474,11 @@ export class SandboxFileSystem extends SandboxAction {
511
474
  const start = (partNumber - 1) * CHUNK_SIZE;
512
475
  const end = Math.min(start + CHUNK_SIZE, size);
513
476
  const chunk = blob.slice(start, end);
514
- batch.push(retryOnTransient(() => this.uploadPart(uploadId, partNumber, chunk)));
477
+ // Slot acquired per-attempt inside the retry (see writeBinary): bounds
478
+ // concurrent part streams, not retry sequences; freed during backoff.
479
+ const doPart = () => this.uploadPart(uploadId, partNumber, chunk);
480
+ const partWithSlot = h2Domain ? () => withUploadSlot(h2Domain, doPart) : doPart;
481
+ batch.push(retryOnTransient(partWithSlot));
515
482
  }
516
483
  // Wait for batch to complete
517
484
  const batchResults = await Promise.all(batch);
@@ -1,5 +1,6 @@
1
1
  import { settings } from "../../common/settings.js";
2
2
  import { SandboxAction } from "../action.js";
3
+ import { retryOnTransientReset } from "../../common/transient-retry.js";
3
4
  import { deleteProcessByIdentifier, deleteProcessByIdentifierKill, getProcess, getProcessByIdentifier, getProcessByIdentifierLogs, postProcess } from "../client/index.js";
4
5
  export class SandboxProcess extends SandboxAction {
5
6
  constructor(sandbox) {
@@ -299,19 +300,27 @@ export class SandboxProcess extends SandboxAction {
299
300
  return data;
300
301
  }
301
302
  async get(identifier) {
302
- const { response, data, error } = await getProcessByIdentifier(this.withClient({
303
- path: { identifier },
304
- baseUrl: this.url,
305
- }));
306
- this.handleResponseError(response, data, error);
307
- return data;
303
+ // Idempotent GET: self-heal a transient connection reset (also makes the
304
+ // wait() poll loop resilient). exec() stays un-retried — it is a
305
+ // non-idempotent POST and retrying it duplicates the process (ENG-2340).
306
+ return retryOnTransientReset(async () => {
307
+ const { response, data, error } = await getProcessByIdentifier(this.withClient({
308
+ path: { identifier },
309
+ baseUrl: this.url,
310
+ }));
311
+ this.handleResponseError(response, data, error);
312
+ return data;
313
+ });
308
314
  }
309
315
  async list() {
310
- const { response, data, error } = await getProcess(this.withClient({
311
- baseUrl: this.url,
312
- }));
313
- this.handleResponseError(response, data, error);
314
- return data;
316
+ // Idempotent GET: self-heal a transient connection reset.
317
+ return retryOnTransientReset(async () => {
318
+ const { response, data, error } = await getProcess(this.withClient({
319
+ baseUrl: this.url,
320
+ }));
321
+ this.handleResponseError(response, data, error);
322
+ return data;
323
+ });
315
324
  }
316
325
  async stop(identifier) {
317
326
  const { response, data, error } = await deleteProcessByIdentifier(this.withClient({
@@ -330,11 +339,15 @@ export class SandboxProcess extends SandboxAction {
330
339
  return data;
331
340
  }
332
341
  async logs(identifier, type = "all") {
333
- const { response, data, error } = await getProcessByIdentifierLogs(this.withClient({
334
- path: { identifier },
335
- baseUrl: this.url,
336
- }));
337
- this.handleResponseError(response, data, error);
342
+ // Idempotent GET: self-heal a transient connection reset.
343
+ const data = await retryOnTransientReset(async () => {
344
+ const { response, data, error } = await getProcessByIdentifierLogs(this.withClient({
345
+ path: { identifier },
346
+ baseUrl: this.url,
347
+ }));
348
+ this.handleResponseError(response, data, error);
349
+ return data;
350
+ });
338
351
  if (type === "all") {
339
352
  return data?.logs || "";
340
353
  }