@ai-ecoverse/slicc-kernel 1.167.1 → 1.167.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
@@ -858,7 +858,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
858
858
  - **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.
859
859
  - **A name another process or tab just removed** ([#950](https://github.com/ai-ecoverse/slicc-kernel/issues/950)): sqlite3 deletes its `-journal` at every commit and the next writer creates it again, so two writers (two processes in one kernel, or two tabs on one OPFS root) keep removing and re-creating one name. Two things used to turn that into `unable to open database file`. The root removes a file's sidecar record a few milliseconds after the file itself (removals are batched), and an exclusive create read that pending record as the name being taken: a plain record whose file is gone now counts as gone, and the new file gets a record of its own. A symlink, which lives only in the sidecar, still takes its name. And an Emscripten program keeps the nodes it has looked up, so after another tab removed a name it still held the old node, and `open(O_CREAT)` opened the gone file instead of creating one. Now, when the kernel answers `ENOENT` for a cached node, for `open`, `stat`, `lstat`, `readlink`, `truncate`, `unlink` or a rename's source, or the program's own lookup answers `EEXIST` to `open(O_CREAT|O_EXCL)`, the node is dropped and the call runs once more, against what is there now. A node the program has open or has unwritten changes in is kept. A rename over a name another tab removed needs nothing: the kernel's rename replaces whatever is there.
860
860
  - **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.
861
- - **Appends across tabs** ([#1520](https://github.com/ai-ecoverse/slicc-kernel/issues/1520)): while another kernel shares the OPFS root, an `O_APPEND` write lands at the file's real end under a per-file Web Lock, so two tabs' appends never overwrite each other. A tab that sees another join flushes its buffered appends and waits for the writes it has under way at that moment (later ones don't hold it) before it answers, and the joiner's appends wait for every such answer pending at the time, without a timeout, as long as the other tab lives; an append already under way when a further tab's note arrives isn't held back ([#1546](https://github.com/ai-ecoverse/slicc-kernel/issues/1546)). Alone, an append is buffered as before. **Limit:** a write at the end *without* `O_APPEND` (a seek to the end, then a write) is close-to-open across tabs, as on NFS: it can overwrite another tab's appends it hasn't seen yet ([#1546](https://github.com/ai-ecoverse/slicc-kernel/issues/1546)).
861
+ - **Appends across tabs** ([#1520](https://github.com/ai-ecoverse/slicc-kernel/issues/1520)): while another kernel shares the OPFS root, an `O_APPEND` write lands at the file's real end under a per-file Web Lock, so two tabs' appends never overwrite each other. A tab that sees another join flushes its buffered appends and waits for the writes it has under way at that moment (later ones don't hold it) before it answers, and the joiner's appends wait for every such answer pending at the time, without a timeout, as long as the other tab lives; an append already under way when a further tab's note arrives isn't held back ([#1546](https://github.com/ai-ecoverse/slicc-kernel/issues/1546)). Alone, an append is buffered as before. While another tab holds a file open, a seek to its end or an `fstat` reads the real end, and a write past that end goes through under the same lock before it returns, so a write at the end *without* `O_APPEND` (a seek to the end, then a write) doesn't overwrite another tab's appends either ([#1546](https://github.com/ai-ecoverse/slicc-kernel/issues/1546)). That holds for a WASI program's own files, for descriptors the kernel holds (a JS program's `ctx.sync.open`, a shell's `exec 3<>` once a child has it), and for a file opened through a symlink, which is looked up by its real path ([#1675](https://github.com/ai-ecoverse/slicc-kernel/issues/1675)). A file no other tab holds stays buffered: no lock and no extra call. **Limits:** a write inside the file stays buffered, so it's close-to-open across tabs, as on NFS. Reading to the end, rather than seeking there, still stops at the end this tab last knew. A WASI program that opened a file through a symlink keeps the real path it had at open, so after the target is renamed its writes stay buffered until it opens the file again. A writer that has seen the file held keeps reading its end until it finds the file no longer held, so a peer that appends and closes right away is covered; a peer whose whole open, append and close fall between two of this tab's looks is seen only through its notices.
862
862
  - **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 Emscripten program's file goes to a kernel descriptor at its first lock. From then on, its `fstat` size is that descriptor's, so on a file that isn't read and written in pages (a root outside OPFS, or `hostfs` without `ranges`), sqlite sees the pages it wrote and the kernel still holds (#1019). 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` (wasi-pnpm's store-operation lock) is a whole-file lock, per process and keyed by path, while the kernel is alone on its root. While another kernel shares the root, it becomes a non-blocking `flock` on the descriptor's open file instead, keyed by the file's identity, so it excludes across kernels and tabs (#983). It's released on `unlock`, on close, and about a second after the holder's tab closes. A tryLock checks both the local table and the flock table, so a lock taken before a peer joins, or while one was there, still excludes after the switch. 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()` reaches the kernel's `flock` table (per open file description) through homescoop's shim, a strong `flock()` that calls the import `env.__syscall_flock(fd, op)`. The kernel wraps that import by name (#947): `LOCK_SH`/`LOCK_EX`/`LOCK_UN` with `LOCK_NB` (`EWOULDBLOCK` for a conflict), 0 or -errno. A glue without the shim keeps Emscripten's stub, which answers 0. 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.
863
863
  - **Locks across tabs** ([#675](https://github.com/ai-ecoverse/slicc-kernel/issues/675)): two kernels on one OPFS root (two tabs, or a tab and a worker page) share one table of `fcntl` record locks and `flock` locks, so a sqlite3 in one tab finds the database locked while a transaction is open in another. **Known issue, data corruption: lock-protected writes across tabs** ([#949](https://github.com/ai-ecoverse/slicc-kernel/issues/949)). The locks are enforced across tabs, but what one tab writes under a lock isn't yet guaranteed to be what the other tab reads once it holds the lock, so a lock-protected read-modify-write across tabs can lose or corrupt data. Don't rely on cross-tab locking until #949 is fixed. Use within a single tab is unaffected.
864
864
  - **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.
@@ -8477,7 +8477,7 @@ function satisfies(version, range2) {
8477
8477
  }
8478
8478
 
8479
8479
  // src/version.ts
8480
- var KERNEL_VERSION = true ? "1.167.1" : "0.0.0-dev";
8480
+ var KERNEL_VERSION = true ? "1.167.2" : "0.0.0-dev";
8481
8481
  var OS_RELEASE = "6.0.0-slicc";
8482
8482
  function procVersion() {
8483
8483
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -15242,7 +15242,7 @@ async function heldOpen(entry, path, real, req, fid) {
15242
15242
  const handle = entry.hold?.(path, true, true, real, void 0, req.ino, fid);
15243
15243
  if (handle === void 0) return null;
15244
15244
  await entry.announceHold?.(real ?? path, fid, ((req.mode ?? 0) & HOLD_CREATED) !== 0);
15245
- return handle;
15245
+ return real !== void 0 && real !== path ? { handle, real } : handle;
15246
15246
  }
15247
15247
  async function idOf(entry, path) {
15248
15248
  return await entry.fileId?.(path)?.catch(() => void 0) ?? void 0;
@@ -16203,6 +16203,10 @@ var AsyncRangedFile = class {
16203
16203
  truncate(size) {
16204
16204
  this.pages.truncate(size);
16205
16205
  }
16206
+ wrote(at, length2) {
16207
+ this.pages.landed(at, length2);
16208
+ this.version = void 0;
16209
+ }
16206
16210
  async flush() {
16207
16211
  if (this.pinned || !this.pages.changed) return;
16208
16212
  const plan = this.pages.plan();
@@ -24488,6 +24492,7 @@ var VfsNode = class {
24488
24492
  ino;
24489
24493
  onDirty;
24490
24494
  onDirtyState;
24495
+ crowded;
24491
24496
  counted = false;
24492
24497
  recount() {
24493
24498
  if (!this.onDirtyState) return;
@@ -24605,9 +24610,48 @@ var VfsNode = class {
24605
24610
  if (!this.dirty) this.data = void 0;
24606
24611
  }
24607
24612
  async size() {
24613
+ if (this.peerHeld()) {
24614
+ const known = await this.exact();
24615
+ if (known !== void 0) return known;
24616
+ }
24608
24617
  const ranged2 = await this.prepare();
24609
24618
  return ranged2 ? ranged2.size() : this.length;
24610
24619
  }
24620
+ peered = false;
24621
+ peerHeld() {
24622
+ if (this.orphaned || this.revoked || this.pin) return false;
24623
+ if (this.crowded?.(this.path) === true) this.peered = true;
24624
+ return this.peered;
24625
+ }
24626
+ async exact() {
24627
+ await this.flush();
24628
+ const { stat } = this.fs;
24629
+ const io = rangedOps(this.fs);
24630
+ if (!stat || !io) return void 0;
24631
+ const st = await this.followed(() => stat.call(this.fs, this.path));
24632
+ this.peered = this.crowded?.(this.path) === true;
24633
+ const size = st.size ?? 0;
24634
+ if (this.ranged) {
24635
+ if (size !== this.ranged.size()) {
24636
+ this.ranged = new AsyncRangedFile(io, this.path, size, st.version);
24637
+ }
24638
+ return void 0;
24639
+ }
24640
+ if (this.data && size !== this.length) this.data = void 0;
24641
+ return this.data ? void 0 : size;
24642
+ }
24643
+ landed(at, bytes) {
24644
+ if (this.ranged) {
24645
+ this.ranged.wrote(at, bytes.length);
24646
+ return;
24647
+ }
24648
+ if (!this.data) return;
24649
+ const end = at + bytes.length;
24650
+ const buf = this.ensure(end);
24651
+ if (at > this.length) buf.fill(0, this.length, at);
24652
+ buf.set(bytes, at);
24653
+ this.length = Math.max(this.length, end);
24654
+ }
24611
24655
  async pread(max, at) {
24612
24656
  const ranged2 = await this.prepare();
24613
24657
  if (ranged2) return this.followed(() => ranged2.read(at, max));
@@ -24679,6 +24723,13 @@ var VfsNode = class {
24679
24723
  if (bytes.length === 0) return 0;
24680
24724
  await this.settled();
24681
24725
  await this.fits(at + bytes.length);
24726
+ const io = this.peerHeld() ? rangedOps(this.fs) : void 0;
24727
+ if (io && at + bytes.length > await this.size()) {
24728
+ const copy = bytes.slice();
24729
+ await this.followed(() => io.pwrite(this.path, at, copy, true));
24730
+ this.landed(at, bytes);
24731
+ return bytes.length;
24732
+ }
24682
24733
  const ranged2 = await this.prepare();
24683
24734
  if (ranged2) {
24684
24735
  await this.followed(() => ranged2.write(at, bytes));
@@ -24775,6 +24826,7 @@ var VfsNodes = class {
24775
24826
  movedAway;
24776
24827
  opened;
24777
24828
  dirtied;
24829
+ crowded;
24778
24830
  fs;
24779
24831
  entries;
24780
24832
  onClosed;
@@ -24920,6 +24972,7 @@ var VfsNodes = class {
24920
24972
  this.reshaped();
24921
24973
  node.onDirty = () => this.changed?.(node.path);
24922
24974
  node.onDirtyState = (on) => this.dirtied?.(on ? 1 : -1);
24975
+ node.crowded = (at) => this.crowded?.(at) === true;
24923
24976
  node.recount();
24924
24977
  this.byPath.set(path, node);
24925
24978
  if (id2 !== void 0) this.know(node, id2);
@@ -31488,6 +31541,7 @@ var Launcher = class {
31488
31541
  hostname;
31489
31542
  watchers = new FsWatchers();
31490
31543
  changes = new FileChanges();
31544
+ peerHeldPaths = /* @__PURE__ */ new Set();
31491
31545
  renameLog = new RenameLog(this.changes);
31492
31546
  cwds = new CwdTable(async (path) => {
31493
31547
  const { dev, ino } = await this.base.stat(path);
@@ -33760,6 +33814,7 @@ var Launcher = class {
33760
33814
  openNodes(fs, entries) {
33761
33815
  const nodes = new VfsNodes(fs, (path) => this.released(path), entries);
33762
33816
  nodes.opened = (node) => this.nodeOpened(node);
33817
+ nodes.crowded = (path) => this.peerHeldPaths.has(path);
33763
33818
  return nodes;
33764
33819
  }
33765
33820
  nodeOpened(node) {
@@ -33781,7 +33836,10 @@ var Launcher = class {
33781
33836
  return this.mounts.locate(path) === void 0 ? this.rootFs.bindId?.(path) : void 0;
33782
33837
  }
33783
33838
  flushingPeers(fs) {
33784
- fs.peerHolds?.((paths) => this.changes.peerHeld(paths));
33839
+ fs.peerHolds?.((paths) => {
33840
+ this.peerHeldPaths = new Set(paths);
33841
+ this.changes.peerHeld(paths);
33842
+ });
33785
33843
  const peers = fs.peers?.();
33786
33844
  if (!peers) return void 0;
33787
33845
  peers.watch((crowded) => {