Namespaces — mount, umount, bind
Shell commands and kernel primitives for namespace manipulation. Brings the existing proc.mount() and proc.bind() to userspace and adds union directories.
Current State
The kernel already provides:
proc.mount(server, path)— adds a Fileserver to the process’s namespaceproc.bind(oldPath, newPath, flags?)— resolves oldPath to its Fileserver, mounts at newPath- Per-process namespaces — shallow-copied on spawn, independently mutable
What’s missing:
unmount— no way to remove a mount point- No shell commands for mount/bind/unmount — only programmable from within a bin via ProcContext
bindignores itsflagsparameter — no union directory support- No way to inspect the current namespace
unmount
New kernel method:
// On Kernelunmount(pid: Pid, path: string): void
// On ProcContextunmount(path: string): voidRemoves the mount point at the exact path from the process’s namespace. Throws ENOENT if no mount exists at that path. Does not affect child processes that already have their own namespace copy.
Decision: exact path match, not subtree removal.
unmount('/mnt')removes the mount at/mnt. It does not remove/mnt/subif that’s a separate mount. This matches Plan 9’sunmountsemantics. Unmounting a parent mount point does not cascade to child mount points.
Shell Commands
Execution model:
mount,umount, andbindare promoted builtins. Their implementations live at/bin/mount,/bin/umount,/bin/bind(discoverable vials /bin,which,type), but the shell executes them in its own process context rather than spawning a child. This is necessary because per-process namespaces are snapshot-on-fork — a child process’s namespace mutations are invisible to the parent. Same reasoncdis a builtin. Non-shell callers (BinFunction code) can still useproc.mount(),proc.bind(),proc.unmount()directly.
/bin/mount
Three modes:
# List current namespace (no args)mount# Output: /dev/cons on /dev/cons (consFS)# / on / (memoryFS)# /proc on /proc (procFS)# ...
# Mount from /srvmount webcam ~/cam# 1. proc.getServer('webcam') → Fileserver# 2. proc.mount(server, ~/cam)
# Bind an existing path to a new locationmount /dev/clipboard ~/clip# Falls back to proc.bind('/dev/clipboard', '~/clip')The mount bin detects mode: if the source contains a / (looks like a path), it’s a bind. Otherwise, it checks proc.getServer(name) for a srv lookup. This avoids ambiguity — bare names go to /srv, paths go to bind.
Flags:
mount -r <source> <target>— read-only mount (wraps Fileserver in a readonlyView middleware before mounting)
Decision: bare names resolve via
/srv, paths resolve viabind.mount webcam ~/camlooks upwebcamin/srv.mount /dev/clipboard ~/clipbinds the path. The/character disambiguates. If a user wants to be explicit,bindis always available for path-to-path binding. This avoids the ambiguity of names that happen to match path components.
/bin/umount
umount ~/cam# Calls proc.unmount(~/cam)Minimal. Resolves the path and calls proc.unmount().
/bin/bind
bind /new /existing # replace — /existing now points to /new's serverbind -b /new /existing # before — /new searched first, then /existingbind -a /new /existing # after — /existing searched first, then /newDecision:
bindis a separate command frommount. In Plan 9,mountandbindare distinct syscalls with distinct semantics.mountconnects to a file server.bindreshuffles existing namespace entries. Keeping them separate preserves clarity, even thoughmountinternally usesbindfor the fallback case.
Union Bind
The most significant change to the namespace system. Union bind allows multiple fileservers to serve the same mount point, searched in order.
Motivation
Union directories are fundamental to Plan 9’s composition model:
bind -b /usr/local/bin /bin— overlay local bins before system binsbind -a /pkg/fonts /lib/font— add package fonts alongside system fonts- Multiple package sources merged into a single view
Without union bind, composing multiple sources into one path requires a separate overlay fileserver for each case. Union bind makes this a namespace-level operation.
Namespace Type Change
// Currentmounts: Map<string, Fileserver>
// With union supportmounts: Map<string, Fileserver | Fileserver[]>Decision: this is a breaking change to the Namespace interface. The following call sites must be updated:
namespace.resolve(),createNamespace(), shallow copy inspawn(), and any code that iteratesmounts.values(). The change is contained within the kernel — no public API changes.
Resolution Changes
namespace.resolve() changes to always return an array:
// Currentresolve(path: string): { server: Fileserver; path: InnerPath }
// With union supportresolve(path: string): { servers: Fileserver[]; path: InnerPath }Single-server mounts return a 1-element array. The caller decides how to iterate. This keeps the namespace resolver simple and puts union dispatch policy in the kernel I/O methods.
Union dispatch rules in the kernel I/O layer:
| Kernel method | Dispatch strategy |
|---|---|
open(path, {read}) | Try each server in order; return first success; throw ENOENT only if all fail |
open(path, {write|create}) | Always goes to servers[0] (the first/top server) |
stat(path) | Try each server in order; return first success; ENOENT if all fail |
readdir(path) | Call all servers, merge results, deduplicate by name (first occurrence wins). Servers that throw ENOENT for the inner path are silently skipped. |
mkdir(path) | Goes to servers[0] |
remove(path) | Goes to servers[0] (may shadow rather than truly delete from lower servers). If the file only exists in a lower server, servers[0].remove() throws ENOENT — the user must create a whiteout or accept that lower-server files can’t be removed through the union. This matches Plan 9 union semantics. |
rename(old, new) | Goes to servers[0] |
wstat(path, changes) | Try each server in order; apply to first success (same pattern as stat — forward and see if it succeeds) |
For non-union mounts (1-element array), all dispatch is trivially servers[0] — no behavioral change from current code.
Decision: writes go to the first server. This matches Plan 9’s union semantics and overlayFS’s “upper layer” concept. The first server in the array is the “active” one. Lower servers provide fallback reads. This is not copy-on-write — if you write to a file that only exists in a lower server, the write goes to the first server’s
open({create: true}), creating a new file that shadows the lower one.
Decision:
readdirmerging applies at any depth within the union. Forreaddir('/bin/subdir')on a union at/bin, each server in the union receivesreaddir('/subdir'). Results are merged and deduplicated. Servers that throw ENOENT for/subdirare silently skipped (that server simply doesn’t have that subdirectory).
Bind Operations
// In kernel bind():bind(pid: Pid, oldPath: string, newPath: string, flags?: BindFlags): void { const proc = getRunningProcess(pid) const resolved = resolvePath(proc, oldPath) const { servers } = proc.namespace.resolve(resolved) // For bind, we take the first server from the resolution const server = servers[0]! const existing = proc.namespace.mounts.get(newPath)
switch (flags) { case 'before': { // Prepend: new server searched first const arr = Array.isArray(existing) ? existing : existing ? [existing] : [] proc.namespace.mounts.set(newPath, [server, ...arr]) break } case 'after': { // Append: new server searched last const arr = Array.isArray(existing) ? existing : existing ? [existing] : [] proc.namespace.mounts.set(newPath, [...arr, server]) break } default: // Replace (current behavior) proc.namespace.mounts.set(newPath, server) }}Decision:
BindFlagsis'replace' | 'before' | 'after'. The type already exists intypes.tsbut is currently ignored. This implementation honors it.
Unmount Behavior for Unions
unmount(path) removes the entire mount entry at that path, including all servers in a union. There is no selective removal of a single server from a union in Phase A.
Decision: selective union removal is deferred. Plan 9’s
unmount(name, old)takes two args to selectively remove one server from a union. This is a Phase B concern. For Phase A,unmountremoves the whole entry. If users need to restructure a union, theyunmountand re-bind.
Decision:
unmount('/')throws EPERM. The root mount cannot be removed. All other paths can be unmounted.
Namespace Copy on Spawn
Currently spawn does new Map(parent.mounts). With union bind, array entries are object references — the shallow copy shares the arrays. This is fine: arrays in the mount table are treated as immutable values. bind -b creates a new array rather than mutating the existing one. Parent and child have independent mount tables after spawn.
Preset and Wiring
// src/presets/srv.ts — provides bins only (not the srvFS instance)function srvPreset(): Extension { return { bins: { mount: mountBin, umount: umountBin, bind: bindBin }, }}srvFS is not created by the preset. It’s created in image.ts at boot, because image.ts needs a reference to the concrete srvFS instance to create the ProcContext closures (postServer/getServer/removeServer). This follows the same pattern as the root memoryFS — image.ts creates it, wires closures over it, and mounts it.
// In image.ts (pseudocode):const srv = srvFS()mounts.set('/srv', srv)// closures closing over srv → passed to KernelOpts → threaded onto ProcContextThe preset provides just the bins. image.ts provides the mount + callbacks. stdSystem() includes srvPreset() for the bins.
mount, umount, and bind are registered as promoted builtins in the shell interpreter (PROMOTED_BUILTINS in interpreter.ts). This ensures they execute in the shell’s own ProcContext, so namespace mutations persist across commands. The bins are still installed at /bin/ for discoverability and programmatic use.
Interaction with Existing Fileservers
| Fileserver | Effect of union bind |
|---|---|
| overlayFS | Orthogonal. OverlayFS is copy-on-write at the fileserver level. Union bind is composition at the namespace level. You could union-bind two overlayFS instances. |
| memoryFS | Works naturally. Union-bind two memoryFS instances to merge their trees. |
| procFS/devFS | Typically not union-bound, but nothing prevents it. bind -a /custom-dev /dev would add custom devices alongside standard ones. |
| srvFS | Not meaningful to union-bind. There’s one /srv registry. |