@ai-ecoverse/slicc-kernel 1.48.1 → 1.48.2

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/README.md CHANGED
@@ -66,7 +66,7 @@ Starts `argv` as the session leader on a new terminal and resolves once it is ru
66
66
  | --- | --- |
67
67
  | `pid` | the session leader's pid |
68
68
  | `onData` | the output listener; output that arrives while it is `null` is kept and delivered when one is set |
69
- | `write(data)` | types a string or bytes into the terminal; with `ISIG` on, `^C`, `^Z` and `^\` signal the foreground process group. A key typed while a child of that group is still setting up is held until the child has finished, then sent to the group in the foreground at that point. Setting up means the child's calls before its first other one: `setpgid`, `tcsetpgrp`, signal masks, and starting the program it execs (which then sets itself up too). The hold ends 2 s after the child's first call at the latest. A key typed while the foreground group is busy, not reading the terminal (a shell between reading a command line and starting it), goes to that group and also to the job the group starts next, once that job has taken the terminal; the second copy is dropped when the group reads the terminal again. So `^C` or `^Z` right after a command line reaches the new job, as it does on Linux, where starting a job takes microseconds rather than a worker start. For the same reason, a child that is still setting up cannot give the terminal to its own group once its parent has already given the terminal to that group and taken it back: on Linux the child's `tcsetpgrp` would have landed first, so the parent's later choice stands. Otherwise `tcsetpgrp` behaves as before. |
69
+ | `write(data)` | types a string or bytes into the terminal; with `ISIG` on, `^C`, `^Z` and `^\` signal the foreground process group. A key typed while a child of that group is still setting up is held until the child has finished, then sent to the group in the foreground at that point. Setting up means the child's calls before its first other one: `setpgid`, `tcsetpgrp`, signal masks, and starting the program it execs (which then sets itself up too). The hold ends 2 s after the child's first call at the latest. A key typed while the foreground group is busy, not reading the terminal (a shell between reading a command line and starting it), goes to that group and also to the job the group starts next, once that job has taken the terminal; the second copy is dropped when the group reads the terminal again. So `^C` or `^Z` right after a command line reaches the new job, as it does on Linux, where starting a job takes microseconds rather than a worker start. For the same reason, a child that is still setting up cannot give the terminal to its own group once its parent has already given the terminal to that group and taken it back: on Linux the child's `tcsetpgrp` would have landed first, so the parent's later choice stands. Otherwise `tcsetpgrp` behaves as before. A `^C`, `^\` or `^Z` that a program with a handler for it takes between two system calls, rather than while waiting, ends the program's next wait in the kernel that would block (reading nothing yet, `select` or `poll` with a timeout, a blocking write, `waitpid`) with `EINTR`, once, as if the key had come during that wait. A zero-timeout `select` or `poll` does not use it up. Without this, an interactive shell whose `^C` landed while it was taking in the key before it would wait for the next key before showing a new prompt: readline acts on `^C` only when a read ends with `EINTR`. On Linux that window is the microseconds between two reads; here it is a few kernel calls per key. A signal sent with `kill` does not do this. |
70
70
  | `resize(cols, rows)` | sets the window size (`TIOCGWINSZ`) and sends `SIGWINCH` to the foreground process group |
71
71
  | `signal(name)` | sends a signal (`'SIGINT'`, `'SIGTSTP'`, `'SIGQUIT'`, `'SIGHUP'`, …) to the foreground process group, regardless of termios |
72
72
  | `exited` | resolves with the session leader's exit status (`128 + n` if killed by signal `n`) |
@@ -127,7 +127,7 @@ When the kernel goes away (the page closed or reloaded, `terminate()`), pending
127
127
 
128
128
  ### Headless in Node, for tests
129
129
 
130
- `@ai-ecoverse/slicc-kernel/node` runs the same kernel in Node without a browser, so packages built for SLICC can test against it. It is a testing entry, not a supported runtime: there is no OPFS and no isolation, and nothing beyond what its tests use is promised.
130
+ `@ai-ecoverse/slicc-kernel/node` runs the same kernel in Node without a browser, so packages built for SLICC can test against it. It is a testing entry, not a supported runtime: there is no OPFS, JS programs get the same guard as in the page but no sandbox (see [JS programs](#js-programs)), and nothing beyond what its tests use is promised.
131
131
 
132
132
  ```js
133
133
  import { createNodeKernel, nodeTransport } from '@ai-ecoverse/slicc-kernel/node';
@@ -228,11 +228,14 @@ The module's default export (or its `main` export) is called with a context and
228
228
  - `websocket(url, { protocols, headers, signal })`, a WebSocket over the kernel's transport (its `websocket`, so `fetchTransport({ webSocket: true })` in a browser), shaped like `WebSocketStream`. It resolves once the socket is open, with `url`, the `protocol` the far end chose (always one the program offered: a transport that hands back a socket with another fails the open with `EPROTO`, while browsers and Node's `WebSocket`, which `fetchTransport()` uses, fail such a handshake themselves without saying why, so the open fails with `ECONNREFUSED`), a `readable` of messages (`string` for text, `Uint8Array` for binary), a `writable` for sending them, `closed` (the `{ code, reason }` of the close, from either side, whether or not the program reads) and `close({ code, reason })`. An `http(s):` URL is taken as `ws(s):`. Sending waits while more than 1 MiB is buffered. A signal aborts the open, and after it the socket. A transport without WebSockets rejects with a `TypeError` whose `cause.code` is `ENOTSUP`, and one that cannot connect with `ECONNREFUSED`;
229
229
  - `fetch(input, init)`, the web's `fetch` over the kernel's transport, the one the proxy uses, so the embedder's transport, its WebSocket and proxy settings and the kernel's routes apply. The response body streams as the program reads it. The request body is read whole first, since a transport takes it whole (streaming it is [#238](https://github.com/ai-ecoverse/slicc-kernel/issues/238)), and one larger than the transport's `maxRequestBody` is answered `413`, as through the proxy. A signal aborts the request at any stage. Redirects follow the fetch rules: at most 20, a 301 or 302 after a `POST` and a 303 turn into a `GET` without a body, `Authorization` is dropped across origins, and `redirect: 'manual'` returns the redirect itself. On a transport that follows redirects on its own (`fetchTransport()` in a browser) only `'follow'` works: `'manual'` and `'error'` reject with a `TypeError` whose `cause.code` is `ENOTSUP`, since such a transport never shows the program a redirect. A status the transport chose comes back as a response whose body is the reason. An unreachable host, or no transport at all, rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED` or `ENETUNREACH`, as Node's `fetch` does. A `data:` URL is answered by `fetch` itself, as the fetch standard decodes it, without a transport: for any method, a `HEAD` without a body, and its type parsed and serialized as a MIME type (lowercase, no spaces, invalid parameters dropped), as Node's `fetch` answers; any other scheme than `http:` and `https:` rejects with a `TypeError` whose `cause.code` is `EPROTONOSUPPORT`, at the start as for a redirect target. The global `fetch` stays withheld;
230
230
  - `tty`, for programs on a terminal: `getAttr(fd)` and `setAttr(fd, termios)` read and set its termios (`{ c_iflag, c_oflag, c_cflag, c_lflag, c_cc }`, Linux values), `setRaw(fd, raw)` switches raw mode on as libuv (and Node's `setRawMode`) does (no echo, no line editing, no `^C` signal, each key read as it comes) and off again to the termios it had before (per terminal: after raw mode on fds 0 and 1 of one terminal, turning it off on either restores the termios from before the first), `size(fd)` answers `{ columns, rows }`, and `foreground(fd)` and `setForeground(fd, pgrp)` get and set the foreground process group, under the same job-control rules as other programs. A window resize arrives as `SIGWINCH` through `signals`. A terminal the program left raw is set back when the program ends, whether it returns, exits or throws (a program killed by a signal cannot do so). On a descriptor that is not a terminal, each call fails with `ENOTTY`;
231
- - `exit(status)`, which ends the program once its pending writes are done.
231
+ - `exit(status)`, which ends the program once its pending writes are done;
232
+ - `onUncaught(listener)` and `onUnhandledRejection(listener)`, which take the errors the program throws outside any call and the promises it leaves rejected (`listener(error, promise)`), as Node's `uncaughtException` and `unhandledRejection` events do. While a listener is registered, the program decides what such an error means; each call answers a function that removes its listener. A listener that throws ends the program as an unhandled error does.
232
233
 
233
- A program that throws, or leaves an error or a rejected promise unhandled, ends with status 1 and the message on stderr; one whose module cannot be loaded, or exports no function, ends with 126. A write to a pipe whose reader is gone ends the program with 141, unless it ignores or catches `SIGPIPE` (an ignore inherited from its parent counts), in which case the write fails with `EPIPE`. Errors carry a POSIX `code`. Reads and writes of pipes, terminals and sockets go through the kernel's asynchronous operations, so a program blocked reading stdin can still write and handle signals; other calls of one process are made one at a time. A JS program cannot `fork`, and threads it starts itself are not the kernel's.
234
+ A program that throws, or leaves an error or a rejected promise unhandled with no listener for it, ends with status 1 and the message on stderr; one whose module cannot be loaded, or exports no function, ends with 126. A write to a pipe whose reader is gone ends the program with 141, unless it ignores or catches `SIGPIPE` (an ignore inherited from its parent counts), in which case the write fails with `EPIPE`. Errors carry a POSIX `code`. Reads and writes of pipes, terminals and sockets go through the kernel's asynchronous operations, so a program blocked reading stdin can still write and handle signals; other calls of one process are made one at a time. A JS program cannot `fork`, and threads it starts itself are not the kernel's.
234
235
 
235
- The module has the trust of an Emscripten program's glue or a WASI program's imports module: it comes from a package that was installed, and it runs in the worker of its own process. The kernel withholds the worker's own ways out before it loads the module: `fetch`, `XMLHttpRequest`, `WebSocket`, `WebTransport`, `EventSource`, `importScripts`, `indexedDB`, `caches`, `cookieStore`, `Worker`, `SharedWorker`, `BroadcastChannel`, `postMessage`, `close`, and `navigator.storage`, `navigator.locks` and `navigator.serviceWorker` are `undefined`, and a worker where one of them cannot be removed refuses to run the program (126). A program's files are the kernel's, through the context; it reaches the kernel only through its own process's system calls, as the context makes them, and no other process's. This is a guard against programs going around the kernel, not a sandbox: `import()` of a URL is a request the kernel cannot withhold. Under the Node entry, a JS program also has Node's own modules.
236
+ The module has the trust of an Emscripten program's glue or a WASI program's imports module: it comes from a package that was installed, and it runs in the worker of its own process. The kernel withholds the worker's own ways out before it loads the module: `fetch`, `XMLHttpRequest`, `WebSocket`, `WebTransport`, `EventSource`, `importScripts`, `indexedDB`, `caches`, `cookieStore`, `Worker`, `SharedWorker`, `BroadcastChannel`, `postMessage`, `close`, and `navigator.storage`, `navigator.locks` and `navigator.serviceWorker` are `undefined`, and a worker where one of them cannot be removed refuses to run the program (126). A program's files are the kernel's, through the context; it reaches the kernel only through its own process's system calls, as the context makes them, and no other process's. This is a guard against programs going around the kernel, not a sandbox: `import()` of a URL is a request the kernel cannot withhold.
237
+
238
+ Under the Node entry, the kernel also withholds the host's Node from the program before its module loads ([#297](https://github.com/ai-ecoverse/slicc-kernel/issues/297)). `require`, `module`, `exports`, `__filename`, `__dirname`, `localStorage` and `sessionStorage` are `undefined` for good. `process`, `Buffer`, `global`, `setImmediate` and `clearImmediate` are `undefined` too, but stay writable, so a program can install its own (as slicc-jsh does). Imports of Node's builtin modules (`node:fs`, `fs`, `node:worker_threads`, …) and of anything but a `data:` URL, `file:` included, fail with `ERR_ACCESS_DENIED`, whether static, dynamic or through `import.meta.resolve`. A process worker that exits or crashes on its own ends its process with 70. This is the same guard, not a sandbox: a host that runs code it does not trust, such as an agent's, needs its own isolation around the Node process as well.
236
239
 
237
240
  ## Network
238
241
 
@@ -9448,6 +9448,7 @@ function isWasmSyscall(req) {
9448
9448
  return typeof op === "string" && SYSCALL_OPS.has(op);
9449
9449
  }
9450
9450
  var MAX_READ = 1024 * 1024;
9451
+ var KEYBOARD = /* @__PURE__ */ new Set([SIG.INT, SIG.QUIT, SIG.TSTP]);
9451
9452
  var DEFAULT_UMASK = 18;
9452
9453
  var DYING = { ok: false, errno: "EINTR", message: "the process is being killed" };
9453
9454
  var HOSTS_ONLY = new Resolver({ routes: new Routes() });
@@ -9466,6 +9467,8 @@ var WasmProcess = class {
9466
9467
  execTermsig;
9467
9468
  stopped = 0;
9468
9469
  stops = 0;
9470
+ owed = false;
9471
+ waits = 0;
9469
9472
  resumed = Promise.resolve();
9470
9473
  wake;
9471
9474
  stateListeners = [];
@@ -9521,6 +9524,7 @@ var WasmProcess = class {
9521
9524
  return "terminate";
9522
9525
  }
9523
9526
  this.options.onPending?.(sig);
9527
+ if (this.options.jobs?.keyboard && KEYBOARD.has(sig)) this.owed = true;
9524
9528
  const blocked = this.interrupt;
9525
9529
  this.interrupt = new AbortController();
9526
9530
  blocked.abort();
@@ -9565,8 +9569,10 @@ var WasmProcess = class {
9565
9569
  await this.resumed;
9566
9570
  if (this.dying) return DYING;
9567
9571
  const stops = this.stops;
9572
+ const waits = this.waits;
9568
9573
  const result = await this.dispatch(req, cancelled);
9569
9574
  if (this.dying) return DYING;
9575
+ if (!result.ok && result.errno === "EINTR" && this.waits !== waits) this.owed = false;
9570
9576
  const restart = !result.ok && result.errno === "EINTR" && this.stops !== stops;
9571
9577
  if (restart && !this.options.hasPending?.()) continue;
9572
9578
  await this.resumed;
@@ -9619,8 +9625,11 @@ var WasmProcess = class {
9619
9625
  refuseIfPending(restart) {
9620
9626
  if (restart && this.options.hasPending?.()) throw new KernelError("EINTR");
9621
9627
  }
9622
- blockingSignal(cancelled) {
9623
- if (this.options.hasPending?.() || cancelled?.aborted) throw new KernelError("EINTR");
9628
+ blockingSignal(cancelled, owing = true) {
9629
+ if (owing) this.waits++;
9630
+ if (this.options.hasPending?.() || cancelled?.aborted || owing && this.owed) {
9631
+ throw new KernelError("EINTR");
9632
+ }
9624
9633
  return cancelled ? AbortSignal.any([this.interrupt.signal, cancelled]) : this.interrupt.signal;
9625
9634
  }
9626
9635
  async read(req, cancelled) {
@@ -9766,7 +9775,7 @@ var WasmProcess = class {
9766
9775
  return { ok: true, kind: "json", json: this.options.jobs?.terminalNames() ?? [] };
9767
9776
  case "fd-select": {
9768
9777
  const { read, write, timeoutMs } = req;
9769
- const signal = this.blockingSignal(cancelled);
9778
+ const signal = this.blockingSignal(cancelled, timeoutMs !== 0);
9770
9779
  const selected = await selectFds(this.fds, read, write, timeoutMs, signal);
9771
9780
  return { ok: true, kind: "json", json: selected };
9772
9781
  }
@@ -10442,6 +10451,7 @@ var JobTable = class {
10442
10451
  handoffs = /* @__PURE__ */ new Map();
10443
10452
  foreground = /* @__PURE__ */ new Map();
10444
10453
  terminals = /* @__PURE__ */ new Map();
10454
+ keyboard = false;
10445
10455
  add(pid, parentPid, signal, terminal) {
10446
10456
  const parent = parentPid === void 0 ? void 0 : this.members.get(parentPid);
10447
10457
  if (!parent && terminal) this.terminals.set(pid, terminal);
@@ -10611,6 +10621,14 @@ var JobTable = class {
10611
10621
  signalForeground(tty, fallback, sig) {
10612
10622
  this.killGroup(this.tcgetpgrp(tty, fallback), sig);
10613
10623
  }
10624
+ fromKeyboard(send) {
10625
+ this.keyboard = true;
10626
+ try {
10627
+ send();
10628
+ } finally {
10629
+ this.keyboard = false;
10630
+ }
10631
+ }
10614
10632
  };
10615
10633
 
10616
10634
  // src/kernel/settling.ts
@@ -12437,7 +12455,8 @@ var Launcher = class {
12437
12455
  keySignal(tty, fallback, sig, send) {
12438
12456
  const pgid = this.jobs.tcgetpgrp(tty, fallback);
12439
12457
  const foreground = () => this.jobs.tcgetpgrp(tty, fallback);
12440
- this.settling.deliver(sig, pgid, send, tty.reading ? void 0 : foreground);
12458
+ const keyed = () => this.jobs.fromKeyboard(send);
12459
+ this.settling.deliver(sig, pgid, keyed, tty.reading ? void 0 : foreground);
12441
12460
  }
12442
12461
  umaskOf(pid) {
12443
12462
  return this.processes.get(pid)?.umask?.() ?? DEFAULT_UMASK;