@ai-ecoverse/slicc-kernel 1.94.4 → 1.95.0
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 +85 -3
- package/dist/kernel-worker.js +393 -54
- package/dist/kernel-worker.js.map +4 -4
- package/dist/node-process-worker.js +108 -21
- package/dist/node-process-worker.js.map +4 -4
- package/dist/node.js +391 -52
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js +108 -21
- package/dist/process-worker.js.map +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,7 +223,7 @@ A WASI preview1 or WASIX program (Zig, Rust, Go, wasi-libc or wasix-libc C) has
|
|
|
223
223
|
{ "slicc": { "abi": "wasi", "commands": { "rg": { "wasm": "bin/rg.wasm" }, "rustc": { "script": "bin/rustc" } } } }
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
-
A command's name is any single path component without whitespace or control characters, so coreutils' `[` is in `/usr/bin` too; a name that is empty, `.` or `..`, or has a `/`, a space, a tab, a newline, NUL or another control character is skipped. Entries may also set `argv0`, `args` and `env`, and a package can set `slicc.env` for all of its commands. A WASI entry with `"preopenRoot": true` also gets `/` as a preopen (see [WASI programs](#wasi-programs)). An entry with `"argv0Path": true` gets the absolute path it was run by as `argv[0]` when that path is a link outside `/bin` and `/usr/bin` to the command, as a venv's `bin/python` is (the `#!` line of a script naming such a link included), so it can find what sits beside that link; other entries keep their `argv0`. In `env`, `${package}` is the package directory, `${cwd}` the process's working directory, and `${NAME}` the caller's `NAME` when the program starts (a default naming an unset variable is left out); a relative path that exists in the package (`lib/python3.14`, `./`) becomes that absolute path. The caller's environment wins over these defaults; a `null` value removes the variable for the command, after the caller's environment is applied (a host-networked program can drop the kernel's proxy settings that way), and a command entry can set a variable its package's `slicc.env` removes. The catalog reads its roots in order, and the first root that provides a name wins it: a user's own global packages (for that user's processes), `/node_modules`, root's global packages, then each appended root. Within a root, packages are read by name and the first wins (compared as strings by UTF-16 code unit, so a scoped `@scope/name` comes before names that start with a letter, but after those that start with a digit), whatever order the file system lists them in; a `PNPM_HOME`'s install groups count as one root, read group by group (by the group directory's name, effectively arbitrary for pnpm's hashed names), each by package name. A variant can take over from the package it extends with `"slicc": { "replaces": ["@ai-ecoverse/wasm-imagemagick"] }`: when it and a package it names are in the same root, it wins every command name they share, whatever the order; names only the other provides stay the other's, a third package that provides the name and sorts first still wins it, and packages that replace each other fall back to the name order. A replace never reaches into an earlier root, so root's global packages can't take a name from `/node_modules` or a user's own packages. The field must be an array of package names; anything else is ignored. `sh` runs bash as `sh` unless a package provides its own. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. A process can also run a program by the path of its glue when the `.wasm` sits next to it, a wasm module by its own path (as a WASI program), and a script through its `#!` line (including `#!/usr/bin/env name`). Executing any other file fails with `ENOEXEC`, so bash runs it as a shell script, as POSIX shells do. Commands also come from pnpm's global directory. Every process gets a `PNPM_HOME` and has `$PNPM_HOME/bin` on `PATH`: root's is `/usr/local/share/pnpm` (unless the embedder sets its own), a user's `~/.local/share/pnpm` (see [Users](#users)). The kernel scans each of pnpm's global install groups (`$PNPM_HOME/global/*/<group>/node_modules`) after `/node_modules`, which wins when both provide a command. The catalog is re-read whenever anything under either changes, so `pnpm add -g @ai-ecoverse/wasi-ripgrep` makes `rg` work in the same shell, and `pnpm remove -g` removes it again.
|
|
226
|
+
A command's name is any single path component without whitespace or control characters, so coreutils' `[` is in `/usr/bin` too; a name that is empty, `.` or `..`, or has a `/`, a space, a tab, a newline, NUL or another control character is skipped. Entries may also set `argv0`, `args` and `env`, and a package can set `slicc.env` for all of its commands. A WASI entry with `"preopenRoot": true` also gets `/` as a preopen (see [WASI programs](#wasi-programs)). An entry with `"argv0Path": true` gets the absolute path it was run by as `argv[0]` when that path is a link outside `/bin` and `/usr/bin` to the command, as a venv's `bin/python` is (the `#!` line of a script naming such a link included), so it can find what sits beside that link; other entries keep their `argv0`. In `env`, `${package}` is the package directory, `${cwd}` the process's working directory, and `${NAME}` the caller's `NAME` when the program starts (a default naming an unset variable is left out); a relative path that exists in the package (`lib/python3.14`, `./`) becomes that absolute path. The caller's environment wins over these defaults; a `null` value removes the variable for the command, after the caller's environment is applied (a host-networked program can drop the kernel's proxy settings that way), and a command entry can set a variable its package's `slicc.env` removes. The catalog reads its roots in order, and the first root that provides a name wins it: a user's own global packages (for that user's processes), `/node_modules`, root's global packages, then each appended root. Within a root, packages are read by name and the first wins (compared as strings by UTF-16 code unit, so a scoped `@scope/name` comes before names that start with a letter, but after those that start with a digit), whatever order the file system lists them in; a `PNPM_HOME`'s install groups count as one root, read group by group (by the group directory's name, effectively arbitrary for pnpm's hashed names), each by package name. A variant can take over from the package it extends with `"slicc": { "replaces": ["@ai-ecoverse/wasm-imagemagick"] }`: when it and a package it names are in the same root, it wins every command name they share, whatever the order; names only the other provides stay the other's, a third package that provides the name and sorts first still wins it, and packages that replace each other fall back to the name order. A replace never reaches into an earlier root, so root's global packages can't take a name from `/node_modules` or a user's own packages. The field must be an array of package names; anything else is ignored. `sh` runs bash as `sh` unless a package provides its own. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. A process can also run a program by the path of its glue when the `.wasm` sits next to it, a wasm module by its own path (as a WASI program; when it is the `wasm` of a WASI command in the caller's catalog, the user's own packages included, it runs as that command, with its `env` defaults, `args` and `argv0`, the command whose name matches the file's, without `.wasm`, if several share it, while a copy elsewhere runs bare), and a script through its `#!` line (including `#!/usr/bin/env name`). Executing any other file fails with `ENOEXEC`, so bash runs it as a shell script, as POSIX shells do. A `..` in a path that is run is resolved physically, as the file system resolves it: `pkg/../tools/x` after a symlinked `pkg` is the `tools/x` beside the link's target, and its exec permission is checked there. The walk is the caller's: with users on, each directory it passes needs search (`x`) permission (`EACCES`, 126 in bash, as on Linux), and a sudo process follows only the links it trusts, as for any other path. Commands also come from pnpm's global directory. Every process gets a `PNPM_HOME` and has `$PNPM_HOME/bin` on `PATH`: root's is `/usr/local/share/pnpm` (unless the embedder sets its own), a user's `~/.local/share/pnpm` (see [Users](#users)). The kernel scans each of pnpm's global install groups (`$PNPM_HOME/global/*/<group>/node_modules`) after `/node_modules`, which wins when both provide a command. The catalog is re-read whenever anything under either changes, so `pnpm add -g @ai-ecoverse/wasi-ripgrep` makes `rg` work in the same shell, and `pnpm remove -g` removes it again.
|
|
227
227
|
|
|
228
228
|
**More roots:** `createKernel({ commands: { append: ['/opt/user/node_modules'] } })` (and `createNodeKernel`) scans more directories, each absolute and normalised (anything else throws a `TypeError`). A name is looked up in a user's own global packages first, then in `modules`, then in root's global packages, then in the appended roots in the order given, and the first match wins, so an appended root never shadows another command (or `sudo`). Their commands are in `/usr/bin` for every user, like root's global packages, and their `binfmt` and file system entries count too. A package in an appended root also gets a command for each `bin` entry of its `package.json` (a string `bin` is named after the package, without its scope), which runs as a `#!` script; a name its `slicc.commands` gives wins. A `bin` entry is skipped when its name isn't a command name (see above), or its file, with symlinks resolved, isn't a regular file inside the package. `/node_modules` and the global packages keep reading only `slicc.commands`. A missing root counts as empty until it appears, and an install or removal in a root is picked up as it happens. With `users: 'enforce'` a root counts only when it and every directory above it are root's and not writable by group or others; another root is skipped without a word. Under `'report'` such a root is still scanned, and reported once to `onDenial` and the report log as `{ pid: 0, uid: 0, op: 'commands', path, errno: 'EACCES' }`. Running a `bin` command needs `r` and `x` on its file under `users` (see [Permissions](#permissions)), checked when it runs, so a `0644` file is `EACCES` under `'enforce'`.
|
|
229
229
|
|
|
@@ -262,6 +262,22 @@ On top of preview1 a WASI program gets:
|
|
|
262
262
|
|
|
263
263
|
- **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.
|
|
264
264
|
- **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`. 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 again, since their data is already in the copied memory. 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`.
|
|
265
|
+
- **epoll and eventfd** (WASIX `epoll_create`, `epoll_ctl`, `epoll_wait` and `fd_event`, which tokio's reactor uses through the WASIX mio fork):
|
|
266
|
+
- **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.
|
|
267
|
+
- **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`).
|
|
268
|
+
- **What it reports:** `EPOLLIN`, `EPOLLOUT`, `EPOLLRDHUP` (the peer shut its write side), and always `EPOLLHUP` and `EPOLLERR` (a pipe's write end with no reader).
|
|
269
|
+
- **Level- and edge-triggered:** a registration is level-triggered by default. With `EPOLLET` it reports once per change of the descriptor (adding it or `EPOLL_CTL_MOD` count as one), so a writer that filled a pipe or socket gets `EPOLLOUT` again once the reader drains it.
|
|
270
|
+
- **ONESHOT:** an `EPOLLONESHOT` registration reports nothing after its first event, hangups included, until `EPOLL_CTL_MOD` re-arms it.
|
|
271
|
+
- **Waiting:** `epoll_wait` waits in the kernel without polling, with a timeout in nanoseconds (rounded up to milliseconds, never early; `u64::MAX` waits forever). A registration, a change or an eventfd write from another thread or process ends the wait at once. A signal with a handler ends it with `EINTR` after the handler has run. When more descriptors are ready than `maxevents`, the next wait starts after the last one reported, so none is starved.
|
|
272
|
+
- **eventfd:** a 64-bit counter. A read takes it (or 1 with `EFD_SEMAPHORE`) and needs 8 bytes; it blocks at zero, or fails with `EAGAIN` when nonblocking. A write of 8 bytes adds to it and blocks, or fails with `EAGAIN`, past 2^64-2.
|
|
273
|
+
- **Differences:**
|
|
274
|
+
- **Files:** regular files, directories and a program's own descriptors (devices, files it opened itself) can't be registered: `EPERM`, as on Linux. Wasmer accepts them and never reports them.
|
|
275
|
+
- **Closing a descriptor:** a registration ends when its descriptor is closed or now names another file. Linux keeps it while any duplicate of the descriptor stays open.
|
|
276
|
+
- **Not supported:** `EPOLLPRI`, `EPOLLEXCLUSIVE` and `EPOLLWAKEUP` are never reported.
|
|
277
|
+
- **UDP:** datagram sockets come with UDP (#476).
|
|
278
|
+
- **Toolchain problems:**
|
|
279
|
+
- **wasix-sysroot 2025.9.30-28's `epoll_ctl`:** it passes Linux's operation numbers (ADD 1, DEL 2, MOD 3) to WASIX untranslated, where WASIX numbers them ADD 0, MOD 1, DEL 2. The kernel takes both: 0 adds, 2 deletes, 3 modifies, and 1 adds a descriptor that isn't registered and modifies one that is. So a C program's second ADD of a descriptor modifies it rather than failing with `EEXIST`, and a WASIX MOD of an unregistered one adds it. That libc also returns the error number instead of -1, so its callers can't see a failure.
|
|
280
|
+
- **The WASIX mio fork (1.0.3+wasix.1):** it opens its sockets blocking, unlike upstream mio's `SOCK_NONBLOCK`. On a socket whose send buffer is full, a tokio program blocks its runtime's thread, as it would on Wasmer.
|
|
265
281
|
- **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.
|
|
266
282
|
- **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 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).
|
|
267
283
|
- **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, even if the main thread blocks it and another thread does not. 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.
|
|
@@ -342,7 +358,7 @@ const kernel = await createKernel({ network: { transport: fetchTransport() } });
|
|
|
342
358
|
await kernel.run(['curl', '-sS', 'https://registry.npmjs.org/@ai-ecoverse/wasm-bash/latest']);
|
|
343
359
|
```
|
|
344
360
|
|
|
345
|
-
- **Sockets**: `AF_INET` stream sockets on `127.x` and `AF_UNIX` sockets are kernel descriptors on one loopback network per kernel, so processes can serve and connect to each other. They survive `dup`, `fork` and `exec` and work with `select` and
|
|
361
|
+
- **Sockets**: `AF_INET` stream sockets on `127.x` and `AF_UNIX` sockets are kernel descriptors on one loopback network per kernel, so processes can serve and connect to each other. They survive `dup`, `fork` and `exec` and work with `select`, `poll` and WASIX epoll, like pipe ends. A bind of an `AF_UNIX` socket creates its path in OPFS. Other addresses are unreachable (`ENETUNREACH`) unless the [uplink](#uplink) routes them: otherwise programs reach the outside world only through the proxy.
|
|
346
362
|
- **The proxy** listens on `127.0.0.1:3128` from the first connection on. It takes absolute-form `http:` requests and `CONNECT` tunnels, which it terminates with a certificate for the requested host, issued by the kernel's own CA, then hands each request to the transport. Programs start with `http_proxy`, `https_proxy` (and the upper-case names) pointing at it, `no_proxy` covering loopback and the [node name](#node-name), and `SSL_CERT_FILE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` pointing at the CA bundle in `/etc/ssl/certs/slicc-kernel-ca.pem`; variables in `env` override them. The CA's key never leaves WebCrypto; it is kept in IndexedDB, in `<metadata>-ca`, or in `slicc-kernel-ca` when `metadata` is the default or `false`, so kernels on one origin share one CA whether or not they keep metadata. `ca` names another database, and `ca: false` keeps a CA in memory for the kernel's lifetime. Each kernel adds its CA to the bundle instead of replacing it, so every kernel on the origin stays trusted; the bundle keeps the newest 16.
|
|
347
363
|
- **TLS** needs [`@ai-ecoverse/wasm-tls-engine`](https://www.npmjs.com/package/@ai-ecoverse/wasm-tls-engine) installed under `modules`, like a command. Without it, `CONNECT` to port 443 is answered `501` with the reason. A tunnel to another port is accepted, and what the program sends first decides: plain HTTP is served as `http://host:port`, without TLS, and a TLS handshake is terminated as above, or the tunnel closes when it cannot be.
|
|
348
364
|
- **The transport** is an object on the page with `traits` (`manualRedirects`, `encodedBodies`, `maxRequestBody`, and `crossOrigin`: `'cors'` or `'any'`) and `fetch(request)`, which answers `{ status, statusText, headers, body, cancel }` with `body` an async iterable of `Uint8Array`, and optionally `url` and `redirected: true` when it followed redirects itself (`fetchTransport()` does), so a JS program's `fetch` reports where it ended. The kernel pulls the body one chunk at a time, so a slow program slows the download. A rejection with a numeric `status` is answered with that status; any other with `502`. `fetchTransport()` is the plain `fetch` of the page, bound by CORS; its `hint` option is appended to the message of a request it could not make, so a program's 502 can name a way around CORS (`registry.npmjs.org` and jsDelivr allow it); an embedder with a way around CORS passes its own. A response body that delivers no data for `bodyIdleMs` (default 5 minutes; `0` or `Infinity` turns it off) fails with `ETIMEDOUT` instead of waiting forever, which matters because Chrome under heavy load can stop handing a page the rest of a body. A slow body that keeps delivering is never cut off.
|
|
@@ -390,7 +406,7 @@ The kernel has one node name, `slicc` unless `createKernel({ hostname })` (or `c
|
|
|
390
406
|
|
|
391
407
|
`bind()` still takes only loopback and `0.0.0.0`, so nothing listens on an uplink address.
|
|
392
408
|
|
|
393
|
-
An uplink connection is an ordinary socket fd: it survives `dup`, `fork` and `exec`, and works with `select` and
|
|
409
|
+
An uplink connection is an ordinary socket fd: it survives `dup`, `fork` and `exec`, and works with `select`, `poll` and WASIX epoll.
|
|
394
410
|
- **Blocking `connect`** waits for the dial, at most 30 s (then `ETIMEDOUT`, even if the uplink ignores `signal`; a connection it opens later is closed). A signal interrupts it with `EINTR` while the dial goes on.
|
|
395
411
|
- **Non-blocking `connect`** answers `EINPROGRESS`. The socket turns writable when the dial is done, and `SO_ERROR` says how it went.
|
|
396
412
|
- **Closing the socket** during the dial gives `ECONNABORTED`.
|
|
@@ -814,6 +830,72 @@ The Biome, TypeScript, lefthook, Renovate and CI configuration comes from [slicc
|
|
|
814
830
|
|
|
815
831
|
Releases are cut by semantic-release on every push to `main`, in two jobs, and only on `main`. The first job runs on the bench pool, so it starts within a minute of the push, before `main` has moved on. It computes the version, tags the commit and creates the GitHub release, with `npmPublish: false`. A run that starts after `main` has moved skips; the run for the newer commit releases it. The second job runs on a GitHub-hosted runner, because npm's trusted publishing and provenance accept no self-hosted runner. It publishes the tagged commit with `tools/release-recover.mjs`. However long it waits for a runner, it publishes exactly the commit the run started on, so the provenance, the tag and the package's `gitHead` agree. For any version tagged at HEAD, `tools/release-recover.mjs` pushes the tag if origin lacks it, publishes the version if npm lacks it (with retries), and creates its GitHub release if that is missing too. So a failure after the version is tagged (a rejected push, a failed `npm publish`) doesn't strand it, and re-running the Release workflow completes it the same way.
|
|
816
832
|
|
|
833
|
+
## Test fixtures
|
|
834
|
+
|
|
835
|
+
The WASIX fixtures of `test/integration/epoll.test.mjs` (#674), under `test/integration/fixtures/`, are committed with their sources, build scripts and built wasm.
|
|
836
|
+
|
|
837
|
+
### `tokiotest`
|
|
838
|
+
|
|
839
|
+
A small tokio program built for WASIX (`wasm32-wasmer-wasi`), the fixture for `test/integration/epoll.test.mjs` (#674). It runs on tokio's current-thread runtime, or on the multi-thread one (2 workers) with `--multi` first:
|
|
840
|
+
|
|
841
|
+
| Mode | What it does | Prints |
|
|
842
|
+
| --- | --- | --- |
|
|
843
|
+
| `echo` | a TcpListener on 127.0.0.1:4701 and a TcpStream to it; the client sends `hello`, the server answers `echo hello` | `echo hello` |
|
|
844
|
+
| `stdin` | copies `tokio::io::stdin()` through a loopback socket (port 4702) and back to stdout | the input |
|
|
845
|
+
| `sleep MS` | `tokio::time::timeout` of a future that never ends | `timed out not early` |
|
|
846
|
+
| `wake` | a `spawn_blocking` thread wakes the reactor through `Notify` (the mio waker's eventfd) | `woken, blocking done` |
|
|
847
|
+
| `backpressure` | writes 4 MiB to a nonblocking loopback socket (port 4703) whose reader starts 100 ms later | `sent 4194304 received 4194304` |
|
|
848
|
+
| `serve PORT` / `client PORT` | two processes over loopback; the server answers in upper case | `served PING` / `client got PING` |
|
|
849
|
+
|
|
850
|
+
The ports are fixed because `local_addr()` reports `0.0.0.0:0` with this toolchain: Rust's std for `wasm32-wasmer-wasi` doesn't import `sock_addr_local`.
|
|
851
|
+
|
|
852
|
+
In `backpressure`, the client socket is set nonblocking by hand (`into_std`, `set_nonblocking`, `from_std`). The WASIX mio fork (1.0.3+wasix.1) opens its sockets blocking, unlike upstream mio's `SOCK_NONBLOCK`. On a blocking socket a full send buffer blocks the runtime's thread, so tokio never sees `EAGAIN` and never waits for `EPOLLOUT`.
|
|
853
|
+
|
|
854
|
+
**Build:**
|
|
855
|
+
|
|
856
|
+
```sh
|
|
857
|
+
WASIX_RUST=/path/to/wasix-rust WASIX_SYSROOT_28=/path/to/wasix-sysroot ./build.sh
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
- `WASIX_RUST`: the unpacked [wasix-org/rust](https://github.com/wasix-org/rust) toolchain `v2026-09-18.1+rust-1.97`, aarch64-apple-darwin (`rust-toolchain-aarch64-apple-darwin.tar.gz`, sha256 `815927a3894ac7a0d4b9709a5825a85678c94733225b9e4da574cfb9674b4059`). `rustc -vV` says `1.97.1-dev`, LLVM 22.1.6. Cargo is the host's (1.98.1 here), with `RUSTC` pointing at the toolchain's.
|
|
861
|
+
- `WASIX_SYSROOT_28`: the unpacked `@ai-ecoverse/wasix-sysroot` 2025.9.30-28 package (sha256 of the tarball `ce48e444ca4677a7afa570fb9076c60a897ed4681a10b1767704a003c674e81d`). It's linked with `-C link-self-contained=no`, its `libc.a` and `crt1-command.o`.
|
|
862
|
+
- The crates come from the WASIX registry (`.cargo/config.toml`, source replacement to `sparse+https://cargo-registry.wasix.org/`), pinned by `Cargo.lock`: tokio 1.47.0+wasix.1, mio 1.0.3+wasix.1, libc 0.2.186+wasix.1, socket2 0.6.0+wasix.1, wasix 0.13.2.
|
|
863
|
+
- Build output goes to `.build/` (or `TOKIOTEST_BUILD`), which git ignores.
|
|
864
|
+
|
|
865
|
+
The build is reproducible: `bin/tokiotest.wasm` is 258,642 bytes, sha256 `3b230fc0f03ad7290fb0cd78724985cc021b8ab19b2e6401bf6bc62989dfd039`.
|
|
866
|
+
|
|
867
|
+
### `epolltest`
|
|
868
|
+
|
|
869
|
+
A C program built with wasixcc against `@ai-ecoverse/wasix-sysroot` 2025.9.30-28, the fixture for the epoll and eventfd rows of `test/integration/epoll.test.mjs` (#674) that tokio can't show:
|
|
870
|
+
- level vs edge-triggered, and edge-triggered on ADD and on MOD;
|
|
871
|
+
- ONESHOT (silent, hangups included, until MOD);
|
|
872
|
+
- the eventfd counter and semaphore;
|
|
873
|
+
- EINTR from a SIGALRM handler, and timeouts;
|
|
874
|
+
- ctl errors;
|
|
875
|
+
- closed descriptors;
|
|
876
|
+
- writable edges on a pipe and a TCP socket;
|
|
877
|
+
- registrations and eventfd writes from another thread;
|
|
878
|
+
- EPOLLRDHUP;
|
|
879
|
+
- fairness;
|
|
880
|
+
- a nonblocking connect;
|
|
881
|
+
- stdin.
|
|
882
|
+
|
|
883
|
+
`epolltest MODE` runs one of them and prints what it saw.
|
|
884
|
+
|
|
885
|
+
The -28 libc's `epoll_ctl` returns the WASI errno instead of -1 with `errno` set. `ctl()` turns a positive return into `errno`. The program uses the libc's own (Linux) operation numbers, as real C programs do, plus WASIX's ADD (0) for the `EEXIST` row.
|
|
886
|
+
|
|
887
|
+
The libc does translate the event bits, both ways, and `epoll_create`, `epoll_wait` and `eventfd` work as on Linux.
|
|
888
|
+
|
|
889
|
+
**Build:**
|
|
890
|
+
|
|
891
|
+
```sh
|
|
892
|
+
WASIX_SYSROOT_28=/path/to/wasix-sysroot ./build.sh
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
- `WASIX_SYSROOT_28` is the unpacked sysroot package.
|
|
896
|
+
- `wasixcc` (0.4.7, WASIX clang 21.1.2) comes from `PATH`, or is installed by `install-wasixcc.sh` as for `dlfork`.
|
|
897
|
+
- The build is reproducible: `bin/epolltest.wasm` is 78,672 bytes, sha256 `f1ff262e2d90080c37c968187ade45c7d3adf81b49dd0dc0109428ef9c7e1df8`.
|
|
898
|
+
|
|
817
899
|
## License
|
|
818
900
|
|
|
819
901
|
Apache-2.0
|