@ai-ecoverse/slicc-kernel 1.97.0 → 1.97.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -276,7 +276,7 @@ On top of preview1 a WASI program gets:
276
276
  - **Not supported:** `EPOLLPRI`, `EPOLLEXCLUSIVE` and `EPOLLWAKEUP` are never reported.
277
277
  - **UDP:** datagram sockets come with UDP (#476).
278
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.
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. Fixing the sysroot is [homescoop#443](https://github.com/ai-ecoverse/homescoop/issues/443); dropping the dual numbering once the fixed sysroot ships is [#684](https://github.com/ai-ecoverse/slicc-kernel/issues/684).
280
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.
281
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.
282
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).
@@ -514,6 +514,8 @@ The proxy answers `200` with `Content-Type: application/vnd.slicc.raw-fetch`: a
514
514
 
515
515
  `/` is the OPFS root. Each process mounts the top-level directories that exist when it starts (plus `/usr` and `/bin`), so files in them are shared by all processes and visible through the OPFS API as soon as the process that wrote them has closed them; `run` resolves only after that. `createKernel` creates `/tmp` and `/home` in OPFS, so they are shared too. Files directly in `/`, and directories created there after a process started, are OPFS as well, the same for Emscripten and WASI programs; only `/dev` and `/proc` live in each process's memory. A program's `mkdir` (WASI `path_create_directory`, Emscripten's `mkdir`) is exclusive, as on Linux: it fails with `EEXIST` when the name exists, and of several processes or threads making one name at once exactly one succeeds, so a directory works as a lock. Under a missing directory it fails with `ENOENT`, as on Linux (`mkdir -p` and `os.makedirs` make the parents in the program); a JS program's `ctx.fs.mkdir` makes parents itself. The kernel makes the system `PNPM_HOME` (`/usr/local/share/pnpm`, or the embedder's) when it starts, since pnpm makes only its `bin` directory there. A full origin storage (`QuotaExceededError`, which also comes early in a browser's private or off-the-record contexts, whatever `navigator.storage.estimate()` says) is `ENOSPC`, never retried. An OPFS or IndexedDB failure that has no POSIX counterpart is `EIO`; its message names the DOM error and the operation (`lookup`, `read`, `write`, `commit` or `sidecar`), and the kernel worker logs the first of each kind to its console. File contents are buffered per open file and written back on close, `fsync` and exit, so a refused write (`ENOSPC`, `EIO`) surfaces at that close or `fsync`, and a program that ignores close errors doesn't see it (`echo x > f` can exit 0 with `f` empty, as with any buffered write-back). A write-back error a close has already reported to a WASI program isn't raised again when it exits, so the program keeps its own exit status. Metadata operations (`mkdir`, `rename`, `rm`, …) go straight to OPFS. The kernel worker is the only writer.
516
516
 
517
+ A lookup follows at most 40 symlinks, as Linux does: the 41st is `ELOOP`, for WASI programs, JS programs and the kernel API alike, with or without `..` in a link's target. Emscripten programs resolve paths in their glue, which stops one link earlier, at 40.
518
+
517
519
  Directories are renamed with `FileSystemHandle.move()` where available, else by copy and delete.
518
520
 
519
521
  `mmap` of a regular file works on every file system, for Emscripten programs built with `mmap` (whose glue exports `emscripten_builtin_memalign`) as well as for WASIX ones: the kernel reads the mapped range into fresh memory of the process, as Emscripten's MEMFS does. A `MAP_PRIVATE` mapping may be written, and its changes stay in the process. A writable `MAP_SHARED` mapping of such a file is `ENODEV`; a file in the process's own in-memory file system (MEMFS, as under `/dev`) keeps Emscripten's own mapping, which writes back. A read-only `MAP_SHARED` mapping is a copy taken at `mmap`, so later changes to the file do not show in it.
@@ -533,7 +535,8 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
533
535
  - **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.
534
536
  - **Caching:** the kernel caches attributes and listings for those TTLs, and a driver can push `invalidate` for changes made outside.
535
537
  - **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.
536
- - **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. Limits for now: the same file under two names (a symlink in the path, a hard link, an alias on a mount without `realpath`) isn't kept coherent yet, so processes sharing a file should name it by the same path ([#669](https://github.com/ai-ecoverse/slicc-kernel/issues/669)); another tab's writes reach this tab's processes only as far as the root's change notifications (OPFS's `BroadcastChannel` watch) carry them; and byte-range locks (`fcntl` `F_SETLK`) are not enforced between processes yet (they come with the next change).
538
+ - **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. Limits for now: the same file under two names (a symlink in the path, a hard link, an alias on a mount without `realpath`) isn't kept coherent yet, so processes sharing a file should name it by the same path ([#669](https://github.com/ai-ecoverse/slicc-kernel/issues/669)); another tab's writes reach this tab's processes only as far as the root's change notifications (OPFS's `BroadcastChannel` watch) carry them.
539
+ - **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, 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's `fcntl` locks (python's `sqlite3`) are no-ops in its libc that never reach the kernel, until wasix-libc gets a kernel import for them ([homescoop#407](https://github.com/ai-ecoverse/homescoop/issues/407)), so python's sqlite doesn't exclude other processes yet.
537
540
  - **Rules on a mount:**
538
541
  - a rename across mounts is `EXDEV`, so `mv` copies;
539
542
  - a symlink on the root file system that leads onto a mount, such as `~/proj` → `/mnt/drive/proj`, or a chain from a mount to the root and back, is followed onto the mount by every operation, in every runtime: reads, writes, `stat`, listings, `mkdir`, removals, renames, `chmod`, `chown` and times. Removing the link itself removes the link. Before [#547](https://github.com/ai-ecoverse/slicc-kernel/issues/547) was fixed, the kernel resolved such a link against the directory under the mount point for JS and WASI programs, the embedder's `fs` and attached clients (Emscripten programs weren't affected): reads failed with `ENOENT`, and writes went into that hidden directory, which shows again once the mount is gone;
@@ -1409,7 +1409,7 @@ var OpfsFs = class {
1409
1409
  if (target.split("/").includes("..")) {
1410
1410
  const resolved2 = await this.physical(parentOf3(link2.path), target, hops + 1, path);
1411
1411
  current = resolved2.path;
1412
- hops = resolved2.hops;
1412
+ hops = resolved2.hops - 1;
1413
1413
  } else {
1414
1414
  current = resolvePath(parentOf3(link2.path), target);
1415
1415
  }
@@ -7695,10 +7695,10 @@ function envOf(pkg, raw) {
7695
7695
  }
7696
7696
  return env;
7697
7697
  }
7698
- function envFields(merged) {
7698
+ function envFields(merged2) {
7699
7699
  const env = {};
7700
7700
  const unset = [];
7701
- for (const [key, value] of Object.entries(merged)) {
7701
+ for (const [key, value] of Object.entries(merged2)) {
7702
7702
  if (value === null) unset.push(key);
7703
7703
  else env[key] = value;
7704
7704
  }
@@ -12054,7 +12054,7 @@ function mountTable(mounts = []) {
12054
12054
  }
12055
12055
 
12056
12056
  // src/version.ts
12057
- var KERNEL_VERSION = true ? "1.97.0" : "0.0.0-dev";
12057
+ var KERNEL_VERSION = true ? "1.97.2" : "0.0.0-dev";
12058
12058
  var OS_RELEASE = "6.0.0-slicc";
12059
12059
  function procVersion() {
12060
12060
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -12731,23 +12731,115 @@ async function epollSyscall(req, proc) {
12731
12731
  }
12732
12732
  }
12733
12733
 
12734
- // src/kernel/host-ops.ts
12735
- var HOST_OPS = [
12736
- "lock",
12737
- "unlock",
12738
- "async-submit",
12739
- "async-wait",
12740
- "async-take",
12741
- "async-resolve",
12742
- "async-hold",
12743
- "async-cancel",
12744
- "async-settle",
12745
- "async-close"
12746
- ];
12747
- var json3 = (value) => ({ ok: true, kind: "json", json: value });
12748
- var done2 = { ok: true, kind: "void" };
12734
+ // src/kernel/locks.ts
12735
+ var overlaps = (a, b) => a.start < b.end && b.start < a.end;
12736
+ var clashes = (lock, owner, want) => lock.owner !== owner && overlaps(lock, want) && (lock.kind === "write" || want.kind === "write");
12737
+ function lockRange(start2, len) {
12738
+ if (!Number.isSafeInteger(start2) || !Number.isSafeInteger(len)) throw new KernelError("EINVAL");
12739
+ const from = len < 0 ? start2 + len : start2;
12740
+ const end = len === 0 ? Number.POSITIVE_INFINITY : len < 0 ? start2 : start2 + len;
12741
+ if (from < 0) throw new KernelError("EINVAL");
12742
+ return { start: from, end };
12743
+ }
12749
12744
  var LockTable = class {
12745
+ records = /* @__PURE__ */ new Map();
12746
+ blocked = /* @__PURE__ */ new Map();
12747
+ wakers = /* @__PURE__ */ new Map();
12750
12748
  held = /* @__PURE__ */ new Map();
12749
+ holder(key, owner, want) {
12750
+ return this.records.get(key)?.find((lock) => clashes(lock, owner, want));
12751
+ }
12752
+ set(key, owner, kind, start2, end) {
12753
+ if (kind !== "unlock" && this.holder(key, owner, { key, kind, start: start2, end })) return false;
12754
+ const range = { start: start2, end };
12755
+ const kept = [];
12756
+ for (const lock of this.records.get(key) ?? []) {
12757
+ if (lock.owner !== owner || !overlaps(lock, range)) kept.push(lock);
12758
+ else {
12759
+ if (lock.start < start2) kept.push({ ...lock, end: start2 });
12760
+ if (lock.end > end) kept.push({ ...lock, start: end });
12761
+ }
12762
+ }
12763
+ if (kind !== "unlock") kept.push({ owner, kind, start: start2, end });
12764
+ this.store(key, merged(kept));
12765
+ this.wake(key);
12766
+ return true;
12767
+ }
12768
+ async wait(key, owner, kind, range, blocking) {
12769
+ const want = { key, kind, ...range };
12770
+ let signal;
12771
+ for (; ; ) {
12772
+ const holder = this.holder(key, owner, want);
12773
+ if (!holder) {
12774
+ this.set(key, owner, kind, range.start, range.end);
12775
+ return;
12776
+ }
12777
+ if (this.waitsOn(holder.owner, owner)) throw new KernelError("EDEADLK");
12778
+ signal ??= blocking();
12779
+ if (signal.aborted) throw new KernelError("EINTR");
12780
+ this.blocked.set(owner, want);
12781
+ try {
12782
+ await this.changed(key, signal);
12783
+ } finally {
12784
+ this.blocked.delete(owner);
12785
+ }
12786
+ }
12787
+ }
12788
+ waitsOn(from, target) {
12789
+ const seen = /* @__PURE__ */ new Set();
12790
+ for (let at = from; at !== void 0 && !seen.has(at); ) {
12791
+ seen.add(at);
12792
+ const want = this.blocked.get(at);
12793
+ if (!want) return false;
12794
+ const holder = this.holder(want.key, at, want);
12795
+ if (!holder) return false;
12796
+ if (holder.owner === target) return true;
12797
+ at = holder.owner;
12798
+ }
12799
+ return false;
12800
+ }
12801
+ changed(key, signal) {
12802
+ return new Promise((resolve2, reject) => {
12803
+ const set2 = this.wakers.get(key) ?? /* @__PURE__ */ new Set();
12804
+ this.wakers.set(key, set2);
12805
+ const done4 = () => {
12806
+ set2.delete(wake);
12807
+ if (set2.size === 0 && this.wakers.get(key) === set2) this.wakers.delete(key);
12808
+ signal.removeEventListener("abort", abort);
12809
+ };
12810
+ const wake = () => {
12811
+ done4();
12812
+ resolve2();
12813
+ };
12814
+ const abort = () => {
12815
+ done4();
12816
+ reject(new KernelError("EINTR"));
12817
+ };
12818
+ set2.add(wake);
12819
+ signal.addEventListener("abort", abort, { once: true });
12820
+ });
12821
+ }
12822
+ wake(key) {
12823
+ for (const wake of [...this.wakers.get(key) ?? []]) wake();
12824
+ }
12825
+ store(key, locks) {
12826
+ if (locks.length === 0) this.records.delete(key);
12827
+ else this.records.set(key, locks);
12828
+ }
12829
+ holds(owner) {
12830
+ for (const locks of this.records.values())
12831
+ if (locks.some((l) => l.owner === owner)) return true;
12832
+ return false;
12833
+ }
12834
+ dropFile(key, owner) {
12835
+ const locks = this.records.get(key);
12836
+ if (!locks?.some((l) => l.owner === owner)) return;
12837
+ this.store(
12838
+ key,
12839
+ locks.filter((l) => l.owner !== owner)
12840
+ );
12841
+ this.wake(key);
12842
+ }
12751
12843
  lock(pid, path, exclusive, fd) {
12752
12844
  const holders = this.held.get(path) ?? /* @__PURE__ */ new Map();
12753
12845
  for (const [other, h] of holders) if (other !== pid && (exclusive || h.exclusive)) return false;
@@ -12771,8 +12863,39 @@ var LockTable = class {
12771
12863
  }
12772
12864
  release(pid) {
12773
12865
  for (const path of [...this.held.keys()]) this.unlock(pid, path);
12866
+ for (const key of [...this.records.keys()]) this.dropFile(key, pid);
12774
12867
  }
12775
12868
  };
12869
+ function merged(locks) {
12870
+ const sorted = [...locks].sort((a, b) => a.owner - b.owner || a.start - b.start);
12871
+ const out = [];
12872
+ for (const lock of sorted) {
12873
+ const last = out[out.length - 1];
12874
+ if (last && last.owner === lock.owner && last.kind === lock.kind && last.end >= lock.start) {
12875
+ last.end = Math.max(last.end, lock.end);
12876
+ } else out.push({ ...lock });
12877
+ }
12878
+ return out;
12879
+ }
12880
+
12881
+ // src/kernel/host-ops.ts
12882
+ var HOST_OPS = [
12883
+ "lock",
12884
+ "unlock",
12885
+ "lk-set",
12886
+ "lk-get",
12887
+ "lk-close",
12888
+ "async-submit",
12889
+ "async-wait",
12890
+ "async-take",
12891
+ "async-resolve",
12892
+ "async-hold",
12893
+ "async-cancel",
12894
+ "async-settle",
12895
+ "async-close"
12896
+ ];
12897
+ var json3 = (value) => ({ ok: true, kind: "json", json: value });
12898
+ var done2 = { ok: true, kind: "void" };
12776
12899
  var KEPT_OPS = /* @__PURE__ */ new Set(["fd-write"]);
12777
12900
  var unknownSettled = false;
12778
12901
  var AsyncOps = class {
@@ -12909,6 +13032,10 @@ async function hostSyscall(req, ctx) {
12909
13032
  case "unlock":
12910
13033
  ctx.locks?.unlock(ctx.pid, req.path);
12911
13034
  return done2;
13035
+ case "lk-set":
13036
+ case "lk-get":
13037
+ case "lk-close":
13038
+ return recordLock2(req, ctx);
12912
13039
  case "async-submit":
12913
13040
  if (!ctx.valid(req.req)) throw new KernelError("EINVAL");
12914
13041
  if (req.ack) ctx.ops.ack(req.ack);
@@ -12931,6 +13058,33 @@ async function hostSyscall(req, ctx) {
12931
13058
  return done2;
12932
13059
  }
12933
13060
  }
13061
+ async function recordLock2(req, ctx) {
13062
+ const { locks, fileKey } = ctx;
13063
+ if (!locks || !fileKey) throw new KernelError("ENOLCK");
13064
+ const owner = ctx.owner?.() ?? ctx.pid;
13065
+ const target = {
13066
+ ...req.path !== void 0 ? { path: req.path } : {},
13067
+ ...req.fd !== void 0 ? { fd: req.fd } : {}
13068
+ };
13069
+ if (req.op === "lk-close") {
13070
+ locks.dropFile(await fileKey(target, "close"), owner);
13071
+ return done2;
13072
+ }
13073
+ const range = lockRange(req.start, req.len);
13074
+ const key = await fileKey(target, req.kind);
13075
+ if (req.op === "lk-get") {
13076
+ const held = locks.holder(key, owner, { key, kind: req.kind, ...range });
13077
+ if (!held) return json3(null);
13078
+ const len = held.end === Number.POSITIVE_INFINITY ? 0 : held.end - held.start;
13079
+ return json3({ kind: held.kind, pid: held.owner, start: held.start, len });
13080
+ }
13081
+ if (req.kind !== "unlock" && req.wait) {
13082
+ await locks.wait(key, owner, req.kind, range, ctx.blocking);
13083
+ return done2;
13084
+ }
13085
+ if (!locks.set(key, owner, req.kind, range.start, range.end)) throw new KernelError("EAGAIN");
13086
+ return done2;
13087
+ }
12934
13088
 
12935
13089
  // src/kernel/net/loopback-transport.ts
12936
13090
  var LOOPBACK_TRAITS = {
@@ -15461,6 +15615,8 @@ var WasmProcess = class {
15461
15615
  if (isHostSyscall(req)) {
15462
15616
  return await hostSyscall(req, {
15463
15617
  pid: this.pid,
15618
+ owner: () => this.lockOwner(),
15619
+ fileKey: (target, kind) => this.lockKey(target, kind),
15464
15620
  locks: this.options.locks,
15465
15621
  ops: this.asyncOps,
15466
15622
  blocking: () => this.blockingSignal(cancelled),
@@ -15487,6 +15643,44 @@ var WasmProcess = class {
15487
15643
  throw e;
15488
15644
  }
15489
15645
  }
15646
+ lockOwners = /* @__PURE__ */ new Set();
15647
+ lockChecked = /* @__PURE__ */ new Set();
15648
+ lockOwner() {
15649
+ const owner = this.options.jobs?.shown(this.pid) ?? this.pid;
15650
+ this.lockOwners.add(owner);
15651
+ return owner;
15652
+ }
15653
+ async lockPath(target, want) {
15654
+ if (target.fd !== void 0) {
15655
+ const st = await this.fds.get(target.fd).file.stat?.();
15656
+ if (!st || st.orphan) throw new KernelError("EBADF");
15657
+ return st.path;
15658
+ }
15659
+ if (target.path === void 0) throw new KernelError("EINVAL");
15660
+ if (!target.path.startsWith("/")) throw new KernelError("EINVAL");
15661
+ const path = target.path;
15662
+ const g = this.guarded();
15663
+ const checked = `${want}:${path}`;
15664
+ if (g && want !== 0 && !this.lockChecked.has(checked)) {
15665
+ await openable(g, "lock", path, want, false);
15666
+ this.lockChecked.add(checked);
15667
+ }
15668
+ return path;
15669
+ }
15670
+ async lockKey(target, kind) {
15671
+ const want = kind === "read" ? R_OK : kind === "write" ? W_OK : 0;
15672
+ const path = await this.lockPath(target, want);
15673
+ const st = await this.options.fs.stat?.(path);
15674
+ if (!st) throw new KernelError("ENOENT");
15675
+ return st.ino === void 0 ? path : `${st.dev ?? 0}:${st.ino}`;
15676
+ }
15677
+ async dropRecordLocks(fd) {
15678
+ const { locks } = this.options;
15679
+ const owner = this.options.jobs?.shown(this.pid) ?? this.pid;
15680
+ if (!locks?.holds(owner) || !this.fds.has(fd)) return;
15681
+ const key = await this.lockKey({ fd }, "close").catch(() => void 0);
15682
+ if (key !== void 0) locks.dropFile(key, owner);
15683
+ }
15490
15684
  refuseIfPending(restart) {
15491
15685
  if (restart && this.options.hasPending?.()) throw new KernelError("EINTR");
15492
15686
  }
@@ -15798,6 +15992,7 @@ var WasmProcess = class {
15798
15992
  };
15799
15993
  case "fd-close": {
15800
15994
  this.options.locks?.closed(this.pid, req.fd);
15995
+ await this.dropRecordLocks(req.fd);
15801
15996
  const channel = this.fds.get(req.fd).file.clipboard;
15802
15997
  if (channel) channel.stamp = await this.clipboardStamp();
15803
15998
  await Promise.resolve(this.fds.close(req.fd)).catch(async (err) => {
@@ -16390,6 +16585,7 @@ var WasmProcess = class {
16390
16585
  this.clearAlarm();
16391
16586
  this.interrupt.abort();
16392
16587
  this.options.locks?.release(this.pid);
16588
+ for (const owner of this.lockOwners) this.options.locks?.release(owner);
16393
16589
  this.asyncOps.close();
16394
16590
  for (const [target, sig] of this.deathSignals) this.sendDeath(target, sig);
16395
16591
  this.deathSignals.clear();