Skip to content

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 namespace
  • proc.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
  • bind ignores its flags parameter — no union directory support
  • No way to inspect the current namespace

unmount

New kernel method:

// On Kernel
unmount(pid: Pid, path: string): void
// On ProcContext
unmount(path: string): void

Removes 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/sub if that’s a separate mount. This matches Plan 9’s unmount semantics. Unmounting a parent mount point does not cascade to child mount points.

Shell Commands

Execution model: mount, umount, and bind are promoted builtins. Their implementations live at /bin/mount, /bin/umount, /bin/bind (discoverable via ls /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 reason cd is a builtin. Non-shell callers (BinFunction code) can still use proc.mount(), proc.bind(), proc.unmount() directly.

/bin/mount

Three modes:

Terminal window
# List current namespace (no args)
mount
# Output: /dev/cons on /dev/cons (consFS)
# / on / (memoryFS)
# /proc on /proc (procFS)
# ...
# Mount from /srv
mount webcam ~/cam
# 1. proc.getServer('webcam') → Fileserver
# 2. proc.mount(server, ~/cam)
# Bind an existing path to a new location
mount /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 via bind. mount webcam ~/cam looks up webcam in /srv. mount /dev/clipboard ~/clip binds the path. The / character disambiguates. If a user wants to be explicit, bind is always available for path-to-path binding. This avoids the ambiguity of names that happen to match path components.

/bin/umount

Terminal window
umount ~/cam
# Calls proc.unmount(~/cam)

Minimal. Resolves the path and calls proc.unmount().

/bin/bind

Terminal window
bind /new /existing # replace — /existing now points to /new's server
bind -b /new /existing # before — /new searched first, then /existing
bind -a /new /existing # after — /existing searched first, then /new

Decision: bind is a separate command from mount. In Plan 9, mount and bind are distinct syscalls with distinct semantics. mount connects to a file server. bind reshuffles existing namespace entries. Keeping them separate preserves clarity, even though mount internally uses bind for 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 bins
  • bind -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

// Current
mounts: Map<string, Fileserver>
// With union support
mounts: 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 in spawn(), and any code that iterates mounts.values(). The change is contained within the kernel — no public API changes.

Resolution Changes

namespace.resolve() changes to always return an array:

// Current
resolve(path: string): { server: Fileserver; path: InnerPath }
// With union support
resolve(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 methodDispatch 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: readdir merging applies at any depth within the union. For readdir('/bin/subdir') on a union at /bin, each server in the union receives readdir('/subdir'). Results are merged and deduplicated. Servers that throw ENOENT for /subdir are 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: BindFlags is 'replace' | 'before' | 'after'. The type already exists in types.ts but 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, unmount removes the whole entry. If users need to restructure a union, they unmount and 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 ProcContext

The 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

FileserverEffect of union bind
overlayFSOrthogonal. OverlayFS is copy-on-write at the fileserver level. Union bind is composition at the namespace level. You could union-bind two overlayFS instances.
memoryFSWorks naturally. Union-bind two memoryFS instances to merge their trees.
procFS/devFSTypically not union-bound, but nothing prevents it. bind -a /custom-dev /dev would add custom devices alongside standard ones.
srvFSNot meaningful to union-bind. There’s one /srv registry.