@ai-ecoverse/slicc-kernel 1.117.2 → 1.117.5

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
@@ -356,7 +356,7 @@ The module's default export (or its `main` export) is called with a context and
356
356
  - `argv` (with `argv[0]`), `env`, `pid` (the pid `ps` shows, also after an `exec`), `ppid()` (asked each time, so an orphan sees 1) and `cwd`, and `resolve(path)` to make a path absolute against `cwd`;
357
357
  - `stdin`, `stdout` and `stderr` as web streams, and `read(fd, max?)` (empty at end of file), `write(fd, bytes | string)` and `close(fd)` on any descriptor it inherited. A write waits until the pipe has room, so a slow reader slows the program down;
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
- - `open(path, { read, write, append, create, exclusive, truncate })`, 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;
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
361
  - `/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
362
  - `/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;
@@ -610,20 +610,21 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
610
610
  - **Capabilities:** `readonly`, `symlinks`, `links` (hard links: the driver answers `link(from, to)`, reports `nlink` in its attributes, and gives every attribute a stable `ino`, the same for all names of a file; a mount whose driver offers `links` but answers an attribute without `ino` loses them: `link` is `EPERM` there and `st_nlink` is 1), `chmod`, `owners`, `ranges`, `maxIo` (the largest read or write per message), `maxFile`, `listingStats`, `sessions`, `linkTimes`, and `attrTtl`/`entryTtl` (in ms, default 1000). `sessions` promises that a handle opened for writing keeps its writes to itself until it is released, so the kernel keeps one open per file being written instead of one per write (the `fsa` driver has it). `linkTimes` promises that `setattr` with `mtime` on a symlink sets the link's own time. Without it, setting a link's own times (`touch -h`, `utimensat` with `AT_SYMLINK_NOFOLLOW`) fails with `EOPNOTSUPP` and never reaches the target. tmpfs has it; hostfs does not. Setting times through a link follows it in the kernel, so `setattr` gets the target. `ranges` (driver protocol 1.1) promises that a write lands in the file at once, with no copy of the whole file on `open` or `release`, and that `setattr` takes `size`. `owners` (driver protocol 1.2) promises that the driver stores owners: its attributes carry `uid` and `gid`, `open` with `create`, `mkdir` and `symlink` may carry the new path's `uid`, `gid` and `mode` (the `owner` of the open flags, the second or third handler argument), a new path without them takes its directory's owner, and `setattr` takes `uid` and `gid`. tmpfs has it.
611
611
  - **Caching:** the kernel caches attributes and listings for those TTLs, and a driver can push `invalidate` for changes made outside.
612
612
  - **File contents:** on a mount with `ranges`, as `tmpfs` is, and on the root file system when it is in OPFS (in a browser with sync access handles) or an in-memory root, a program reads and writes a file in 64 KiB pages: reads come from the driver 1 MiB at a time, and changed pages go back in 1 MiB runs once 8 MiB have changed, at `fsync`, and when the file is closed (from the kernel's own descriptors also 250 ms after a write). A file of any size the mount can hold works, with memory to spare for about one more copy of it. In OPFS a write opens a sync access handle for just that write. An `fsa` mount (a picked folder) has no sync access handles, and Chrome copies the whole file into a swap file for every writable stream, so it writes through one stream per open file: the first write back opens it, later runs go into it at their offsets, and it is committed, one copy of the file, when the last process writing the file closes it (or exits), before another process reads what was written, and before a rename, unlink or unmount. Until then other processes see the new size but read the old bytes outside what was written. Elsewhere (`hostfs` with an older proxy, and a root outside OPFS), file contents are read whole when a program opens a file and written back when it closes it. Before a program's next call into the kernel (any wait, read of a pipe or terminal, sleep or spawn), its changed pages also go back, so other processes and the embedder see a write as soon as the writer does anything but compute: a daemon's pidfile, a log another process follows. On a whole-file mount that holds only for files of up to 64 KiB; a larger one goes back at close, fsync and exit, as before. Beyond that, both give close-to-open consistency, as NFS has. A process that ends without closing its files keeps what it wrote: on exit, on a trap, and when a signal kills it. For a killed WASI program, the kernel lets its worker write its changed pages back before `waitpid` reports the death. The only exception is a worker that makes no call into the kernel for 2 seconds after the signal; it is stopped without that write-back.
613
+ - **Exclusive create across tabs** ([#912](https://github.com/ai-ecoverse/slicc-kernel/issues/912)): two kernels on one OPFS root (two tabs, or a tab and a worker page) agree on `O_CREAT|O_EXCL` and `set -C`: exactly one wins a name. While another kernel on the root is present, the create runs under a Web Lock for its path and checks the OPFS entry itself. A kernel alone on its root takes no lock. Each kernel holds an alive Web Lock, granted before it first looks for peers. A kernel that finds peers waits for their answer (at most 500 ms) before its first exclusive create, and a kernel answers a newcomer only once its unlocked creates are done. So of two kernels that boot at the same moment, the later one to look sees the other, and no unlocked create overlaps a locked one.
613
614
  - **One file, several processes** ([#665](https://github.com/ai-ecoverse/slicc-kernel/issues/665)): a process that keeps a file open sees what another process writes to it. Every change the kernel applies to a file (a write back, a truncate, an append, a rename or a removal) bumps a change ring the kernel shares with all its processes. Before each read, write or seek to the end, a process's open file checks the ring (one atomic load) and, if its file changed, drops its clean pages and reads the size again, keeping its own unwritten changes. A write by one process is visible to another one's next read once it has gone back to the kernel: at the latest at the writer's next `fsync`, close, exit or call into the kernel (see above), so sqlite, which `fsync`s every commit, sees another connection's commit at its next transaction. A write through a file the kernel holds (a JS program's `ctx.open`, a WASI program's file after a fork or spawn) is in the kernel already, so other processes see it at their next read, before it is written back. A file the kernel itself holds open (a JS program's file, a WASI program's file after a fork or spawn) is the one copy: reads, writes, truncates and appends by path from other processes go to it, and their writes go through to storage at once, so a commit is in storage (and in `kernel.readFile`) when the committing process returns from `fsync`. `O_APPEND` writes go to the kernel at once, and the kernel picks the offset, so appenders in several processes keep every line. A file system without ranged I/O (`hostfs` with an older proxy, a root outside OPFS) rereads a file another process changed, but writes it back whole, so two writers there still need locks. The same file under two names is one file too ([#669](https://github.com/ai-ecoverse/slicc-kernel/issues/669)): a write by path through a symlink reaches the file the kernel holds under its real path (resolved without following links in a `sudo` process's no-links view, so an untrusted link is still refused), a WASI program's open file also watches its real path (resolved once, the first time another file changes), and an open file with more than one hard link checks its size, version and mtime when anything changed, so a write through another name is seen. Limits for now: an alias on a mount that has no `realpath` is still only the name it was opened by; another tab's writes reach this tab's processes only as far as the root's change notifications (OPFS's `BroadcastChannel` watch) carry them. A by-path `stat` or `lstat` of a file the kernel holds open has its current size, writes not yet written back included ([#780](https://github.com/ai-ecoverse/slicc-kernel/issues/780)). Its mtime is the file system's until write-back, though, so a tool that compares times, like `make`, may see a just-written file as older than it is for up to the write-back delay.
614
615
  - **Locks between processes** ([#665](https://github.com/ai-ecoverse/slicc-kernel/issues/665)): an Emscripten program's `fcntl` byte-range locks (`F_SETLK`, `F_SETLKW`, `F_GETLK`) are the kernel's, and so are a WASIX program's through the `slicc.fcntl_lock` import (below), so sqlite3 connections in different processes exclude each other: a second writer gets `database is locked` while the first holds its transaction, or waits with a busy timeout. The kernel keeps one table keyed by file identity (device and inode, so every name of a hard link is the same file), with POSIX semantics: locks belong to the process (an `exec`'d program keeps them under its pid; a forked child doesn't get them), a conflicting `F_SETLK` fails with `EAGAIN`, `F_GETLK` reports the holder's pid and range, `F_SETLKW` waits in the kernel without holding up anything else and a signal ends the wait with `EINTR`, a wait that would close a cycle between waiting processes fails with `EDEADLK`, a write lock on a descriptor not open for writing (or a read lock on one not open for reading) is `EBADF`, closing any descriptor of the file drops the process's locks on it, and exit releases them all. Before an unlock, the process writes back what it changed, so the next holder reads it. An open file's directory entry is checked against the change ring too: a name another process removed or replaced is looked up again, so sqlite's check for a hot journal sees the other connection's commit. A WASI imports module's `tryLock`/`unlock` keeps its behaviour (whole file, per process, keyed by path). Limits: locks are per kernel, not across tabs ([#675](https://github.com/ai-ecoverse/slicc-kernel/issues/675)); and a WASIX program built against a wasix-sysroot without the import keeps its libc's local answer (every lock granted), so python's sqlite excludes other processes only once wasix-python is rebuilt on the sysroot that has it ([homescoop#407](https://github.com/ai-ecoverse/homescoop/issues/407)). An Emscripten program's `flock()` is a stub compiled into its libc that never reaches the kernel either, until a homescoop shim routes it to a kernel import ([#680](https://github.com/ai-ecoverse/slicc-kernel/issues/680)). A lock belongs to the file its descriptor was opened on: a file renamed over the name later, or the name's removal, doesn't move it.
615
616
  - **`slicc.fcntl_lock(fd, cmd, flock_ptr) -> errno`** ([#722](https://github.com/ai-ecoverse/slicc-kernel/issues/722)): a WASIX program's `fcntl` record locks reach the same table as an Emscripten program's, so the two see each other's locks.
616
617
  - **cmd:** `12` `F_GETLK`, `13` `F_SETLK` or `14` `F_SETLKW`. Any other cmd is `EINVAL`.
617
618
  - **The struct:** musl's wasm32 `struct flock`, 32 bytes: `l_type` i16 at 0 (`0` read, `1` write, `2` unlock), `l_whence` i16 at 2 (`0` SET, `1` CUR, `2` END, resolved against the descriptor's offset or size), `l_start` i64 at 8, `l_len` i64 at 16 (`0` to EOF and beyond, negative before `l_start`), `l_pid` i32 at 24. The kernel reads and writes only those five fields and never the padding.
618
619
  - **F_GETLK:** with a conflicting lock, it writes that lock back (`l_whence` 0, its absolute start, its length, the holder's pid). Without one, it sets only `l_type` to `2`.
619
- - **Errors (WASI numbers):** `EAGAIN` for a conflict on `F_SETLK` (never `EACCES`), `EINTR` when a signal with a handler ends an `F_SETLKW` wait, `EDEADLK` for a wait that would close a cycle, `EBADF` for a bad descriptor or a lock type the descriptor's access mode doesn't allow, and `EINVAL` for a bad `l_type` or `l_whence` or a start before 0.
620
+ - **Errors (WASI numbers):** `EAGAIN` for a conflict on `F_SETLK` (never `EACCES`), `EINTR` when a signal with a handler ends an `F_SETLKW` wait (a handler installed with `SA_RESTART` runs and the wait goes on, [#931](https://github.com/ai-ecoverse/slicc-kernel/issues/931)), `EDEADLK` for a wait that would close a cycle, `EBADF` for a bad descriptor or a lock type the descriptor's access mode doesn't allow, and `EINVAL` for a bad `l_type` or `l_whence` or a start before 0.
620
621
  - **Semantics:** the same as Emscripten's, above. A libc that finds no import on an older kernel gets `ENOSYS` and keeps its local answer.
621
622
  - **Any kind of descriptor** ([#893](https://github.com/ai-ecoverse/slicc-kernel/issues/893)): a pipe, a socket or a terminal takes record locks too, as on Linux, keyed by the object (both ends of a pipe are one object, each socket is its own, a terminal is one object however often it's opened). `l_whence` `CUR` and `END` count from 0 there.
622
623
  - **`slicc.flock(fd, op) -> errno`** ([#893](https://github.com/ai-ecoverse/slicc-kernel/issues/893)): BSD `flock(2)` locks, with Linux's semantics, for a WASIX program on any descriptor: a regular file, a directory, a pipe, a socket, a terminal or a `/dev` device.
623
624
  - **op:** Linux's bits, `LOCK_SH` `1`, `LOCK_EX` `2` or `LOCK_UN` `8`, optionally with `LOCK_NB` `4`. Anything else is `EINVAL`.
624
625
  - **Owner:** the open file description, not the process. A `dup`'d descriptor and one a forked child or a spawned program inherits share it, so the child's `LOCK_EX|LOCK_NB` is granted and its `LOCK_UN` releases the lock for the parent too. So children that `flock(STDOUT)` an inherited STDOUT don't exclude each other, on Linux either: for that, each child opens a lock file itself. A second open of the same object is another description and conflicts: the file opened again (by any name of it), `/dev/tty` opened again, the other end of a pipe.
625
626
  - **Rules:** shared locks coexist, an exclusive one excludes every other description, and any access mode takes either kind, so `LOCK_EX` on a read-only open excludes (homescoop#758). A conversion drops the old lock before it takes the new one, as on Linux, so a refused `LOCK_NB` conversion leaves no lock. The lock goes with `LOCK_UN` or when the description's last descriptor closes, at exit or kill too. flock locks and `fcntl` record locks are separate and don't block each other.
626
- - **Errors (WASI numbers):** `EAGAIN` for a conflict with `LOCK_NB`, `EINTR` when a signal with a handler ends a wait (as for `F_SETLKW`: a libc that wants `SA_RESTART` asks again), `EBADF` for a bad descriptor, `EINVAL` for a bad op. There's no deadlock detection, as on Linux.
627
+ - **Errors (WASI numbers):** `EAGAIN` for a conflict with `LOCK_NB`, `EINTR` when a signal with a handler ends a wait (with `SA_RESTART` the handler runs and the wait goes on, [#931](https://github.com/ai-ecoverse/slicc-kernel/issues/931)), `EBADF` for a bad descriptor, `EINVAL` for a bad op. There's no deadlock detection, as on Linux.
627
628
  - **Limits:** locks are per kernel ([#675](https://github.com/ai-ecoverse/slicc-kernel/issues/675)). A file the program buffers itself is handed to the kernel at its first `flock`, with every other descriptor the program has open on it. A libc that finds no import on an older kernel gets `ENOSYS`; wasix-libc's `flock()` then keeps its old answer. Emscripten programs and the imports module's `tryLock` are unchanged.
628
629
  - **Rules on a mount:**
629
630
  - a rename across mounts is `EXDEV`, so `mv` copies;
@@ -923,7 +924,7 @@ Not covered: hard links (OPFS cannot share contents between names; they work onl
923
924
 
924
925
  Reads retry briefly when a concurrent write has invalidated the OPFS file snapshot (`NotReadableError`).
925
926
 
926
- A kernel caches the directory handles it has walked and the sidecar entries it has read. Its own changes keep both caches current, and the other kernels on the origin hear of them through a `BroadcastChannel` named after the database (one for the sidecar, one for directories). A cached directory is checked against its path (`resolve()`) at most once a second before a lookup goes through it, so a directory moved, removed or replaced by another writer, such as a page using the OPFS API, is found at its new place or reported missing within a second. It also keeps the file handles it has looked up (up to 4,096, the least recently used dropped first) for a second after each lookup. Before it uses one, it reads the file through it, which is the read a `stat` needs anyway, so a file another writer removed is missing at once and a rewritten one shows its new contents. One replaced by a directory is seen as a directory within a second. Its own changes, and the other kernels' notices, drop the handles they touch at once. A lookup of a directory it has cached and checked within the second is answered from the cache, so a directory replaced by another writer is seen as replaced within a second, as above. A name it has just found missing stays missing for 2 ms, which serves the several checks a single create makes of a name that isn't there yet (about ten per file in a pnpm install); its own creates, renames and links in that directory end that at once, so only a file another kernel or a page using the OPFS API makes in those 2 ms is seen up to 2 ms late.
927
+ A kernel caches the directory handles it has walked and the sidecar entries it has read. Its own changes keep both caches current, and the other kernels on the origin hear of them through a `BroadcastChannel` named after the database (one for the sidecar, one for directories). A cached directory is checked against its path (`resolve()`) at most once a second before a lookup goes through it, so a directory moved, removed or replaced by another writer, such as a page using the OPFS API, is found at its new place or reported missing within a second. It also keeps the file handles it has looked up (up to 4,096, the least recently used dropped first) for a second after each lookup. Before it uses one, it reads the file through it, which is the read a `stat` needs anyway, and that read serves the handle's uses of the next 2 ms. So a file another writer removed is missing, and a rewritten one shows its new contents, within 2 ms. The kernel's own writes, truncates, rewrites and renames drop the read at once, and a read that started before an own change isn't kept, so the kernel's own changes always show at once. One replaced by a directory is seen as a directory within a second. Its own changes, and the other kernels' notices, drop the handles they touch at once. A lookup of a directory it has cached and checked within the second is answered from the cache, so a directory replaced by another writer is seen as replaced within a second, as above. A name it has just found missing stays missing for 2 ms, which serves the several checks a single create makes of a name that isn't there yet (about ten per file in a pnpm install); its own creates, renames and links in that directory end that at once, so only a file another kernel or a page using the OPFS API makes in those 2 ms is seen up to 2 ms late.
927
928
 
928
929
  The filesystem is our own rather than [ZenFS](https://github.com/zen-fs/core) (which SLICC uses), because OPFS stays the single source of truth: other writers, such as the BIOS installing packages or a page writing files, need no index to stay consistent with, and nothing is preloaded into memory at mount. POSIX metadata lives in the IndexedDB sidecar described above, which has per-entry transactions instead of one JSON file rewritten on every change.
929
930
 
@@ -1252,11 +1253,11 @@ A WASIX C program for `test/integration/wasix-locks.test.mjs` (#722). It calls `
1252
1253
 
1253
1254
  A WASIX C program for `test/integration/flock.test.mjs` (#893). It calls `slicc.flock` and `slicc.fcntl_lock` directly (`import_module("slicc")`). Its arguments are a sequence of steps, run in order:
1254
1255
  - `open SLOT r|w|rw PATH`, `pipe SLOT SLOT`, `pair SLOT SLOT` (a `socketpair`), `dup FROM TO`, `fd SLOT N` (names an inherited descriptor), `close SLOT`;
1255
- - `flock SLOT OP`, where OP is made of `sh`, `ex`, `un` and `nb` (`exnb` is `LOCK_EX|LOCK_NB`) or is a number; `badfd`; `fcntl SLOT r|w|u` (`F_SETLK` on the whole file);
1256
+ - `flock SLOT OP`, where OP is made of `sh`, `ex`, `un` and `nb` (`exnb` is `LOCK_EX|LOCK_NB`) or is a number; `badfd`; `fcntl SLOT r|w|u` (`F_SETLK` on the whole file), `fcntlw SLOT r|w` (`F_SETLKW`);
1256
1257
  - `fork … end` (the child runs the steps up to the matching `end`, its lines start with `child `, and the parent waits for it before it goes on after `end`);
1257
- - `touch PATH`, `await PATH`, `sleep MS`, `say TEXT`, `trap` (a `SIGUSR1` handler).
1258
+ - `touch PATH`, `await PATH`, `sleep MS`, `say TEXT`, `trap` (a `SIGUSR1` handler), `trapr` (one with `SA_RESTART` that prints `handler`).
1258
1259
 
1259
- **Build:** as `wlocktest`. Two builds give the same bytes: `bin/flocktest.wasm` is 58,442 bytes, sha256 `687ecf21c836adda4ef2e328b6032eed28ca7d37efc8e88d3aaefb8fc117115a`.
1260
+ **Build:** as `wlocktest`. Two builds give the same bytes: `bin/flocktest.wasm` is 58,907 bytes, sha256 `58b16f19911e0665ba1fbda3fa6d370d6228f4c59dfc13902f126c6e8b43b120`.
1260
1261
 
1261
1262
  ## License
1262
1263
 
@@ -870,22 +870,54 @@ var RemoteWatch = class {
870
870
  mine = [];
871
871
  seq = 0;
872
872
  pruned = 0;
873
+ joined;
874
+ met;
875
+ quiet;
873
876
  constructor(name2, options = {}) {
874
877
  this.name = name2;
875
878
  this.locks = options.locks === void 0 ? lockManager() : options.locks ?? void 0;
876
879
  this.channel = new BroadcastChannel(name2);
877
880
  this.channel.onmessage = ({ data }) => this.receive(data);
878
881
  this.channel.unref?.();
879
- void this.locks?.request(this.lockOf(this.tab), () => new Promise(() => {
880
- }));
882
+ const granted2 = new Promise((resolve2) => {
883
+ void this.locks?.request(this.lockOf(this.tab), () => {
884
+ resolve2();
885
+ return new Promise(() => {
886
+ });
887
+ });
888
+ if (!this.locks) resolve2();
889
+ });
890
+ this.joined = granted2.then(() => this.join());
881
891
  this.channel.postMessage({ hello: this.tab });
882
892
  }
893
+ async join() {
894
+ if (!this.locks) return;
895
+ const prefix = `${this.name}:`;
896
+ const mine = this.lockOf(this.tab);
897
+ const held = ((await this.locks.query()).held ?? []).map((lock) => lock.name ?? "");
898
+ if (!held.some((name2) => name2.startsWith(prefix) && name2 !== mine) || this.peers.size > 0)
899
+ return;
900
+ await new Promise((resolve2) => {
901
+ const timer = setTimeout(resolve2, ANNOUNCE_WAIT_MS);
902
+ this.met = () => {
903
+ clearTimeout(timer);
904
+ resolve2();
905
+ };
906
+ });
907
+ }
908
+ async peered() {
909
+ await this.joined;
910
+ return this.peers.size > 0;
911
+ }
883
912
  lockOf(tab) {
884
913
  return `${this.name}:${tab}`;
885
914
  }
886
915
  receive(data) {
887
916
  if (data.hello !== void 0) this.greeted(data.hello);
888
- if (data.here !== void 0 && data.to === this.tab) this.peers.add(data.here);
917
+ if (data.here !== void 0 && data.to === this.tab) {
918
+ this.peers.add(data.here);
919
+ this.met?.();
920
+ }
889
921
  if (data.tab !== void 0 && data.paths !== void 0) this.announced(data);
890
922
  if (data.ack !== void 0 && data.to === this.tab && data.from !== void 0) {
891
923
  this.acked(data.ack, data.from);
@@ -894,8 +926,12 @@ var RemoteWatch = class {
894
926
  }
895
927
  greeted(peer) {
896
928
  this.peers.add(peer);
897
- this.channel.postMessage({ here: this.tab, to: peer });
898
- if (this.mine.length > 0) void this.announce(this.mine);
929
+ const answer = () => {
930
+ this.channel.postMessage({ here: this.tab, to: peer });
931
+ if (this.mine.length > 0) void this.announce(this.mine);
932
+ };
933
+ if (this.quiet) void this.quiet().then(answer, answer);
934
+ else answer();
899
935
  }
900
936
  announced({ tab, paths, seq: seq2 }) {
901
937
  const from = tab;
@@ -995,6 +1031,7 @@ var READ_ATTEMPTS = 3;
995
1031
  var MAX_LINKS2 = 40;
996
1032
  var CHECK_MS = 1e3;
997
1033
  var ABSENT_MS = 2;
1034
+ var GLANCE_MS = 2;
998
1035
  var ABSENT_NAMES = 4096;
999
1036
  var FILE_HANDLES = 4096;
1000
1037
  var STALE = /* @__PURE__ */ new Set(["NotFoundError", "TypeMismatchError"]);
@@ -1135,7 +1172,7 @@ var OpfsFs = class {
1135
1172
  changes = 0;
1136
1173
  replacing = Promise.resolve();
1137
1174
  replacements = 0;
1138
- snapshots = /* @__PURE__ */ new WeakMap();
1175
+ glances = /* @__PURE__ */ new WeakMap();
1139
1176
  found = /* @__PURE__ */ new WeakMap();
1140
1177
  checked = /* @__PURE__ */ new WeakMap();
1141
1178
  channel;
@@ -1154,6 +1191,7 @@ var OpfsFs = class {
1154
1191
  this.lockPrefix = `slicc-excl:${channel ?? ""}:`;
1155
1192
  this.channel = channel === void 0 ? void 0 : new BroadcastChannel(channel);
1156
1193
  this.remote = channel === void 0 ? void 0 : new RemoteWatch(`${channel}:watch`);
1194
+ if (this.remote) this.remote.quiet = () => this.quiet();
1157
1195
  if (this.channel) {
1158
1196
  const remote = this.remote;
1159
1197
  meta.listen?.(
@@ -1297,8 +1335,24 @@ var OpfsFs = class {
1297
1335
  throw fsError("EROFS", path);
1298
1336
  }
1299
1337
  }
1338
+ unlocked = 0;
1339
+ idle = [];
1340
+ async alone(op) {
1341
+ this.unlocked++;
1342
+ try {
1343
+ return await op();
1344
+ } finally {
1345
+ this.unlocked--;
1346
+ if (this.unlocked === 0) for (const done4 of this.idle.splice(0)) done4();
1347
+ }
1348
+ }
1349
+ quiet() {
1350
+ if (this.unlocked === 0) return Promise.resolve();
1351
+ return new Promise((resolve2) => this.idle.push(resolve2));
1352
+ }
1300
1353
  async locked(physicalPath, op) {
1301
1354
  if (!this.locks) return op();
1355
+ if (this.remote && !await this.remote.peered()) return this.alone(op);
1302
1356
  const key = `${this.lockPrefix}${physicalPath}`;
1303
1357
  let result;
1304
1358
  await this.locks.request(key, async () => {
@@ -1389,7 +1443,7 @@ var OpfsFs = class {
1389
1443
  if (handle.kind !== "file") return handle;
1390
1444
  if (looked) {
1391
1445
  this.found.set(handle, performance.now());
1392
- this.snapshots.delete(handle);
1446
+ this.glances.delete(handle);
1393
1447
  }
1394
1448
  this.files.delete(path);
1395
1449
  if (this.files.size >= FILE_HANDLES)
@@ -1423,14 +1477,15 @@ var OpfsFs = class {
1423
1477
  const fresh2 = cached && performance.now() - (this.found.get(cached) ?? -CHECK_MS) < CHECK_MS;
1424
1478
  if (cached && !fresh2) this.files.delete(path);
1425
1479
  if (cached && fresh2) {
1480
+ if (this.glanced(cached)) return this.remember(path, cached);
1426
1481
  try {
1427
- this.snapshots.set(cached, await cached.getFile());
1482
+ await this.glance(cached);
1428
1483
  return this.remember(path, cached);
1429
1484
  } catch (err) {
1430
1485
  if (!STALE.has(err?.name))
1431
1486
  throw translate(err, path, "read");
1432
1487
  this.files.delete(path);
1433
- this.snapshots.delete(cached);
1488
+ this.glances.delete(cached);
1434
1489
  }
1435
1490
  }
1436
1491
  const kept = this.dirs.get(path);
@@ -1623,11 +1678,25 @@ var OpfsFs = class {
1623
1678
  if (this.dirTimes.get(path) === ms) this.dirTimes.delete(path);
1624
1679
  }
1625
1680
  }
1681
+ glanced(handle) {
1682
+ const seen = this.glances.get(handle);
1683
+ const fresh2 = seen && seen.changes === this.changes && performance.now() - seen.at < GLANCE_MS;
1684
+ return fresh2 ? seen.file : void 0;
1685
+ }
1686
+ async glance(handle) {
1687
+ const at = performance.now();
1688
+ const before = this.changes;
1689
+ const file = await handle.getFile();
1690
+ if (before === this.changes) this.glances.set(handle, { at, file, changes: before });
1691
+ else this.glances.delete(handle);
1692
+ return file;
1693
+ }
1694
+ wrote(handle) {
1695
+ this.changes++;
1696
+ this.glances.delete(handle);
1697
+ }
1626
1698
  async snapshot(handle) {
1627
- const taken = this.snapshots.get(handle);
1628
- if (!taken) return handle.getFile();
1629
- this.snapshots.delete(handle);
1630
- return taken;
1699
+ return this.glanced(handle) ?? this.glance(handle);
1631
1700
  }
1632
1701
  async statOf(path, handle, meta, touched) {
1633
1702
  const directory = handle.kind === "directory";
@@ -1762,9 +1831,11 @@ var OpfsFs = class {
1762
1831
  async pwrite(path, offset, bytes) {
1763
1832
  const [handle, , target] = await this.fileFor(path);
1764
1833
  await this.counting(target);
1834
+ this.wrote(handle);
1765
1835
  try {
1766
1836
  await this.retrying(path, () => positioned(handle, (io) => io.write(offset, bytes)), true);
1767
1837
  } finally {
1838
+ this.wrote(handle);
1768
1839
  await this.recounting(handle, target);
1769
1840
  }
1770
1841
  this.written(target);
@@ -1775,9 +1846,11 @@ var OpfsFs = class {
1775
1846
  const resolve2 = this.ranged || this.remote?.watched();
1776
1847
  const target = resolve2 || this.counted.length > 0 ? (await this.locate(path, true)).path : path;
1777
1848
  await this.counting(target);
1849
+ this.wrote(handle);
1778
1850
  try {
1779
1851
  await this.retrying(path, () => positioned(handle, (io) => io.truncate(size)), true);
1780
1852
  } finally {
1853
+ this.wrote(handle);
1781
1854
  await this.recounting(handle, target);
1782
1855
  }
1783
1856
  this.written(target);
@@ -1797,9 +1870,12 @@ var OpfsFs = class {
1797
1870
  throw translate(err, path, "write", false);
1798
1871
  }),
1799
1872
  close: async () => {
1873
+ this.wrote(handle);
1800
1874
  await writable2.close().catch((err) => {
1875
+ this.wrote(handle);
1801
1876
  throw translate(err, path, "commit", false);
1802
1877
  });
1878
+ this.wrote(handle);
1803
1879
  this.committed(target);
1804
1880
  }
1805
1881
  };
@@ -1846,6 +1922,7 @@ var OpfsFs = class {
1846
1922
  if (!existed) await this.stampNew(target, owner, false);
1847
1923
  if (!existed && content.length === 0) return;
1848
1924
  let writable2;
1925
+ this.wrote(file);
1849
1926
  try {
1850
1927
  writable2 = await file.createWritable();
1851
1928
  await writable2.write(content);
@@ -1853,6 +1930,8 @@ var OpfsFs = class {
1853
1930
  } catch (err) {
1854
1931
  await writable2?.abort().catch(() => void 0);
1855
1932
  throw translate(err, path, "commit", false);
1933
+ } finally {
1934
+ this.wrote(file);
1856
1935
  }
1857
1936
  this.committed(target);
1858
1937
  }
@@ -2106,7 +2185,7 @@ var OpfsFs = class {
2106
2185
  }
2107
2186
  async modified(path) {
2108
2187
  const handle = await this.handle(path);
2109
- return handle.kind === "file" ? (await handle.getFile()).lastModified : 0;
2188
+ return handle.kind === "file" ? (await this.snapshot(handle)).lastModified : 0;
2110
2189
  }
2111
2190
  async explicitTimes(path) {
2112
2191
  const timed2 = [];
@@ -6285,7 +6364,7 @@ function satisfies(version, range2) {
6285
6364
  }
6286
6365
 
6287
6366
  // src/version.ts
6288
- var KERNEL_VERSION = true ? "1.117.2" : "0.0.0-dev";
6367
+ var KERNEL_VERSION = true ? "1.117.5" : "0.0.0-dev";
6289
6368
  var OS_RELEASE = "6.0.0-slicc";
6290
6369
  function procVersion() {
6291
6370
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -8789,6 +8868,7 @@ function withCommandDirs(fs, names, userDir = async () => void 0, owners = false
8789
8868
  readlink: (path) => fs.readlink(path),
8790
8869
  ...fs.realpath ? { realpath: fs.realpath.bind(fs) } : {},
8791
8870
  ...fs.chown ? { chown: fs.chown.bind(fs) } : {},
8871
+ ...fs.createExclusive ? { createExclusive: fs.createExclusive.bind(fs) } : {},
8792
8872
  ...fs.ancestry ? { ancestry: fs.ancestry.bind(fs) } : {},
8793
8873
  ...fs.hasOwner ? { hasOwner: fs.hasOwner.bind(fs) } : {},
8794
8874
  chmod: (path, mode) => fs.chmod(path, mode),
@@ -9088,6 +9168,7 @@ var FsWatchers = class {
9088
9168
  };
9089
9169
  const overrides = {
9090
9170
  writeFile: content(fs.writeFile.bind(fs)),
9171
+ ...fs.createExclusive ? { createExclusive: content(fs.createExclusive.bind(fs)) } : {},
9091
9172
  mkdir: async (path, options) => {
9092
9173
  const created3 = [];
9093
9174
  if (options?.recursive) {
@@ -10784,6 +10865,23 @@ var MountFs = class _MountFs {
10784
10865
  throw err;
10785
10866
  }
10786
10867
  }
10868
+ async createExclusive(path, owner, redirects = 0) {
10869
+ const exclusive = this.base.createExclusive?.bind(this.base);
10870
+ if (!this.at(path) && exclusive) {
10871
+ return this.based(
10872
+ [path],
10873
+ redirects,
10874
+ () => exclusive(path, owner),
10875
+ ([to], n) => this.createExclusive(to, owner, n)
10876
+ );
10877
+ }
10878
+ const there = await this.lstat(path).then(
10879
+ () => true,
10880
+ () => false
10881
+ );
10882
+ if (there) throw errnoError("EEXIST", path);
10883
+ await this.writeFile(path, new Uint8Array(0), owner);
10884
+ }
10787
10885
  async lstat(path, redirects = 0) {
10788
10886
  const found = this.at(path);
10789
10887
  if (!found) {
@@ -17020,7 +17118,10 @@ var VfsNodes = class {
17020
17118
  return this.onEntry(path, async () => {
17021
17119
  const node = this.byPath.get(path);
17022
17120
  const held = node ? await node.serial(async () => node.holds()) : false;
17023
- if (held || await present2(this.fs, path)) throw fsError("EEXIST", path);
17121
+ if (held) throw fsError("EEXIST", path);
17122
+ const exclusive = this.fs.createExclusive;
17123
+ if (exclusive) return exclusive.call(this.fs, path, owner);
17124
+ if (await present2(this.fs, path)) throw fsError("EEXIST", path);
17024
17125
  await this.fs.writeFile(path, new Uint8Array(0), owner);
17025
17126
  });
17026
17127
  }