@blaxel/core 0.3.22-preview.296 → 0.3.22-preview.298

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 (33) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/common/settings.js +2 -2
  3. package/dist/cjs/sandbox/process/process.http.test.js +126 -0
  4. package/dist/cjs/sandbox/process/process.js +82 -67
  5. package/dist/cjs/sandbox/process/process.live.test.js +103 -0
  6. package/dist/cjs/sandbox/process/process.wait.test.js +121 -0
  7. package/dist/cjs/types/sandbox/process/process.d.ts +7 -2
  8. package/dist/cjs/types/sandbox/process/process.http.test.d.ts +1 -0
  9. package/dist/cjs/types/sandbox/process/process.live.test.d.ts +1 -0
  10. package/dist/cjs/types/sandbox/process/process.wait.test.d.ts +1 -0
  11. package/dist/cjs-browser/.tsbuildinfo +1 -1
  12. package/dist/cjs-browser/common/settings.js +2 -2
  13. package/dist/cjs-browser/sandbox/process/process.http.test.js +126 -0
  14. package/dist/cjs-browser/sandbox/process/process.js +82 -67
  15. package/dist/cjs-browser/sandbox/process/process.live.test.js +103 -0
  16. package/dist/cjs-browser/sandbox/process/process.wait.test.js +121 -0
  17. package/dist/cjs-browser/types/sandbox/process/process.d.ts +7 -2
  18. package/dist/cjs-browser/types/sandbox/process/process.http.test.d.ts +1 -0
  19. package/dist/cjs-browser/types/sandbox/process/process.live.test.d.ts +1 -0
  20. package/dist/cjs-browser/types/sandbox/process/process.wait.test.d.ts +1 -0
  21. package/dist/esm/.tsbuildinfo +1 -1
  22. package/dist/esm/common/settings.js +2 -2
  23. package/dist/esm/sandbox/process/process.http.test.js +124 -0
  24. package/dist/esm/sandbox/process/process.js +85 -70
  25. package/dist/esm/sandbox/process/process.live.test.js +101 -0
  26. package/dist/esm/sandbox/process/process.wait.test.js +119 -0
  27. package/dist/esm-browser/.tsbuildinfo +1 -1
  28. package/dist/esm-browser/common/settings.js +2 -2
  29. package/dist/esm-browser/sandbox/process/process.http.test.js +124 -0
  30. package/dist/esm-browser/sandbox/process/process.js +85 -70
  31. package/dist/esm-browser/sandbox/process/process.live.test.js +101 -0
  32. package/dist/esm-browser/sandbox/process/process.wait.test.js +119 -0
  33. 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.22-preview.296";
28
- const BUILD_COMMIT = "a65a57ef50380a21d37481404d9c834cbcb624b5";
27
+ const BUILD_VERSION = "0.3.22-preview.298";
28
+ const BUILD_COMMIT = "94d483b7b57386ae909bb8c438c550405f671211";
29
29
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
30
30
  const BLAXEL_API_VERSION = "2026-04-28";
31
31
  // Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
@@ -0,0 +1,124 @@
1
+ import { createClient } from "@hey-api/client-fetch";
2
+ import { createServer } from "node:http";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ vi.mock("../../common/env.js", () => ({ env: process.env }));
5
+ import { SandboxProcess } from "./process.js";
6
+ const servers = [];
7
+ afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); }))); });
8
+ async function localServer(handler) {
9
+ const server = createServer(handler);
10
+ servers.push(server);
11
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
12
+ const address = server.address();
13
+ if (!address || typeof address === "string")
14
+ throw new Error("missing port");
15
+ return new SandboxProcess({ metadata: { name: "local" }, spec: {}, forceUrl: `http://127.0.0.1:${address.port}`, headers: {} });
16
+ }
17
+ describe("process HTTP contract", () => {
18
+ it("preserves caller identity after a lost POST response and never retries POST", async () => {
19
+ let starts = 0;
20
+ let name = "";
21
+ const process = await localServer((request, response) => {
22
+ void (async () => {
23
+ if (request.method === "POST") {
24
+ starts++;
25
+ const chunks = [];
26
+ for await (const chunk of request) {
27
+ if (Buffer.isBuffer(chunk))
28
+ chunks.push(chunk);
29
+ }
30
+ name = JSON.parse(Buffer.concat(chunks).toString()).name;
31
+ response.destroy();
32
+ return;
33
+ }
34
+ response.setHeader("content-type", "application/json");
35
+ response.end(JSON.stringify({ name, status: "completed", exitCode: 0, stdout: "done" }));
36
+ })().catch((error) => response.destroy(error));
37
+ });
38
+ const input = { name: "original-command", command: "echo done" };
39
+ let error;
40
+ try {
41
+ await process.exec(input);
42
+ }
43
+ catch (caught) {
44
+ error = caught;
45
+ }
46
+ expect(error).toBeInstanceOf(TypeError);
47
+ expect(name).toBe(input.name);
48
+ expect(input).toEqual({ name: "original-command", command: "echo done" });
49
+ expect((await process.wait(name)).status).toBe("completed");
50
+ expect(starts).toBe(1);
51
+ });
52
+ it("rejects failed log streams while intentional close remains successful", async () => {
53
+ let streaming = false;
54
+ const process = await localServer((_request, response) => {
55
+ if (!streaming) {
56
+ response.writeHead(503);
57
+ response.end("unavailable");
58
+ return;
59
+ }
60
+ response.writeHead(200, { "content-type": "text/plain" });
61
+ response.write("stdout:hello\n");
62
+ });
63
+ const onError = vi.fn();
64
+ await expect(process.streamLogs("p", { onError }).wait()).rejects.toThrow("503");
65
+ expect(onError).toHaveBeenCalledTimes(1);
66
+ streaming = true;
67
+ const stream = process.streamLogs("p");
68
+ stream.close();
69
+ await expect(stream.wait()).resolves.toBeUndefined();
70
+ });
71
+ it("retries real status HTTP errors within the wait deadline", async () => {
72
+ let reads = 0;
73
+ const process = await localServer((_request, response) => {
74
+ reads++;
75
+ response.setHeader("content-type", "application/json");
76
+ if (reads === 1) {
77
+ response.writeHead(503);
78
+ response.end(JSON.stringify({ error: "unavailable" }));
79
+ return;
80
+ }
81
+ response.end(JSON.stringify({ status: "completed", exitCode: 0 }));
82
+ });
83
+ expect((await process.wait("p", { maxWait: 2000 })).status).toBe("completed");
84
+ expect(reads).toBe(2);
85
+ });
86
+ it("uses the configured client and interceptors for execution and log streaming", async () => {
87
+ const process = await localServer((request, response) => {
88
+ expect(request.headers["x-session"]).toBe("inherited");
89
+ expect(request.headers["x-intercepted"]).toBe("yes");
90
+ if (request.method === "POST") {
91
+ response.setHeader("content-type", "application/x-ndjson");
92
+ response.end(JSON.stringify({ type: "stdout", data: "streamed" }) + "\n" + JSON.stringify({ type: "result", data: JSON.stringify({ status: "completed", exitCode: 0, pid: "123" }) }) + "\n");
93
+ }
94
+ else {
95
+ response.setHeader("content-type", "text/plain");
96
+ response.end("stdout:reconnected\n");
97
+ }
98
+ });
99
+ const generated = createClient({ headers: { "x-session": "inherited" } });
100
+ const intercept = vi.fn((request) => { request.headers.set("x-intercepted", "yes"); return request; });
101
+ generated.interceptors.request.use(intercept);
102
+ Object.defineProperty(process, "client", { value: generated });
103
+ const onLog = vi.fn();
104
+ expect((await process.exec({ command: "echo streamed", waitForCompletion: true, onLog })).status).toBe("completed");
105
+ await process.streamLogs("123", { onLog }).wait();
106
+ expect(onLog.mock.calls).toEqual([["streamed"], ["reconnected"]]);
107
+ expect(intercept).toHaveBeenCalledTimes(2);
108
+ });
109
+ it("observes after a lost kill response, without sending kill twice", async () => {
110
+ let kills = 0;
111
+ const process = await localServer((request, response) => {
112
+ if (request.method === "DELETE") {
113
+ kills++;
114
+ response.destroy();
115
+ return;
116
+ }
117
+ response.setHeader("content-type", "application/json");
118
+ response.end(JSON.stringify({ status: "killed" }));
119
+ });
120
+ await expect(process.kill("p")).rejects.toBeInstanceOf(TypeError);
121
+ expect((await process.wait("p", { maxWait: 2000 })).status).toBe("killed");
122
+ expect(kills).toBe(1);
123
+ });
124
+ });
@@ -1,7 +1,6 @@
1
- import { settings } from "../../common/settings.js";
2
- import { SandboxAction } from "../action.js";
3
- import { retryOnTransientReset } from "../../common/transient-retry.js";
4
- import { deleteProcessByIdentifier, deleteProcessByIdentifierKill, deleteProcessByIdentifierStdin, getProcess, getProcessByIdentifier, getProcessByIdentifierLogs, postProcess, postProcessByIdentifierStdin } from "../client/index.js";
1
+ import { ResponseError, SandboxAction } from "../action.js";
2
+ import { isRetryableGatewayError, isTransientResetError, retryOnTransientReset } from "../../common/transient-retry.js";
3
+ import { deleteProcessByIdentifier, deleteProcessByIdentifierKill, deleteProcessByIdentifierStdin, getProcess, getProcessByIdentifier, getProcessByIdentifierLogs, getProcessByIdentifierLogsStream, postProcess, postProcessByIdentifierStdin } from "../client/index.js";
5
4
  export class SandboxProcess extends SandboxAction {
6
5
  constructor(sandbox) {
7
6
  super(sandbox);
@@ -12,9 +11,7 @@ export class SandboxProcess extends SandboxAction {
12
11
  if (options.onError) {
13
12
  options.onError(err);
14
13
  }
15
- else {
16
- console.error("Stream error:", err);
17
- }
14
+ throw err;
18
15
  };
19
16
  const processLine = (line) => {
20
17
  if (line.startsWith("[keepalive]")) {
@@ -35,19 +32,15 @@ export class SandboxProcess extends SandboxAction {
35
32
  const done = (async () => {
36
33
  let buffer = '';
37
34
  try {
38
- const headers = this.sandbox.forceUrl ? this.sandbox.headers : settings.headers;
39
- const stream = await this.h2Fetch(`${this.url}/process/${identifier}/logs/stream`, {
40
- method: 'GET',
35
+ const { response: stream, data, error } = await getProcessByIdentifierLogsStream(this.withClient({
36
+ path: { identifier },
37
+ baseUrl: this.url,
41
38
  signal: controller.signal,
42
- headers,
43
- });
44
- if (stream.status !== 200) {
45
- handleError(new Error(`Failed to stream logs: ${await stream.text()}`));
46
- return;
47
- }
39
+ parseAs: "stream",
40
+ }));
41
+ this.handleResponseError(stream, data, error);
48
42
  if (!stream.body) {
49
- handleError(new Error('No stream body'));
50
- return;
43
+ throw new Error('No stream body');
51
44
  }
52
45
  const reader = stream.body.getReader();
53
46
  const decoder = new TextDecoder();
@@ -71,7 +64,7 @@ export class SandboxProcess extends SandboxAction {
71
64
  }
72
65
  }
73
66
  catch (err) {
74
- if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError') {
67
+ if (controller.signal.aborted) {
75
68
  // Process remaining buffer before returning on abort
76
69
  if (buffer.trim()) {
77
70
  processLine(buffer);
@@ -81,27 +74,16 @@ export class SandboxProcess extends SandboxAction {
81
74
  handleError(err instanceof Error ? err : new Error('Unknown stream error'));
82
75
  }
83
76
  })();
77
+ // Callback-only users need not await wait(); preserve rejection for those who do.
78
+ void done.catch(() => { });
84
79
  return {
85
80
  close: () => controller.abort(),
86
81
  wait: () => done,
87
82
  };
88
83
  }
89
84
  async exec(process) {
90
- let onLog;
91
- let onStdout;
92
- let onStderr;
93
- if ('onLog' in process && process.onLog) {
94
- onLog = process.onLog;
95
- delete process.onLog;
96
- }
97
- if ('onStdout' in process && process.onStdout) {
98
- onStdout = process.onStdout;
99
- delete process.onStdout;
100
- }
101
- if ('onStderr' in process && process.onStderr) {
102
- onStderr = process.onStderr;
103
- delete process.onStderr;
104
- }
85
+ const { onLog, onStdout, onStderr, ...request } = process;
86
+ process = request;
105
87
  // Store original wait_for_completion setting
106
88
  const shouldWaitForCompletion = process.waitForCompletion;
107
89
  // When waiting for completion with streaming callbacks, use streaming endpoint
@@ -130,22 +112,15 @@ export class SandboxProcess extends SandboxAction {
130
112
  }
131
113
  }
132
114
  async execWithStreaming(processRequest, options) {
133
- const headers = this.sandbox.forceUrl ? this.sandbox.headers : settings.headers;
134
115
  const controller = new AbortController();
135
- const response = await this.h2Fetch(`${this.url}/process`, {
136
- method: 'POST',
116
+ const { response, data, error } = await postProcess(this.withClient({
117
+ baseUrl: this.url,
137
118
  signal: controller.signal,
138
- headers: {
139
- ...headers,
140
- 'Content-Type': 'application/json',
141
- 'Accept': 'text/event-stream',
142
- },
143
- body: JSON.stringify(processRequest),
144
- });
145
- if (!response.ok) {
146
- const errorText = await response.text();
147
- throw new Error(`Failed to execute process: ${errorText}`);
148
- }
119
+ headers: { Accept: "text/event-stream" },
120
+ body: processRequest,
121
+ parseAs: "stream",
122
+ }));
123
+ this.handleResponseError(response, data, error);
149
124
  const contentType = response.headers.get('Content-Type') || '';
150
125
  const isStreaming = contentType.includes('application/x-ndjson');
151
126
  // Fallback: server doesn't support streaming, use legacy approach
@@ -280,37 +255,77 @@ export class SandboxProcess extends SandboxAction {
280
255
  close: () => controller.abort(),
281
256
  };
282
257
  }
283
- async wait(identifier, { maxWait = 60000, interval = 1000 } = {}) {
284
- const startTime = Date.now();
285
- let data = await this.get(identifier);
286
- let status = data.status ?? "running";
287
- while (status === "running") {
288
- await new Promise((resolve) => setTimeout(resolve, interval));
289
- try {
290
- data = await this.get(identifier);
291
- status = data.status ?? "running";
292
- }
293
- catch {
294
- break;
295
- }
296
- if (Date.now() - startTime > maxWait) {
297
- throw new Error("Process did not finish in time");
258
+ /** Wait for a terminal API state. Timeout/cancellation never stops the command. */
259
+ async wait(identifier, { maxWait = 60000, interval = 1000, signal } = {}) {
260
+ if (!Number.isFinite(maxWait) || (maxWait < 0 && maxWait !== -1) || !Number.isFinite(interval) || interval <= 0) {
261
+ throw new RangeError("maxWait must be -1 or finite and non-negative; interval must be finite and positive");
262
+ }
263
+ signal?.throwIfAborted();
264
+ const controller = new AbortController();
265
+ let lastError;
266
+ let pollTimer;
267
+ const cancel = () => controller.abort(signal?.reason);
268
+ const timeoutError = () => new Error(`Process did not finish in time (${identifier}); it may still be running`, { cause: lastError });
269
+ if (maxWait === 0)
270
+ throw timeoutError();
271
+ const deadline = maxWait === -1 ? Infinity : performance.now() + maxWait;
272
+ const timeout = maxWait === -1 ? undefined : setTimeout(() => controller.abort(timeoutError()), maxWait);
273
+ signal?.addEventListener("abort", cancel, { once: true });
274
+ const interrupted = new Promise((_, reject) => {
275
+ // Preserve the caller's AbortSignal reason, which need not be an Error.
276
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
277
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
278
+ });
279
+ const poll = async () => {
280
+ while (true) {
281
+ if (performance.now() >= deadline)
282
+ controller.abort(timeoutError());
283
+ controller.signal.throwIfAborted();
284
+ try {
285
+ const result = await this.get(identifier, { signal: controller.signal, retry: false });
286
+ if (performance.now() >= deadline)
287
+ controller.abort(timeoutError());
288
+ controller.signal.throwIfAborted();
289
+ if (["completed", "failed", "killed", "stopped"].includes(result.status))
290
+ return result;
291
+ if (result.status !== "running")
292
+ throw new Error(`Unknown process status: ${result.status}`);
293
+ lastError = undefined;
294
+ }
295
+ catch (error) {
296
+ controller.signal.throwIfAborted();
297
+ const retryable = isTransientResetError(error) || isRetryableGatewayError(error)
298
+ || (error instanceof ResponseError && [408, 429, 500].includes(error.status ?? 0))
299
+ || (error instanceof TypeError && /^(fetch failed|Failed to fetch|NetworkError when attempting to fetch resource\.)$/.test(error.message));
300
+ if (!retryable)
301
+ throw error;
302
+ lastError = error;
303
+ }
304
+ // A failed observation uses the same polling cadence as a running process.
305
+ await new Promise(resolve => { pollTimer = setTimeout(resolve, interval); });
298
306
  }
307
+ };
308
+ try {
309
+ return await Promise.race([poll(), interrupted]);
310
+ }
311
+ finally {
312
+ clearTimeout(timeout);
313
+ clearTimeout(pollTimer);
314
+ signal?.removeEventListener("abort", cancel);
299
315
  }
300
- return data;
301
316
  }
302
- async get(identifier) {
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 () => {
317
+ async get(identifier, { signal, retry = true } = {}) {
318
+ const read = async () => {
307
319
  const { response, data, error } = await getProcessByIdentifier(this.withClient({
308
320
  path: { identifier },
309
321
  baseUrl: this.url,
322
+ signal,
310
323
  }));
311
324
  this.handleResponseError(response, data, error);
312
325
  return data;
313
- });
326
+ };
327
+ // wait owns the retry budget; standalone reads retain their existing retries.
328
+ return retry ? retryOnTransientReset(read) : read();
314
329
  }
315
330
  async list() {
316
331
  // Idempotent GET: self-heal a transient connection reset.
@@ -0,0 +1,101 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ vi.mock("../../common/env.js", () => ({ env: process.env }));
5
+ import { SandboxProcess } from "./process.js";
6
+ // Opt-in disposable sandbox API. No credentials or .env files are loaded.
7
+ const url = process.env.LOCAL_PROCESS_API_URL;
8
+ const client = (forceUrl) => new SandboxProcess({ metadata: { name: "local-process-tests" }, spec: {}, forceUrl, headers: {} });
9
+ describe.skipIf(!url)("real sandbox process API", () => {
10
+ it("reconnects after wait timeout and recovers exit code and logs", async () => {
11
+ const api = client(url);
12
+ const name = `ts-timeout-${randomUUID()}`;
13
+ await api.exec({ name, command: "sleep 1; echo recovered; exit 7", waitForCompletion: false, keepAlive: false });
14
+ await expect(api.wait(name, { maxWait: 20, interval: 5 })).rejects.toThrow("did not finish in time");
15
+ const result = await api.wait(name, { maxWait: -1, interval: 20, signal: AbortSignal.timeout(5000) });
16
+ expect(result.status).toBe("failed");
17
+ expect(result.exitCode).toBe(7);
18
+ expect(await api.logs(name)).toContain("recovered");
19
+ });
20
+ it("cancels only observation, then explicitly kills and confirms the original command", async () => {
21
+ const api = client(url);
22
+ const name = `ts-cancel-${randomUUID()}`;
23
+ await api.exec({ name, command: "sleep 30", waitForCompletion: false, keepAlive: false });
24
+ try {
25
+ const controller = new AbortController();
26
+ controller.abort("done waiting");
27
+ await expect(api.wait(name, { signal: controller.signal })).rejects.toBe("done waiting");
28
+ expect((await api.get(name)).status).toBe("running");
29
+ await api.kill(name);
30
+ const result = await api.wait(name, { maxWait: 5000, interval: 20 });
31
+ expect(["killed", "failed", "completed"]).toContain(result.status);
32
+ }
33
+ finally {
34
+ await api.kill(name).catch(() => { });
35
+ }
36
+ });
37
+ it("gracefully stops and confirms an API terminal state", async () => {
38
+ const api = client(url);
39
+ const name = `ts-stop-${randomUUID()}`;
40
+ await api.exec({ name, command: "sleep 30", waitForCompletion: false, keepAlive: false });
41
+ try {
42
+ await api.stop(name);
43
+ const result = await api.wait(name, { maxWait: 5000, interval: 20 });
44
+ expect(["stopped", "failed", "completed"]).toContain(result.status);
45
+ }
46
+ finally {
47
+ await api.kill(name).catch(() => { });
48
+ }
49
+ });
50
+ it("streams logs and the final result through the generated client", async () => {
51
+ const api = client(url);
52
+ const output = [];
53
+ const result = await api.exec({ name: `ts-stream-${randomUUID()}`, command: "echo streamed-live", waitForCompletion: true, keepAlive: false, onLog: line => output.push(line) });
54
+ expect(result.status).toBe("completed");
55
+ expect(result.exitCode).toBe(0);
56
+ expect(output.join("\n")).toContain("streamed-live");
57
+ });
58
+ it("recovers the original command after its execution response is lost", async () => {
59
+ let posts = 0;
60
+ const server = createServer((request, response) => {
61
+ void (async () => {
62
+ const chunks = [];
63
+ for await (const chunk of request) {
64
+ if (Buffer.isBuffer(chunk))
65
+ chunks.push(chunk);
66
+ }
67
+ const body = Buffer.concat(chunks);
68
+ const upstream = await fetch(`${url}${request.url}`, { method: request.method, headers: { "content-type": "application/json" }, body: body.length ? body : undefined });
69
+ await upstream.arrayBuffer();
70
+ posts++;
71
+ response.destroy();
72
+ })().catch((error) => response.destroy(error));
73
+ });
74
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
75
+ const address = server.address();
76
+ if (!address || typeof address === "string")
77
+ throw new Error("missing port");
78
+ const proxy = client(`http://127.0.0.1:${address.port}`);
79
+ try {
80
+ const identifier = `ts-lost-response-${randomUUID()}`;
81
+ let failure;
82
+ try {
83
+ await proxy.exec({ name: identifier, command: "echo exactly-once", waitForCompletion: true, keepAlive: false });
84
+ }
85
+ catch (error) {
86
+ failure = error;
87
+ }
88
+ expect(failure).toBeInstanceOf(TypeError);
89
+ const api = client(url);
90
+ const result = await api.wait(identifier, { maxWait: 5000 });
91
+ expect(result.status).toBe("completed");
92
+ expect(result.exitCode).toBe(0);
93
+ expect((await api.logs(identifier)).match(/exactly-once/g)).toHaveLength(1);
94
+ expect(posts).toBe(1);
95
+ }
96
+ finally {
97
+ server.closeAllConnections();
98
+ await new Promise(resolve => server.close(() => resolve()));
99
+ }
100
+ });
101
+ });
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ vi.mock("../../common/env.js", () => ({ env: process.env }));
3
+ import { SandboxProcess } from "./process.js";
4
+ import { ResponseError } from "../action.js";
5
+ const api = () => new SandboxProcess({ metadata: { name: "local" }, spec: {}, forceUrl: "http://localhost", headers: {} });
6
+ const response = (status) => new Response(JSON.stringify({ status, exitCode: status === "failed" ? 7 : 0 }), { headers: { "content-type": "application/json" } });
7
+ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
8
+ describe("process wait", () => {
9
+ it.each(["completed", "failed", "killed", "stopped"])("returns terminal %s immediately", async (status) => {
10
+ const fetch = vi.fn().mockResolvedValue(response(status));
11
+ vi.stubGlobal("fetch", fetch);
12
+ expect((await api().wait("original")).status).toBe(status);
13
+ expect(fetch).toHaveBeenCalledTimes(1);
14
+ });
15
+ it.each([401, 403, 404])("propagates HTTP %s instead of returning the previous running state", async (status) => {
16
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(response("running")).mockResolvedValue(new Response("{}", { status })));
17
+ await expect(api().wait("original", { interval: 1 })).rejects.toBeInstanceOf(ResponseError);
18
+ });
19
+ it.each([408, 429, 500, 502, 503, 504])("recovers from HTTP %s", async (status) => {
20
+ const fetch = vi.fn().mockResolvedValueOnce(new Response("{}", { status })).mockResolvedValue(response("completed"));
21
+ vi.stubGlobal("fetch", fetch);
22
+ expect((await api().wait("original", { interval: 1 })).status).toBe("completed");
23
+ expect(fetch).toHaveBeenCalledTimes(2);
24
+ });
25
+ it("recovers from a lost connection", async () => {
26
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new TypeError("fetch failed")).mockResolvedValue(response("completed")));
27
+ expect((await api().wait("original", { interval: 1 })).status).toBe("completed");
28
+ });
29
+ it("bounds a hung request and aborts its transport", async () => {
30
+ vi.useFakeTimers();
31
+ const fetch = vi.fn(() => new Promise(() => { }));
32
+ vi.stubGlobal("fetch", fetch);
33
+ const assertion = expect(api().wait("original", { maxWait: 25 })).rejects.toThrow("Process did not finish in time (original)");
34
+ await vi.advanceTimersByTimeAsync(25);
35
+ await assertion;
36
+ expect(fetch.mock.calls[0][0].signal.aborted).toBe(true);
37
+ expect(vi.getTimerCount()).toBe(0);
38
+ });
39
+ it("retains the last network error as the timeout cause", async () => {
40
+ vi.useFakeTimers();
41
+ const cause = new TypeError("fetch failed");
42
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(cause));
43
+ const assertion = expect(api().wait("original", { maxWait: 25 })).rejects.toMatchObject({ cause });
44
+ await vi.advanceTimersByTimeAsync(25);
45
+ await assertion;
46
+ });
47
+ it("cancels while sleeping without another request or leaked timers", async () => {
48
+ vi.useFakeTimers();
49
+ const fetch = vi.fn().mockResolvedValue(response("running"));
50
+ vi.stubGlobal("fetch", fetch);
51
+ const controller = new AbortController();
52
+ const reason = new Error("caller cancelled");
53
+ const assertion = expect(api().wait("original", { signal: controller.signal })).rejects.toBe(reason);
54
+ await vi.advanceTimersByTimeAsync(5);
55
+ controller.abort(reason);
56
+ await assertion;
57
+ await vi.advanceTimersByTimeAsync(2000);
58
+ expect(fetch).toHaveBeenCalledTimes(1);
59
+ expect(vi.getTimerCount()).toBe(0);
60
+ });
61
+ it("waits indefinitely through transient failures and returns a terminal state", async () => {
62
+ vi.useFakeTimers();
63
+ const fetch = vi.fn()
64
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
65
+ .mockImplementation(() => Promise.resolve(response("running")));
66
+ vi.stubGlobal("fetch", fetch);
67
+ let settled = false;
68
+ const waiting = api().wait("original", { maxWait: -1, interval: 60_000 });
69
+ void waiting.then(() => { settled = true; });
70
+ await vi.advanceTimersByTimeAsync(86_400_000);
71
+ expect(settled).toBe(false);
72
+ expect(vi.getTimerCount()).toBe(1); // Only the next polling interval.
73
+ fetch.mockImplementation(() => Promise.resolve(response("failed")));
74
+ await vi.advanceTimersByTimeAsync(60_000);
75
+ expect(await waiting).toMatchObject({ status: "failed", exitCode: 7 });
76
+ expect(vi.getTimerCount()).toBe(0);
77
+ });
78
+ it("cancels an infinite wait with a hung request", async () => {
79
+ vi.useFakeTimers();
80
+ const fetch = vi.fn(() => new Promise(() => { }));
81
+ vi.stubGlobal("fetch", fetch);
82
+ const controller = new AbortController();
83
+ const reason = new Error("caller cancelled");
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);
87
+ controller.abort(reason);
88
+ await assertion;
89
+ expect(fetch.mock.calls[0][0].signal.aborted).toBe(true);
90
+ expect(fetch).toHaveBeenCalledTimes(1);
91
+ });
92
+ it("still propagates permanent errors during an infinite wait", async () => {
93
+ const fetch = vi.fn().mockResolvedValue(new Response("{}", { status: 404 }));
94
+ vi.stubGlobal("fetch", fetch);
95
+ await expect(api().wait("original", { maxWait: -1 })).rejects.toBeInstanceOf(ResponseError);
96
+ expect(fetch).toHaveBeenCalledTimes(1);
97
+ });
98
+ it.each([-2, -0.5, Infinity, NaN])("rejects invalid maxWait %s", async (maxWait) => {
99
+ const fetch = vi.fn();
100
+ vi.stubGlobal("fetch", fetch);
101
+ await expect(api().wait("original", { maxWait })).rejects.toBeInstanceOf(RangeError);
102
+ expect(fetch).not.toHaveBeenCalled();
103
+ });
104
+ it("keeps zero as an immediate deadline", async () => {
105
+ const fetch = vi.fn();
106
+ vi.stubGlobal("fetch", fetch);
107
+ await expect(api().wait("original", { maxWait: 0 })).rejects.toThrow("did not finish in time");
108
+ expect(fetch).not.toHaveBeenCalled();
109
+ });
110
+ it("rejects an unknown state instead of claiming completion", async () => {
111
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response("unknown")));
112
+ await expect(api().wait("original")).rejects.toThrow("Unknown process status");
113
+ });
114
+ it("rejects a late terminal response even before the timeout callback runs", async () => {
115
+ vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(30);
116
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response("completed")));
117
+ await expect(api().wait("original", { maxWait: 25 })).rejects.toThrow("Process did not finish in time");
118
+ });
119
+ });