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