summaryrefslogtreecommitdiff
path: root/9proc/docs/LIBRARY.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
committerGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
commitba996acfcad1698adbf4a1834fe50e73b1c6cab9 (patch)
tree282ba00ce5b10d7416aecb9f2f0f0a439340a57d /9proc/docs/LIBRARY.md
parentb05abcba3ea09ea106ad28364c6e40a3ec31b890 (diff)
downloadcloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.tar.gz
cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.zip
Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web)
Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9proc/docs/LIBRARY.md')
-rw-r--r--9proc/docs/LIBRARY.md287
1 files changed, 287 insertions, 0 deletions
diff --git a/9proc/docs/LIBRARY.md b/9proc/docs/LIBRARY.md
new file mode 100644
index 0000000..eef7dad
--- /dev/null
+++ b/9proc/docs/LIBRARY.md
@@ -0,0 +1,287 @@
+# 9proc: a 9P debug/introspection server as a library
+
+The demo server that 9ns's tests use grows into a library any Zig program
+can embed: a debugger-shaped interface where the protocol is just files.
+Anything that can read a filesystem (a shell, an agent, an editor, `9p`,
+9ns) can inspect a running process: build facts, comptime type layouts,
+live values, threads and their stacks, memory, breakpoints, panics.
+
+Design rules (non-negotiable, they mirror cloud9):
+
+1. **The core is freestanding.** No allocator, no OS, no threads, no `std.Io`.
+ Caller-owned buffers, fixed-capacity tables sized at comptime. It must
+ compile for `riscv32-freestanding-none` (the ESP32-P4 firmware target,
+ `../05-zig-p4`), and `zig build 9proc-check-freestanding` proves it.
+2. **Every dependency on a runtime is an explicit argument.** Features that
+ truly need an `Allocator` or an `std.Io` take them in their `init`; nothing
+ reaches for `std.heap.page_allocator` or a global `Io`. Where memory is
+ needed it is preferably a caller-provided `[]u8` or a comptime-sized
+ `Storage` struct the caller places in static memory.
+3. **All allocation happens up front**, at init, from what the caller passed.
+ Steady-state operation does not allocate.
+4. **Platform layers are separate modules** (`9proc.linux`) and are the
+ only places that touch sockets, threads, signals, `/proc` or `std.debug`.
+
+```
+ 9proc/src/root.zig pub const core, vars, scratch, linux (linux only), Server(cfg)
+ 9proc/src/core.zig Tree/Server engine on cloud9.Server: fids, walks, dir reads, providers
+ 9proc/src/vars.zig comptime value renderers (@typeInfo) for /vars
+ 9proc/src/scratch.zig in-memory read/write tree provider (takes an Allocator)
+ 9proc/src/linux/probe.zig background thread + poll loop + unix/tcp/fd listeners
+ 9proc/src/linux/debug.zig threads, stacks, registers, addr→source, memory, breakpoints, panic
+ 9proc/demo/main.zig the `9proc-demo` binary: embeds everything, worker thread, exposed vars
+
+(paths from the cloud9 root; the library is the module `9proc` that
+cloud9's `build.zig` exports next to `cloud9`, wired by `9proc/build.zig`)
+```
+
+## Core (`core.zig`)
+
+```zig
+pub const Config = struct {
+ name: []const u8 = "9proc", // /README and Stat uid/gid
+ types: []const type = &.{}, // /comptime/types/<short name>/...
+ decls_of: ?type = null, // /comptime/decls lists this type's pub decls
+ fns: type = struct {}, // /runtime/fn/<name>: pub fn (ctx: *anyopaque, w: *std.Io.Writer) anyerror!void
+ ctl: ?*const fn (ctx: *anyopaque, cmd: []const u8, out: *std.Io.Writer) anyerror!void = null, // /ctl
+ max_fids: u16 = 64,
+ max_providers: u8 = 8,
+ max_vars: u8 = 32,
+ /// Dynamic file contents are generated at open time into per-fid snapshot
+ /// slots so that reads at arbitrary offsets are consistent.
+ snapshot_slots: u8 = 8,
+ snapshot_bytes: u32 = 16 * 1024,
+};
+
+pub fn Server(comptime cfg: Config) type {
+ return struct {
+ pub const Storage = struct { // caller places this in static memory
+ in: [msize]u8, out: [msize]u8, snapshots: [cfg.snapshot_slots][cfg.snapshot_bytes]u8,
+ };
+ pub const Shared = struct { // state common to all connections (providers, vars)
+ pub fn init(name_ctx: *anyopaque) Shared;
+ pub fn addProvider(s: *Shared, p: Provider) error{Full}!void;
+ pub fn expose(s: *Shared, name: []const u8, ptr: anytype) error{Full}!void; // typed value → /vars/<name>
+ };
+ pub const Conn = struct { // one 9P connection, push/step/output like cloud9
+ pub fn init(shared: *Shared, storage: *Storage, msize: u32) Conn;
+ pub fn push(c: *Conn, bytes: []const u8) usize; // feed transport bytes
+ pub fn step(c: *Conn) error{Protocol}!bool; // handle ≤ 1 request; false = nothing to do
+ pub fn output(c: *const Conn) []const u8; // bytes to send
+ pub fn wrote(c: *Conn, n: usize) void;
+ pub fn hangup(c: *Conn) void; // drop fids, tell providers
+ };
+ };
+}
+```
+
+`step` drives `cloud9.Server.receive/reply/negotiate` and the backend: the
+static tree (comptime-generated from `cfg`: `/README`, `/build/*` via a
+`build_options`-like struct passed in `cfg.build`, `/comptime/types/*`,
+`/comptime/decls`, `/runtime/fn/*`, `/ctl`, `/vars/*`) plus **providers**.
+
+A provider is a runtime vtable mounted at a top-level name. It owns a subtree
+with its own naming (dynamic directories such as `/threads/<tid>` or
+`/addr/<hex>` cannot be enumerated at comptime):
+
+```zig
+pub const Provider = struct {
+ name: []const u8,
+ ctx: *anyopaque,
+ vtable: *const VTable,
+ pub const Handle = u64; // provider-defined node id; 0 = provider root
+ pub const VTable = struct {
+ walk: *const fn (ctx, parent: Handle, name: []const u8) Error!Handle,
+ stat: *const fn (ctx, h: Handle, out: *NodeStat) Error!void, // kind (dir/file), mode, length, mtime
+ list: *const fn (ctx, dir: Handle, index: usize, out: *NodeStat) Error!bool, // nth entry; false when done
+ open: *const fn (ctx, h: Handle, mode: u8) Error!void,
+ read: *const fn (ctx, h: Handle, offset: u64, buf: []u8) Error!usize,
+ write: *const fn (ctx, h: Handle, offset: u64, data: []const u8) Error!usize,
+ create: ?*const fn (ctx, dir: Handle, name: []const u8, perm: u32, mode: u8) Error!Handle,
+ remove: ?*const fn (ctx, h: Handle) Error!void,
+ wstat: ?*const fn (ctx, h: Handle, st: *const cloud9.Stat) Error!void,
+ clunk: *const fn (ctx, h: Handle) void, // fid released (also on hangup)
+ };
+ pub const Error = error{ NotFound, Exists, Perm, NotDir, IsDir, NotEmpty, BadOffset, NoSpace, Io, Unsupported };
+};
+```
+
+Error → Rerror text mapping lives in one place in the core, using the Plan 9
+strings 9ns's bridge already understands (`file does not exist`,
+`permission denied`, `file already exists`, `directory not empty`,
+`not a directory`, `is a directory`, `bad offset`, `no space`, `i/o error`,
+`not supported`).
+
+Directory reads follow the 9P rule (offset 0 or previous offset+count, never
+split a record). Dynamic file reads: on open the content is generated once
+into a snapshot slot (`open` runs the generator; `read` serves the slot; a
+read at offset 0 regenerates); no free slot → Rerror `too many open dynamic
+files`. Stats of dynamic files report length 0.
+
+Qids: static nodes get comptime paths; provider nodes get
+`(provider index << 56) | handle`.
+
+Static memory: `Server(cfg).Storage` per connection, `Shared` once. No heap.
+The core has unit tests driven through `cloud9.Client` in memory (like today).
+
+## Value renderers (`vars.zig`)
+
+`expose(name, ptr: anytype)` builds at comptime a `VTable` for
+`@TypeOf(ptr.*)`:
+
+```
+/vars/<name>/value rendered text (structs: "field: value" lines, nested indented; unions: tag + payload;
+ optionals: "null" or the value; enums: tag; ints/floats/bools; []const u8 and [*:0]const u8
+ as quoted strings (≤ 256 bytes); other pointers as 0x… never followed; arrays/slices ≤ 64 elements)
+/vars/<name>/type @typeName
+/vars/<name>/size @sizeOf
+/vars/<name>/addr 0x…
+/vars/<name>/raw the bytes (length = @sizeOf)
+/vars/<name>/f/<field>/... same layout recursively for struct fields (depth ≤ 4), leaves writable:
+ writing text to a scalar's `value` parses and stores it (ints: decimal/0x, bools, floats, enums by tag)
+```
+
+Rendering is by a comptime-generated function table; no allocation.
+Writes to scalars are plain stores (not atomic; documented).
+
+## Scratch provider (`scratch.zig`)
+
+The in-memory read/write tree from the current server, as a provider, with
+`init(allocator, budget_bytes)`; the only core-level component that takes an
+allocator, and it is optional.
+
+## Linux layer (`linux/probe.zig`)
+
+```zig
+pub const Probe = struct {
+ pub const Options = struct {
+ io: std.Io, // for std.debug symbolization
+ listen: union(enum) { unix: []const u8, tcp: []const u8, fd: i32 },
+ max_clients: u8 = 8,
+ msize: u32 = 64 * 1024,
+ hold_on_panic: bool = true,
+ capture_signal: u8 = SIGRTMIN + 3, // used to snapshot other threads
+ breakpoints: bool = true, // install the SIGTRAP handler
+ };
+ pub fn Storage(comptime max_clients: u8) type; // static: per-client Server.Storage + poll table
+ pub fn init(p: *Probe, shared: *Server.Shared, storage: *Storage, opts: Options) !void; // listens, registers the debug provider
+ pub fn start(p: *Probe) !void; // spawns ONE background thread running a poll loop over listener + clients
+ pub fn stop(p: *Probe) void; // closes, joins
+};
+```
+
+One thread, `poll()` over the listener and every connection; each connection
+is a core `Conn` fed with `push`/`step`/`output`. No per-connection threads.
+Symbolization uses `std.debug.getSelfDebugInfo()` with the `io` passed in and
+a caller-provided fixed buffer as the text arena.
+
+## Debug provider (`linux/debug.zig`)
+
+Mounted as `/threads`, `/addr`, `/mem`, `/hex`, `/breakpoints`, `/panic`.
+
+```
+/threads/ one directory per tid, enumerated from /proc/self/task at list time
+/threads/<tid>/name comm
+/threads/<tid>/stat state letter + a few fields from /proc/self/task/<tid>/stat
+/threads/<tid>/stack "#n 0x<addr> in <fn> (<file>:<line>:<col>)" per frame
+/threads/<tid>/regs "<reg> 0x<value>" per general register, from the captured cpu context
+/addr/<hex> dynamic dir: walk of any hex address yields a file "fn\nfile:line:col\nmodule\n"
+/mem/maps /proc/self/maps served by pread at the requested offset (any size)
+/mem/<hex> raw bytes at address+offset via process_vm_readv/writev (never faults); writable
+/hex/<hex> hexdump text of 256 bytes at address (+offset), like std.debug.dumpHex
+/breakpoints/ directory of tids currently stopped in @breakpoint()
+/breakpoints/<tid>/stack, regs as above
+/breakpoints/<tid>/ctl write "continue" (or "step"? no: continue only) to resume
+/panic/message the panic message, empty before any panic
+/panic/stack frames of the panicking thread
+/panic/ctl write "continue" to let the default panic handler run (abort)
+```
+
+**Capturing another thread** (`stack`, `regs`): the server thread `tgkill`s
+the target with `capture_signal`. The handler (SA_SIGINFO, async-signal-safe:
+no allocation, no locks) copies the `cpu_context.Native` obtained through
+`std.debug.cpu_context.fromPosixSignalContext` into a slot and futex-waits.
+The server thread unwinds with `std.debug.StackIterator.init(&ctx)` while the
+target is parked, symbolizes, then releases the slot; the target resumes. The
+server's own thread unwinds itself directly. Timeout 250 ms → Rerror
+`thread did not respond`. Threads blocked in uninterruptible syscalls simply
+time out. A target parked while holding std.debug's `SelfInfo` lock (it was
+printing a stack trace itself) cannot be unwound without deadlocking; the
+probe detects that with `tryLock`, releases the target and answers
+`i/o error` (registers still work).
+
+**Breakpoints**: `@breakpoint()` raises SIGTRAP on the executing thread only.
+The installed handler stores the context in a slot, marks the thread paused,
+and futex-waits until `/breakpoints/<tid>/ctl` receives `continue`. On x86_64
+the saved PC already points past `int3`; on aarch64 the handler advances PC by
+4 (`brk`) before returning, but only for kernel-generated traps
+(`si_code > 0`); a user-sent `SIGTRAP` (`kill -TRAP`, `tgkill`) parks the
+thread exactly where it was, which makes it a usable "pause this thread"
+request. Other threads keep running; a slot table (`max_paused`, default 16)
+bounds simultaneous pauses and, when it is full, the trapping thread simply
+steps over the breakpoint (`traps_skipped` counts these). The probe's own
+serving thread is never parked or held: a trap or panic on it goes straight
+to the default behaviour, since nobody could write its `ctl` files.
+
+**Panics**: `pub const panic = proc9.linux.panic;` in the root module
+(built with `std.debug.FullPanic`). The first panic records message and a
+stack capture (`captureCurrentStackTrace` with `first_address`), publishes
+them, and, if `hold_on_panic` and the probe is running, futex-waits until
+`/panic/ctl` says `continue`; then `std.debug.defaultPanic` runs (prints the
+trace and aborts). A nested or second panic goes straight to the default.
+
+Signal handlers are installed by `Probe.init` (breakpoints optional) and
+restored by `stop`.
+
+## Demo (`demo/main.zig`, binary `9proc-demo`)
+
+Keeps every path the existing tests read (`/build/*`, `/comptime/types/Qid/*`,
+`/comptime/decls`, `/runtime/fn/now|hostname|…`, `/runtime/ctl` with
+`add|echo|fib|sleep-ms`, `/runtime/pid|ppid|uptime|argv|cwd|env|clients`,
+`/scratch`), served by the library. Adds:
+
+* a worker thread running `workerLoop` that increments an exposed
+ `State { ticks: u64, phase: enum, last_job: Job }` (`/vars/state/...`);
+* `/runtime/ctl` commands `trap` (the worker executes `@breakpoint()` on its
+ next tick) and `panic` (the worker panics with a message);
+* `--stdio | --unix PATH | --tcp IP:PORT` as today, `--no-hold` to disable
+ panic holding.
+
+`main` passes `init.io` and an explicit allocator to the pieces that need one;
+the demo's Storage is a global.
+
+## Verification
+
+* Unit tests: core (in-memory client drives every op incl. providers and
+ snapshots), vars (render/set for every category), scratch, debug (capture
+ own thread and a helper thread; breakpoint pause/continue on a helper
+ thread; panic record path without holding).
+* `zig build 9proc-check-freestanding`: compiles `core.zig` + `vars.zig`
+ for `riscv32-freestanding-none` with a tiny freestanding root that
+ instantiates `Server(cfg)` with static Storage.
+* `zig build 9proc-test` (library and demo unit tests) and
+ `zig build 9proc-debug-test` (linux/debug.zig).
+* 9ns's `test/integration.sh` unchanged and passing; `test/debug.sh`
+ (`zig build 9proc-debug-itest`) through 9ns: read the worker's
+ stack (contains `workerLoop` and `demo/main.zig:`),
+ resolve a frame through `/addr`, dump `/hex` of the exposed state, read and
+ write `/vars/state/f/ticks/value`, trap → `/breakpoints` lists the worker,
+ its stack shows `workerLoop`, `continue` resumes (ticks keep increasing),
+ panic → `/panic/message`, `/panic/stack`, `continue` → server exits
+ non-zero.
+* Adversarial pass afterwards (`zig build 9proc-adv`: hostile client
+ against the server and the core, signal races, memory reads of unmapped
+ addresses, panic while a capture is in flight; `test/adversarial.sh` runs
+ the same suites by hand).
+
+## Known upstream issue (Zig 0.16 std.debug)
+
+`std.debug.SelfInfo` for ELF (`std/debug/SelfInfo/Elf.zig`, `findModule`)
+rebuilds its module list whenever it is asked about an address outside every
+known module. That frees each module's `Dwarf.Unwind` and CIE list but leaves
+`unwind_cache` entries pointing into the freed memory, so later unwinds read
+freed data: empty traces, "unwind info invalid", or segfaults once the arena
+reuses the block. `linux/debug.zig` records the executable's `PT_LOAD` ranges
+at init and refuses to hand std an address outside them (`knownCode`), which
+is why `/addr/<hex>` of a bogus address renders `?` instead of poisoning the
+process. Worth reporting upstream; the guard can go once std clears the cache.