Control Plane Reference
Overview
The control plane is the VFS namespace a supervisor uses to install, launch, observe, and stop wapps, and to drive the engine's own power state. It is usually mounted at /dev/wanted (depnds on mount path in config; in default its /dev/wanted) and is a privileged capability: a wapp reaches it only if its launch config grants the wanted driver. An ordinary wapp has no path to it.
Every interaction is an ordinary file operation. Identity travels in the path — never in a payload field — so reads compose as plain text and only the launch config is JSON.
This reference documents the engine-provided contract — the nodes, verbs, and semantics the engine guarantees. How a supervisor drives that contract (reconciliation cadence, capability policy, retry and back-off) is supervisor behaviour and is out of scope here.
Mounting and access
A supervisor is granted the namespace by a drivers[] entry in its launch config:
1{ "name": "wanted" }
The wanted driver is a device singleton: it mounts at its canonical /dev/wanted derived from the name.
Namespace map
1/dev/wanted/
2 ctl root verbs: "create <name>" | "delete <name>"
3 | "poweroff" | "reboot"
4 wapps/ enumerable directory — one entry per known instance
5 <name>/ <name> is the instance name (set by create)
6 ctl (w) per-wapp verb: "start [<image>]" | "stop"
7 state (r) lifecycle token
8 image (r) registry image the instance runs
9 version (r) image version tag (opaque string)
10 id (r) engine-assigned wapp id
11 exit_code (r) WASI exit code (authoritative when state == exited)
12 log (r) buffered stdout/stderr (when console: log)
13 config (w) JSON launch config, consumed by the next start
14 reg/ installed-wapp registry directory
15 config supervisor bootstrap meta-config
Root nodes
| Path | Access | Description |
|---|---|---|
/dev/wanted/ctl |
w | Root verbs. create <name> registers a wapp's control namespace ahead of configuring it. delete <name> releases a slot — a create reservation or a terminal (exited/failure) wapp — so the name leaves wapps/ and its nodes return -ENOENT again. poweroff stops the engine without respawning the supervisor. reboot restarts the engine (host re-exec / board reset). |
/dev/wanted/reg |
rw | Installed-wapp registry. readdir enumerates name:version entries; reading an entry returns a small JSON descriptor (name/version/size) synthesized from the registry — no image load. Install by ref: open reg/<name>:<version> for write and stream the OCI TAR; the path names the stored image. The version is an opaque tag; each ref component must match [A-Za-z0-9_][A-Za-z0-9._-]* or the open is rejected. A plain read of the directory itself returns -EISDIR. |
/dev/wanted/config |
r | Supervisor bootstrap meta-config. |
The root ctl accepts only create <name>, delete <name>, poweroff, and reboot; any other token returns -EINVAL. The root ctl does not launch wapps — start and stop exist only per-wapp (wapps/<name>/ctl). poweroff and reboot take no argument and are the only writes that end the engine's run loop: a supervisor that exits on its own is respawned.
Releasing a slot (delete)
delete <name> is the inverse of create: it removes the name from the control plane so wapps/<name>/ stops enumerating and every node under it returns -ENOENT again.
1write /dev/wanted/ctl delete app1
It frees a create reservation (created/not_started) and/or a terminal platform slot (exited/failure). The slot allocator already reuses a terminal slot on the next start, so delete is for explicitly reclaiming a name — releasing the wapps/ entry and any buffered config — without launching another wapp.
| Target state | Result |
|---|---|
created / not_started |
Reservation freed; name → -ENOENT. |
exited / failure |
Terminal slot released; name → -ENOENT. |
running / starting |
Rejected with -EBUSY — stop the wapp first; there is no implicit stop-then-delete. |
| unknown name | -ENOENT. |
First-start lifecycle
A wapp is launched in three deliberate steps — identity always in the path, configuration always JSON on its own node, and the launch verb on the wapp's own ctl:
1write /dev/wanted/ctl create app1 # register the namespace
2write /dev/wanted/wapps/app1/config { ...JSON config... } # buffer the launch config
3write /dev/wanted/wapps/app1/ctl start # launch with that config
create is the only way to bring a wapp's namespace into being: it reserves wapps/app1/ (which then enumerates and reads state == created), and only then are that wapp's config, ctl, and read nodes openable. The subsequent config write targets the reservation (moving it to not_started), and the per-wapp start consumes it. A bare created wapp cannot start — its config must be written first. There is no config-without-create: opening any node of a name the engine doesn't know returns -ENOENT, so a name cannot be probed or configured by guessing its path.
Command-line arguments (argv[1..]) and environment variables travel in the config node's args[] and envs[] arrays (see the schema below) — there is no inline-argument shorthand on the control plane. argv[0] is always the instance name, set by the engine.
Instance vs. image. create <name> reserves an instance name; the image it runs is resolved at start in priority order: an explicit start <image> argument, else the config's image field, else the instance name. So one image can back several instances (different <name>s, same image), and a bare create can go straight to start <image> — the explicit image satisfies the start gate without a prior config write (the wapp launches on default console/args). A bare start on an unconfigured reservation is still rejected.
Wapp namespace
readdir on wapps/ enumerates every wapp the engine knows — live ones plus created reservations. A wapp's directory and the nodes below it exist only for such a known name; opening wapps/<unknown>/<anything> returns -ENOENT. Each wapps/<name>/ exposes:
| Node | Access | Content / Verb |
|---|---|---|
state |
r | Lifecycle token: created, not_started, starting, running, exited, failure. A bare create reservation (no config yet) reads created; once its config is written it reads not_started. |
image |
r | The registry image the instance runs, known once it is live (the loader stamps it). A created/not_started reservation has not bound an image yet and reads empty. |
version |
r | The image's version tag — an opaque string (e.g. 0.0.1-1, stable, a digest), from its registry entry. |
id |
r | Engine-assigned wapp id (decimal). |
exit_code |
r | The wapp's WASI exit code as a decimal integer. Authoritative only when state == exited; otherwise (running, or a trap that set state == failure) it reads the sentinel -1. Lets a supervisor distinguish a clean zero exit, a clean non-zero (application-level) exit, and a trap. |
log |
r | Ring-buffered stdout/stderr, present when the wapp was launched with a log console. |
ctl |
w | start [<image>] launches the instance (optional explicit image overrides config.image); stop terminates it. The instance name comes from the path. |
config |
w | JSON launch config (see below). Buffered and consumed by the next start for this name, then cleared. |
A read node returns its value once; the next read on the same fd returns 0 (EOF), and the value regenerates on a fresh open. A control write that overflows the fixed line buffer is rejected with -EMSGSIZE.
Verbs
A verb is one write() to the node. The engine has no shell or I/O redirection; the examples below use the wsh debug shell's write builtin, which opens the path and writes the joined tokens verbatim:
1write /dev/wanted/wapps/app1/ctl start # launch app1 (uses its buffered config, if any)
2write /dev/wanted/wapps/app1/ctl start duplex # launch app1 from the "duplex" image
3write /dev/wanted/wapps/app1/ctl stop # terminate app1
start [<image>]: resolve the image (explicit argument →config.image→ the instance name) in the registry → load the OCI layers → install the buffered config's console, drivers, mounts, sockets, args, and envs → start the wapp. Image identity (name + version) is read from the registry entry; the instance keeps its own name. Returns bytes written, or a negative errno if any step fails.stop: terminate the wapp named by the path. Returns bytes written / negative errno.
The engine declares no capability requirements in the image and enforces none; a wapp's effective capabilities are exactly the consoles, drivers, mounts, and sockets its launch config grants. Validating those against policy before issuing start is the supervisor's responsibility. The engine trusts the config it is handed.
Wapp state machine
stateDiagram-v2
[*] --> created: create
created --> not_started: config written
not_started --> starting: start
starting --> running
running --> exited: stop / clean exit (any code via proc_exit)
running --> failure: trap
created --> [*]: delete
not_started --> [*]: delete
exited --> [*]: delete
failure --> [*]: delete
delete is the only edge back to [*] from created/not_started; a terminal exited/failure slot also leaves via delete (or is silently reused by the next start). A running/starting wapp has no delete edge — stop it first.
state is the authoritative observed status. A supervisor maps these tokens onto its own reconciliation state machine; starting and stopping are supervisor-side transient states, not engine tokens.
Engine state |
Meaning |
|---|---|
created |
Namespace reserved via create but no config written yet — the bare reservation. A created wapp cannot start; its config must be written first. |
not_started |
A configured reservation, ready to start: config has been written but the wapp has not been launched. (A name the engine doesn't know has no namespace at all — its nodes return -ENOENT rather than a state token.) |
starting |
Launch issued, instantiation in progress. |
running |
Executing. |
exited |
Terminated cleanly — stop, or a proc_exit/return from the wapp. The exit code (zero or non-zero) is on the exit_code node. |
failure |
Terminated by a trap (illegal instruction, OOB access, unreachable) — no WASI exit code exists; exit_code reads the sentinel. |
Launch-config schema
A wapp that needs a console, drivers, mounts, or sockets has its config written as one JSON object to wapps/<name>/config before start. The config carries no name — identity is the path.
1{
2 "image": "app",
3 "console": {
4 "in": { "name": "null" },
5 "out": { "name": "log" },
6 "err": { "name": "log" }
7 },
8 "drivers": [
9 { "name": "gpio" }
10 ],
11 "mounts": [
12 { "name": "platform", "path": "/var/lib/app" },
13 { "name": "config", "path": "/etc/config", "options": "{\"config_file\":\"/config.json\"}" }
14 ],
15 "sockets": [
16 { "name": "uplink", "address": "tcp://127.0.0.1:8888" }
17 ],
18 "args": ["--port", "8888"],
19 "envs": ["TZ=UTC", "LOG_LEVEL=info"]
20}
| Field | Type | Notes |
|---|---|---|
image |
string | Optional registry image this instance runs, as a reference <name>[:<tag>] — a bare name resolves to the first match, a pinned tag (duplex:stable) resolves exactly. When omitted it defaults to the instance name, so a single-instance wapp needs no image. Set it to run several instances off one image, or override it per launch with start <image>. |
console |
object | Slots in / out / err, each a driver spec backing the wapp's stdio. Optional: an unset slot defaults — in to null, out/err to log — so a wapp launches without an explicit console and its output is captured to the log node. Override a slot with log (capture), null (discard), or platform (redirect to the engine's native stdio, fds 0/1/2). The platform name backs stdio here; in mounts[] it is instead a host directory. |
drivers |
array | Up to 10 device singletons. Each mounts at /dev/<name> derived from the name; a path is rejected. |
mounts |
array | Up to 10 file/backend drivers, each bound at an arbitrary absolute path outside /dev, /proc and /net. The platform backend binds a host directory as a native WASI preopen (a bind mount); the volume backend binds an engine-managed persistent store (the wapp names only a volume, the engine owns the host path); other backends mount through the VFS router. options are backend-specific — see below. |
sockets |
array | Up to 10 named connections. Each is created at /net/<name>; the transport is the entry's address. A path is rejected. |
args |
array | Up to 8 strings (≤63 chars each), the wapp's argv[1..]. argv[0] is always the instance name, set by the engine. |
envs |
array | Up to 8 POSIX KEY=VALUE strings (≤63 chars each), the wapp's environ. |
Entry shapes per section:
| Section | Keys | Notes |
|---|---|---|
console.* |
name, options |
name is one of null, log, platform. |
drivers[] |
name, options |
name is a device driver (e.g. null, wanted); mounted at /dev/<name>. |
mounts[] |
name, path, options |
name is a file/backend driver (platform, volume, config, 9p); path is required, absolute, and outside /dev//net. |
sockets[] |
name, address |
name is the /net node label; address is a URL <scheme>://<host>:<port> with scheme tcp/udp/tcps/udps. |
A platform mount is a bind mount; its options accept two comma-separated knobs: src=<abshostpath> (the host directory backing the mount — defaults to path) and ro/rw (access mode — defaults to rw). A ro mount denies every write (-EROFS) and requires the host directory to already exist; path stays the wapp-visible mount point. A relative/empty src or an unrecognised token is rejected at install. Example: { "name": "platform", "path": "/cfg", "options": "src=/etc/app,ro" }.
A volume mount is an engine-managed persistent store; its options accept name=<volname> (the volume name — a single path component, default default), ro/rw (access mode — default rw), and shared (namespace — default private). The engine backs it at a host location it owns and creates it on first use; the wapp never sees a host path, so the store is portable across hosts. A private volume (the default) is namespaced under the wapp instance — unreachable by any other wapp. A shared volume is global by name: every wapp mounting the same name=<volname>,shared sees one store (the substrate for a producer→processor→publisher pipeline). Private and shared namespaces are disjoint, so a private name=X and a shared name=X never alias. ro applies to either, so a downstream stage can be handed a shared feed read-only. The store persists across restarts and reboots and is not deleted on delete/uninstall. A name containing /, ., or .., or an unrecognised token, is rejected at install. Example: { "name": "volume", "path": "/feed", "options": "name=stream,shared,ro" }.
The parser uses a bounded token pool and a 2048-byte stack buffer; an oversized config returns -EMSGSIZE.
Driving it from wsh
The wsh debug supervisor wraps the raw node operations as builtins:
| Command | Effect |
|---|---|
status |
List every wapp under wapps/ with its state. |
status <name> |
Print one wapp's state, version, and id. |
create <name> |
Write create <name> to the root ctl to reserve the namespace. |
delete <name> |
Write delete <name> to the root ctl to release the slot (stop a running wapp first). |
set_config <name> <json> |
Write the JSON launch config to wapps/<name>/config. |
start <name> |
Write start to wapps/<name>/ctl (launches with the buffered config; the image comes from config.image, defaulting to <name>). |
stop <name> |
Write stop to wapps/<name>/ctl. |
poweroff / reboot |
Drain child wapps, then write the verb to the root ctl. |
The filesystem builtins (ls, cat, write) operate on any node directly — e.g. cat /dev/wanted/wapps/app1/log or ls /dev/wanted/wapps.
See also
- Quick Start — a worked launch from the
wshshell. - Configuration Reference — the engine-level config that boots the supervisor.
- VFS Reference — every other namespace a wapp can see.