@blaxel/core 0.3.35-preview.343 → 0.3.35-preview.344

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 (29) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/common/settings.js +2 -2
  3. package/dist/cjs/sandbox/action.js +2 -2
  4. package/dist/cjs/sandbox/process/process.js +48 -1
  5. package/dist/cjs/sandbox/process/process.test.js +2 -0
  6. package/dist/cjs/sandbox/process/process.wait.test.js +93 -2
  7. package/dist/cjs/types/sandbox/action.d.ts +2 -2
  8. package/dist/cjs/types/sandbox/process/process.d.ts +2 -0
  9. package/dist/cjs-browser/.tsbuildinfo +1 -1
  10. package/dist/cjs-browser/common/settings.js +2 -2
  11. package/dist/cjs-browser/sandbox/action.js +2 -2
  12. package/dist/cjs-browser/sandbox/process/process.js +48 -1
  13. package/dist/cjs-browser/sandbox/process/process.test.js +2 -0
  14. package/dist/cjs-browser/sandbox/process/process.wait.test.js +93 -2
  15. package/dist/cjs-browser/types/sandbox/action.d.ts +2 -2
  16. package/dist/cjs-browser/types/sandbox/process/process.d.ts +2 -0
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/common/settings.js +2 -2
  19. package/dist/esm/sandbox/action.js +2 -2
  20. package/dist/esm/sandbox/process/process.js +48 -1
  21. package/dist/esm/sandbox/process/process.test.js +2 -0
  22. package/dist/esm/sandbox/process/process.wait.test.js +93 -2
  23. package/dist/esm-browser/.tsbuildinfo +1 -1
  24. package/dist/esm-browser/common/settings.js +2 -2
  25. package/dist/esm-browser/sandbox/action.js +2 -2
  26. package/dist/esm-browser/sandbox/process/process.js +48 -1
  27. package/dist/esm-browser/sandbox/process/process.test.js +2 -0
  28. package/dist/esm-browser/sandbox/process/process.wait.test.js +93 -2
  29. package/package.json +1 -1
@@ -30,8 +30,8 @@ function missingCredentialsMessage() {
30
30
  return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
31
31
  }
32
32
  // Build info - these placeholders are replaced at build time by build:replace-imports
33
- const BUILD_VERSION = "0.3.35-preview.343";
34
- const BUILD_COMMIT = "e66e8bf5e0039bfc9727326f22348a237c42d737";
33
+ const BUILD_VERSION = "0.3.35-preview.344";
34
+ const BUILD_COMMIT = "5f8cd5a9c6ba61ebc883d5c583b198b67da4167d";
35
35
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
36
36
  const BLAXEL_API_VERSION = "2026-09-22";
37
37
  // Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
@@ -67,8 +67,8 @@ exports.ResponseError = ResponseError;
67
67
  /**
68
68
  * Thrown when the edge/CDN in front of the sandbox returns a gateway status
69
69
  * (502/503/504) — the request never got a usable answer from the sandbox
70
- * itself (it is waking from standby, the command outran the edge's ~60s
71
- * origin-read timeout, or the edge could not reach origin). Safe to retry on an
70
+ * itself (it is waking from standby, the request outran the edge, which cuts
71
+ * held requests at 120s, or the edge could not reach origin). Safe to retry on an
72
72
  * idempotent operation. Catch it with `err instanceof SandboxGatewayError` or
73
73
  * `isGatewayTimeout(err)`.
74
74
  */
@@ -4,6 +4,11 @@ exports.SandboxProcess = void 0;
4
4
  const action_js_1 = require("../action.js");
5
5
  const transient_retry_js_1 = require("../../common/transient-retry.js");
6
6
  const index_js_1 = require("../client/index.js");
7
+ // Server-side hold per long-poll: just under the gateway's 120s cut on held requests.
8
+ const PROCESS_WAIT_MAX_SECONDS = 119;
9
+ // Client-side slack on top of the hold before a wait request counts as stalled.
10
+ const PROCESS_WAIT_GRACE_SECONDS = 10;
11
+ const waitSeconds = (remainingMs) => Math.max(1, Math.ceil(Math.min(PROCESS_WAIT_MAX_SECONDS, remainingMs / 1000)));
7
12
  class SandboxProcess extends action_js_1.SandboxAction {
8
13
  constructor(sandbox) {
9
14
  super(sandbox);
@@ -279,13 +284,19 @@ class SandboxProcess extends action_js_1.SandboxAction {
279
284
  // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
280
285
  controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
281
286
  });
287
+ let longPoll = true;
282
288
  const poll = async () => {
283
289
  while (true) {
284
290
  if (performance.now() >= deadline)
285
291
  controller.abort(timeoutError());
286
292
  controller.signal.throwIfAborted();
287
293
  try {
288
- const result = await this.get(identifier, { signal: controller.signal, retry: false });
294
+ let result = longPoll ? await this.waitOnce(identifier, waitSeconds(deadline - performance.now()), controller.signal) : undefined;
295
+ if (!result) {
296
+ // Sandbox older than the /wait route: poll for the rest of this call.
297
+ longPoll = false;
298
+ result = await this.get(identifier, { signal: controller.signal, retry: false });
299
+ }
289
300
  if (performance.now() >= deadline)
290
301
  controller.abort(timeoutError());
291
302
  controller.signal.throwIfAborted();
@@ -317,6 +328,42 @@ class SandboxProcess extends action_js_1.SandboxAction {
317
328
  signal?.removeEventListener("abort", cancel);
318
329
  }
319
330
  }
331
+ /** One long-poll on GET /process/{identifier}/wait; undefined when the sandbox predates the route. */
332
+ async waitOnce(identifier, timeout, signal) {
333
+ // A request stalled past hold + 10s is dropped and retried by wait() like a lost connection.
334
+ // ponytail: AbortController + setTimeout, AbortSignal.any needs Node 20.3+ and engines allows 18.
335
+ const request = new AbortController();
336
+ let stalled = false;
337
+ const timer = setTimeout(() => { stalled = true; request.abort(); }, (timeout + PROCESS_WAIT_GRACE_SECONDS) * 1000);
338
+ // Clear here too: a transport that ignores abort never reaches the finally below.
339
+ const forward = () => { clearTimeout(timer); request.abort(signal.reason); };
340
+ signal.addEventListener("abort", forward, { once: true });
341
+ try {
342
+ // Not in the generated client yet (ENG-6233): same call shape as getProcessByIdentifier.
343
+ const { response, data, error } = await this.client.get({
344
+ security: [{ scheme: "bearer", type: "http" }],
345
+ url: "/process/{identifier}/wait",
346
+ path: { identifier },
347
+ query: { timeout },
348
+ baseUrl: this.url,
349
+ signal: request.signal,
350
+ });
351
+ // gin answers an unknown route with a plain-text 404; the endpoint's own 404 is JSON.
352
+ if (response.status === 404 && typeof error === "string")
353
+ return undefined;
354
+ this.handleResponseError(response, data, error);
355
+ return data;
356
+ }
357
+ catch (error) {
358
+ if (stalled && !signal.aborted)
359
+ throw new TypeError("fetch failed", { cause: error });
360
+ throw error;
361
+ }
362
+ finally {
363
+ clearTimeout(timer);
364
+ signal.removeEventListener("abort", forward);
365
+ }
366
+ }
320
367
  async get(identifier, { signal, retry = true } = {}) {
321
368
  const read = async () => {
322
369
  const { response, data, error } = await (0, index_js_1.getProcessByIdentifier)(this.withClient({
@@ -11,6 +11,8 @@ const process_js_1 = require("./process.js");
11
11
  const process = Object.create(process_js_1.SandboxProcess.prototype);
12
12
  const completed = { status: "completed" };
13
13
  const get = vitest_1.vi.spyOn(process, "get").mockResolvedValue(completed);
14
+ // Sandbox without the /wait route: the read goes through the polling fallback.
15
+ Object.assign(process, { waitOnce: () => Promise.resolve(undefined) });
14
16
  await (0, vitest_1.expect)(process.wait("process-id", { interval: 5000 })).resolves.toBe(completed);
15
17
  (0, vitest_1.expect)(get).toHaveBeenCalledTimes(1);
16
18
  (0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
@@ -84,8 +84,8 @@ const response = (status) => new Response(JSON.stringify({ status, exitCode: sta
84
84
  const controller = new AbortController();
85
85
  const reason = new Error("caller cancelled");
86
86
  const assertion = (0, vitest_1.expect)(api().wait("original", { maxWait: -1, signal: controller.signal })).rejects.toBe(reason);
87
- await vitest_1.vi.advanceTimersByTimeAsync(86_400_000);
88
- (0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
87
+ await vitest_1.vi.advanceTimersByTimeAsync(60_000);
88
+ (0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(1); // Only the per-request stall timer.
89
89
  controller.abort(reason);
90
90
  await assertion;
91
91
  (0, vitest_1.expect)(fetch.mock.calls[0][0].signal.aborted).toBe(true);
@@ -119,3 +119,94 @@ const response = (status) => new Response(JSON.stringify({ status, exitCode: sta
119
119
  await (0, vitest_1.expect)(api().wait("original", { maxWait: 25 })).rejects.toThrow("Process did not finish in time");
120
120
  });
121
121
  });
122
+ (0, vitest_1.describe)("process wait long-poll (GET /process/{id}/wait)", () => {
123
+ const requested = (fetch) => fetch.mock.calls.map(call => new URL(call[0].url));
124
+ const missingRoute = () => new Response("404 page not found", { status: 404, headers: { "content-type": "text/plain" } });
125
+ (0, vitest_1.it)("finishes on the first wait call", async () => {
126
+ const fetch = vitest_1.vi.fn().mockResolvedValue(response("completed"));
127
+ vitest_1.vi.stubGlobal("fetch", fetch);
128
+ (0, vitest_1.expect)((await api().wait("original")).status).toBe("completed");
129
+ const [url] = requested(fetch);
130
+ (0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
131
+ (0, vitest_1.expect)(url.pathname).toBe("/process/original/wait");
132
+ (0, vitest_1.expect)(url.searchParams.get("timeout")).toBe("60"); // default maxWait 60000 ms
133
+ });
134
+ (0, vitest_1.it)("calls /wait again while the process is running", async () => {
135
+ const fetch = vitest_1.vi.fn()
136
+ .mockResolvedValueOnce(response("running"))
137
+ .mockResolvedValueOnce(response("running"))
138
+ .mockResolvedValue(response("failed"));
139
+ vitest_1.vi.stubGlobal("fetch", fetch);
140
+ (0, vitest_1.expect)((await api().wait("original", { maxWait: -1, interval: 1 })).exitCode).toBe(7);
141
+ const urls = requested(fetch);
142
+ (0, vitest_1.expect)(urls.map(u => u.pathname)).toEqual(Array(3).fill("/process/original/wait"));
143
+ (0, vitest_1.expect)(urls.every(u => u.searchParams.get("timeout") === "119")).toBe(true);
144
+ });
145
+ (0, vitest_1.it)("falls back to polling for the rest of the call on gin's plain-text 404", async () => {
146
+ const fetch = vitest_1.vi.fn()
147
+ .mockResolvedValueOnce(missingRoute())
148
+ .mockResolvedValueOnce(response("running"))
149
+ .mockResolvedValue(response("completed"));
150
+ vitest_1.vi.stubGlobal("fetch", fetch);
151
+ (0, vitest_1.expect)((await api().wait("original", { interval: 1 })).status).toBe("completed");
152
+ (0, vitest_1.expect)(requested(fetch).map(u => u.pathname)).toEqual(["/process/original/wait", "/process/original", "/process/original"]);
153
+ });
154
+ (0, vitest_1.it)("treats a JSON 404 from /wait as process not found", async () => {
155
+ const fetch = vitest_1.vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: "process not found" }), { status: 404, headers: { "content-type": "application/json" } }));
156
+ vitest_1.vi.stubGlobal("fetch", fetch);
157
+ await (0, vitest_1.expect)(api().wait("original")).rejects.toMatchObject({ status: 404 });
158
+ (0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
159
+ });
160
+ (0, vitest_1.it)("caps the server hold at what is left of maxWait and still times out", async () => {
161
+ vitest_1.vi.useFakeTimers();
162
+ const fetch = vitest_1.vi.fn().mockImplementation(() => Promise.resolve(response("running")));
163
+ vitest_1.vi.stubGlobal("fetch", fetch);
164
+ const assertion = (0, vitest_1.expect)(api().wait("original", { maxWait: 1500, interval: 100 })).rejects.toThrow("did not finish in time");
165
+ await vitest_1.vi.advanceTimersByTimeAsync(1500);
166
+ await assertion;
167
+ const holds = requested(fetch).map(u => Number(u.searchParams.get("timeout")));
168
+ (0, vitest_1.expect)(holds[0]).toBe(2);
169
+ (0, vitest_1.expect)(holds.every(h => h >= 1 && h <= 2)).toBe(true);
170
+ (0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
171
+ });
172
+ (0, vitest_1.it)("does not abort a held request before the server answers", async () => {
173
+ // The per-request timeout is hold + 10s, so a request held for the full 119s is not aborted.
174
+ vitest_1.vi.useFakeTimers();
175
+ let answer;
176
+ const fetch = vitest_1.vi.fn(() => new Promise(resolve => { answer = resolve; }));
177
+ vitest_1.vi.stubGlobal("fetch", fetch);
178
+ const waiting = api().wait("original", { maxWait: -1 });
179
+ await vitest_1.vi.advanceTimersByTimeAsync(119_000);
180
+ (0, vitest_1.expect)(fetch.mock.calls[0][0].signal.aborted).toBe(false);
181
+ answer(response("completed"));
182
+ (0, vitest_1.expect)((await waiting).status).toBe("completed");
183
+ });
184
+ (0, vitest_1.it)("drops a /wait request stalled past hold + 10s and retries it", async () => {
185
+ vitest_1.vi.useFakeTimers();
186
+ const hang = (request) => new Promise((_, reject) => {
187
+ request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true });
188
+ });
189
+ const fetch = vitest_1.vi.fn().mockImplementationOnce(hang).mockResolvedValue(response("completed"));
190
+ vitest_1.vi.stubGlobal("fetch", fetch);
191
+ const waiting = api().wait("original", { maxWait: -1, interval: 1 });
192
+ await vitest_1.vi.advanceTimersByTimeAsync(128_999);
193
+ (0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
194
+ await vitest_1.vi.advanceTimersByTimeAsync(10);
195
+ (0, vitest_1.expect)((await waiting).status).toBe("completed");
196
+ (0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(2);
197
+ (0, vitest_1.expect)(fetch.mock.calls[0][0].signal.aborted).toBe(true);
198
+ (0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
199
+ });
200
+ (0, vitest_1.it)("a short maxWait times out before the per-request stall timer, leaving no timers", async () => {
201
+ vitest_1.vi.useFakeTimers();
202
+ const hang = (request) => new Promise((_, reject) => {
203
+ request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true });
204
+ });
205
+ vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn(hang));
206
+ // maxWait 5s: hold 5s, stall timer 15s, so the overall deadline fires first.
207
+ const assertion = (0, vitest_1.expect)(api().wait("original", { maxWait: 5_000 })).rejects.toThrow("did not finish in time");
208
+ await vitest_1.vi.advanceTimersByTimeAsync(5_000);
209
+ await assertion;
210
+ (0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
211
+ });
212
+ });
@@ -13,8 +13,8 @@ export declare class ResponseError extends Error {
13
13
  /**
14
14
  * Thrown when the edge/CDN in front of the sandbox returns a gateway status
15
15
  * (502/503/504) — the request never got a usable answer from the sandbox
16
- * itself (it is waking from standby, the command outran the edge's ~60s
17
- * origin-read timeout, or the edge could not reach origin). Safe to retry on an
16
+ * itself (it is waking from standby, the request outran the edge, which cuts
17
+ * held requests at 120s, or the edge could not reach origin). Safe to retry on an
18
18
  * idempotent operation. Catch it with `err instanceof SandboxGatewayError` or
19
19
  * `isGatewayTimeout(err)`.
20
20
  */
@@ -21,6 +21,8 @@ export declare class SandboxProcess extends SandboxAction {
21
21
  interval?: number;
22
22
  signal?: AbortSignal;
23
23
  }): Promise<GetProcessByIdentifierResponse>;
24
+ /** One long-poll on GET /process/{identifier}/wait; undefined when the sandbox predates the route. */
25
+ private waitOnce;
24
26
  get(identifier: string, { signal, retry }?: {
25
27
  signal?: AbortSignal;
26
28
  retry?: boolean;