@ai-ecoverse/slicc-kernel 1.48.0 → 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';
@@ -186,7 +186,7 @@ A descriptor a program inherits at one of those numbers (from bash's `3<file`, o
186
186
  On top of preview1 a WASI program gets:
187
187
 
188
188
  - **Threads**: `wasi.thread-spawn` (wasm32-wasip1-threads) and WASIX `thread_spawn_v2` start a worker per thread on the process's shared memory, sized from the module's own import (a 2 GiB maximum when the engine cannot reserve more). The threads share one descriptor table; `exit` or a trap in any thread ends the process. At most 64 threads per process; `SLICC_WASM_THREADS` sets another cap, up to 256.
189
- - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid`, pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`.
189
+ - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid` (with `WNOHANG`, and `WUNTRACED` for a child that stops; WASIX has no `WCONTINUED`), pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`.
190
190
  - **Job control:** `^Z`, `SIGTSTP`, `SIGTTIN` and `SIGTTOU` stop a WASIX program at once, unless it has a handler for them. A stopped program that is busy computing is stopped for job control (`jobs` shows it, `fg`, `bg` and `SIGCONT` work), but its worker keeps a core busy until its next system call, since a worker's running code cannot be frozen from outside. Agents should `kill` such a job rather than leave it stopped. For the same reason, a signal with a handler or a terminating default (`^C`, `SIGTERM`) reaches a program that is busy computing only at its next system call, so a CPU-bound loop may not end on `^C`; `kill -9` always ends it. A thread blocked in the kernel or waiting on a lock (`pthread_cond_wait`, a futex) is woken by a signal for it (a futex wait checks for signals every 50 ms, so each waiting thread wakes briefly that often), and `pthread_kill` runs the handler on the thread it names.
191
191
  - **Pipes** show to WASI and WASIX programs as stream sockets (`fstat` gives `S_IFSOCK`), because WASI has no FIFO file type and an unknown type made programs refuse them (Python's asyncio subprocess pipes). They are still read and written as pipes; socket calls on them fail with `ENOTSOCK`, and `isatty()` is false. In a WASIX shell, `[ -p ]` is therefore false for a pipe and `[ -S ]` true. Emscripten programs, bash included, still see a FIFO. The same holds for any kernel descriptor that is neither seekable nor a terminal or device, such as captured output: WASI and WASIX programs see it as a stream socket too.
192
192
  - **Sockets**: the WASIX socket calls (`sock_open`, `sock_pair` for an `AF_UNIX` stream pair, `bind`, `listen`, `connect`, `accept`, options, local and peer names) are the kernel's loopback sockets, and preview1's `sock_recv` / `sock_send` / `sock_shutdown` work on them and on inherited ones. Writing to a socket whose peer is gone fails with `EPIPE`, through `fd_write` as through `sock_send`, as if `MSG_NOSIGNAL` were set, since a WASI program cannot ignore `SIGPIPE`; a pipe or terminal whose reader is gone still ends the program with status 141. HTTP and HTTPS leave through the realm proxy that `https_proxy` names, as for any other program; `resolve` answers `/etc/hosts` names and literal addresses, and the [uplink](#uplink)'s names.
@@ -226,13 +226,16 @@ The module's default export (or its `main` export) is called with a context and
226
226
  - `wait(pid)` resolves with `{ status }` once a child ends, or `{ status: 128 + signal, signal }` when a signal ended it, without blocking the program; `kill(pid, signal)` signals any process, by number or as `'SIGTERM'` (the default);
227
227
  - `net`: `listen({ port, host, backlog })` and `connect({ host, port, signal })` (`host` defaults to `127.0.0.1`) on the kernel's loopback, the same sockets WASIX programs use, so a JS server is reached by other processes, by the page as `<port>.kernel.localhost` (`loopbackFetch`, `dial`) and by attached clients. A listener has its `port` (as bound, so port `0` picks a free one), `accept({ signal })`, `close()` and `for await` over its connections. A connection has `local` and `remote` addresses, `readable` and `writable` web streams (closing the writable shuts down the sending side, so the peer reads the end), and `close()`. The loopback is IPv4 (`127.0.0.1` by default, or `0.0.0.0`), so an IPv6 host such as `::1` fails with `EADDRNOTAVAIL` for both `listen` and `connect`, and binding and connecting fail as they would for a WASIX program (`EADDRINUSE`, `ECONNREFUSED`, `EADDRNOTAVAIL`). A blocked accept, read or write waits without holding anything in the kernel, so aborting it or closing the socket leaves nothing behind. A write to a connection the peer closed fails with `EPIPE` and no `SIGPIPE`;
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
- - `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 (a `GET` only, as in Node), without a transport; 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
- - `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, `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.
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
+ - `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;
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
 
@@ -465,7 +468,7 @@ WASIX's own terminal calls carry only echo and line buffering (`tty_set`) and an
465
468
  | Import | Does |
466
469
  | --- | --- |
467
470
  | `tcgetattr(fd, termios_out)` | writes the terminal's termios |
468
- | `tcsetattr(fd, actions, termios)` | sets every flag and `c_cc` as given (unknown bits are kept and read back), at once for `TCSANOW`, `TCSADRAIN` and `TCSAFLUSH` (0 to 2; anything else is `EINVAL`) |
471
+ | `tcsetattr(fd, actions, termios)` | sets every flag and `c_cc` as given (unknown bits are kept and read back), at once for `TCSANOW`, `TCSADRAIN` and `TCSAFLUSH` (0 to 2; anything else is `EINVAL`); `TCSAFLUSH` also drops the input typed but not yet read, the pending line too, as an Emscripten program's `TCSETSF` does |
469
472
  | `winsize(fd, winsize_out)` | writes `struct winsize` (`u16` rows, columns, then zero pixels) |
470
473
 
471
474
  A wasix-sysroot that builds `tcgetattr`, `tcsetattr`, `isatty` and `ioctl(TCGETS, TCSETS*, TIOCGWINSZ)` on them gives WASIX programs (python's `tty.setraw`, curses, ruby's `IO#raw`) a really raw terminal. Stock `tty_get` answers `ENOTTY` when none of the process's descriptors is a terminal, instead of an 80×24 terminal that isn't there, so stdio on pipes and files isn't taken for a terminal.
@@ -8059,9 +8059,13 @@ var KernelTty = class {
8059
8059
  tcgets() {
8060
8060
  return { ...this.termios, c_cc: [...this.termios.c_cc] };
8061
8061
  }
8062
- tcsets(termios) {
8062
+ tcsets(termios, flush = false) {
8063
8063
  const wasCanonical = this.canonical;
8064
8064
  this.termios = { ...termios, c_cc: [...termios.c_cc] };
8065
+ if (flush) {
8066
+ this.line = [];
8067
+ this.readable = [];
8068
+ }
8065
8069
  if (wasCanonical && !this.canonical && this.line.length > 0) {
8066
8070
  this.readable.push(Uint8Array.from(this.line));
8067
8071
  this.line = [];
@@ -9444,6 +9448,7 @@ function isWasmSyscall(req) {
9444
9448
  return typeof op === "string" && SYSCALL_OPS.has(op);
9445
9449
  }
9446
9450
  var MAX_READ = 1024 * 1024;
9451
+ var KEYBOARD = /* @__PURE__ */ new Set([SIG.INT, SIG.QUIT, SIG.TSTP]);
9447
9452
  var DEFAULT_UMASK = 18;
9448
9453
  var DYING = { ok: false, errno: "EINTR", message: "the process is being killed" };
9449
9454
  var HOSTS_ONLY = new Resolver({ routes: new Routes() });
@@ -9462,6 +9467,8 @@ var WasmProcess = class {
9462
9467
  execTermsig;
9463
9468
  stopped = 0;
9464
9469
  stops = 0;
9470
+ owed = false;
9471
+ waits = 0;
9465
9472
  resumed = Promise.resolve();
9466
9473
  wake;
9467
9474
  stateListeners = [];
@@ -9517,6 +9524,7 @@ var WasmProcess = class {
9517
9524
  return "terminate";
9518
9525
  }
9519
9526
  this.options.onPending?.(sig);
9527
+ if (this.options.jobs?.keyboard && KEYBOARD.has(sig)) this.owed = true;
9520
9528
  const blocked = this.interrupt;
9521
9529
  this.interrupt = new AbortController();
9522
9530
  blocked.abort();
@@ -9561,8 +9569,10 @@ var WasmProcess = class {
9561
9569
  await this.resumed;
9562
9570
  if (this.dying) return DYING;
9563
9571
  const stops = this.stops;
9572
+ const waits = this.waits;
9564
9573
  const result = await this.dispatch(req, cancelled);
9565
9574
  if (this.dying) return DYING;
9575
+ if (!result.ok && result.errno === "EINTR" && this.waits !== waits) this.owed = false;
9566
9576
  const restart = !result.ok && result.errno === "EINTR" && this.stops !== stops;
9567
9577
  if (restart && !this.options.hasPending?.()) continue;
9568
9578
  await this.resumed;
@@ -9615,8 +9625,11 @@ var WasmProcess = class {
9615
9625
  refuseIfPending(restart) {
9616
9626
  if (restart && this.options.hasPending?.()) throw new KernelError("EINTR");
9617
9627
  }
9618
- blockingSignal(cancelled) {
9619
- 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
+ }
9620
9633
  return cancelled ? AbortSignal.any([this.interrupt.signal, cancelled]) : this.interrupt.signal;
9621
9634
  }
9622
9635
  async read(req, cancelled) {
@@ -9762,7 +9775,7 @@ var WasmProcess = class {
9762
9775
  return { ok: true, kind: "json", json: this.options.jobs?.terminalNames() ?? [] };
9763
9776
  case "fd-select": {
9764
9777
  const { read, write, timeoutMs } = req;
9765
- const signal = this.blockingSignal(cancelled);
9778
+ const signal = this.blockingSignal(cancelled, timeoutMs !== 0);
9766
9779
  const selected = await selectFds(this.fds, read, write, timeoutMs, signal);
9767
9780
  return { ok: true, kind: "json", json: selected };
9768
9781
  }
@@ -9850,6 +9863,7 @@ var WasmProcess = class {
9850
9863
  fdInfo(fd) {
9851
9864
  const file = this.fds.get(fd).file;
9852
9865
  const flags = this.fds.statusFlags(fd);
9866
+ const terminal = (file.tty ?? file.pty?.slave)?.name;
9853
9867
  return {
9854
9868
  tty: file.tty !== void 0,
9855
9869
  kind: file instanceof KernelSocket ? "socket" : kernelFdKind(file),
@@ -9858,7 +9872,8 @@ var WasmProcess = class {
9858
9872
  ...this.fds.closesOnExec(fd) ? { cloexec: true } : {},
9859
9873
  ...file.read && !file.write ? { access: "read" } : {},
9860
9874
  ...file.write && !file.read ? { access: "write" } : {},
9861
- ...file.tty?.name ? { name: file.tty.name } : file.pty ? { name: "/dev/ptmx" } : {}
9875
+ ...file.tty?.name ? { name: file.tty.name } : file.pty ? { name: "/dev/ptmx" } : {},
9876
+ ...terminal ? { terminal } : {}
9862
9877
  };
9863
9878
  }
9864
9879
  ttySyscall(req) {
@@ -9868,7 +9883,7 @@ var WasmProcess = class {
9868
9883
  case "tty-get":
9869
9884
  return { ok: true, kind: "json", json: tty.tcgets() };
9870
9885
  case "tty-set":
9871
- tty.tcsets(req.termios);
9886
+ tty.tcsets(req.termios, req.flush === true);
9872
9887
  return { ok: true, kind: "void" };
9873
9888
  case "tty-winsz":
9874
9889
  return { ok: true, kind: "json", json: tty.winsize() };
@@ -10436,6 +10451,7 @@ var JobTable = class {
10436
10451
  handoffs = /* @__PURE__ */ new Map();
10437
10452
  foreground = /* @__PURE__ */ new Map();
10438
10453
  terminals = /* @__PURE__ */ new Map();
10454
+ keyboard = false;
10439
10455
  add(pid, parentPid, signal, terminal) {
10440
10456
  const parent = parentPid === void 0 ? void 0 : this.members.get(parentPid);
10441
10457
  if (!parent && terminal) this.terminals.set(pid, terminal);
@@ -10605,6 +10621,14 @@ var JobTable = class {
10605
10621
  signalForeground(tty, fallback, sig) {
10606
10622
  this.killGroup(this.tcgetpgrp(tty, fallback), sig);
10607
10623
  }
10624
+ fromKeyboard(send) {
10625
+ this.keyboard = true;
10626
+ try {
10627
+ send();
10628
+ } finally {
10629
+ this.keyboard = false;
10630
+ }
10631
+ }
10608
10632
  };
10609
10633
 
10610
10634
  // src/kernel/settling.ts
@@ -12431,7 +12455,8 @@ var Launcher = class {
12431
12455
  keySignal(tty, fallback, sig, send) {
12432
12456
  const pgid = this.jobs.tcgetpgrp(tty, fallback);
12433
12457
  const foreground = () => this.jobs.tcgetpgrp(tty, fallback);
12434
- 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);
12435
12460
  }
12436
12461
  umaskOf(pid) {
12437
12462
  return this.processes.get(pid)?.umask?.() ?? DEFAULT_UMASK;