@watasu/sdk 0.1.71 → 0.1.72

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.
@@ -8,6 +8,11 @@ export interface CommandResult {
8
8
  error?: string;
9
9
  stdout: string;
10
10
  stderr: string;
11
+ /**
12
+ * True when the runtime dropped stream output that could not be replayed
13
+ * (retention evicted the events before the SDK could backfill them).
14
+ */
15
+ truncated?: boolean;
11
16
  }
12
17
  /** Error thrown by `CommandHandle.wait()` when a process exits non-zero. */
13
18
  export declare class CommandExitError extends SandboxError implements CommandResult {
@@ -17,6 +22,7 @@ export declare class CommandExitError extends SandboxError implements CommandRes
17
22
  get error(): string | undefined;
18
23
  get stdout(): string;
19
24
  get stderr(): string;
25
+ get truncated(): boolean | undefined;
20
26
  }
21
27
  export interface ProcessInfo {
22
28
  pid: number | string;
@@ -39,6 +45,11 @@ export interface ProcessStatus {
39
45
  startedAt?: string;
40
46
  finishedAt?: string;
41
47
  exitCode?: number;
48
+ /**
49
+ * Set when a kill has been requested but the process has not finished yet
50
+ * (the runtime reports a non-terminal `killing` status in that window).
51
+ */
52
+ killRequestedAt?: string;
42
53
  }
43
54
  export interface ProcessOutputEvent {
44
55
  cursor: number;
@@ -98,6 +109,8 @@ type ProcessReconnect = (cursor: number) => Promise<{
98
109
  socket: ProcessSocket;
99
110
  events: AsyncIterable<ProcessFrame>;
100
111
  }>;
112
+ /** Client-side wait deadline for a command: its timeout plus grace. */
113
+ export declare function commandWaitDeadlineMs(timeoutMs?: number): number;
101
114
  /** Live handle for one sandbox process stream. */
102
115
  export declare class CommandHandle implements Partial<CommandResult> {
103
116
  readonly pid: number | string;
@@ -115,6 +128,8 @@ export declare class CommandHandle implements Partial<CommandResult> {
115
128
  private readonly pending;
116
129
  private nextCursor;
117
130
  private disconnected;
131
+ private pendingLagged;
132
+ private outputDropped;
118
133
  constructor(pid: number | string, socket: ProcessSocket, handleKill: () => Promise<boolean>, events: AsyncIterable<ProcessFrame>, onStdout?: ((data: string) => void | Promise<void>) | undefined, onStderr?: ((data: string) => void | Promise<void>) | undefined, onPty?: ((data: Uint8Array) => void | Promise<void>) | undefined, onExit?: ((exitCode: number) => void | Promise<void>) | undefined, reconnect?: ProcessReconnect | undefined);
119
134
  get stdout(): string;
120
135
  get stderr(): string;
package/dist/commands.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { withQuery } from './transport.js';
2
2
  import { ProcessSocket, base64DecodeBytes, base64DecodeText } from './processSocket.js';
3
- import { SandboxError, TimeoutError } from './errors.js';
3
+ import { NotFoundError, SandboxError, TimeoutError } from './errors.js';
4
4
  /** Error thrown by `CommandHandle.wait()` when a process exits non-zero. */
5
5
  export class CommandExitError extends SandboxError {
6
6
  result;
7
7
  constructor(result) {
8
- super(result.error ?? `Command exited with code ${result.exitCode}`);
8
+ super((result.error ?? `Command exited with code ${result.exitCode}`) +
9
+ (result.truncated ? '; some process output was dropped before delivery' : ''));
9
10
  this.result = result;
10
11
  this.name = 'CommandExitError';
11
12
  }
@@ -13,10 +14,26 @@ export class CommandExitError extends SandboxError {
13
14
  get error() { return this.result.error; }
14
15
  get stdout() { return this.result.stdout; }
15
16
  get stderr() { return this.result.stderr; }
17
+ get truncated() { return this.result.truncated; }
16
18
  }
17
19
  const STREAM_RECONNECT_ATTEMPTS = 12;
18
20
  const STREAM_RECONNECT_BASE_DELAY_MS = 250;
19
21
  const STREAM_RECONNECT_MAX_DELAY_MS = 2_000;
22
+ /**
23
+ * Sentinel exit code reported when the runtime finishes a command without an
24
+ * explicit exit code (for example when it was killed or failed). Success is
25
+ * only ever an explicit exit code of 0.
26
+ */
27
+ const MISSING_EXIT_CODE = -1;
28
+ /** Server-side command ceiling used when the caller supplied no timeout. */
29
+ const DEFAULT_WAIT_TIMEOUT_MS = 3_600_000;
30
+ /** Extra client-side grace on top of the command timeout before giving up. */
31
+ const WAIT_TIMEOUT_GRACE_MS = 60_000;
32
+ /** Client-side wait deadline for a command: its timeout plus grace. */
33
+ export function commandWaitDeadlineMs(timeoutMs) {
34
+ const base = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : DEFAULT_WAIT_TIMEOUT_MS;
35
+ return base + WAIT_TIMEOUT_GRACE_MS;
36
+ }
20
37
  /** Live handle for one sandbox process stream. */
21
38
  export class CommandHandle {
22
39
  pid;
@@ -34,6 +51,8 @@ export class CommandHandle {
34
51
  pending;
35
52
  nextCursor = 0;
36
53
  disconnected = false;
54
+ pendingLagged = false;
55
+ outputDropped = false;
37
56
  constructor(pid, socket, handleKill, events, onStdout, onStderr, onPty, onExit, reconnect) {
38
57
  this.pid = pid;
39
58
  this.socket = socket;
@@ -52,7 +71,7 @@ export class CommandHandle {
52
71
  get error() { return this.result?.error; }
53
72
  /** Wait until the process exits and return captured output. */
54
73
  async wait(timeoutMs) {
55
- await waitFor(this.pending, timeoutMs);
74
+ await waitFor(this.pending, commandWaitDeadlineMs(timeoutMs), `timed out waiting for command ${this.pid} to exit; the command may still be running, check its status with commands.process(pid)`);
56
75
  if (!this.result)
57
76
  throw new SandboxError('Command ended without an exit event');
58
77
  if (this.result.exitCode !== 0)
@@ -84,8 +103,30 @@ export class CommandHandle {
84
103
  while (!this.disconnected && !this.result) {
85
104
  let streamError;
86
105
  for await (const frame of this.events) {
87
- this.advanceCursor(frame);
88
106
  const type = frame.type;
107
+ if (type === 'lagged') {
108
+ // The runtime dropped frames from this stream. Replay them from the
109
+ // next undelivered cursor when the stream supports reconnecting;
110
+ // otherwise flag the dropped output. Do not advance the cursor from
111
+ // a lagged frame, or the replay would skip the dropped events.
112
+ if (!this.reconnect) {
113
+ this.outputDropped = true;
114
+ continue;
115
+ }
116
+ this.pendingLagged = true;
117
+ break;
118
+ }
119
+ if (this.pendingLagged) {
120
+ const cursor = numberValue(frame.cursor);
121
+ if (cursor !== undefined) {
122
+ // Backfill was impossible when the first replayed event sits past
123
+ // the requested cursor: the runtime evicted the events between.
124
+ if (cursor > this.nextCursor)
125
+ this.outputDropped = true;
126
+ this.pendingLagged = false;
127
+ }
128
+ }
129
+ this.advanceCursor(frame);
89
130
  if (type === 'started' || type === 'ready' || type === 'pong')
90
131
  continue;
91
132
  if (type === 'stdout') {
@@ -105,12 +146,25 @@ export class CommandHandle {
105
146
  await this.onPty?.(bytes);
106
147
  }
107
148
  else if (type === 'exit') {
108
- const exitCode = Number(frame.exit_code ?? frame.exitCode ?? 0);
149
+ const rawExitCode = numericExitCode(frame.exit_code ?? frame.exitCode);
150
+ let error = typeof frame.error === 'string' ? frame.error : undefined;
151
+ let exitCode;
152
+ if (rawExitCode === undefined) {
153
+ // Killed/failed commands may finish without an exit code. Keep
154
+ // exitCode a plain number and take the failure path.
155
+ const status = typeof frame.status === 'string' && frame.status ? frame.status : 'failed';
156
+ exitCode = MISSING_EXIT_CODE;
157
+ error ??= `command ${status} without an exit code${this._stderr ? `; stderr:\n${this._stderr}` : ''}`;
158
+ }
159
+ else {
160
+ exitCode = rawExitCode;
161
+ }
109
162
  this.result = {
110
163
  exitCode,
111
- error: typeof frame.error === 'string' ? frame.error : undefined,
164
+ error,
112
165
  stdout: this._stdout,
113
166
  stderr: this._stderr,
167
+ truncated: this.outputDropped,
114
168
  };
115
169
  await this.onExit?.(exitCode);
116
170
  this.socket.close();
@@ -228,15 +282,19 @@ export class Commands {
228
282
  }
229
283
  /** Look up process status without attaching a WebSocket. */
230
284
  async process(pid, opts = {}) {
231
- const payload = await this.dataPlane.getJson(`/runtime/v1/process/${encodeURIComponent(String(pid))}`, opts);
285
+ const payload = await this.dataPlane
286
+ .getJson(`/runtime/v1/process/${encodeURIComponent(String(pid))}`, opts)
287
+ .catch((error) => { throw processPurgedNotFound(error, pid); });
232
288
  return processStatus(payload);
233
289
  }
234
290
  /** Read available process output since a cursor without blocking. */
235
291
  async readProcessOutput(pid, opts = {}) {
236
- const payload = await this.dataPlane.getJson(withQuery(`/runtime/v1/process/${encodeURIComponent(String(pid))}/output`, {
292
+ const payload = await this.dataPlane
293
+ .getJson(withQuery(`/runtime/v1/process/${encodeURIComponent(String(pid))}/output`, {
237
294
  since: opts.since,
238
295
  limit_bytes: opts.limitBytes,
239
- }), opts);
296
+ }), opts)
297
+ .catch((error) => { throw processPurgedNotFound(error, pid); });
240
298
  return processOutputSnapshot(payload);
241
299
  }
242
300
  /** Stop a process, optionally signalling the full process group. */
@@ -292,14 +350,22 @@ function processStartConfig(cmd, opts) {
292
350
  }
293
351
  return { cmd: '/bin/bash', args: ['-l', '-c', cmd] };
294
352
  }
295
- function waitFor(promise, timeoutMs) {
296
- if (timeoutMs === undefined || timeoutMs <= 0)
297
- return promise;
353
+ function waitFor(promise, timeoutMs, message) {
298
354
  return new Promise((resolve, reject) => {
299
- const timer = setTimeout(() => reject(new TimeoutError()), timeoutMs);
355
+ const timer = setTimeout(() => reject(new TimeoutError(message)), timeoutMs);
300
356
  promise.then(resolve, reject).finally(() => clearTimeout(timer));
301
357
  });
302
358
  }
359
+ function numericExitCode(value) {
360
+ if (typeof value === 'number' && Number.isFinite(value))
361
+ return value;
362
+ if (typeof value === 'string' && value !== '') {
363
+ const parsed = Number(value);
364
+ if (Number.isFinite(parsed))
365
+ return parsed;
366
+ }
367
+ return undefined;
368
+ }
303
369
  function sleep(ms) {
304
370
  return new Promise((resolve) => setTimeout(resolve, ms));
305
371
  }
@@ -355,8 +421,15 @@ function processStatus(value) {
355
421
  startedAt: stringValue(process.started_at ?? process.startedAt),
356
422
  finishedAt: stringValue(process.finished_at ?? process.finishedAt),
357
423
  exitCode: numberValue(process.exit_code ?? process.exitCode),
424
+ killRequestedAt: stringValue(process.kill_requested_at ?? process.killRequestedAt),
358
425
  };
359
426
  }
427
+ function processPurgedNotFound(error, pid) {
428
+ if (error instanceof NotFoundError) {
429
+ return new NotFoundError(`process ${pid} not found: process records are purged when the sandbox is destroyed; terminal results were delivered via wait()/callbacks`);
430
+ }
431
+ return error;
432
+ }
360
433
  function processOutputSnapshot(value) {
361
434
  const payload = value && typeof value === 'object' ? value : {};
362
435
  return {
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import WebSocket from 'ws';
3
3
  import { KEEPALIVE_PING_INTERVAL_SEC } from './connectionConfig.js';
4
- import { SandboxError, TimeoutError } from './errors.js';
4
+ import { NotFoundError, SandboxError, TimeoutError } from './errors.js';
5
5
  /** Streaming WebSocket connection to the sandbox process runtime. */
6
6
  export class ProcessSocket {
7
7
  baseUrl;
@@ -36,6 +36,15 @@ export class ProcessSocket {
36
36
  clearTimeout(timeout);
37
37
  resolve();
38
38
  });
39
+ ws.once('unexpected-response', (_request, response) => {
40
+ clearTimeout(timeout);
41
+ if (response.statusCode === 404) {
42
+ reject(new NotFoundError('process not found: process records are purged when the sandbox is destroyed; terminal results were delivered via wait()/callbacks'));
43
+ }
44
+ else {
45
+ reject(new SandboxError(`process websocket failed to connect (status ${response.statusCode})`));
46
+ }
47
+ });
39
48
  ws.once('error', () => {
40
49
  clearTimeout(timeout);
41
50
  reject(new SandboxError('process websocket failed to connect'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watasu/sdk",
3
- "version": "0.1.71",
3
+ "version": "0.1.72",
4
4
  "type": "module",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "description": "TypeScript SDK for Watasu",