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