@solarisdk/core 0.4.4 → 0.4.6

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/handle.d.ts CHANGED
@@ -131,6 +131,8 @@ export declare class SessionHandle {
131
131
  connect: () => Promise<void>;
132
132
  };
133
133
  private startCommand;
134
+ /** SIGTERM + close stdin now, SIGKILL after CANCEL_KILL_GRACE_MS. Best effort: a dead channel or a finished command is fine. */
135
+ private cancelCommand;
134
136
  private runCommand;
135
137
  private runCommandInner;
136
138
  readonly pty: {
@@ -146,6 +148,7 @@ export declare class SessionHandle {
146
148
  /** Create a fresh stateful kernel context (`code.context.create`). */
147
149
  createCodeContext(language?: CodeLanguageArg): Promise<string>;
148
150
  readonly files: {
151
+ /** Read a whole file. Chunked under the hood (4 MiB per frame), so a big file never travels as one frame. */
149
152
  read: (path: string) => Promise<Uint8Array>;
150
153
  readText: (path: string) => Promise<string>;
151
154
  write: (path: string, data: Uint8Array | string, mode?: number) => Promise<void>;
@@ -161,7 +164,7 @@ export declare class SessionHandle {
161
164
  * Returns an unwatch function.
162
165
  */
163
166
  watch: (path: string, cb: (ev: FsWatchEvent) => void, recursive?: boolean) => Promise<() => Promise<void>>;
164
- /** Upload local bytes to an in-guest path (chunked base64 under the hood). */
167
+ /** Upload local bytes to an in-guest path: sent in 4 MiB chunks (offset/truncate), so a big file never travels as one frame. */
165
168
  upload: (path: string, data: Uint8Array | string) => Promise<void>;
166
169
  /** Download an in-guest file's bytes. */
167
170
  download: (path: string) => Promise<Uint8Array>;
@@ -300,4 +303,10 @@ export declare class SessionHandle {
300
303
  }
301
304
  /** Local helper type for `createCodeContext` to avoid a hard import cycle. */
302
305
  type CodeLanguageArg = "python" | "javascript" | "typescript" | "bash" | "r";
306
+ /** How long a cancelled command gets between SIGTERM and SIGKILL. */
307
+ export declare const CANCEL_KILL_GRACE_MS = 2000;
308
+ /** Bytes per fs.upload / fs.download frame. */
309
+ export declare const FILE_CHUNK_BYTES: number;
310
+ /** Largest whole-file payload one single-frame call (files.write) accepts; above it use files.upload / files.download / a signed URL. */
311
+ export declare const MAX_SINGLE_CALL_BYTES: number;
303
312
  export {};
package/dist/handle.js CHANGED
@@ -127,6 +127,22 @@ export class SessionHandle {
127
127
  exitResolve?.(-1);
128
128
  }, opts.timeoutMs);
129
129
  }
130
+ if (opts.signal) {
131
+ const signal = opts.signal;
132
+ const onAbort = () => {
133
+ clearTimeout(deadline);
134
+ void this.cancelCommand(cmdId);
135
+ this.channel.offFrame("cmd.data", cmdId);
136
+ this.channel.offFrame("cmd.exit", cmdId);
137
+ this.channel.offStreamClose(cmdId);
138
+ exitReject?.(abortReason(signal));
139
+ };
140
+ if (signal.aborted)
141
+ onAbort();
142
+ else
143
+ signal.addEventListener("abort", onAbort, { once: true });
144
+ void exitPromise.then(() => signal.removeEventListener("abort", onAbort), () => signal.removeEventListener("abort", onAbort));
145
+ }
130
146
  if (acc) {
131
147
  dataCbs.push((c) => {
132
148
  if (c.stream === "stdout")
@@ -191,6 +207,13 @@ export class SessionHandle {
191
207
  .then(() => undefined),
192
208
  };
193
209
  }
210
+ async cancelCommand(cmdId) {
211
+ await this.channel.call("cmd.kill", { cmdId, signal: 15 }).catch(() => undefined);
212
+ await this.channel.call("cmd.stdin", { cmdId, base64: "", eof: true }).catch(() => undefined);
213
+ setTimeout(() => {
214
+ this.channel.call("cmd.kill", { cmdId, signal: 9 }).catch(() => undefined);
215
+ }, CANCEL_KILL_GRACE_MS).unref?.();
216
+ }
194
217
  async runCommand(cmd, opts) {
195
218
  try {
196
219
  return await this.runCommandInner(cmd, opts);
@@ -210,6 +233,7 @@ export class SessionHandle {
210
233
  !opts.onStderr &&
211
234
  !opts.env &&
212
235
  !opts.user &&
236
+ !opts.signal &&
213
237
  timeoutMs >= MIN_REST_TIMEOUT_MS &&
214
238
  timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS;
215
239
  const viaRest = async () => {
@@ -320,11 +344,34 @@ export class SessionHandle {
320
344
  };
321
345
  }
322
346
  async runCode(code, opts = {}) {
323
- const r = await this.channel.call("code.run", {
324
- code,
325
- language: opts.language,
326
- contextId: opts.contextId,
327
- });
347
+ const params = { code, language: opts.language, contextId: opts.contextId };
348
+ const running = this.channel.call("code.run", params);
349
+ let r;
350
+ const signal = opts.signal;
351
+ if (signal) {
352
+ let onAbort;
353
+ const aborted = new Promise((_, reject) => {
354
+ onAbort = () => {
355
+ this.channel.call("code.interrupt", { language: opts.language, contextId: opts.contextId }).catch(() => undefined);
356
+ reject(abortReason(signal));
357
+ };
358
+ if (signal.aborted)
359
+ onAbort();
360
+ else
361
+ signal.addEventListener("abort", onAbort, { once: true });
362
+ });
363
+ running.catch(() => undefined);
364
+ try {
365
+ r = await Promise.race([running, aborted]);
366
+ }
367
+ finally {
368
+ if (onAbort)
369
+ signal.removeEventListener("abort", onAbort);
370
+ }
371
+ }
372
+ else {
373
+ r = await running;
374
+ }
328
375
  const results = Array.isArray(r.results) ? r.results : [];
329
376
  for (const item of results) {
330
377
  if (item.type === "stdout" && item.text)
@@ -343,16 +390,29 @@ export class SessionHandle {
343
390
  }
344
391
  files = {
345
392
  read: async (path) => {
346
- const r = await this.channel.call("fs.read", { path });
347
- return base64ToBytes(r.base64);
393
+ try {
394
+ return await downloadAll((method, params) => this.channel.call(method, { ...params, length: FILE_CHUNK_BYTES }), path);
395
+ }
396
+ catch (err) {
397
+ if (!isUnknownMethod(err))
398
+ throw err;
399
+ const r = await this.channel.call("fs.read", { path });
400
+ return base64ToBytes(r.base64);
401
+ }
348
402
  },
349
403
  readText: async (path) => {
350
404
  const bytes = await this.files.read(path);
351
405
  return new TextDecoder().decode(bytes);
352
406
  },
353
- write: (path, data, mode) => this.channel
354
- .call("fs.write", { path, base64: toB64(data), mode })
355
- .then(() => undefined),
407
+ write: (path, data, mode) => {
408
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
409
+ if (bytes.length > MAX_SINGLE_CALL_BYTES) {
410
+ return Promise.reject(singleCallTooLarge(bytes.length));
411
+ }
412
+ return this.channel
413
+ .call("fs.write", { path, base64: toB64(bytes), mode })
414
+ .then(() => undefined);
415
+ },
356
416
  list: async (path) => {
357
417
  const r = await this.channel.call("fs.list", {
358
418
  path,
@@ -387,10 +447,16 @@ export class SessionHandle {
387
447
  await this.channel.call("fs.unwatch", { watchId });
388
448
  };
389
449
  },
390
- upload: (path, data) => this.channel
391
- .call("fs.upload", { path, base64: toB64(data) })
392
- .then(() => undefined),
393
- download: (path) => downloadAll((method, params) => this.channel.call(method, params), path),
450
+ upload: async (path, data) => {
451
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
452
+ let offset = 0;
453
+ do {
454
+ const slice = bytes.subarray(offset, offset + FILE_CHUNK_BYTES);
455
+ await this.channel.call("fs.upload", { path, base64: toB64(slice), offset, truncate: offset === 0 });
456
+ offset += slice.length;
457
+ } while (offset < bytes.length);
458
+ },
459
+ download: (path) => downloadAll((method, params) => this.channel.call(method, { ...params, length: FILE_CHUNK_BYTES }), path),
394
460
  };
395
461
  env(vars) {
396
462
  return this.channel
@@ -621,11 +687,10 @@ export class SessionHandle {
621
687
  if (!this.hooks.revert) {
622
688
  return Promise.reject(new Error("revert requires a client-created handle"));
623
689
  }
690
+ const hadSocket = this.channel.hasSocket;
624
691
  return this.hooks.revert(this.id, snapshotId).then(async () => {
625
- if (this.channel.connected) {
626
- this.close({ quiet: true });
627
- await this.channel.connect().catch(() => undefined);
628
- }
692
+ if (hadSocket)
693
+ await this.channel.refresh();
629
694
  });
630
695
  }
631
696
  async pause() {
@@ -728,6 +793,23 @@ function isRouteUnavailable(err) {
728
793
  return (err instanceof GatewayError &&
729
794
  (err.status === 404 || err.status === 405 || err.status === 501));
730
795
  }
796
+ function isUnknownMethod(err) {
797
+ return /unknown method|method not found|not implemented/i.test(err instanceof Error ? err.message : String(err));
798
+ }
799
+ export const CANCEL_KILL_GRACE_MS = 2000;
800
+ function abortReason(signal) {
801
+ const r = signal.reason;
802
+ if (r instanceof Error)
803
+ return r;
804
+ const e = new Error("The command was cancelled");
805
+ e.name = "AbortError";
806
+ return e;
807
+ }
808
+ export const FILE_CHUNK_BYTES = 4 * 1024 * 1024;
809
+ export const MAX_SINGLE_CALL_BYTES = 32 * 1024 * 1024;
810
+ function singleCallTooLarge(n) {
811
+ return new SolariError(`file too large for a single call (${n} bytes, max ${MAX_SINGLE_CALL_BYTES}): use files.upload()/files.download() or uploadUrl()`);
812
+ }
731
813
  function toB64(data) {
732
814
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
733
815
  return bytesToBase64(bytes);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle, markSessionReleased, unreleasedSessionIds, exitReminderMessages } from "./handle.js";
1
+ export { CANCEL_KILL_GRACE_MS, DEFAULT_COMMAND_TIMEOUT_MS, FILE_CHUNK_BYTES, MAX_SINGLE_CALL_BYTES, SessionHandle, markSessionReleased, unreleasedSessionIds, exitReminderMessages } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
3
  export { formatIdlePolicy } from "./idle-policy.js";
4
4
  export type { SessionConfig, SessionHooks } from "./handle.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle, markSessionReleased, unreleasedSessionIds, exitReminderMessages } from "./handle.js";
1
+ export { CANCEL_KILL_GRACE_MS, DEFAULT_COMMAND_TIMEOUT_MS, FILE_CHUNK_BYTES, MAX_SINGLE_CALL_BYTES, SessionHandle, markSessionReleased, unreleasedSessionIds, exitReminderMessages } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
3
  export { formatIdlePolicy } from "./idle-policy.js";
4
4
  export { Desktop } from "./desktop.js";
@@ -81,6 +81,17 @@ export declare class ControlChannel {
81
81
  * `commands.start` / `pty.create` survive so callers can re-subscribe.
82
82
  */
83
83
  reconnect(): Promise<void>;
84
+ /**
85
+ * Swap the socket for a fresh, working one after the guest behind it was replaced in place (revert). The old socket
86
+ * is detached first, so its close, arriving whenever it likes (it may even have arrived already), cannot fail calls
87
+ * made on the new connection. Right after a restore the host can still be re-accepting the guest's control
88
+ * connection and closes a connection opened in that window within milliseconds (seen on staging as the first call
89
+ * failing with 1000/1005), so the new connection is proven with a cheap request and re-dialled a few times before
90
+ * this resolves. Never rejects: if no working connection can be had, calls retry lazily.
91
+ */
92
+ refresh(): Promise<void>;
93
+ /** True while the channel holds a socket, open or not. */
94
+ get hasSocket(): boolean;
84
95
  /** Register a "channel closed" callback for a stream id (cmdId/ptyId) so a
85
96
  * drop rejects its in-flight wait. Cleared on the stream's terminal frame.
86
97
  *
package/dist/transport.js CHANGED
@@ -2,6 +2,9 @@ import { ActionError, ConnectionError, SolariError, TimeoutError, } from "./erro
2
2
  import { openWebSocket } from "./ws.js";
3
3
  const DEFAULT_CALL_TIMEOUT_MS = 300_000;
4
4
  const CONNECT_TIMEOUT_MS = 15_000;
5
+ const REFRESH_MAX_ATTEMPTS = 4;
6
+ const REFRESH_RETRY_BASE_MS = 300;
7
+ const REFRESH_PROBE_TIMEOUT_MS = 5_000;
5
8
  const CONNECT_MAX_ATTEMPTS = 3;
6
9
  const CONNECT_RETRY_BASE_MS = 150;
7
10
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -41,6 +44,7 @@ export class ControlChannel {
41
44
  return Promise.resolve();
42
45
  if (this.connecting)
43
46
  return this.connecting;
47
+ this.#detachSocket();
44
48
  this.closed = false;
45
49
  this.connecting = this.#connectWithRetry();
46
50
  return this.connecting;
@@ -117,15 +121,39 @@ export class ControlChannel {
117
121
  async reconnect() {
118
122
  if (this.connected)
119
123
  return;
120
- if (this.ws) {
121
- const old = this.ws;
122
- this.ws = null;
123
- this.#failAll(new ConnectionError("Control channel replaced by a reconnect"));
124
- old.close();
125
- }
124
+ this.#detachSocket();
126
125
  this.connecting = null;
127
126
  await this.connect();
128
127
  }
128
+ async refresh() {
129
+ for (let attempt = 1; attempt <= REFRESH_MAX_ATTEMPTS; attempt++) {
130
+ if (this.closed)
131
+ return;
132
+ try {
133
+ this.#detachSocket();
134
+ this.connecting = null;
135
+ await this.connect();
136
+ await this.#send("fs.stat", { path: "/" }, { timeoutMs: REFRESH_PROBE_TIMEOUT_MS });
137
+ return;
138
+ }
139
+ catch (err) {
140
+ if (!(err instanceof ConnectionError))
141
+ return;
142
+ await sleep(REFRESH_RETRY_BASE_MS * attempt);
143
+ }
144
+ }
145
+ }
146
+ get hasSocket() {
147
+ return this.ws !== null;
148
+ }
149
+ #detachSocket() {
150
+ if (!this.ws)
151
+ return;
152
+ const old = this.ws;
153
+ this.ws = null;
154
+ this.#failAll(new ConnectionError("Control channel replaced by a reconnect"));
155
+ old.close();
156
+ }
129
157
  onStreamClose(id, cb) {
130
158
  if (this.closed || !this.ws || !this.ws.isOpen) {
131
159
  cb(new ConnectionError("Control channel closed"));
package/dist/types.d.ts CHANGED
@@ -767,6 +767,12 @@ export interface CommandOptions {
767
767
  background?: boolean;
768
768
  onStdout?: (data: string) => void;
769
769
  onStderr?: (data: string) => void;
770
+ /**
771
+ * Cancel the command. On abort the process in the sandbox gets SIGTERM and its stdin is closed, then SIGKILL after
772
+ * 2 s if it is still running; the call rejects with the signal's reason. Giving a signal runs the command over the live
773
+ * connection (the one-shot REST route has nothing to kill).
774
+ */
775
+ signal?: AbortSignal;
770
776
  }
771
777
  /** Terminal result of `commands.run`. */
772
778
  export interface CommandResult {
@@ -835,6 +841,8 @@ export interface RunCodeOptions {
835
841
  contextId?: string;
836
842
  onStdout?: (data: string) => void;
837
843
  onStderr?: (data: string) => void;
844
+ /** Cancel the run: the cell is interrupted in the kernel (KeyboardInterrupt) and the call rejects with the signal's reason. */
845
+ signal?: AbortSignal;
838
846
  }
839
847
  /** Kind of a structured chart extracted from a matplotlib figure. */
840
848
  export type ChartType = "line" | "scatter" | "bar" | "pie" | "box_and_whisker" | "composite" | "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/core",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Shared runtime for the Solari SDK \u2014 transport, session handles, images, templates, and types used by @solarisdk/desktop and @solarisdk/sandbox.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",