@ai-ecoverse/slicc-kernel 1.93.0 → 1.93.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
@@ -161,6 +161,8 @@ Page-only: neither is on an attached client (`attachKernel`, `connect()`), so no
161
161
  - A change is a write, a truncate, a rename, a removal, or a change of mode, owner or times of the file or of any directory above it. A change to a parent directory's own metadata counts too, and creating or removing a sibling changes it when the change comes from another kernel.
162
162
  - It sees changes made through any slicc kernel on the origin, in this tab or another one: the kernels tell each other which files are watched and report writes to those. `watch` resolves once every other live kernel on the origin has the watch (a kernel that doesn't answer within 500 ms is not waited for), so a write made after it resolves is reported. A kernel that ends with watches still on is forgotten (by its Web Lock) at the next reported write or unanswered announcement. This needs the shared metadata (the default): with `metadata: false` a kernel sees only its own changes. It doesn't see writes made with the OPFS API outside a kernel, such as a page script or an installer writing the root directly. After such a write the page should attest again itself (and may also attest on tab focus, as a safety net).
163
163
  - The cost when no kernel on the origin watches anything is one empty-set check per write.
164
+ - **A kernel that starts late:** a kernel learns the other kernels' watched files only when they answer its greeting, one message round trip (usually milliseconds) after it starts. A write it makes before then isn't reported. A consumer that must not miss a change checks again (attests) after its first report.
165
+ - **Trust:** the kernels coordinate over a `BroadcastChannel`, which every same-origin context can post to. A script on the origin can fake an acknowledgement, so `watch` resolves before another kernel really has it, clear a kernel's watched files, or report changes that didn't happen. That is the same boundary as a direct OPFS write, which `watch` doesn't see either. Treat `watch` as an invalidation signal only, never as proof: attest again before each use that matters, as the vault and bios do.
164
166
 
165
167
  ### `attachKernel(port, options?) → Promise<KernelClient>`
166
168
 
@@ -259,7 +261,7 @@ An empty path is `ENOENT` in every path call (`path_open`, `path_filestat_get`,
259
261
  On top of preview1 a WASI program gets:
260
262
 
261
263
  - **Threads**: `wasi.thread-spawn` (wasm32-wasip1-threads) and WASIX `thread_spawn_v2` start a worker per thread on the process's shared memory, sized from the module's own import (a 2 GiB maximum when the engine cannot reserve more). The threads share one descriptor table, and a file written, created, renamed or removed in one thread shows its new size and metadata to `stat` in every other thread at once, under any of its names (a symlink, a hard link, a new name after a rename in the same process), also when another thread renames a file over that name meanwhile, since the kernel reports which file an open or a change reached; this holds among the kernel's own processes, not against other tabs, `hostfs` writers or programs that aren't WASI; another process's changes show at close and after a `spawn` or `wait`, as with close-to-open caching, so a file another process renames while this one holds it open may show its old size under the new name until then; `exit` or a trap in any thread ends the process. At most 64 threads per process; `SLICC_WASM_THREADS` sets another cap, up to 256.
262
- - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid` (with `WNOHANG`, and `WUNTRACED` for a child that stops; WASIX has no `WCONTINUED`), pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. As in glibc, one `dlopen` at a time runs in a process, constructors included: a `dlopen` from a library's constructor goes ahead in its own thread, and another thread's `dlopen` waits, then finds the library loaded and gets the same handle. A thread waiting there still ends or stops with its process, but a signal handler it has runs once that `dlopen` returns. The lock is let go when a `dlopen` fails (an exception, a trap or `pthread_exit` out of a constructor included) and when its thread ends; a forked child starts with it free. In such a program, `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); a program that isn't position-independent gets an error from `dlopen(NULL)`, as from any `dlopen`; `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`.
264
+ - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid` (with `WNOHANG`, and `WUNTRACED` for a child that stops; WASIX has no `WCONTINUED`), pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. As in glibc, one `dlopen` at a time runs in a process, constructors included: a `dlopen` from a library's constructor goes ahead in its own thread, and another thread's `dlopen` waits, then finds the library loaded and gets the same handle. A thread waiting there still ends or stops with its process, but a signal handler it has runs once that `dlopen` returns. The lock is let go when a `dlopen` fails (an exception, a trap or `pthread_exit` out of a constructor included) and when its thread ends; a forked child starts with it free. A forked child also gets the side modules its parent had loaded, at the same addresses and table slots, with the slot and closure counters and the parent's thread-local blocks: they are linked again in the child before it resumes, without running their constructors again, since their data is already in the copied memory. A side-module function on the stack when `fork` is called (a callback into the main module that forks) is not instrumented by Asyncify, so it runs again when the stack is rewound, in the parent and in the child, and libffi frames there can trap; side modules would need building with Asyncify for that, which the toolchain does not do yet. In such a program, `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); a program that isn't position-independent gets an error from `dlopen(NULL)`, as from any `dlopen`; `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`.
263
265
  - **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.
264
266
  - **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).
265
267
  - **Blocked signals** (`sigprocmask`, `pthread_sigmask`, `sigpending`): a program whose libc imports `slicc.sigmask_set(how, set_lo, set_hi, old_ptr)` and `slicc.sigpending_get(ptr)` (wasix-sysroot 2025.9.30-28 and later) has a blocked mask per thread. `how` is musl's `SIG_BLOCK` 0, `SIG_UNBLOCK` 1 or `SIG_SETMASK` 2, the set is the 64-bit `sigset_t` (bit `sig - 1`), and the old mask is written as a u64 at `old_ptr`; any other `how` is `EINVAL`. `SIGKILL` and `SIGSTOP` cannot be blocked. A blocked signal is not delivered, does not interrupt a sleep or a blocked call, and its default action (terminate or stop) waits: it stays pending, `sigpending` shows it (the thread's pending signals and the process's), and it is delivered when it is unblocked, before `sigmask_set` returns. A signal ignored with `SIG_IGN` is discarded even while blocked. A forked child (with the mask of the thread that forked), the program an `exec` starts and a spawned child keep the mask, all 64 bits of it, and so does a new thread; `/proc/<pid>/status` shows the main thread's as `SigBlk`. The mask is kept in a word of the worker's shared header, so changing it costs no kernel call. A program without the import (an older libc) has no mask and gets every signal at once, as before, and one it starts by `exec` with a mask set starts with none. A signal for the whole process goes to the main thread, even if the main thread blocks it and another thread does not. A signal sent to one thread (`pthread_kill`) that it blocks waits for that thread, and if it has no handler, the thread's unblock ends the whole process by the signal, whatever the other threads block. `slicc.sigmask_poll(set_lo, set_hi, in_ptr, out_ptr, nsubscriptions, nevents_ptr)` is `pselect`, `ppoll` and `sigsuspend`: it swaps the thread's mask to the given set for the call and puts the old one back before it returns. It delivers a pending signal the new mask unblocks, without polling, or polls as `poll_oneoff` does, under the new mask, so a signal it unblocks interrupts the wait with no window between the swap and the wait; with no subscriptions it waits until a signal is delivered. An interrupted call returns `EINTR` with no events, after the handler has run.
@@ -618,7 +620,7 @@ Lowering a soft resource limit (`ulimit -S -n 64`) still fails with `EPERM` in E
618
620
  Every process has credentials in the kernel, as on Linux: real, effective and saved user and group ids, and supplementary groups. A process gets them from the user it was started as (`run`, `openTerminal`, a client's `spawn`, or the client's own user; root by default) and keeps its parent's across fork, spawn and exec. Setuid and setgid bits are never honoured on exec. Files may have owners (see [File owners](#file-owners)), checked against the credentials with `users: 'enforce'` (see [Permissions](#permissions)); the credentials also decide who may signal whom, and what `/proc` and `ps` show.
619
621
 
620
622
  - **The database:** `/etc/passwd` and `/etc/group` are real files. On first start the kernel writes them when they're missing, with `root` (uid 0, home `/root`, created `0700`) and `nobody` (65534), and the groups `root`, `sudo` (27), `users` (100) and `nogroup` (65534); files already there are kept as they are. The kernel reads them for every lookup, so an edit counts at once. [`kernel.users`](#kernelusers) adds and removes users.
621
- - **Environment:** a process started as a user gets `HOME`, `USER`, `LOGNAME`, `PNPM_HOME`, `NPM_CONFIG_PREFIX` and `PATH`. A user's `PNPM_HOME` is `~/.local/share/pnpm` and its npm prefix `~/.local`; root's are `/usr/local/share/pnpm` (or the embedder's `env.PNPM_HOME`) and `/usr/local`. `PATH` is `$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin`.
623
+ - **Environment:** a process started as a user gets `HOME`, `USER`, `LOGNAME`, `PNPM_HOME`, `NPM_CONFIG_PREFIX` and `PATH`. A user's `PNPM_HOME` is `~/.local/share/pnpm` and its npm prefix `~/.local`; root's are `/usr/local/share/pnpm` (or the embedder's `env.PNPM_HOME`) and `/usr/local`. `PATH` is `$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin`. pnpm makes only `$PNPM_HOME/bin`, so the kernel makes root's `PNPM_HOME` when it is missing, just before a process starts: once at boot, and again after any mount or unmount, a removable medium inserted, a `hostfs` folder coming back, or a change at that directory or above it (removing it, say), and after a failed try (no medium yet). With owners on, the directories it makes there get root's owner and `0755`, as the boot layout does. A process already running when a medium arrives gets it at its next child launch. Writes inside it cost nothing extra.
622
624
  - **Commands per user:** root's global packages (`pnpm add -g` as root) are commands every user finds in `/usr/bin`. A user's own global packages are commands for that user only: they show up in the user's `~/.local/share/pnpm/bin`, first on its `PATH`, and a program the user starts finds them by name before the system's; another user, root included, doesn't. A user's packages can map file suffixes (`binfmt`) for that user too. The kernel picks up an install, a removal and a new user as they happen.
623
625
  - **Changing credentials:** POSIX `setresuid`/`setresgid` rules. Root (effective uid 0) may set any id and the group list; anyone else may only swap among their own real, effective and saved ids, and gets `EPERM` otherwise.
624
626
  - **Signals:** a process may signal another if its effective uid is 0, or if its real or effective uid is the target's real or saved uid; otherwise `kill` fails with `EPERM`. A group kill signals the members it may, and fails with `EPERM` only when it may signal none.
@@ -805,6 +807,8 @@ Unit tests live in `test/unit/`, which stays out of git. They run real wasm bash
805
807
 
806
808
  The Biome, TypeScript, lefthook, Renovate and CI configuration comes from [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web), which also provides the `slicc-lint-comments` (no comments anywhere), `slicc-no-unit-tests` (no unit tests in git) and `slicc-diff-cover` (100% coverage of changed lines) commands that `npm run lint` and the pre-commit hook use.
807
809
 
810
+ `test/integration/fixtures/dlfork/` is a WASIX PIE program (`dlfork.c`) that `dlopen`s a side module (`side.c`, built as `lib/libdlside.so`), forks, and has the child read the module's data and call through function pointers taken before the fork, for the fork-after-`dlopen` test. It is built with wasix-sysroot 2025.9.30-28's legacy-EH PIC variant and homescoop's pinned wasixcc, then Asyncify for `proc_fork`: `INSTALL_WASIXCC=<homescoop>/scripts/install-wasixcc.sh WASIX_SYSROOT_28=<unpacked -28 package> test/integration/fixtures/dlfork/build.sh`. `bin/dlfork.wasm` is about 800 KB, far bigger than the other fixtures: wasixcc links a position-independent main with `--whole-archive --export-all`, so side modules can find any libc symbol, and neither `--gc-sections` nor a narrower export list (`--no-export-dynamic --export=…`) takes that out.
811
+
808
812
  `tools/big-file.mjs [bytes]` is a manual check, not part of any suite. It writes a file of that size (64 MiB by default) through a tmpfs mount with the unit-test launcher, reads it back, and prints the peak RSS and its ratio to the file size. A 256 MiB file peaks at about 1.8 times its size. Before ranged I/O (#127), a 1.1 GiB file peaked at 18 GB of RSS. **Do not run it past 1 GiB on a workstation.** It refuses anything over 256 MiB unless `SLICC_BIG_FILE_I_HAVE_THE_RAM=1` is set. Measure a small file and extrapolate instead. The unit suite checks a 32 MiB file the same way, and allows a fixed 192 MiB for garbage V8 has yet to collect, which dominates at that size.
809
813
 
810
814
  Releases are cut by semantic-release on every push to `main`, in two jobs, and only on `main`. The first job runs on the bench pool, so it starts within a minute of the push, before `main` has moved on. It computes the version, tags the commit and creates the GitHub release, with `npmPublish: false`. A run that starts after `main` has moved skips; the run for the newer commit releases it. The second job runs on a GitHub-hosted runner, because npm's trusted publishing and provenance accept no self-hosted runner. It publishes the tagged commit with `tools/release-recover.mjs`. However long it waits for a runner, it publishes exactly the commit the run started on, so the provenance, the tag and the package's `gitHead` agree. For any version tagged at HEAD, `tools/release-recover.mjs` pushes the tag if origin lacks it, publishes the version if npm lacks it (with retries), and creates its GitHub release if that is missing too. So a failure after the version is tagged (a rejected push, a failed `npm publish`) doesn't strand it, and re-running the Release workflow completes it the same way.
@@ -11569,7 +11569,7 @@ function mountTable(mounts = []) {
11569
11569
  }
11570
11570
 
11571
11571
  // src/version.ts
11572
- var KERNEL_VERSION = true ? "1.93.0" : "0.0.0-dev";
11572
+ var KERNEL_VERSION = true ? "1.93.2" : "0.0.0-dev";
11573
11573
  var OS_RELEASE = "6.0.0-slicc";
11574
11574
  function procVersion() {
11575
11575
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -14378,6 +14378,7 @@ var WasmProcess = class {
14378
14378
  this.nodes = options.nodes ?? new VfsNodes(options.fs);
14379
14379
  if (!options.nodes) options.openFiles?.add(this.nodes);
14380
14380
  this.children.onChildState = () => this.signal(SIG.CHLD);
14381
+ this.seedDl(options.dl);
14381
14382
  this.children.onReap = (pid2) => {
14382
14383
  this.deathSignals.delete(pid2);
14383
14384
  options.onReap?.(pid2);
@@ -15212,7 +15213,7 @@ var WasmProcess = class {
15212
15213
  switch (req.op) {
15213
15214
  case "proc-fork":
15214
15215
  this.refuseIfPending(req.restart);
15215
- return { ok: true, kind: "json", json: await this.children.fork(req.state) };
15216
+ return { ok: true, kind: "json", json: await this.children.fork(this.withDl(req.state)) };
15216
15217
  case "proc-spawn":
15217
15218
  this.refuseIfPending(req.restart);
15218
15219
  return { ok: true, kind: "json", json: await this.spawn(req) };
@@ -15355,6 +15356,20 @@ var WasmProcess = class {
15355
15356
  free: [],
15356
15357
  live: /* @__PURE__ */ new Set()
15357
15358
  };
15359
+ withDl(state) {
15360
+ if (this.dlLog.length === 0) return state;
15361
+ const { next: next2, handle, free, live } = this.closures;
15362
+ const dl = { log: [...this.dlLog], next: next2, handle, free: [...free], live: [...live] };
15363
+ return { ...state, dl };
15364
+ }
15365
+ seedDl(dl) {
15366
+ if (!dl) return;
15367
+ this.dlLog.push(...dl.log);
15368
+ this.closures.next = dl.next;
15369
+ this.closures.handle = dl.handle;
15370
+ this.closures.free.push(...dl.free);
15371
+ for (const index of dl.live) this.closures.live.add(index);
15372
+ }
15358
15373
  allocateClosure(floor) {
15359
15374
  const index = this.closures.free.pop() ?? Math.max(floor, this.closures.next);
15360
15375
  this.closures.next = Math.max(this.closures.next, index + 1);
@@ -15664,6 +15679,7 @@ function processOptions(opts, header3, hooks, created3) {
15664
15679
  ...opts.settling ? { settling: opts.settling } : {},
15665
15680
  spawner: opts.spawner,
15666
15681
  forker: opts.forker,
15682
+ ...opts.fork?.dl ? { dl: opts.fork.dl } : {},
15667
15683
  fs: opts.fs,
15668
15684
  kill: opts.kill,
15669
15685
  ...opts.writesBack ? { writesBack: opts.writesBack } : {},
@@ -19249,6 +19265,9 @@ var Launcher = class {
19249
19265
  hostname;
19250
19266
  watchers = new FsWatchers();
19251
19267
  pnpmHome;
19268
+ pnpmChain;
19269
+ pnpmMade;
19270
+ pnpmEpoch = 0;
19252
19271
  mounts;
19253
19272
  createDriverWorker;
19254
19273
  media;
@@ -19299,7 +19318,10 @@ var Launcher = class {
19299
19318
  this.mounts = new MountTable({
19300
19319
  open: (type, spec) => this.driver(type, spec),
19301
19320
  busy: (target) => [...this.openFiles].some((nodes) => nodes.holds(target)) || heldUnder(this.held, target),
19302
- changed: this.watchers.changed.bind(this.watchers),
19321
+ changed: (...paths) => {
19322
+ if (paths.some((path) => this.pnpmChain.has(path))) this.pnpmStale();
19323
+ this.watchers.changed(...paths);
19324
+ },
19303
19325
  owners: this.owners
19304
19326
  });
19305
19327
  this.mounted = this.mounts.wrap(options.fs);
@@ -19312,6 +19334,7 @@ var Launcher = class {
19312
19334
  this.fstabRetries = options.fstabRetries ?? FSTAB_RETRIES;
19313
19335
  this.processMounts = options.processMounts ?? true;
19314
19336
  this.pnpmHome = options.env?.PNPM_HOME ?? SYSTEM_PNPM_HOME;
19337
+ this.pnpmChain = new Set(chain(normalizePath(this.pnpmHome)));
19315
19338
  this.modulesDir = options.modules ?? "/node_modules";
19316
19339
  this.appended = appendedRoots(options.commands);
19317
19340
  this.cdp = new CdpHosts(options.cdp);
@@ -19563,8 +19586,17 @@ var Launcher = class {
19563
19586
  }
19564
19587
  if (interpreter(head) !== "node") return void 0;
19565
19588
  const wasm = modulePath(glue);
19566
- if (!await this.base.exists(wasm)) return void 0;
19567
- return { abi: "emscripten", glue, wasm, argv0: baseName(argv0 || file), ...asked };
19589
+ if (await this.base.exists(wasm)) {
19590
+ return { abi: "emscripten", glue, wasm, argv0: baseName(argv0 || file), ...asked };
19591
+ }
19592
+ const owner = await this.glueOwner(glue, baseName(file), scope);
19593
+ return owner && { ...targetOf(owner), ...asked };
19594
+ }
19595
+ async glueOwner(glue, name2, scope) {
19596
+ const owners = [...(await this.commands(scope)).values()].filter(
19597
+ (c) => c.abi === "emscripten" && !c.script && c.glue === glue
19598
+ );
19599
+ return owners.find((c) => c.name === name2) ?? owners[0];
19568
19600
  }
19569
19601
  async head(path) {
19570
19602
  try {
@@ -19918,6 +19950,7 @@ var Launcher = class {
19918
19950
  delete env[TICKET_ENV];
19919
19951
  env.HOSTNAME = this.hostname;
19920
19952
  let program;
19953
+ await this.pnpmReady();
19921
19954
  try {
19922
19955
  const caller = { pid: req.ppid ?? 0, uid: req.cred.euid, tag: req.tag };
19923
19956
  if (this.integrity) await this.checkedSources(planned, caller);
@@ -20562,8 +20595,7 @@ var Launcher = class {
20562
20595
  this.fs
20563
20596
  );
20564
20597
  void this.unlinked.sweep(entry.target);
20565
- if (this.pnpmHome.startsWith(`${entry.target.replace(/\/$/, "")}/`))
20566
- await this.provisionPnpmHome();
20598
+ this.pnpmStale();
20567
20599
  return entry;
20568
20600
  }
20569
20601
  async ownTmp() {
@@ -20611,8 +20643,18 @@ var Launcher = class {
20611
20643
  if (path === "/") return path;
20612
20644
  return joinName(await realDir(this.base, parentOf4(path)), baseName(path));
20613
20645
  }
20614
- async provisionPnpmHome() {
20615
- await this.base.mkdir(this.pnpmHome, { recursive: true }).catch(() => void 0);
20646
+ pnpmReady() {
20647
+ if (this.pnpmMade !== void 0) return this.pnpmMade;
20648
+ const epoch = this.pnpmEpoch;
20649
+ const made2 = this.base.mkdir(this.pnpmHome, { recursive: true }).then(() => this.owners ? this.stampLayout() : void 0).catch(() => {
20650
+ if (this.pnpmEpoch === epoch) this.pnpmMade = void 0;
20651
+ });
20652
+ this.pnpmMade = made2;
20653
+ return made2;
20654
+ }
20655
+ pnpmStale() {
20656
+ this.pnpmEpoch++;
20657
+ this.pnpmMade = void 0;
20616
20658
  }
20617
20659
  mountedBy(spec, cred) {
20618
20660
  if (!this.owners || cred.euid === 0 && cred.egid === 0) return spec;
@@ -20646,6 +20688,7 @@ var Launcher = class {
20646
20688
  umount(target, detach = false) {
20647
20689
  const at = normalizePath(target);
20648
20690
  this.mounts.umount(at, detach);
20691
+ this.pnpmStale();
20649
20692
  if (!detach) return;
20650
20693
  for (const nodes of this.openFiles) nodes.revoke(at);
20651
20694
  for (const holds of this.held) holds.revoke(at);
@@ -20711,7 +20754,7 @@ var Launcher = class {
20711
20754
  await this.resolvePrefixes();
20712
20755
  for (const dir of SHARED_DIRS) await this.base.mkdir(dir, { recursive: true });
20713
20756
  if (this.owners) await this.ownTmp();
20714
- await this.provisionPnpmHome();
20757
+ await this.pnpmReady();
20715
20758
  await this.users.ensure();
20716
20759
  if (this.owners) await this.stampLayout();
20717
20760
  if (this.owners) await this.sudoer.provision();