@ai-ecoverse/slicc-kernel 1.124.13 → 1.125.1

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
@@ -265,7 +265,7 @@ An empty path is `ENOENT` in every path call (`path_open`, `path_filestat_get`,
265
265
  On top of preview1 a WASI program gets:
266
266
 
267
267
  - **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, and a file written, created, renamed or removed in one thread shows its new size and metadata to `stat` in every other thread at once, under any of its names (a symlink, a hard link, a new name after a rename in the same process), also when another thread renames a file over that name meanwhile, since the kernel reports which file an open or a change reached; this holds among the kernel's own processes, not against other tabs, `hostfs` writers or programs that aren't WASI; another process's changes show at close and after a `spawn` or `wait`, as with close-to-open caching, so a file another process renames while this one holds it open may show its old size under the new name until then; `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.
268
- - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), with the unwound frames kept in the unused part of the program's stack, below its stack pointer (for a position-independent program, the stack the kernel's linker gives it), so a fork from deep in the stack leaves the program's data alone, and a forked child's thread-locals (`errno` included) where the parent left them: the kernel reruns the module's `__wasm_init_tls` at the parent's TLS base (and puts back the block it overwrites), which also sets the internal globals that hold thread-local addresses (a position-independent program's, a side module's, and those of a variable in the `global-dynamic` TLS model), 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`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. As in glibc, one `dlopen` at a time runs in a process, constructors included: a `dlopen` from a library's constructor goes ahead in its own thread, and another thread's `dlopen` waits, then finds the library loaded and gets the same handle. A thread waiting there still ends or stops with its process, but a signal handler it has runs once that `dlopen` returns. The lock is let go when a `dlopen` fails (an exception, a trap or `pthread_exit` out of a constructor included) and when its thread ends; a forked child starts with it free. A forked child also gets the side modules its parent had loaded, at the same addresses and table slots, with the slot and closure counters and the parent's thread-local blocks: they are linked again in the child before it resumes, without running their constructors or their data relocations again, since their data is already in the copied memory. The same goes for the main module of a position-independent program: its data relocations ran once, in the parent, so a pointer the parent changed before the fork (`int *p = &a; p = &b;`) is still changed in the child (#777). A side-module function on the stack when `fork` is called (a callback into the main module that forks) is not instrumented by Asyncify, so it runs again when the stack is rewound, in the parent and in the child, and libffi frames there can trap; side modules would need building with Asyncify for that, which the toolchain does not do yet. In such a program, `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); a program that isn't position-independent gets an error from `dlopen(NULL)`, as from any `dlopen`; `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`. Each WASIX thread has its own Asyncify driver too, so a thread can `longjmp` through Asyncify and `fork` (#710). The unwound frames go at the bottom of the thread's own stack (the `stack` and `stack_size` its libc passes to `wasi_thread_start`), never on the main thread's. A thread whose start args don't look like wasix-libc's (a `stack_size` under 4096 or over the memory, a stack top that isn't 16-aligned or not right above the args) gets no driver, so `fork` there is `ENOSYS` as before, rather than an unwind buffer at a bad address. A child forked from a thread runs as that thread: it's rewound through `wasi_thread_start`, can fork again, and ends when the thread's function returns. A program built with exceptions (every position-independent one) does `setjmp` with exceptions rather than Asyncify, so there only `fork` uses the driver. A kernel timer can be longer than `setTimeout`'s 2^31-1 ms (about 24.8 days): an `alarm`, an interval timer, or a `select`, `poll_oneoff` or `epoll_wait` timeout waits its whole time, re-armed in steps of at most 2^31-1 ms, instead of firing at once or spinning (#767). A WASI or WASIX program that a signal ends (`SIGKILL`, or a signal whose default terminates it) stops at its next kernel call. The kernel first lets the calls it is already serving finish and the program write its files back: it ends the process once the program has made no file call for 2 s, and 5 s after the signal at the latest. A call the program makes after the kill is answered `EINTR` at once and doesn't extend the wait, so a program that swallows the kill and keeps retrying (python writing a fatal error to stderr) still ends (#755).
268
+ - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), with the unwound frames kept in the unused part of the program's stack, below its stack pointer (for a position-independent program, the stack the kernel's linker gives it), so a fork from deep in the stack leaves the program's data alone, and a forked child's thread-locals (`errno` included) where the parent left them: the kernel reruns the module's `__wasm_init_tls` at the parent's TLS base (and puts back the block it overwrites), which also sets the internal globals that hold thread-local addresses (a position-independent program's, a side module's, and those of a variable in the `global-dynamic` TLS model), 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`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. As in glibc, one `dlopen` at a time runs in a process, constructors included: a `dlopen` from a library's constructor goes ahead in its own thread, and another thread's `dlopen` waits, then finds the library loaded and gets the same handle. A thread waiting there still ends or stops with its process, but a signal handler it has runs once that `dlopen` returns. The lock is let go when a `dlopen` fails (an exception, a trap or `pthread_exit` out of a constructor included) and when its thread ends; a forked child starts with it free. A forked child also gets the side modules its parent had loaded, at the same addresses and table slots, with the slot and closure counters and the parent's thread-local blocks: they are linked again in the child before it resumes, without running their constructors or their data relocations again, since their data is already in the copied memory. The same goes for the main module of a position-independent program: its data relocations ran once, in the parent, so a pointer the parent changed before the fork (`int *p = &a; p = &b;`) is still changed in the child (#777). A side-module function on the stack when `fork` is called (a callback into the main module that forks) is not instrumented by Asyncify, so it runs again when the stack is rewound, in the parent and in the child, and libffi frames there can trap; side modules would need building with Asyncify for that, which the toolchain does not do yet. In such a program, `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); a program that isn't position-independent gets an error from `dlopen(NULL)`, as from any `dlopen`; `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`. Each WASIX thread has its own Asyncify driver too, so a thread can `longjmp` through Asyncify and `fork` (#710). The unwound frames go at the bottom of the thread's own stack (the `stack` and `stack_size` its libc passes to `wasi_thread_start`), never on the main thread's. A thread whose start args don't look like wasix-libc's (a `stack_size` under 4096 or over the memory, a stack top that isn't 16-aligned or not right above the args) gets no driver, so `fork` there is `ENOSYS` as before, rather than an unwind buffer at a bad address. A child forked from a thread runs as that thread: it's rewound through `wasi_thread_start`, can fork again, and ends when the thread's function returns. A program built with exceptions (every position-independent one) does `setjmp` with exceptions rather than Asyncify, so there only `fork` uses the driver. A kernel timer can be longer than `setTimeout`'s 2^31-1 ms (about 24.8 days): an `alarm`, an interval timer, or a `select`, `poll_oneoff` or `epoll_wait` timeout waits its whole time, re-armed in steps of at most 2^31-1 ms, instead of firing at once or spinning (#767). A `poll_oneoff` read event's `nbytes` is the number of bytes a read would get at once, as `FIONREAD` counts them, for a pipe, a FIFO, a socket, a terminal and a pty master; on a hangup it's 0 when nothing is left, so a libc can report `POLLHUP` without `POLLIN`, as Linux does (#1053). Other descriptors, regular files included, answer 1. A WASI or WASIX program that a signal ends (`SIGKILL`, or a signal whose default terminates it) stops at its next kernel call. The kernel first lets the calls it is already serving finish and the program write its files back: it ends the process once the program has made no file call for 2 s, and 5 s after the signal at the latest. A call the program makes after the kill is answered `EINTR` at once and doesn't extend the wait, so a program that swallows the kill and keeps retrying (python writing a fatal error to stderr) still ends (#755).
269
269
  - **epoll and eventfd** (WASIX `epoll_create`, `epoll_ctl`, `epoll_wait` and `fd_event`, which tokio's reactor uses through the WASIX mio fork):
270
270
  - **Where they live:** an epoll instance and an eventfd are kernel descriptors, so they survive `dup`, `fork` and `exec` (unless close-on-exec) and are shared by a process's threads.
271
271
  - **What epoll watches:** sockets (listening, connecting, connected, loopback and uplink), pipes, terminals, `kernel.run`'s stdin, eventfds and other epoll instances (a cycle of instances is `ELOOP`).
@@ -358,6 +358,20 @@ The module's default export (or its `main` export) is called with a context and
358
358
  - `fdStatus(fd)` (`{ type, tty, seekable }`, `type` being `file`, `pipe`, `tty`, `socket`, `device` or `directory`) and `isatty(fd)`, to tell `< file` from a pipe;
359
359
  - `open(path, { read, write, append, create, exclusive, truncate, nonblock, signal })`, which returns a file with `read(position, length)`, `write(position, bytes)`, `size()`, `truncate(size)`, `sync()` and `close()`. Reads and writes go through the kernel's paged files at those positions, so a large file is never read whole; **A FIFO** ([#935](https://github.com/ai-ecoverse/slicc-kernel/issues/935)) opens through the kernel's FIFO open, as fifo(7) says. A read-open waits for a writer and a write-open for a reader, unless the open is `read` and `write` both. `nonblock: true` gives a read-open at once and a write-open with no reader `ENXIO`, and `signal` aborts a waiting open with its reason. Its file reads and writes the pipe: `read(0, length)` and `write(0, bytes)`, any other position is `ESPIPE`, `size()` is `0`, and `truncate` is `EINVAL`. A socket node is `ENXIO`.
360
360
  - `fs`: `stat`, `lstat`, `exists`, `readdir`, `mkdir` (with parents, or, with `{ recursive: false }`, as Node's `fs.mkdir` without `recursive`: `EEXIST` for an existing entry and `ENOENT` for a missing parent, atomically among the processes of one kernel, so it can serve as a lock there, but not between two kernels on one origin, such as two tabs ([#519](https://github.com/ai-ecoverse/slicc-kernel/issues/519)); `sync.fs.mkdir` takes the same option), `rm` (recursive), `unlink`, `rename`, `symlink`, `readlink`, `statfs`, `chmod(path, mode)`, and `readFile`/`writeFile` for small files. `chmod` takes the permission bits (`0` to `0o7777`, `EINVAL` otherwise) and follows a symlink; as `chmod(2)`, only the file's owner or root may (`EPERM`), so a program can make a script it wrote runnable by path. `sync.fs.chmod` is the same, synchronously. `statfs(path)` answers `{ type, bsize, blocks, bfree, bavail, files, ffree, quota, usage }`: the fields of Node's `StatFs`, plus `quota` and `usage` in bytes (what jsh 0.17 reads). The values come from the same source as an Emscripten program's `statfs`/`df`: the driver of the mount the path is on, or, on the root file system, the origin's storage estimate (`navigator.storage.estimate()`). `bsize` is 4096, `bfree` and `bavail` are what's left of the quota, and `type`, `files` and `ffree` are 0, since the kernel doesn't know them. Where there's no estimate (the Node entry's root), every count is 0. A missing path is `ENOENT`. `sync.fs` has it too. `rm` removes a whole tree in one kernel call (on the OPFS root, one recursive removal and one metadata delete: 50,000 files take about 3 s in Chrome, where `rm -r` takes about 25 s) with the result of a file-by-file removal: symlinks inside the tree are removed, never followed; a tree holding a protected path is refused with `EROFS` and nothing is removed; a mount inside the tree is emptied and its mount point answers `EBUSY`, while the rest is removed; under `users`, what the user may remove is removed and the call then fails with the first `EACCES`/`EPERM`; files a process holds open stay readable to it;
361
+ - **`fs.watch(path, options?, onEvent)`** resolves with `{ close() }` ([#1031](https://github.com/ai-ecoverse/slicc-kernel/issues/1031)). `sync.fs.watch` returns the watcher directly and is in place when it returns, so it sees every change made after the call. This is the shape for Node's synchronous `fs.watch`.
362
+ - **What it reports:** a file reports its own changes. A directory reports its direct entries, or with `{ recursive: true }`, everything below it.
363
+ - **Events:** each one is `{ type, filename }`, in the order the operations happened.
364
+ - `'rename'`: an entry appeared or went away (create, `mkdir`, `unlink`, `rm`, both names of a `rename`, `link`, `symlink`).
365
+ - `'change'`: an existing entry's data or metadata changed (a write, `truncate`, `chmod`, `chown`, `utimes`). A write that creates a file gives `'rename'` and then `'change'`, as on Linux.
366
+ - `filename` is the entry's name relative to the watched directory (`sub/a.txt` when recursive), or the file's basename for a file watch.
367
+ - **Overflow:** a watch holds 1,000 events between reads. Beyond that, the rest are dropped, and `{ overflow: true }` follows the ones kept.
368
+ - **The watched path itself:** when it is removed or renamed away (or an ancestor is), the watch gets one `'rename'` with its own name, then `{ ended: true }`, and ends. `close()` is safe at any time and idempotent, and nothing is delivered after it returns.
369
+ - **Errors at watch time:** a path the program may not read (under `users`) is `EACCES`, and one that doesn't exist is `ENOENT`. Permission is checked first, so a path under a directory the program can't search is `EACCES` whether or not it exists. A process holds at most 8,192 watches; past that, `ENOSPC`.
370
+ - **Permissions per event:** under `users`, a program only hears about paths it may reach. An event about `P` below the watched directory is delivered only if the program's user can search every directory down to `P`'s parent and read that parent, as Node's recursive watch would need in order to add a watch there. The check runs when the program takes the events, so another user's `0700` directory stays silent until it's opened up. A consequence is that events inside a directory that is gone by the time the program takes them are dropped: after `rm -r sub` the program sees `rename sub` but not `sub/a`. A watcher should rescan on a directory's `rename`, as Node users do anyway.
371
+ - **Who counts as a writer:** every process in the kernel, the embedder (`kernel.writeFile` and the like) and the mounts that report changes. A `hostfs` change from the host comes as `'change'`, since the host doesn't say which kind it was.
372
+ - **Not reported:** another kernel's changes on the same origin (another tab), as inotify on a shared file system doesn't see other hosts.
373
+ - **Lifetime:** a watch ends with its process and doesn't keep the program alive.
374
+ - **The older watches** (`kernel.watch`, a client's `fs.watch` with `{ paths }`) are unchanged.
361
375
  - `/dev`, in `open`, `fs` and their `sync` forms, as a WASI or Emscripten program sees it, with the same modes and owners as under `users`: `null`, `zero`, `full`, `random`, `urandom` and `tty` (character devices, `0666`, root's), `stdin`, `stdout` and `stderr` (links to `/proc/self/fd/0`, `1`, `2`), `fd` (a link to `/proc/self/fd`, so `/dev/fd/N` is descriptor N) and the kernel's terminals `/dev/ttyN` and `/dev/pts/N` (with users on, owned by the session's user or the master's opener, `0620`). `/dev/shm` is the kernel's shared tmpfs (see [Mounts](#mounts)), read and written like any other mount; `/dev/ptmx` isn't there. `stat` through `/dev/stdin`, `/dev/stdout` or `/dev/fd/N` describes the program's own descriptor (the file it is redirected to, a pipe, a terminal), `readlink` of `/dev/fd/N` names it (`pipe:[N]` and `socket:[N]` for a pipe or a socket), and a closed descriptor is `ENOENT`. Opening a device gives a kernel descriptor on it; opening `/dev/std*` or `/dev/fd/N` opens the file again with the flags asked for when the descriptor is on a file (as Linux reopens `/proc/self/fd/N`, so `append` appends and `truncate` truncates), and otherwise duplicates the descriptor; `/dev/tty` is the program's controlling terminal (`ENXIO` without one); and `/dev/ttyN` and `/dev/pts/N` are opened by name through the kernel, so a terminal that isn't the user's is `EACCES` under `enforce`. A file on anything but a regular file reads and writes as a stream (the position is ignored) and only in the directions it was opened for (`EBADF` otherwise), its `size()` is 0 and `truncate` is `EINVAL`. `readFile` reads a device or a descriptor to its end, up to 64 MiB, and fails with `EFBIG` past that (where Node's `readFile` throws `ERR_FS_FILE_TOO_LARGE`), so `readFile('/dev/zero')` fails instead of hanging, while a bounded `read` of it works. Other paths pay only a prefix check: they make the same kernel calls as before. Removing a `/dev` entry fails (see [`/dev`](#dev-entries)), and so does a new name directly in `/dev` or `/proc`;
362
376
  - `/proc`, in `fs`, `open` and their `sync` forms, the same as a WASI program's (see [`/proc`](#proc)): `stat`, `lstat`, `exists`, `readdir`, `readlink` and `readFile` answer it, and `/proc/self` is the program's own pid. It's read-only, so every change in it is `EROFS`: `writeFile`, `mkdir`, `rm`, `unlink`, `chmod`, a `rename`, `link` or `symlink` into or out of it, and an `open` for writing, creating or truncating. A file opened in it is a snapshot read at the open, with no descriptor (`fd` is `-1`): its `read` and `size` answer from the snapshot until it's closed, and `write` and `truncate` are `EBADF`. Other paths pay only a prefix check;
363
377
  - `signals`: `on(signal, handler)` catches a signal (by number or as `'SIGINT'`), `ignore(signal)` ignores it and `reset(signal)` restores its default action. A call blocked in a read or write is restarted after a handler runs; signals without a handler keep their default actions, so an uncaught `SIGINT` ends the program with 130 and `SIGKILL` always does;
@@ -1020,7 +1034,7 @@ The kernel is ported from SLICC's `packages/webapp/src/kernel/` with the browser
1020
1034
 
1021
1035
  - `src/kernel/`: the kernel side, per process (`host.ts` starts the worker and answers its syscalls in `process.ts`) and shared tables (descriptors, pipes, children, jobs, signals, terminals and pseudo-terminals, `select`), and process credentials (`cred.ts`).
1022
1036
  - `src/process/`: the runtime inside each process worker. It evaluates the Emscripten glue, mounts the live VFS, routes descriptors through the kernel and implements `fork` by copying the whole linear memory into a new worker (Asyncify). `fork` is the only call allowed to suspend through Asyncify: an `fsync` that Emscripten made asynchronous is answered synchronously by the file's own stream instead. `src/process/wasi/` runs WASI and WASIX programs.
1023
- - `src/realm/`: the synchronous bridge (`SharedArrayBuffer` + `Atomics.wait`) and the live Emscripten filesystem on top of it. When a program's file call gets no answer, the bridge treats it like an NFS mount (#990). A read-only call (`read`, `pread`, `stat`, `lstat`, `readdir`, `readlink`, `exists`, `access`, `statfs`) fails with `EIO` once nothing has come back for 25 s; the clock starts again with every chunk of a long answer, so a slow transfer that keeps answering never fails (the host keeps a long answer for 30 s after each chunk it hands over, not after the first), and a late answer is dropped, which is harmless because the call changed nothing. A call that changes the file system (writes, `rename`, `unlink`, `mkdir`, creates, `chmod`, holds…) waits for its answer like a hard mount, however long the backend is gone, so it never returns an error for an effect that lands later. Only a signal that ends the process (`SIGKILL`) or the kernel shutting down ends that wait. JavaScript programs' synchronous `ctx.sync.fs` calls go through their own lane, which waits without a deadline for every call, reads included, so a stalled read hangs there instead of failing with `EIO` (#1033).
1037
+ - `src/realm/`: the synchronous bridge (`SharedArrayBuffer` + `Atomics.wait`) and the live Emscripten filesystem on top of it. When a program's file call gets no answer, the bridge treats it like an NFS mount (#990). A read-only call (`read`, `pread`, `stat`, `lstat`, `readdir`, `readlink`, `exists`, `access`, `statfs`) fails with `EIO` once nothing has come back for 25 s; the clock starts again with every chunk of a long answer, so a slow transfer that keeps answering never fails (the host keeps a long answer for 30 s after each chunk it hands over, not after the first), and a late answer is dropped, which is harmless because the call changed nothing. A call that changes the file system (writes, `rename`, `unlink`, `mkdir`, creates, `chmod`, holds…) waits for its answer like a hard mount, however long the backend is gone, so it never returns an error for an effect that lands later. Only a signal that ends the process (`SIGKILL`) or the kernel shutting down ends that wait. JavaScript programs' synchronous `ctx.sync.fs` calls get the same treatment on their own lane (#1033): a read-only call (`readFile`, `stat`, `lstat`, `readdir`, `readlink`, `exists`, `statfs`) throws `EIO` after 25 s without an answer, and every other call, as well as descriptor calls such as a read from a pipe or a terminal, waits for its answer.
1024
1038
  - `src/fs/`: the OPFS filesystem and the virtual command directories.
1025
1039
  - `src/launcher.ts`, `src/commands.ts`, `src/serve.ts`, `src/index.ts`, `src/users.ts`: command resolution, the kernel worker protocol, the page API and the user database; `src/node.ts`, `src/node-process-worker.ts` and `src/node/` the headless Node entry. An embedder that uses `Launcher` directly, without `createKernel`, calls `await launcher.prepare()` first: it creates `/tmp` and `/home` and writes the CA certificate.
1026
1040
  - `src/kernel/net/`: the proxy, HTTP/1.1, TLS termination and the local CA, and the bridge to the page's transport; `src/transport.ts` and `src/local-proxy-transport.ts` are the page side.
@@ -1073,7 +1087,7 @@ Every change is certified in its pull request, before it merges (`cert-review-pr
1073
1087
 
1074
1088
  The tools in `tools/cert-review/`:
1075
1089
 
1076
- - **delta** (`delta.mjs`): lists the pull requests merged between the two tags (their merge commits, plus `pr` for a planted branch). Each gets its conventional-commit kind (`fix`, `feat`, `perf` and `revert` are behaviour changes) and the integration test files and titles it adds or changes. The titles are read from the pull request's patch: the added `test(…)` lines and the hunk headers git puts above changed lines. Escapes are decoded as JavaScript does. A title git cut short matches as a prefix, and each `${…}` in a template-literal title matches anything there, so `` `a forked child of a ${kind} WASIX program…` `` matches the rows it ran as. A title made only of placeholders is skipped.
1090
+ - **delta** (`delta.mjs`): lists the pull requests merged between the two tags (their merge commits, plus `pr` for a planted branch). Each is a behaviour change **by the paths it changes**, with the same rule the plan uses (`behaviourFiles`: `src/`, `build.mjs`, `package.json` and its lock, the build and publish workflows, the reviewer…), whatever its title says (#1036 and #1041 were titled "A2 …"), and it gets its conventional-commit kind and the integration test files and titles it adds or changes. Every behaviour change with tests gets its NEGATIVE: its tests run on the previous tree. A `fix` must fail there (`no-negative` otherwise); any other kind is `negative` or `passes`. A behaviour change whose NEGATIVE has no result (`prev-not-run`), or whose changed tests the delta can't name (`no-titles`), makes the record INCOMPLETE. The delta names the test around every changed line: a title on the `test(` line or the line after it (as biome wraps a long one), a template literal, a title in the context lines above the change, and, when the hunk header shows only `test(`, the title read from the file at the merge commit. A pull request that changes only `package.json` and `package-lock.json`, and in `package.json` only the versions in `dependencies`, `devDependencies` and `optionalDependencies` (delta.mjs compares the file before and after its merge), is decided `manifest` with no test gap: it passed the required `cert-review` in the merge queue, as identical dist or with a full review, so the release line doesn't repeat it. A scripts, exports or new-dependency change stays a behaviour change, with `no-test` when it has no tests. The titles are read from the pull request's patch: the added `test(…)` lines and the hunk headers git puts above changed lines. Escapes are decoded as JavaScript does. A title git cut short matches as a prefix, and each `${…}` in a template-literal title matches anything there, so `` `a forked child of a ${kind} WASIX program…` `` matches the rows it ran as. A title made only of placeholders is skipped.
1077
1091
  - **suite** (`suite.mjs`, `reporter.mjs`) runs on the bench pool, without secrets. It takes the tag's tree with its own dependencies and Chromium, and replaces `dist/` with the **published npm tarball** (for a planted branch, its own `npm pack`). The test command runs in a session of its own (`detached`), and `suite.mjs` notes a `SIGINT` (`ignored` in its summary) instead of dying of it. That way a stray signal to the step's process group can't cut the run short, while a `SIGTERM` (a real cancel) still ends it. The log is written as the run goes, and a `SIGTERM` still writes the summary with the results so far (`exit: 'signal SIGTERM'`). A run that left only its `.jsonl` (the runner was lost) is read from that, as `exit: 'no summary'`. The NEGATIVE step runs even when the release suite's step failed. Before it reports, `suite.mjs` re-runs once just the test files that left no result (no rows, a cancelled row, or a file-level failure, such as a lost process), with a second `node --test` over those files, and merges their results (`retried`, `firstExit`, `unfinished` in the summary). That holds for the release suite and for each NEGATIVE run. A fix whose test passes on its baseline stays `no-negative`.
1078
1092
  - The candidate job runs the pull request's whole `test/integration/` suite on its packed build.
1079
1093
  - The base job runs `main`'s own suite on `main`'s packed build, then the NEGATIVE (#861): `main`'s tree, with all of the pull request's `test/**` changes applied (test files, helpers and fixtures), on `main`'s build. So a fix in `tools/` fails there just as a fix in `src/` does. A test file that fails to load on `main` (a module or export that only the pull request adds) leaves no result for its titles, which is `prev-not-run`, never a NEGATIVE.
@@ -1091,7 +1105,7 @@ The tools in `tools/cert-review/`:
1091
1105
  - **record** (`record.mjs`), in code, with read-only permissions:
1092
1106
  - It re-downloads the tarball and checks `dist.integrity` (sha512) and the sha256, the SLSA v1 provenance (the tarball is its subject; `release.yml`, `refs/heads/main`, a GitHub-hosted builder; commit = `gitHead` = the tag), `npm audit signatures`, and that the commit is on `main`. A problem there, a `compat-crash`, or a planted build is a FAIL whatever the review says.
1093
1107
  - It adds the **known issues as of the release**: every issue labelled `known-issue` (open or closed) and every one in `known.json`, unless it was fixed before the release. An issue in another repository, whether listed or named by the reviewer, counts only while it is open. That holds when `known.json`'s `fixedIn` names an earlier or equal version, or the merged pull request that closed it is an ancestor of the tag, or it was closed (with no closing pull request) before the release commit. An issue closed as not planned or as a duplicate is dropped.
1094
- - The line is `certified @ai-ecoverse/slicc-kernel@<v> sha256=<…> (ci: <run>; known: …)`. Any test gaps from the findings (`no-test`, `no-negative`, `prev-not-run`, `no-titles`, `not-run`) are appended in code as `test gaps: #<pr> <kind>`, so a fix without a NEGATIVE is never certified silently.
1108
+ - The line is `certified @ai-ecoverse/slicc-kernel@<v> sha256=<…> (ci: <run>; known: …)`. Any test gaps from the findings (`no-test`, `no-negative`, `no-titles`, `not-run`) are appended in code as `test gaps: #<pr> <kind>`, so a fix without a NEGATIVE is never certified silently.
1095
1109
  - **verify** (`verify.mjs`) is the `release / verify on npm` job above.
1096
1110
 
1097
1111
  ### Certification in the pull request (`cert-review`)
@@ -4028,7 +4028,12 @@ function openPipe(capacity) {
4028
4028
  }
4029
4029
  },
4030
4030
  close: () => pipe.closeRead(),
4031
- poll: () => ({ readable: pipe.readReady, writable: false, hangup: pipe.writersGone }),
4031
+ poll: () => ({
4032
+ readable: pipe.readReady,
4033
+ writable: false,
4034
+ hangup: pipe.writersGone,
4035
+ nread: pipe.buffered
4036
+ }),
4032
4037
  changed: (signal) => pipe.changed(signal),
4033
4038
  identity: pipe
4034
4039
  }),
@@ -4816,7 +4821,8 @@ var KernelSocket = class _KernelSocket {
4816
4821
  readable: this.readShut || rx.readReady,
4817
4822
  writable: this.writeShut || tx.writeReady,
4818
4823
  hangup: rx.writersGone && tx.readersGone,
4819
- rdhup: rx.writersGone
4824
+ rdhup: rx.writersGone,
4825
+ nread: this.readShut ? 0 : rx.buffered
4820
4826
  };
4821
4827
  }
4822
4828
  changed(signal) {
@@ -4876,8 +4882,8 @@ var KernelSocket = class _KernelSocket {
4876
4882
  if (this.state !== "open") throw new KernelError("EINVAL");
4877
4883
  if (addr.family !== this.domain) throw new KernelError("EAFNOSUPPORT");
4878
4884
  this.failed = void 0;
4879
- const route = viaUplink && addr.family === "inet" ? this.net.uplinkFor(addr.host) : void 0;
4880
- if (route) return this.dial(addr, route);
4885
+ const route2 = viaUplink && addr.family === "inet" ? this.net.uplinkFor(addr.host) : void 0;
4886
+ if (route2) return this.dial(addr, route2);
4881
4887
  const target = this.net.target(addr, name2);
4882
4888
  if (!this.local) {
4883
4889
  if (addr.family === "inet") {
@@ -4933,9 +4939,9 @@ var KernelSocket = class _KernelSocket {
4933
4939
  }
4934
4940
  }
4935
4941
  switch;
4936
- connectSecure(addr, route, secure2) {
4942
+ connectSecure(addr, route2, secure2) {
4937
4943
  if (this.state !== "open") throw new KernelError("EISCONN");
4938
- return this.dial(addr, route, secure2);
4944
+ return this.dial(addr, route2, secure2);
4939
4945
  }
4940
4946
  upgradeSecure(make) {
4941
4947
  if (this.state !== "connected") throw new KernelError("ENOTCONN");
@@ -4943,7 +4949,7 @@ var KernelSocket = class _KernelSocket {
4943
4949
  if (this.switch.upgraded || this.writeShut || this.readShut) throw new KernelError("EINVAL");
4944
4950
  return this.switch.upgrade(make);
4945
4951
  }
4946
- dial(addr, route, secure2) {
4952
+ dial(addr, route2, secure2) {
4947
4953
  this.state = "connecting";
4948
4954
  const abort = new AbortController();
4949
4955
  this.dialing = abort;
@@ -4951,13 +4957,13 @@ var KernelSocket = class _KernelSocket {
4951
4957
  const timer = setTimeout(() => {
4952
4958
  timedOut = true;
4953
4959
  abort.abort(new KernelError("ETIMEDOUT"));
4954
- }, route.timeoutMs ?? DIAL_TIMEOUT_MS);
4960
+ }, route2.timeoutMs ?? DIAL_TIMEOUT_MS);
4955
4961
  const abandoned = new Promise((_, reject) => {
4956
4962
  abort.signal.addEventListener("abort", () => reject(abort.signal.reason), { once: true });
4957
4963
  });
4958
4964
  const { host, port } = addr;
4959
4965
  const dialled = new Promise(
4960
- (resolve2) => resolve2(route.uplink.dial({ network: "tcp", host, port, signal: abort.signal }))
4966
+ (resolve2) => resolve2(route2.uplink.dial({ network: "tcp", host, port, signal: abort.signal }))
4961
4967
  );
4962
4968
  dialled.then(
4963
4969
  (conn) => abort.signal.aborted && conn.close(),
@@ -5106,14 +5112,14 @@ var LoopbackNet = class {
5106
5112
  if (!this.origins) throw new KernelError("ENETUNREACH");
5107
5113
  void this.origins(conn, origin).catch(() => conn.close());
5108
5114
  }
5109
- useUplink(route) {
5110
- this.route = route;
5115
+ useUplink(route2) {
5116
+ this.route = route2;
5111
5117
  }
5112
5118
  tlsClient;
5113
5119
  uplinkFor(host) {
5114
- const route = this.route;
5115
- if (!route) return void 0;
5116
- return this.destination(host) === "uplink" ? route : void 0;
5120
+ const route2 = this.route;
5121
+ if (!route2) return void 0;
5122
+ return this.destination(host) === "uplink" ? route2 : void 0;
5117
5123
  }
5118
5124
  destination(host) {
5119
5125
  return (this.route?.routes ?? LOCAL_ONLY).classify(host);
@@ -6626,7 +6632,7 @@ function satisfies(version, range2) {
6626
6632
  }
6627
6633
 
6628
6634
  // src/version.ts
6629
- var KERNEL_VERSION = true ? "1.124.13" : "0.0.0-dev";
6635
+ var KERNEL_VERSION = true ? "1.125.1" : "0.0.0-dev";
6630
6636
  var OS_RELEASE = "6.0.0-slicc";
6631
6637
  function procVersion() {
6632
6638
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -9419,6 +9425,8 @@ async function physicalPathAsync(fs, path) {
9419
9425
  }
9420
9426
 
9421
9427
  // src/fs/watch.ts
9428
+ var WATCH_NOTES_LIMIT = 1e3;
9429
+ var baseOf2 = (path) => path.slice(path.lastIndexOf("/") + 1);
9422
9430
  var WATCH_BATCH_LIMIT = 1e3;
9423
9431
  var under3 = (path, dir) => dir === "/" || path.startsWith(`${dir}/`);
9424
9432
  var parentOf6 = (path) => path.slice(0, path.lastIndexOf("/")) || "/";
@@ -9428,8 +9436,39 @@ function touches(watcher, changed) {
9428
9436
  );
9429
9437
  }
9430
9438
  var REALS_KEPT = 4096;
9439
+ function noted(watch, note) {
9440
+ if (watch.ended) return;
9441
+ if ("ended" in note) {
9442
+ watch.notes.push(note);
9443
+ watch.ended = true;
9444
+ watch.wake?.();
9445
+ return;
9446
+ }
9447
+ if (watch.overflowed) return;
9448
+ if (watch.notes.length >= WATCH_NOTES_LIMIT) {
9449
+ watch.overflowed = true;
9450
+ watch.notes.push({ overflow: true });
9451
+ } else watch.notes.push(note);
9452
+ watch.wake?.();
9453
+ }
9454
+ function route(watch, type, path) {
9455
+ for (const root of watch.roots) if (routed(watch, root, type, path)) return;
9456
+ }
9457
+ function routed(watch, root, type, path) {
9458
+ if (path === root || type === "rename" && root !== "/" && under3(root, path)) {
9459
+ noted(watch, { type, filename: baseOf2(root) });
9460
+ if (type === "rename") noted(watch, { ended: true });
9461
+ return true;
9462
+ }
9463
+ if (parentOf6(path) === root) noted(watch, { type, filename: baseOf2(path) });
9464
+ else if (watch.recursive && under3(path, root)) {
9465
+ noted(watch, { type, filename: path.slice(root === "/" ? 1 : root.length + 1) });
9466
+ } else return false;
9467
+ return true;
9468
+ }
9431
9469
  var FsWatchers = class {
9432
9470
  watchers = /* @__PURE__ */ new Set();
9471
+ typed = /* @__PURE__ */ new Set();
9433
9472
  taps = [];
9434
9473
  reals = /* @__PURE__ */ new Map();
9435
9474
  reshapes = [];
@@ -9456,7 +9495,65 @@ var FsWatchers = class {
9456
9495
  this.watchers.delete(watcher);
9457
9496
  };
9458
9497
  }
9498
+ typedWatch(root, recursive, alias) {
9499
+ const main = normalizePath(root);
9500
+ const other = alias === void 0 ? main : normalizePath(alias);
9501
+ const watch = {
9502
+ roots: other === main ? [main] : [main, other],
9503
+ recursive,
9504
+ notes: [],
9505
+ overflowed: false,
9506
+ ended: false,
9507
+ wake: void 0
9508
+ };
9509
+ this.typed.add(watch);
9510
+ const close = () => {
9511
+ this.typed.delete(watch);
9512
+ watch.ended = true;
9513
+ watch.wake?.();
9514
+ };
9515
+ return {
9516
+ close,
9517
+ next: async (signal) => {
9518
+ while (watch.notes.length === 0 && !watch.ended && !signal.aborted) {
9519
+ await new Promise((resolve2) => {
9520
+ const done4 = () => {
9521
+ watch.wake = void 0;
9522
+ signal.removeEventListener("abort", done4);
9523
+ resolve2();
9524
+ };
9525
+ watch.wake = done4;
9526
+ signal.addEventListener("abort", done4, { once: true });
9527
+ });
9528
+ }
9529
+ const notes = watch.notes.splice(0);
9530
+ watch.overflowed = false;
9531
+ if (watch.ended) this.typed.delete(watch);
9532
+ return notes.length === 0 && watch.ended ? [{ ended: true }] : notes;
9533
+ }
9534
+ };
9535
+ }
9536
+ covers(path) {
9537
+ for (const watch of this.typed) {
9538
+ for (const root of watch.roots) {
9539
+ if (path === root || parentOf6(path) === root || watch.recursive && under3(path, root))
9540
+ return true;
9541
+ }
9542
+ }
9543
+ return false;
9544
+ }
9545
+ emit(type, paths) {
9546
+ if (this.typed.size === 0) return;
9547
+ for (const path of paths) {
9548
+ const at = normalizePath(path);
9549
+ for (const watch of [...this.typed]) route(watch, type, at);
9550
+ }
9551
+ }
9459
9552
  changed(...paths) {
9553
+ this.report(paths);
9554
+ this.emit("change", paths);
9555
+ }
9556
+ report(paths) {
9460
9557
  for (const tap of this.taps) tap(paths);
9461
9558
  for (const watcher of this.watchers) {
9462
9559
  for (const path of paths) {
@@ -9476,11 +9573,15 @@ var FsWatchers = class {
9476
9573
  watcher.notify(paths.length > WATCH_BATCH_LIMIT ? { overflow: true } : { paths });
9477
9574
  }
9478
9575
  wrap(fs) {
9479
- const after = (run2, changed) => async (...args) => {
9576
+ const after = (run2, changed, type) => async (...args) => {
9577
+ let done4 = false;
9480
9578
  try {
9481
9579
  await run2(...args);
9580
+ done4 = true;
9482
9581
  } finally {
9483
- this.changed(...changed(...args));
9582
+ const paths = changed(...args);
9583
+ this.report(paths);
9584
+ if (done4) this.emit(type, paths);
9484
9585
  }
9485
9586
  };
9486
9587
  const reals = this.reals;
@@ -9499,15 +9600,21 @@ var FsWatchers = class {
9499
9600
  reals.set(at, real);
9500
9601
  return real;
9501
9602
  };
9502
- const content = (run2) => async (...args) => {
9603
+ const content = (run2, types) => async (...args) => {
9604
+ const at = normalizePath(args[0]);
9605
+ const fresh2 = this.covers(at) && !await fs.exists(at).catch(() => true);
9606
+ let done4 = false;
9503
9607
  try {
9504
9608
  await run2(...args);
9609
+ done4 = true;
9505
9610
  } finally {
9506
- const at = normalizePath(args[0]);
9507
9611
  const real = await realOf(at);
9508
- this.changed(...real === at ? [at] : [at, real]);
9612
+ const paths = real === at ? [at] : [at, real];
9613
+ this.report(paths);
9614
+ if (done4) for (const type of types(fresh2)) this.emit(type, paths);
9509
9615
  }
9510
9616
  };
9617
+ const written2 = (fresh2) => fresh2 ? ["rename", "change"] : ["change"];
9511
9618
  const reshaping = (run2) => async (...args) => {
9512
9619
  this.reshaped();
9513
9620
  try {
@@ -9517,8 +9624,8 @@ var FsWatchers = class {
9517
9624
  }
9518
9625
  };
9519
9626
  const overrides = {
9520
- writeFile: content(fs.writeFile.bind(fs)),
9521
- ...fs.createExclusive ? { createExclusive: content(fs.createExclusive.bind(fs)) } : {},
9627
+ writeFile: content(fs.writeFile.bind(fs), written2),
9628
+ ...fs.createExclusive ? { createExclusive: content(fs.createExclusive.bind(fs), () => ["rename"]) } : {},
9522
9629
  mkdir: async (path, options) => {
9523
9630
  const created3 = [];
9524
9631
  if (options?.recursive) {
@@ -9528,33 +9635,39 @@ var FsWatchers = class {
9528
9635
  if (created3.length > 0 || !await fs.exists(ancestor)) created3.push(ancestor);
9529
9636
  }
9530
9637
  }
9638
+ const fresh2 = !options?.recursive || !(this.covers(path) && await fs.exists(path));
9639
+ let done4 = false;
9531
9640
  try {
9532
9641
  await fs.mkdir(path, options);
9642
+ done4 = true;
9533
9643
  } finally {
9534
- this.changed(...created3, path);
9644
+ this.report([...created3, path]);
9645
+ if (done4) this.emit("rename", fresh2 ? [...created3, path] : created3);
9535
9646
  }
9536
9647
  },
9537
- rm: reshaping(after(fs.rm.bind(fs), (path) => [path])),
9538
- rename: reshaping(after(fs.rename.bind(fs), (from, to) => [from, to])),
9648
+ rm: reshaping(after(fs.rm.bind(fs), (path) => [path], "rename")),
9649
+ rename: reshaping(after(fs.rename.bind(fs), (from, to) => [from, to], "rename")),
9539
9650
  link: reshaping(
9540
9651
  after(
9541
9652
  (from, to, follow2) => fs.link(from, to, follow2),
9542
- (_from, to) => [to]
9653
+ (_from, to) => [to],
9654
+ "rename"
9543
9655
  )
9544
9656
  ),
9545
- symlink: reshaping(after(fs.symlink.bind(fs), (_target, path) => [path])),
9546
- chmod: after(fs.chmod.bind(fs), (path) => [path]),
9547
- ...fs.chown ? { chown: after(fs.chown.bind(fs), (path) => [path]) } : {},
9548
- utimes: after(fs.utimes.bind(fs), (path) => [path]),
9657
+ symlink: reshaping(after(fs.symlink.bind(fs), (_target, path) => [path], "rename")),
9658
+ chmod: after(fs.chmod.bind(fs), (path) => [path], "change"),
9659
+ ...fs.chown ? { chown: after(fs.chown.bind(fs), (path) => [path], "change") } : {},
9660
+ utimes: after(fs.utimes.bind(fs), (path) => [path], "change"),
9549
9661
  lutimes: after(
9550
9662
  (path, atime, mtime) => lutimesOf(fs, path, atime, mtime),
9551
- (path) => [path]
9663
+ (path) => [path],
9664
+ "change"
9552
9665
  )
9553
9666
  };
9554
9667
  const ranged2 = rangedOps(fs);
9555
9668
  if (ranged2) {
9556
- overrides.pwrite = content(ranged2.pwrite);
9557
- overrides.truncate = content(ranged2.truncate);
9669
+ overrides.pwrite = content(ranged2.pwrite, () => ["change"]);
9670
+ overrides.truncate = content(ranged2.truncate, () => ["change"]);
9558
9671
  }
9559
9672
  return new Proxy(fs, {
9560
9673
  get(target, key, receiver) {
@@ -10162,7 +10275,8 @@ var FifoTable = class {
10162
10275
  poll: () => ({
10163
10276
  readable: reads && pipe.readReady,
10164
10277
  writable: writes && pipe.writeReady,
10165
- hangup: reads ? pipe.writersGone : pipe.readersGone
10278
+ hangup: reads ? pipe.writersGone : pipe.readersGone,
10279
+ ...reads ? { nread: pipe.buffered } : {}
10166
10280
  }),
10167
10281
  changed: (signal) => pipe.changed(signal)
10168
10282
  });
@@ -10561,8 +10675,8 @@ var MountTable = class {
10561
10675
  ...error ? { error } : {}
10562
10676
  })
10563
10677
  );
10564
- const noted = [...this.notes.values()].filter((note) => !this.mounts.has(note.target));
10565
- return [...mounted, ...noted.map((note) => ({ ...note, options: { ...note.options } }))];
10678
+ const noted2 = [...this.notes.values()].filter((note) => !this.mounts.has(note.target));
10679
+ return [...mounted, ...noted2.map((note) => ({ ...note, options: { ...note.options } }))];
10566
10680
  }
10567
10681
  listed() {
10568
10682
  return this.list().filter((entry) => !this.system.has(entry.target));
@@ -16031,7 +16145,8 @@ var KernelTty = class {
16031
16145
  poll: () => ({
16032
16146
  readable: this.readable.length > 0 || this.hungUp,
16033
16147
  writable: true,
16034
- hangup: this.hungUp
16148
+ hangup: this.hungUp,
16149
+ nread: this.readable.reduce((n, chunk2) => n + (chunk2?.length ?? 0), 0)
16035
16150
  }),
16036
16151
  changed: (signal) => this.changed(signal),
16037
16152
  close: () => {
@@ -16227,7 +16342,8 @@ var PtyPair = class {
16227
16342
  poll: () => ({
16228
16343
  readable: this.queue.length > 0 || this.slaveGone,
16229
16344
  writable: true,
16230
- hangup: this.slaveGone
16345
+ hangup: this.slaveGone,
16346
+ nread: this.queue.reduce((n, chunk2) => n + chunk2.length, 0)
16231
16347
  }),
16232
16348
  changed: (signal) => this.changed(signal),
16233
16349
  close: () => this.closeMaster(),
@@ -16509,13 +16625,17 @@ async function runChild(req, deps) {
16509
16625
  // src/kernel/select.ts
16510
16626
  function ready(fds, read, write) {
16511
16627
  const hangup = /* @__PURE__ */ new Set();
16628
+ const nread = {};
16512
16629
  const pick = (list, dir) => list.filter((fd) => {
16513
16630
  const state = pollFile(fds.get(fd).file);
16514
16631
  if (state.hangup) hangup.add(fd);
16515
- return state[dir] || state.hangup;
16632
+ const picked = state[dir] || state.hangup;
16633
+ if (picked && dir === "readable" && state.nread !== void 0) nread[fd] = state.nread;
16634
+ return picked;
16516
16635
  });
16517
16636
  const out = { read: pick(read, "readable"), write: pick(write, "writable") };
16518
16637
  if (hangup.size > 0) out.hangup = [...hangup];
16638
+ if (Object.keys(nread).length > 0) out.nread = nread;
16519
16639
  return out;
16520
16640
  }
16521
16641
  function interruption(signal) {
@@ -17510,14 +17630,14 @@ async function connectSecure(proc, socket, host, port, req) {
17510
17630
  const servername = serverName(req.servername || host);
17511
17631
  if (isLoopbackHost(servername) || isLoopbackHost(host)) throw new KernelError("ENOTSUP");
17512
17632
  const address = await uplinkAddress(proc, host);
17513
- const route = proc.net.uplinkFor(address);
17514
- if (!route)
17633
+ const route2 = proc.net.uplinkFor(address);
17634
+ if (!route2)
17515
17635
  throw new KernelError("ENOTSUP", "TLS in the kernel needs a destination the uplink routes");
17516
17636
  const support = supportOf(proc.net);
17517
17637
  let info;
17518
17638
  const { make, abandon } = secure(await prepare(support, req, servername));
17519
17639
  try {
17520
- await socket.connectSecure({ family: "inet", host: address, port }, route, async (conn) => {
17640
+ await socket.connectSecure({ family: "inet", host: address, port }, route2, async (conn) => {
17521
17641
  info = await conn.upgrade(make);
17522
17642
  });
17523
17643
  } finally {
@@ -18728,6 +18848,65 @@ function vfsFile(fs, opts, nodes) {
18728
18848
  });
18729
18849
  }
18730
18850
 
18851
+ // src/kernel/watch-ops.ts
18852
+ var WATCH_OPS = ["watch-open", "watch-next", "watch-close"];
18853
+ var WATCHING = new Set(WATCH_OPS);
18854
+ function isWatchSyscall(req) {
18855
+ return WATCHING.has(req.op);
18856
+ }
18857
+ var WATCHES_MAX = 8192;
18858
+ var parentOf8 = (path) => path.slice(0, path.lastIndexOf("/")) || "/";
18859
+ function visibleTo2(watch, root, visible) {
18860
+ return {
18861
+ close: () => watch.close(),
18862
+ next: async (signal) => {
18863
+ const notes = await watch.next(signal);
18864
+ const seen = /* @__PURE__ */ new Map();
18865
+ const shown = await Promise.all(
18866
+ notes.map(async (note) => {
18867
+ if (!("filename" in note) || !note.filename.includes("/")) return true;
18868
+ const dir = parentOf8(`${root === "/" ? "" : root}/${note.filename}`);
18869
+ if (!seen.has(dir)) seen.set(dir, visible(dir));
18870
+ return seen.get(dir);
18871
+ })
18872
+ );
18873
+ return notes.filter((_note, i) => shown[i]);
18874
+ }
18875
+ };
18876
+ }
18877
+ var ProcessWatches = class {
18878
+ open = /* @__PURE__ */ new Map();
18879
+ nextId = 1;
18880
+ source;
18881
+ constructor(source) {
18882
+ this.source = source;
18883
+ }
18884
+ async syscall(req, cancelled) {
18885
+ if (req.op === "watch-open") {
18886
+ if (!this.source) throw new KernelError("ENOSYS");
18887
+ if (this.open.size >= WATCHES_MAX) throw new KernelError("ENOSPC");
18888
+ const watch2 = await this.source.open(req.path, req.recursive === true);
18889
+ const id2 = this.nextId++;
18890
+ this.open.set(id2, watch2);
18891
+ return { ok: true, kind: "json", json: id2 };
18892
+ }
18893
+ const watch = this.open.get(req.id);
18894
+ if (req.op === "watch-close") {
18895
+ watch?.close();
18896
+ this.open.delete(req.id);
18897
+ return { ok: true, kind: "void" };
18898
+ }
18899
+ if (!watch) return { ok: true, kind: "json", json: [{ ended: true }] };
18900
+ const notes = await watch.next(cancelled);
18901
+ if (notes.some((note) => "ended" in note)) this.open.delete(req.id);
18902
+ return { ok: true, kind: "json", json: notes };
18903
+ }
18904
+ closeAll() {
18905
+ for (const watch of this.open.values()) watch.close();
18906
+ this.open.clear();
18907
+ }
18908
+ };
18909
+
18731
18910
  // src/kernel/process.ts
18732
18911
  function aborted(signal) {
18733
18912
  return new Promise((_, reject) => {
@@ -18854,7 +19033,8 @@ var SYSCALL_OPS = /* @__PURE__ */ new Set([
18854
19033
  ...SEM_OPS,
18855
19034
  ...HTTP_OPS,
18856
19035
  ...PTY_OPS,
18857
- ...HOST_OPS
19036
+ ...HOST_OPS,
19037
+ ...WATCH_OPS
18858
19038
  ]);
18859
19039
  function isWasmSyscall(req) {
18860
19040
  const op = req.op;
@@ -18906,6 +19086,7 @@ function timeLeft(req, started) {
18906
19086
  }
18907
19087
  var WasmProcess = class {
18908
19088
  exited = false;
19089
+ watches;
18909
19090
  children;
18910
19091
  deathSignals = /* @__PURE__ */ new Map();
18911
19092
  caught = 0;
@@ -18936,6 +19117,7 @@ var WasmProcess = class {
18936
19117
  this.pid = pid;
18937
19118
  this.fds = fds;
18938
19119
  this.options = options;
19120
+ this.watches = new ProcessWatches(options.watches);
18939
19121
  this.inherited = options.ignored ?? 0;
18940
19122
  this.umask = options.umask ?? DEFAULT_UMASK2;
18941
19123
  this.cred = copyCred(options.cred ?? ROOT);
@@ -19193,6 +19375,7 @@ var WasmProcess = class {
19193
19375
  });
19194
19376
  }
19195
19377
  if (isHttpSyscall(req)) return await this.httpSyscall(req);
19378
+ if (isWatchSyscall(req)) return await this.watches.syscall(req, cancelled);
19196
19379
  if (isHostSyscall(req)) {
19197
19380
  return await hostSyscall(req, {
19198
19381
  pid: this.pid,
@@ -20333,6 +20516,7 @@ var WasmProcess = class {
20333
20516
  this.options.locks?.release(this.pid);
20334
20517
  for (const owner of this.lockOwners) this.options.locks?.release(owner);
20335
20518
  this.asyncOps.close();
20519
+ this.watches.closeAll();
20336
20520
  for (const [target, sig] of this.deathSignals) this.sendDeath(target, sig);
20337
20521
  this.deathSignals.clear();
20338
20522
  for (const pid of this.children.pids()) this.options.onReap?.(pid);
@@ -20594,6 +20778,7 @@ function processOptions(opts, header3, hooks, created3) {
20594
20778
  locks: opts.locks,
20595
20779
  fifos: opts.fifos,
20596
20780
  ...opts.sems ? { sems: opts.sems } : {},
20781
+ watches: opts.watches,
20597
20782
  onReap: opts.onReap,
20598
20783
  resolver: opts.resolver,
20599
20784
  ...notifiers(header3),
@@ -25811,6 +25996,7 @@ var Launcher = class {
25811
25996
  locks: this.locks,
25812
25997
  fifos: this.fifos,
25813
25998
  sems: this.sems,
25999
+ watches: this.watchSource(pid),
25814
26000
  resolver: this.resolver,
25815
26001
  onReap: (child) => this.reaped(child, pid),
25816
26002
  ...req.fork ? { fork: req.fork } : {},
@@ -26354,6 +26540,23 @@ var Launcher = class {
26354
26540
  mounts: this.mounts.list().map((entry) => entry.target).filter((target) => this.mounts.mounted(target) !== void 0)
26355
26541
  });
26356
26542
  }
26543
+ watchSource(pid) {
26544
+ return {
26545
+ open: async (path, recursive) => {
26546
+ const at = normalizePath(path);
26547
+ const g = this.execGuard(this.credOf(pid), pid);
26548
+ if (g) await readable(g, "watch", at);
26549
+ const st = await this.fs.stat(at);
26550
+ const real = await this.fs.realpath?.(at, true).catch(() => at) ?? at;
26551
+ const watch = this.watchers.typedWatch(real, recursive && st.isDirectory, at);
26552
+ if (!g || g.cred.euid === 0) return watch;
26553
+ return visibleTo2(watch, real, async (dir) => {
26554
+ const walked = await access(g.fs, g.cred, dir, R_OK | X_OK, { directory: true });
26555
+ return walked.denied === void 0 && walked.missing !== true;
26556
+ });
26557
+ }
26558
+ };
26559
+ }
26357
26560
  watchFiles(paths, notify) {
26358
26561
  this.fileWatches ??= new FileWatches(this.watchers, this.rootFs);
26359
26562
  return this.fileWatches.watch(paths, notify);