@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
|
@@ -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.
|
|
28
|
-
const BUILD_COMMIT = "
|
|
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
|
|
65
|
-
*
|
|
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
|
-
|
|
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
|
-
expect(vi.getTimerCount()).toBe(
|
|
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
|
+
});
|