@blaxel/core 0.3.35-preview.342 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/common/settings.js +2 -2
- package/dist/cjs/sandbox/action.js +2 -2
- package/dist/cjs/sandbox/process/process.js +48 -1
- package/dist/cjs/sandbox/process/process.test.js +2 -0
- package/dist/cjs/sandbox/process/process.wait.test.js +93 -2
- package/dist/cjs/types/sandbox/action.d.ts +2 -2
- package/dist/cjs/types/sandbox/process/process.d.ts +2 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/settings.js +2 -2
- package/dist/cjs-browser/sandbox/action.js +2 -2
- package/dist/cjs-browser/sandbox/process/process.js +48 -1
- package/dist/cjs-browser/sandbox/process/process.test.js +2 -0
- package/dist/cjs-browser/sandbox/process/process.wait.test.js +93 -2
- package/dist/cjs-browser/types/sandbox/action.d.ts +2 -2
- package/dist/cjs-browser/types/sandbox/process/process.d.ts +2 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/settings.js +2 -2
- package/dist/esm/sandbox/action.js +2 -2
- package/dist/esm/sandbox/process/process.js +48 -1
- package/dist/esm/sandbox/process/process.test.js +2 -0
- package/dist/esm/sandbox/process/process.wait.test.js +93 -2
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/settings.js +2 -2
- package/dist/esm-browser/sandbox/action.js +2 -2
- package/dist/esm-browser/sandbox/process/process.js +48 -1
- package/dist/esm-browser/sandbox/process/process.test.js +2 -0
- package/dist/esm-browser/sandbox/process/process.wait.test.js +93 -2
- package/package.json +2 -2
|
@@ -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.
|
|
34
|
-
const BUILD_COMMIT = "
|
|
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
|
|
71
|
-
*
|
|
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
|
-
|
|
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(
|
|
88
|
-
(0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(
|
|
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
|
|
17
|
-
*
|
|
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;
|