@ai-ecoverse/slicc-kernel 1.137.0 → 1.138.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
@@ -319,6 +319,7 @@ On top of preview1 a WASI program gets:
319
319
  - `MAP_SHARED` mappings aren't shared between processes, so unnamed semaphores (`sem_init` with `pshared`) work only between a process's threads, and `multiprocessing.shared_memory` doesn't work.
320
320
  - **Foreign calls** (libffi's WASIX backend, so Python's `ctypes`): `call_dynamic` calls a function through the program's indirect function table (a static module's exported `__indirect_function_table`, or the dynamic linker's), its arguments and results packed in memory one after another (`i32`/`f32` 4 bytes, `i64`/`f64` 8). With `strict` set, the buffers must fit the function exactly (else `EINVAL`, before the function is called); without it, missing arguments are zero (one cut short keeps the bytes given, with zeros above them) and results that do not fit are left out. A trap or exception of the callee goes through to the caller. JS cannot ask a function for its type, so the kernel reads the types from the module files (the program's and each side module's, once, when a program first calls through `call_dynamic`): a table slot that is empty, past the end, holds a function of no module the kernel knows of, or takes or returns a `v128`, gives `EINVAL`. For closures (callbacks such as `CFUNCTYPE`), a dynamically linked program gets table slots from `closure_allocate`, and `closure_free` gives them back for reuse and empties the slot in every thread; a static program gets `ENOTSUP`. `closure_prepare` puts a trampoline with the requested signature (any of `i32`, `i64`, `f32`, `f64`, `v128`) into such a slot, in every thread: each call stores its arguments below the stack pointer and calls the backing function as `backing(values, results, user_data)`, then returns what it wrote to `results`. The trampolines are small wasm modules made and compiled at run time, once per signature in each thread (an instance per closure), so a page whose Content Security Policy forbids compiling WebAssembly (no `'wasm-unsafe-eval'`) cannot run them; it cannot run the program either. A `fork` or a `longjmp` (both through Asyncify) inside a function reached through `call_dynamic` cannot unwind through it.
321
321
  - **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. An Emscripten program stops at its next kernel call as well, and at its next write, read or sleep that its glue answers without the kernel (a write to its own `/dev/null`, as in `yes > /dev/null`), so a stopped one keeps no core busy unless it computes without any of these calls. A stopped program doesn't move on while stopped: a `sleep` (or a blocked read) that would end meanwhile ends after `SIGCONT`. A call that the stop interrupted runs again after `SIGCONT`, as on Linux. One with a timeout (`select`, `poll`, `ppoll`, `pselect`, `nanosleep`, a timed semaphore wait) waits only for the time that's left, counted from when the kernel first got the call, so it still ends at its deadline. `epoll_wait` and `epoll_pwait` aren't run again: they fail with `EINTR`, even without a handler (signal(7)). A signal for such a program is also delivered at those glue-answered calls, so a handler runs in a loop that only writes to `/dev/null`. 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 pending signal is delivered at the program's next WASI or WASIX call, including the calls its worker answers without the kernel (`random_get`, `args_get`, …), except the exempt ones: `clock_time_get` (a tight loop of it measured up to 30% slower), `proc_exit`, `proc_exit2`, `thread_exit`, `thread_spawn_v2` and `wasi.thread-spawn` (wasi-threads), the calls that install handlers (`callback_signal`, `slicc.sigaction_set`), and calls made while Asyncify unwinds or rewinds the stack for `fork` or `longjmp`. A signal that arrives while a thread computes is thus delivered at its next call; when that call is a wait (`poll_oneoff` with a timeout or a descriptor, so `nanosleep` and `poll`/`select` too, `sigmask_poll`, or a futex wait), the handler has run during the call, so the wait ends at once: `EINTR` for a libc of -22 or later (an older one sees its clocks fire, as for any interruption), and a futex wait returns woken. 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. A sleep, or a clock in `poll_oneoff`, never ends before the time asked (it can end later, never earlier). A signal that ends a sleep or a `poll`/`select` with a timeout makes the call fail with `EINTR` in a program whose libc says it is wasix-sysroot 2025.9.30-22 or later (its `slicc.libc` custom section). Older libcs turn that answer into `ENOTSUP` from `nanosleep` (up to -18), so for a program without the section the wait ends as if it had timed out, unless it imports `fd_fdflags_set` (then `EINTR`, as before).
322
+ - **A wait interrupted without a handler** (#1291): `poll_oneoff` (and so `nanosleep`, `sleep` and Rust's `std::thread::sleep`) returns `EINTR` only when a signal handler of the program ran during the wait. A wake that ran none, such as a stop and continue, a signal the program ignores or whose default is to ignore, or anything a plain wasip1 module (which has no handlers) receives, resumes the wait for the time left. A signal whose default ends the process still ends it. This matters because wasi-libc turns any `poll_oneoff` error into `ENOTSUP`, which Rust's `thread::sleep` treats as fatal. A masked wait (`sigmask_poll`, as in `pselect`) keeps its own rules.
322
323
  - **Blocked signals** (`sigprocmask`, `pthread_sigmask`, `sigpending`): a program whose libc imports `slicc.sigmask_set(how, set_lo, set_hi, old_ptr)` and `slicc.sigpending_get(ptr)` (wasix-sysroot 2025.9.30-28 and later) has a blocked mask per thread. `how` is musl's `SIG_BLOCK` 0, `SIG_UNBLOCK` 1 or `SIG_SETMASK` 2, the set is the 64-bit `sigset_t` (bit `sig - 1`), and the old mask is written as a u64 at `old_ptr`; any other `how` is `EINVAL`. `SIGKILL` and `SIGSTOP` cannot be blocked. A blocked signal is not delivered, does not interrupt a sleep or a blocked call, and its default action (terminate or stop) waits: it stays pending, `sigpending` shows it (the thread's pending signals and the process's), and it is delivered when it is unblocked, before `sigmask_set` returns. A signal ignored with `SIG_IGN` is discarded even while blocked. A forked child (with the mask of the thread that forked), the program an `exec` starts and a spawned child keep the mask, all 64 bits of it, and so does a new thread; `/proc/<pid>/status` shows the main thread's as `SigBlk`. The mask is kept in a word of the worker's shared header, so changing it costs no kernel call. A program without the import (an older libc) has no mask and gets every signal at once, as before, and one it starts by `exec` with a mask set starts with none. A signal for the whole process goes to the main thread, unless the main thread blocks it and another thread does not. Then, as on Linux, it goes to that thread: its handler runs there, or with no handler its default action ends (or stops) the whole process at once. So the "signal thread" pattern works: block in main, unblock in a worker (#520). It waits on the main thread only when every thread blocks it. A signal sent to one thread (`pthread_kill`) that it blocks waits for that thread, and if it has no handler, the thread's unblock ends the whole process by the signal, whatever the other threads block. `slicc.sigmask_poll(set_lo, set_hi, in_ptr, out_ptr, nsubscriptions, nevents_ptr)` is `pselect`, `ppoll` and `sigsuspend`: it swaps the thread's mask to the given set for the call and puts the old one back before it returns. It delivers a pending signal the new mask unblocks, without polling, or polls as `poll_oneoff` does, under the new mask, so a signal it unblocks interrupts the wait with no window between the swap and the wait; with no subscriptions it waits until a signal is delivered. An interrupted call returns `EINTR` with no events, after the handler has run. `slicc.sigmask_epoll_wait(set_lo, set_hi, epfd, events_ptr, maxevents, timeout_ns, nevents_ptr)` is `epoll_pwait` (#771). It swaps the thread's mask to the given set in the same way. A pending signal the new mask unblocks runs its handler and makes the call return `EINTR` with no events, without waiting. Otherwise the call waits exactly as `wasix_32v1.epoll_wait` with the same arguments does (the same event layout and errors; a `timeout_ns` of all bits set is forever, 0 doesn't wait), under the new mask, so a signal it unblocks ends the wait with `EINTR` after the handler. The old mask is back before the call returns, on every path, and `*nevents_ptr` is always written. A kernel without it answers `ENOSYS`, and the libc keeps its non-atomic swap around `epoll_wait`. For both masked waits, a signal that came while the program computed and was delivered as the call began (its handler ran under the old mask, as on Linux, where it would have run before the call) ends the call with `EINTR` only if the new mask leaves it unblocked; when the new mask blocks every signal delivered then, the call waits as if none had come (#799). `slicc.sigwait(set_lo, set_hi, timeout_ns, sig_ptr)` is `sigwait`, `sigwaitinfo` and `sigtimedwait` (#520). If a signal of the set is pending for the thread, or else for the process, it takes it: the lowest-numbered first, with no handler run. It writes the number as a `u32` at `sig_ptr` and returns 0. Otherwise it waits until one is pending. `timeout_ns` of all bits set is forever and 0 doesn't wait; when the time runs out it answers `EAGAIN` (`*sig_ptr = 0`). A signal outside the set that the thread has unblocked and handles runs its handler and ends the call with `EINTR` (also one that came while the program computed and was delivered as the call began, as for the other waits), and so does a job-control stop, once continued. While a thread waits, the kernel counts it as able to take the signals of its set: a process-directed signal that every thread blocks goes to it (the main thread first, if it is the one waiting), so the signal-thread pattern works with `sigwait` too. Only kernel signals 1 to 31 can be waited for. A kernel without it answers `ENOSYS`. `slicc.sigaction_inherited(ign_ptr)` (#820) writes, as a little-endian `u64` at `ign_ptr`, the signals the program arrived with set to `SIG_IGN`, inherited across `exec` or spawn (bit `sig - 1`; never `SIGKILL` or `SIGSTOP`; kernel signals are 1 to 31, so the high word is 0), and returns 0. A libc calls it once before constructors and `main` and seeds its `sigaction` table with `SIG_IGN` for each bit, so `sigaction(sig, NULL, &old)` reads an inherited ignore back (wasix-sysroot 2025.9.30-32 and later); a kernel without it answers `ENOSYS`, and the libc keeps its defaults. **A handler and its own signal** (#971): while a handler runs, the same signal waits, as if `sa_mask` held it: it arrives once after the handler returns, not nested inside it, unless the handler was set with `SA_NODEFER`. The hold is part of the handler's mask, so a handler that unblocks its own signal (`pthread_sigmask(SIG_UNBLOCK, …)` or a `SIG_SETMASK` without it) lifts it: a signal raised after that nests at once, as on Linux (#1236). A program that is sent its fatal signal twice, or raises it from its own handler, therefore no longer recurses (gpg's `got_fatal_signal` under `timeout` overflowed the stack). Other signals still run nested, as with an empty `sa_mask`. Handlers are never inherited. **The sender** (#892): `slicc.sigwait_info(set_lo, set_hi, timeout_ns, info_ptr)` is `slicc.sigwait` that also says who sent the signal it took, for `sigwaitinfo` and `sigtimedwait`. `slicc.siginfo_take(sig, info_ptr)` fills the same struct with the sender of the instance of `sig` just delivered to this thread's handler, for an `SA_SIGINFO` handler's `siginfo_t`, and consumes it; with none recorded it answers `ENOENT` and leaves the struct alone, and libc fills `SI_USER` with pid and uid 0. Both write `struct { u32 size; i32 signo; i32 code; i32 pid; u32 uid; i32 status; }`, little-endian, 24 bytes:
323
324
  - the caller puts in `size` how many bytes it has, and the kernel fills the fields that fit and writes back how many it filled, so later fields can follow without a new import;
324
325
  - `kill()`/`raise()` by a process give `SI_USER` (0), the sender's pid as `getpid()` shows it and its real uid; a `SIGPIPE` from a write is `SI_USER` from the process itself, as on Linux;
@@ -1149,7 +1150,7 @@ OPFS stays authoritative: an entry is consulted only for a path that exists in O
1149
1150
 
1150
1151
  **Stamping files written to OPFS:** `import { writeMetadata } from '@ai-ecoverse/slicc-kernel/meta'` and `await writeMetadata(root, [{ path, mode?, uid?, gid? }], { metadata?, indexedDB? })` is the supported way for an embedder to give files it wrote straight to OPFS a mode and an owner, also while no kernel runs (an installer before the first boot). `root` is the directory handle that becomes `/`, each `path` is absolute (`EINVAL` otherwise) and must exist there (`ENOENT` otherwise), `mode` is the permission bits (`0` to `0o7777`), and `uid` and `gid` come together or not at all (`EINVAL` otherwise). Entries for the same path (after normalization) are merged in order, a later field winning. All entries are written in one IndexedDB transaction, to the database a kernel on that root reads (`metadata`, default `slicc-kernel`), keeping what else a record holds and setting its change time (`ctime`) to now, and a running kernel on the same origin drops what it cached of them. The record format is the kernel's own and changes with it, so use this rather than writing the database. The kernel gives its layout paths (see [Permissions](#permissions)) an owner and mode only when they have no owner yet, so a path stamped here with `uid` and `gid` keeps them.
1151
1152
 
1152
- **Telling running kernels about files written to OPFS:** a writer outside the kernel (an installer worker, a service worker, the page) that changes files on a root a kernel may be running on calls `import { postFsNotice } from '@ai-ecoverse/slicc-kernel/meta'` and `postFsNotice({ paths?, trees? }, { metadata? })` **after** each write has committed: after `close()` of its writable, `removeEntry` or `move()` has resolved. `paths` are absolute paths written, created or removed. List a created or removed name's parent directory too, so listings refresh. `trees` are absolute directories whose whole subtree changed, for a recursive replace or a sweep. The notice goes on the BroadcastChannel `slicc-kernel-dirs:<metadata>` (default `slicc-kernel-dirs:slicc-kernel`), the one kernels on that root already use between themselves, as `{ paths, trees }`. One notice per batch of writes is enough, if it's posted after the last of them. A kernel that receives it drops its cached lookups, handles and misses for those paths (for a tree, everything under it), tells its open files to reread, and publishes the paths to its programs' caches. A tree marks every path changed for them, so a running program's next look at any path goes to the kernel. **The residual window:** a reader can still see the old state between the write's commit and the notice's delivery (a message, usually well under a millisecond, but not bounded). A writer that runs while no kernel is up needs no notice: a kernel's caches start empty when it boots. **Without a notice** a kernel that is alone on its root keeps what it cached: a running program's stats of a path stay as they were for as long as the program runs, and the kernel's own directory handles for up to a second. So a writer that doesn't post notices must not change files a running kernel uses ([#1156](https://github.com/ai-ecoverse/slicc-kernel/issues/1156)).
1153
+ **Telling running kernels about files written to OPFS:** a writer outside the kernel (an installer worker, a service worker, the page) that changes files on a root a kernel may be running on calls `import { postFsNotice } from '@ai-ecoverse/slicc-kernel/meta'` and `postFsNotice({ paths?, trees? }, { metadata? })` **after** each write has committed: after `close()` of its writable, `removeEntry` or `move()` has resolved. `paths` are absolute paths written, created or removed. List a created or removed name's parent directory too, so listings refresh. `trees` are absolute directories whose whole subtree changed, for a recursive replace or a sweep. The notice goes on the BroadcastChannel `slicc-kernel-dirs:<metadata>` (default `slicc-kernel-dirs:slicc-kernel`), the one kernels on that root already use between themselves, as `{ paths, trees }`. One notice per batch of writes is enough, if it's posted after the last of them. A kernel that receives it drops its cached lookups, handles and misses for those paths (for a tree, everything under it), tells its open files to reread, and publishes the paths to its programs' caches. A tree marks every path changed for them, so a running program's next look at any path goes to the kernel. **The residual window:** a reader can still see the old state between the write's commit and the notice's delivery (a message, usually well under a millisecond, but not bounded). A writer that runs while no kernel is up needs no notice: a kernel's caches start empty when it boots. A notice for a path that exists as a regular file, listed in `paths` (a `trees` entry refreshes caches but stamps no times, since a tree can hold thousands of files), also gives it a new modification time, before running programs are told of the change: the notice's arrival, with the change time along, unless its modification time is already later (an explicit future one is kept, with its change time), as a write does on Linux, recorded once per burst of notices for the same file ([#1275](https://github.com/ai-ecoverse/slicc-kernel/issues/1275)). That matters because Chrome can keep a file's OPFS `lastModified` across an append through `createWritable({ keepExistingData: true })`, and the kernel reports `lastModified` for files it didn't write, so `-nt` and make wouldn't see the change. A notice without a real change still moves the time, so a make may rebuild once. An outside write that posts no notice keeps OPFS's time. Notices between kernels (the array form they post for their own writes) don't stamp: those kernels record their times themselves. That's part of the channel's contract: kernels post their own change notices on the dirs channel as a plain array of paths, and only the `{ paths, trees }` object form that `postFsNotice` sends counts as an outside writer's. A kernel must keep posting arrays there, which `fs-notice-page.test.mjs`'s peer row checks. **Without a notice** a kernel that is alone on its root keeps what it cached: a running program's stats of a path stay as they were for as long as the program runs, and the kernel's own directory handles for up to a second. So a writer that doesn't post notices must not change files a running kernel uses ([#1156](https://github.com/ai-ecoverse/slicc-kernel/issues/1156)).
1153
1154
 
1154
1155
  The database is `slicc-kernel` (or the `metadata` option), schema version 1, with one object store, `entries`, keyed by absolute path (`keyPath: 'path'`) and an index `dir` on the parent directory of symlinks. A record looks like `{ path, mode?, atimeMs?, mtimeMs?, mtimeFor?, ctimeMs?, ino?, link?, dir?, uid?, gid?, heir?, writes?, epoch? }`, where `mtimeFor` is the OPFS `lastModified` an explicit `mtimeMs` belongs to, `uid`, `gid` and `heir` (`[uid, gid]`) are the owners of [File owners](#file-owners), `writes` counts the writes kernels with `integrity` made to a protected file (see [Protected paths](#protected-paths)), whose `lastModified` OPFS leaves alone for a write through a sync access handle, and `epoch`, on the entry of `/`, counts their renames and symlinks at or above a protected path. A future schema bumps the version and migrates in `onupgradeneeded`; an older kernel then closes its connection. The sidecar is keyed by path from the `root` handle, so use a different `metadata` name for each distinct root.
1155
1156
 
@@ -1277,7 +1278,7 @@ The tools in `tools/cert-review/`:
1277
1278
  - and record, which re-hashes the candidate's artifact against the pack job's sha256.
1278
1279
  - **The verdict** goes into the commit status on the pull request's head and a comment on the pull request. Certified is green. A FAIL is red with the line, and the pull request needs a fix. **INCOMPLETE** (a job that didn't succeed, no verdict, a suite without results) is red with `re-run the failed jobs`, never a silent pass. Main moving on after a review doesn't trigger another one; a push does.
1279
1280
  - **Merge groups only look up.** On `merge_group`, the job lists the group's pull requests (its merge commits) and requires, for each one, a green status on the head being merged, or no behaviour change. Otherwise it's red, naming the pull request. Then it adds the group's downstream verdict. The group's `ci / test` covers the combined tree.
1280
- - **Downstream, once per merge group:** the `merge group pack` job packs the group's tree with `main`'s tools (`x.y.z-group.<run>`), and `downstream bios canary` calls slicc-bios's `canary.yml` at a pinned full sha (`1f071319…`, never a tag) with that tarball, bound by artifact id, digest and sha256, on the `sweep`, `guard-prompt`, `guard-lagging`, `k4-upgrade`, `k4-agent-modes` and `link-serve` shards, each run once, on the runner the `canary runner` job picks: `.github/runner.mjs`'s `decide()` from `main`, as `ci.yml` uses it, gives `vars.CERT_CANARY_RUNNER` only to a merge group whose entries are all same-repo PRs, and `ubuntu-latest` otherwise, without the variable, or if the decider fails (bios honours a runner only for merge groups and pushes to main in ai-ecoverse, so rolling back is unsetting the variable). The verdict line names the shards, or their count past 32 characters (`6 shards`), so a status description (140 characters) keeps the reason. `downstream.mjs` judges its result (`downstreamOf` in `lib.mjs`), and the lookup requires it. The `merge group` job reads that result as `result.json` from the canary's own artifact, downloaded by the call's `artifact-id` with `digest-mismatch: error`, not from the `result` output: that output can be up to 512 KB, and one environment variable holds 128 KB, so passing it that way stopped the step from starting at all (`Argument list too long`, runs 39166647175 and 39168823799). A missing or unreadable artifact leaves no result, so the group is INCOMPLETE:
1281
+ - **Downstream, once per merge group:** the `merge group pack` job packs the group's tree with `main`'s tools (`x.y.z-group.<run>`), and `downstream bios canary` calls slicc-bios's `canary.yml` at a pinned full sha (`fcaa9a23…`, never a tag) with that tarball, bound by artifact id, digest and sha256, on the `sweep`, `guard-prompt`, `guard-lagging`, `k4-upgrade`, `k4-agent-modes` and `link-serve` shards, each run once, on the runner the `canary runner` job picks: `.github/runner.mjs`'s `decide()` from `main`, as `ci.yml` uses it, gives `vars.CERT_CANARY_RUNNER` only to a merge group whose entries are all same-repo PRs, and `ubuntu-latest` otherwise, without the variable, or if the decider fails (bios honours a runner only for merge groups and pushes to main in ai-ecoverse, so rolling back is unsetting the variable). The verdict line names the shards, or their count past 32 characters (`6 shards`), so a status description (140 characters) keeps the reason. `downstream.mjs` judges its result (`downstreamOf` in `lib.mjs`), and the lookup requires it. The `merge group` job reads that result as `result.json` from the canary's own artifact, downloaded by the call's `artifact-id` with `digest-mismatch: error`, not from the `result` output: that output can be up to 512 KB, and one environment variable holds 128 KB, so passing it that way stopped the step from starting at all (`Argument list too long`, runs 39166647175 and 39168823799). A missing or unreadable artifact leaves no result, so the group is INCOMPLETE:
1281
1282
  - **PASS:** the candidate passes, `downstream: PASS bios … N pass`;
1282
1283
  - **FAIL:** a regression, or a failing test (by the counts, even without its rows) is **not certified**, and the group is red;
1283
1284
  - **INCOMPLETE** blocks too: no result, a result from another bios than the pinned one, a missing shard, a shard that exited without a failing test, a refused candidate, 0 tests run, or no test passed;
@@ -1313,11 +1313,7 @@ function chain(target) {
1313
1313
  function hide(target, names) {
1314
1314
  for (const name of names) {
1315
1315
  const holders = chain(target);
1316
- for (const holder of holders) {
1317
- if (Object.getOwnPropertyDescriptor(holder, name)?.configurable) {
1318
- Reflect.deleteProperty(holder, name);
1319
- }
1320
- }
1316
+ for (const holder of holders) Reflect.deleteProperty(holder, name);
1321
1317
  const kept = holders.slice(1).some((holder) => Object.hasOwn(holder, name));
1322
1318
  const defined = Reflect.defineProperty(target, name, {
1323
1319
  value: void 0,
@@ -1333,11 +1329,7 @@ function hide(target, names) {
1333
1329
  function vacate(target, names) {
1334
1330
  for (const name of names) {
1335
1331
  const holders = chain(target);
1336
- for (const holder of holders) {
1337
- if (Object.getOwnPropertyDescriptor(holder, name)?.configurable) {
1338
- Reflect.deleteProperty(holder, name);
1339
- }
1340
- }
1332
+ for (const holder of holders) Reflect.deleteProperty(holder, name);
1341
1333
  const kept = holders.slice(1).some((holder) => Object.hasOwn(holder, name));
1342
1334
  const opened = Reflect.defineProperty(target, name, {
1343
1335
  value: void 0,