summaryrefslogtreecommitdiff
path: root/introspect
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 /introspect
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 'introspect')
-rw-r--r--introspect/README.md130
-rw-r--r--introspect/build.zig169
-rw-r--r--introspect/demo/main.zig423
-rw-r--r--introspect/docs/LIBRARY.md287
-rw-r--r--introspect/src/core.zig2656
-rw-r--r--introspect/src/freestanding_check.zig71
-rw-r--r--introspect/src/linux/debug.zig1458
-rw-r--r--introspect/src/linux/probe.zig829
-rw-r--r--introspect/src/linux/provider.zig604
-rw-r--r--introspect/src/linux/runtime.zig90
-rw-r--r--introspect/src/root.zig24
-rw-r--r--introspect/src/scratch.zig689
-rw-r--r--introspect/src/vars.zig824
-rwxr-xr-xintrospect/test/adv_core_hostile.py1018
-rwxr-xr-xintrospect/test/adv_core_hostile.sh10
-rwxr-xr-xintrospect/test/adv_introspect_hostile.py999
-rwxr-xr-xintrospect/test/adv_introspect_hostile.sh10
-rwxr-xr-xintrospect/test/adv_linux_probe.py421
-rwxr-xr-xintrospect/test/adv_linux_probe.sh10
-rwxr-xr-xintrospect/test/adversarial.sh19
-rwxr-xr-xintrospect/test/debug.sh84
21 files changed, 0 insertions, 10825 deletions
diff --git a/introspect/README.md b/introspect/README.md
deleted file mode 100644
index ba7120b..0000000
--- a/introspect/README.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# introspect
-
-A 9P2000 debug/introspection server as a Zig 0.16 library, built on
-[cloud9](../): a debugger-shaped interface where the protocol is just files.
-Anything that can read a filesystem (a shell, an agent, an editor, `9p`,
-[9player](../9player)) can inspect a running program: build facts, comptime
-type layouts, live values, threads and their stacks, memory, breakpoints,
-panics.
-
-The core (`core`, `vars`) is freestanding: no allocator, no OS, no threads,
-caller-owned static `Storage`, fixed-capacity tables sized at comptime. It
-compiles for `riscv32-freestanding-none`. `scratch` (an in-memory read/write
-tree) takes an allocator; `linux` is the platform layer (listeners, a poll
-loop on one background thread, threads/stacks/registers, memory, breakpoints
-and panics via `std.debug`). [docs/LIBRARY.md](docs/LIBRARY.md) has the full
-contract.
-
-```
-introspect/
- build.zig fragment imported by cloud9's root build.zig (steps below)
- src/root.zig pub const core, vars, scratch, linux; Config, Server(cfg), Provider
- src/core.zig Tree/Server engine on cloud9.Server: fids, walks, dir reads, providers
- src/vars.zig comptime value renderers (@typeInfo) for /vars
- src/scratch.zig in-memory read/write tree provider (takes an Allocator)
- src/freestanding_check.zig root for the riscv32-freestanding-none compile check
- src/linux/probe.zig background thread + poll loop + unix/tcp/fd listeners
- src/linux/debug.zig threads, stacks, registers, addr→source, memory, breakpoints, panic
- src/linux/provider.zig the debug provider (/threads, /addr, /mem, /hex, /breakpoints, /panic)
- src/linux/runtime.zig /runtime generators (pid, uptime, argv, cwd, env, clients)
- demo/main.zig the `introspect` binary (below)
- test/ debug.sh, adv_introspect_hostile, adv_core_hostile, adv_linux_probe, adversarial.sh
- docs/LIBRARY.md design rules and the module contracts
-```
-
-## Using the library
-
-cloud9's `build.zig` exports two modules: `cloud9` (the protocol) and
-`introspect` (this library, which imports `cloud9` itself). A package that
-depends on cloud9 takes both from the one dependency:
-
-```zig
-const cloud9_dep = b.dependency("cloud9", .{ .target = target, .optimize = optimize });
-exe.root_module.addImport("cloud9", cloud9_dep.module("cloud9"));
-exe.root_module.addImport("introspect", cloud9_dep.module("introspect"));
-```
-
-Embedding the core is three static objects and a push/step/output loop, the
-same shape as cloud9's `Server`:
-
-```zig
-const introspect = @import("introspect");
-
-const State = struct { ticks: u32, phase: enum { idle, busy } };
-const cfg: introspect.Config = .{ .name = "fw", .types = &.{State}, .msize = 2048, .max_fids = 16 };
-const S = introspect.Server(cfg);
-
-var state: State = .{ .ticks = 0, .phase = .idle };
-var storage: S.Storage = undefined; // per connection: in/out frames + snapshot slots
-var shared: S.Shared = undefined; // once: providers and exposed variables
-
-pub fn main() void {
- shared = .init(&state);
- shared.expose("state", &state) catch unreachable; // /vars/state/{value,type,size,addr,raw,f/...}
- var conn: S.Conn = .init(&shared, &storage, cfg.msize);
- // transport loop: conn.push(bytes) ... while (try conn.step()) {} ... send conn.output(), conn.wrote(n)
-}
-```
-
-On Linux, `introspect.linux.Probe` runs that loop for you on one background
-thread over a Unix, TCP or inherited listener, and adds the debug provider;
-`pub const panic = std.debug.FullPanic(introspect.linux.debug.panicHook);`
-in the root module publishes panics under `/panic`. `demo/main.zig` shows
-every piece together.
-
-## The demo (`zig build introspect`, binary `introspect`)
-
-A single-binary 9P2000 server whose file tree is the binary itself: build-time
-facts, `comptime` reflection, live runtime state, a worker thread whose state
-is exposed under `/vars`, and the Linux debug layer.
-
-```
-/README
-/build/{zig_version,target,optimize,time,change} captured by introspect/build.zig (jj change id, UTC time)
-/comptime/types/<T>/{name,size,align,fields} @sizeOf/@alignOf/@typeInfo, generated at comptime
-/comptime/decls pub declarations of the server module
-/runtime/{pid,ppid,uptime,argv,cwd,env,clients}
-/runtime/fn/<name> reading calls a Zig function (hostname, now, random, uname, fib30)
-/runtime/ctl write "fib N" | "add A B" | "echo TEXT" | "sleep-ms N" | "trap" | "panic"
-/scratch/ in-memory read/write tree
-/vars/state/... the worker's State (readable and writable leaves)
-/threads/<tid>/{name,stat,stack,regs} /addr/<hex> /mem/{maps,<hex>} /hex/<hex>
-/breakpoints/<tid>/{stack,regs,ctl} /panic/{message,stack,ctl}
-```
-
-`/runtime/env` exposes the server's whole environment, so serve it on a Unix
-socket or loopback only.
-
-```sh
-zig-out/bin/introspect --unix /tmp/intro.sock & # or --tcp IP:PORT, --stdio, --no-hold
-zig-out/bin/9player --unix /tmp/intro.sock -- sh -c '
- cat $NINEPLAYER_MOUNT/build/zig_version; echo
- cat $NINEPLAYER_MOUNT/comptime/types/Qid/fields
- echo "fib 20" > $NINEPLAYER_MOUNT/runtime/ctl; cat $NINEPLAYER_MOUNT/runtime/ctl
- cat $NINEPLAYER_MOUNT/threads/*/stack'
-```
-
-## Building and testing
-
-introspect lives in the cloud9 repository as `cloud9/introspect/` and is
-wired into cloud9's `build.zig` through the fragment `introspect/build.zig`.
-Everything is run from the cloud9 root:
-
-```sh
-zig build # installs zig-out/bin/introspect with the other binaries
-zig build introspect # build and install only the demo
-zig build introspect-test # library unit tests (core, vars, scratch, linux) and the demo's
-zig build introspect-check-freestanding # compile the core for riscv32-freestanding-none
-zig build introspect-debug-test # src/linux/debug.zig unit tests
-zig build introspect-debug-itest # test/debug.sh: threads, stacks, breakpoints, panic through 9player
-zig build introspect-adv # hostile raw-9P clients against the demo and the core,
- # the Linux layer through a 9player mount (several minutes)
-zig build -Dintrospect=false # leave introspect out
-zig build introspect-check-freestanding -Dtarget=riscv32-freestanding-none -Dintrospect=true
-```
-
-`-Dintrospect` (default: on for Linux targets) enables the module and the
-freestanding check on any target; the demo and the Linux suites are added
-only when the target OS is Linux. The end-to-end suites also need 9player
-(`-D9player=true`, the Linux default), unprivileged user namespaces,
-`/dev/fuse` and Python 3, and skip themselves otherwise.
diff --git a/introspect/build.zig b/introspect/build.zig
deleted file mode 100644
index 6662151..0000000
--- a/introspect/build.zig
+++ /dev/null
@@ -1,169 +0,0 @@
-//! Build fragment for introspect: the 9P debug/introspection library (module
-//! `introspect`), its freestanding check, the `introspect` demo server and the
-//! test suites under introspect/test. It is `@import`ed by the root build.zig
-//! and called with the root builder, so every `b.path(...)` here is relative
-//! to the cloud9 root (hence the `introspect/` prefix), every option is
-//! defined by the root (no `standardTargetOptions` here) and every step it
-//! registers lands in the root's step list under the `introspect` prefix.
-//!
-//! Steps: introspect, introspect-test, introspect-check-freestanding,
-//! introspect-debug-test, introspect-debug-itest, introspect-adv.
-const std = @import("std");
-
-/// What the root passes in. The root owns target/optimize resolution and the
-/// cloud9 module; this fragment derives everything else from them.
-pub const Context = struct {
- target: std.Build.ResolvedTarget,
- optimize: std.builtin.OptimizeMode,
- /// The cloud9 library module for `target`. Its `root_source_file` is also
- /// used to instantiate cloud9 for the freestanding check target.
- cloud9: *std.Build.Module,
-};
-
-pub const Artifacts = struct {
- /// The `introspect` module, exported with `b.addModule` so dependents of
- /// cloud9 can `.module("introspect")`. Built for any target; the Linux
- /// layer is compiled in only when the target OS is Linux.
- module: *std.Build.Module,
- /// The demo 9P2000 server (binary `introspect`); null when the target is
- /// not Linux. 9player's integration tests use it as their server.
- demo: ?*std.Build.Step.Compile,
- /// `introspect-test`: library (and demo) unit tests.
- test_step: *std.Build.Step,
- /// `introspect-check-freestanding`: the core compiled for riscv32-freestanding-none.
- check_step: *std.Build.Step,
- /// `introspect-debug-test`: linux/debug.zig unit tests; null when not Linux.
- debug_test_step: ?*std.Build.Step,
- /// `introspect-adv`: the hostile-client suites are attached by `add`, the
- /// Linux-layer suite (which needs a 9player mount) by `addPlayerTests`.
- adv_step: *std.Build.Step,
-};
-
-pub fn add(b: *std.Build, ctx: Context) Artifacts {
- const target = ctx.target;
- const optimize = ctx.optimize;
- const is_linux = target.result.os.tag == .linux;
-
- // Build-time facts embedded into the demo (/build/*): jj change id, UTC
- // time, optimize mode, target triple. `jj` is pointed at the build root
- // (the cloud9 checkout) explicitly, so the result does not depend on the
- // directory `zig build` was invoked from.
- const build_options = b.addOptions();
- var code: u8 = 0;
- const repo = b.build_root.path orelse ".";
- const change_id = b.runAllowFail(&.{ "jj", "-R", repo, "log", "--no-graph", "-r", "@", "-T", "change_id.short()", "--ignore-working-copy" }, &code, .ignore) catch "unknown";
- build_options.addOption([]const u8, "change_id", std.mem.trim(u8, change_id, " \n\r\t"));
- const stamp = b.runAllowFail(&.{ "date", "-u", "+%Y-%m-%dT%H:%M:%SZ" }, &code, .ignore) catch "unknown";
- build_options.addOption([]const u8, "build_time", std.mem.trim(u8, stamp, " \n\r\t"));
- build_options.addOption([]const u8, "optimize", @tagName(optimize));
- build_options.addOption([]const u8, "target", target.result.zigTriple(b.allocator) catch "unknown");
-
- // The library: freestanding core + vars, scratch (allocator), Linux layer.
- // Exported under the name `introspect` for packages that depend on cloud9.
- const lib_mod = b.addModule("introspect", .{
- .root_source_file = b.path("introspect/src/root.zig"),
- .target = target,
- .optimize = optimize,
- .imports = &.{.{ .name = "cloud9", .module = ctx.cloud9 }},
- });
-
- const test_step = b.step("introspect-test", "Run the introspect library's unit tests (and the demo's)");
- test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = lib_mod })).step);
-
- // The core must compile without an OS (rule 1 of introspect/docs/LIBRARY.md).
- // cloud9 is re-instantiated for that target from the same root source.
- const fs_target = b.resolveTargetQuery(.{ .cpu_arch = .riscv32, .os_tag = .freestanding, .abi = .none });
- const fs_cloud9 = b.createModule(.{
- .root_source_file = ctx.cloud9.root_source_file.?,
- .target = fs_target,
- .optimize = optimize,
- });
- const fs_check = b.addObject(.{
- .name = "introspect-freestanding",
- .root_module = b.createModule(.{
- .root_source_file = b.path("introspect/src/freestanding_check.zig"),
- .target = fs_target,
- .optimize = optimize,
- .imports = &.{.{ .name = "cloud9", .module = fs_cloud9 }},
- }),
- });
- const check_step = b.step("introspect-check-freestanding", "Compile the introspect core for riscv32-freestanding-none");
- check_step.dependOn(&fs_check.step);
-
- // `introspect-adv` exists on every target so the step list is stable; its
- // suites are attached below (Linux only) and by addPlayerTests.
- const adv_step = b.step("introspect-adv", "Run introspect's adversarial suites (hostile clients, Linux layer; several minutes)");
-
- if (!is_linux) {
- adv_step.dependOn(&b.addFail("introspect-adv needs a Linux target (the demo server is Linux-only)").step);
- return .{ .module = lib_mod, .demo = null, .test_step = test_step, .check_step = check_step, .debug_test_step = null, .adv_step = adv_step };
- }
-
- // The demo: a 9P2000 server whose tree is the binary itself (build-time,
- // comptime and runtime facts, a worker thread, the Linux debug layer).
- const demo_mod = b.createModule(.{
- .root_source_file = b.path("introspect/demo/main.zig"),
- .target = target,
- .optimize = optimize,
- .imports = &.{
- .{ .name = "cloud9", .module = ctx.cloud9 },
- .{ .name = "build_options", .module = build_options.createModule() },
- .{ .name = "introspect", .module = lib_mod },
- },
- });
- const demo = b.addExecutable(.{ .name = "introspect", .root_module = demo_mod });
- const install_demo = b.addInstallArtifact(demo, .{});
- b.getInstallStep().dependOn(&install_demo.step);
- b.step("introspect", "Build and install only the introspect demo server").dependOn(&install_demo.step);
- test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = demo_mod })).step);
-
- // Linux debug facilities (threads, stacks, breakpoints, panic): self-contained unit tests.
- const debug_mod = b.createModule(.{
- .root_source_file = b.path("introspect/src/linux/debug.zig"),
- .target = target,
- .optimize = optimize,
- });
- const debug_test_step = b.step("introspect-debug-test", "Run the introspect/src/linux/debug.zig unit tests");
- debug_test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = debug_mod })).step);
-
- // Hostile raw-9P clients against the demo (framing, tags, floods; the core's
- // /vars tree, snapshots, fid table). `--fast` as in the umbrella script.
- inline for (.{ "adv_introspect_hostile", "adv_core_hostile" }) |suite| {
- const run = b.addSystemCommand(&.{"bash"});
- run.addFileArg(b.path("introspect/test/" ++ suite ++ ".sh"));
- run.addArtifactArg(demo);
- run.addArg("--fast");
- adv_step.dependOn(&run.step);
- }
-
- return .{ .module = lib_mod, .demo = demo, .test_step = test_step, .check_step = check_step, .debug_test_step = debug_test_step, .adv_step = adv_step };
-}
-
-/// The suites that drive the demo through a 9player mount: test/debug.sh
-/// (threads, stacks, breakpoints, panic end to end) and
-/// test/adv_linux_probe.sh (memory endpoints, signal machinery, poll loop).
-/// Called by the root after the 9player fragment; `player` is null when
-/// 9player is disabled, in which case the steps exist but fail with a notice.
-/// Returns the `introspect-debug-itest` step (null when the target is not Linux).
-pub fn addPlayerTests(b: *std.Build, arts: Artifacts, player: ?*std.Build.Step.Compile) ?*std.Build.Step {
- const demo = arts.demo orelse return null; // not Linux: nothing to drive
- const debug_itest = b.step("introspect-debug-itest", "Run introspect/test/debug.sh (threads, stacks, breakpoints, panic through 9player)");
- const exe = player orelse {
- const fail = b.addFail("introspect-debug-itest and the Linux-layer adversarial suite need 9player (build with -D9player=true)");
- debug_itest.dependOn(&fail.step);
- arts.adv_step.dependOn(&fail.step);
- return debug_itest;
- };
- const dbg = b.addSystemCommand(&.{"bash"});
- dbg.addFileArg(b.path("introspect/test/debug.sh"));
- dbg.addArtifactArg(exe);
- dbg.addArtifactArg(demo);
- debug_itest.dependOn(&dbg.step);
-
- const adv_linux = b.addSystemCommand(&.{"bash"});
- adv_linux.addFileArg(b.path("introspect/test/adv_linux_probe.sh"));
- adv_linux.addArtifactArg(exe);
- adv_linux.addArtifactArg(demo);
- arts.adv_step.dependOn(&adv_linux.step);
- return debug_itest;
-}
diff --git a/introspect/demo/main.zig b/introspect/demo/main.zig
deleted file mode 100644
index b1c2c57..0000000
--- a/introspect/demo/main.zig
+++ /dev/null
@@ -1,423 +0,0 @@
-//! introspect: the demo 9P2000 server, built on the introspect library.
-//!
-//! /README, /build/*, /comptime/{types,decls}, /runtime/{pid,ppid,uptime,argv,cwd,env,clients},
-//! /runtime/fn/{fib30,hostname,now,random,uname}, /runtime/ctl (echo|fib|sleep-ms|add|trap|panic),
-//! /scratch (in-memory tree), /vars/state (the worker's exposed state),
-//! /threads, /addr, /mem, /hex, /breakpoints, /panic (the Linux debug layer).
-//!
-//! A worker thread ("worker") runs `workerLoop`, incrementing `state.ticks`
-//! every ~10 ms; `trap` makes it execute `@breakpoint()` on its next tick and
-//! `panic` makes it panic from inside `workerLoop`. Panics go through the
-//! library's hook, so the message and stack are published under /panic and
-//! the process is held there until /panic/ctl says "continue" (`--no-hold`
-//! disables the hold).
-//!
-//! Static memory: every buffer is a global; the only heap user is /scratch
-//! (init.gpa, 512 MiB budget). With `max_clients` = 16 and msize = 1 MiB the
-//! per-client core Storage is 3 MiB + 8 x 64 KiB snapshots and the Conn's fid
-//! table ~1.88 MiB (32768 fids plus their hash index, needed for the 20000-fid
-//! adversarial test), so `probe_storage` is ~86 MiB of BSS; untouched pages
-//! cost nothing (an idle server has an RSS of ~7 MiB).
-const std = @import("std");
-const builtin = @import("builtin");
-const cloud9 = @import("cloud9");
-const build_options = @import("build_options");
-const introspect = @import("introspect");
-const linux = std.os.linux;
-const Writer = std.Io.Writer;
-const plinux = introspect.linux;
-const runtime = plinux.runtime;
-
-pub const panic = std.debug.FullPanic(plinux.debug.panicHook);
-
-/// Simultaneous 9P clients; further connections are closed (see probe.zig for
-/// the idle-eviction rule).
-pub const max_clients = 16;
-pub const max_msize: u32 = 1 << 20;
-
-pub const State = struct {
- ticks: u64,
- phase: enum { idle, working, trapped },
- last_job: struct { id: u32, cost: f32 },
-};
-
-const Build = struct {
- pub const zig_version: []const u8 = builtin.zig_version_string;
- pub const target: []const u8 = build_options.target;
- pub const optimize: []const u8 = build_options.optimize;
- pub const time: []const u8 = build_options.build_time;
- pub const change: []const u8 = build_options.change_id;
-};
-
-/// What every generator and the ctl handler see (`Shared.ctx`).
-const App = struct {
- info: runtime.Info,
- probe: *ProbeT,
- shared: *S.Shared,
-};
-
-/// /runtime/fn/<name>: reading the file calls the function.
-pub const Fns = struct {
- pub fn hostname(_: *anyopaque, w: *Writer) anyerror!void {
- var u: linux.utsname = undefined;
- if (linux.errno(linux.uname(&u)) != .SUCCESS) return error.Uname;
- try w.writeAll(std.mem.sliceTo(&u.nodename, 0));
- }
-
- pub fn now(_: *anyopaque, w: *Writer) anyerror!void {
- try w.print("{d}", .{runtime.realtimeSecs()});
- }
-
- pub fn random(_: *anyopaque, w: *Writer) anyerror!void {
- var b: [8]u8 = undefined;
- var got: usize = 0;
- while (got < b.len) {
- const rc = linux.getrandom(b[got..].ptr, b.len - got, 0);
- switch (linux.errno(rc)) {
- .SUCCESS => got += rc,
- .INTR => continue,
- else => return error.Random,
- }
- }
- try w.print("{x:0>16}", .{std.mem.readInt(u64, &b, .little)});
- }
-
- pub fn uname(_: *anyopaque, w: *Writer) anyerror!void {
- var u: linux.utsname = undefined;
- if (linux.errno(linux.uname(&u)) != .SUCCESS) return error.Uname;
- try w.writeAll(std.mem.sliceTo(&u.release, 0));
- }
-
- pub fn fib30(_: *anyopaque, w: *Writer) anyerror!void {
- try w.print("{d}", .{fib(30)});
- }
-};
-
-const cfg: introspect.Config = .{
- .name = "introspect",
- .build = Build,
- .types = &.{ cloud9.Qid, cloud9.Stat, cloud9.Msg, introspect.core.Node, linux.Statx },
- .decls_of = @This(),
- .fns = Fns,
- .runtime = runtime.Fns(App, "info"),
- .ctl = &ctl,
- .ctl_dir = .runtime,
- .ctl_bytes = 64 * 1024,
- .msize = max_msize,
- .max_fids = 32768,
- .max_providers = 8,
- .snapshot_slots = 8,
- .snapshot_bytes = 64 * 1024,
-};
-const S = introspect.Server(cfg);
-const ProbeT = plinux.Probe(S);
-const ProbeStorage = ProbeT.Storage(max_clients);
-
-// -- static state -------------------------------------------------------------
-
-pub var state: State = .{ .ticks = 0, .phase = .idle, .last_job = .{ .id = 0, .cost = 0 } };
-var trap_requested: std.atomic.Value(bool) = .init(false);
-var panic_requested: std.atomic.Value(bool) = .init(false);
-
-/// Zero-filled static memory for `T`. (An `= undefined` global is emitted as
-/// 0xAA-filled .data in Debug builds, which would make the binary 120 MiB;
-/// zeros go to .bss and cost nothing until touched.)
-fn Bss(comptime T: type) type {
- return struct {
- bytes: [@sizeOf(T)]u8 align(@alignOf(T)) = @splat(0),
- fn get(b: *@This()) *T {
- return @ptrCast(&b.bytes);
- }
- };
-}
-var app_mem: Bss(App) = .{};
-var shared_mem: Bss(S.Shared) = .{};
-var probe_storage_mem: Bss(ProbeStorage) = .{};
-var probe_mem: Bss(ProbeT) = .{};
-var scratch_mem: Bss(introspect.Scratch) = .{};
-
-/// Sizes of the static pieces, for the report and `--help`.
-pub const static_bytes = @sizeOf(ProbeStorage) + @sizeOf(S.Shared) + @sizeOf(ProbeT);
-
-// -- the worker ---------------------------------------------------------------
-
-/// Ticks every ~10 ms; honours `trap` and `panic` requests from /runtime/ctl.
-pub noinline fn workerLoop() void {
- plinux.setThreadName("worker");
- var job: u32 = 0;
- while (true) {
- napMs(10);
- state.ticks +%= 1;
- if (panic_requested.swap(false, .acq_rel)) {
- state.phase = .working;
- @panic("demo panic requested over 9p");
- }
- if (trap_requested.swap(false, .acq_rel)) {
- state.phase = .trapped;
- @breakpoint();
- state.phase = .idle;
- }
- if (state.ticks % 100 == 0) {
- job +%= 1;
- state.phase = .working;
- state.last_job = .{ .id = job, .cost = @as(f32, @floatFromInt(job % 7)) * 0.5 };
- state.phase = .idle;
- }
- }
-}
-
-/// The worker's sleep, issued as a raw syscall from this file so that the
-/// thread's innermost frame (the first line of /threads/<tid>/stack, which
-/// introspect/test/debug.sh resolves through /addr) is in demo/main.zig rather than in std.
-/// EINTR (a capture signal) just ends the nap early.
-inline fn napMs(ms: u64) void {
- var req: linux.timespec = .{ .sec = @intCast(ms / 1000), .nsec = @intCast((ms % 1000) * 1_000_000) };
- switch (builtin.cpu.arch) {
- .x86_64 => _ = asm volatile ("syscall"
- : [ret] "={rax}" (-> usize),
- : [number] "{rax}" (@intFromEnum(linux.SYS.nanosleep)),
- [arg1] "{rdi}" (@intFromPtr(&req)),
- [arg2] "{rsi}" (@as(usize, 0)),
- : .{ .rcx = true, .r11 = true, .memory = true }),
- .aarch64 => _ = asm volatile ("svc #0"
- : [ret] "={x0}" (-> usize),
- : [number] "{x8}" (@intFromEnum(linux.SYS.nanosleep)),
- [arg1] "{x0}" (@intFromPtr(&req)),
- [arg2] "{x1}" (@as(usize, 0)),
- : .{ .memory = true }),
- else => plinux.sleepMs(ms),
- }
-}
-
-// -- /runtime/ctl -------------------------------------------------------------
-
-/// The /runtime/ctl handler. The core stages the output and commits it only
-/// on success, so a failed command leaves the previous result in place
-/// (test/adv_introspect_hostile.py checks that).
-fn ctl(ctx: *anyopaque, cmd: []const u8, out: *Writer) anyerror!void {
- const a: *App = @ptrCast(@alignCast(ctx));
- const line = std.mem.trim(u8, cmd, " \t\r\n\x00");
- var it = std.mem.tokenizeScalar(u8, line, ' ');
- const verb = it.next() orelse return error.BadCommand;
- if (std.mem.eql(u8, verb, "echo")) {
- try out.writeAll(std.mem.trimStart(u8, line[verb.len..], " \t"));
- } else if (std.mem.eql(u8, verb, "fib")) {
- const n = std.fmt.parseInt(u32, it.next() orelse return error.BadCommand, 10) catch return error.BadCommand;
- if (n > 93) return error.BadCommand; // fib(94) overflows u64
- try out.print("{d}", .{fib(n)});
- } else if (std.mem.eql(u8, verb, "sleep-ms")) {
- const n = std.fmt.parseInt(u64, it.next() orelse return error.BadCommand, 10) catch return error.BadCommand;
- const ms = @min(n, 10_000);
- a.probe.sleepServing(ms);
- try out.print("slept {d} ms", .{ms});
- } else if (std.mem.eql(u8, verb, "add")) {
- const x = std.fmt.parseInt(i64, it.next() orelse return error.BadCommand, 10) catch return error.BadCommand;
- const y = std.fmt.parseInt(i64, it.next() orelse return error.BadCommand, 10) catch return error.BadCommand;
- try out.print("{d}", .{x +% y});
- } else if (std.mem.eql(u8, verb, "trap")) {
- trap_requested.store(true, .release);
- try out.writeAll("trap armed: the worker stops in @breakpoint() on its next tick");
- } else if (std.mem.eql(u8, verb, "panic")) {
- panic_requested.store(true, .release);
- try out.writeAll("panic armed: the worker panics on its next tick");
- } else return error.BadCommand;
-}
-
-/// fib(n) for n <= 93 (fib(93) is the largest that fits u64).
-fn fib(n: u32) u64 {
- std.debug.assert(n <= 93);
- if (n == 0) return 0;
- var a: u64 = 0;
- var b: u64 = 1;
- for (1..n) |_| {
- const c = a + b;
- a = b;
- b = c;
- }
- return b;
-}
-
-// -- main ---------------------------------------------------------------------
-
-const usage_text =
- \\usage: introspect [--unix PATH | --tcp IP:PORT | --stdio] [--no-hold]
- \\
- \\A demo 9P2000 file server exposing this binary's build-time, comptime and
- \\runtime facts, plus a debugger-shaped view of the process (threads, stacks,
- \\memory, breakpoints, panics). Default is --stdio (9P on fd 0/1).
- \\--no-hold lets a panic abort at once instead of waiting for /panic/ctl.
- \\
-;
-
-pub fn main(init: std.process.Init) !void {
- run(init) catch |e| switch (e) {
- // Already reported on stderr; no stack trace wanted.
- error.Usage, error.Syscall => std.process.exit(1),
- else => return e,
- };
-}
-
-fn run(init: std.process.Init) !void {
- // Transparent huge pages would back the first touched page of every
- // client buffer with 2 MiB. Best effort: ignore failure.
- _ = linux.prctl(@intFromEnum(linux.PR.SET_THP_DISABLE), 1, 0, 0, 0);
- const arena = init.arena.allocator();
- const args = try init.minimal.args.toSlice(arena);
-
- const Mode = enum { stdio, unix, tcp };
- var mode: Mode = .stdio;
- var address: []const u8 = "";
- var hold = true;
- var i: usize = 1;
- while (i < args.len) : (i += 1) {
- const a = args[i];
- if (std.mem.eql(u8, a, "--stdio")) {
- mode = .stdio;
- } else if (std.mem.eql(u8, a, "--unix") or std.mem.eql(u8, a, "--tcp")) {
- i += 1;
- if (i >= args.len) {
- std.debug.print("introspect: {s} needs an argument\n{s}", .{ a, usage_text });
- return error.Usage;
- }
- mode = if (a[2] == 'u') .unix else .tcp;
- address = args[i];
- } else if (std.mem.eql(u8, a, "--no-hold")) {
- hold = false;
- } else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) {
- std.debug.print("{s}\nstatic memory: {d} bytes ({d} clients, msize {d})\n", .{ usage_text, static_bytes, max_clients, max_msize });
- return;
- } else {
- std.debug.print("introspect: unknown argument {s}\n{s}", .{ a, usage_text });
- return error.Usage;
- }
- }
-
- // argv, env and cwd are gathered once, into the arena.
- var argv_text: std.ArrayList(u8) = .empty;
- for (args) |a| {
- try argv_text.appendSlice(arena, a);
- try argv_text.append(arena, '\n');
- }
- var env_text: std.ArrayList(u8) = .empty;
- for (init.minimal.environ.block.view().slice) |entry| {
- try env_text.appendSlice(arena, std.mem.span(entry));
- try env_text.append(arena, '\n');
- }
- var cwd_buf: [4096]u8 = undefined;
- const cwd_rc = linux.getcwd(&cwd_buf, cwd_buf.len);
- const cwd_text: []const u8 = if (linux.errno(cwd_rc) == .SUCCESS)
- try arena.dupe(u8, std.mem.sliceTo(cwd_buf[0..cwd_rc], 0))
- else
- "";
-
- const app = app_mem.get();
- const shared = shared_mem.get();
- const probe = probe_mem.get();
- const scratch = scratch_mem.get();
- app.* = .{
- .info = .{ .argv = argv_text.items, .env = env_text.items, .cwd = cwd_text, .start_mono = runtime.monotonicSecs() },
- .probe = probe,
- .shared = shared,
- };
- shared.* = .init(app);
- try shared.expose("state", &state);
- scratch.* = try introspect.Scratch.init(init.gpa, 512 << 20);
- // Freed on the way out so a Debug build's allocator does not report the
- // tree as leaked (with a stack trace on stderr) after a clean --stdio EOF.
- defer scratch.deinit();
- scratch.max_file = 64 << 20;
- scratch.now = &runtime.realtimeSecs;
- try shared.addProvider(scratch.provider("scratch"));
-
- const listen: plinux.Listen = switch (mode) {
- .stdio => .{ .client = .{ .in = 0, .out = 1 } },
- .unix => .{ .unix = address },
- .tcp => .{ .tcp = address },
- };
- probe.init(shared, probe_storage_mem.get(), .{ .io = init.io, .listen = listen, .msize = max_msize, .hold_on_panic = hold }) catch |e| {
- switch (e) {
- error.PathTooLong => std.debug.print("introspect: unix socket path too long\n", .{}),
- error.BadAddress => std.debug.print("introspect: --tcp wants an IPv4 literal a.b.c.d:port\n", .{}),
- error.Syscall => std.debug.print("introspect: {t} ({t})\n", .{ e, probe.last_errno }),
- else => std.debug.print("introspect: {t}\n", .{e}),
- }
- return if (e == error.PathTooLong or e == error.BadAddress) error.Usage else error.Syscall;
- };
- // Failure past this point (thread spawn) still closes the listener and
- // restores the signal dispositions.
- errdefer probe.stop();
- app.info.clients = probe.clientCounter();
-
- const worker = std.Thread.spawn(.{}, workerLoop, .{}) catch |e| {
- std.debug.print("introspect: worker thread: {t}\n", .{e});
- return error.Syscall;
- };
- worker.detach();
-
- switch (mode) {
- .unix => std.debug.print("introspect: listening on unix!{s}\n", .{address}),
- .tcp => std.debug.print("introspect: listening on tcp!{s}\n", .{address}),
- .stdio => {},
- }
- probe.start() catch |e| {
- std.debug.print("introspect: thread spawn failed: {t}\n", .{e});
- return error.Syscall;
- };
- // SIGTERM/SIGINT end the loop cleanly: the socket file is unlinked, the
- // signal dispositions restored and the scratch tree freed.
- // A disposition of SIG_IGN inherited from the parent is left alone (Unix
- // convention): 9player runs a --spawn server with SIGINT ignored so that
- // Ctrl-C on the terminal reaches only the program, not its file server.
- const term: linux.Sigaction = .{ .handler = .{ .handler = onTerm }, .mask = linux.sigemptyset(), .flags = 0 };
- for ([_]linux.SIG{ .TERM, .INT }) |sig| {
- var old: linux.Sigaction = undefined;
- _ = linux.sigaction(sig, null, &old);
- if (old.handler.handler != linux.SIG.IGN) _ = linux.sigaction(sig, &term, null);
- }
- probe.wait();
- probe.stop();
-}
-
-/// Async-signal-safe: an atomic store and one eventfd write.
-fn onTerm(_: linux.SIG) callconv(.c) void {
- probe_mem.get().requestStop();
-}
-
-// -- tests --------------------------------------------------------------------
-
-test "ctl commands" {
- const probe = probe_mem.get();
- const shared = shared_mem.get();
- var a: App = .{ .info = .{}, .probe = probe, .shared = shared };
- probe.thread_tid = .init(0);
- probe.serving = .initEmpty();
- probe.nested = 0;
- var buf: [128]u8 = undefined;
- var w: Writer = .fixed(&buf);
- try ctl(&a, "add 2 3\n", &w);
- try std.testing.expectEqualStrings("5", w.buffered());
- try std.testing.expectError(error.BadCommand, ctl(&a, "nope", &w));
- w = .fixed(&buf);
- try ctl(&a, "fib 93", &w);
- try std.testing.expectEqualStrings("12200160415121876738", w.buffered());
- w = .fixed(&buf);
- try std.testing.expectError(error.BadCommand, ctl(&a, "fib 94", &w));
- try std.testing.expectError(error.BadCommand, ctl(&a, "frobnicate", &w));
- w = .fixed(&buf);
- try ctl(&a, "echo hi there ", &w);
- try std.testing.expectEqualStrings("hi there", w.buffered());
- w = .fixed(&buf);
- try ctl(&a, "sleep-ms 1", &w);
- try std.testing.expectEqualStrings("slept 1 ms", w.buffered());
- w = .fixed(&buf);
- try ctl(&a, "trap", &w);
- try std.testing.expect(trap_requested.swap(false, .acq_rel));
- w = .fixed(&buf);
- try ctl(&a, "panic", &w);
- try std.testing.expect(panic_requested.swap(false, .acq_rel));
-}
-
-test "static footprint is what the file comment says" {
- try std.testing.expect(@sizeOf(ProbeStorage) > 16 * (3 << 20));
- try std.testing.expect(@sizeOf(ProbeStorage) < 100 << 20);
-}
diff --git a/introspect/docs/LIBRARY.md b/introspect/docs/LIBRARY.md
deleted file mode 100644
index 22840dc..0000000
--- a/introspect/docs/LIBRARY.md
+++ /dev/null
@@ -1,287 +0,0 @@
-# introspect: a 9P debug/introspection server as a library
-
-The demo server that 9player'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`,
-9player) 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 introspect-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** (`introspect.linux`) and are the
- only places that touch sockets, threads, signals, `/proc` or `std.debug`.
-
-```
- introspect/src/root.zig pub const core, vars, scratch, linux (linux only), Server(cfg)
- introspect/src/core.zig Tree/Server engine on cloud9.Server: fids, walks, dir reads, providers
- introspect/src/vars.zig comptime value renderers (@typeInfo) for /vars
- introspect/src/scratch.zig in-memory read/write tree provider (takes an Allocator)
- introspect/src/linux/probe.zig background thread + poll loop + unix/tcp/fd listeners
- introspect/src/linux/debug.zig threads, stacks, registers, addr→source, memory, breakpoints, panic
- introspect/demo/main.zig the `introspect` binary: embeds everything, worker thread, exposed vars
-
-(paths from the cloud9 root; the library is the module `introspect` that
-cloud9's `build.zig` exports next to `cloud9`, wired by `introspect/build.zig`)
-```
-
-## Core (`core.zig`)
-
-```zig
-pub const Config = struct {
- name: []const u8 = "introspect", // /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 9player'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 = introspect.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 `introspect`)
-
-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 introspect-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 introspect-test` (library and demo unit tests) and
- `zig build introspect-debug-test` (linux/debug.zig).
-* 9player's `test/integration.sh` unchanged and passing; `test/debug.sh`
- (`zig build introspect-debug-itest`) through 9player: 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 introspect-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.
diff --git a/introspect/src/core.zig b/introspect/src/core.zig
deleted file mode 100644
index 2707af2..0000000
--- a/introspect/src/core.zig
+++ /dev/null
@@ -1,2656 +0,0 @@
-//! The freestanding 9P2000 introspection engine: a static tree generated at
-//! comptime from a `Config` (README, /build, /comptime, /runtime/fn, /ctl,
-//! /vars) plus runtime `Provider`s mounted at the top level, served over a
-//! `cloud9.Server` connection. No allocator, no OS, no threads: every buffer is
-//! caller-owned (`Storage`, `Shared`, `Conn`), every table is sized at comptime.
-//! See docs/LIBRARY.md.
-const std = @import("std");
-const builtin = @import("builtin");
-const cloud9 = @import("cloud9");
-const vars = @import("vars.zig");
-const Writer = std.Io.Writer;
-
-/// Longest file name accepted in a create or rename.
-pub const max_name: usize = 255;
-
-/// A dynamic-file generator (`Config.fns`, `Config.runtime`): writes the file's
-/// content into `w` at open time (and again at each read from offset 0).
-pub const Gen = *const fn (ctx: *anyopaque, w: *Writer) anyerror!void;
-/// The /ctl command handler: `cmd` is the written text, `out` receives the
-/// result that later reads of /ctl return.
-pub const Ctl = *const fn (ctx: *anyopaque, cmd: []const u8, out: *Writer) anyerror!void;
-
-pub const Config = struct {
- /// Appears in /README and as uid/gid/muid of every Stat.
- name: []const u8 = "introspect",
- /// A type whose pub decls `zig_version`, `target`, `optimize`, `time`
- /// and `change` (all `[]const u8`) become the files of /build. `null`
- /// omits /build.
- build: ?type = null,
- /// /comptime/types/<short name>/{name,size,align,fields}.
- types: []const type = &.{},
- /// /comptime/decls lists this type's pub decls (empty when null).
- decls_of: ?type = null,
- /// /runtime/fn/<name>: every pub decl is a `fn (ctx: *anyopaque, w: *std.Io.Writer) anyerror!void`.
- fns: type = struct {},
- /// /runtime/<name>: same signature as `fns`, one level up (pid, uptime, ...).
- runtime: type = struct {},
- /// The /ctl handler; `null` omits /ctl.
- ctl: ?Ctl = null,
- /// Where /ctl lives: the root or /runtime/ctl.
- ctl_dir: enum { root, runtime } = .root,
- /// Capacity of the ctl result (in `Shared`).
- ctl_bytes: u32 = 4096,
- /// Largest negotiable msize; sizes `Storage.in/out/data`.
- msize: u32 = 8192,
- 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,
-};
-
-/// Attributes of a provider node, filled by `VTable.stat` and `VTable.list`.
-pub const NodeStat = struct {
- /// Permission bits plus `cloud9.dmdir`/`dmappend`/`dmexcl`.
- mode: u32,
- length: u64 = 0,
- atime: u32 = 0,
- mtime: u32 = 0,
- /// Becomes the qid version.
- version: u32 = 0,
- /// The entry name (`list`) or the node's own name (`stat`; ignored for the
- /// provider root, whose name is the mount name). Must stay valid until the
- /// provider's next call.
- name: []const u8 = "",
- /// Filled by `list`: the entry's handle. Not retained by the core.
- handle: Provider.Handle = 0,
- /// A stable identity for the qid path (low 56 bits), for providers whose
- /// handles are not stable across the node's life (e.g. memory addresses
- /// that an allocator may reuse). 0 means "the handle is the path".
- path: u64 = 0,
-
- pub fn isDir(s: NodeStat) bool {
- return s.mode & cloud9.dmdir != 0;
- }
-};
-
-/// A runtime subtree mounted at a top-level name.
-///
-/// Handle lifetime: every handle returned by `walk` or `create` is released by
-/// the core with exactly one `clunk` (after `close` if the fid was open). The
-/// root handle 0 is never obtained through `walk`, so providers must treat
-/// `clunk(0)` as a no-op. `walk` must accept "." on any node, file or directory
-/// (a fresh reference to the same node; the core clones fids with it), and ".."
-/// on directories (except at the root, which the core resolves itself).
-pub const Provider = struct {
- name: []const u8,
- ctx: *anyopaque,
- vtable: *const VTable,
-
- /// Provider-defined node id; 0 = provider root.
- pub const Handle = u64;
- pub const root: Handle = 0;
-
- pub const Error = error{ NotFound, Exists, Perm, NotDir, IsDir, NotEmpty, BadOffset, NoSpace, Io, Unsupported, Excl };
-
- pub const VTable = struct {
- walk: *const fn (ctx: *anyopaque, parent: Handle, name: []const u8) Error!Handle,
- stat: *const fn (ctx: *anyopaque, h: Handle, out: *NodeStat) Error!void,
- /// The `index`-th entry of `dir`; false when done.
- list: *const fn (ctx: *anyopaque, dir: Handle, index: usize, out: *NodeStat) Error!bool,
- open: *const fn (ctx: *anyopaque, h: Handle, mode: u8) Error!void,
- read: *const fn (ctx: *anyopaque, h: Handle, offset: u64, buf: []u8) Error!usize,
- write: *const fn (ctx: *anyopaque, h: Handle, offset: u64, data: []const u8) Error!usize,
- /// Returns the new node, already open with `mode`.
- create: ?*const fn (ctx: *anyopaque, dir: Handle, name: []const u8, perm: u32, mode: u8) Error!Handle = null,
- remove: ?*const fn (ctx: *anyopaque, h: Handle) Error!void = null,
- /// Only name, length, mode and mtime can differ from the current stat
- /// (the core has already checked the immutable fields and the name).
- wstat: ?*const fn (ctx: *anyopaque, h: Handle, st: *const cloud9.Stat) Error!void = null,
- /// An open fid on `h` was released (before `clunk`).
- close: ?*const fn (ctx: *anyopaque, h: Handle) void = null,
- /// A fid holding `h` was released (also on hangup and Tversion).
- clunk: *const fn (ctx: *anyopaque, h: Handle) void,
- };
-};
-
-/// The Plan 9 error string for any error the engine or a provider can raise.
-pub fn ename(err: anyerror) []const u8 {
- return switch (err) {
- error.NotFound, error.NoFile => "file does not exist",
- error.Perm => "permission denied",
- error.Exists => "file already exists",
- error.NotEmpty => "directory not empty",
- error.NotDir => "not a directory",
- error.IsDir => "is a directory",
- error.BadOffset => "bad offset",
- error.NoSpace => "no space left on device",
- error.Io => "i/o error",
- error.Unsupported => "not supported",
- error.Excl => "exclusive use file already open",
- error.FidInUse => "fid in use",
- error.UnknownFid => "unknown fid",
- error.NotOpen => "file not open",
- error.AlreadyOpen => "file already open",
- error.AuthNotRequired => "authentication not required",
- error.BadCommand => "bad command",
- error.BadName => "bad file name",
- error.Invalid, error.BadValue => "bad value",
- error.TooManyFids => "too many fids",
- error.NoSnapshot => "too many open dynamic files",
- error.WriteFailed => "no space in buffer",
- error.ReplyTooLarge => "reply too large for msize",
- error.OutOfMemory => "out of memory",
- else => "i/o error",
- };
-}
-
-/// A "don't care" Twstat: every field left as it is.
-pub const stat_dontcare: cloud9.Stat = .{
- .type = 0xFFFF,
- .dev = 0xFFFF_FFFF,
- .qid = .{ .type = 0xFF, .version = 0xFFFF_FFFF, .path = 0xFFFF_FFFF_FFFF_FFFF },
- .mode = 0xFFFF_FFFF,
- .atime = 0xFFFF_FFFF,
- .mtime = 0xFFFF_FFFF,
- .length = 0xFFFF_FFFF_FFFF_FFFF,
- .name = "",
- .uid = "",
- .gid = "",
- .muid = "",
-};
-
-pub fn validName(name: []const u8) error{BadName}!void {
- if (name.len == 0 or name.len > max_name) return error.BadName;
- if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.BadName;
- if (std.mem.indexOfAny(u8, name, "/\x00") != null) return error.BadName;
-}
-
-/// "YYYY-MM-DDTHH:MM:SSZ" as unix seconds, or null.
-pub fn parseIso8601(s: []const u8) ?u32 {
- if (s.len != 20 or s[4] != '-' or s[7] != '-' or s[10] != 'T' or s[13] != ':' or s[16] != ':' or s[19] != 'Z') return null;
- const y = std.fmt.parseInt(i64, s[0..4], 10) catch return null;
- const mo = std.fmt.parseInt(i64, s[5..7], 10) catch return null;
- const d = std.fmt.parseInt(i64, s[8..10], 10) catch return null;
- const h = std.fmt.parseInt(i64, s[11..13], 10) catch return null;
- const mi = std.fmt.parseInt(i64, s[14..16], 10) catch return null;
- const sec = std.fmt.parseInt(i64, s[17..19], 10) catch return null;
- if (mo < 1 or mo > 12 or d < 1 or d > 31 or h > 23 or mi > 59 or sec > 60) return null;
- // Howard Hinnant's days_from_civil.
- const yy = if (mo <= 2) y - 1 else y;
- const era = @divFloor(yy, 400);
- const yoe = yy - era * 400;
- const mp = if (mo > 2) mo - 3 else mo + 9;
- const doy = @divFloor(153 * mp + 2, 5) + d - 1;
- const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy;
- const days = era * 146097 + doe - 719468;
- const total = days * 86400 + h * 3600 + mi * 60 + sec;
- if (total < 0 or total > std.math.maxInt(u32)) return null;
- return @intCast(total);
-}
-
-/// The last component of @typeName(T): "wire.Qid" -> "Qid".
-pub fn shortTypeName(comptime T: type) []const u8 {
- const full = @typeName(T);
- const dot = std.mem.lastIndexOfScalar(u8, full, '.') orelse return full;
- return full[dot + 1 ..];
-}
-
-/// The /comptime/types/<T>/fields text: "name: type @offset" per line.
-pub fn fieldsText(comptime T: type) []const u8 {
- comptime {
- @setEvalBranchQuota(200_000);
- var s: []const u8 = "";
- switch (@typeInfo(T)) {
- .@"struct" => |info| for (info.fields) |f| {
- if (info.layout == .@"packed") {
- s = s ++ std.fmt.comptimePrint("{s}: {s} @{d}b\n", .{ f.name, @typeName(f.type), @bitOffsetOf(T, f.name) });
- } else if (f.is_comptime) {
- s = s ++ std.fmt.comptimePrint("{s}: {s} (comptime)\n", .{ f.name, @typeName(f.type) });
- } else {
- s = s ++ std.fmt.comptimePrint("{s}: {s} @{d}\n", .{ f.name, @typeName(f.type), @offsetOf(T, f.name) });
- }
- },
- .@"union" => |info| for (info.fields) |f| {
- s = s ++ f.name ++ ": " ++ @typeName(f.type) ++ "\n";
- },
- .@"enum" => |info| for (info.fields) |f| {
- s = s ++ std.fmt.comptimePrint("{s} = {d}\n", .{ f.name, f.value });
- },
- else => s = @typeName(T) ++ "\n",
- }
- return s;
- }
-}
-
-fn declsText(comptime T: type) []const u8 {
- comptime {
- @setEvalBranchQuota(20_000);
- const decls = switch (@typeInfo(T)) {
- inline .@"struct", .@"union", .@"enum", .@"opaque" => |info| info.decls,
- else => &[_]std.builtin.Type.Declaration{},
- };
- var s: []const u8 = "";
- for (decls) |d| s = s ++ d.name ++ "\n";
- return s;
- }
-}
-
-/// Wraps `Fns.<name>` in a function of exactly the `Gen` signature.
-fn genFor(comptime Fns: type, comptime name: []const u8) Gen {
- return &struct {
- fn g(ctx: *anyopaque, w: *Writer) anyerror!void {
- return @field(Fns, name)(ctx, w);
- }
- }.g;
-}
-
-/// A node of the static tree, described at comptime.
-pub const Node = struct {
- name: []const u8,
- kind: Kind,
- children: []const Node = &.{},
- content: []const u8 = "",
- gen: ?Gen = null,
-
- pub const Kind = enum(u8) { dir, static, dynamic, ctl, vars };
-
- fn isDir(n: Node) bool {
- return n.kind == .dir or n.kind == .vars;
- }
-};
-
-fn genNodes(comptime Fns: type) [@typeInfo(Fns).@"struct".decls.len]Node {
- const decls = @typeInfo(Fns).@"struct".decls;
- var arr: [decls.len]Node = undefined;
- for (decls, 0..) |d, i| arr[i] = .{ .name = d.name, .kind = .dynamic, .gen = genFor(Fns, d.name) };
- return arr;
-}
-
-pub fn Server(comptime cfg: Config) type {
- return struct {
- const Self = @This();
-
- // -- the static tree ------------------------------------------------
-
- pub const readme_text = std.fmt.comptimePrint(
- \\{s}: a 9P2000 introspection server (built on cloud9).
- \\
- \\/build facts baked in at build time (zig version, target, optimize, time, change id)
- \\/comptime facts computed by the Zig compiler: type layouts under types/<T>/, pub decls
- \\/runtime live facts; fn/<name> calls a Zig function on every read
- \\/ctl write a command, read the result
- \\/vars exposed variables: <name>/{{value,type,size,addr,raw,f/<field>/...}}
- \\
- \\Other top-level directories are providers mounted at runtime.
- \\
- , .{cfg.name});
-
- fn typeDir(comptime T: type) Node {
- return .{ .name = shortTypeName(T), .kind = .dir, .children = &.{
- .{ .name = "name", .kind = .static, .content = @typeName(T) },
- .{ .name = "size", .kind = .static, .content = std.fmt.comptimePrint("{d}", .{@sizeOf(T)}) },
- .{ .name = "align", .kind = .static, .content = std.fmt.comptimePrint("{d}", .{@alignOf(T)}) },
- .{ .name = "fields", .kind = .static, .content = fieldsText(T) },
- } };
- }
-
- const type_dirs: [cfg.types.len]Node = blk: {
- @setEvalBranchQuota(200_000);
- var arr: [cfg.types.len]Node = undefined;
- for (cfg.types, 0..) |T, i| arr[i] = typeDir(T);
- for (arr, 0..) |a, i| for (arr[i + 1 ..]) |b| {
- if (std.mem.eql(u8, a.name, b.name)) @compileError("duplicate short type name " ++ a.name);
- };
- break :blk arr;
- };
-
- const decls_text: []const u8 = if (cfg.decls_of) |T| declsText(T) else "";
- const fn_nodes = genNodes(cfg.fns);
- const runtime_nodes = genNodes(cfg.runtime);
- const ctl_node: Node = .{ .name = "ctl", .kind = .ctl };
-
- const build_nodes: []const Node = if (cfg.build) |B| &[_]Node{
- .{ .name = "zig_version", .kind = .static, .content = B.zig_version },
- .{ .name = "target", .kind = .static, .content = B.target },
- .{ .name = "optimize", .kind = .static, .content = B.optimize },
- .{ .name = "time", .kind = .static, .content = B.time },
- .{ .name = "change", .kind = .static, .content = B.change },
- } else &.{};
-
- /// Build time as unix seconds (for static atime/mtime), or 0.
- pub const build_secs: u32 = if (cfg.build) |B| (parseIso8601(B.time) orelse 0) else 0;
-
- const runtime_children: []const Node = blk: {
- var list: []const Node = &runtime_nodes;
- list = list ++ &[_]Node{.{ .name = "fn", .kind = .dir, .children = &fn_nodes }};
- if (cfg.ctl != null and cfg.ctl_dir == .runtime) list = list ++ &[_]Node{ctl_node};
- break :blk list;
- };
-
- const root_children: []const Node = blk: {
- var list: []const Node = &[_]Node{.{ .name = "README", .kind = .static, .content = readme_text }};
- if (cfg.build != null) list = list ++ &[_]Node{.{ .name = "build", .kind = .dir, .children = build_nodes }};
- list = list ++ &[_]Node{
- .{ .name = "comptime", .kind = .dir, .children = &.{
- .{ .name = "types", .kind = .dir, .children = &type_dirs },
- .{ .name = "decls", .kind = .static, .content = decls_text },
- } },
- .{ .name = "runtime", .kind = .dir, .children = runtime_children },
- };
- if (cfg.ctl != null and cfg.ctl_dir == .root) list = list ++ &[_]Node{ctl_node};
- list = list ++ &[_]Node{.{ .name = "vars", .kind = .vars }};
- break :blk list;
- };
-
- pub const root_node: Node = .{ .name = "/", .kind = .dir, .children = root_children };
-
- /// The static tree flattened so nodes can be referenced by index; the
- /// children of a node occupy consecutive slots `first..first+count`.
- const Flat = struct { node: Node, parent: u32, first: u32, count: u32 };
-
- fn countNodes(n: Node) usize {
- var c: usize = 1;
- for (n.children) |ch| c += countNodes(ch);
- return c;
- }
-
- fn fillFlat(arr: []Flat, next: *usize, idx: usize, n: Node, parent: u32) void {
- const first = next.*;
- next.* += n.children.len;
- arr[idx] = .{ .node = n, .parent = parent, .first = @intCast(first), .count = @intCast(n.children.len) };
- for (n.children, 0..) |ch, i| fillFlat(arr, next, first + i, ch, @intCast(idx));
- }
-
- pub const flat_len = countNodes(root_node);
- pub const flat: [flat_len]Flat = blk: {
- @setEvalBranchQuota(100_000);
- var arr: [flat_len]Flat = undefined;
- var next: usize = 1;
- fillFlat(&arr, &next, 0, root_node, 0);
- break :blk arr;
- };
- const vars_idx: u32 = blk: {
- for (flat, 0..) |f, i| if (f.node.kind == .vars) break :blk @intCast(i);
- @compileError("no vars node");
- };
-
- comptime {
- for (flat) |f| if (f.node.kind == .dynamic and f.node.gen == null) @compileError("dynamic node without generator");
- std.debug.assert(cfg.msize >= cloud9.Server.msize_min);
- std.debug.assert(cfg.snapshot_slots > 0 and cfg.max_fids > 0);
- // Provider index 0xFE/0xFF would collide with the var/static qid tags.
- std.debug.assert(cfg.max_providers < 0xFE);
- }
-
- // -- qid paths ------------------------------------------------------
-
- const static_tag: u64 = 0xFF << 56;
- const var_tag: u64 = 0xFE << 56;
- const handle_mask: u64 = (1 << 56) - 1;
-
- // -- storage --------------------------------------------------------
-
- /// Per-connection buffers; the caller places one in static memory.
- pub const Storage = struct {
- in: [cfg.msize]u8,
- out: [cfg.msize]u8,
- /// Staging area for read replies (directory records, provider and raw reads).
- data: [cfg.msize]u8,
- snapshots: [cfg.snapshot_slots][cfg.snapshot_bytes]u8,
- };
-
- const Var = struct {
- name: []const u8,
- ptr: *anyopaque,
- vt: *const vars.VTable,
- };
-
- /// State common to all connections: providers, exposed variables, the
- /// ctl result. Not internally synchronized: one thread serves all
- /// connections (or the caller serializes).
- pub const Shared = struct {
- ctx: *anyopaque,
- providers: [cfg.max_providers]Provider = undefined,
- nprov: u8 = 0,
- vars: [cfg.max_vars]Var = undefined,
- nvars: u8 = 0,
- /// The ctl result is double-buffered: a command writes into the
- /// buffer that is not current and commits it only on success, so
- /// a failed command leaves the previous result intact.
- ctl_bufs: [2][cfg.ctl_bytes]u8 = undefined,
- ctl_cur: u1 = 0,
- /// Length of the current ctl result (in `ctl_bufs[ctl_cur]`).
- ctl_len: u32 = 0,
- ctl_version: u32 = 0,
- /// Entropy for the per-connection fid hash. The core mixes in a
- /// connection counter and buffer addresses; a platform layer with a
- /// random source may set this once after `init` to make the seed
- /// unpredictable even where addresses are static.
- hash_seed: u32 = 0,
- conn_seq: u32 = 0,
-
- /// `ctx` is passed to every `fns`/`runtime` generator and to `ctl`.
- pub fn init(ctx: *anyopaque) Shared {
- return .{ .ctx = ctx };
- }
-
- /// Mounts `p` at `/<p.name>`. The name must not collide with a
- /// static entry or another provider.
- pub fn addProvider(s: *Shared, p: Provider) error{Full}!void {
- if (s.nprov == cfg.max_providers) return error.Full;
- std.debug.assert(validName(p.name) != error.BadName);
- std.debug.assert(s.findProvider(p.name) == null);
- std.debug.assert(staticChild(0, p.name) == null);
- s.providers[s.nprov] = p;
- s.nprov += 1;
- }
-
- /// Publishes `ptr.*` as /vars/<name>. `name` and the pointee must
- /// outlive the server.
- pub fn expose(s: *Shared, name: []const u8, ptr: anytype) error{Full}!void {
- const P = @TypeOf(ptr);
- const info = @typeInfo(P);
- if (info != .pointer or info.pointer.size != .one or info.pointer.is_const) @compileError("expose wants a *T, got " ++ @typeName(P));
- if (s.nvars == cfg.max_vars) return error.Full;
- std.debug.assert(validName(name) != error.BadName);
- std.debug.assert(s.findVar(name) == null);
- s.vars[s.nvars] = .{ .name = name, .ptr = @ptrCast(ptr), .vt = vars.vtableFor(info.pointer.child) };
- s.nvars += 1;
- }
-
- /// The result of the last successful ctl command.
- pub fn ctlResult(s: *const Shared) []const u8 {
- return s.ctl_bufs[s.ctl_cur][0..s.ctl_len];
- }
-
- fn findProvider(s: *const Shared, name: []const u8) ?u8 {
- for (s.providers[0..s.nprov], 0..) |p, i| if (std.mem.eql(u8, p.name, name)) return @intCast(i);
- return null;
- }
-
- fn findVar(s: *const Shared, name: []const u8) ?u8 {
- for (s.vars[0..s.nvars], 0..) |v, i| if (std.mem.eql(u8, v.name, name)) return @intCast(i);
- return null;
- }
- };
-
- fn staticChild(idx: u32, name: []const u8) ?u32 {
- const f = flat[idx];
- for (f.first..f.first + f.count) |ci| {
- if (std.mem.eql(u8, flat[ci].node.name, name)) return @intCast(ci);
- }
- return null;
- }
-
- // -- connection -----------------------------------------------------
-
- const NodeRef = union(enum) {
- static: u32,
- prov: struct { idx: u8, h: Provider.Handle },
- @"var": struct { idx: u8, node: u32 },
- };
-
- const Fid = struct {
- id: u32 = 0,
- used: bool = false,
- node: NodeRef = .{ .static = 0 },
- is_dir: bool = true,
- open: bool = false,
- mode: u8 = 0,
- rclose: bool = false,
- dir_offset: u64 = 0,
- dir_index: usize = 0,
- /// Snapshot slot of an open dynamic file.
- snap: ?u8 = null,
- /// Free-list link, meaningful while `!used`.
- next_free: u16 = no_slot,
- };
-
- const no_slot: u16 = std.math.maxInt(u16);
- /// The fid index is an open-addressing (linear probing) table from fid
- /// number to a slot of `Conn.fids`, sized to stay at most half full so
- /// that lookups are O(1) with any number of fids.
- const index_len: usize = std.math.ceilPowerOfTwoAssert(usize, @as(usize, cfg.max_fids) * 2);
- const index_mask: usize = index_len - 1;
- const index_shift: u5 = @intCast(32 - @as(usize, std.math.log2_int(usize, index_len)));
-
- /// MurmurHash3's 32-bit finalizer: every input bit affects every output bit.
- fn fmix32(x: u32) u32 {
- var h = x;
- h ^= h >> 16;
- h *%= 0x85EB_CA6B;
- h ^= h >> 13;
- h *%= 0xC2B2_AE35;
- h ^= h >> 16;
- return h;
- }
-
- /// Everything the engine needs to know about a node for qid/stat.
- const Info = struct {
- is_dir: bool,
- mode: u32,
- length: u64,
- atime: u32,
- mtime: u32,
- version: u32,
- path: u64,
- name: []const u8,
-
- fn qid(i: Info) cloud9.Qid {
- var t: u8 = if (i.is_dir) cloud9.qtdir else cloud9.qtfile;
- if (i.mode & cloud9.dmappend != 0) t |= cloud9.qtappend;
- if (i.mode & cloud9.dmexcl != 0) t |= cloud9.qtexcl;
- return .{ .type = t, .version = i.version, .path = i.path };
- }
-
- fn stat(i: Info) cloud9.Stat {
- return .{
- .type = 0,
- .dev = 0,
- .qid = i.qid(),
- .mode = i.mode,
- .atime = i.atime,
- .mtime = i.mtime,
- .length = i.length,
- .name = i.name,
- .uid = cfg.name,
- .gid = cfg.name,
- .muid = cfg.name,
- };
- }
- };
-
- /// One 9P connection: a cloud9.Server plus a fid table and snapshot slots.
- pub const Conn = struct {
- shared: *Shared,
- storage: *Storage,
- server: cloud9.Server,
- /// Largest msize this connection negotiates.
- msize_cap: u32,
- fids: [cfg.max_fids]Fid = @splat(.{}),
- /// XORed into every fid number before hashing so that a client
- /// cannot precompute fid numbers that collide (which would turn the
- /// index back into a linear scan).
- hash_seed: u32,
- /// fid number -> slot of `fids` (`no_slot` = empty bucket).
- index: [index_len]u16 = @splat(no_slot),
- /// Head of the free list threaded through `Fid.next_free`.
- free_head: u16 = no_slot,
- /// Slots `high_water..` have never been used (bump allocation).
- high_water: u16 = 0,
- nfids: u16 = 0,
- slot_used: [cfg.snapshot_slots]bool = @splat(false),
- slot_len: [cfg.snapshot_slots]u32 = @splat(0),
- name_buf: [max_name]u8 = undefined,
-
- pub fn init(shared: *Shared, storage: *Storage, msize: u32) Conn {
- shared.conn_seq +%= 1;
- const addr = @intFromPtr(storage) ^ (@intFromPtr(shared) << 7);
- const seed = fmix32(shared.hash_seed ^ (shared.conn_seq *% 0x9E37_79B1) ^ @as(u32, @truncate(addr)) ^ @as(u32, @truncate(addr >> 16)));
- return .{
- .shared = shared,
- .storage = storage,
- .server = .init(.{ .in = &storage.in, .out = &storage.out }),
- .msize_cap = @max(@min(msize, cfg.msize), cloud9.Server.msize_min),
- .hash_seed = seed,
- };
- }
-
- /// Fibonacci hashing of the (seeded) fid number into `index_len` buckets.
- fn fidHome(c: *const Conn, id: u32) usize {
- return @intCast(((id ^ c.hash_seed) *% 0x9E37_79B1) >> index_shift);
- }
-
- /// Feeds transport bytes; returns how many were taken.
- pub fn push(c: *Conn, bytes: []const u8) usize {
- return c.server.push(bytes);
- }
-
- /// Bytes to send to the client.
- pub fn output(c: *const Conn) []const u8 {
- return c.server.output();
- }
-
- pub fn wrote(c: *Conn, n: usize) void {
- c.server.wrote(n);
- }
-
- /// Drops every fid (telling providers) and kills the session.
- pub fn hangup(c: *Conn) void {
- c.resetFids();
- c.server.hangup();
- }
-
- /// Handles at most one request. Returns false when more input (or
- /// output drainage) is needed. `error.Protocol` is terminal.
- pub fn step(c: *Conn) error{Protocol}!bool {
- const req = (c.server.receive() catch return error.Protocol) orelse return false;
- defer c.server.release();
- switch (req.msg) {
- .tversion => |m| {
- c.resetFids();
- c.server.negotiate(@min(m.msize, c.msize_cap), m.version) catch return error.Protocol;
- },
- else => {
- const reply = c.dispatch(req.msg) catch |e| cloud9.Msg{ .rerror = .{ .ename = ename(e) } };
- c.server.reply(req.tag, reply) catch |e| switch (e) {
- // The reply does not fit the negotiated msize (Rstat or a long
- // Rwalk at a tiny msize). receive() guarantees room for one
- // msize-sized message, so this is never backpressure: answer with
- // an Rerror (truncated to fit by cloud9). Rwalk, the only
- // variable-size reply that follows a state change, is size-checked
- // in walk() before anything is mutated.
- error.TooLarge => c.server.reply(req.tag, .{ .rerror = .{ .ename = ename(error.ReplyTooLarge) } }) catch return error.Protocol,
- else => return error.Protocol,
- };
- },
- }
- return true;
- }
-
- /// Number of fids currently held.
- pub fn fidCount(c: *const Conn) usize {
- return c.nfids;
- }
-
- fn dispatch(c: *Conn, msg: cloud9.Msg) anyerror!cloud9.Msg {
- return switch (msg) {
- .tauth => error.AuthNotRequired,
- .tattach => |m| c.attach(m),
- .tflush => .rflush,
- .twalk => |m| c.walk(m),
- .topen => |m| c.open(m),
- .tcreate => |m| c.create(m),
- .tread => |m| c.read(m),
- .twrite => |m| c.write(m),
- .tclunk => |m| c.clunk(m),
- .tremove => |m| c.remove(m),
- .tstat => |m| c.stat(m),
- .twstat => |m| c.wstat(m),
- else => error.Protocol,
- };
- }
-
- // -- fid table --
-
- /// The index bucket holding `id`, if any.
- fn findBucket(c: *const Conn, id: u32) ?usize {
- var pos = c.fidHome(id);
- while (true) : (pos = (pos + 1) & index_mask) {
- const slot = c.index[pos];
- if (slot == no_slot) return null;
- if (c.fids[slot].id == id) return pos;
- }
- }
-
- fn findFid(c: *Conn, id: u32) ?*Fid {
- const pos = c.findBucket(id) orelse return null;
- return &c.fids[c.index[pos]];
- }
-
- fn allocFid(c: *Conn, id: u32) !*Fid {
- if (c.findBucket(id) != null) return error.FidInUse;
- if (c.nfids >= cfg.max_fids) return error.TooManyFids;
- const slot: u16 = if (c.free_head != no_slot) blk: {
- const slot = c.free_head;
- c.free_head = c.fids[slot].next_free;
- break :blk slot;
- } else blk: {
- const slot = c.high_water;
- c.high_water += 1;
- break :blk slot;
- };
- c.fids[slot] = .{ .id = id, .used = true };
- var pos = c.fidHome(id);
- while (c.index[pos] != no_slot) pos = (pos + 1) & index_mask;
- c.index[pos] = slot;
- c.nfids += 1;
- return &c.fids[slot];
- }
-
- /// Removes `id` from the index (backward-shift deletion: no tombstones).
- fn unlinkFid(c: *Conn, id: u32) void {
- var i = c.findBucket(id).?;
- var j = i;
- while (true) {
- j = (j + 1) & index_mask;
- const slot = c.index[j];
- if (slot == no_slot) break;
- const k = c.fidHome(c.fids[slot].id);
- // The entry at j may move into the hole at i unless its home
- // lies in the cyclic interval (i, j].
- const stays = if (i <= j) (k > i and k <= j) else (k > i or k <= j);
- if (!stays) {
- c.index[i] = slot;
- i = j;
- }
- }
- c.index[i] = no_slot;
- }
-
- /// Releases everything a fid holds; the slot stays allocated.
- fn dropContents(c: *Conn, f: *Fid) void {
- if (f.snap) |s| c.slot_used[s] = false;
- f.snap = null;
- if (f.node == .prov) {
- const p = c.shared.providers[f.node.prov.idx];
- if (f.open) if (p.vtable.close) |close| close(p.ctx, f.node.prov.h);
- if (f.rclose and f.open) if (p.vtable.remove) |rm| rm(p.ctx, f.node.prov.h) catch {};
- p.vtable.clunk(p.ctx, f.node.prov.h);
- }
- f.open = false;
- f.rclose = false;
- }
-
- fn freeFid(c: *Conn, f: *Fid) void {
- c.dropContents(f);
- c.unlinkFid(f.id);
- const slot: u16 = @intCast((@intFromPtr(f) - @intFromPtr(&c.fids)) / @sizeOf(Fid));
- f.* = .{ .next_free = c.free_head };
- c.free_head = slot;
- c.nfids -= 1;
- }
-
- fn resetFids(c: *Conn) void {
- for (c.fids[0..c.high_water]) |*f| {
- if (f.used) c.dropContents(f);
- f.* = .{};
- }
- @memset(&c.index, no_slot);
- c.free_head = no_slot;
- c.high_water = 0;
- c.nfids = 0;
- }
-
- /// Releases a provider handle that is not held by any fid.
- fn releaseRef(c: *Conn, ref: NodeRef) void {
- if (ref == .prov) {
- const p = c.shared.providers[ref.prov.idx];
- p.vtable.clunk(p.ctx, ref.prov.h);
- }
- }
-
- // -- node helpers --
-
- fn provider(c: *Conn, idx: u8) Provider {
- return c.shared.providers[idx];
- }
-
- fn varBase(c: *Conn, idx: u8, node: u32) [*]u8 {
- const v = c.shared.vars[idx];
- return @as([*]u8, @ptrCast(v.ptr)) + v.vt.nodes[node].offset;
- }
-
- fn info(c: *Conn, ref: NodeRef) !Info {
- switch (ref) {
- .static => |idx| {
- const n = flat[idx].node;
- return .{
- .is_dir = n.isDir(),
- .mode = switch (n.kind) {
- .dir, .vars => cloud9.dmdir | 0o555,
- .ctl => 0o666,
- else => 0o444,
- },
- .length = switch (n.kind) {
- .static => n.content.len,
- .ctl => c.shared.ctl_len,
- else => 0,
- },
- .atime = build_secs,
- .mtime = build_secs,
- .version = if (n.kind == .ctl) c.shared.ctl_version else 0,
- .path = static_tag | idx,
- .name = n.name,
- };
- },
- .@"var" => |v| {
- const sv = c.shared.vars[v.idx];
- const n = sv.vt.nodes[v.node];
- return .{
- .is_dir = n.isDir(),
- .mode = if (n.isDir()) cloud9.dmdir | 0o555 else if (n.writable()) 0o644 else 0o444,
- .length = switch (n.kind) {
- .type_name, .size => n.content.len,
- .raw => n.size,
- else => 0,
- },
- .atime = 0,
- .mtime = 0,
- .version = 0,
- .path = var_tag | (@as(u64, v.idx) << 32) | v.node,
- .name = if (v.node == 0) sv.name else n.name,
- };
- },
- .prov => |p| {
- const pr = c.provider(p.idx);
- var st: NodeStat = .{ .mode = 0 };
- try pr.vtable.stat(pr.ctx, p.h, &st);
- return provInfo(pr, p.idx, p.h, st);
- },
- }
- }
-
- fn provInfo(pr: Provider, idx: u8, h: Provider.Handle, st: NodeStat) Info {
- return .{
- .is_dir = st.isDir(),
- .mode = st.mode,
- .length = if (st.isDir()) 0 else st.length,
- .atime = st.atime,
- .mtime = st.mtime,
- .version = st.version,
- .path = (@as(u64, idx) << 56) | ((if (st.path != 0) st.path else h) & handle_mask),
- .name = if (h == Provider.root) pr.name else st.name,
- };
- }
-
- /// Copies `st.name` into the connection so the reply cannot dangle.
- fn pinName(c: *Conn, st: cloud9.Stat) cloud9.Stat {
- var out = st;
- const n = @min(st.name.len, c.name_buf.len);
- @memcpy(c.name_buf[0..n], st.name[0..n]);
- out.name = c.name_buf[0..n];
- return out;
- }
-
- const Looked = struct { ref: NodeRef, info: Info };
-
- /// Resolves `name` in the directory `ref`. A returned provider ref
- /// is a fresh handle the caller must release or retain.
- fn lookup(c: *Conn, ref: NodeRef, name: []const u8) !Looked {
- switch (ref) {
- .static => |idx| {
- const dot = std.mem.eql(u8, name, ".");
- const dotdot = std.mem.eql(u8, name, "..");
- var next: NodeRef = undefined;
- if (dot) {
- next = ref;
- } else if (dotdot) {
- next = .{ .static = flat[idx].parent };
- } else if (flat[idx].node.kind == .vars) {
- const vi = c.shared.findVar(name) orelse return error.NotFound;
- next = .{ .@"var" = .{ .idx = vi, .node = 0 } };
- } else if (staticChild(idx, name)) |ci| {
- next = .{ .static = ci };
- } else if (idx == 0) {
- const pi = c.shared.findProvider(name) orelse return error.NotFound;
- next = .{ .prov = .{ .idx = pi, .h = Provider.root } };
- } else return error.NotFound;
- return .{ .ref = next, .info = try c.info(next) };
- },
- .@"var" => |v| {
- const vt = c.shared.vars[v.idx].vt;
- var next = ref;
- if (std.mem.eql(u8, name, ".")) {
- // unchanged
- } else if (std.mem.eql(u8, name, "..")) {
- next = if (v.node == 0) .{ .static = vars_idx } else .{ .@"var" = .{ .idx = v.idx, .node = vt.nodes[v.node].parent } };
- } else {
- const ci = vt.child(v.node, name) orelse return error.NotFound;
- next = .{ .@"var" = .{ .idx = v.idx, .node = ci } };
- }
- return .{ .ref = next, .info = try c.info(next) };
- },
- .prov => |p| {
- if (p.h == Provider.root and std.mem.eql(u8, name, "..")) {
- const next: NodeRef = .{ .static = 0 };
- return .{ .ref = next, .info = try c.info(next) };
- }
- const pr = c.provider(p.idx);
- const h = try pr.vtable.walk(pr.ctx, p.h, name);
- const next: NodeRef = .{ .prov = .{ .idx = p.idx, .h = h } };
- errdefer c.releaseRef(next);
- return .{ .ref = next, .info = try c.info(next) };
- },
- }
- }
-
- /// The i-th entry of directory `ref` as a Stat, or null past the end.
- /// The name borrows either static memory or the provider's NodeStat.
- fn entryStat(c: *Conn, ref: NodeRef, i: usize) !?cloud9.Stat {
- switch (ref) {
- .static => |idx| {
- const f = flat[idx];
- if (f.node.kind == .vars) {
- if (i >= c.shared.nvars) return null;
- return (try c.info(.{ .@"var" = .{ .idx = @intCast(i), .node = 0 } })).stat();
- }
- if (i < f.count) return (try c.info(.{ .static = f.first + @as(u32, @intCast(i)) })).stat();
- if (idx == 0) {
- const pi = i - f.count;
- if (pi >= c.shared.nprov) return null;
- return (try c.info(.{ .prov = .{ .idx = @intCast(pi), .h = Provider.root } })).stat();
- }
- return null;
- },
- .@"var" => |v| {
- const n = c.shared.vars[v.idx].vt.nodes[v.node];
- if (i >= n.count) return null;
- return (try c.info(.{ .@"var" = .{ .idx = v.idx, .node = n.first + @as(u32, @intCast(i)) } })).stat();
- },
- .prov => |p| {
- const pr = c.provider(p.idx);
- var st: NodeStat = .{ .mode = 0 };
- if (!try pr.vtable.list(pr.ctx, p.h, i, &st)) return null;
- return provInfo(pr, p.idx, st.handle, st).stat();
- },
- }
- }
-
- // -- snapshots --
-
- fn takeSlot(c: *Conn) !u8 {
- for (&c.slot_used, 0..) |*u, i| if (!u.*) {
- u.* = true;
- return @intCast(i);
- };
- return error.NoSnapshot;
- }
-
- /// (Re)generates the content of a dynamic file into its slot.
- fn generate(c: *Conn, f: *Fid) !void {
- const s = f.snap.?;
- var w: Writer = .fixed(&c.storage.snapshots[s]);
- c.slot_len[s] = 0;
- switch (f.node) {
- .static => |idx| try flat[idx].node.gen.?(c.shared.ctx, &w),
- .@"var" => |v| {
- const n = c.shared.vars[v.idx].vt.nodes[v.node];
- const base = c.varBase(v.idx, v.node);
- switch (n.kind) {
- .value => try n.render.?(base, &w),
- .addr => try w.print("0x{x}", .{@intFromPtr(base)}),
- else => unreachable,
- }
- },
- .prov => unreachable,
- }
- c.slot_len[s] = @intCast(w.buffered().len);
- }
-
- fn isDynamic(c: *Conn, ref: NodeRef) bool {
- return switch (ref) {
- .static => |idx| flat[idx].node.kind == .dynamic,
- .@"var" => |v| switch (c.shared.vars[v.idx].vt.nodes[v.node].kind) {
- .value, .addr => true,
- else => false,
- },
- .prov => false,
- };
- }
-
- // -- request handlers --
-
- fn attach(c: *Conn, m: anytype) !cloud9.Msg {
- const f = try c.allocFid(m.fid);
- f.node = .{ .static = 0 };
- f.is_dir = true;
- return .{ .rattach = .{ .qid = (try c.info(f.node)).qid() } };
- }
-
- fn walk(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- if (m.newfid != m.fid and c.findFid(m.newfid) != null) return error.FidInUse;
- if (m.newfid != m.fid and c.nfids >= cfg.max_fids) return error.TooManyFids;
- if (m.nwname > 0 and f.open) return error.AlreadyOpen;
- // Cloning a fid onto itself changes nothing; in particular it must not
- // close an open fid or discard generated content.
- if (m.nwname == 0 and m.newfid == m.fid) return .{ .rwalk = .{ .nwqid = 0 } };
- // A full Rwalk must fit the negotiated msize; check before binding anything.
- if (cloud9.header_len + 2 + cloud9.qid_len * @as(usize, m.nwname) > c.server.msize) return error.ReplyTooLarge;
- var cur = f.node;
- var cur_is_dir = f.is_dir;
- var held = false; // cur is a provider handle obtained here, not the fid's
- var reply: cloud9.Msg = .{ .rwalk = .{ .nwqid = 0 } };
- const names = m.wname[0..m.nwname];
- for (names, 0..) |name, i| {
- if (!cur_is_dir) {
- if (i == 0) return error.NotDir;
- break;
- }
- const next = c.lookup(cur, name) catch |e| {
- if (i == 0) return e;
- break;
- };
- if (held) c.releaseRef(cur);
- cur = next.ref;
- cur_is_dir = next.info.is_dir;
- held = cur == .prov;
- reply.rwalk.wqid[i] = next.info.qid();
- reply.rwalk.nwqid += 1;
- }
- if (reply.rwalk.nwqid != names.len) {
- if (held) c.releaseRef(cur);
- return reply;
- }
- if (names.len == 0 and cur == .prov) {
- // A clone of a provider handle needs its own reference.
- const dup = try c.lookup(cur, ".");
- cur = dup.ref;
- cur_is_dir = dup.info.is_dir;
- held = true;
- }
- const target = if (m.newfid == m.fid) f else c.allocFid(m.newfid) catch |e| {
- if (held) c.releaseRef(cur);
- return e;
- };
- if (target == f) c.dropContents(f);
- target.node = cur;
- target.is_dir = cur_is_dir;
- return reply;
- }
-
- fn open(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- if (f.open) return error.AlreadyOpen;
- const acc = m.mode & 3;
- const want_write = acc == cloud9.owrite or acc == cloud9.ordwr;
- const trunc = m.mode & cloud9.otrunc != 0;
- if (f.is_dir and (want_write or trunc)) return error.IsDir;
- switch (f.node) {
- .static => |idx| switch (flat[idx].node.kind) {
- .dir, .vars, .ctl => {},
- .static, .dynamic => if (want_write or trunc) return error.Perm,
- },
- .@"var" => |v| {
- const n = c.shared.vars[v.idx].vt.nodes[v.node];
- if ((want_write or trunc) and !n.writable()) return error.Perm;
- },
- .prov => |p| {
- const pr = c.provider(p.idx);
- try pr.vtable.open(pr.ctx, p.h, m.mode);
- },
- }
- const qid = (c.info(f.node) catch |e| {
- // The provider's open succeeded but its stat did not: undo the open.
- if (f.node == .prov) {
- const pr = c.provider(f.node.prov.idx);
- if (pr.vtable.close) |close| close(pr.ctx, f.node.prov.h);
- }
- return e;
- }).qid();
- if (c.isDynamic(f.node)) {
- f.snap = try c.takeSlot();
- c.generate(f) catch |e| {
- c.slot_used[f.snap.?] = false;
- f.snap = null;
- if (f.node == .prov) unreachable;
- return e;
- };
- }
- f.open = true;
- f.mode = m.mode;
- f.rclose = m.mode & cloud9.orclose != 0;
- f.dir_offset = 0;
- f.dir_index = 0;
- return .{ .ropen = .{ .qid = qid, .iounit = 0 } };
- }
-
- fn create(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- if (f.open) return error.AlreadyOpen;
- const p = switch (f.node) {
- .prov => |p| p,
- else => return error.Perm,
- };
- if (!f.is_dir) return error.NotDir;
- const pr = c.provider(p.idx);
- const create_fn = pr.vtable.create orelse return error.Perm;
- try validName(m.name);
- const is_dir = m.perm & cloud9.dmdir != 0;
- const acc = m.mode & 3;
- if (is_dir and (acc != cloud9.oread or m.mode & cloud9.otrunc != 0)) return error.IsDir;
- const h = try create_fn(pr.ctx, p.h, m.name, m.perm, m.mode);
- const node: NodeRef = .{ .prov = .{ .idx = p.idx, .h = h } };
- const qid = (c.info(node) catch |e| {
- if (pr.vtable.close) |close| close(pr.ctx, h);
- pr.vtable.clunk(pr.ctx, h);
- return e;
- }).qid();
- c.dropContents(f);
- f.node = node;
- f.is_dir = is_dir;
- f.open = true;
- f.mode = m.mode;
- f.rclose = m.mode & cloud9.orclose != 0;
- f.dir_offset = 0;
- f.dir_index = 0;
- return .{ .rcreate = .{ .qid = qid, .iounit = 0 } };
- }
-
- fn read(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- if (!f.open or (f.mode & 3) == cloud9.owrite) return error.NotOpen;
- const count: usize = @min(m.count, c.server.msize -| cloud9.iohdrsz, c.storage.data.len);
- if (f.is_dir) return c.readDir(f, m.offset, count);
- const data = &c.storage.data;
- const src: []const u8 = switch (f.node) {
- .static => |idx| blk: {
- const n = flat[idx].node;
- switch (n.kind) {
- .static => break :blk n.content,
- .ctl => break :blk c.shared.ctlResult(),
- .dynamic => {
- if (m.offset == 0) try c.generate(f);
- break :blk c.storage.snapshots[f.snap.?][0..c.slot_len[f.snap.?]];
- },
- .dir, .vars => unreachable,
- }
- },
- .@"var" => |v| blk: {
- const n = c.shared.vars[v.idx].vt.nodes[v.node];
- switch (n.kind) {
- .type_name, .size => break :blk n.content,
- .value, .addr => {
- if (m.offset == 0) try c.generate(f);
- break :blk c.storage.snapshots[f.snap.?][0..c.slot_len[f.snap.?]];
- },
- .raw => break :blk c.varBase(v.idx, v.node)[0..n.size],
- .dir, .fields => unreachable,
- }
- },
- .prov => |p| {
- const pr = c.provider(p.idx);
- const n = try pr.vtable.read(pr.ctx, p.h, m.offset, data[0..count]);
- return .{ .rread = .{ .data = data[0..@min(n, count)] } };
- },
- };
- if (m.offset >= src.len) return .{ .rread = .{ .data = "" } };
- const off: usize = @intCast(m.offset);
- const n = @min(count, src.len - off);
- if (f.node == .@"var" and c.shared.vars[f.node.@"var".idx].vt.nodes[f.node.@"var".node].kind == .raw) {
- // Copy out of the variable so the reply does not read live memory twice.
- @memcpy(data[0..n], src[off..][0..n]);
- return .{ .rread = .{ .data = data[0..n] } };
- }
- return .{ .rread = .{ .data = src[off..][0..n] } };
- }
-
- fn readDir(c: *Conn, f: *Fid, offset: u64, count: usize) !cloud9.Msg {
- if (offset == 0) {
- f.dir_offset = 0;
- f.dir_index = 0;
- } else if (offset != f.dir_offset) return error.BadOffset;
- const data = &c.storage.data;
- var used: usize = 0;
- var i = f.dir_index;
- while (try c.entryStat(f.node, i)) |st| : (i += 1) {
- const rec = st.encode(data[used..count]) catch |e| switch (e) {
- error.NoSpace => break,
- else => return error.Io,
- };
- used += rec.len;
- }
- f.dir_offset += used;
- f.dir_index = i;
- return .{ .rread = .{ .data = data[0..used] } };
- }
-
- fn write(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- const acc = f.mode & 3;
- if (!f.open or (acc != cloud9.owrite and acc != cloud9.ordwr)) return error.NotOpen;
- if (f.is_dir) return error.IsDir;
- switch (f.node) {
- .static => |idx| switch (flat[idx].node.kind) {
- .ctl => try c.ctlCommand(m.data),
- else => return error.Perm,
- },
- .@"var" => |v| {
- const n = c.shared.vars[v.idx].vt.nodes[v.node];
- const set = n.set orelse return error.Perm;
- try set(c.varBase(v.idx, v.node), m.data);
- },
- .prov => |p| {
- const pr = c.provider(p.idx);
- const n = try pr.vtable.write(pr.ctx, p.h, m.offset, m.data);
- return .{ .rwrite = .{ .count = @intCast(@min(n, m.data.len)) } };
- },
- }
- return .{ .rwrite = .{ .count = @intCast(m.data.len) } };
- }
-
- /// Runs `cfg.ctl`; on success its output becomes the ctl result.
- /// On failure the previous result (and its qid version) survive:
- /// the handler writes into the staging half of `ctl_bufs`.
- fn ctlCommand(c: *Conn, line: []const u8) !void {
- const s = c.shared;
- const next = s.ctl_cur ^ 1;
- var w: Writer = .fixed(&s.ctl_bufs[next]);
- try cfg.ctl.?(s.ctx, line, &w);
- s.ctl_cur = next;
- s.ctl_len = @intCast(w.buffered().len);
- s.ctl_version +%= 1;
- }
-
- fn clunk(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- c.freeFid(f);
- return .rclunk;
- }
-
- fn remove(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- defer c.freeFid(f); // Tremove always clunks
- f.rclose = false;
- switch (f.node) {
- .prov => |p| {
- const pr = c.provider(p.idx);
- const rm = pr.vtable.remove orelse return error.Perm;
- try rm(pr.ctx, p.h);
- },
- else => return error.Perm,
- }
- return .rremove;
- }
-
- fn stat(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- return .{ .rstat = .{ .stat = c.pinName((try c.info(f.node)).stat()) } };
- }
-
- fn wstat(c: *Conn, m: anytype) !cloud9.Msg {
- const f = c.findFid(m.fid) orelse return error.UnknownFid;
- const p = switch (f.node) {
- .prov => |p| p,
- else => return error.Perm,
- };
- const pr = c.provider(p.idx);
- const ws = pr.vtable.wstat orelse return error.Perm;
- const cur = try c.info(f.node);
- const st = m.stat;
- const q = cur.qid();
- // Fields we cannot change must be "don't care" or unchanged.
- if (st.type != 0xFFFF and st.type != 0) return error.Perm;
- if (st.dev != 0xFFFF_FFFF and st.dev != 0) return error.Perm;
- if (st.qid.type != 0xFF and st.qid.type != q.type) return error.Perm;
- if (st.qid.version != 0xFFFF_FFFF and st.qid.version != q.version) return error.Perm;
- if (st.qid.path != 0xFFFF_FFFF_FFFF_FFFF and st.qid.path != q.path) return error.Perm;
- if (st.uid.len != 0 and !std.mem.eql(u8, st.uid, cfg.name)) return error.Perm;
- if (st.gid.len != 0 and !std.mem.eql(u8, st.gid, cfg.name)) return error.Perm;
- if (st.muid.len != 0 and !std.mem.eql(u8, st.muid, cfg.name)) return error.Perm;
- if (st.name.len != 0 and !std.mem.eql(u8, st.name, cur.name)) {
- if (p.h == Provider.root) return error.Perm;
- try validName(st.name);
- }
- if (st.length != 0xFFFF_FFFF_FFFF_FFFF and st.length != cur.length and cur.is_dir) return error.IsDir;
- if (st.mode != 0xFFFF_FFFF and (st.mode & cloud9.dmdir) != (cur.mode & cloud9.dmdir)) return error.Perm;
- try ws(pr.ctx, p.h, &st);
- return .rwstat;
- }
- };
-
- // -- in-memory test harness -------------------------------------------
-
- /// Drives a `Conn` with a `cloud9.Client` in memory. Test-only (uses
- /// std.testing.allocator); never referenced by non-test code.
- pub const Harness = struct {
- shared: *Shared,
- storage: *Storage,
- conn: Conn,
- client: cloud9.Client,
- cin: []u8,
- cout: []u8,
-
- pub fn init(h: *Harness, shared: *Shared, storage: *Storage) !void {
- h.shared = shared;
- h.storage = storage;
- h.conn = .init(shared, storage, cfg.msize);
- h.cin = try testing.allocator.alloc(u8, cfg.msize);
- errdefer testing.allocator.free(h.cin);
- h.cout = try testing.allocator.alloc(u8, cfg.msize);
- errdefer testing.allocator.free(h.cout);
- h.client = .init(.{ .in = h.cin, .out = h.cout });
- try h.version(cfg.msize);
- _ = try h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
- }
-
- pub fn deinit(h: *Harness) void {
- h.conn.hangup();
- testing.allocator.free(h.cin);
- testing.allocator.free(h.cout);
- }
-
- pub fn version(h: *Harness, msize: u32) !void {
- const v = try h.rpc(.{ .version = .{ .msize = msize } });
- try testing.expectEqual(msize, v.version.msize);
- try testing.expectEqualStrings("9P2000", v.version.version);
- }
-
- /// One round trip; the result borrows the client input buffer until the next call.
- pub fn rpc(h: *Harness, req: cloud9.Client.Request) !cloud9.Client.Result {
- _ = try h.client.submit(req);
- while (true) {
- var moved = false;
- while (h.client.output().len > 0) {
- const k = h.conn.push(h.client.output());
- h.client.wrote(k);
- moved = moved or k > 0;
- while (try h.conn.step()) {}
- while (h.conn.output().len > 0) {
- const n = h.client.push(h.conn.output());
- h.conn.wrote(n);
- moved = moved or n > 0;
- }
- if (k == 0) break;
- }
- while (try h.conn.step()) {}
- while (h.conn.output().len > 0) {
- const n = h.client.push(h.conn.output());
- h.conn.wrote(n);
- moved = moved or n > 0;
- }
- if (h.client.take()) |done| return done.result;
- if (!moved) return error.Stuck;
- }
- }
-
- pub fn ok(h: *Harness, req: cloud9.Client.Request) !cloud9.Client.Result {
- const r = try h.rpc(req);
- if (r == .fail) {
- std.debug.print("unexpected Rerror: {s}\n", .{r.fail});
- return error.Rerror;
- }
- return r;
- }
-
- pub fn expectFail(h: *Harness, req: cloud9.Client.Request, msg: []const u8) !void {
- const r = try h.rpc(req);
- if (r != .fail) return error.ExpectedRerror;
- try testing.expectEqualStrings(msg, r.fail);
- }
-
- pub fn walkTo(h: *Harness, newfid: u32, names: []const []const u8) !void {
- const r = try h.ok(.{ .walk = .{ .fid = 0, .newfid = newfid, .names = names } });
- try testing.expectEqual(@as(u16, @intCast(names.len)), r.walk.nwqid);
- }
-
- /// Opens `fid` for reading and reads it whole (across consecutive offsets); caller frees.
- pub fn readAll(h: *Harness, fid: u32) ![]u8 {
- _ = try h.ok(.{ .open = .{ .fid = fid, .mode = cloud9.oread } });
- return h.readOpen(fid);
- }
-
- pub fn readOpen(h: *Harness, fid: u32) ![]u8 {
- var acc: std.ArrayList(u8) = .empty;
- errdefer acc.deinit(testing.allocator);
- while (true) {
- const r = try h.ok(.{ .read = .{ .fid = fid, .offset = acc.items.len, .count = 1024 } });
- if (r.read.len == 0) break;
- try acc.appendSlice(testing.allocator, r.read);
- }
- return acc.toOwnedSlice(testing.allocator);
- }
-
- pub fn readPath(h: *Harness, names: []const []const u8) ![]u8 {
- try h.walkTo(99, names);
- defer _ = h.rpc(.{ .clunk = .{ .fid = 99 } }) catch {};
- return h.readAll(99);
- }
-
- pub fn writePath(h: *Harness, names: []const []const u8, data: []const u8) !void {
- try h.walkTo(98, names);
- defer _ = h.rpc(.{ .clunk = .{ .fid = 98 } }) catch {};
- _ = try h.ok(.{ .open = .{ .fid = 98, .mode = cloud9.owrite } });
- const w = try h.ok(.{ .write = .{ .fid = 98, .offset = 0, .data = data } });
- try testing.expectEqual(@as(u32, @intCast(data.len)), w.write);
- }
-
- /// Reads a whole directory in `count`-byte reads at consecutive offsets; returns owned names.
- pub fn listDir(h: *Harness, fid: u32, count: u32) ![][]u8 {
- var names: std.ArrayList([]u8) = .empty;
- errdefer {
- for (names.items) |n| testing.allocator.free(n);
- names.deinit(testing.allocator);
- }
- var offset: u64 = 0;
- while (true) {
- const r = try h.ok(.{ .read = .{ .fid = fid, .offset = offset, .count = count } });
- if (r.read.len == 0) break;
- offset += r.read.len;
- var rest = r.read;
- while (rest.len > 0) {
- const n = std.mem.readInt(u16, rest[0..2], .little) + 2;
- const st = try cloud9.Stat.decode(rest[0..n]);
- try names.append(testing.allocator, try testing.allocator.dupe(u8, st.name));
- rest = rest[n..];
- }
- }
- return names.toOwnedSlice(testing.allocator);
- }
-
- pub fn listPath(h: *Harness, names: []const []const u8) ![][]u8 {
- try h.walkTo(97, names);
- defer _ = h.rpc(.{ .clunk = .{ .fid = 97 } }) catch {};
- _ = try h.ok(.{ .open = .{ .fid = 97, .mode = cloud9.oread } });
- return h.listDir(97, 1024);
- }
-
- pub fn freeNames(names: [][]u8) void {
- for (names) |n| testing.allocator.free(n);
- testing.allocator.free(names);
- }
-
- pub fn hasName(names: []const []const u8, want: []const u8) bool {
- for (names) |n| if (std.mem.eql(u8, n, want)) return true;
- return false;
- }
- };
- };
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-const testing = std.testing;
-
-const TestBuild = struct {
- pub const zig_version: []const u8 = builtin.zig_version_string;
- pub const target: []const u8 = "test-target";
- pub const optimize: []const u8 = "Debug";
- pub const time: []const u8 = "2023-11-14T22:13:20Z";
- pub const change: []const u8 = "abc123";
-};
-
-const Layout = struct { a: u8, b: u32, c: u64 };
-const Decls = struct {
- pub const one = 1;
- pub const two = 2;
- pub fn three() void {}
-};
-
-/// The context every generator and the ctl handler receive in tests.
-const TestCtx = struct {
- calls: u32 = 0,
- ctl_state: i64 = 0,
-};
-
-const TestFns = struct {
- pub fn counter(ctx: *anyopaque, w: *Writer) anyerror!void {
- const t: *TestCtx = @ptrCast(@alignCast(ctx));
- t.calls += 1;
- try w.print("{d}", .{t.calls});
- }
- pub fn fib30(_: *anyopaque, w: *Writer) anyerror!void {
- try w.print("{d}", .{fib(30)});
- }
- pub fn failing(_: *anyopaque, _: *Writer) anyerror!void {
- return error.BadCommand;
- }
- pub fn huge(_: *anyopaque, w: *Writer) anyerror!void {
- try w.splatByteAll('x', 1 << 20);
- }
-};
-
-const TestRuntime = struct {
- pub fn pid(_: *anyopaque, w: *Writer) anyerror!void {
- try w.writeAll("4242");
- }
-};
-
-fn fib(n: u32) u64 {
- if (n == 0) return 0;
- var a: u64 = 0;
- var b: u64 = 1;
- for (1..n) |_| {
- const c = a + b;
- a = b;
- b = c;
- }
- return b;
-}
-
-fn testCtl(ctx: *anyopaque, cmd: []const u8, out: *Writer) anyerror!void {
- const t: *TestCtx = @ptrCast(@alignCast(ctx));
- const line = std.mem.trim(u8, cmd, " \t\r\n\x00");
- var it = std.mem.tokenizeScalar(u8, line, ' ');
- const verb = it.next() orelse return error.BadCommand;
- if (std.mem.eql(u8, verb, "echo")) {
- try out.writeAll(std.mem.trimStart(u8, line[verb.len..], " \t"));
- } else if (std.mem.eql(u8, verb, "add")) {
- const a = std.fmt.parseInt(i64, it.next() orelse return error.BadCommand, 10) catch return error.BadCommand;
- const b = std.fmt.parseInt(i64, it.next() orelse return error.BadCommand, 10) catch return error.BadCommand;
- t.ctl_state = a +% b;
- try out.print("{d}", .{t.ctl_state});
- } else if (std.mem.eql(u8, verb, "partial")) {
- try out.writeAll("half-written");
- return error.BadCommand;
- } else return error.BadCommand;
-}
-
-const test_cfg: Config = .{
- .name = "tester",
- .build = TestBuild,
- .types = &.{ Layout, cloud9.Qid },
- .decls_of = Decls,
- .fns = TestFns,
- .runtime = TestRuntime,
- .ctl = &testCtl,
- .msize = 8192,
- .max_fids = 8,
- .max_providers = 2,
- .max_vars = 4,
- .snapshot_slots = 2,
- .snapshot_bytes = 512,
-};
-
-const TS = Server(test_cfg);
-
-/// A small in-memory provider: /prov/{hello,dir/{inner}} with create/remove/wstat,
-/// counting every handle reference so tests can check clunk discipline.
-const TestProv = struct {
- const max_nodes = 16;
- const Entry = struct {
- used: bool = false,
- name: [max_name]u8 = undefined,
- name_len: u8 = 0,
- parent: u32 = 0,
- is_dir: bool = false,
- mode: u32 = 0o644,
- data: [64]u8 = undefined,
- len: usize = 0,
- refs: u32 = 0,
- opens: u32 = 0,
- mtime: u32 = 0,
-
- fn nameSlice(e: *const Entry) []const u8 {
- return e.name[0..e.name_len];
- }
- };
- nodes: [max_nodes]Entry = @splat(.{}),
- total_refs: u32 = 0,
- clunks: u32 = 0,
- fail_io: bool = false,
- fail_stat: bool = false,
-
- fn init() TestProv {
- var p: TestProv = .{};
- p.nodes[0] = .{ .used = true, .is_dir = true, .mode = cloud9.dmdir | 0o755 };
- _ = p.add(0, "hello", false, 0o644);
- p.nodes[1].len = 5;
- @memcpy(p.nodes[1].data[0..5], "hello");
- const d = p.add(0, "dir", true, cloud9.dmdir | 0o755);
- _ = p.add(d, "inner", false, 0o600);
- _ = p.add(0, "locked", false, 0o000);
- return p;
- }
-
- fn add(p: *TestProv, parent: u32, name: []const u8, is_dir: bool, mode: u32) u32 {
- for (&p.nodes, 0..) |*e, i| if (!e.used) {
- e.* = .{ .used = true, .parent = parent, .is_dir = is_dir, .mode = mode };
- @memcpy(e.name[0..name.len], name);
- e.name_len = @intCast(name.len);
- return @intCast(i);
- };
- unreachable;
- }
-
- fn self(ctx: *anyopaque) *TestProv {
- return @ptrCast(@alignCast(ctx));
- }
-
- fn node(p: *TestProv, h: Provider.Handle) Provider.Error!*Entry {
- if (h >= max_nodes or !p.nodes[h].used) return error.NotFound;
- return &p.nodes[h];
- }
-
- fn retain(p: *TestProv, h: Provider.Handle) Provider.Handle {
- if (h != 0) {
- p.nodes[h].refs += 1;
- p.total_refs += 1;
- }
- return h;
- }
-
- fn walk(ctx: *anyopaque, parent: Provider.Handle, name: []const u8) Provider.Error!Provider.Handle {
- const p = self(ctx);
- if (p.fail_io) return error.Io;
- const d = try p.node(parent);
- if (std.mem.eql(u8, name, ".")) return p.retain(parent);
- if (!d.is_dir) return error.NotDir;
- if (std.mem.eql(u8, name, "..")) return p.retain(d.parent);
- for (p.nodes[0..], 0..) |*e, i| {
- if (e.used and e.parent == parent and i != 0 and std.mem.eql(u8, e.nameSlice(), name)) return p.retain(@intCast(i));
- }
- return error.NotFound;
- }
-
- fn fillStat(e: *const Entry, h: Provider.Handle, out: *NodeStat) void {
- out.* = .{ .mode = e.mode, .length = e.len, .mtime = e.mtime, .name = e.nameSlice(), .handle = h };
- }
-
- fn stat(ctx: *anyopaque, h: Provider.Handle, out: *NodeStat) Provider.Error!void {
- const p = self(ctx);
- if (p.fail_stat) return error.Io;
- fillStat(try p.node(h), h, out);
- }
-
- fn list(ctx: *anyopaque, dir: Provider.Handle, index: usize, out: *NodeStat) Provider.Error!bool {
- const p = self(ctx);
- const d = try p.node(dir);
- if (!d.is_dir) return error.NotDir;
- var k: usize = 0;
- for (p.nodes[0..], 0..) |*e, i| {
- if (!e.used or e.parent != dir or i == 0) continue;
- if (k == index) {
- fillStat(e, @intCast(i), out);
- return true;
- }
- k += 1;
- }
- return false;
- }
-
- fn open(ctx: *anyopaque, h: Provider.Handle, mode: u8) Provider.Error!void {
- const p = self(ctx);
- const e = try p.node(h);
- const acc = mode & 3;
- if (acc != cloud9.owrite and e.mode & 0o400 == 0) return error.Perm;
- if (acc != cloud9.oread and e.mode & 0o200 == 0) return error.Perm;
- if (mode & cloud9.otrunc != 0) e.len = 0;
- e.opens += 1;
- }
-
- fn close(ctx: *anyopaque, h: Provider.Handle) void {
- const p = self(ctx);
- p.nodes[h].opens -= 1;
- }
-
- fn read(ctx: *anyopaque, h: Provider.Handle, offset: u64, buf: []u8) Provider.Error!usize {
- const p = self(ctx);
- const e = try p.node(h);
- if (offset >= e.len) return 0;
- const n = @min(buf.len, e.len - @as(usize, @intCast(offset)));
- @memcpy(buf[0..n], e.data[@intCast(offset)..][0..n]);
- return n;
- }
-
- fn write(ctx: *anyopaque, h: Provider.Handle, offset: u64, data: []const u8) Provider.Error!usize {
- const p = self(ctx);
- const e = try p.node(h);
- if (offset + data.len > e.data.len) return error.NoSpace;
- const off: usize = @intCast(offset);
- @memcpy(e.data[off..][0..data.len], data);
- e.len = @max(e.len, off + data.len);
- e.mtime += 1;
- return data.len;
- }
-
- fn create(ctx: *anyopaque, dir: Provider.Handle, name: []const u8, perm: u32, mode: u8) Provider.Error!Provider.Handle {
- const p = self(ctx);
- const d = try p.node(dir);
- if (!d.is_dir) return error.NotDir;
- for (p.nodes[0..]) |*e| if (e.used and e.parent == dir and std.mem.eql(u8, e.nameSlice(), name)) return error.Exists;
- var free: ?u32 = null;
- for (p.nodes[0..], 0..) |*e, i| if (!e.used) {
- free = @intCast(i);
- break;
- };
- const idx = free orelse return error.NoSpace;
- const h = p.add(@intCast(dir), name, perm & cloud9.dmdir != 0, perm);
- std.debug.assert(h == idx);
- p.nodes[h].opens = 1;
- _ = mode;
- return p.retain(h);
- }
-
- fn remove(ctx: *anyopaque, h: Provider.Handle) Provider.Error!void {
- const p = self(ctx);
- const e = try p.node(h);
- if (h == 0) return error.Perm;
- for (p.nodes[0..]) |*c| if (c.used and c.parent == h) return error.NotEmpty;
- e.used = false; // refs still keep the slot "alive" for clunk accounting
- e.used = true;
- e.parent = std.math.maxInt(u32); // unlinked
- }
-
- fn wstat(ctx: *anyopaque, h: Provider.Handle, st: *const cloud9.Stat) Provider.Error!void {
- const p = self(ctx);
- const e = try p.node(h);
- if (st.name.len != 0) {
- @memcpy(e.name[0..st.name.len], st.name);
- e.name_len = @intCast(st.name.len);
- }
- if (st.length != 0xFFFF_FFFF_FFFF_FFFF) {
- if (st.length > e.data.len) return error.NoSpace;
- e.len = @intCast(st.length);
- }
- if (st.mode != 0xFFFF_FFFF) e.mode = st.mode;
- if (st.mtime != 0xFFFF_FFFF) e.mtime = st.mtime;
- }
-
- fn clunk(ctx: *anyopaque, h: Provider.Handle) void {
- const p = self(ctx);
- p.clunks += 1;
- if (h != 0) {
- p.nodes[h].refs -= 1;
- p.total_refs -= 1;
- }
- }
-
- const vtable: Provider.VTable = .{
- .walk = &walk,
- .stat = &stat,
- .list = &list,
- .open = &open,
- .read = &read,
- .write = &write,
- .create = &create,
- .remove = &remove,
- .wstat = &wstat,
- .close = &close,
- .clunk = &clunk,
- };
-
- fn provider(p: *TestProv) Provider {
- return .{ .name = "prov", .ctx = p, .vtable = &vtable };
- }
-};
-
-const Inner = struct { x: f32 };
-const Exposed = struct { a: u32, b: bool, name: []const u8, inner: Inner };
-
-/// Everything a core test needs, in one place; `harness.init` runs version+attach.
-const Fixture = struct {
- ctx: TestCtx = .{},
- shared: TS.Shared = undefined,
- storage: TS.Storage = undefined,
- prov: TestProv = undefined,
- exposed: Exposed = .{ .a = 1, .b = true, .name = "hello", .inner = .{ .x = 0.5 } },
- counter: u64 = 7,
- h: TS.Harness = undefined,
-
- fn init(x: *Fixture) !void {
- x.shared = .init(&x.ctx);
- x.prov = TestProv.init();
- try x.shared.addProvider(x.prov.provider());
- try x.shared.expose("state", &x.exposed);
- try x.shared.expose("counter", &x.counter);
- try x.h.init(&x.shared, &x.storage);
- }
-
- fn deinit(x: *Fixture) void {
- x.h.deinit();
- }
-};
-
-test "README, /build and the static tree read as expected" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const readme = try x.h.readPath(&.{"README"});
- defer testing.allocator.free(readme);
- try testing.expect(std.mem.startsWith(u8, readme, "tester: a 9P2000 introspection server"));
- const zv = try x.h.readPath(&.{ "build", "zig_version" });
- defer testing.allocator.free(zv);
- try testing.expectEqualStrings(builtin.zig_version_string, zv);
- const ch = try x.h.readPath(&.{ "build", "change" });
- defer testing.allocator.free(ch);
- try testing.expectEqualStrings("abc123", ch);
- try testing.expectEqual(@as(u32, 1_700_000_000), TS.build_secs);
- const names = try x.h.listPath(&.{});
- defer TS.Harness.freeNames(names);
- for ([_][]const u8{ "README", "build", "comptime", "runtime", "ctl", "vars", "prov" }) |n| try testing.expect(TS.Harness.hasName(names, n));
- try testing.expectEqual(@as(usize, 7), names.len);
- // static files are read-only; the static tree admits no creates or removes
- try x.h.walkTo(1, &.{ "build", "target" });
- try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.owrite } }, "permission denied");
- try x.h.expectFail(.{ .remove = .{ .fid = 1 } }, "permission denied");
- try x.h.walkTo(2, &.{"build"});
- try x.h.expectFail(.{ .create = .{ .fid = 2, .name = "nope", .perm = 0o644, .mode = cloud9.owrite } }, "permission denied");
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = stat_dontcare } }, "permission denied");
- const st = try x.h.ok(.{ .stat = .{ .fid = 2 } });
- try testing.expectEqualStrings("build", st.stat.name);
- try testing.expectEqualStrings("tester", st.stat.uid);
- try testing.expect(st.stat.qid.type & cloud9.qtdir != 0);
- try testing.expectEqual(TS.build_secs, st.stat.mtime);
- try testing.expectEqual(@as(u32, 0), parseIso8601("1970-01-01T00:00:00Z").?);
- try testing.expectEqual(@as(?u32, null), parseIso8601("unknown"));
-}
-
-test "comptime/types fields carry @offsetOf and comptime/decls lists pub decls" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const names = try x.h.listPath(&.{ "comptime", "types" });
- defer TS.Harness.freeNames(names);
- try testing.expectEqual(@as(usize, 2), names.len);
- try testing.expect(TS.Harness.hasName(names, "Layout"));
- try testing.expect(TS.Harness.hasName(names, "Qid"));
- const fields = try x.h.readPath(&.{ "comptime", "types", "Layout", "fields" });
- defer testing.allocator.free(fields);
- var expect_buf: [128]u8 = undefined;
- const expect = try std.fmt.bufPrint(&expect_buf, "a: u8 @{d}\nb: u32 @{d}\nc: u64 @{d}\n", .{ @offsetOf(Layout, "a"), @offsetOf(Layout, "b"), @offsetOf(Layout, "c") });
- try testing.expectEqualStrings(expect, fields);
- const size = try x.h.readPath(&.{ "comptime", "types", "Layout", "size" });
- defer testing.allocator.free(size);
- try testing.expectEqualStrings(std.fmt.comptimePrint("{d}", .{@sizeOf(Layout)}), size);
- const name = try x.h.readPath(&.{ "comptime", "types", "Qid", "name" });
- defer testing.allocator.free(name);
- try testing.expectEqualStrings(@typeName(cloud9.Qid), name);
- const decls = try x.h.readPath(&.{ "comptime", "decls" });
- defer testing.allocator.free(decls);
- try testing.expectEqualStrings("one\ntwo\nthree\n", decls);
-}
-
-test "runtime/fn calls the function at open and at each read from offset 0" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const names = try x.h.listPath(&.{ "runtime", "fn" });
- defer TS.Harness.freeNames(names);
- try testing.expectEqual(@typeInfo(TestFns).@"struct".decls.len, names.len);
- const fib_text = try x.h.readPath(&.{ "runtime", "fn", "fib30" });
- defer testing.allocator.free(fib_text);
- try testing.expectEqualStrings("832040", fib_text);
- const pid = try x.h.readPath(&.{ "runtime", "pid" });
- defer testing.allocator.free(pid);
- try testing.expectEqualStrings("4242", pid);
- // the generator runs at open, then again at each read from offset 0, not at offset > 0
- try x.h.walkTo(1, &.{ "runtime", "fn", "counter" });
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } });
- try testing.expectEqual(@as(u32, 1), x.ctx.calls);
- const r1 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("2", r1.read);
- const r2 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 1, .count = 100 } });
- try testing.expectEqualStrings("", r2.read);
- try testing.expectEqual(@as(u32, 2), x.ctx.calls);
- // stat of a dynamic file reports length 0
- const st = try x.h.ok(.{ .stat = .{ .fid = 1 } });
- try testing.expectEqual(@as(u64, 0), st.stat.length);
- try testing.expectEqual(@as(u32, 0o444), st.stat.mode);
- // a generator error is the file's Rerror; a generator that overflows the slot too
- try x.h.walkTo(2, &.{ "runtime", "fn", "failing" });
- try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.oread } }, "bad command");
- try x.h.walkTo(3, &.{ "runtime", "fn", "huge" });
- try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "no space in buffer");
- // a failed open frees its slot: two more dynamic opens still succeed
- try x.h.walkTo(4, &.{ "runtime", "fn", "fib30" });
- _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- _ = try x.h.ok(.{ .walk = .{ .fid = 4, .newfid = 5, .names = &.{} } });
- _ = try x.h.ok(.{ .open = .{ .fid = 5, .mode = cloud9.oread } });
-}
-
-test "snapshot slot exhaustion is an Rerror and clunk frees the slot" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{ "runtime", "fn", "fib30" });
- try x.h.walkTo(2, &.{ "runtime", "fn", "fib30" });
- try x.h.walkTo(3, &.{ "vars", "counter", "value" });
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "too many open dynamic files");
- // static and provider files need no slot
- const t = try x.h.readPath(&.{ "vars", "counter", "type" });
- defer testing.allocator.free(t);
- try testing.expectEqualStrings("u64", t);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- _ = try x.h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.oread } });
- const r = try x.h.ok(.{ .read = .{ .fid = 3, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("7", r.read);
- // cloning an open fid onto itself keeps it open and its content
- const w = try x.h.ok(.{ .walk = .{ .fid = 3, .newfid = 3, .names = &.{} } });
- try testing.expectEqual(@as(u16, 0), w.walk.nwqid);
- const r2 = try x.h.ok(.{ .read = .{ .fid = 3, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("7", r2.read);
- try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{".."} } }, "file already open");
- _ = try x.h.ok(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{} } });
- try x.h.expectFail(.{ .read = .{ .fid = 4, .offset = 0, .count = 100 } }, "file not open");
-}
-
-test "ctl round trip" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"ctl"});
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } });
- const w = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "add 2 3\n" } });
- try testing.expectEqual(@as(u32, 8), w.write);
- const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("5", r.read);
- try testing.expectEqual(@as(i64, 5), x.ctx.ctl_state);
- const st = try x.h.ok(.{ .stat = .{ .fid = 1 } });
- try testing.expectEqual(@as(u64, 1), st.stat.length);
- try testing.expectEqualStrings("ctl", st.stat.name);
- try testing.expectEqual(@as(u32, 0o666), st.stat.mode);
- const v1 = st.stat.qid.version;
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "echo hello world" } });
- const r2 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("hello world", r2.read);
- const r3 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 6, .count = 100 } });
- try testing.expectEqualStrings("world", r3.read);
- try testing.expect((try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.qid.version != v1);
- const v2 = (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.qid.version;
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "frobnicate" } }, "bad command");
- // a failed command leaves the previous result, length and version in place
- const r4 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("hello world", r4.read);
- const st4 = try x.h.ok(.{ .stat = .{ .fid = 1 } });
- try testing.expectEqual(@as(u64, 11), st4.stat.length);
- try testing.expectEqual(v2, st4.stat.qid.version);
- // even when the handler wrote part of a result before failing
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "partial" } }, "bad command");
- const r5 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("hello world", r5.read);
- try testing.expectEqualStrings("hello world", x.shared.ctlResult());
- // the empty result is a legitimate result too
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "echo" } });
- try testing.expectEqualStrings("", (try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } })).read);
- try testing.expectEqual(@as(u64, 0), (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.length);
- // Tversion resets the ctl fid like any other
- try x.h.version(4096);
- try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, "unknown fid");
-}
-
-test "auth is not required and flush is answered" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.expectFail(.{ .auth = .{ .afid = 5, .uname = "tester" } }, "authentication not required");
- const f = try x.h.ok(.{ .flush = .{ .oldtag = 1 } });
- try testing.expect(f == .flush);
- try x.h.expectFail(.{ .attach = .{ .fid = 0, .uname = "tester" } }, "fid in use");
-}
-
-test "vars: value/type/size/addr/raw, fields and writes" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const names = try x.h.listPath(&.{"vars"});
- defer TS.Harness.freeNames(names);
- try testing.expectEqual(@as(usize, 2), names.len);
- try testing.expectEqualStrings("state", names[0]);
- const entries = try x.h.listPath(&.{ "vars", "state" });
- defer TS.Harness.freeNames(entries);
- for ([_][]const u8{ "value", "type", "size", "addr", "raw", "f" }) |n| try testing.expect(TS.Harness.hasName(entries, n));
- try testing.expectEqual(@as(usize, 6), entries.len);
- const value = try x.h.readPath(&.{ "vars", "state", "value" });
- defer testing.allocator.free(value);
- try testing.expectEqualStrings("a: 1\nb: true\nname: \"hello\"\ninner:\n x: 0.5\n", value);
- const tn = try x.h.readPath(&.{ "vars", "state", "type" });
- defer testing.allocator.free(tn);
- try testing.expectEqualStrings(@typeName(Exposed), tn);
- const size = try x.h.readPath(&.{ "vars", "state", "size" });
- defer testing.allocator.free(size);
- try testing.expectEqualStrings(std.fmt.comptimePrint("{d}", .{@sizeOf(Exposed)}), size);
- const addr = try x.h.readPath(&.{ "vars", "state", "addr" });
- defer testing.allocator.free(addr);
- var addr_buf: [32]u8 = undefined;
- try testing.expectEqualStrings(try std.fmt.bufPrint(&addr_buf, "0x{x}", .{@intFromPtr(&x.exposed)}), addr);
- const raw = try x.h.readPath(&.{ "vars", "state", "raw" });
- defer testing.allocator.free(raw);
- try testing.expectEqualSlices(u8, std.mem.asBytes(&x.exposed), raw);
- try x.h.walkTo(1, &.{ "vars", "state", "raw" });
- const raw_st = try x.h.ok(.{ .stat = .{ .fid = 1 } });
- try testing.expectEqual(@as(u64, @sizeOf(Exposed)), raw_st.stat.length);
- try testing.expectEqual(@as(u32, 0o444), raw_st.stat.mode);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- // fields
- const fnames = try x.h.listPath(&.{ "vars", "state", "f" });
- defer TS.Harness.freeNames(fnames);
- try testing.expectEqual(@as(usize, 4), fnames.len);
- const a_value = try x.h.readPath(&.{ "vars", "state", "f", "a", "value" });
- defer testing.allocator.free(a_value);
- try testing.expectEqualStrings("1", a_value);
- const a_type = try x.h.readPath(&.{ "vars", "state", "f", "a", "type" });
- defer testing.allocator.free(a_type);
- try testing.expectEqualStrings("u32", a_type);
- const xv = try x.h.readPath(&.{ "vars", "state", "f", "inner", "f", "x", "value" });
- defer testing.allocator.free(xv);
- try testing.expectEqualStrings("0.5", xv);
- const b_raw = try x.h.readPath(&.{ "vars", "state", "f", "b", "raw" });
- defer testing.allocator.free(b_raw);
- try testing.expectEqualSlices(u8, &.{1}, b_raw);
- // writes
- try x.h.writePath(&.{ "vars", "state", "f", "a", "value" }, "42");
- try testing.expectEqual(@as(u32, 42), x.exposed.a);
- try x.h.writePath(&.{ "vars", "state", "f", "b", "value" }, "false\n");
- try testing.expect(!x.exposed.b);
- try x.h.writePath(&.{ "vars", "state", "f", "inner", "f", "x", "value" }, "2.25");
- try testing.expectEqual(@as(f32, 2.25), x.exposed.inner.x);
- try x.h.writePath(&.{ "vars", "counter", "value" }, "0x10");
- try testing.expectEqual(@as(u64, 16), x.counter);
- try x.h.walkTo(2, &.{ "vars", "state", "f", "a", "value" });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.ordwr } });
- try x.h.expectFail(.{ .write = .{ .fid = 2, .offset = 0, .data = "abc" } }, "bad value");
- const rd = try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("42", rd.read);
- const a_st = try x.h.ok(.{ .stat = .{ .fid = 2 } });
- try testing.expectEqual(@as(u32, 0o644), a_st.stat.mode);
- try testing.expectEqualStrings("value", a_st.stat.name);
- // non-scalar values, type/size/addr/raw and directories are read-only
- try x.h.walkTo(3, &.{ "vars", "state", "value" });
- try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.owrite } }, "permission denied");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
- try x.h.walkTo(4, &.{ "vars", "state", "f", "name", "value" });
- try x.h.expectFail(.{ .open = .{ .fid = 4, .mode = cloud9.owrite } }, "permission denied");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 4 } });
- try x.h.walkTo(5, &.{ "vars", "state" });
- try x.h.expectFail(.{ .open = .{ .fid = 5, .mode = cloud9.owrite } }, "is a directory");
- try x.h.expectFail(.{ .create = .{ .fid = 5, .name = "z", .perm = 0o644, .mode = cloud9.owrite } }, "permission denied");
- // .. climbs back out of the var tree; unknown names fail
- const up = try x.h.ok(.{ .walk = .{ .fid = 5, .newfid = 6, .names = &.{ "f", "inner", "..", "..", "..", "..", "README" } } });
- try testing.expectEqual(@as(u16, 7), up.walk.nwqid);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 6 } });
- try x.h.walkTo(7, &.{"vars"});
- try x.h.expectFail(.{ .walk = .{ .fid = 7, .newfid = 8, .names = &.{"nope"} } }, "file does not exist");
- try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 8, .names = &.{"x"} } }, "file already open");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
- try x.h.walkTo(2, &.{ "vars", "state", "f", "a", "value" });
- try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 8, .names = &.{"x"} } }, "not a directory");
-}
-
-test "provider: walk/list/stat/open/read/write/create/remove/wstat/clunk and error mapping" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const names = try x.h.listPath(&.{"prov"});
- defer TS.Harness.freeNames(names);
- try testing.expectEqual(@as(usize, 3), names.len);
- try testing.expect(TS.Harness.hasName(names, "hello") and TS.Harness.hasName(names, "dir") and TS.Harness.hasName(names, "locked"));
- const hello = try x.h.readPath(&.{ "prov", "hello" });
- defer testing.allocator.free(hello);
- try testing.expectEqualStrings("hello", hello);
- try testing.expectEqual(@as(u32, 0), x.prov.total_refs); // every temp handle was clunked
- // stat and qid scheme
- try x.h.walkTo(1, &.{ "prov", "dir", "inner" });
- const st = try x.h.ok(.{ .stat = .{ .fid = 1 } });
- try testing.expectEqualStrings("inner", st.stat.name);
- try testing.expectEqual(@as(u32, 0o600), st.stat.mode);
- try testing.expectEqual(@as(u64, 3), st.stat.qid.path); // provider 0, handle 3
- try testing.expectEqualStrings("tester", st.stat.gid);
- try x.h.walkTo(2, &.{"prov"});
- const root_st = try x.h.ok(.{ .stat = .{ .fid = 2 } });
- try testing.expectEqualStrings("prov", root_st.stat.name);
- try testing.expect(root_st.stat.qid.type & cloud9.qtdir != 0);
- try testing.expectEqual(@as(u64, 0), root_st.stat.qid.path);
- try testing.expectEqual(@as(u32, 1), x.prov.total_refs); // fid 1 holds inner; fid 2 holds root (unref'd)
- // write then read back; opens are tracked through close
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } });
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].opens);
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "abc" } });
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 3, .data = "def" } });
- const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 1, .count = 100 } });
- try testing.expectEqualStrings("bcdef", r.read);
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 100, .data = "z" } }, "no space left on device");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].opens);
- try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].refs);
- // permission and kind errors come from the provider
- try x.h.walkTo(3, &.{ "prov", "locked" });
- try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "permission denied");
- try x.h.walkTo(4, &.{ "prov", "hello" });
- try x.h.expectFail(.{ .walk = .{ .fid = 4, .newfid = 5, .names = &.{"x"} } }, "not a directory");
- try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = 5, .names = &.{"missing"} } }, "file does not exist");
- try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.owrite } }, "is a directory");
- // create in a provider directory: the fid becomes the new open file
- const cr = try x.h.ok(.{ .create = .{ .fid = 2, .name = "new", .perm = 0o644, .mode = cloud9.ordwr } });
- try testing.expectEqual(cloud9.qtfile, cr.create.qid.type);
- _ = try x.h.ok(.{ .write = .{ .fid = 2, .offset = 0, .data = "fresh" } });
- const rr = try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("fresh", rr.read);
- try x.h.walkTo(6, &.{"prov"});
- try x.h.expectFail(.{ .create = .{ .fid = 6, .name = "new", .perm = 0o644, .mode = cloud9.oread } }, "file already exists");
- const long_name = [_]u8{'n'} ** (max_name + 1);
- try x.h.expectFail(.{ .create = .{ .fid = 6, .name = &long_name, .perm = 0o644, .mode = cloud9.oread } }, "bad file name");
- try x.h.expectFail(.{ .create = .{ .fid = 6, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.owrite } }, "is a directory");
- const dr = try x.h.ok(.{ .create = .{ .fid = 6, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.oread } });
- try testing.expectEqual(cloud9.qtdir, dr.create.qid.type);
- // wstat: rename, truncate, mode, mtime; immutable fields are refused
- var ws = stat_dontcare;
- ws.name = "renamed";
- ws.length = 2;
- ws.mode = 0o600;
- ws.mtime = 99;
- _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = ws } });
- const st2 = try x.h.ok(.{ .stat = .{ .fid = 2 } });
- try testing.expectEqualStrings("renamed", st2.stat.name);
- try testing.expectEqual(@as(u64, 2), st2.stat.length);
- try testing.expectEqual(@as(u32, 0o600), st2.stat.mode);
- try testing.expectEqual(@as(u32, 99), st2.stat.mtime);
- ws = stat_dontcare;
- ws.uid = "someone-else";
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "permission denied");
- ws = stat_dontcare;
- ws.mode = cloud9.dmdir | 0o755;
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "permission denied");
- ws = stat_dontcare;
- ws.name = "bad/name";
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "bad file name");
- ws.name = "..";
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = ws } }, "bad file name");
- ws = stat_dontcare;
- ws.length = 5;
- try x.h.walkTo(7, &.{ "prov", "d" });
- try x.h.expectFail(.{ .wstat = .{ .fid = 7, .stat = ws } }, "is a directory");
- ws = stat_dontcare;
- ws.name = "root2"; // the provider root cannot be renamed
- try x.h.walkTo(14, &.{"prov"});
- try x.h.expectFail(.{ .wstat = .{ .fid = 14, .stat = ws } }, "permission denied");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 14 } });
- // remove always clunks; a non-empty directory refuses
- _ = try x.h.ok(.{ .clunk = .{ .fid = 6 } });
- try x.h.walkTo(8, &.{ "prov", "dir" });
- try x.h.expectFail(.{ .remove = .{ .fid = 8 } }, "directory not empty");
- try x.h.expectFail(.{ .clunk = .{ .fid = 8 } }, "unknown fid");
- _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
- try x.h.walkTo(9, &.{"prov"});
- try x.h.expectFail(.{ .walk = .{ .fid = 9, .newfid = 15, .names = &.{"renamed"} } }, "file does not exist");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 9 } });
- // an i/o error from the provider maps to "i/o error"
- try x.h.walkTo(9, &.{"prov"});
- x.prov.fail_io = true;
- try x.h.expectFail(.{ .walk = .{ .fid = 9, .newfid = 15, .names = &.{"hello"} } }, "i/o error");
- x.prov.fail_io = false;
- _ = try x.h.ok(.{ .clunk = .{ .fid = 9 } });
- // ORCLOSE removes on clunk
- try x.h.walkTo(9, &.{"prov"});
- _ = try x.h.ok(.{ .create = .{ .fid = 9, .name = "tmp", .perm = 0o644, .mode = cloud9.owrite | cloud9.orclose } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 9 } });
- try x.h.walkTo(9, &.{"prov"});
- try x.h.expectFail(.{ .walk = .{ .fid = 9, .newfid = 15, .names = &.{"tmp"} } }, "file does not exist");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 9 } });
- // walking .. out of the provider root and cloning provider fids keeps refs balanced
- const up = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 10, .names = &.{ "prov", "dir", "..", "..", "build" } } });
- try testing.expectEqual(@as(u16, 5), up.walk.nwqid);
- try x.h.walkTo(11, &.{ "prov", "dir", "inner" });
- _ = try x.h.ok(.{ .walk = .{ .fid = 11, .newfid = 12, .names = &.{} } });
- try testing.expectEqual(@as(u32, 2), x.prov.nodes[3].refs);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 11 } });
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].refs);
- // a partial walk releases the handles it obtained
- const part = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 13, .names = &.{ "prov", "dir", "nope" } } });
- try testing.expectEqual(@as(u16, 2), part.walk.nwqid);
- try x.h.expectFail(.{ .clunk = .{ .fid = 13 } }, "unknown fid");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 12 } });
- for (x.h.conn.fids) |f| {
- if (f.used) _ = try x.h.ok(.{ .clunk = .{ .fid = f.id } });
- }
- try testing.expectEqual(@as(u32, 0), x.prov.total_refs);
-}
-
-test "directory reads across offsets, bad offset, and records never split" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{});
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } });
- const first = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 4096 } });
- try testing.expect(first.read.len > 0);
- try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 5, .count = 4096 } }, "bad offset");
- // offset 0 restarts; the same bytes come back
- const again = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 4096 } });
- try testing.expectEqual(first.read.len, again.read.len);
- // small reads at consecutive offsets return every record exactly once
- const names = try x.h.listDir(1, 80);
- defer TS.Harness.freeNames(names);
- try testing.expectEqual(@as(usize, 7), names.len);
- // a count too small for even one record returns nothing rather than splitting it
- const tiny = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } });
- try testing.expectEqual(@as(usize, 0), tiny.read.len);
- // the same for provider and var directories
- const pn = try x.h.listPath(&.{ "prov", "dir" });
- defer TS.Harness.freeNames(pn);
- try testing.expectEqual(@as(usize, 1), pn.len);
- try x.h.walkTo(2, &.{ "vars", "state", "f" });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- const vn = try x.h.listDir(2, 100);
- defer TS.Harness.freeNames(vn);
- try testing.expectEqual(@as(usize, 4), vn.len);
- try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 1, .count = 100 } }, "bad offset");
-}
-
-test "Tversion mid-session resets fids and clunks every provider handle" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{ "prov", "hello" });
- try x.h.walkTo(2, &.{ "prov", "dir", "inner" });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- try x.h.walkTo(3, &.{ "runtime", "fn", "fib30" });
- _ = try x.h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.oread } });
- try testing.expectEqual(@as(u32, 2), x.prov.total_refs);
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].opens);
- try testing.expectEqual(@as(usize, 4), x.h.conn.fidCount());
- const before = x.prov.clunks;
- try x.h.version(4096);
- try testing.expectEqual(@as(usize, 0), x.h.conn.fidCount());
- try testing.expectEqual(@as(u32, 0), x.prov.total_refs);
- try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].opens);
- try testing.expectEqual(before + 2, x.prov.clunks);
- try testing.expect(!x.h.conn.slot_used[0] and !x.h.conn.slot_used[1]);
- try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, "unknown fid");
- _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
- try x.h.walkTo(1, &.{ "prov", "hello" });
- // hangup does the same
- x.h.conn.hangup();
- try testing.expectEqual(@as(u32, 0), x.prov.total_refs);
- try testing.expectEqual(@as(usize, 0), x.h.conn.fidCount());
-}
-
-test "a reply that does not fit msize is an Rerror, not a dead connection" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.version(64);
- _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "t" } });
- // Rstat of the root is ~70 bytes.
- try x.h.expectFail(.{ .stat = .{ .fid = 0 } }, "reply too large for msize");
- // Rwalk with 5 qids is 74 bytes; the walk must not bind newfid.
- try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ ".", ".", ".", ".", "." } } }, "reply too large for msize");
- try x.h.expectFail(.{ .clunk = .{ .fid = 1 } }, "unknown fid");
- try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 0, .names = &.{ ".", ".", ".", ".", "." } } }, "reply too large for msize");
- const r = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"README"} } });
- try testing.expectEqual(@as(u16, 1), r.walk.nwqid);
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } });
- const rd = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 40 } });
- try testing.expect(rd.read.len > 0 and rd.read.len <= 64 - cloud9.iohdrsz);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
-}
-
-test "fid table is bounded per connection" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- var i: u32 = 1;
- while (x.h.conn.fidCount() < test_cfg.max_fids) : (i += 1) {
- _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } });
- }
- try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } }, "too many fids");
- try x.h.expectFail(.{ .attach = .{ .fid = i, .uname = "tester" } }, "too many fids");
- // self-walks and clunks still work at the limit
- _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 0, .names = &.{"build"} } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = i, .names = &.{} } });
- try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{"README"} } }, "fid in use");
- try x.h.expectFail(.{ .walk = .{ .fid = 1234, .newfid = 2, .names = &.{} } }, "unknown fid");
- // a walk into a provider at the limit must not leak the handle
- _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
- _ = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{ "..", "prov", "hello" } } });
- try x.h.expectFail(.{ .walk = .{ .fid = 2, .newfid = i + 1, .names = &.{} } }, "too many fids");
- try testing.expectEqual(@as(u32, 1), x.prov.total_refs);
-}
-
-test "Shared refuses more providers or vars than configured" {
- var ctx: TestCtx = .{};
- var shared: TS.Shared = .init(&ctx);
- var p1 = TestProv.init();
- var p2 = TestProv.init();
- var p3 = TestProv.init();
- try shared.addProvider(.{ .name = "a", .ctx = &p1, .vtable = &TestProv.vtable });
- try shared.addProvider(.{ .name = "b", .ctx = &p2, .vtable = &TestProv.vtable });
- try testing.expectError(error.Full, shared.addProvider(.{ .name = "c", .ctx = &p3, .vtable = &TestProv.vtable }));
- var v: [5]u32 = @splat(0);
- try shared.expose("v0", &v[0]);
- try shared.expose("v1", &v[1]);
- try shared.expose("v2", &v[2]);
- try shared.expose("v3", &v[3]);
- try testing.expectError(error.Full, shared.expose("v4", &v[4]));
-}
-
-/// A server with a large fid table for the index tests.
-const big_cfg: Config = .{
- .name = "big",
- .msize = 8192,
- .max_fids = 4096,
- .max_providers = 1,
- .max_vars = 1,
- .snapshot_slots = 1,
- .snapshot_bytes = 256,
-};
-const BigS = Server(big_cfg);
-
-/// Fid numbers chosen to stress the index: dense low ids, ids with only high
-/// bits set, and ids counting down from 2^32-1 (all distinct for i < 2^20).
-fn adversarialId(i: u32) u32 {
- return switch (i % 3) {
- 0 => i * 8192 + 1,
- 1 => 0x8000_0000 | i,
- else => 0xFFFF_FFFF - i,
- };
-}
-
-/// Every index bucket points at a used fid that finds itself, and every used
-/// fid is found: the invariant the hostile fid tests check after each phase.
-fn checkFidIndex(c: *BigS.Conn) !void {
- var indexed: usize = 0;
- for (c.index) |slot| {
- if (slot == BigS.no_slot) continue;
- indexed += 1;
- try testing.expect(c.fids[slot].used);
- try testing.expectEqual(&c.fids[slot], c.findFid(c.fids[slot].id).?);
- }
- var used: usize = 0;
- for (c.fids[0..c.high_water]) |*f| if (f.used) {
- used += 1;
- try testing.expectEqual(f, c.findFid(f.id).?);
- };
- for (c.fids[c.high_water..]) |*f| try testing.expect(!f.used);
- try testing.expectEqual(indexed, used);
- try testing.expectEqual(used, c.nfids);
-}
-
-test "fid index: thousands of fids, clunk in hostile orders, reuse, Tversion" {
- var ctx: TestCtx = .{};
- var shared: BigS.Shared = .init(&ctx);
- var prov = TestProv.init();
- try shared.addProvider(prov.provider());
- const storage = try testing.allocator.create(BigS.Storage);
- defer testing.allocator.destroy(storage);
- var h: BigS.Harness = undefined;
- try h.init(&shared, storage);
- defer h.deinit();
- const n: u32 = big_cfg.max_fids - 1; // fid 0 is the attach
- var i: u32 = 0;
- while (i < n) : (i += 1) {
- _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = adversarialId(i), .names = &.{ "prov", "hello" } } });
- }
- try testing.expectEqual(@as(usize, n + 1), h.conn.fidCount());
- try testing.expectEqual(n, prov.total_refs);
- try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 0x7FFF_FFFF, .names = &.{} } }, "too many fids");
- try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = adversarialId(5), .names = &.{} } }, "fid in use");
- try h.expectFail(.{ .attach = .{ .fid = adversarialId(7), .uname = "t" } }, "fid in use");
- try testing.expect(h.conn.findFid(0x7FFF_FFFF) == null);
- try testing.expect(h.conn.findFid(adversarialId(n)) == null);
- try checkFidIndex(&h.conn);
- // clunk every third fid, then the rest from the top: backward-shift deletion under churn
- i = 0;
- while (i < n) : (i += 3) _ = try h.ok(.{ .clunk = .{ .fid = adversarialId(i) } });
- try checkFidIndex(&h.conn);
- i = n;
- while (i > 0) {
- i -= 1;
- if (i % 3 == 0) {
- try h.expectFail(.{ .clunk = .{ .fid = adversarialId(i) } }, "unknown fid");
- } else {
- _ = try h.ok(.{ .clunk = .{ .fid = adversarialId(i) } });
- }
- }
- try testing.expectEqual(@as(usize, 1), h.conn.fidCount());
- try testing.expectEqual(@as(u32, 0), prov.total_refs);
- try checkFidIndex(&h.conn);
- // the whole table is reusable after the churn, through the free list
- i = 0;
- while (i < n) : (i += 1) _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = n - i, .names = &.{} } });
- try h.expectFail(.{ .walk = .{ .fid = 0, .newfid = n + 1, .names = &.{} } }, "too many fids");
- try checkFidIndex(&h.conn);
- // pseudo-random alloc/free storm with verification
- var prng = std.Random.DefaultPrng.init(0x9a11);
- const rnd = prng.random();
- var live: [n + 1]bool = @splat(true);
- live[0] = false; // never touch the attach fid
- var round: usize = 0;
- while (round < 20_000) : (round += 1) {
- const id = 1 + rnd.uintLessThan(u32, n);
- if (live[id]) {
- _ = try h.ok(.{ .clunk = .{ .fid = id } });
- } else {
- _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = id, .names = &.{"prov"} } });
- }
- live[id] = !live[id];
- if (round % 997 == 0) try checkFidIndex(&h.conn);
- }
- try checkFidIndex(&h.conn);
- // Tversion drops everything and the table starts over, provider refs balanced
- try h.version(big_cfg.msize);
- try testing.expectEqual(@as(usize, 0), h.conn.fidCount());
- try testing.expectEqual(@as(u32, 0), prov.total_refs);
- try testing.expectEqual(@as(u16, 0), h.conn.high_water);
- try checkFidIndex(&h.conn);
- _ = try h.ok(.{ .attach = .{ .fid = 0xFFFF_FFFE, .uname = "t" } });
- _ = try h.ok(.{ .walk = .{ .fid = 0xFFFF_FFFE, .newfid = 0, .names = &.{} } });
- try checkFidIndex(&h.conn);
-}
-
-test "open: a provider stat failure after a successful open closes the file again" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{ "prov", "hello" });
- x.prov.fail_stat = true;
- try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, "i/o error");
- x.prov.fail_stat = false;
- try testing.expectEqual(@as(u32, 0), x.prov.nodes[1].opens);
- try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, "file not open");
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } });
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[1].opens);
- // the same for create: a stat failure after the provider created the node releases it
- try x.h.walkTo(2, &.{"prov"});
- x.prov.fail_stat = true;
- try x.h.expectFail(.{ .create = .{ .fid = 2, .name = "born", .perm = 0o644, .mode = cloud9.owrite } }, "i/o error");
- x.prov.fail_stat = false;
- for (x.prov.nodes) |e| if (e.used and std.mem.eql(u8, e.nameSlice(), "born")) {
- try testing.expectEqual(@as(u32, 0), e.opens);
- try testing.expectEqual(@as(u32, 0), e.refs);
- };
- try testing.expect(!x.h.conn.findFid(2).?.open);
- try testing.expectEqual(@as(u32, 1), x.prov.total_refs); // fid 1 only
-}
-
-test "fid state machine: open twice, walk from open, remove/clunk of open provider fids" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{ "prov", "dir", "inner" });
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } });
- try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, "file already open");
- try x.h.expectFail(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{"."} } }, "file already open");
- try x.h.expectFail(.{ .create = .{ .fid = 1, .name = "z", .perm = 0o644, .mode = cloud9.oread } }, "file already open");
- // a clone of an open fid is a fresh, unopened reference
- _ = try x.h.ok(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{} } });
- try testing.expectEqual(@as(u32, 2), x.prov.nodes[3].refs);
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].opens);
- // walking newfid == fid with names on an unopened provider fid swaps the handle, refs balanced
- try x.h.walkTo(7, &.{ "prov", "dir" });
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[2].refs);
- _ = try x.h.ok(.{ .walk = .{ .fid = 7, .newfid = 7, .names = &.{ "..", "dir", "inner", "..", "..", "dir" } } });
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[2].refs);
- try testing.expectEqual(@as(u32, 2), x.prov.nodes[3].refs);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 7 } });
- try testing.expectEqual(@as(u32, 0), x.prov.nodes[2].refs);
- // remove of an open fid: close, then remove, then clunk; refs and opens return to zero
- _ = try x.h.ok(.{ .remove = .{ .fid = 1 } });
- try testing.expectEqual(@as(u32, 0), x.prov.nodes[3].opens);
- try testing.expectEqual(@as(u32, 1), x.prov.nodes[3].refs);
- try x.h.expectFail(.{ .open = .{ .fid = 1, .mode = cloud9.oread } }, "unknown fid");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
- try testing.expectEqual(@as(u32, 0), x.prov.total_refs);
- // walking "." on a file fid is "not a directory" at the protocol level, without a provider walk
- try x.h.walkTo(3, &.{ "prov", "hello" });
- const before = x.prov.clunks;
- try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"."} } }, "not a directory");
- try testing.expectEqual(before, x.prov.clunks);
- try testing.expectEqual(@as(u32, 1), x.prov.total_refs);
- // a partial walk through a file releases the handles it took
- const part = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 5, .names = &.{ "prov", "hello", "x", "y" } } });
- try testing.expectEqual(@as(u16, 2), part.walk.nwqid);
- try testing.expectEqual(@as(u32, 1), x.prov.total_refs);
- try x.h.expectFail(.{ .clunk = .{ .fid = 5 } }, "unknown fid");
- // Tremove is always a clunk, even of a static node or when the provider refuses
- try x.h.walkTo(6, &.{"README"});
- try x.h.expectFail(.{ .remove = .{ .fid = 6 } }, "permission denied");
- try x.h.expectFail(.{ .clunk = .{ .fid = 6 } }, "unknown fid");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
- try testing.expectEqual(@as(u32, 0), x.prov.total_refs);
-}
-
-test "snapshot slots: exhaust, hold, Tversion frees; reads past the end and at huge offsets" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{ "runtime", "fn", "fib30" });
- try x.h.walkTo(2, &.{ "vars", "state", "value" });
- try x.h.walkTo(3, &.{ "vars", "state", "addr" });
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.oread } });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "too many open dynamic files");
- try testing.expect(!x.h.conn.findFid(3).?.open);
- // reads at offsets near 2^64 never trap (counts above msize are a raw-9P
- // case: the cloud9 client refuses to send them; test/adv_core_hostile.py covers it)
- const max_count = test_cfg.msize - cloud9.iohdrsz;
- const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = std.math.maxInt(u64), .count = max_count } });
- try testing.expectEqualStrings("", r.read);
- const r2 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 1 << 63, .count = 0 } });
- try testing.expectEqualStrings("", r2.read);
- const r3 = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = max_count } });
- try testing.expectEqualStrings("832040", r3.read);
- // raw beyond @sizeOf is empty; a partial raw read at the tail is bounded
- try x.h.walkTo(4, &.{ "vars", "state", "raw" });
- _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } });
- const raw_end = try x.h.ok(.{ .read = .{ .fid = 4, .offset = @sizeOf(Exposed), .count = 100 } });
- try testing.expectEqualStrings("", raw_end.read);
- const raw_tail = try x.h.ok(.{ .read = .{ .fid = 4, .offset = @sizeOf(Exposed) - 1, .count = 100 } });
- try testing.expectEqual(@as(usize, 1), raw_tail.read.len);
- const raw_huge = try x.h.ok(.{ .read = .{ .fid = 4, .offset = std.math.maxInt(u64) - 1, .count = 100 } });
- try testing.expectEqualStrings("", raw_huge.read);
- // Tversion releases the held slots
- try x.h.version(test_cfg.msize);
- try testing.expect(!x.h.conn.slot_used[0] and !x.h.conn.slot_used[1]);
- _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
- try x.h.walkTo(3, &.{ "vars", "state", "addr" });
- _ = try x.h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.oread } });
-}
-
-test "static and var nodes refuse create, remove and wstat; directories refuse writes" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const dirs = [_][]const []const u8{ &.{}, &.{"build"}, &.{"comptime"}, &.{ "comptime", "types" }, &.{ "comptime", "types", "Layout" }, &.{"runtime"}, &.{ "runtime", "fn" }, &.{"vars"}, &.{ "vars", "state" }, &.{ "vars", "state", "f" }, &.{ "vars", "state", "f", "inner" } };
- for (dirs, 0..) |d, k| {
- const fid: u32 = @intCast(10 + k);
- try x.h.walkTo(fid, d);
- try x.h.expectFail(.{ .create = .{ .fid = fid, .name = "x", .perm = 0o644, .mode = cloud9.owrite } }, "permission denied");
- try x.h.expectFail(.{ .wstat = .{ .fid = fid, .stat = stat_dontcare } }, "permission denied");
- try x.h.expectFail(.{ .open = .{ .fid = fid, .mode = cloud9.owrite } }, "is a directory");
- try x.h.expectFail(.{ .open = .{ .fid = fid, .mode = cloud9.oread | cloud9.otrunc } }, "is a directory");
- try x.h.expectFail(.{ .remove = .{ .fid = fid } }, "permission denied");
- try x.h.expectFail(.{ .clunk = .{ .fid = fid } }, "unknown fid");
- }
- const files = [_][]const []const u8{ &.{"README"}, &.{ "build", "time" }, &.{ "comptime", "decls" }, &.{ "runtime", "pid" }, &.{ "runtime", "fn", "fib30" }, &.{"ctl"}, &.{ "vars", "state", "value" }, &.{ "vars", "state", "raw" }, &.{ "vars", "state", "f", "a", "value" }, &.{ "vars", "counter", "type" } };
- for (files, 0..) |f, k| {
- const fid: u32 = @intCast(30 + k);
- try x.h.walkTo(fid, f);
- try x.h.expectFail(.{ .wstat = .{ .fid = fid, .stat = stat_dontcare } }, "permission denied");
- try x.h.expectFail(.{ .walk = .{ .fid = fid, .newfid = 99, .names = &.{".."} } }, "not a directory");
- try x.h.expectFail(.{ .remove = .{ .fid = fid } }, "permission denied");
- }
- // writes to a var value at a non-zero offset and with an empty payload
- try x.h.walkTo(1, &.{ "vars", "state", "f", "a", "value" });
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.owrite | cloud9.otrunc } });
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "" } }, "bad value");
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "-1" } }, "bad value");
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "1e3" } }, "bad value");
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "99999999999999999999" } }, "bad value");
- try testing.expectEqual(@as(u32, 1), x.exposed.a);
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = std.math.maxInt(u64), .data = "77\n" } });
- try testing.expectEqual(@as(u32, 77), x.exposed.a);
- // reads of a write-only fid are refused; OEXEC reads like OREAD
- try x.h.expectFail(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } }, "file not open");
- try x.h.walkTo(2, &.{"README"});
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oexec } });
- try testing.expect((try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 10 } })).read.len == 10);
-}
-
-test "msize 24: every request that fits is answered, every reply that cannot fit is an Rerror" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.version(24);
- _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "u" } }); // Tattach 20, Rattach 20
- try x.h.expectFail(.{ .stat = .{ .fid = 0 } }, "reply too large"); // Rerror truncated to fit 24 bytes
- const w = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"ctl"} } }); // Rwalk 22
- try testing.expectEqual(@as(u16, 1), w.walk.nwqid);
- try x.h.expectFail(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{ ".", "." } } }, "reply too large");
- try x.h.expectFail(.{ .clunk = .{ .fid = 2 } }, "unknown fid");
- _ = try x.h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } }); // Ropen 24
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 0, .data = "e" } }, "bad command"); // Twrite 24
- // the largest read the client may ask for is msize - iohdrsz = 0 bytes
- const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 0 } });
- try testing.expectEqual(@as(usize, 0), r.read.len);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- try x.h.walkTo(3, &.{"build"});
- _ = try x.h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.oread } });
- const d = try x.h.ok(.{ .read = .{ .fid = 3, .offset = 0, .count = 0 } });
- try testing.expectEqual(@as(usize, 0), d.read.len); // no record fits in 0 bytes, nothing is split
- try x.h.expectFail(.{ .read = .{ .fid = 3, .offset = 1, .count = 0 } }, "bad offset");
-}
-
-test "Conn.init clamps the msize cap to [msize_min, cfg.msize]" {
- var ctx: TestCtx = .{};
- var shared: TS.Shared = .init(&ctx);
- var storage: TS.Storage = undefined;
- const lo: TS.Conn = .init(&shared, &storage, 0);
- try testing.expectEqual(cloud9.Server.msize_min, lo.msize_cap);
- const hi: TS.Conn = .init(&shared, &storage, std.math.maxInt(u32));
- try testing.expectEqual(test_cfg.msize, hi.msize_cap);
- const mid: TS.Conn = .init(&shared, &storage, 4096);
- try testing.expectEqual(@as(u32, 4096), mid.msize_cap);
-}
-
-test "parseIso8601 rejects malformed stamps and never traps" {
- try testing.expectEqual(@as(?u32, null), parseIso8601(""));
- try testing.expectEqual(@as(?u32, null), parseIso8601("2023-11-14T22:13:20"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("2023-13-14T22:13:20Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("2023-11-32T22:13:20Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("2023-11-14T24:13:20Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("2023-11-14T22:60:20Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("1969-12-31T23:59:59Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("9999-12-31T23:59:59Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("20x3-11-14T22:13:20Z"));
- try testing.expectEqual(@as(?u32, null), parseIso8601("0000-01-01T00:00:00Z"));
- try testing.expectEqual(@as(u32, 1_700_000_000), parseIso8601("2023-11-14T22:13:20Z").?);
- try testing.expectEqual(@as(u32, 951_782_400), parseIso8601("2000-02-29T00:00:00Z").?);
- try testing.expectEqual(@as(u32, 4_102_444_799), parseIso8601("2099-12-31T23:59:59Z").?);
- try testing.expectEqual(@as(u32, std.math.maxInt(u32)), parseIso8601("2106-02-07T06:28:15Z").?);
- try testing.expectEqual(@as(?u32, null), parseIso8601("2106-02-07T06:28:16Z"));
-}
-
-/// Multiplicative inverse of an odd 32-bit constant (Newton iteration).
-fn inverseMod32(a: u32) u32 {
- var x: u32 = a;
- for (0..5) |_| x *%= 2 -% a *% x;
- return x;
-}
-
-test "fid index: fid numbers crafted to collide under the public hash do not cluster a seeded connection" {
- var ctx: TestCtx = .{};
- var shared: BigS.Shared = .init(&ctx);
- const storage = try testing.allocator.create(BigS.Storage);
- defer testing.allocator.destroy(storage);
- var h: BigS.Harness = undefined;
- try h.init(&shared, storage);
- defer h.deinit();
- // two connections on the same Shared never share a seed
- const other: BigS.Conn = .init(&shared, storage, big_cfg.msize);
- try testing.expect(other.hash_seed != h.conn.hash_seed);
- // ids whose products with the golden ratio share their top bits: all one bucket when unseeded
- const inv = inverseMod32(0x9E37_79B1);
- try testing.expectEqual(@as(u32, 1), inv *% 0x9E37_79B1);
- const n: u32 = big_cfg.max_fids - 1;
- const base: u32 = 0x4242_0000;
- var i: u32 = 0;
- while (i < n) : (i += 1) {
- const id = (base + i) *% inv;
- try testing.expectEqual(@as(usize, base >> BigS.index_shift), @as(usize, @intCast((id *% 0x9E37_79B1) >> BigS.index_shift)));
- _ = try h.ok(.{ .walk = .{ .fid = 0, .newfid = id, .names = &.{} } });
- }
- try checkFidIndex(&h.conn);
- // the longest probe sequence in the seeded table is short; unseeded it would be ~n
- var worst: usize = 0;
- i = 0;
- while (i < n) : (i += 1) {
- const id = (base + i) *% inv;
- var pos = h.conn.fidHome(id);
- var steps: usize = 0;
- while (h.conn.fids[h.conn.index[pos]].id != id) : (pos = (pos + 1) & BigS.index_mask) steps += 1;
- worst = @max(worst, steps);
- }
- try testing.expect(worst < 64);
-}
diff --git a/introspect/src/freestanding_check.zig b/introspect/src/freestanding_check.zig
deleted file mode 100644
index ad88d0e..0000000
--- a/introspect/src/freestanding_check.zig
+++ /dev/null
@@ -1,71 +0,0 @@
-//! A tiny freestanding root proving that `core` and `vars` compile without an
-//! OS: `zig build introspect-check-freestanding` builds this for riscv32-freestanding-none.
-//! It instantiates `Server(cfg)` with static Storage/Shared, exposes one
-//! variable, and runs one push/step over a canned Tversion frame. It must not
-//! import scratch.zig (allocator) or anything OS-specific.
-const std = @import("std");
-const core = @import("core.zig");
-const Writer = std.Io.Writer;
-
-const Build = struct {
- pub const zig_version: []const u8 = @import("builtin").zig_version_string;
- pub const target: []const u8 = "riscv32-freestanding-none";
- pub const optimize: []const u8 = "check";
- pub const time: []const u8 = "1970-01-01T00:00:00Z";
- pub const change: []const u8 = "none";
-};
-
-const State = struct { ticks: u32, phase: enum { idle, busy }, inner: struct { x: f32 } };
-
-const Fns = struct {
- pub fn ticks(ctx: *anyopaque, w: *Writer) anyerror!void {
- const s: *State = @ptrCast(@alignCast(ctx));
- try w.print("{d}", .{s.ticks});
- }
-};
-
-fn ctl(ctx: *anyopaque, cmd: []const u8, out: *Writer) anyerror!void {
- const s: *State = @ptrCast(@alignCast(ctx));
- if (std.mem.startsWith(u8, cmd, "reset")) s.ticks = 0;
- try out.writeAll("ok");
-}
-
-const cfg: core.Config = .{
- .name = "fw",
- .build = Build,
- .types = &.{ State, core.NodeStat },
- .decls_of = Fns,
- .fns = Fns,
- .ctl = &ctl,
- .msize = 2048,
- .max_fids = 16,
- .snapshot_slots = 2,
- .snapshot_bytes = 1024,
-};
-
-const S = core.Server(cfg);
-
-var state: State = .{ .ticks = 0, .phase = .idle, .inner = .{ .x = 0 } };
-var storage: S.Storage = undefined;
-var shared: S.Shared = undefined;
-var conn: S.Conn = undefined;
-
-/// Tversion msize=2048 version="9P2000".
-const tversion = [_]u8{ 19, 0, 0, 0, 100, 0xFF, 0xFF, 0, 8, 0, 0, 6, 0, '9', 'P', '2', '0', '0', '0' };
-
-/// Runs one Tversion through the engine; returns the number of reply bytes.
-pub export fn introspect_check() u32 {
- shared = .init(&state);
- shared.expose("state", &state) catch unreachable;
- conn = .init(&shared, &storage, cfg.msize);
- _ = conn.push(&tversion);
- _ = conn.step() catch return 0;
- const out = conn.output();
- conn.wrote(out.len);
- return @intCast(out.len);
-}
-
-pub export fn _start() noreturn {
- _ = introspect_check();
- while (true) {}
-}
diff --git a/introspect/src/linux/debug.zig b/introspect/src/linux/debug.zig
deleted file mode 100644
index 4d43d5b..0000000
--- a/introspect/src/linux/debug.zig
+++ /dev/null
@@ -1,1458 +0,0 @@
-//! Linux debug facilities for the introspect server: threads, stacks,
-//! registers, address → source, memory, breakpoints and panics.
-//!
-//! This file is a pure API; a later adapter turns it into a core `Provider`.
-//! All text is written to a `*std.Io.Writer`. Nothing here allocates after
-//! `init` except from the caller-provided `text_buf`, which is used as a fixed
-//! arena for symbol text and reset before every query.
-//!
-//! Only one `Debug` may exist per process: the signal handlers and the panic
-//! hook find their state through the global `current` pointer set by `init`.
-//!
-//! Mechanics
-//!
-//! * Capturing another thread's stack or registers: the calling (server)
-//! thread sends `capture_signal` with `tgkill`. The SA_SIGINFO handler copies
-//! the interrupted register state (`cpu_context.fromPosixSignalContext`) into
-//! the single capture slot and parks on a futex. The server unwinds the
-//! parked thread's stack from that context, releases the target, then
-//! symbolizes. The handler is async-signal-safe: no allocation, no
-//! `std.debug`, no locks other than the futex. A target that does not run
-//! the handler within `capture_timeout_ns` (signal masked, thread in D
-//! state, ...) yields `error.Timeout`; a late-arriving handler run cannot
-//! corrupt a reused slot because it must match the requested tid and win a
-//! compare-and-swap from `armed` on the slot state (that pair plays the role
-//! of a generation counter: a stale run finds the slot idle, armed for
-//! another tid, or armed for itself, in which case its capture is simply the
-//! valid answer to the new request).
-//! * Breakpoints: `@breakpoint()` raises SIGTRAP on the executing thread only.
-//! The handler claims a pause slot, saves the context and parks on a futex
-//! until `resumeThread`. On x86_64 the saved PC is already past `int3`; on
-//! aarch64 the handler advances PC by 4 in the ucontext before returning
-//! (only for a real `brk`, i.e. a kernel-generated si_code; a SIGTRAP sent
-//! with kill/tgkill parks the thread where it was). With no free slot the
-//! thread steps over the breakpoint and keeps running (`traps_skipped`
-//! counts them): the debug layer never kills the process. The server thread
-//! itself (`server_tid`) is never parked, a breakpoint there is stepped
-//! over, because nobody could resume it. Only a stale handler run after
-//! `deinit` (no `current`) falls back to the default disposition.
-//! * Panics: `panicHook` records the message and a stack capture, then, if
-//! `hold_on_panic` and a `Debug` exists, parks until `panicContinue`; then
-//! `std.debug.defaultPanic` runs. A nested or second panic, or a panic on
-//! the server thread itself (which could never be continued), goes
-//! straight to the default handler.
-//! * std.debug's `SelfInfo` guards its state with an `Io.RwLock`. A target
-//! parked while holding it (a thread inside a stack-trace dump, say) would
-//! deadlock the unwind, so after parking a thread the lock is probed with
-//! `tryLock`; a held lock yields `error.Busy` and the target is released.
-//! * Known-module guard: `std.debug.SelfInfo` (Zig 0.16) rebuilds its module
-//! list whenever it is asked about an address outside every known module,
-//! freeing the CIE lists its unwind cache still points into; later unwinds
-//! then read freed memory. `init` records the PT_LOAD ranges of the
-//! executable (the same source std uses) and every lookup or unwind is
-//! first checked against them; addresses outside (unmapped, vDSO, ...)
-//! render as "?" and are never handed to std.
-
-const std = @import("std");
-const builtin = @import("builtin");
-const linux = std.os.linux;
-const cpu_context = std.debug.cpu_context;
-const Writer = std.Io.Writer;
-const Native = cpu_context.Native;
-const arch = builtin.cpu.arch;
-
-pub const Options = struct {
- /// Used for `std.debug` symbolization (reading debug info from disk).
- io: std.Io,
- /// Fixed arena for symbol text. A `FixedBufferAllocator` is placed over it
- /// and reset before every query. 16 KiB is plenty; 4 KiB is a sane floor.
- text_buf: []u8,
- /// Real-time signal used to snapshot other threads. SIGRTMIN is 32 on
- /// Linux without libc; the default is SIGRTMIN+3.
- capture_signal: u8 = default_capture_signal,
- /// How long to wait for a target thread to run the capture handler.
- capture_timeout_ns: u64 = 250 * std.time.ns_per_ms,
- /// How many threads may be parked in `@breakpoint()` at once (≤ 32).
- max_paused: u8 = 16,
-};
-
-pub const default_capture_signal: u8 = 32 + 3;
-
-/// Hard upper bound of `Options.max_paused` (slot storage is static).
-pub const max_paused_cap = 32;
-/// Maximum number of frames written by any stack function.
-pub const max_frames = 64;
-/// Maximum number of tids enumerated from /proc/self/task.
-pub const max_threads = 512;
-/// Upper bound of the recorded panic message.
-pub const panic_msg_cap = 1024;
-/// Maximum number of PT_LOAD ranges recorded by the known-module guard.
-pub const max_ranges = 64;
-
-/// Consulted by `panicHook`: when true and a `Debug` is initialized, the
-/// panicking thread is held until `panicContinue`.
-pub var hold_on_panic: bool = true;
-
-/// The one live instance, set by `init`, cleared by `deinit`.
-pub var current: ?*Debug = null;
-
-/// The tid of the thread serving requests (0 = none). That thread is never
-/// parked by a breakpoint or held by a panic, since nobody could release it.
-pub var server_tid: std.atomic.Value(u32) = .init(0);
-
-/// Breakpoints stepped over because no pause slot was free, or because they
-/// were hit on the server thread.
-pub var traps_skipped: std.atomic.Value(u32) = .init(0);
-
-pub const Error = error{
- /// The target thread did not run the capture handler in time.
- Timeout,
- /// No thread with that tid exists in this process.
- NoThread,
- /// The address is not mapped (EFAULT from process_vm_readv/writev).
- Unmapped,
- /// The thread is not parked in a breakpoint.
- NotPaused,
- /// No panic has been recorded / is being held.
- NoPanic,
- /// Another `Debug` already exists in this process.
- AlreadyInitialized,
- /// The operation is not available on this architecture / kernel.
- Unsupported,
- /// The target thread is parked inside std.debug (holding its lock); its
- /// stack cannot be unwound without deadlocking. Retry later.
- Busy,
- /// Invalid option value.
- InvalidOptions,
- /// A syscall or /proc read failed unexpectedly.
- Unexpected,
- /// The writer failed.
- WriteFailed,
-};
-
-// Capture slot states.
-const cap_idle: u32 = 0;
-const cap_armed: u32 = 1;
-const cap_capturing: u32 = 2;
-const cap_captured: u32 = 3;
-const cap_failed: u32 = 4;
-
-// Pause slot states.
-const pause_free: u32 = 0;
-const pause_claimed: u32 = 1;
-const pause_paused: u32 = 2;
-const pause_resuming: u32 = 3;
-
-const CaptureSlot = struct {
- state: std.atomic.Value(u32) = .init(cap_idle),
- target_tid: std.atomic.Value(u32) = .init(0),
- ctx: Native = undefined,
-};
-
-const PauseSlot = struct {
- state: std.atomic.Value(u32) = .init(pause_free),
- tid: std.atomic.Value(u32) = .init(0),
- ctx: Native = undefined,
-};
-
-pub const Debug = struct {
- io: std.Io,
- text_buf: []u8,
- capture_signal: linux.SIG,
- capture_timeout_ns: u64,
- max_paused: u8,
-
- capture: CaptureSlot = .{},
- paused: [max_paused_cap]PauseSlot = [_]PauseSlot{.{}} ** max_paused_cap,
-
- old_capture_action: linux.Sigaction = undefined,
- old_trap_action: linux.Sigaction = undefined,
- breakpoints_enabled: bool = false,
-
- tids: [max_threads]u32 = undefined,
- tid_count: usize = 0,
-
- ranges: [max_ranges]Range = undefined,
- range_count: usize = 0,
-
- const Range = struct { start: usize, len: usize };
-
- /// Installs the capture handler (not the SIGTRAP handler) and publishes
- /// `d` as `current`.
- pub fn init(d: *Debug, opts: Options) Error!void {
- if (current != null) return error.AlreadyInitialized;
- if (opts.capture_signal < 32 or opts.capture_signal >= linux.NSIG) return error.InvalidOptions;
- if (opts.max_paused == 0 or opts.max_paused > max_paused_cap) return error.InvalidOptions;
- if (Native == noreturn) return error.Unsupported;
- d.* = .{
- .io = opts.io,
- .text_buf = opts.text_buf,
- .capture_signal = @enumFromInt(opts.capture_signal),
- .capture_timeout_ns = opts.capture_timeout_ns,
- .max_paused = opts.max_paused,
- };
- d.scanModules();
- const act: linux.Sigaction = .{
- .handler = .{ .sigaction = captureHandler },
- .mask = linux.sigemptyset(),
- .flags = linux.SA.SIGINFO | linux.SA.RESTART,
- };
- current = d;
- if (linux.errno(linux.sigaction(d.capture_signal, &act, &d.old_capture_action)) != .SUCCESS) {
- current = null;
- return error.Unexpected;
- }
- }
-
- /// Restores the signal dispositions and clears `current`. Threads parked
- /// in a breakpoint are resumed first.
- pub fn deinit(d: *Debug) void {
- d.disableBreakpoints();
- _ = linux.sigaction(d.capture_signal, &d.old_capture_action, null);
- if (current == d) current = null;
- }
-
- /// Installs the SIGTRAP handler so that `@breakpoint()` parks the thread.
- pub fn enableBreakpoints(d: *Debug) Error!void {
- if (d.breakpoints_enabled) return;
- if (arch != .x86_64 and !arch.isAARCH64()) return error.Unsupported;
- const act: linux.Sigaction = .{
- .handler = .{ .sigaction = trapHandler },
- .mask = linux.sigemptyset(),
- .flags = linux.SA.SIGINFO | linux.SA.RESTART,
- };
- if (linux.errno(linux.sigaction(.TRAP, &act, &d.old_trap_action)) != .SUCCESS) return error.Unexpected;
- d.breakpoints_enabled = true;
- }
-
- /// Restores the previous SIGTRAP disposition and resumes every parked thread.
- pub fn disableBreakpoints(d: *Debug) void {
- if (!d.breakpoints_enabled) return;
- _ = linux.sigaction(.TRAP, &d.old_trap_action, null);
- d.breakpoints_enabled = false;
- for (&d.paused) |*slot| {
- if (slot.state.cmpxchgStrong(pause_paused, pause_resuming, .acq_rel, .acquire) == null)
- futexWake(&slot.state);
- }
- }
-
- // ---------------------------------------------------------------- threads
-
- /// The nth tid of this process, numerically sorted; null past the end.
- /// Index 0 rescans /proc/self/task; higher indices reuse that scan.
- pub fn threadAt(d: *Debug, index: usize) ?u32 {
- if (index == 0 or d.tid_count == 0) d.scanThreads();
- if (index >= d.tid_count) return null;
- return d.tids[index];
- }
-
- pub fn threadExists(d: *Debug, tid: u32) bool {
- _ = d;
- var path_buf: [64]u8 = undefined;
- const path = std.fmt.bufPrintZ(&path_buf, "/proc/self/task/{d}/comm", .{tid}) catch return false;
- var buf: [32]u8 = undefined;
- _ = readFile(path, &buf) catch return false;
- return true;
- }
-
- /// The thread's comm (without the trailing newline).
- pub fn threadName(d: *Debug, tid: u32, w: *Writer) Error!void {
- _ = d;
- var path_buf: [64]u8 = undefined;
- const path = std.fmt.bufPrintZ(&path_buf, "/proc/self/task/{d}/comm", .{tid}) catch return error.Unexpected;
- var buf: [64]u8 = undefined;
- const text = readFile(path, &buf) catch |err| switch (err) {
- error.NotFound => return error.NoThread,
- else => return error.Unexpected,
- };
- w.writeAll(std.mem.trimEnd(u8, text, "\n")) catch return error.WriteFailed;
- }
-
- /// A few fields of /proc/self/task/<tid>/stat, one "name value" per line:
- /// state, utime, stime, minflt, majflt, priority, nice, processor.
- pub fn threadStat(d: *Debug, tid: u32, w: *Writer) Error!void {
- _ = d;
- var path_buf: [64]u8 = undefined;
- const path = std.fmt.bufPrintZ(&path_buf, "/proc/self/task/{d}/stat", .{tid}) catch return error.Unexpected;
- var buf: [1024]u8 = undefined;
- const text = readFile(path, &buf) catch |err| switch (err) {
- error.NotFound => return error.NoThread,
- else => return error.Unexpected,
- };
- // "<pid> (<comm>) S <fields...>"; comm may contain spaces and parens.
- const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return error.Unexpected;
- var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' ');
- // Field numbers below are 0-based from `state`.
- const wanted = [_]struct { idx: usize, name: []const u8 }{
- .{ .idx = 0, .name = "state" },
- .{ .idx = 11, .name = "utime" },
- .{ .idx = 12, .name = "stime" },
- .{ .idx = 7, .name = "minflt" },
- .{ .idx = 9, .name = "majflt" },
- .{ .idx = 15, .name = "priority" },
- .{ .idx = 16, .name = "nice" },
- .{ .idx = 36, .name = "processor" },
- };
- var fields: [40][]const u8 = undefined;
- var n: usize = 0;
- while (it.next()) |f| : (n += 1) {
- if (n == fields.len) break;
- fields[n] = f;
- }
- for (wanted) |want| {
- const value = if (want.idx < n) fields[want.idx] else "?";
- w.print("{s} {s}\n", .{ want.name, value }) catch return error.WriteFailed;
- }
- }
-
- /// "#n 0x<addr> in <fn> (<file>:<line>:<col>)" per frame. The calling
- /// thread unwinds itself directly; any other thread is captured with the
- /// capture signal.
- pub fn threadStack(d: *Debug, tid: u32, w: *Writer) Error!void {
- var addrs: [max_frames]usize = undefined;
- var trace: std.debug.StackTrace = undefined;
- if (tid == selfTid()) {
- trace = std.debug.captureCurrentStackTrace(.{}, &addrs);
- } else {
- try d.captureThread(tid);
- if (!d.selfInfoFree()) {
- d.releaseCapture();
- return error.Busy;
- }
- trace = d.unwindContext(&d.capture.ctx, &addrs);
- d.releaseCapture();
- }
- try d.writeFrames(trace.return_addresses, w);
- }
-
- /// "<reg> 0x<hex>" per general register, plus pc/sp/fp aliases.
- pub fn threadRegs(d: *Debug, tid: u32, w: *Writer) Error!void {
- if (tid == selfTid()) {
- const ctx = Native.current();
- return writeRegs(&ctx, w);
- }
- try d.captureThread(tid);
- const ctx = d.capture.ctx;
- d.releaseCapture();
- return writeRegs(&ctx, w);
- }
-
- // ------------------------------------------------------ addresses & memory
-
- /// "fn\nfile:line:col\nmodule\n", unknown parts as "?".
- pub fn resolveAddr(d: *Debug, addr: usize, w: *Writer) Error!void {
- if (!d.knownCode(addr)) return w.writeAll("?\n?\n?\n") catch error.WriteFailed;
- var fba = std.heap.FixedBufferAllocator.init(d.text_buf);
- const alloc = fba.allocator();
- const di = std.debug.getSelfDebugInfo() catch return error.Unsupported;
- var sym = std.debug.Symbol.unknown;
- var symbols: std.ArrayList(std.debug.Symbol) = .empty;
- if (di.getSymbols(d.io, alloc, alloc, addr, true, &symbols)) {
- if (symbols.items.len > 0) sym = symbols.items[0];
- } else |_| {}
- w.print("{s}\n", .{sym.name orelse "?"}) catch return error.WriteFailed;
- if (sym.source_location) |sl| {
- w.print("{s}:{d}:{d}\n", .{ sl.file_name, sl.line, sl.column }) catch return error.WriteFailed;
- } else {
- w.writeAll("?\n") catch return error.WriteFailed;
- }
- const module = di.getModuleName(d.io, addr) catch "?";
- w.print("{s}\n", .{module}) catch return error.WriteFailed;
- }
-
- /// Reads `buf.len` bytes at `addr` via process_vm_readv on the own
- /// process. Never faults. Returns the number of bytes read (short when the
- /// range crosses into an unmapped page); `error.Unmapped` when nothing
- /// could be read.
- pub fn readMem(d: *Debug, addr: usize, buf: []u8) Error!usize {
- _ = d;
- if (buf.len == 0) return 0;
- // Page 0 is never mapped (mmap_min_addr) and a null `iovec.base` is a
- // safety-checked cast; the same answer without the trap.
- if (addr == 0) return error.Unmapped;
- const local = [_]std.posix.iovec{.{ .base = buf.ptr, .len = buf.len }};
- const remote = [_]std.posix.iovec_const{.{ .base = @ptrFromInt(addr), .len = buf.len }};
- const rc = linux.process_vm_readv(linux.getpid(), &local, &remote, 0);
- switch (linux.errno(rc)) {
- .SUCCESS => return rc,
- .FAULT => return error.Unmapped,
- .NOSYS, .PERM => return error.Unsupported,
- else => return error.Unexpected,
- }
- }
-
- /// Writes `data` at `addr` via process_vm_writev. Read-only mappings also
- /// report `error.Unmapped` (the kernel says EFAULT for both).
- pub fn writeMem(d: *Debug, addr: usize, data: []const u8) Error!usize {
- _ = d;
- if (data.len == 0) return 0;
- if (addr == 0) return error.Unmapped;
- const local = [_]std.posix.iovec_const{.{ .base = data.ptr, .len = data.len }};
- const remote = [_]std.posix.iovec_const{.{ .base = @ptrFromInt(addr), .len = data.len }};
- const rc = linux.process_vm_writev(linux.getpid(), &local, &remote, 0);
- switch (linux.errno(rc)) {
- .SUCCESS => return rc,
- .FAULT => return error.Unmapped,
- .NOSYS, .PERM => return error.Unsupported,
- else => return error.Unexpected,
- }
- }
-
- /// Hexdump of `len` bytes at `addr` in the shape of `std.debug.dumpHex`
- /// (16 bytes per line, address column, bytes in two groups, ASCII column).
- /// Stops early at the first unmapped byte; `error.Unmapped` only when the
- /// very first chunk is unreadable.
- pub fn hexdump(d: *Debug, addr: usize, len: usize, w: *Writer) Error!void {
- var chunk: [256]u8 = undefined;
- var done: usize = 0;
- while (done < len) {
- const want = @min(chunk.len, len - done);
- const got = d.readMem(addr +% done, chunk[0..want]) catch |err| switch (err) {
- error.Unmapped => if (done == 0) return error.Unmapped else break,
- else => return err,
- };
- if (got == 0) break;
- try writeHexLines(addr +% done, chunk[0..got], w);
- done += got;
- if (got < want) break;
- }
- }
-
- /// Copies /proc/self/maps to `w`.
- pub fn maps(d: *Debug, w: *Writer) Error!void {
- _ = d;
- return streamFile("/proc/self/maps", w);
- }
-
- /// Reads `buf.len` bytes of /proc/self/maps at `offset` (0 at the end).
- /// Not a consistent snapshot across reads; a map appearing between two
- /// reads shifts the text, like `cat` on /proc itself.
- pub fn readMaps(d: *Debug, offset: u64, buf: []u8) Error!usize {
- _ = d;
- if (offset > std.math.maxInt(i64)) return 0;
- return preadFile("/proc/self/maps", offset, buf);
- }
-
- // ------------------------------------------------------------ breakpoints
-
- /// The nth tid currently parked in `@breakpoint()`.
- pub fn pausedAt(d: *Debug, index: usize) ?u32 {
- var n: usize = 0;
- for (d.paused[0..d.max_paused]) |*slot| {
- if (slot.state.load(.acquire) != pause_paused) continue;
- if (n == index) return slot.tid.load(.acquire);
- n += 1;
- }
- return null;
- }
-
- pub fn isPaused(d: *Debug, tid: u32) bool {
- return d.pausedSlot(tid) != null;
- }
-
- pub fn pausedStack(d: *Debug, tid: u32, w: *Writer) Error!void {
- const slot = d.pausedSlot(tid) orelse return error.NotPaused;
- if (!d.selfInfoFree()) return error.Busy;
- var addrs: [max_frames]usize = undefined;
- const trace = d.unwindContext(&slot.ctx, &addrs);
- try d.writeFrames(trace.return_addresses, w);
- }
-
- pub fn pausedRegs(d: *Debug, tid: u32, w: *Writer) Error!void {
- const slot = d.pausedSlot(tid) orelse return error.NotPaused;
- return writeRegs(&slot.ctx, w);
- }
-
- /// Lets a parked thread continue past its breakpoint.
- pub fn resumeThread(d: *Debug, tid: u32) Error!void {
- const slot = d.pausedSlot(tid) orelse return error.NotPaused;
- if (slot.state.cmpxchgStrong(pause_paused, pause_resuming, .acq_rel, .acquire) != null) return error.NotPaused;
- futexWake(&slot.state);
- }
-
- fn pausedSlot(d: *Debug, tid: u32) ?*PauseSlot {
- for (d.paused[0..d.max_paused]) |*slot| {
- if (slot.state.load(.acquire) == pause_paused and slot.tid.load(.acquire) == tid) return slot;
- }
- return null;
- }
-
- // ------------------------------------------------------------------ panic
-
- /// The recorded panic message; nothing before any panic.
- pub fn panicMessage(d: *Debug, w: *Writer) Error!void {
- _ = d;
- if (panic_state.load(.acquire) == panic_none) return;
- w.writeAll(panic_msg[0..panic_msg_len]) catch return error.WriteFailed;
- }
-
- /// Frames of the panicking thread, symbolized lazily.
- pub fn panicStack(d: *Debug, w: *Writer) Error!void {
- if (panic_state.load(.acquire) == panic_none) return;
- try d.writeFrames(panic_addrs[0..panic_addr_count], w);
- }
-
- /// True while a panicking thread is parked waiting for `panicContinue`.
- pub fn panicHeld(d: *Debug) bool {
- _ = d;
- return panic_state.load(.acquire) == panic_held;
- }
-
- /// Releases the held panicking thread into `std.debug.defaultPanic`.
- pub fn panicContinue(d: *Debug) Error!void {
- _ = d;
- if (panic_state.cmpxchgStrong(panic_held, panic_continued, .acq_rel, .acquire) != null) return error.NoPanic;
- futexWake(&panic_state);
- }
-
- // -------------------------------------------------------------- internals
-
- fn scanThreads(d: *Debug) void {
- d.tid_count = 0;
- const fd_rc = linux.open("/proc/self/task", .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0);
- if (linux.errno(fd_rc) != .SUCCESS) return;
- const fd: i32 = @intCast(fd_rc);
- defer _ = linux.close(fd);
- var buf: [4096]u8 align(@alignOf(linux.dirent64)) = undefined;
- while (true) {
- const rc = linux.getdents64(fd, &buf, buf.len);
- if (linux.errno(rc) != .SUCCESS or rc == 0) break;
- var off: usize = 0;
- while (off < rc) {
- const ent: *align(1) const linux.dirent64 = @ptrCast(&buf[off]);
- const name_ptr: [*:0]const u8 = @ptrCast(&buf[off + @offsetOf(linux.dirent64, "name")]);
- const name = std.mem.span(name_ptr);
- if (std.fmt.parseInt(u32, name, 10)) |tid| {
- if (d.tid_count < max_threads) {
- d.tids[d.tid_count] = tid;
- d.tid_count += 1;
- }
- } else |_| {}
- off += ent.reclen;
- }
- }
- std.mem.sort(u32, d.tids[0..d.tid_count], {}, std.sort.asc(u32));
- }
-
- /// Arms the capture slot for `tid`, signals it and waits until the handler
- /// has parked with its context copied. On success the caller owns the
- /// slot until `releaseCapture`.
- fn captureThread(d: *Debug, tid: u32) Error!void {
- const slot = &d.capture;
- slot.target_tid.store(tid, .release);
- slot.state.store(cap_armed, .release);
- const rc = linux.tgkill(linux.getpid(), @intCast(tid), d.capture_signal);
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .SRCH => {
- slot.state.store(cap_idle, .release);
- return error.NoThread;
- },
- else => {
- slot.state.store(cap_idle, .release);
- return error.Unexpected;
- },
- }
- const deadline = monotonicNs() + d.capture_timeout_ns;
- while (true) {
- const s = slot.state.load(.acquire);
- switch (s) {
- cap_captured => return,
- cap_failed => {
- slot.state.store(cap_idle, .release);
- return error.Unsupported;
- },
- cap_armed => {
- const now = monotonicNs();
- if (now >= deadline) {
- // Disarm; if the handler raced us it has moved on to
- // `capturing` and we simply keep waiting for it.
- if (slot.state.cmpxchgStrong(cap_armed, cap_idle, .acq_rel, .acquire) == null) return error.Timeout;
- continue;
- }
- futexWaitNs(&slot.state, cap_armed, deadline - now);
- },
- // The handler is copying registers; it finishes promptly.
- cap_capturing => futexWaitNs(&slot.state, cap_capturing, 1 * std.time.ns_per_ms),
- else => unreachable,
- }
- }
- }
-
- /// Records the PT_LOAD ranges of every module `dl_iterate_phdr` reports
- /// (for a static executable: the executable itself, not the vDSO).
- fn scanModules(d: *Debug) void {
- d.range_count = 0;
- std.posix.dl_iterate_phdr(d, error{}, struct {
- fn cb(info: *std.posix.dl_phdr_info, _: usize, ctx: *Debug) error{}!void {
- for (info.phdr[0..info.phnum]) |phdr| {
- if (phdr.type != .LOAD) continue;
- if (ctx.range_count == max_ranges) return;
- ctx.ranges[ctx.range_count] = .{ .start = info.addr +% phdr.vaddr, .len = phdr.memsz };
- ctx.range_count += 1;
- }
- }
- }.cb) catch {};
- }
-
- /// True when `addr` lies in a module `std.debug` already knows about, so
- /// that asking it about `addr` cannot trigger a module rescan.
- fn knownCode(d: *const Debug, addr: usize) bool {
- for (d.ranges[0..d.range_count]) |r| {
- if (addr >= r.start and addr - r.start < r.len) return true;
- }
- return false;
- }
-
- /// Unwinds from a saved context. A pc outside every known module (e.g. a
- /// thread inside the vDSO) is reported as a single frame and not unwound,
- /// because std would otherwise rescan its module list (see the header).
- fn unwindContext(d: *const Debug, ctx: *const Native, addrs: *[max_frames]usize) std.debug.StackTrace {
- if (!d.knownCode(ctx.getPc())) {
- addrs[0] = ctx.getPc() +| 1;
- return .{ .return_addresses = addrs[0..1], .skipped = .unknown };
- }
- return std.debug.captureCurrentStackTrace(.{ .context = ctx }, addrs);
- }
-
- /// True when nobody holds std.debug's `SelfInfo` lock right now. Called
- /// with the target parked, so a held lock means the *target* (or another
- /// live thread, which will let go) holds it; only the former deadlocks,
- /// and the caller cannot tell them apart, so both yield `error.Busy`.
- fn selfInfoFree(d: *const Debug) bool {
- if (comptime !@hasField(std.debug.SelfInfo, "rwlock")) return true;
- const di = std.debug.getSelfDebugInfo() catch return true;
- if (!di.rwlock.tryLock(d.io)) return false;
- di.rwlock.unlock(d.io);
- return true;
- }
-
- fn releaseCapture(d: *Debug) void {
- d.capture.state.store(cap_idle, .release);
- futexWake(&d.capture.state);
- }
-
- fn writeFrames(d: *Debug, addrs: []const usize, w: *Writer) Error!void {
- var fba = std.heap.FixedBufferAllocator.init(d.text_buf);
- const alloc = fba.allocator();
- const di = std.debug.getSelfDebugInfo() catch return error.Unsupported;
- for (addrs, 0..) |ret_addr, i| {
- // Return addresses point after the call; the first frame of a
- // context capture is stored as pc+1 by std for the same reason.
- const addr = ret_addr -| 1;
- fba.reset();
- var symbols: std.ArrayList(std.debug.Symbol) = .empty;
- var sym = std.debug.Symbol.unknown;
- if (d.knownCode(addr)) {
- if (di.getSymbols(d.io, alloc, alloc, addr, true, &symbols)) {
- if (symbols.items.len > 0) sym = symbols.items[0];
- } else |_| {}
- }
- w.print("#{d} 0x{x} in {s} (", .{ i, addr, sym.name orelse "?" }) catch return error.WriteFailed;
- if (sym.source_location) |sl| {
- w.print("{s}:{d}:{d})\n", .{ sl.file_name, sl.line, sl.column }) catch return error.WriteFailed;
- } else {
- w.writeAll("?)\n") catch return error.WriteFailed;
- }
- }
- }
-};
-
-// ------------------------------------------------------------------ handlers
-
-fn selfTid() u32 {
- return @intCast(linux.gettid());
-}
-
-fn captureHandler(_: linux.SIG, _: *const linux.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) void {
- const d = current orelse return;
- const slot = &d.capture;
- const me = selfTid();
- if (slot.target_tid.load(.acquire) != me) return;
- if (slot.state.cmpxchgStrong(cap_armed, cap_capturing, .acq_rel, .acquire) != null) return;
- // The tid check and the swap are not one atomic step: a stale run (a
- // signal that stayed pending while its request timed out) may have read
- // the old tid and then won the swap of a request re-armed for another
- // thread. `target_tid` is fixed while the slot is armed, so re-checking
- // after the swap closes the window; hand the slot back untouched.
- if (slot.target_tid.load(.acquire) != me) {
- slot.state.store(cap_armed, .release);
- futexWake(&slot.state);
- return;
- }
- if (cpu_context.fromPosixSignalContext(ctx_ptr)) |ctx| {
- slot.ctx = ctx;
- slot.state.store(cap_captured, .release);
- futexWake(&slot.state);
- while (slot.state.load(.acquire) == cap_captured) futexWaitNs(&slot.state, cap_captured, null);
- } else {
- slot.state.store(cap_failed, .release);
- futexWake(&slot.state);
- }
-}
-
-/// aarch64 Linux ucontext_t, only as far as `mcontext.pc` (see
-/// std.debug.cpu_context's signal_ucontext_t).
-const UcontextAarch64 = extern struct {
- flags: usize,
- link: ?*UcontextAarch64,
- stack: linux.stack_t,
- sigmask: linux.sigset_t,
- unused: [120]u8,
- mcontext: extern struct {
- fault_address: u64 align(16),
- x: [30]u64,
- lr: u64,
- sp: u64,
- pc: u64,
- },
-};
-
-fn trapHandler(_: linux.SIG, info: *const linux.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) void {
- const d = current orelse return trapFallback();
- const ctx = cpu_context.fromPosixSignalContext(ctx_ptr) orelse return trapFallback();
- // si_code > 0 is kernel-generated (TRAP_BRKPT for int3/brk); <= 0 is
- // kill/tgkill/sigqueue from user space, where PC points at the
- // interrupted instruction and must not be touched.
- const from_instruction = info.code > 0;
- if (comptime arch.isAARCH64()) {
- // `brk #imm` does not advance PC; step over it so returning from the
- // handler does not re-trap.
- if (from_instruction) {
- const uc: *UcontextAarch64 = @ptrCast(@alignCast(ctx_ptr.?));
- uc.mcontext.pc += 4;
- }
- } else if (comptime arch != .x86_64) {
- return trapFallback();
- }
- const tid = selfTid();
- if (tid == server_tid.load(.acquire)) {
- // Nobody could resume the thread that serves /breakpoints: step over.
- _ = traps_skipped.fetchAdd(1, .acq_rel);
- return;
- }
- const slot: *PauseSlot = for (d.paused[0..d.max_paused]) |*slot| {
- if (slot.state.cmpxchgStrong(pause_free, pause_claimed, .acq_rel, .acquire) == null) break slot;
- } else {
- _ = traps_skipped.fetchAdd(1, .acq_rel);
- return;
- };
- slot.ctx = ctx;
- slot.tid.store(tid, .release);
- slot.state.store(pause_paused, .release);
- while (slot.state.load(.acquire) == pause_paused) futexWaitNs(&slot.state, pause_paused, null);
- slot.state.store(pause_free, .release);
-}
-
-/// Restores the default SIGTRAP disposition and re-raises it: the signal is
-/// blocked while the handler runs, so it is delivered (fatally) on return.
-/// Only for a handler run with no `Debug` (a trap in flight during `deinit`)
-/// or on an architecture whose context cannot be read.
-fn trapFallback() void {
- const act: linux.Sigaction = .{
- .handler = .{ .handler = linux.SIG.DFL },
- .mask = linux.sigemptyset(),
- .flags = 0,
- };
- _ = linux.sigaction(.TRAP, &act, null);
- _ = linux.tkill(linux.gettid(), .TRAP);
-}
-
-// --------------------------------------------------------------------- panic
-
-const panic_none: u32 = 0;
-const panic_recording: u32 = 1;
-const panic_recorded: u32 = 2;
-const panic_held: u32 = 3;
-const panic_continued: u32 = 4;
-
-var panic_state: std.atomic.Value(u32) = .init(panic_none);
-var panic_msg: [panic_msg_cap]u8 = undefined;
-var panic_msg_len: usize = 0;
-var panic_addrs: [max_frames]usize = undefined;
-var panic_addr_count: usize = 0;
-/// The tid of the panicking thread (0 before any panic).
-pub var panic_tid: u32 = 0;
-
-/// Records the first panic: message (bounded copy) and stack addresses.
-/// Returns false if a panic was already recorded (nested or second panic).
-pub fn recordPanic(msg: []const u8, first_trace_addr: ?usize) bool {
- if (panic_state.cmpxchgStrong(panic_none, panic_recording, .acq_rel, .acquire) != null) return false;
- panic_tid = selfTid();
- panic_msg_len = @min(msg.len, panic_msg.len);
- @memcpy(panic_msg[0..panic_msg_len], msg[0..panic_msg_len]);
- const trace = std.debug.captureCurrentStackTrace(.{ .first_address = first_trace_addr }, &panic_addrs);
- panic_addr_count = trace.return_addresses.len;
- panic_state.store(panic_recorded, .release);
- return true;
-}
-
-/// Parks the panicking thread until `Debug.panicContinue` when holding is
-/// enabled and a `Debug` exists; then hands over to `std.debug.defaultPanic`.
-pub fn panicHook(msg: []const u8, first_trace_addr: ?usize) noreturn {
- @branchHint(.cold);
- if (recordPanic(msg, first_trace_addr)) {
- // The server thread cannot be held: it is the one that would have to
- // serve /panic/ctl.
- if (hold_on_panic and current != null and panic_tid != server_tid.load(.acquire)) {
- if (panic_state.cmpxchgStrong(panic_recorded, panic_held, .acq_rel, .acquire) == null) {
- while (panic_state.load(.acquire) == panic_held) futexWaitNs(&panic_state, panic_held, null);
- }
- }
- }
- std.debug.defaultPanic(msg, first_trace_addr);
-}
-
-/// Clears the recorded panic. Only meaningful in tests of the record path.
-pub fn resetPanicRecord() void {
- panic_msg_len = 0;
- panic_addr_count = 0;
- panic_tid = 0;
- panic_state.store(panic_none, .release);
-}
-
-// ------------------------------------------------------------------- helpers
-
-fn futexWake(word: *std.atomic.Value(u32)) void {
- _ = linux.futex_3arg(&word.raw, .{ .cmd = .WAKE, .private = true }, std.math.maxInt(u32));
-}
-
-/// Waits while `*word == expect`, at most `timeout_ns` (forever when null).
-/// Returns on wake, timeout, value change or EINTR; callers loop.
-fn futexWaitNs(word: *std.atomic.Value(u32), expect: u32, timeout_ns: ?u64) void {
- var ts: linux.timespec = undefined;
- const ts_ptr: ?*const linux.timespec = if (timeout_ns) |ns| blk: {
- ts = .{ .sec = @intCast(ns / std.time.ns_per_s), .nsec = @intCast(ns % std.time.ns_per_s) };
- break :blk &ts;
- } else null;
- _ = linux.futex_4arg(&word.raw, .{ .cmd = .WAIT, .private = true }, expect, ts_ptr);
-}
-
-fn monotonicNs() u64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(.MONOTONIC, &ts);
- return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec));
-}
-
-const FileError = error{ NotFound, Unexpected, TooBig };
-
-/// Reads a whole (small) file with raw syscalls.
-fn readFile(path: [*:0]const u8, buf: []u8) FileError![]u8 {
- const fd_rc = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
- switch (linux.errno(fd_rc)) {
- .SUCCESS => {},
- .NOENT, .SRCH => return error.NotFound,
- else => return error.Unexpected,
- }
- const fd: i32 = @intCast(fd_rc);
- defer _ = linux.close(fd);
- var len: usize = 0;
- while (len < buf.len) {
- const rc = linux.read(fd, buf[len..].ptr, buf.len - len);
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .INTR => continue,
- .SRCH, .NOENT => return error.NotFound,
- else => return error.Unexpected,
- }
- if (rc == 0) return buf[0..len];
- len += rc;
- }
- return error.TooBig;
-}
-
-/// One pread of `buf.len` bytes at `offset`; 0 at the end of the file.
-fn preadFile(path: [*:0]const u8, offset: u64, buf: []u8) Error!usize {
- const fd_rc = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
- if (linux.errno(fd_rc) != .SUCCESS) return error.Unexpected;
- const fd: i32 = @intCast(fd_rc);
- defer _ = linux.close(fd);
- var len: usize = 0;
- while (len < buf.len) {
- const rc = linux.pread(fd, buf[len..].ptr, buf.len - len, @intCast(offset + len));
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .INTR => continue,
- else => return error.Unexpected,
- }
- if (rc == 0) break;
- len += rc;
- }
- return len;
-}
-
-/// Streams a file of any size to `w`.
-fn streamFile(path: [*:0]const u8, w: *Writer) Error!void {
- const fd_rc = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
- if (linux.errno(fd_rc) != .SUCCESS) return error.Unexpected;
- const fd: i32 = @intCast(fd_rc);
- defer _ = linux.close(fd);
- var buf: [4096]u8 = undefined;
- while (true) {
- const rc = linux.read(fd, &buf, buf.len);
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .INTR => continue,
- else => return error.Unexpected,
- }
- if (rc == 0) return;
- w.writeAll(buf[0..rc]) catch return error.WriteFailed;
- }
-}
-
-fn writeHexLines(base: usize, bytes: []const u8, w: *Writer) Error!void {
- var offset: usize = 0;
- while (offset < bytes.len) : (offset += 16) {
- const line = bytes[offset..@min(offset + 16, bytes.len)];
- w.print("{x:0>[1]} ", .{ base +% offset, @sizeOf(usize) * 2 }) catch return error.WriteFailed;
- for (line, 0..) |byte, i| {
- w.print("{X:0>2} ", .{byte}) catch return error.WriteFailed;
- if (i == 7) w.writeByte(' ') catch return error.WriteFailed;
- }
- w.writeByte(' ') catch return error.WriteFailed;
- if (line.len < 16) {
- var missing = (16 - line.len) * 3;
- if (line.len < 8) missing += 1;
- w.splatByteAll(' ', missing) catch return error.WriteFailed;
- }
- for (line) |byte| {
- w.writeByte(if (std.ascii.isPrint(byte)) byte else '.') catch return error.WriteFailed;
- }
- w.writeByte('\n') catch return error.WriteFailed;
- }
-}
-
-fn writeRegs(ctx: *const Native, w: *Writer) Error!void {
- if (comptime arch == .x86_64) {
- inline for (@typeInfo(Native.Gpr).@"enum".fields) |f| {
- w.print("{s} 0x{x}\n", .{ f.name, ctx.gprs.get(@field(Native.Gpr, f.name)) }) catch return error.WriteFailed;
- }
- w.print("pc 0x{x}\nsp 0x{x}\nfp 0x{x}\n", .{
- ctx.gprs.get(.rip), ctx.gprs.get(.rsp), ctx.gprs.get(.rbp),
- }) catch return error.WriteFailed;
- } else if (comptime arch.isAARCH64()) {
- for (ctx.x, 0..) |x, i| w.print("x{d} 0x{x}\n", .{ i, x }) catch return error.WriteFailed;
- w.print("sp 0x{x}\npc 0x{x}\nfp 0x{x}\nlr 0x{x}\n", .{
- ctx.sp, ctx.pc, ctx.x[29], ctx.x[30],
- }) catch return error.WriteFailed;
- } else {
- w.print("pc 0x{x}\nfp 0x{x}\n", .{ ctx.getPc(), ctx.getFp() }) catch return error.WriteFailed;
- }
-}
-
-// --------------------------------------------------------------------- tests
-
-const testing = std.testing;
-
-fn testOptions(text_buf: []u8) Options {
- return .{ .io = testing.io, .text_buf = text_buf };
-}
-
-noinline fn sleepMs(ms: u64) void {
- var ts: linux.timespec = .{ .sec = @intCast(ms / 1000), .nsec = @intCast((ms % 1000) * std.time.ns_per_ms) };
- _ = linux.nanosleep(&ts, null);
-}
-
-// The test threads use atomic builtins rather than `std.atomic.Value` methods
-// so that, in release modes, their pc is never inside an inlined callee: the
-// DWARF symbolizer names the innermost inlined function at an address (see
-// the notes on `writeFrames`).
-const SpinState = struct {
- tid: std.atomic.Value(u32) = .init(0),
- stop: bool = false,
- counter: u32 = 0,
- done: bool = false,
-};
-
-noinline fn spinHere(st: *SpinState) void {
- while (!@atomicLoad(bool, &st.stop, .acquire)) {
- _ = @atomicRmw(u32, &st.counter, .Add, 1, .monotonic);
- }
-}
-
-fn spinThreadMain(st: *SpinState) void {
- st.tid.store(selfTid(), .release);
- spinHere(st);
- @atomicStore(bool, &st.done, true, .release); // keeps the call above from becoming a tail call
-}
-
-fn waitForTid(st: *SpinState) u32 {
- var tries: usize = 0;
- while (st.tid.load(.acquire) == 0) : (tries += 1) {
- if (tries > 2000) return 0;
- sleepMs(1);
- }
- return st.tid.load(.acquire);
-}
-
-test "capture own stack" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
- try testing.expect(current == &d);
-
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- try d.threadStack(selfTid(), &out.writer);
- const text = out.written();
- try testing.expect(std.mem.indexOf(u8, text, "#0 0x") != null);
- try testing.expect(std.mem.indexOf(u8, text, "debug.zig:") != null);
- try testing.expect(std.mem.indexOf(u8, text, "test.capture own stack") != null);
-
- out.clearRetainingCapacity();
- try d.threadRegs(selfTid(), &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "pc 0x") != null);
- try testing.expect(std.mem.indexOf(u8, out.written(), "pc 0x0\n") == null);
-}
-
-test "capture another thread: stack, regs, name, stat" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
-
- var st: SpinState = .{};
- const th = try std.Thread.spawn(.{}, spinThreadMain, .{&st});
- const tid = waitForTid(&st);
- try testing.expect(tid != 0);
-
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- try d.threadStack(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "spinHere") != null);
- try testing.expect(std.mem.indexOf(u8, out.written(), "spinThreadMain") != null);
-
- out.clearRetainingCapacity();
- try d.threadRegs(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "pc 0x") != null);
- try testing.expect(std.mem.indexOf(u8, out.written(), "pc 0x0\n") == null);
-
- out.clearRetainingCapacity();
- try d.threadName(tid, &out.writer);
- try testing.expect(out.written().len > 0);
- try testing.expect(std.mem.indexOfScalar(u8, out.written(), '\n') == null);
-
- out.clearRetainingCapacity();
- try d.threadStat(tid, &out.writer);
- try testing.expect(std.mem.startsWith(u8, out.written(), "state "));
- try testing.expect(std.mem.indexOf(u8, out.written(), "\nutime ") != null);
-
- // Enumeration lists both threads and nothing bogus.
- try testing.expect(d.threadExists(tid));
- try testing.expect(d.threadExists(selfTid()));
- var found_self = false;
- var found_other = false;
- var i: usize = 0;
- var prev: u32 = 0;
- while (d.threadAt(i)) |t| : (i += 1) {
- try testing.expect(t > prev);
- prev = t;
- if (t == tid) found_other = true;
- if (t == selfTid()) found_self = true;
- }
- try testing.expect(found_self and found_other);
-
- // Repeated captures of the same thread keep working.
- var k: usize = 0;
- while (k < 5) : (k += 1) {
- out.clearRetainingCapacity();
- try d.threadStack(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "spinHere") != null);
- }
- const before = @atomicLoad(u32, &st.counter, .acquire);
- sleepMs(2);
- try testing.expect(@atomicLoad(u32, &st.counter, .acquire) != before); // the thread is running again
-
- @atomicStore(bool, &st.stop, true, .release);
- th.join();
- try testing.expect(!d.threadExists(tid));
- try testing.expectError(error.NoThread, d.threadStack(tid, &out.writer));
- try testing.expectError(error.NoThread, d.threadName(tid, &out.writer));
-}
-
-/// The address of the call site in the caller, i.e. inside this file's test.
-noinline fn callerAddress() usize {
- return @returnAddress() - 1;
-}
-
-test "resolveAddr names this file" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- try d.resolveAddr(callerAddress(), &out.writer);
- const text = out.written();
- var lines = std.mem.splitScalar(u8, text, '\n');
- const fn_name = lines.next().?;
- const loc = lines.next().?;
- const module = lines.next().?;
- try testing.expect(fn_name.len > 0 and !std.mem.eql(u8, fn_name, "?"));
- try testing.expect(std.mem.indexOf(u8, loc, "debug.zig:") != null);
- try testing.expect(module.len > 0);
-
- out.clearRetainingCapacity();
- try d.resolveAddr(8, &out.writer);
- try testing.expectEqualStrings("?\n?\n?\n", out.written());
-
- // Regression: an unmapped lookup must not poison std's unwind cache (see
- // the header); unwinding afterwards still works.
- out.clearRetainingCapacity();
- try d.threadStack(selfTid(), &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "test.resolveAddr names this file") != null);
-}
-
-test "readMem, writeMem, hexdump" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
-
- var value: [8]u8 = .{ 1, 2, 3, 4, 5, 6, 7, 8 };
- var got: [8]u8 = undefined;
- try testing.expectEqual(@as(usize, 8), try d.readMem(@intFromPtr(&value), &got));
- try testing.expectEqualSlices(u8, &value, &got);
- try testing.expectError(error.Unmapped, d.readMem(8, &got));
-
- const new = [_]u8{ 0xaa, 0xbb, 0xcc };
- try testing.expectEqual(@as(usize, 3), try d.writeMem(@intFromPtr(&value) + 2, &new));
- try testing.expectEqualSlices(u8, &.{ 1, 2, 0xaa, 0xbb, 0xcc, 6, 7, 8 }, &value);
- try testing.expectError(error.Unmapped, d.writeMem(8, &new));
-
- var bytes: [19]u8 = .{ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x12, 0x13 };
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- try d.hexdump(@intFromPtr(&bytes), bytes.len, &out.writer);
- const expected = try std.fmt.allocPrint(testing.allocator,
- \\{x:0>[2]} 00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF .."3DUfw........
- \\{x:0>[2]} 01 12 13 ...
- \\
- , .{ @intFromPtr(&bytes), @intFromPtr(&bytes) + 16, @sizeOf(usize) * 2 });
- defer testing.allocator.free(expected);
- try testing.expectEqualStrings(expected, out.written());
- try testing.expectError(error.Unmapped, d.hexdump(8, 16, &out.writer));
-
- // Address 0 (also reached by an offset that wraps) must be an error, not a
- // safety-checked null pointer cast on the server thread.
- try testing.expectError(error.Unmapped, d.readMem(0, &got));
- try testing.expectError(error.Unmapped, d.writeMem(0, &new));
- try testing.expectError(error.Unmapped, d.hexdump(0, 16, &out.writer));
- try testing.expectError(error.Unmapped, d.readMem(std.math.maxInt(usize) - 3, &got));
- try testing.expectError(error.Unmapped, d.hexdump(std.math.maxInt(usize) - 3, 16, &out.writer));
-
- out.clearRetainingCapacity();
- try d.maps(&out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "[stack]") != null);
-
- // readMaps serves the file piecewise at any offset and ends with 0.
- var piece: [4096]u8 = undefined;
- var total: usize = 0;
- while (true) {
- const n = try d.readMaps(total, &piece);
- if (n == 0) break;
- total += n;
- }
- try testing.expect(total >= out.written().len / 2);
- try testing.expectEqual(@as(usize, 0), try d.readMaps(std.math.maxInt(u64), &piece));
-}
-
-test "breakpoint on the server thread and past the slot table steps over; tgkill SIGTRAP parks" {
- if (arch != .x86_64 and !arch.isAARCH64()) return error.SkipZigTest;
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- var opts = testOptions(&text_buf);
- opts.max_paused = 1;
- try d.init(opts);
- defer d.deinit();
- try d.enableBreakpoints();
- defer d.disableBreakpoints();
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
-
- // The "server" thread (this one, for the test) hits a breakpoint: it keeps running.
- const skipped0 = traps_skipped.load(.acquire);
- server_tid.store(selfTid(), .release);
- defer server_tid.store(0, .release);
- @breakpoint();
- try testing.expectEqual(skipped0 + 1, traps_skipped.load(.acquire));
- try testing.expect(!d.isPaused(selfTid()));
-
- // One slot: the first trapping thread parks, the second steps over.
- var a: TrapState = .{};
- const ta = try std.Thread.spawn(.{}, trapThreadMain, .{&a});
- var tries: usize = 0;
- while (a.tid.load(.acquire) == 0 or !d.isPaused(a.tid.load(.acquire))) : (tries += 1) {
- try testing.expect(tries < 5000);
- sleepMs(1);
- }
- var b: TrapState = .{};
- const tb = try std.Thread.spawn(.{}, trapThreadMain, .{&b});
- tb.join();
- try testing.expectEqual(@as(u32, 1), @atomicLoad(u32, &b.counter, .acquire));
- try testing.expectEqual(skipped0 + 2, traps_skipped.load(.acquire));
- try testing.expectEqual(@as(u32, 0), @atomicLoad(u32, &a.counter, .acquire));
- try d.resumeThread(a.tid.load(.acquire));
- ta.join();
- try testing.expectEqual(@as(u32, 1), @atomicLoad(u32, &a.counter, .acquire));
-
- // A SIGTRAP sent with tgkill (not an int3/brk) parks the thread where it
- // was; resuming it must not skip an instruction: the spinner keeps counting.
- var st: SpinState = .{};
- const th = try std.Thread.spawn(.{}, spinThreadMain, .{&st});
- const tid = waitForTid(&st);
- try testing.expect(tid != 0);
- try testing.expectEqual(linux.E.SUCCESS, linux.errno(linux.tgkill(linux.getpid(), @intCast(tid), .TRAP)));
- tries = 0;
- while (!d.isPaused(tid)) : (tries += 1) {
- try testing.expect(tries < 5000);
- sleepMs(1);
- }
- const frozen = @atomicLoad(u32, &st.counter, .acquire);
- sleepMs(5);
- try testing.expectEqual(frozen, @atomicLoad(u32, &st.counter, .acquire));
- out.clearRetainingCapacity();
- try d.pausedStack(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "spinHere") != null);
- try d.resumeThread(tid);
- sleepMs(5);
- try testing.expect(@atomicLoad(u32, &st.counter, .acquire) != frozen);
- @atomicStore(bool, &st.stop, true, .release);
- th.join();
-}
-
-const LockState = struct {
- tid: std.atomic.Value(u32) = .init(0),
- release: std.atomic.Value(bool) = .init(false),
- unlocked: std.atomic.Value(bool) = .init(false),
- stop: std.atomic.Value(bool) = .init(false),
- io: std.Io,
-};
-
-fn lockHolderMain(st: *LockState) void {
- const di = std.debug.getSelfDebugInfo() catch return;
- di.rwlock.lockUncancelable(st.io);
- st.tid.store(selfTid(), .release);
- while (!st.release.load(.acquire)) sleepMs(1);
- di.rwlock.unlock(st.io);
- st.unlocked.store(true, .release);
- while (!st.stop.load(.acquire)) sleepMs(1);
-}
-
-test "a target parked while holding std.debug's lock is Busy, not a deadlock" {
- if (comptime !@hasField(std.debug.SelfInfo, "rwlock")) return error.SkipZigTest;
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
- var st: LockState = .{ .io = testing.io };
- const th = try std.Thread.spawn(.{}, lockHolderMain, .{&st});
- var tries: usize = 0;
- while (st.tid.load(.acquire) == 0) : (tries += 1) {
- try testing.expect(tries < 2000);
- sleepMs(1);
- }
- const tid = st.tid.load(.acquire);
- // No allocation while the holder has the lock: `testing.allocator`
- // records a stack trace per allocation, which needs that same lock.
- var buf: [16 * 1024]u8 = undefined;
- var w: Writer = .fixed(&buf);
- try testing.expectError(error.Busy, d.threadStack(tid, &w));
- try testing.expectEqual(cap_idle, d.capture.state.load(.acquire));
- // Registers need no unwind and are still available.
- try d.threadRegs(tid, &w);
- try testing.expect(std.mem.indexOf(u8, w.buffered(), "pc 0x") != null);
- // Handshake, not a sleep: a slow holder would otherwise still hold the
- // lock and the next capture would legitimately be Busy again.
- st.release.store(true, .release);
- tries = 0;
- while (!st.unlocked.load(.acquire)) : (tries += 1) {
- try testing.expect(tries < 5000);
- sleepMs(1);
- }
- w = .fixed(&buf);
- try d.threadStack(tid, &w);
- try testing.expect(std.mem.indexOf(u8, w.buffered(), "lockHolderMain") != null);
- st.stop.store(true, .release);
- th.join();
-}
-
-const TrapState = struct {
- tid: std.atomic.Value(u32) = .init(0),
- counter: u32 = 0,
-};
-
-noinline fn trapThreadMain(st: *TrapState) void {
- st.tid.store(selfTid(), .release);
- @breakpoint();
- _ = @atomicRmw(u32, &st.counter, .Add, 1, .acq_rel);
-}
-
-test "breakpoint: pause, inspect, resume" {
- if (arch != .x86_64 and !arch.isAARCH64()) return error.SkipZigTest;
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
- try d.enableBreakpoints();
-
- var st: TrapState = .{};
- const th = try std.Thread.spawn(.{}, trapThreadMain, .{&st});
- var tries: usize = 0;
- while (st.tid.load(.acquire) == 0 or !d.isPaused(st.tid.load(.acquire))) : (tries += 1) {
- try testing.expect(tries < 5000);
- sleepMs(1);
- }
- const tid = st.tid.load(.acquire);
- try testing.expectEqual(@as(?u32, tid), d.pausedAt(0));
- try testing.expectEqual(@as(?u32, null), d.pausedAt(1));
- try testing.expectEqual(@as(u32, 0), @atomicLoad(u32, &st.counter, .acquire));
-
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- try d.pausedStack(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "trapThreadMain") != null);
- out.clearRetainingCapacity();
- try d.pausedRegs(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "pc 0x") != null);
-
- // A paused thread can also be captured through the signal path.
- out.clearRetainingCapacity();
- try d.threadStack(tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "#0 0x") != null);
-
- sleepMs(5);
- try testing.expectEqual(@as(u32, 0), @atomicLoad(u32, &st.counter, .acquire));
- try d.resumeThread(tid);
- th.join();
- try testing.expectEqual(@as(u32, 1), @atomicLoad(u32, &st.counter, .acquire));
- try testing.expect(!d.isPaused(tid));
- try testing.expectEqual(@as(?u32, null), d.pausedAt(0));
- try testing.expectError(error.NotPaused, d.resumeThread(tid));
- try testing.expectError(error.NotPaused, d.pausedStack(tid, &out.writer));
- d.disableBreakpoints();
-}
-
-/// Stands in for `FullPanic`'s call: the first trace address is the return
-/// address into the panicking function.
-noinline fn panicLike(msg: []const u8) bool {
- return recordPanic(msg, @returnAddress());
-}
-
-test "panic record path" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- try d.init(testOptions(&text_buf));
- defer d.deinit();
- defer resetPanicRecord();
-
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- try d.panicMessage(&out.writer);
- try testing.expectEqualStrings("", out.written());
- try testing.expect(!d.panicHeld());
- try testing.expectError(error.NoPanic, d.panicContinue());
-
- try testing.expect(panicLike("something broke"));
- try testing.expect(!recordPanic("nested", null));
- try testing.expectEqual(selfTid(), panic_tid);
-
- try d.panicMessage(&out.writer);
- try testing.expectEqualStrings("something broke", out.written());
- out.clearRetainingCapacity();
- try d.panicStack(&out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "#0 0x") != null);
- try testing.expect(std.mem.indexOf(u8, out.written(), "test.panic record path") != null);
- try testing.expect(!d.panicHeld());
- try testing.expectError(error.NoPanic, d.panicContinue());
-
- // A long message is truncated, not overflowed.
- resetPanicRecord();
- const long = [_]u8{'x'} ** (panic_msg_cap + 100);
- try testing.expect(recordPanic(&long, null));
- out.clearRetainingCapacity();
- try d.panicMessage(&out.writer);
- try testing.expectEqual(@as(usize, panic_msg_cap), out.written().len);
-}
-
-const MaskState = struct {
- tid: std.atomic.Value(u32) = .init(0),
- unblock: std.atomic.Value(bool) = .init(false),
- stop: std.atomic.Value(bool) = .init(false),
- signal: linux.SIG,
-};
-
-fn maskedThreadMain(st: *MaskState) void {
- var set = linux.sigemptyset();
- linux.sigaddset(&set, st.signal);
- _ = linux.sigprocmask(linux.SIG.BLOCK, &set, null);
- st.tid.store(selfTid(), .release);
- while (!st.unblock.load(.acquire)) sleepMs(1);
- _ = linux.sigprocmask(linux.SIG.UNBLOCK, &set, null);
- while (!st.stop.load(.acquire)) sleepMs(1);
-}
-
-test "capture timeout on a thread with the signal masked" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: Debug = undefined;
- var opts = testOptions(&text_buf);
- opts.capture_timeout_ns = 50 * std.time.ns_per_ms;
- try d.init(opts);
- defer d.deinit();
-
- var st: MaskState = .{ .signal = d.capture_signal };
- const th = try std.Thread.spawn(.{}, maskedThreadMain, .{&st});
- var tries: usize = 0;
- while (st.tid.load(.acquire) == 0) : (tries += 1) {
- try testing.expect(tries < 2000);
- sleepMs(1);
- }
- const masked_tid = st.tid.load(.acquire);
-
- var out: Writer.Allocating = .init(testing.allocator);
- defer out.deinit();
- const t0 = monotonicNs();
- try testing.expectError(error.Timeout, d.threadStack(masked_tid, &out.writer));
- try testing.expect(monotonicNs() - t0 >= 50 * std.time.ns_per_ms);
- try testing.expectEqual(cap_idle, d.capture.state.load(.acquire));
-
- // The process is healthy: another thread can still be captured...
- var spin: SpinState = .{};
- const spinner = try std.Thread.spawn(.{}, spinThreadMain, .{&spin});
- const spin_tid = waitForTid(&spin);
- try testing.expect(spin_tid != 0);
- out.clearRetainingCapacity();
- try d.threadStack(spin_tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "spinHere") != null);
-
- // ...and the late delivery of the pending signal is harmless.
- st.unblock.store(true, .release);
- sleepMs(20);
- out.clearRetainingCapacity();
- try d.threadStack(spin_tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "spinHere") != null);
- out.clearRetainingCapacity();
- try d.threadStack(masked_tid, &out.writer);
- try testing.expect(std.mem.indexOf(u8, out.written(), "maskedThreadMain") != null);
-
- @atomicStore(bool, &spin.stop, true, .release);
- spinner.join();
- st.stop.store(true, .release);
- th.join();
-}
-
-test "options validation and single instance" {
- var text_buf: [4096]u8 = undefined;
- var d: Debug = undefined;
- var opts = testOptions(&text_buf);
- opts.capture_signal = 5;
- try testing.expectError(error.InvalidOptions, d.init(opts));
- opts = testOptions(&text_buf);
- opts.max_paused = max_paused_cap + 1;
- try testing.expectError(error.InvalidOptions, d.init(opts));
- try d.init(testOptions(&text_buf));
- defer d.deinit();
- var d2: Debug = undefined;
- try testing.expectError(error.AlreadyInitialized, d2.init(testOptions(&text_buf)));
-}
diff --git a/introspect/src/linux/probe.zig b/introspect/src/linux/probe.zig
deleted file mode 100644
index f38c491..0000000
--- a/introspect/src/linux/probe.zig
+++ /dev/null
@@ -1,829 +0,0 @@
-//! The Linux platform layer: one background thread runs a `poll()` loop over
-//! a listener and every client connection, feeding each connection's core
-//! `Conn` with `push`/`step`/`output`/`wrote`. No per-connection threads, no
-//! allocation after `init`; every buffer lives in a caller-placed `Storage`.
-//!
-//! Also home of the debug facilities (`debug`, `provider`) and the /runtime
-//! generators (`runtime`). See docs/LIBRARY.md.
-//!
-//! Client admission: a new connection takes a free slot. When every slot is
-//! taken, the connection that has held a slot without any fid (never
-//! attached, or fully clunked) for longer than `evict_idle_ms` is dropped in
-//! its favour; if there is none, the new connection is closed ("refused").
-//! Nothing that holds a fid is ever evicted.
-//!
-//! `sleepServing(ms)` lets a request handler (a ctl command, say) wait
-//! without stalling the other clients: called on the probe thread from inside
-//! a request it keeps running the poll loop for every client whose request
-//! is not in progress until the time is up. Requests served from inside such
-//! a wait may wait themselves, up to `max_nested_sleeps` deep (each level is a
-//! different client, so the depth is bounded by the client table anyway); the
-//! level past that, and any call off the probe thread, is a plain sleep.
-const std = @import("std");
-const builtin = @import("builtin");
-const linux = std.os.linux;
-const cloud9 = @import("cloud9");
-const core = @import("../core.zig");
-
-pub const debug = @import("debug.zig");
-pub const provider = @import("provider.zig");
-pub const runtime = @import("runtime.zig");
-pub const DebugProvider = provider.DebugProvider;
-
-pub const Listen = union(enum) {
- /// A unix socket path (< 108 bytes); a stale socket file is unlinked first.
- unix: []const u8,
- /// An IPv4 literal "a.b.c.d:port".
- tcp: []const u8,
- /// An already listening socket, owned by the caller.
- fd: i32,
- /// One pre-connected client on these descriptors (stdio: 0 and 1). Nothing
- /// is accepted and the loop ends when the client hangs up.
- client: struct { in: i32, out: i32 },
-};
-
-pub const Options = struct {
- /// For `std.debug` symbolization.
- io: std.Io,
- listen: Listen,
- /// Largest msize offered to clients (clamped to the server's `cfg.msize`).
- msize: u32 = 64 * 1024,
- /// Hold a panicking thread until /panic/ctl says "continue".
- hold_on_panic: bool = true,
- /// Real-time signal used to snapshot other threads.
- capture_signal: u8 = debug.default_capture_signal,
- /// Install the SIGTRAP handler so `@breakpoint()` parks the thread.
- breakpoints: bool = true,
- /// Mount /threads, /addr, /mem, /hex, /breakpoints, /panic (six provider slots).
- mount_debug: bool = true,
-};
-
-pub const Error = error{
- /// Another `Debug` (another probe) exists in this process.
- AlreadyInitialized,
- /// No register capture on this architecture.
- Unsupported,
- /// `Shared` has fewer than six free provider slots.
- TooManyProviders,
- PathTooLong,
- BadAddress,
- /// A syscall failed; `last_errno` says which error.
- Syscall,
-};
-
-/// Idle time without fids after which a slot holder may be evicted.
-pub const evict_idle_ms: i64 = 500;
-/// Largest number of connections accepted per poll wakeup.
-const accept_burst = 64;
-/// How deep `sleepServing` may nest (each level keeps a poll round on the stack).
-pub const max_nested_sleeps = 8;
-
-/// Static per-client storage: `max_clients` core `Storage`s and `Conn`s, the
-/// poll table, the debug text arena and the debug provider's snapshot pool.
-pub fn Storage(comptime max_clients: u8, comptime Srv: type) type {
- return Probe(Srv).Storage(max_clients);
-}
-
-pub fn Probe(comptime Srv: type) type {
- return struct {
- const Self = @This();
-
- pub fn Storage(comptime max_clients: u8) type {
- comptime std.debug.assert(max_clients > 0);
- return struct {
- pub const capacity = max_clients;
- conns: [max_clients]Srv.Storage,
- clients: [max_clients]Client,
- /// [0] wake eventfd, [1] listener, [2..] one per client slot.
- pollfds: [max_clients + 2]linux.pollfd,
- text_buf: [16 * 1024]u8,
- dp: DebugProvider,
- };
- }
-
- pub const Client = struct {
- conn: Srv.Conn,
- in: i32 = -1,
- out: i32 = -1,
- used: bool = false,
- /// Descriptors we opened (accepted) are closed on drop; borrowed ones are not.
- owned: bool = false,
- /// Send with MSG_NOSIGNAL; falls back to write(2) on ENOTSOCK.
- is_socket: bool = true,
- /// Monotonic ms of the last byte received.
- last_active: i64 = 0,
- };
-
- shared: *Srv.Shared,
- clients: []Client,
- conns: []Srv.Storage,
- pollfds: []linux.pollfd,
- dbg: debug.Debug,
- dp: *DebugProvider,
- msize: u32,
- listen_fd: i32 = -1,
- own_listener: bool = false,
- is_tcp: bool = false,
- single: bool = false,
- wake_fd: i32 = -1,
- unix_path: [108]u8 = undefined,
- unix_len: usize = 0,
- thread: ?std.Thread = null,
- thread_tid: std.atomic.Value(u32) = .init(0),
- nclients: std.atomic.Value(u32) = .init(0),
- /// Connections closed because no slot was free.
- refused: u64 = 0,
- stopping: std.atomic.Value(bool) = .init(false),
- /// Slots whose request is being handled (excluded from nested servicing and eviction).
- serving: std.StaticBitSet(256) = .initEmpty(),
- /// Current `sleepServing` nesting depth.
- nested: u8 = 0,
- debug_ready: bool = false,
- last_errno: linux.E = .SUCCESS,
-
- // -- lifecycle -------------------------------------------------------
-
- /// Installs the debug facilities, mounts the debug providers into
- /// `shared` and opens the listener. `storage` is a `*Storage(n)`. On
- /// failure `shared` may already hold the debug providers and must be
- /// discarded.
- pub fn init(p: *Self, shared: *Srv.Shared, storage: anytype, opts: Options) Error!void {
- p.* = .{
- .shared = shared,
- .clients = &storage.clients,
- .conns = &storage.conns,
- .pollfds = &storage.pollfds,
- .dbg = undefined,
- .dp = &storage.dp,
- .msize = opts.msize,
- };
- for (p.clients) |*c| c.used = false;
- shared.hash_seed = randomSeed();
- debug.hold_on_panic = opts.hold_on_panic;
- p.dbg.init(.{ .io = opts.io, .text_buf = &storage.text_buf, .capture_signal = opts.capture_signal }) catch |e| return switch (e) {
- error.AlreadyInitialized => error.AlreadyInitialized,
- error.Unsupported => error.Unsupported,
- else => error.Syscall,
- };
- p.debug_ready = true;
- errdefer {
- p.dbg.deinit();
- p.debug_ready = false;
- }
- if (opts.breakpoints) p.dbg.enableBreakpoints() catch |e| switch (e) {
- // No breakpoint support on this architecture: everything else still works.
- error.Unsupported => {},
- else => return error.Syscall,
- };
- if (opts.mount_debug) {
- p.dp.init(&p.dbg);
- p.dp.mountAll(shared) catch return error.TooManyProviders;
- }
- const efd = linux.eventfd(0, linux.EFD.CLOEXEC | linux.EFD.NONBLOCK);
- try p.check(efd);
- p.wake_fd = @intCast(efd);
- errdefer {
- _ = linux.close(p.wake_fd);
- p.wake_fd = -1;
- }
- switch (opts.listen) {
- .unix => |path| try p.listenUnix(path),
- .tcp => |text| try p.listenTcp(text),
- .fd => |fd| {
- try p.setNonblock(fd);
- p.listen_fd = fd;
- },
- .client => |c| {
- p.single = true;
- try p.setNonblock(c.in);
- if (c.out != c.in) try p.setNonblock(c.out);
- _ = p.addClient(c.in, c.out, false);
- },
- }
- }
-
- /// Spawns the poll thread.
- pub fn start(p: *Self) std.Thread.SpawnError!void {
- std.debug.assert(p.thread == null);
- p.stopping.store(false, .release);
- p.thread = try std.Thread.spawn(.{}, run, .{p});
- }
-
- /// Waits for the poll thread to end (only happens by itself in
- /// `.client` mode, when the client hangs up).
- pub fn wait(p: *Self) void {
- if (p.thread) |t| {
- t.join();
- p.thread = null;
- }
- }
-
- /// Stops the poll thread, drops every client, closes what `init`
- /// opened and restores the signal dispositions.
- pub fn stop(p: *Self) void {
- // Joining the poll thread from itself would hang forever; a
- // request handler that wants the server gone uses `requestStop`.
- std.debug.assert(p.thread_tid.load(.acquire) != @as(u32, @intCast(linux.gettid())));
- p.stopping.store(true, .release);
- p.wakeLoop();
- p.wait();
- for (p.clients, 0..) |*c, i| if (c.used) p.dropClient(i);
- if (p.listen_fd >= 0) {
- if (p.own_listener) _ = linux.close(p.listen_fd);
- p.listen_fd = -1;
- }
- if (p.unix_len > 0) {
- _ = linux.unlink(@ptrCast(&p.unix_path));
- p.unix_len = 0;
- }
- if (p.wake_fd >= 0) {
- _ = linux.close(p.wake_fd);
- p.wake_fd = -1;
- }
- if (p.debug_ready) {
- p.dbg.deinit();
- p.debug_ready = false;
- }
- }
-
- /// Live client count (for /runtime/clients).
- pub fn clientCount(p: *const Self) u32 {
- return p.nclients.load(.acquire);
- }
-
- pub fn clientCounter(p: *const Self) *const std.atomic.Value(u32) {
- return &p.nclients;
- }
-
- /// Waits `ms` while keeping the other clients served (see the file comment).
- pub fn sleepServing(p: *Self, ms: u64) void {
- const on_thread = p.thread_tid.load(.acquire) == @as(u32, @intCast(linux.gettid()));
- if (!on_thread or p.serving.count() == 0 or p.nested >= max_nested_sleeps) return sleepMs(ms);
- p.nested += 1;
- defer p.nested -= 1;
- const deadline = monotonicMs() + @as(i64, @intCast(@min(ms, std.math.maxInt(i32))));
- while (!p.stopping.load(.acquire)) {
- const now = monotonicMs();
- if (now >= deadline) break;
- p.pollOnce(@intCast(deadline - now));
- }
- }
-
- /// Asks the poll thread to stop; safe to call from a signal handler
- /// (an atomic store and one write to the wake eventfd). `stop` (or
- /// `wait`) still has to run afterwards to release everything.
- pub fn requestStop(p: *Self) void {
- p.stopping.store(true, .release);
- p.wakeLoop();
- }
-
- // -- the loop --------------------------------------------------------
-
- fn run(p: *Self) void {
- const tid: u32 = @intCast(linux.gettid());
- p.thread_tid.store(tid, .release);
- // A breakpoint or panic on this thread must never park it (see debug.zig).
- debug.server_tid.store(tid, .release);
- setThreadName("introspect");
- while (!p.stopping.load(.acquire)) {
- if (p.single and p.clientCount() == 0) break;
- p.pollOnce(-1);
- }
- debug.server_tid.store(0, .release);
- p.thread_tid.store(0, .release);
- }
-
- fn wakeLoop(p: *Self) void {
- if (p.wake_fd < 0) return;
- const one: u64 = 1;
- _ = linux.write(p.wake_fd, @ptrCast(&one), 8);
- }
-
- /// One `poll()` round: accept, read, step, write. Slots whose request
- /// is in progress (`serving`, only inside `sleepServing`) are left untouched.
- fn pollOnce(p: *Self, timeout_ms: i32) void {
- p.pollfds[0] = .{ .fd = p.wake_fd, .events = linux.POLL.IN, .revents = 0 };
- p.pollfds[1] = .{ .fd = p.listen_fd, .events = linux.POLL.IN, .revents = 0 };
- for (p.clients, 0..) |*c, i| {
- var fd: i32 = -1;
- var events: i16 = 0;
- if (c.used and !p.serving.isSet(i)) {
- fd = c.in;
- if (c.conn.output().len > 0) {
- fd = c.out;
- events = linux.POLL.OUT;
- } else if (inputRoom(&c.conn) > 0) {
- events = linux.POLL.IN;
- }
- }
- p.pollfds[2 + i] = .{ .fd = fd, .events = events, .revents = 0 };
- }
- const rc = linux.poll(p.pollfds.ptr, p.pollfds.len, timeout_ms);
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .INTR => return,
- else => {
- sleepMs(10);
- return;
- },
- }
- if (p.pollfds[0].revents != 0) {
- var v: u64 = 0;
- _ = linux.read(p.wake_fd, @ptrCast(&v), 8);
- }
- if (p.stopping.load(.acquire)) return;
- if (p.pollfds[1].revents != 0) p.acceptSome();
- for (p.clients, 0..) |*c, i| {
- const re = p.pollfds[2 + i].revents;
- if (re == 0 or !c.used or p.serving.isSet(i)) continue;
- if (re & (linux.POLL.IN | linux.POLL.HUP | linux.POLL.ERR | linux.POLL.NVAL) != 0) {
- p.readClient(i, re & linux.POLL.HUP != 0);
- } else if (re & linux.POLL.OUT != 0) {
- p.service(i);
- }
- if (p.stopping.load(.acquire)) return;
- }
- }
-
- fn acceptSome(p: *Self) void {
- var n: usize = 0;
- while (n < accept_burst) : (n += 1) {
- const rc = linux.accept4(p.listen_fd, null, null, linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC);
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .AGAIN => return,
- .INTR, .CONNABORTED => continue,
- // Descriptor/memory exhaustion is transient (clients hang up);
- // back off instead of spinning on a readable listener.
- .MFILE, .NFILE, .NOBUFS, .NOMEM, .PERM => {
- sleepMs(100);
- return;
- },
- else => return,
- }
- const cfd: i32 = @intCast(rc);
- if (p.is_tcp) {
- const one: u32 = 1;
- _ = linux.setsockopt(cfd, linux.IPPROTO.TCP, linux.TCP.NODELAY, @ptrCast(&one), @sizeOf(u32));
- }
- if (p.addClient(cfd, cfd, true) != null) continue;
- if (p.evictable()) |victim| {
- p.dropClient(victim);
- _ = p.addClient(cfd, cfd, true);
- continue;
- }
- p.refused += 1;
- // A flood must not flood stderr.
- if (p.refused == 1 or p.refused % 1000 == 0)
- std.debug.print("introspect: refused connection ({d} clients open, {d} refused so far)\n", .{ p.clients.len, p.refused });
- _ = linux.close(cfd);
- }
- }
-
- /// The longest-idle slot holder without fids, if idle long enough.
- fn evictable(p: *Self) ?usize {
- const now = monotonicMs();
- var best: ?usize = null;
- for (p.clients, 0..) |*c, i| {
- if (!c.used or !c.owned) continue;
- if (p.serving.isSet(i)) continue;
- if (c.conn.fidCount() != 0) continue;
- if (now - c.last_active < evict_idle_ms) continue;
- if (best == null or c.last_active < p.clients[best.?].last_active) best = i;
- }
- return best;
- }
-
- fn addClient(p: *Self, in: i32, out: i32, owned: bool) ?usize {
- for (p.clients, 0..) |*c, i| {
- if (c.used) continue;
- c.conn = Srv.Conn.init(p.shared, &p.conns[i], p.msize);
- c.in = in;
- c.out = out;
- c.used = true;
- c.owned = owned;
- c.is_socket = true;
- c.last_active = monotonicMs();
- _ = p.nclients.fetchAdd(1, .acq_rel);
- return i;
- }
- return null;
- }
-
- fn dropClient(p: *Self, i: usize) void {
- const c = &p.clients[i];
- if (!c.used) return;
- c.conn.hangup();
- if (c.owned) {
- _ = linux.close(c.in);
- if (c.out != c.in) _ = linux.close(c.out);
- }
- c.used = false;
- c.in = -1;
- c.out = -1;
- _ = p.nclients.fetchSub(1, .acq_rel);
- }
-
- /// Free space in the connection's input buffer (cloud9 keeps one
- /// msize-sized frame; `push` copies at most this much).
- fn inputRoom(conn: *const Srv.Conn) usize {
- return conn.server.in.len - conn.server.in_len;
- }
-
- fn readClient(p: *Self, i: usize, hup: bool) void {
- const c = &p.clients[i];
- var buf: [64 * 1024]u8 = undefined;
- const room = inputRoom(&c.conn);
- if (room == 0) return p.service(i);
- const want = @min(room, buf.len);
- while (true) {
- const rc = linux.read(c.in, &buf, want);
- switch (linux.errno(rc)) {
- .SUCCESS => {
- if (rc == 0) return p.dropClient(i);
- const taken = c.conn.push(buf[0..rc]);
- std.debug.assert(taken == rc);
- c.last_active = monotonicMs();
- return p.service(i);
- },
- .INTR => continue,
- .AGAIN => {
- if (hup) p.dropClient(i);
- return;
- },
- else => return p.dropClient(i),
- }
- }
- }
-
- /// Runs requests and drains output until nothing moves.
- fn service(p: *Self, i: usize) void {
- const c = &p.clients[i];
- std.debug.assert(!p.serving.isSet(i));
- p.serving.set(i);
- defer p.serving.unset(i);
- while (c.used) {
- var moved = false;
- while (true) {
- const more = c.conn.step() catch return p.dropClient(i);
- if (!more) break;
- moved = true;
- }
- const before = c.conn.output().len;
- p.flush(c) catch return p.dropClient(i);
- if (c.conn.output().len != before) moved = true;
- if (!moved) return;
- }
- }
-
- fn flush(p: *Self, c: *Client) error{Closed}!void {
- _ = p;
- while (c.conn.output().len > 0) {
- const chunk = c.conn.output();
- const rc = if (c.is_socket)
- linux.sendto(c.out, chunk.ptr, chunk.len, linux.MSG.NOSIGNAL, null, 0)
- else
- linux.write(c.out, chunk.ptr, chunk.len);
- switch (linux.errno(rc)) {
- .SUCCESS => {
- if (rc == 0) return;
- c.conn.wrote(rc);
- },
- .INTR => continue,
- .AGAIN => return,
- .NOTSOCK => c.is_socket = false,
- else => return error.Closed,
- }
- }
- }
-
- // -- listeners -------------------------------------------------------
-
- fn check(p: *Self, rc: usize) Error!void {
- const e = linux.errno(rc);
- if (e != .SUCCESS) {
- p.last_errno = e;
- return error.Syscall;
- }
- }
-
- fn setNonblock(p: *Self, fd: i32) Error!void {
- const rc = linux.fcntl(fd, linux.F.GETFL, 0);
- try p.check(rc);
- const nonblock: u32 = @bitCast(linux.O{ .NONBLOCK = true });
- try p.check(linux.fcntl(fd, linux.F.SETFL, rc | nonblock));
- }
-
- fn listenUnix(p: *Self, path: []const u8) Error!void {
- var sa: linux.sockaddr.un = .{ .path = @splat(0) };
- if (path.len == 0 or path.len >= sa.path.len) return error.PathTooLong;
- @memcpy(sa.path[0..path.len], path);
- const rc = linux.socket(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC | linux.SOCK.NONBLOCK, 0);
- try p.check(rc);
- const lfd: i32 = @intCast(rc);
- errdefer _ = linux.close(lfd);
- // No libc, so no "is it still listening" probe: unlink a stale socket and bind.
- _ = linux.unlink(@ptrCast(&sa.path));
- try p.check(linux.bind(lfd, @ptrCast(&sa), @sizeOf(linux.sockaddr.un)));
- try p.check(linux.listen(lfd, 128));
- p.listen_fd = lfd;
- p.own_listener = true;
- p.unix_path = sa.path;
- p.unix_len = path.len;
- }
-
- fn listenTcp(p: *Self, text: []const u8) Error!void {
- const sa = parseIpv4(text) orelse return error.BadAddress;
- const rc = linux.socket(linux.AF.INET, linux.SOCK.STREAM | linux.SOCK.CLOEXEC | linux.SOCK.NONBLOCK, 0);
- try p.check(rc);
- const lfd: i32 = @intCast(rc);
- errdefer _ = linux.close(lfd);
- const one: u32 = 1;
- _ = linux.setsockopt(lfd, linux.SOL.SOCKET, linux.SO.REUSEADDR, @ptrCast(&one), @sizeOf(u32));
- try p.check(linux.bind(lfd, @ptrCast(&sa), @sizeOf(linux.sockaddr.in)));
- try p.check(linux.listen(lfd, 128));
- p.listen_fd = lfd;
- p.own_listener = true;
- p.is_tcp = true;
- }
- };
-}
-
-/// Entropy for the core's fid hash (so fid numbers cannot be chosen to
-/// collide); falls back to the clock if getrandom fails.
-fn randomSeed() u32 {
- var b: [4]u8 = undefined;
- if (linux.errno(linux.getrandom(&b, b.len, 0)) == .SUCCESS) return std.mem.readInt(u32, &b, .little);
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(.MONOTONIC, &ts);
- return @truncate(@as(u64, @bitCast(ts.nsec)) ^ (@as(u64, @bitCast(ts.sec)) << 20));
-}
-
-/// "a.b.c.d:port" as a socket address, or null.
-pub fn parseIpv4(text: []const u8) ?linux.sockaddr.in {
- const colon = std.mem.lastIndexOfScalar(u8, text, ':') orelse return null;
- const port = std.fmt.parseInt(u16, text[colon + 1 ..], 10) catch return null;
- var octets: [4]u8 = undefined;
- var it = std.mem.splitScalar(u8, text[0..colon], '.');
- for (&octets) |*o| o.* = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null;
- if (it.next() != null) return null;
- return .{ .port = std.mem.nativeToBig(u16, port), .addr = @bitCast(octets) };
-}
-
-/// Names the calling thread (comm, at most 15 bytes) via prctl.
-pub fn setThreadName(name: []const u8) void {
- var buf: [16]u8 = @splat(0);
- const n = @min(name.len, 15);
- @memcpy(buf[0..n], name[0..n]);
- _ = linux.prctl(@intFromEnum(linux.PR.SET_NAME), @intFromPtr(&buf), 0, 0, 0);
-}
-
-pub fn sleepMs(ms: u64) void {
- var req: linux.timespec = .{ .sec = @intCast(ms / 1000), .nsec = @intCast((ms % 1000) * 1_000_000) };
- var rem: linux.timespec = undefined;
- while (linux.errno(linux.nanosleep(&req, &rem)) == .INTR) req = rem;
-}
-
-pub fn monotonicMs() i64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(.MONOTONIC, &ts);
- return ts.sec * 1000 + @divTrunc(ts.nsec, 1_000_000);
-}
-
-// ---------------------------------------------------------------------------
-// Tests: a real unix socket, a cloud9.Client on the other end.
-// ---------------------------------------------------------------------------
-
-const testing = std.testing;
-
-test {
- _ = debug;
- _ = provider;
- _ = runtime;
-}
-
-const TestBuild = struct {
- pub const zig_version: []const u8 = builtin.zig_version_string;
- pub const target: []const u8 = "test";
- pub const optimize: []const u8 = "Debug";
- pub const time: []const u8 = "2024-01-01T00:00:00Z";
- pub const change: []const u8 = "none";
-};
-
-const test_cfg: core.Config = .{
- .name = "probetest",
- .build = TestBuild,
- .msize = 8192,
- .max_fids = 16,
- .max_providers = 6,
- .snapshot_slots = 2,
- .snapshot_bytes = 1024,
-};
-const TS = core.Server(test_cfg);
-const TP = Probe(TS);
-
-/// A blocking client over a connected socket.
-const SockClient = struct {
- fd: i32,
- client: cloud9.Client,
- cin: [8192]u8 = undefined,
- cout: [8192]u8 = undefined,
-
- fn connect(sc: *SockClient, path: []const u8) !void {
- var sa: linux.sockaddr.un = .{ .path = @splat(0) };
- @memcpy(sa.path[0..path.len], path);
- const rc = linux.socket(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0);
- if (linux.errno(rc) != .SUCCESS) return error.Socket;
- sc.fd = @intCast(rc);
- if (linux.errno(linux.connect(sc.fd, &sa, @sizeOf(linux.sockaddr.un))) != .SUCCESS) return error.Connect;
- sc.client = .init(.{ .in = &sc.cin, .out = &sc.cout });
- }
-
- fn close(sc: *SockClient) void {
- _ = linux.close(sc.fd);
- }
-
- /// One round trip; null when the server closed the connection.
- fn rpc(sc: *SockClient, req: cloud9.Client.Request) !?cloud9.Client.Result {
- _ = try sc.client.submit(req);
- while (sc.client.output().len > 0) {
- const out = sc.client.output();
- const rc = linux.write(sc.fd, out.ptr, out.len);
- switch (linux.errno(rc)) {
- .SUCCESS => sc.client.wrote(rc),
- .PIPE, .CONNRESET => return null,
- else => return error.Write,
- }
- }
- var buf: [8192]u8 = undefined;
- while (true) {
- if (sc.client.take()) |done| return done.result;
- const rc = linux.read(sc.fd, &buf, buf.len);
- switch (linux.errno(rc)) {
- .SUCCESS => {},
- .CONNRESET => return null,
- else => return error.Read,
- }
- if (rc == 0) return null;
- var rest: []const u8 = buf[0..rc];
- while (rest.len > 0) rest = rest[sc.client.push(rest)..];
- }
- }
-
- fn session(sc: *SockClient) !void {
- const v = (try sc.rpc(.{ .version = .{ .msize = 8192 } })) orelse return error.Closed;
- try testing.expectEqual(@as(u32, 8192), v.version.msize);
- const a = (try sc.rpc(.{ .attach = .{ .fid = 0, .uname = "t" } })) orelse return error.Closed;
- try testing.expect(a == .attach);
- }
-
- fn readFile(sc: *SockClient, names: []const []const u8, out: []u8) ![]u8 {
- const w = (try sc.rpc(.{ .walk = .{ .fid = 0, .newfid = 1, .names = names } })) orelse return error.Closed;
- try testing.expectEqual(@as(u16, @intCast(names.len)), w.walk.nwqid);
- _ = (try sc.rpc(.{ .open = .{ .fid = 1, .mode = cloud9.oread } })) orelse return error.Closed;
- const r = (try sc.rpc(.{ .read = .{ .fid = 1, .offset = 0, .count = @intCast(out.len) } })) orelse return error.Closed;
- const n = r.read.len;
- @memcpy(out[0..n], r.read);
- _ = (try sc.rpc(.{ .clunk = .{ .fid = 1 } })) orelse return error.Closed;
- return out[0..n];
- }
-};
-
-fn testSockPath(buf: []u8, tag: []const u8) ![]const u8 {
- return std.fmt.bufPrint(buf, "/tmp/introspect-probe-{d}-{s}.sock", .{ linux.getpid(), tag });
-}
-
-const TestCtx = struct { info: runtime.Info };
-
-test "probe: start, serve a client over a unix socket, stop" {
- var ctx: TestCtx = .{ .info = .now() };
- var shared: TS.Shared = .init(&ctx);
- const storage = try testing.allocator.create(TP.Storage(2));
- defer testing.allocator.destroy(storage);
- var probe: TP = undefined;
- var path_buf: [64]u8 = undefined;
- const path = try testSockPath(&path_buf, "basic");
- try probe.init(&shared, storage, .{ .io = testing.io, .listen = .{ .unix = path } });
- defer probe.stop();
- try probe.start();
- ctx.info.clients = probe.clientCounter();
-
- var sc: SockClient = undefined;
- try sc.connect(path);
- defer sc.close();
- try sc.session();
- var buf: [1024]u8 = undefined;
- const zv = try sc.readFile(&.{ "build", "zig_version" }, &buf);
- try testing.expectEqualStrings(builtin.zig_version_string, zv);
- try testing.expectEqual(@as(u32, 1), probe.clientCount());
-
- // The debug providers are mounted: /threads lists the probe thread by name.
- const names = (try sc.rpc(.{ .walk = .{ .fid = 0, .newfid = 2, .names = &.{"threads"} } })) orelse return error.Closed;
- try testing.expectEqual(@as(u16, 1), names.walk.nwqid);
- _ = (try sc.rpc(.{ .open = .{ .fid = 2, .mode = cloud9.oread } })) orelse return error.Closed;
- const dir = (try sc.rpc(.{ .read = .{ .fid = 2, .offset = 0, .count = 4096 } })) orelse return error.Closed;
- try testing.expect(dir.read.len > 0);
- _ = (try sc.rpc(.{ .clunk = .{ .fid = 2 } })) orelse return error.Closed;
-
- // A missing file is the Plan 9 error string.
- const bad = (try sc.rpc(.{ .walk = .{ .fid = 0, .newfid = 3, .names = &.{"nope"} } })) orelse return error.Closed;
- try testing.expect(bad == .fail);
- try testing.expectEqualStrings("file does not exist", bad.fail);
-
- probe.stop();
- // stop() is idempotent and the socket file is gone.
- probe.stop();
- var gone: SockClient = undefined;
- try testing.expectError(error.Connect, gone.connect(path));
- // The debug facilities can be set up again after stop.
- var probe2: TP = undefined;
- var shared2: TS.Shared = .init(&ctx);
- try probe2.init(&shared2, storage, .{ .io = testing.io, .listen = .{ .unix = path } });
- probe2.stop();
-}
-
-test "probe: max_clients refusal and idle eviction" {
- var ctx: TestCtx = .{ .info = .now() };
- var shared: TS.Shared = .init(&ctx);
- const storage = try testing.allocator.create(TP.Storage(2));
- defer testing.allocator.destroy(storage);
- var probe: TP = undefined;
- var path_buf: [64]u8 = undefined;
- const path = try testSockPath(&path_buf, "limit");
- try probe.init(&shared, storage, .{ .io = testing.io, .listen = .{ .unix = path } });
- defer probe.stop();
- try probe.start();
-
- // Two attached clients fill the table; a third is accepted then closed.
- var a: SockClient = undefined;
- try a.connect(path);
- defer a.close();
- try a.session();
- var b: SockClient = undefined;
- try b.connect(path);
- defer b.close();
- try b.session();
- var c: SockClient = undefined;
- try c.connect(path);
- defer c.close();
- try testing.expectEqual(@as(?cloud9.Client.Result, null), try c.rpc(.{ .version = .{ .msize = 8192 } }));
- try testing.expectEqual(@as(u64, 1), probe.refused);
- // Attached clients are never evicted, even when idle for long.
- sleepMs(evict_idle_ms + 100);
- var d: SockClient = undefined;
- try d.connect(path);
- defer d.close();
- try testing.expectEqual(@as(?cloud9.Client.Result, null), try d.rpc(.{ .version = .{ .msize = 8192 } }));
- var buf: [256]u8 = undefined;
- _ = try a.readFile(&.{"README"}, &buf);
-
- // A client without fids that has been idle long enough gives way.
- _ = (try b.rpc(.{ .clunk = .{ .fid = 0 } })) orelse return error.Closed;
- sleepMs(evict_idle_ms + 100);
- var e: SockClient = undefined;
- try e.connect(path);
- defer e.close();
- try e.session();
- try testing.expectEqual(@as(?cloud9.Client.Result, null), try b.rpc(.{ .version = .{ .msize = 8192 } }));
- try testing.expectEqual(@as(u32, 2), probe.clientCount());
-}
-
-test "probe: single pre-connected client mode ends when the client hangs up" {
- var ctx: TestCtx = .{ .info = .now() };
- var shared: TS.Shared = .init(&ctx);
- const storage = try testing.allocator.create(TP.Storage(1));
- defer testing.allocator.destroy(storage);
- var sv: [2]i32 = undefined;
- try testing.expectEqual(linux.E.SUCCESS, linux.errno(linux.socketpair(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0, &sv)));
- var probe: TP = undefined;
- try probe.init(&shared, storage, .{ .io = testing.io, .listen = .{ .client = .{ .in = sv[1], .out = sv[1] } } });
- defer probe.stop();
- try probe.start();
- var sc: SockClient = .{ .fd = sv[0], .client = undefined };
- sc.client = .init(.{ .in = &sc.cin, .out = &sc.cout });
- try sc.session();
- var buf: [256]u8 = undefined;
- try testing.expect((try sc.readFile(&.{"README"}, &buf)).len > 0);
- try testing.expectEqual(@as(u32, 1), probe.clientCount());
- _ = linux.close(sv[0]);
- probe.wait();
- try testing.expectEqual(@as(u32, 0), probe.clientCount());
- _ = linux.close(sv[1]);
-}
-
-test "parseIpv4 and sleepServing off the probe thread" {
- const sa = parseIpv4("127.0.0.1:564").?;
- try testing.expectEqual(std.mem.nativeToBig(u16, 564), sa.port);
- try testing.expectEqual(@as(u32, @bitCast([4]u8{ 127, 0, 0, 1 })), sa.addr);
- try testing.expect(parseIpv4("localhost:1") == null);
- try testing.expect(parseIpv4("1.2.3:1") == null);
- try testing.expect(parseIpv4("1.2.3.4") == null);
- try testing.expect(parseIpv4("1.2.3.4:70000") == null);
- const t0 = monotonicMs();
- var probe: TP = undefined;
- probe.thread_tid = .init(0);
- probe.serving = .initEmpty();
- probe.nested = 0;
- probe.sleepServing(20);
- try testing.expect(monotonicMs() - t0 >= 20);
-}
diff --git a/introspect/src/linux/provider.zig b/introspect/src/linux/provider.zig
deleted file mode 100644
index 9952398..0000000
--- a/introspect/src/linux/provider.zig
+++ /dev/null
@@ -1,604 +0,0 @@
-//! Adapts `debug.Debug` into core `Provider`s. The core mounts providers at
-//! top level only, so one `DebugProvider` registers six of them, all sharing
-//! the same `Debug` and the same snapshot pool:
-//!
-//! /threads/<tid>/{name,stat,stack,regs} (lists only live tids)
-//! /addr/<hex> "fn\nfile:line:col\nmodule\n"
-//! /mem/maps, /mem/<hex> /proc/self/maps; raw bytes at address+offset (writable)
-//! /hex/<hex> hexdump of 256 bytes at the address
-//! /breakpoints/<tid>/{stack,regs,ctl} ctl accepts "continue" (lists only paused tids)
-//! /panic/{message,stack,ctl} ctl accepts "continue"
-//!
-//! /addr, /mem, /hex and /panic carry a README; /threads and /breakpoints
-//! list nothing but tids so that a shell glob over them sees only threads.
-//!
-//! Handles encode `(kind, tid-or-address)` in 56 bits (the core keeps the
-//! low 56 bits of a handle for the qid path): kind in bits 48..55, value in
-//! bits 0..47. Handles carry no reference count, so `clunk` is a no-op.
-//!
-//! The core hands providers a buffer-based `read`, not a writer, so every
-//! text file is generated at `open` into one of `snapshot_slots` fixed slots
-//! (keyed by handle, reference counted across fids) and served from there; a
-//! read at offset 0 regenerates, like the core's own dynamic files. `/mem/<hex>`
-//! is read and written directly at address+offset and never snapshotted, and
-//! `/mem/maps` is read straight from /proc/self/maps at the requested offset
-//! (a big process has more mappings than a snapshot slot holds).
-const std = @import("std");
-const cloud9 = @import("cloud9");
-const core = @import("../core.zig");
-const debug = @import("debug.zig");
-const Writer = std.Io.Writer;
-const Provider = core.Provider;
-const Handle = Provider.Handle;
-const Error = Provider.Error;
-const NodeStat = core.NodeStat;
-
-pub const snapshot_slots = 8;
-pub const snapshot_bytes = 32 * 1024;
-/// Bytes shown by /hex/<hex>.
-pub const hex_bytes = 256;
-
-pub const Tree = enum(u8) { threads, addr, mem, hex, breakpoints, panic };
-pub const tree_names = [_][]const u8{ "threads", "addr", "mem", "hex", "breakpoints", "panic" };
-
-const Kind = enum(u8) {
- root = 0,
- readme,
- thread_dir,
- thread_name,
- thread_stat,
- thread_stack,
- thread_regs,
- addr_file,
- maps,
- mem_file,
- hex_file,
- bp_dir,
- bp_stack,
- bp_regs,
- bp_ctl,
- panic_message,
- panic_stack,
- panic_ctl,
-
- fn isDir(k: Kind) bool {
- return k == .root or k == .thread_dir or k == .bp_dir;
- }
-
- /// Text files generated into a snapshot slot at open.
- fn isText(k: Kind) bool {
- return switch (k) {
- .readme, .thread_name, .thread_stat, .thread_stack, .thread_regs, .addr_file, .hex_file, .bp_stack, .bp_regs, .panic_message, .panic_stack => true,
- else => false,
- };
- }
-
- fn isCtl(k: Kind) bool {
- return k == .bp_ctl or k == .panic_ctl;
- }
-
- fn mode(k: Kind) u32 {
- if (k.isDir()) return cloud9.dmdir | 0o555;
- if (k.isCtl()) return 0o222;
- if (k == .mem_file) return 0o666;
- return 0o444;
- }
-
- fn fixedName(k: Kind) ?[]const u8 {
- return switch (k) {
- .readme => "README",
- .thread_name => "name",
- .thread_stat => "stat",
- .thread_stack, .bp_stack, .panic_stack => "stack",
- .thread_regs, .bp_regs => "regs",
- .maps => "maps",
- .bp_ctl, .panic_ctl => "ctl",
- .panic_message => "message",
- else => null,
- };
- }
-};
-
-const value_bits = 48;
-const value_mask: u64 = (1 << value_bits) - 1;
-
-fn mk(kind: Kind, value: u64) Handle {
- return (@as(u64, @intFromEnum(kind)) << value_bits) | (value & value_mask);
-}
-
-fn kindOf(h: Handle) Kind {
- return @enumFromInt(@as(u8, @truncate(h >> value_bits)));
-}
-
-fn valueOf(h: Handle) u64 {
- return h & value_mask;
-}
-
-const readme_threads =
- \\One directory per thread of this process, named by tid:
- \\ name the thread's comm
- \\ stat state and a few fields of /proc/self/task/<tid>/stat
- \\ stack "#n 0x<addr> in <fn> (<file>:<line>:<col>)" per frame
- \\ regs general registers captured while the thread was stopped
- \\
-;
-const readme_addr =
- \\Walk any hex address: /addr/<hex> reads as "fn\nfile:line:col\nmodule\n".
- \\
-;
-const readme_mem =
- \\maps /proc/self/maps
- \\<hex> raw process memory at that address (+ file offset); writable
- \\
-;
-const readme_hex =
- \\Walk any hex address: /hex/<hex> is a hexdump of the 256 bytes there.
- \\
-;
-const readme_breakpoints =
- \\One directory per thread stopped in @breakpoint(), named by tid:
- \\ stack, regs as under /threads
- \\ ctl write "continue" to resume the thread
- \\
-;
-const readme_panic =
- \\message the first panic's message (empty before any panic)
- \\stack frames of the panicking thread
- \\ctl write "continue" to let the default panic handler run
- \\
-;
-
-fn readmeFor(tree: Tree) []const u8 {
- return switch (tree) {
- .threads => readme_threads,
- .addr => readme_addr,
- .mem => readme_mem,
- .hex => readme_hex,
- .breakpoints => readme_breakpoints,
- .panic => readme_panic,
- };
-}
-
-const Slot = struct {
- handle: Handle = 0,
- refs: u32 = 0,
- len: u32 = 0,
- buf: [snapshot_bytes]u8 = undefined,
-};
-
-pub const DebugProvider = struct {
- d: *debug.Debug,
- slots: [snapshot_slots]Slot = @splat(.{}),
- /// Backs `NodeStat.name` until the next call.
- name_buf: [32]u8 = undefined,
-
- pub fn init(dp: *DebugProvider, d: *debug.Debug) void {
- dp.* = .{ .d = d };
- }
-
- /// The provider for one tree, to pass to `Shared.addProvider`.
- pub fn provider(dp: *DebugProvider, comptime tree: Tree) Provider {
- return .{ .name = tree_names[@intFromEnum(tree)], .ctx = dp, .vtable = vtableFor(tree) };
- }
-
- /// Mounts all six trees; `shared` is a `Server(cfg).Shared`.
- pub fn mountAll(dp: *DebugProvider, shared: anytype) error{Full}!void {
- inline for (comptime std.meta.tags(Tree)) |tree| try shared.addProvider(dp.provider(tree));
- }
-
- fn self(ctx: *anyopaque) *DebugProvider {
- return @ptrCast(@alignCast(ctx));
- }
-
- fn vtableFor(comptime tree: Tree) *const Provider.VTable {
- return &struct {
- const vt: Provider.VTable = .{
- .walk = walkFn,
- .stat = statFn,
- .list = listFn,
- .open = openFn,
- .read = readFn,
- .write = writeFn,
- .close = closeFn,
- .clunk = clunkFn,
- };
- fn walkFn(ctx: *anyopaque, parent: Handle, name: []const u8) Error!Handle {
- return self(ctx).walk(tree, parent, name);
- }
- fn statFn(ctx: *anyopaque, h: Handle, out: *NodeStat) Error!void {
- return self(ctx).stat(tree, h, out);
- }
- fn listFn(ctx: *anyopaque, dir: Handle, index: usize, out: *NodeStat) Error!bool {
- return self(ctx).list(tree, dir, index, out);
- }
- fn openFn(ctx: *anyopaque, h: Handle, mode: u8) Error!void {
- return self(ctx).open(tree, h, mode);
- }
- fn readFn(ctx: *anyopaque, h: Handle, offset: u64, buf: []u8) Error!usize {
- return self(ctx).read(tree, h, offset, buf);
- }
- fn writeFn(ctx: *anyopaque, h: Handle, offset: u64, data: []const u8) Error!usize {
- return self(ctx).write(tree, h, offset, data);
- }
- fn closeFn(ctx: *anyopaque, h: Handle) void {
- self(ctx).close(h);
- }
- fn clunkFn(_: *anyopaque, _: Handle) void {}
- }.vt;
- }
-
- // -- naming ------------------------------------------------------------
-
- fn parseTid(name: []const u8) ?u32 {
- if (name.len == 0 or name.len > 10) return null;
- for (name) |ch| if (!std.ascii.isDigit(ch)) return null;
- return std.fmt.parseInt(u32, name, 10) catch null;
- }
-
- fn parseHex(name: []const u8) ?u64 {
- const digits = if (std.mem.startsWith(u8, name, "0x")) name[2..] else name;
- if (digits.len == 0 or digits.len > 12) return null;
- for (digits) |ch| if (!std.ascii.isHex(ch)) return null;
- const v = std.fmt.parseInt(u64, digits, 16) catch return null;
- if (v > value_mask) return null;
- return v;
- }
-
- fn nodeName(dp: *DebugProvider, h: Handle) []const u8 {
- const k = kindOf(h);
- if (k.fixedName()) |n| return n;
- return switch (k) {
- .root => "",
- .thread_dir, .bp_dir => std.fmt.bufPrint(&dp.name_buf, "{d}", .{valueOf(h)}) catch unreachable,
- .addr_file, .mem_file, .hex_file => std.fmt.bufPrint(&dp.name_buf, "{x}", .{valueOf(h)}) catch unreachable,
- else => unreachable,
- };
- }
-
- // -- vtable ------------------------------------------------------------
-
- fn walk(dp: *DebugProvider, tree: Tree, parent: Handle, name: []const u8) Error!Handle {
- const k = kindOf(parent);
- if (std.mem.eql(u8, name, ".")) return parent;
- if (!k.isDir()) return error.NotDir;
- if (std.mem.eql(u8, name, "..")) return Provider.root;
- switch (k) {
- .root => {
- if (tree != .threads and tree != .breakpoints and std.mem.eql(u8, name, "README")) return mk(.readme, 0);
- switch (tree) {
- .threads => {
- const tid = parseTid(name) orelse return error.NotFound;
- if (!dp.d.threadExists(tid)) return error.NotFound;
- return mk(.thread_dir, tid);
- },
- .addr => return mk(.addr_file, parseHex(name) orelse return error.NotFound),
- .mem => {
- if (std.mem.eql(u8, name, "maps")) return mk(.maps, 0);
- return mk(.mem_file, parseHex(name) orelse return error.NotFound);
- },
- .hex => return mk(.hex_file, parseHex(name) orelse return error.NotFound),
- .breakpoints => {
- const tid = parseTid(name) orelse return error.NotFound;
- if (!dp.d.isPaused(tid)) return error.NotFound;
- return mk(.bp_dir, tid);
- },
- .panic => {
- if (std.mem.eql(u8, name, "message")) return mk(.panic_message, 0);
- if (std.mem.eql(u8, name, "stack")) return mk(.panic_stack, 0);
- if (std.mem.eql(u8, name, "ctl")) return mk(.panic_ctl, 0);
- return error.NotFound;
- },
- }
- },
- .thread_dir => {
- const tid = valueOf(parent);
- if (std.mem.eql(u8, name, "name")) return mk(.thread_name, tid);
- if (std.mem.eql(u8, name, "stat")) return mk(.thread_stat, tid);
- if (std.mem.eql(u8, name, "stack")) return mk(.thread_stack, tid);
- if (std.mem.eql(u8, name, "regs")) return mk(.thread_regs, tid);
- return error.NotFound;
- },
- .bp_dir => {
- const tid = valueOf(parent);
- if (std.mem.eql(u8, name, "stack")) return mk(.bp_stack, tid);
- if (std.mem.eql(u8, name, "regs")) return mk(.bp_regs, tid);
- if (std.mem.eql(u8, name, "ctl")) return mk(.bp_ctl, tid);
- return error.NotFound;
- },
- else => unreachable,
- }
- }
-
- fn fill(dp: *DebugProvider, tree: Tree, h: Handle, out: *NodeStat) void {
- const k = kindOf(h);
- out.* = .{
- .mode = k.mode(),
- .length = if (k == .readme) readmeFor(tree).len else 0,
- .name = dp.nodeName(h),
- .handle = h,
- };
- }
-
- fn stat(dp: *DebugProvider, tree: Tree, h: Handle, out: *NodeStat) Error!void {
- dp.fill(tree, h, out);
- }
-
- fn list(dp: *DebugProvider, tree: Tree, dir: Handle, index: usize, out: *NodeStat) Error!bool {
- const k = kindOf(dir);
- if (!k.isDir()) return error.NotDir;
- const h: Handle = switch (k) {
- .root => switch (tree) {
- .threads => mk(.thread_dir, dp.d.threadAt(index) orelse return false),
- .addr, .hex => if (index == 0) mk(.readme, 0) else return false,
- .mem => switch (index) {
- 0 => mk(.readme, 0),
- 1 => mk(.maps, 0),
- else => return false,
- },
- .breakpoints => mk(.bp_dir, dp.d.pausedAt(index) orelse return false),
- .panic => switch (index) {
- 0 => mk(.readme, 0),
- 1 => mk(.panic_message, 0),
- 2 => mk(.panic_stack, 0),
- 3 => mk(.panic_ctl, 0),
- else => return false,
- },
- },
- .thread_dir => switch (index) {
- 0 => mk(.thread_name, valueOf(dir)),
- 1 => mk(.thread_stat, valueOf(dir)),
- 2 => mk(.thread_stack, valueOf(dir)),
- 3 => mk(.thread_regs, valueOf(dir)),
- else => return false,
- },
- .bp_dir => switch (index) {
- 0 => mk(.bp_stack, valueOf(dir)),
- 1 => mk(.bp_regs, valueOf(dir)),
- 2 => mk(.bp_ctl, valueOf(dir)),
- else => return false,
- },
- else => unreachable,
- };
- dp.fill(tree, h, out);
- return true;
- }
-
- fn open(dp: *DebugProvider, tree: Tree, h: Handle, mode: u8) Error!void {
- const k = kindOf(h);
- const acc = mode & 3;
- const wants_write = acc == cloud9.owrite or acc == cloud9.ordwr;
- const wants_read = acc != cloud9.owrite;
- if (k.isDir()) {
- if (wants_write or mode & cloud9.otrunc != 0) return error.IsDir;
- return;
- }
- if (k.isCtl()) {
- if (wants_read) return error.Perm;
- return;
- }
- if (k == .mem_file) return;
- if (wants_write or mode & cloud9.otrunc != 0) return error.Perm;
- if (k == .maps) return;
- std.debug.assert(k.isText());
- const slot = dp.takeSlot(h) orelse return error.NoSpace;
- errdefer dp.releaseSlot(slot);
- try dp.generate(tree, h, slot);
- }
-
- fn close(dp: *DebugProvider, h: Handle) void {
- if (!kindOf(h).isText()) return;
- if (dp.findSlot(h)) |s| dp.releaseSlot(s);
- }
-
- fn read(dp: *DebugProvider, tree: Tree, h: Handle, offset: u64, buf: []u8) Error!usize {
- const k = kindOf(h);
- if (k.isDir()) return error.IsDir;
- if (k.isCtl()) return error.Perm;
- if (k == .mem_file) {
- const addr = valueOf(h) +% offset;
- return dp.d.readMem(addr, buf) catch |e| mapErr(e);
- }
- if (k == .maps) return dp.d.readMaps(offset, buf) catch |e| mapErr(e);
- const slot = dp.findSlot(h) orelse return error.Io;
- if (offset == 0) try dp.generate(tree, h, slot);
- if (offset >= slot.len) return 0;
- const off: usize = @intCast(offset);
- const n = @min(buf.len, slot.len - off);
- @memcpy(buf[0..n], slot.buf[off..][0..n]);
- return n;
- }
-
- fn write(dp: *DebugProvider, tree: Tree, h: Handle, offset: u64, data: []const u8) Error!usize {
- _ = tree;
- const k = kindOf(h);
- if (k.isDir()) return error.IsDir;
- switch (k) {
- .mem_file => {
- const addr = valueOf(h) +% offset;
- return dp.d.writeMem(addr, data) catch |e| mapErr(e);
- },
- .bp_ctl, .panic_ctl => {
- const cmd = std.mem.trim(u8, data, " \t\r\n\x00");
- if (!std.mem.eql(u8, cmd, "continue")) return error.Unsupported;
- if (k == .bp_ctl) {
- dp.d.resumeThread(@intCast(valueOf(h))) catch |e| return mapErr(e);
- } else {
- dp.d.panicContinue() catch |e| return mapErr(e);
- }
- return data.len;
- },
- else => return error.Perm,
- }
- }
-
- // -- snapshots ---------------------------------------------------------
-
- fn findSlot(dp: *DebugProvider, h: Handle) ?*Slot {
- for (&dp.slots) |*s| if (s.refs > 0 and s.handle == h) return s;
- return null;
- }
-
- fn takeSlot(dp: *DebugProvider, h: Handle) ?*Slot {
- if (dp.findSlot(h)) |s| {
- s.refs += 1;
- return s;
- }
- for (&dp.slots) |*s| if (s.refs == 0) {
- s.* = .{ .handle = h, .refs = 1 };
- return s;
- };
- return null;
- }
-
- fn releaseSlot(_: *DebugProvider, s: *Slot) void {
- s.refs -= 1;
- }
-
- /// (Re)generates the text of `h` into `slot`. A text that does not fit is
- /// truncated, not an error.
- fn generate(dp: *DebugProvider, tree: Tree, h: Handle, slot: *Slot) Error!void {
- var w: Writer = .fixed(&slot.buf);
- slot.len = 0;
- dp.render(tree, h, &w) catch |e| switch (e) {
- error.WriteFailed => {},
- else => return mapErr(e),
- };
- slot.len = @intCast(w.buffered().len);
- }
-
- fn render(dp: *DebugProvider, tree: Tree, h: Handle, w: *Writer) debug.Error!void {
- const d = dp.d;
- const v = valueOf(h);
- switch (kindOf(h)) {
- .readme => w.writeAll(readmeFor(tree)) catch return error.WriteFailed,
- .thread_name => try d.threadName(@intCast(v), w),
- .thread_stat => try d.threadStat(@intCast(v), w),
- .thread_stack => try d.threadStack(@intCast(v), w),
- .thread_regs => try d.threadRegs(@intCast(v), w),
- .addr_file => try d.resolveAddr(@intCast(v), w),
- .hex_file => try d.hexdump(@intCast(v), hex_bytes, w),
- .bp_stack => try d.pausedStack(@intCast(v), w),
- .bp_regs => try d.pausedRegs(@intCast(v), w),
- .panic_message => try d.panicMessage(w),
- .panic_stack => try d.panicStack(w),
- else => unreachable,
- }
- }
-
- fn mapErr(e: debug.Error) Error {
- return switch (e) {
- error.NoThread, error.NotPaused, error.NoPanic => error.NotFound,
- error.Unsupported => error.Unsupported,
- error.WriteFailed => error.NoSpace,
- error.Timeout, error.Busy, error.Unmapped, error.Unexpected, error.AlreadyInitialized, error.InvalidOptions => error.Io,
- };
- }
-};
-
-// ---------------------------------------------------------------------------
-// Tests (through the core's in-memory harness)
-// ---------------------------------------------------------------------------
-
-const testing = std.testing;
-
-const TestCfg: core.Config = .{ .name = "dbgtest", .msize = 8192, .max_fids = 16, .max_providers = 6, .snapshot_slots = 2, .snapshot_bytes = 512 };
-const TS = core.Server(TestCfg);
-
-test "debug provider: threads, addr, mem, hex, breakpoints, panic through the core" {
- var text_buf: [16 * 1024]u8 = undefined;
- var d: debug.Debug = undefined;
- try d.init(.{ .io = testing.io, .text_buf = &text_buf });
- defer d.deinit();
- var dp: DebugProvider = undefined;
- dp.init(&d);
-
- var dummy: u8 = 0;
- var shared: TS.Shared = .init(&dummy);
- try dp.mountAll(&shared);
- var storage: TS.Storage = undefined;
- var h: TS.Harness = undefined;
- try h.init(&shared, &storage);
- defer h.deinit();
-
- // /threads lists tids only, among them this thread.
- const names = try h.listPath(&.{"threads"});
- defer TS.Harness.freeNames(names);
- try testing.expect(names.len >= 1);
- try testing.expect(!TS.Harness.hasName(names, "README"));
- const no_readme = try h.ok(.{ .walk = .{ .fid = 0, .newfid = 7, .names = &.{ "threads", "README" } } });
- try testing.expectEqual(@as(u16, 1), no_readme.walk.nwqid);
- var tid_buf: [16]u8 = undefined;
- const tid = try std.fmt.bufPrint(&tid_buf, "{d}", .{std.os.linux.gettid()});
- try testing.expect(TS.Harness.hasName(names, tid));
-
- // Own stack names this test function's file.
- const stack = try h.readPath(&.{ "threads", tid, "stack" });
- defer testing.allocator.free(stack);
- try testing.expect(std.mem.indexOf(u8, stack, "#0 0x") != null);
-
- // /addr/<hex> of a function here resolves to this file.
- var addr_buf: [32]u8 = undefined;
- const addr_name = try std.fmt.bufPrint(&addr_buf, "{x}", .{@intFromPtr(&DebugProvider.parseTid)});
- const resolved = try h.readPath(&.{ "addr", addr_name });
- defer testing.allocator.free(resolved);
- try testing.expect(std.mem.indexOf(u8, resolved, "provider.zig") != null);
- const partial = try h.ok(.{ .walk = .{ .fid = 0, .newfid = 5, .names = &.{ "addr", "zzz" } } });
- try testing.expectEqual(@as(u16, 1), partial.walk.nwqid);
- try h.walkTo(5, &.{"addr"});
- try h.expectFail(.{ .walk = .{ .fid = 5, .newfid = 6, .names = &.{"zzz"} } }, "file does not exist");
- _ = try h.ok(.{ .clunk = .{ .fid = 5 } });
-
- // /mem/<hex> reads and writes live memory; unmapped is an error.
- var cell: [8]u8 = "abcdefgh".*;
- var mem_buf: [32]u8 = undefined;
- const mem_name = try std.fmt.bufPrint(&mem_buf, "{x}", .{@intFromPtr(&cell)});
- try h.walkTo(1, &.{ "mem", mem_name });
- _ = try h.ok(.{ .open = .{ .fid = 1, .mode = cloud9.ordwr } });
- const r = try h.ok(.{ .read = .{ .fid = 1, .offset = 2, .count = 4 } });
- try testing.expectEqualStrings("cdef", r.read);
- _ = try h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "XY" } });
- try testing.expectEqualStrings("XYcdefgh", &cell);
- _ = try h.ok(.{ .clunk = .{ .fid = 1 } });
- try h.walkTo(2, &.{ "mem", "8" });
- _ = try h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- try h.expectFail(.{ .read = .{ .fid = 2, .offset = 0, .count = 4 } }, "i/o error");
- _ = try h.ok(.{ .clunk = .{ .fid = 2 } });
- const maps = try h.readPath(&.{ "mem", "maps" });
- defer testing.allocator.free(maps);
- try testing.expect(std.mem.indexOf(u8, maps, "r-xp") != null or std.mem.indexOf(u8, maps, "r--p") != null);
-
- // /hex/<hex> is a hexdump.
- const hex = try h.readPath(&.{ "hex", mem_name });
- defer testing.allocator.free(hex);
- try testing.expect(std.mem.indexOf(u8, hex, "XYcdefgh") != null);
-
- // Nothing paused, no panic.
- const bps = try h.listPath(&.{"breakpoints"});
- defer TS.Harness.freeNames(bps);
- try testing.expectEqual(@as(usize, 0), bps.len);
- const msg = try h.readPath(&.{ "panic", "message" });
- defer testing.allocator.free(msg);
- try testing.expectEqualStrings("", msg);
- try h.walkTo(3, &.{ "panic", "ctl" });
- _ = try h.ok(.{ .open = .{ .fid = 3, .mode = cloud9.owrite } });
- try h.expectFail(.{ .write = .{ .fid = 3, .offset = 0, .data = "continue" } }, "file does not exist");
- try h.expectFail(.{ .write = .{ .fid = 3, .offset = 0, .data = "bogus" } }, "not supported");
- _ = try h.ok(.{ .clunk = .{ .fid = 3 } });
-
- // Snapshot slots are released on clunk: open more files than slots, sequentially.
- for (0..4) |_| {
- const t = try h.readPath(&.{ "threads", tid, "name" });
- testing.allocator.free(t);
- }
- for (&dp.slots) |s| try testing.expectEqual(@as(u32, 0), s.refs);
-}
-
-test "handle encoding round-trips" {
- const h = mk(.mem_file, 0x7fff_dead_beef);
- try testing.expectEqual(Kind.mem_file, kindOf(h));
- try testing.expectEqual(@as(u64, 0x7fff_dead_beef), valueOf(h));
- try testing.expect(h < (1 << 56));
- try testing.expectEqual(@as(?u64, null), DebugProvider.parseHex("1_0"));
- try testing.expectEqual(@as(?u64, 0x10), DebugProvider.parseHex("0x10"));
- try testing.expectEqual(@as(?u32, null), DebugProvider.parseTid("+5"));
-}
diff --git a/introspect/src/linux/runtime.zig b/introspect/src/linux/runtime.zig
deleted file mode 100644
index 503d6c2..0000000
--- a/introspect/src/linux/runtime.zig
+++ /dev/null
@@ -1,90 +0,0 @@
-//! Generators for `Config.runtime`: /runtime/{pid,ppid,uptime,argv,cwd,env,clients}.
-//! The core passes every generator `Shared.ctx`; `Fns(Ctx, field)` casts it
-//! to `*Ctx` and reads the `Info` stored in `@field(ctx, field)`.
-const std = @import("std");
-const linux = std.os.linux;
-const Writer = std.Io.Writer;
-
-/// What the generators report. Texts are borrowed for the server's lifetime.
-pub const Info = struct {
- /// argv, one argument per line.
- argv: []const u8 = "",
- /// Environment, one KEY=VALUE per line.
- env: []const u8 = "",
- cwd: []const u8 = "",
- /// Monotonic seconds at startup; /runtime/uptime is the difference.
- start_mono: i64 = 0,
- /// Live client count, published by the probe.
- clients: ?*const std.atomic.Value(u32) = null,
-
- pub fn now() Info {
- return .{ .start_mono = monotonicSecs() };
- }
-};
-
-pub fn monotonicSecs() i64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(.MONOTONIC, &ts);
- return ts.sec;
-}
-
-/// Seconds since the epoch, clamped to u32 (for atime/mtime and `fn/now`).
-pub fn realtimeSecs() u32 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(.REALTIME, &ts);
- return @intCast(std.math.clamp(ts.sec, 0, std.math.maxInt(u32)));
-}
-
-/// The `Config.runtime` type: `Ctx` is the type behind `Shared.ctx`, `field`
-/// the name of its `Info` field.
-pub fn Fns(comptime Ctx: type, comptime field: []const u8) type {
- return struct {
- fn info(ctx: *anyopaque) *const Info {
- const c: *Ctx = @ptrCast(@alignCast(ctx));
- return &@field(c, field);
- }
- pub fn pid(_: *anyopaque, w: *Writer) anyerror!void {
- try w.print("{d}", .{linux.getpid()});
- }
- pub fn ppid(_: *anyopaque, w: *Writer) anyerror!void {
- try w.print("{d}", .{linux.getppid()});
- }
- pub fn uptime(ctx: *anyopaque, w: *Writer) anyerror!void {
- try w.print("{d}", .{monotonicSecs() - info(ctx).start_mono});
- }
- pub fn argv(ctx: *anyopaque, w: *Writer) anyerror!void {
- try w.writeAll(info(ctx).argv);
- }
- pub fn cwd(ctx: *anyopaque, w: *Writer) anyerror!void {
- try w.writeAll(info(ctx).cwd);
- }
- pub fn env(ctx: *anyopaque, w: *Writer) anyerror!void {
- try w.writeAll(info(ctx).env);
- }
- pub fn clients(ctx: *anyopaque, w: *Writer) anyerror!void {
- const n: u32 = if (info(ctx).clients) |c| c.load(.acquire) else 0;
- try w.print("{d}", .{n});
- }
- };
-}
-
-test "runtime generators read Info through the context" {
- const Ctx = struct { x: u32, info: Info };
- var count: std.atomic.Value(u32) = .init(3);
- var ctx: Ctx = .{ .x = 0, .info = .{ .argv = "a\nb\n", .cwd = "/tmp", .env = "K=V\n", .start_mono = monotonicSecs(), .clients = &count } };
- const F = Fns(Ctx, "info");
- var buf: [64]u8 = undefined;
- var w: Writer = .fixed(&buf);
- try F.clients(&ctx, &w);
- try std.testing.expectEqualStrings("3", w.buffered());
- w = .fixed(&buf);
- try F.argv(&ctx, &w);
- try std.testing.expectEqualStrings("a\nb\n", w.buffered());
- w = .fixed(&buf);
- try F.uptime(&ctx, &w);
- try std.testing.expect(w.buffered().len >= 1);
- w = .fixed(&buf);
- try F.pid(&ctx, &w);
- try std.testing.expectEqual(linux.getpid(), try std.fmt.parseInt(i32, w.buffered(), 10));
- try std.testing.expectEqual(@as(usize, 7), @typeInfo(F).@"struct".decls.len);
-}
diff --git a/introspect/src/root.zig b/introspect/src/root.zig
deleted file mode 100644
index 1dcc892..0000000
--- a/introspect/src/root.zig
+++ /dev/null
@@ -1,24 +0,0 @@
-//! introspect: a 9P2000 debug/introspection server as a library. See
-//! docs/LIBRARY.md. `core` and `vars` are freestanding; `scratch` takes an
-//! allocator; `linux` is the platform layer (only on Linux).
-const std = @import("std");
-const builtin = @import("builtin");
-
-pub const core = @import("core.zig");
-pub const vars = @import("vars.zig");
-pub const scratch = @import("scratch.zig");
-pub const linux = if (builtin.os.tag == .linux) @import("linux/probe.zig") else struct {};
-
-pub const Config = core.Config;
-pub const Server = core.Server;
-pub const Provider = core.Provider;
-pub const NodeStat = core.NodeStat;
-pub const Scratch = scratch.Scratch;
-
-test {
- std.testing.refAllDecls(@This());
- _ = core;
- _ = vars;
- _ = scratch;
- if (builtin.os.tag == .linux) _ = linux;
-}
diff --git a/introspect/src/scratch.zig b/introspect/src/scratch.zig
deleted file mode 100644
index 2163a28..0000000
--- a/introspect/src/scratch.zig
+++ /dev/null
@@ -1,689 +0,0 @@
-//! An in-memory read/write tree as a `Provider`: create, write, truncate,
-//! rename, remove, mkdir, DMAPPEND, DMEXCL. The one core-level component that
-//! takes an `Allocator` (nodes and file contents live on it); it is optional.
-//!
-//! Nodes are kept alive by `refs` (fids holding a handle) after removal, so a
-//! handle stays valid until the core clunks it. Handles are node addresses;
-//! the root is handle 0. Not internally synchronized (like `Shared`).
-const std = @import("std");
-const cloud9 = @import("cloud9");
-const core = @import("core.zig");
-const Allocator = std.mem.Allocator;
-const Provider = core.Provider;
-const Handle = Provider.Handle;
-const Error = Provider.Error;
-const NodeStat = core.NodeStat;
-
-/// A node of the tree.
-pub const Node = struct {
- name: []u8,
- path: u64,
- version: u32 = 0,
- mode: u32,
- atime: u32,
- mtime: u32,
- data: std.ArrayList(u8) = .empty,
- children: std.ArrayList(*Node) = .empty,
- parent: ?*Node,
- /// Handles held by the core.
- refs: u32 = 0,
- /// Fids currently open on this node (DMEXCL admits at most one).
- opens: u32 = 0,
- removed: bool = false,
-
- pub fn isDir(n: *const Node) bool {
- return n.mode & cloud9.dmdir != 0;
- }
-
- fn find(n: *const Node, name: []const u8) ?*Node {
- for (n.children.items) |ch| if (std.mem.eql(u8, ch.name, name)) return ch;
- return null;
- }
-};
-
-/// Seconds since the epoch, for atime/mtime; the default clock reports 0.
-pub const Clock = *const fn () u32;
-
-fn zeroClock() u32 {
- return 0;
-}
-
-pub const Scratch = struct {
- gpa: Allocator,
- root: *Node,
- /// Qid paths are a counter, never reused: the root is 0 (the provider
- /// root handle), so a removed-and-recreated file gets a fresh identity
- /// even when the allocator hands back the same address.
- next_path: u64 = 0,
- /// Sum of all file lengths, bounded by `budget`.
- bytes: usize = 0,
- /// Largest total of file contents across all files.
- budget: usize,
- /// Largest single file; defaults to the budget.
- max_file: usize,
- /// The time source for atime/mtime (a platform layer sets it).
- now: Clock = &zeroClock,
-
- /// The tree's only allocation policy: every node and every file's
- /// contents come from `gpa`, and no file content ever exceeds `budget_bytes`
- /// in total.
- pub fn init(gpa: Allocator, budget_bytes: usize) Allocator.Error!Scratch {
- var s: Scratch = .{ .gpa = gpa, .root = undefined, .budget = budget_bytes, .max_file = budget_bytes };
- s.root = try s.newNode("", cloud9.dmdir | 0o777, null);
- return s;
- }
-
- pub fn deinit(s: *Scratch) void {
- s.destroyTree(s.root);
- s.* = undefined;
- }
-
- /// The provider to mount, at `/<name>`.
- pub fn provider(s: *Scratch, name: []const u8) Provider {
- return .{ .name = name, .ctx = s, .vtable = &vtable };
- }
-
- pub const vtable: Provider.VTable = .{
- .walk = &walk,
- .stat = &stat,
- .list = &list,
- .open = &open,
- .read = &read,
- .write = &write,
- .create = &create,
- .remove = &remove,
- .wstat = &wstat,
- .close = &close,
- .clunk = &clunk,
- };
-
- // -- node management --
-
- fn destroyTree(s: *Scratch, n: *Node) void {
- for (n.children.items) |ch| s.destroyTree(ch);
- n.children.clearRetainingCapacity();
- n.removed = true;
- if (n.refs == 0 or n == s.root) s.destroyNode(n);
- }
-
- fn destroyNode(s: *Scratch, n: *Node) void {
- s.bytes -= n.data.items.len;
- s.gpa.free(n.name);
- n.data.deinit(s.gpa);
- n.children.deinit(s.gpa);
- s.gpa.destroy(n);
- }
-
- fn newNode(s: *Scratch, name: []const u8, mode: u32, parent: ?*Node) Allocator.Error!*Node {
- const n = try s.gpa.create(Node);
- errdefer s.gpa.destroy(n);
- const t = s.now();
- n.* = .{
- .name = try s.gpa.dupe(u8, name),
- .path = s.next_path,
- .mode = mode,
- .atime = t,
- .mtime = t,
- .parent = parent,
- };
- errdefer s.gpa.free(n.name);
- if (parent) |p| try p.children.append(s.gpa, n);
- s.next_path += 1;
- return n;
- }
-
- /// Sets a file's length, zero-filling growth and charging the budget.
- /// Shrinking releases the memory so a truncated file costs nothing.
- fn resizeData(s: *Scratch, n: *Node, new_len: usize) Error!void {
- const old = n.data.items.len;
- if (new_len > old) {
- if (new_len > s.max_file) return error.NoSpace;
- if (s.bytes + (new_len - old) > s.budget) return error.NoSpace;
- n.data.resize(s.gpa, new_len) catch return error.NoSpace;
- @memset(n.data.items[old..new_len], 0);
- s.bytes += new_len - old;
- } else if (new_len < old) {
- n.data.shrinkAndFree(s.gpa, new_len);
- s.bytes -= old - new_len;
- }
- }
-
- fn touch(s: *Scratch, n: *Node) void {
- n.version +%= 1;
- n.mtime = s.now();
- }
-
- fn self(ctx: *anyopaque) *Scratch {
- return @ptrCast(@alignCast(ctx));
- }
-
- fn handle(s: *Scratch, n: *Node) Handle {
- return if (n == s.root) Provider.root else @intFromPtr(n);
- }
-
- fn node(s: *Scratch, h: Handle) *Node {
- return if (h == Provider.root) s.root else @ptrFromInt(@as(usize, @intCast(h)));
- }
-
- /// A handle the core will clunk exactly once.
- fn retain(s: *Scratch, n: *Node) Handle {
- if (n != s.root) n.refs += 1;
- return s.handle(n);
- }
-
- fn release(s: *Scratch, n: *Node) void {
- if (n == s.root) return;
- n.refs -= 1;
- if (n.refs == 0 and n.removed) s.destroyNode(n);
- }
-
- fn fillStat(n: *const Node, h: Handle, out: *NodeStat) void {
- out.* = .{
- .mode = n.mode,
- .length = if (n.isDir()) 0 else n.data.items.len,
- .atime = n.atime,
- .mtime = n.mtime,
- .version = n.version,
- .name = n.name,
- .handle = h,
- .path = n.path,
- };
- }
-
- // -- the vtable --
-
- fn walk(ctx: *anyopaque, parent: Handle, name: []const u8) Error!Handle {
- const s = self(ctx);
- const p = s.node(parent);
- if (std.mem.eql(u8, name, ".")) return s.retain(p);
- if (p.removed) return error.NotFound;
- if (!p.isDir()) return error.NotDir;
- if (std.mem.eql(u8, name, "..")) return s.retain(p.parent orelse s.root);
- return s.retain(p.find(name) orelse return error.NotFound);
- }
-
- fn stat(ctx: *anyopaque, h: Handle, out: *NodeStat) Error!void {
- const s = self(ctx);
- fillStat(s.node(h), h, out);
- }
-
- fn list(ctx: *anyopaque, dir: Handle, index: usize, out: *NodeStat) Error!bool {
- const s = self(ctx);
- const d = s.node(dir);
- if (!d.isDir()) return error.NotDir;
- if (index >= d.children.items.len) return false;
- const ch = d.children.items[index];
- fillStat(ch, s.handle(ch), out);
- return true;
- }
-
- fn open(ctx: *anyopaque, h: Handle, mode: u8) Error!void {
- const s = self(ctx);
- const n = s.node(h);
- if (n.removed) return error.NotFound;
- const acc = mode & 3;
- const want_write = acc == cloud9.owrite or acc == cloud9.ordwr;
- const want_read = !want_write or acc == cloud9.ordwr;
- const trunc = mode & cloud9.otrunc != 0;
- if (n.isDir()) {
- if (want_write or trunc) return error.IsDir;
- if (n.mode & 0o400 == 0) return error.Perm;
- } else {
- if (want_read and n.mode & 0o400 == 0) return error.Perm;
- if ((want_write or trunc) and n.mode & 0o200 == 0) return error.Perm;
- if (n.mode & cloud9.dmexcl != 0 and n.opens != 0) return error.Excl;
- if (trunc and n.mode & cloud9.dmappend == 0) {
- s.resizeData(n, 0) catch unreachable; // shrinking cannot fail
- s.touch(n);
- }
- }
- n.opens += 1;
- }
-
- fn close(ctx: *anyopaque, h: Handle) void {
- const s = self(ctx);
- s.node(h).opens -= 1;
- }
-
- fn read(ctx: *anyopaque, h: Handle, offset: u64, buf: []u8) Error!usize {
- const s = self(ctx);
- const n = s.node(h);
- if (n.isDir()) return error.IsDir;
- const src = n.data.items;
- if (offset >= src.len) return 0;
- const off: usize = @intCast(offset);
- const len = @min(buf.len, src.len - off);
- @memcpy(buf[0..len], src[off..][0..len]);
- return len;
- }
-
- fn write(ctx: *anyopaque, h: Handle, offset: u64, data: []const u8) Error!usize {
- const s = self(ctx);
- const n = s.node(h);
- if (n.isDir()) return error.IsDir;
- // A zero-length write changes nothing (and must not extend the file).
- if (data.len == 0) return 0;
- const off: usize = if (n.mode & cloud9.dmappend != 0) n.data.items.len else @intCast(@min(offset, s.max_file));
- const end = off + data.len;
- if (end > s.max_file) return error.NoSpace;
- if (end > n.data.items.len) try s.resizeData(n, end);
- @memcpy(n.data.items[off..end], data);
- s.touch(n);
- return data.len;
- }
-
- fn create(ctx: *anyopaque, dir: Handle, name: []const u8, perm: u32, mode: u8) Error!Handle {
- const s = self(ctx);
- const d = s.node(dir);
- if (d.removed) return error.NotFound;
- if (!d.isDir()) return error.NotDir;
- if (d.mode & 0o200 == 0) return error.Perm;
- if (d.find(name) != null) return error.Exists;
- const is_dir = perm & cloud9.dmdir != 0;
- const inherit: u32 = if (is_dir) d.mode & 0o777 else d.mode & 0o666;
- const n = s.newNode(name, perm & (~@as(u32, 0o777) | inherit), d) catch return error.NoSpace;
- s.touch(d);
- n.opens += 1;
- _ = mode;
- return s.retain(n);
- }
-
- fn remove(ctx: *anyopaque, h: Handle) Error!void {
- const s = self(ctx);
- const n = s.node(h);
- if (n.removed) return error.NotFound;
- const parent = n.parent orelse return error.Perm;
- if (parent.mode & 0o200 == 0) return error.Perm;
- if (n.isDir() and n.children.items.len != 0) return error.NotEmpty;
- const i = std.mem.indexOfScalar(*Node, parent.children.items, n) orelse return error.NotFound;
- _ = parent.children.orderedRemove(i);
- s.touch(parent);
- n.removed = true;
- if (n.refs == 0) s.destroyNode(n);
- }
-
- fn wstat(ctx: *anyopaque, h: Handle, st: *const cloud9.Stat) Error!void {
- const s = self(ctx);
- const n = s.node(h);
- if (n.removed) return error.NotFound;
- // Validate everything before changing anything.
- const rename = st.name.len != 0 and !std.mem.eql(u8, st.name, n.name);
- if (rename) {
- const parent = n.parent orelse return error.Perm;
- if (parent.find(st.name) != null) return error.Exists;
- }
- const cur_len: u64 = if (n.isDir()) 0 else n.data.items.len;
- const set_len = st.length != 0xFFFF_FFFF_FFFF_FFFF and st.length != cur_len;
- if (set_len) {
- if (n.isDir()) return error.IsDir;
- if (st.length > s.max_file) return error.NoSpace;
- }
- const set_mode = st.mode != 0xFFFF_FFFF and st.mode != n.mode;
- if (set_mode and (st.mode & cloud9.dmdir) != (n.mode & cloud9.dmdir)) return error.Perm;
- const set_mtime = st.mtime != 0xFFFF_FFFF and st.mtime != n.mtime;
- if (!(rename or set_len or set_mode or set_mtime)) return;
- const new_name: ?[]u8 = if (rename) s.gpa.dupe(u8, st.name) catch return error.NoSpace else null;
- errdefer if (new_name) |nn| s.gpa.free(nn);
- if (set_len) try s.resizeData(n, @intCast(st.length));
- // Nothing below can fail.
- if (new_name) |nn| {
- s.gpa.free(n.name);
- n.name = nn;
- s.touch(n.parent.?);
- }
- if (set_mode) n.mode = (n.mode & cloud9.dmdir) | (st.mode & ~cloud9.dmdir);
- s.touch(n);
- if (set_mtime) n.mtime = st.mtime;
- }
-
- fn clunk(ctx: *anyopaque, h: Handle) void {
- const s = self(ctx);
- s.release(s.node(h));
- }
-};
-
-// ---------------------------------------------------------------------------
-// Tests: the scratch tree mounted at /scratch of a core server.
-// ---------------------------------------------------------------------------
-
-const testing = std.testing;
-
-const test_cfg: core.Config = .{ .name = "tester", .msize = 8192, .max_fids = 32 };
-const TS = core.Server(test_cfg);
-
-const budget: usize = 1 << 20;
-
-const Fixture = struct {
- ctx: u8 = 0,
- shared: TS.Shared = undefined,
- storage: TS.Storage = undefined,
- scratch: Scratch = undefined,
- h: TS.Harness = undefined,
-
- fn init(x: *Fixture) !void {
- x.shared = .init(&x.ctx);
- x.scratch = try Scratch.init(testing.allocator, budget);
- errdefer x.scratch.deinit();
- try x.shared.addProvider(x.scratch.provider("scratch"));
- try x.h.init(&x.shared, &x.storage);
- }
-
- fn deinit(x: *Fixture) void {
- x.h.deinit();
- x.scratch.deinit();
- }
-
- fn nodeOf(x: *Fixture, fid: u32) *Node {
- for (x.h.conn.fids) |f| if (f.used and f.id == fid) return x.scratch.node(f.node.prov.h);
- unreachable;
- }
-};
-
-const dontcare = core.stat_dontcare;
-
-test "scratch create/write/read/rename/truncate/remove" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"scratch"});
- // create + write
- const cr = try x.h.ok(.{ .create = .{ .fid = 1, .name = "x", .perm = 0o644, .mode = cloud9.ordwr } });
- try testing.expectEqual(cloud9.qtfile, cr.create.qid.type);
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "hello" } });
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 5, .data = " world" } });
- const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 100 } });
- try testing.expectEqualStrings("hello world", r.read);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- // rename x -> y
- try x.h.walkTo(2, &.{ "scratch", "x" });
- var st = dontcare;
- st.name = "y";
- _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = st } });
- try x.h.walkTo(3, &.{"scratch"});
- try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 30, .names = &.{"x"} } }, "file does not exist");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
- try x.h.walkTo(3, &.{ "scratch", "y" });
- // truncate then extend with zero fill
- st = dontcare;
- st.length = 2;
- _ = try x.h.ok(.{ .wstat = .{ .fid = 3, .stat = st } });
- st.length = 4;
- _ = try x.h.ok(.{ .wstat = .{ .fid = 3, .stat = st } });
- const text = try x.h.readAll(3);
- defer testing.allocator.free(text);
- try testing.expectEqualStrings("he\x00\x00", text);
- const s3 = try x.h.ok(.{ .stat = .{ .fid = 3 } });
- try testing.expectEqualStrings("y", s3.stat.name);
- try testing.expectEqual(@as(u64, 4), s3.stat.length);
- try testing.expectEqualStrings("tester", s3.stat.uid);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
- // mkdir, nested create, remove rules
- try x.h.walkTo(4, &.{"scratch"});
- const dr = try x.h.ok(.{ .create = .{ .fid = 4, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.oread } });
- try testing.expectEqual(cloud9.qtdir, dr.create.qid.type);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 4 } });
- try x.h.walkTo(5, &.{ "scratch", "d" });
- _ = try x.h.ok(.{ .create = .{ .fid = 5, .name = "inner", .perm = 0o600, .mode = cloud9.owrite } });
- _ = try x.h.ok(.{ .write = .{ .fid = 5, .offset = 0, .data = "z" } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 5 } });
- try x.h.walkTo(6, &.{ "scratch", "d" });
- try x.h.expectFail(.{ .remove = .{ .fid = 6 } }, "directory not empty");
- try x.h.expectFail(.{ .clunk = .{ .fid = 6 } }, "unknown fid"); // remove always clunks
- try x.h.walkTo(7, &.{ "scratch", "d", "inner" });
- _ = try x.h.ok(.{ .remove = .{ .fid = 7 } });
- try x.h.walkTo(8, &.{ "scratch", "d" });
- _ = try x.h.ok(.{ .remove = .{ .fid = 8 } });
- try x.h.walkTo(9, &.{ "scratch", "y" });
- _ = try x.h.ok(.{ .remove = .{ .fid = 9 } });
- try x.h.walkTo(10, &.{"scratch"});
- _ = try x.h.ok(.{ .open = .{ .fid = 10, .mode = cloud9.oread } });
- const names = try x.h.listDir(10, 1024);
- defer testing.allocator.free(names);
- try testing.expectEqual(@as(usize, 0), names.len);
- // append-only files ignore the offset
- try x.h.walkTo(11, &.{"scratch"});
- _ = try x.h.ok(.{ .create = .{ .fid = 11, .name = "log", .perm = cloud9.dmappend | 0o644, .mode = cloud9.ordwr } });
- _ = try x.h.ok(.{ .write = .{ .fid = 11, .offset = 100, .data = "a" } });
- _ = try x.h.ok(.{ .write = .{ .fid = 11, .offset = 0, .data = "b" } });
- const lr = try x.h.ok(.{ .read = .{ .fid = 11, .offset = 0, .count = 10 } });
- try testing.expectEqualStrings("ab", lr.read);
- try testing.expect(lr.read.len == 2);
- const ls = try x.h.ok(.{ .stat = .{ .fid = 11 } });
- try testing.expect(ls.stat.qid.type & cloud9.qtappend != 0);
- // the scratch root cannot be removed
- try x.h.walkTo(12, &.{"scratch"});
- try x.h.expectFail(.{ .remove = .{ .fid = 12 } }, "permission denied");
-}
-
-test "walk of a missing name and walking a file" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"scratch"});
- try x.h.expectFail(.{ .walk = .{ .fid = 1, .newfid = 2, .names = &.{"nope"} } }, "file does not exist");
- _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "f", .perm = 0o644, .mode = cloud9.oread } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- try x.h.walkTo(3, &.{ "scratch", "f" });
- try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"x"} } }, "not a directory");
- // a walk that fails past the first element is a partial Rwalk that leaves newfid unused
- const r = try x.h.ok(.{ .walk = .{ .fid = 0, .newfid = 4, .names = &.{ "scratch", "nope", "x" } } });
- try testing.expectEqual(@as(u16, 1), r.walk.nwqid);
- try x.h.expectFail(.{ .clunk = .{ .fid = 4 } }, "unknown fid");
- // .. from a file is not a directory; .. from the scratch root reaches the server root
- try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 5, .names = &.{".."} } }, "not a directory");
- try x.h.walkTo(5, &.{"scratch"});
- const up = try x.h.ok(.{ .walk = .{ .fid = 5, .newfid = 6, .names = &.{ "..", "scratch", "..", "README" } } });
- try testing.expectEqual(@as(u16, 4), up.walk.nwqid);
- try testing.expectEqual(@as(u64, 0), up.walk.wqid[1].path); // provider 0, root
- try testing.expect(up.walk.wqid[0].type & cloud9.qtdir != 0);
-}
-
-test "directory read across consecutive offsets returns every record exactly once" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- const n = 40;
- for (0..n) |i| {
- try x.h.walkTo(1, &.{"scratch"});
- var name_buf: [64]u8 = undefined;
- const name = try std.fmt.bufPrint(&name_buf, "file-with-a-long-name-{d:0>3}", .{i});
- _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = name, .perm = 0o644, .mode = cloud9.oread } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- }
- try x.h.walkTo(2, &.{"scratch"});
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- // 200 bytes fits two records, so this takes many reads.
- const names = try x.h.listDir(2, 200);
- defer TS.Harness.freeNames(names);
- try testing.expectEqual(@as(usize, n), names.len);
- var seen: [n]bool = @splat(false);
- for (names) |nm| {
- const idx = try std.fmt.parseInt(usize, nm[nm.len - 3 ..], 10);
- try testing.expect(!seen[idx]);
- seen[idx] = true;
- }
- for (seen) |s| try testing.expect(s);
- try x.h.expectFail(.{ .read = .{ .fid = 2, .offset = 7, .count = 200 } }, "bad offset");
- // a read that cannot fit even one record returns nothing rather than splitting it
- const tiny = try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 30 } });
- try testing.expectEqual(@as(usize, 0), tiny.read.len);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
- // Tversion resets every fid and every reference
- try x.h.version(8192);
- try testing.expectEqual(@as(usize, 0), x.h.conn.fidCount());
- for (x.scratch.root.children.items) |ch| try testing.expectEqual(@as(u32, 0), ch.refs);
- _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
-}
-
-test "DMEXCL admits one open fid at a time" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"scratch"});
- const cr = try x.h.ok(.{ .create = .{ .fid = 1, .name = "lock", .perm = cloud9.dmexcl | 0o644, .mode = cloud9.owrite } });
- try testing.expect(cr.create.qid.type & cloud9.qtexcl != 0);
- try x.h.walkTo(2, &.{ "scratch", "lock" });
- try x.h.expectFail(.{ .open = .{ .fid = 2, .mode = cloud9.oread } }, "exclusive use file already open");
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- try x.h.walkTo(3, &.{ "scratch", "lock" });
- try x.h.expectFail(.{ .open = .{ .fid = 3, .mode = cloud9.oread } }, "exclusive use file already open");
- // a Tversion reset drops the open and frees the file for the next session
- try x.h.version(8192);
- _ = try x.h.ok(.{ .attach = .{ .fid = 0, .uname = "tester" } });
- try x.h.walkTo(4, &.{ "scratch", "lock" });
- _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } });
- try testing.expectEqual(@as(u32, 1), x.nodeOf(4).opens);
- _ = try x.h.ok(.{ .remove = .{ .fid = 4 } });
-}
-
-test "scratch memory: zero-length writes, truncation frees, global budget" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- x.scratch.max_file = 4096;
- try x.h.walkTo(1, &.{"scratch"});
- _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "f", .perm = 0o644, .mode = cloud9.ordwr } });
- // a zero-length write at a huge offset must not extend the file
- const w0 = try x.h.ok(.{ .write = .{ .fid = 1, .offset = std.math.maxInt(u64), .data = "" } });
- try testing.expectEqual(@as(u32, 0), w0.write);
- var st = try x.h.ok(.{ .stat = .{ .fid = 1 } });
- try testing.expectEqual(@as(u64, 0), st.stat.length);
- // growth is charged to the budget; truncation releases it (memory too)
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 1000, .data = "x" } });
- try testing.expectEqual(@as(usize, 1001), x.scratch.bytes);
- var ws = dontcare;
- ws.length = 10;
- _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = ws } });
- try testing.expectEqual(@as(usize, 10), x.scratch.bytes);
- try testing.expectEqual(@as(usize, 10), x.nodeOf(1).data.capacity);
- // per-file cap and the global budget both answer "no space"
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 4096, .data = "x" } }, "no space left on device");
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = std.math.maxInt(u64), .data = "x" } }, "no space left on device");
- x.scratch.bytes = budget - 10; // pretend other files hold the rest
- try x.h.expectFail(.{ .write = .{ .fid = 1, .offset = 10, .data = "0123456789A" } }, "no space left on device");
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 10, .data = "0123456789" } });
- try testing.expectEqual(budget, x.scratch.bytes);
- ws.length = 4096;
- try x.h.expectFail(.{ .wstat = .{ .fid = 1, .stat = ws } }, "no space left on device");
- x.scratch.bytes -= budget - 20;
- // OTRUNC releases too
- try x.h.walkTo(2, &.{ "scratch", "f" });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.owrite | cloud9.otrunc } });
- try testing.expectEqual(@as(usize, 0), x.scratch.bytes);
- st = try x.h.ok(.{ .stat = .{ .fid = 2 } });
- try testing.expectEqual(@as(u64, 0), st.stat.length);
- // removing a file with content returns its bytes once the last fid lets go
- _ = try x.h.ok(.{ .write = .{ .fid = 2, .offset = 0, .data = "abc" } });
- try testing.expectEqual(@as(usize, 3), x.scratch.bytes);
- _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
- try testing.expectEqual(@as(usize, 3), x.scratch.bytes); // fid 1 still holds it
- const r = try x.h.ok(.{ .read = .{ .fid = 1, .offset = 0, .count = 10 } });
- try testing.expectEqualStrings("abc", r.read);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- try testing.expectEqual(@as(usize, 0), x.scratch.bytes);
-}
-
-test "wstat with every field equal to the current stat changes nothing" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"scratch"});
- _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "same", .perm = 0o640, .mode = cloud9.oread } });
- const before = (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat;
- var copy = before;
- var name_buf: [core.max_name]u8 = undefined;
- @memcpy(name_buf[0..before.name.len], before.name);
- copy.name = name_buf[0..before.name.len];
- copy.uid = "tester";
- copy.gid = "tester";
- copy.muid = "tester";
- _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = copy } });
- const after = (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat;
- try testing.expectEqual(before.qid, after.qid);
- try testing.expectEqual(before.mtime, after.mtime);
- try testing.expectEqual(before.mode, after.mode);
- try testing.expectEqualStrings("same", after.name);
- // and a rename to the very same name is also a no-op
- var st = dontcare;
- st.name = "same";
- _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = st } });
- try testing.expectEqual(before.qid, (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.qid);
- // renaming onto an existing sibling is refused
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- try x.h.walkTo(2, &.{"scratch"});
- _ = try x.h.ok(.{ .create = .{ .fid = 2, .name = "other", .perm = 0o640, .mode = cloud9.oread } });
- st.name = "same";
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = st } }, "file already exists");
- // the mode's directory bit is immutable, mtime is settable
- st = dontcare;
- st.mode = cloud9.dmdir | 0o640;
- try x.h.expectFail(.{ .wstat = .{ .fid = 2, .stat = st } }, "permission denied");
- st = dontcare;
- st.mtime = 12345;
- _ = try x.h.ok(.{ .wstat = .{ .fid = 2, .stat = st } });
- try testing.expectEqual(@as(u32, 12345), (try x.h.ok(.{ .stat = .{ .fid = 2 } })).stat.mtime);
- _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
-}
-
-test "ORCLOSE removes on clunk and removed files stay readable through open fids" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"scratch"});
- _ = try x.h.ok(.{ .create = .{ .fid = 1, .name = "tmp", .perm = 0o644, .mode = cloud9.ordwr | cloud9.orclose } });
- _ = try x.h.ok(.{ .write = .{ .fid = 1, .offset = 0, .data = "gone" } });
- try x.h.walkTo(2, &.{ "scratch", "tmp" });
- _ = try x.h.ok(.{ .open = .{ .fid = 2, .mode = cloud9.oread } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 1 } });
- try x.h.walkTo(3, &.{"scratch"});
- try x.h.expectFail(.{ .walk = .{ .fid = 3, .newfid = 4, .names = &.{"tmp"} } }, "file does not exist");
- const r = try x.h.ok(.{ .read = .{ .fid = 2, .offset = 0, .count = 10 } });
- try testing.expectEqualStrings("gone", r.read);
- try testing.expectEqual(@as(usize, 4), x.scratch.bytes);
- _ = try x.h.ok(.{ .clunk = .{ .fid = 2 } });
- try testing.expectEqual(@as(usize, 0), x.scratch.bytes);
- // create inside a removed directory fails
- _ = try x.h.ok(.{ .create = .{ .fid = 3, .name = "d", .perm = cloud9.dmdir | 0o755, .mode = cloud9.oread } });
- try x.h.walkTo(5, &.{ "scratch", "d" });
- _ = try x.h.ok(.{ .remove = .{ .fid = 5 } });
- _ = try x.h.ok(.{ .clunk = .{ .fid = 3 } });
- try x.h.walkTo(6, &.{"scratch"});
- try x.h.expectFail(.{ .walk = .{ .fid = 6, .newfid = 7, .names = &.{"d"} } }, "file does not exist");
- try x.h.expectFail(.{ .create = .{ .fid = 5, .name = "x", .perm = 0o644, .mode = cloud9.oread } }, "unknown fid"); // remove clunked it
-}
-
-test "qid paths are stable identities, not addresses: remove + recreate differ" {
- var x: Fixture = .{};
- try x.init();
- defer x.deinit();
- try x.h.walkTo(1, &.{"scratch"});
- const a = try x.h.ok(.{ .create = .{ .fid = 1, .name = "f", .perm = 0o644, .mode = cloud9.oread } });
- const path_a = a.create.qid.path;
- try testing.expectEqual(x.nodeOf(1).path, path_a & ((1 << 56) - 1));
- try testing.expect(path_a != 0);
- // a rename keeps the identity
- var st = dontcare;
- st.name = "g";
- _ = try x.h.ok(.{ .wstat = .{ .fid = 1, .stat = st } });
- try testing.expectEqual(path_a, (try x.h.ok(.{ .stat = .{ .fid = 1 } })).stat.qid.path);
- _ = try x.h.ok(.{ .remove = .{ .fid = 1 } });
- // the allocator very likely reuses the freed node's address here
- try x.h.walkTo(2, &.{"scratch"});
- const b = try x.h.ok(.{ .create = .{ .fid = 2, .name = "f", .perm = 0o644, .mode = cloud9.oread } });
- try testing.expect(b.create.qid.path != path_a);
- _ = try x.h.ok(.{ .remove = .{ .fid = 2 } });
- // the scratch root keeps path 0 (its handle), like every provider root
- try x.h.walkTo(3, &.{"scratch"});
- try testing.expectEqual(@as(u64, 0), (try x.h.ok(.{ .stat = .{ .fid = 3 } })).stat.qid.path);
- // directory listing reports the same identities as walking
- _ = try x.h.ok(.{ .create = .{ .fid = 3, .name = "listed", .perm = 0o644, .mode = cloud9.oread } });
- const via_create = (try x.h.ok(.{ .stat = .{ .fid = 3 } })).stat.qid;
- try x.h.walkTo(4, &.{"scratch"});
- _ = try x.h.ok(.{ .open = .{ .fid = 4, .mode = cloud9.oread } });
- const r = try x.h.ok(.{ .read = .{ .fid = 4, .offset = 0, .count = 1024 } });
- const listed = try cloud9.Stat.decode(r.read[0 .. std.mem.readInt(u16, r.read[0..2], .little) + 2]);
- try testing.expectEqual(via_create, listed.qid);
- _ = try x.h.ok(.{ .remove = .{ .fid = 3 } });
-}
diff --git a/introspect/src/vars.zig b/introspect/src/vars.zig
deleted file mode 100644
index 20cadfc..0000000
--- a/introspect/src/vars.zig
+++ /dev/null
@@ -1,824 +0,0 @@
-//! Comptime value renderers for /vars. For a type T, `vtableFor(T)` builds (at
-//! comptime) a flat table of the files and directories that describe a value of
-//! that type:
-//!
-//! <var>/value rendered text <var>/type @typeName
-//! <var>/size @sizeOf <var>/addr 0x…
-//! <var>/raw the bytes <var>/f/<field>/... recursively (depth <= max_depth)
-//!
-//! The core serves a variable by walking this table; a node index is the whole
-//! state it needs. Rendering writes into a `*std.Io.Writer` and never allocates.
-//! Writes to scalar `value` files are plain stores (not atomic).
-const std = @import("std");
-const Writer = std.Io.Writer;
-
-/// Deepest `f/` nesting: /vars/x/f/a/f/b/f/c/f/d/value is depth 4.
-pub const max_depth: u8 = 4;
-/// Longest string rendered from a `[]const u8` / `[*:0]const u8` before "…".
-pub const max_string: usize = 256;
-/// Most array/slice elements rendered before "…".
-pub const max_elems: usize = 64;
-
-pub const Kind = enum(u8) {
- /// The directory of a value: value, type, size, addr, raw, [f].
- dir,
- /// Rendered text; writable when `set` is non-null.
- value,
- /// @typeName, static content.
- type_name,
- /// @sizeOf, static content.
- size,
- /// "0x…" of the value's address.
- addr,
- /// The bytes of the value, length = size.
- raw,
- /// The `f` directory: one `dir` per struct field.
- fields,
-};
-
-pub const RenderFn = *const fn (base: [*]const u8, w: *Writer) Writer.Error!void;
-pub const SetFn = *const fn (base: [*]u8, text: []const u8) SetError!void;
-pub const SetError = error{ Invalid, Unsupported };
-
-/// One node of a type's tree. Children occupy `first..first+count`.
-pub const Node = struct {
- name: []const u8,
- kind: Kind,
- parent: u32,
- first: u32 = 0,
- count: u32 = 0,
- /// Byte offset of the described value from the variable's base address.
- offset: usize,
- /// @sizeOf the described value.
- size: usize,
- /// Static text for `type_name` and `size` leaves.
- content: []const u8 = "",
- render: ?RenderFn = null,
- set: ?SetFn = null,
-
- pub fn isDir(n: Node) bool {
- return n.kind == .dir or n.kind == .fields;
- }
-
- pub fn writable(n: Node) bool {
- return n.kind == .value and n.set != null;
- }
-};
-
-pub const VTable = struct {
- nodes: []const Node,
- type_name: []const u8,
- size: usize,
-
- /// The child of `dir` named `name`, if any.
- pub fn child(vt: *const VTable, dir: u32, name: []const u8) ?u32 {
- const d = vt.nodes[dir];
- for (d.first..d.first + d.count) |i| {
- if (std.mem.eql(u8, vt.nodes[i].name, name)) return @intCast(i);
- }
- return null;
- }
-};
-
-/// The comptime-generated table for `T`; the same pointer for the same `T`.
-pub fn vtableFor(comptime T: type) *const VTable {
- const S = struct {
- const nodes = buildTable(T);
- const vt: VTable = .{ .nodes = &nodes, .type_name = @typeName(T), .size = @sizeOf(T) };
- };
- return &S.vt;
-}
-
-/// Whether a struct's fields get an `f/` directory at this depth.
-fn hasFields(comptime T: type, depth: u8) bool {
- if (depth >= max_depth) return false;
- return switch (@typeInfo(T)) {
- .@"struct" => |s| s.layout != .@"packed" and fieldCount(T) > 0,
- else => false,
- };
-}
-
-fn fieldCount(comptime T: type) usize {
- var n: usize = 0;
- for (@typeInfo(T).@"struct".fields) |f| {
- if (!f.is_comptime and @sizeOf(f.type) != 0) n += 1;
- }
- return n;
-}
-
-fn countNodes(comptime T: type, depth: u8) usize {
- var n: usize = 6; // dir + value, type, size, addr, raw
- if (hasFields(T, depth)) {
- n += 1; // f
- for (@typeInfo(T).@"struct".fields) |f| {
- if (f.is_comptime or @sizeOf(f.type) == 0) continue;
- n += countNodes(f.type, depth + 1);
- }
- }
- return n;
-}
-
-fn fill(nodes: []Node, next: *usize, idx: usize, comptime T: type, name: []const u8, offset: usize, depth: u8, parent: u32) void {
- const with_fields = hasFields(T, depth);
- const count: u32 = if (with_fields) 6 else 5;
- const first = next.*;
- next.* += count;
- nodes[idx] = .{ .name = name, .kind = .dir, .parent = parent, .first = @intCast(first), .count = count, .offset = offset, .size = @sizeOf(T) };
- const me: u32 = @intCast(idx);
- nodes[first + 0] = .{ .name = "value", .kind = .value, .parent = me, .offset = offset, .size = @sizeOf(T), .render = renderFor(T), .set = setFor(T) };
- nodes[first + 1] = .{ .name = "type", .kind = .type_name, .parent = me, .offset = offset, .size = @sizeOf(T), .content = @typeName(T) };
- nodes[first + 2] = .{ .name = "size", .kind = .size, .parent = me, .offset = offset, .size = @sizeOf(T), .content = std.fmt.comptimePrint("{d}", .{@sizeOf(T)}) };
- nodes[first + 3] = .{ .name = "addr", .kind = .addr, .parent = me, .offset = offset, .size = @sizeOf(T) };
- nodes[first + 4] = .{ .name = "raw", .kind = .raw, .parent = me, .offset = offset, .size = @sizeOf(T) };
- if (with_fields) {
- const fdir: u32 = @intCast(first + 5);
- const nf = fieldCount(T);
- const ffirst = next.*;
- next.* += nf;
- nodes[fdir] = .{ .name = "f", .kind = .fields, .parent = me, .first = @intCast(ffirst), .count = @intCast(nf), .offset = offset, .size = @sizeOf(T) };
- var i: usize = 0;
- for (@typeInfo(T).@"struct".fields) |f| {
- if (f.is_comptime or @sizeOf(f.type) == 0) continue;
- fill(nodes, next, ffirst + i, f.type, f.name, offset + @offsetOf(T, f.name), depth + 1, fdir);
- i += 1;
- }
- }
-}
-
-fn buildTable(comptime T: type) [countNodes(T, 0)]Node {
- @setEvalBranchQuota(1_000_000);
- var nodes: [countNodes(T, 0)]Node = undefined;
- var next: usize = 1;
- fill(&nodes, &next, 0, T, "", 0, 0, 0);
- std.debug.assert(next == nodes.len);
- return nodes;
-}
-
-fn renderFor(comptime T: type) RenderFn {
- return &struct {
- fn f(base: [*]const u8, w: *Writer) Writer.Error!void {
- const p: *const T = @ptrCast(@alignCast(base));
- try render(p, w, max_depth);
- }
- }.f;
-}
-
-fn setFor(comptime T: type) ?SetFn {
- if (!settable(T)) return null;
- return &struct {
- fn f(base: [*]u8, text: []const u8) SetError!void {
- const p: *T = @ptrCast(@alignCast(base));
- try set(p, text);
- }
- }.f;
-}
-
-fn settable(comptime T: type) bool {
- return switch (@typeInfo(T)) {
- .int, .float, .bool, .@"enum" => true,
- else => false,
- };
-}
-
-// ---------------------------------------------------------------------------
-// Rendering
-// ---------------------------------------------------------------------------
-
-/// Renders `ptr.*`. Structs become "field: value" lines (nested structs
-/// indented); everything else is a single line without a trailing newline.
-/// `depth` bounds struct/union/optional nesting; deeper values print as "…".
-pub fn render(ptr: anytype, w: *Writer, depth: usize) Writer.Error!void {
- const T = @TypeOf(ptr.*);
- if (comptime isPlainStruct(T)) {
- try renderStruct(T, ptr, w, depth, 0);
- } else {
- try renderValue(T, ptr, w, depth);
- }
-}
-
-fn isPlainStruct(comptime T: type) bool {
- return switch (@typeInfo(T)) {
- .@"struct" => |s| !s.is_tuple and s.fields.len > 0,
- else => false,
- };
-}
-
-/// The multi-line form: each field on its own line, nested structs indented.
-fn renderStruct(comptime T: type, ptr: *const T, w: *Writer, depth: usize, indent: usize) Writer.Error!void {
- if (depth == 0) {
- try w.splatByteAll(' ', indent);
- try w.writeAll("…\n");
- return;
- }
- const packed_layout = @typeInfo(T).@"struct".layout == .@"packed";
- inline for (@typeInfo(T).@"struct".fields) |f| {
- try w.splatByteAll(' ', indent);
- try w.writeAll(f.name);
- try w.writeByte(':');
- if (comptime f.is_comptime) {
- try w.writeAll(" (comptime)\n");
- } else if (comptime packed_layout) {
- // Fields of a packed struct have no byte address: render a copy.
- const v = @field(ptr.*, f.name);
- try w.writeByte(' ');
- try renderValue(f.type, &v, w, depth - 1);
- try w.writeByte('\n');
- } else if (comptime isPlainStruct(f.type)) {
- try w.writeByte('\n');
- try renderStruct(f.type, &@field(ptr.*, f.name), w, depth - 1, indent + 2);
- } else {
- try w.writeByte(' ');
- try renderValue(f.type, &@field(ptr.*, f.name), w, depth - 1);
- try w.writeByte('\n');
- }
- }
-}
-
-/// The single-line form of any value.
-fn renderValue(comptime T: type, ptr: *const T, w: *Writer, depth: usize) Writer.Error!void {
- switch (@typeInfo(T)) {
- .int, .comptime_int => try w.print("{d}", .{ptr.*}),
- .float, .comptime_float => try w.print("{d}", .{ptr.*}),
- .bool => {
- // The variable is live memory that anything (a debugger's /mem write,
- // a torn update) may have corrupted: judge the byte, not the bool.
- const b = @as(*const u8, @ptrCast(ptr)).*;
- switch (b) {
- 0 => try w.writeAll("false"),
- 1 => try w.writeAll("true"),
- else => try w.print("{d}", .{b}),
- }
- },
- .void => try w.writeAll("{}"),
- .@"enum" => |e| {
- // Read the storage bytes as one integer: @tagName/switch on a corrupt
- // value is a safety panic, and the value is caller memory we do not
- // control. A load through the tag type would truncate to its bit
- // width (a u2 tag in a byte), so the full storage width is read.
- if (@sizeOf(T) == 0) return w.writeAll(e.fields[0].name);
- const Raw = std.meta.Int(.unsigned, @sizeOf(T) * 8);
- const raw = @as(*align(@alignOf(T)) const Raw, @ptrCast(ptr)).*;
- const TagU = std.meta.Int(.unsigned, @bitSizeOf(e.tag_type));
- const padding: Raw = if (@bitSizeOf(TagU) == @bitSizeOf(Raw)) 0 else ~@as(Raw, std.math.maxInt(TagU));
- if (raw & padding == 0) {
- const low: TagU = @truncate(raw);
- inline for (e.fields) |f| {
- if (low == @as(TagU, @bitCast(@as(e.tag_type, f.value)))) return w.writeAll(f.name);
- }
- }
- try w.print("{d}", .{raw});
- },
- .error_set => try w.print("error.{s}", .{@errorName(ptr.*)}),
- .error_union => |eu| if (ptr.*) |v| {
- try renderValue(eu.payload, &v, w, depth);
- } else |e| {
- try w.print("error.{s}", .{@errorName(e)});
- },
- .optional => |o| if (ptr.*) |v| {
- try renderValue(o.child, &v, w, depth);
- } else {
- try w.writeAll("null");
- },
- .pointer => |p| switch (p.size) {
- .slice => if (p.child == u8) {
- try renderString(ptr.*, w);
- } else {
- try renderElems(p.child, ptr.*, w, depth);
- },
- .many => if (p.child == u8 and p.sentinel() == 0) {
- try renderCString(ptr.*, w);
- } else {
- try w.print("0x{x}", .{@intFromPtr(ptr.*)});
- },
- .one, .c => try w.print("0x{x}", .{@intFromPtr(ptr.*)}),
- },
- .array => |a| if (a.child == u8) {
- try renderString(ptr.*[0..], w);
- } else {
- try renderElems(a.child, ptr.*[0..], w, depth);
- },
- .vector => |v| {
- const arr: [v.len]v.child = ptr.*;
- try renderElems(v.child, &arr, w, depth);
- },
- .@"struct" => |s| {
- if (depth == 0) {
- try w.writeAll("…");
- return;
- }
- if (s.fields.len == 0) {
- try w.writeAll("{}");
- return;
- }
- try w.writeAll("{ ");
- inline for (s.fields, 0..) |f, i| {
- if (i != 0) try w.writeAll(", ");
- if (!s.is_tuple) {
- try w.writeAll(f.name);
- try w.writeAll(": ");
- }
- if (comptime f.is_comptime) {
- try w.writeAll("(comptime)");
- } else if (comptime s.layout == .@"packed") {
- const v = @field(ptr.*, f.name);
- try renderValue(f.type, &v, w, depth - 1);
- } else {
- try renderValue(f.type, &@field(ptr.*, f.name), w, depth - 1);
- }
- }
- try w.writeAll(" }");
- },
- .@"union" => |u| {
- const Tag = u.tag_type orelse {
- try w.print("(untagged union, {d} bytes)", .{@sizeOf(T)});
- return;
- };
- if (depth == 0) {
- try w.writeAll("…");
- return;
- }
- // A switch on a corrupt tag is a safety panic: match the integer first.
- const raw = @intFromEnum(@as(Tag, ptr.*));
- inline for (u.fields) |f| {
- if (raw == @intFromEnum(@field(Tag, f.name))) {
- try w.writeAll(f.name);
- if (f.type != void) {
- try w.writeAll(": ");
- try renderValue(f.type, &@field(ptr.*, f.name), w, depth - 1);
- }
- return;
- }
- }
- try w.print("(invalid tag {d})", .{raw});
- },
- .@"fn" => try w.print("0x{x}", .{@intFromPtr(ptr)}),
- else => try w.print("<{s}>", .{@typeName(T)}),
- }
-}
-
-fn renderElems(comptime E: type, items: []const E, w: *Writer, depth: usize) Writer.Error!void {
- try w.writeByte('[');
- for (items, 0..) |*item, i| {
- if (i == max_elems) {
- try w.writeAll(", …");
- break;
- }
- if (i != 0) try w.writeAll(", ");
- try renderValue(E, item, w, depth);
- }
- try w.writeByte(']');
-}
-
-/// A NUL-terminated string, scanning at most `max_string` + 1 bytes for the
-/// terminator so that a missing one cannot walk off the end of the mapping.
-fn renderCString(s: [*:0]const u8, w: *Writer) Writer.Error!void {
- var n: usize = 0;
- while (n <= max_string and s[n] != 0) n += 1;
- try renderString(s[0..n], w);
-}
-
-/// A double-quoted string with C-style escapes, truncated to `max_string` bytes.
-fn renderString(s: []const u8, w: *Writer) Writer.Error!void {
- try w.writeByte('"');
- for (s[0..@min(s.len, max_string)]) |b| switch (b) {
- '\n' => try w.writeAll("\\n"),
- '\r' => try w.writeAll("\\r"),
- '\t' => try w.writeAll("\\t"),
- '\\' => try w.writeAll("\\\\"),
- '"' => try w.writeAll("\\\""),
- ' '...'!', '#'...'[', ']'...'~' => try w.writeByte(b),
- else => {
- const hex = "0123456789abcdef";
- try w.writeAll("\\x");
- try w.writeByte(hex[b >> 4]);
- try w.writeByte(hex[b & 15]);
- },
- };
- try w.writeByte('"');
- if (s.len > max_string) try w.writeAll("…");
-}
-
-// ---------------------------------------------------------------------------
-// Setting
-// ---------------------------------------------------------------------------
-
-/// Parses `text` and stores it into `ptr.*`: ints in decimal or 0x/0o/0b,
-/// floats, bools (true/false/1/0), enums by tag name (or by integer value for
-/// non-exhaustive enums). Other types are `error.Unsupported`.
-pub fn set(ptr: anytype, text: []const u8) SetError!void {
- const T = @TypeOf(ptr.*);
- const s = std.mem.trim(u8, text, " \t\r\n\x00");
- switch (@typeInfo(T)) {
- .int => ptr.* = std.fmt.parseInt(T, s, 0) catch return error.Invalid,
- .float => ptr.* = std.fmt.parseFloat(T, s) catch return error.Invalid,
- .bool => {
- if (std.mem.eql(u8, s, "true") or std.mem.eql(u8, s, "1")) {
- ptr.* = true;
- } else if (std.mem.eql(u8, s, "false") or std.mem.eql(u8, s, "0")) {
- ptr.* = false;
- } else return error.Invalid;
- },
- .@"enum" => |e| {
- if (std.meta.stringToEnum(T, s)) |v| {
- ptr.* = v;
- } else if (!e.is_exhaustive) {
- const raw = std.fmt.parseInt(e.tag_type, s, 0) catch return error.Invalid;
- ptr.* = @enumFromInt(raw);
- } else return error.Invalid;
- },
- else => return error.Unsupported,
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-const testing = std.testing;
-
-fn renderToBuf(buf: []u8, ptr: anytype) ![]const u8 {
- var w: Writer = .fixed(buf);
- try render(ptr, &w, max_depth);
- return w.buffered();
-}
-
-test "render scalars, strings, pointers, optionals, enums, arrays" {
- var buf: [512]u8 = undefined;
- const i: i32 = -42;
- try testing.expectEqualStrings("-42", try renderToBuf(&buf, &i));
- const f: f32 = 1.5;
- try testing.expectEqualStrings("1.5", try renderToBuf(&buf, &f));
- const b: bool = true;
- try testing.expectEqualStrings("true", try renderToBuf(&buf, &b));
- const s: []const u8 = "hi \"there\"\n";
- try testing.expectEqualStrings("\"hi \\\"there\\\"\\n\"", try renderToBuf(&buf, &s));
- const z: [*:0]const u8 = "zed";
- try testing.expectEqualStrings("\"zed\"", try renderToBuf(&buf, &z));
- const p: *const i32 = &i;
- var expect_buf: [32]u8 = undefined;
- const expect = try std.fmt.bufPrint(&expect_buf, "0x{x}", .{@intFromPtr(&i)});
- try testing.expectEqualStrings(expect, try renderToBuf(&buf, &p));
- const o: ?u8 = null;
- try testing.expectEqualStrings("null", try renderToBuf(&buf, &o));
- const o2: ?u8 = 7;
- try testing.expectEqualStrings("7", try renderToBuf(&buf, &o2));
- const E = enum { red, green };
- const e: E = .green;
- try testing.expectEqualStrings("green", try renderToBuf(&buf, &e));
- const NE = enum(u8) { a, _ };
- const ne: NE = @enumFromInt(9);
- try testing.expectEqualStrings("9", try renderToBuf(&buf, &ne));
- const arr = [_]u16{ 1, 2, 3 };
- try testing.expectEqualStrings("[1, 2, 3]", try renderToBuf(&buf, &arr));
- const bytes = [_]u8{ 0, 'a', 0xff };
- try testing.expectEqualStrings("\"\\x00a\\xff\"", try renderToBuf(&buf, &bytes));
- const U = union(enum) { none, some: u32 };
- const u: U = .{ .some = 5 };
- try testing.expectEqualStrings("some: 5", try renderToBuf(&buf, &u));
- const un: U = .none;
- try testing.expectEqualStrings("none", try renderToBuf(&buf, &un));
-}
-
-test "render structs multi-line with nested indentation and depth limit" {
- const Inner = struct { x: f32, flags: [2]bool };
- const Outer = struct { a: u32, b: bool, name: []const u8, inner: Inner, items: []const Inner };
- const v: Outer = .{ .a = 1, .b = false, .name = "n", .inner = .{ .x = 2.5, .flags = .{ true, false } }, .items = &.{.{ .x = 0, .flags = .{ false, false } }} };
- var buf: [512]u8 = undefined;
- try testing.expectEqualStrings(
- \\a: 1
- \\b: false
- \\name: "n"
- \\inner:
- \\ x: 2.5
- \\ flags: [true, false]
- \\items: [{ x: 0, flags: [false, false] }]
- \\
- , try renderToBuf(&buf, &v));
- var w: Writer = .fixed(&buf);
- try render(&v, &w, 1);
- try testing.expectEqualStrings(
- \\a: 1
- \\b: false
- \\name: "n"
- \\inner:
- \\ …
- \\items: […]
- \\
- , w.buffered());
-}
-
-test "long strings and arrays are truncated" {
- const long = [_]u8{'x'} ** 300;
- var buf: [1024]u8 = undefined;
- const s: []const u8 = &long;
- const out = try renderToBuf(&buf, &s);
- try testing.expectEqual(@as(usize, 1 + max_string + 1 + "…".len), out.len);
- try testing.expect(std.mem.endsWith(u8, out, "\"…"));
- const nums: [100]u32 = @splat(1);
- const out2 = try renderToBuf(&buf, &nums);
- try testing.expect(std.mem.endsWith(u8, out2, ", …]"));
- try testing.expectEqual(@as(usize, max_elems), std.mem.count(u8, out2, "1"));
-}
-
-test "set parses ints, floats, bools and enums" {
- var i: u32 = 0;
- try set(&i, "42\n");
- try testing.expectEqual(@as(u32, 42), i);
- try set(&i, "0x10");
- try testing.expectEqual(@as(u32, 16), i);
- try testing.expectError(error.Invalid, set(&i, "-1"));
- try testing.expectError(error.Invalid, set(&i, "abc"));
- var si: i8 = 0;
- try set(&si, " -7 ");
- try testing.expectEqual(@as(i8, -7), si);
- try testing.expectError(error.Invalid, set(&si, "200"));
- var f: f64 = 0;
- try set(&f, "2.25");
- try testing.expectEqual(@as(f64, 2.25), f);
- var b: bool = false;
- try set(&b, "true");
- try testing.expect(b);
- try set(&b, "0");
- try testing.expect(!b);
- try testing.expectError(error.Invalid, set(&b, "maybe"));
- const E = enum { off, on };
- var e: E = .off;
- try set(&e, "on");
- try testing.expectEqual(E.on, e);
- try testing.expectError(error.Invalid, set(&e, "blue"));
- var s: []const u8 = "x";
- try testing.expectError(error.Unsupported, set(&s, "y"));
-}
-
-test "vtable table layout for a nested struct" {
- const Inner = struct { x: f32 };
- const T = struct { a: u32, b: bool, name: []const u8, inner: Inner };
- const vt = vtableFor(T);
- try testing.expectEqual(vt, vtableFor(T));
- try testing.expectEqualStrings(@typeName(T), vt.type_name);
- const root = vt.nodes[0];
- try testing.expect(root.isDir());
- try testing.expectEqual(@as(u32, 6), root.count);
- const value = vt.child(0, "value").?;
- try testing.expect(!vt.nodes[value].writable()); // a struct is not settable
- try testing.expectEqualStrings(std.fmt.comptimePrint("{d}", .{@sizeOf(T)}), vt.nodes[vt.child(0, "size").?].content);
- const f = vt.child(0, "f").?;
- try testing.expectEqual(Kind.fields, vt.nodes[f].kind);
- try testing.expectEqual(@as(u32, 4), vt.nodes[f].count);
- const a = vt.child(f, "a").?;
- try testing.expectEqual(@offsetOf(T, "a"), vt.nodes[a].offset);
- const a_value = vt.child(a, "value").?;
- try testing.expect(vt.nodes[a_value].writable());
- try testing.expectEqual(@as(usize, 4), vt.nodes[a_value].size);
- try testing.expectEqual(a, vt.nodes[a_value].parent);
- const inner = vt.child(f, "inner").?;
- const inner_f = vt.child(inner, "f").?;
- const x = vt.child(inner_f, "x").?;
- try testing.expectEqual(@offsetOf(T, "inner") + @offsetOf(Inner, "x"), vt.nodes[x].offset);
- try testing.expectEqualStrings("f32", vt.nodes[vt.child(x, "type").?].content);
- try testing.expect(vt.child(f, "nope") == null);
- // rendering and setting through the table
- var v: T = .{ .a = 1, .b = true, .name = "n", .inner = .{ .x = 0.5 } };
- const base: [*]u8 = @ptrCast(&v);
- var buf: [256]u8 = undefined;
- var w: Writer = .fixed(&buf);
- const x_value = vt.child(x, "value").?;
- try vt.nodes[x_value].render.?(base + vt.nodes[x_value].offset, &w);
- try testing.expectEqualStrings("0.5", w.buffered());
- try vt.nodes[a_value].set.?(base + vt.nodes[a_value].offset, "42");
- try testing.expectEqual(@as(u32, 42), v.a);
- w = .fixed(&buf);
- try vt.nodes[value].render.?(base, &w);
- try testing.expect(std.mem.startsWith(u8, w.buffered(), "a: 42\nb: true\n"));
-}
-
-test "depth limit stops the f/ tree at max_depth" {
- const L4 = struct { v: u8 };
- const L3 = struct { l4: L4 };
- const L2 = struct { l3: L3 };
- const L1 = struct { l2: L2 };
- const L0 = struct { l1: L1 };
- const vt = vtableFor(L0);
- var node: u32 = 0;
- var depth: usize = 0;
- while (vt.child(node, "f")) |f| : (depth += 1) {
- node = vt.nodes[f].first; // the single field
- }
- try testing.expectEqual(@as(usize, max_depth), depth);
- try testing.expect(vt.child(node, "value") != null);
-}
-
-test "every @typeInfo category renders without dereferencing anything unbounded" {
- var buf: [2048]u8 = undefined;
- // packed and extern structs (packed fields have no address: rendered by copy)
- const Packed = packed struct { a: u3, b: bool, c: u12, e: enum(u2) { p, q, r } };
- const pk: Packed = .{ .a = 5, .b = true, .c = 300, .e = .r };
- try testing.expectEqualStrings("a: 5\nb: true\nc: 300\ne: r\n", try renderToBuf(&buf, &pk));
- const Ext = extern struct { x: u16, y: f32, inner: extern struct { z: u8 } };
- const ex: Ext = .{ .x = 1, .y = 0.5, .inner = .{ .z = 9 } };
- try testing.expectEqualStrings("x: 1\ny: 0.5\ninner:\n z: 9\n", try renderToBuf(&buf, &ex));
- const Holder = struct { p: Packed, list: [2]Packed };
- const ho: Holder = .{ .p = pk, .list = .{ pk, pk } };
- try testing.expect(std.mem.startsWith(u8, try renderToBuf(&buf, &ho), "p:\n a: 5\n"));
- // the f/ tree has no entries for a packed struct and works through a table
- const vt = vtableFor(Packed);
- try testing.expect(vt.child(0, "f") == null);
- var w: Writer = .fixed(&buf);
- try vt.nodes[vt.child(0, "value").?].render.?(@ptrCast(&pk), &w);
- try testing.expect(std.mem.startsWith(u8, w.buffered(), "a: 5\n"));
- // optionals of pointers are printed, never followed
- var target: u32 = 7;
- const op: ?*u32 = &target;
- var expect_buf: [32]u8 = undefined;
- try testing.expectEqualStrings(try std.fmt.bufPrint(&expect_buf, "0x{x}", .{@intFromPtr(&target)}), try renderToBuf(&buf, &op));
- const np: ?*u32 = null;
- try testing.expectEqualStrings("null", try renderToBuf(&buf, &np));
- const dangling: *const u32 = @ptrFromInt(0x1000);
- try testing.expectEqualStrings("0x1000", try renderToBuf(&buf, &dangling));
- const cptr: [*c]const u8 = @ptrFromInt(0x2000);
- try testing.expectEqualStrings("0x2000", try renderToBuf(&buf, &cptr));
- const manyp: [*]const u32 = @ptrFromInt(0x3000);
- try testing.expectEqualStrings("0x3000", try renderToBuf(&buf, &manyp));
- // untagged and tagged unions, error unions, error sets
- const Untagged = union { a: u32, b: f32 };
- const un: Untagged = .{ .a = 1 };
- try testing.expectEqualStrings(std.fmt.comptimePrint("(untagged union, {d} bytes)", .{@sizeOf(Untagged)}), try renderToBuf(&buf, &un));
- const Tagged = union(enum(u8)) { none, some: u32, pair: struct { l: u8, r: u8 } };
- const tg: Tagged = .{ .pair = .{ .l = 1, .r = 2 } };
- try testing.expectEqualStrings("pair: { l: 1, r: 2 }", try renderToBuf(&buf, &tg));
- const eu: anyerror!u8 = error.Boom;
- try testing.expectEqualStrings("error.Boom", try renderToBuf(&buf, &eu));
- const eu2: error{X}!u8 = 4;
- try testing.expectEqualStrings("4", try renderToBuf(&buf, &eu2));
- const es: anyerror = error.Zap;
- try testing.expectEqualStrings("error.Zap", try renderToBuf(&buf, &es));
- // wide ints and floats, vectors, sentinel arrays, slices of slices, void, comptime fields
- const big: u128 = std.math.maxInt(u128);
- try testing.expectEqualStrings("340282366920938463463374607431768211455", try renderToBuf(&buf, &big));
- const neg: i128 = std.math.minInt(i128);
- try testing.expectEqualStrings("-170141183460469231731687303715884105728", try renderToBuf(&buf, &neg));
- const h: f16 = 1.5;
- try testing.expectEqualStrings("1.5", try renderToBuf(&buf, &h));
- const ld: f80 = 2.25;
- try testing.expectEqualStrings("2.25", try renderToBuf(&buf, &ld));
- const quad: f128 = 3.125;
- try testing.expectEqualStrings("3.125", try renderToBuf(&buf, &quad));
- const vec: @Vector(4, i16) = .{ 1, -2, 3, -4 };
- try testing.expectEqualStrings("[1, -2, 3, -4]", try renderToBuf(&buf, &vec));
- const sarr: [3:0]u8 = .{ 'a', 'b', 'c' };
- try testing.expectEqualStrings("\"abc\"", try renderToBuf(&buf, &sarr));
- const rows: []const []const u8 = &.{ "ab", "cd" };
- try testing.expectEqualStrings("[\"ab\", \"cd\"]", try renderToBuf(&buf, &rows));
- const Odd = struct { v: void, comptime k: u8 = 3, n: u8 };
- const odd: Odd = .{ .v = {}, .n = 1 };
- try testing.expectEqualStrings("v: {}\nk: (comptime)\nn: 1\n", try renderToBuf(&buf, &odd));
- try testing.expectEqual(@as(u32, 1), vtableFor(Odd).nodes[vtableFor(Odd).child(0, "f").?].count);
- // self-referential through a pointer: rendered as an address, table stays finite
- const Link = struct { next: ?*const @This(), v: u8 };
- var a: Link = .{ .next = null, .v = 1 };
- const b: Link = .{ .next = &a, .v = 2 };
- a.next = &b;
- try testing.expectEqualStrings(try std.fmt.bufPrint(&expect_buf, "next: 0x{x}\nv: 2\n", .{@intFromPtr(&a)}), try renderToBuf(&buf, &b));
- try testing.expect(vtableFor(Link).nodes.len < 32);
- // tuples
- const tup: struct { u8, []const u8 } = .{ 1, "x" };
- try testing.expectEqualStrings("{ 1, \"x\" }", try renderToBuf(&buf, &tup));
-}
-
-test "corrupt live memory renders instead of trapping: enums, unions, bools" {
- var buf: [128]u8 = undefined;
- const E = enum(u8) { a, b };
- var raw_e: u8 = 7;
- try testing.expectEqualStrings("7", try renderToBuf(&buf, @as(*const E, @ptrCast(&raw_e))));
- raw_e = 1;
- try testing.expectEqualStrings("b", try renderToBuf(&buf, @as(*const E, @ptrCast(&raw_e))));
- // a u2 tag in a byte: the whole byte is judged, not the truncated tag (ReleaseSafe would say "c")
- const E3 = enum { a, b, c };
- var raw3: u8 = 0xEE;
- try testing.expectEqualStrings("238", try renderToBuf(&buf, @as(*const E3, @ptrCast(&raw3))));
- raw3 = 3;
- try testing.expectEqualStrings("3", try renderToBuf(&buf, @as(*const E3, @ptrCast(&raw3))));
- raw3 = 2;
- try testing.expectEqualStrings("c", try renderToBuf(&buf, @as(*const E3, @ptrCast(&raw3))));
- const E12 = enum(u12) { p = 5, q = 4095 };
- var raw12: u16 = 0xF005;
- try testing.expectEqualStrings("61445", try renderToBuf(&buf, @as(*const E12, @ptrCast(&raw12))));
- raw12 = 4095;
- try testing.expectEqualStrings("q", try renderToBuf(&buf, @as(*const E12, @ptrCast(&raw12))));
- const ES = enum(i8) { neg = -3, pos = 7 };
- var raws: u8 = 0xFD;
- try testing.expectEqualStrings("neg", try renderToBuf(&buf, @as(*const ES, @ptrCast(&raws))));
- raws = 0x80;
- try testing.expectEqualStrings("128", try renderToBuf(&buf, @as(*const ES, @ptrCast(&raws))));
- const E1 = enum { only };
- const e1: E1 = .only;
- try testing.expectEqualStrings("only", try renderToBuf(&buf, &e1));
- const NE = enum(u16) { x = 5, _ };
- var raw_ne: u16 = 5;
- try testing.expectEqualStrings("x", try renderToBuf(&buf, @as(*const NE, @ptrCast(&raw_ne))));
- raw_ne = 6;
- try testing.expectEqualStrings("6", try renderToBuf(&buf, @as(*const NE, @ptrCast(&raw_ne))));
- var raw_b: u8 = 2;
- try testing.expectEqualStrings("2", try renderToBuf(&buf, @as(*const bool, @ptrCast(&raw_b))));
- const U = union(enum(u8)) { x: u32, y: bool };
- var raw_u: [@sizeOf(U)]u8 align(@alignOf(U)) = @splat(0x55);
- const out = try renderToBuf(&buf, @as(*const U, @ptrCast(&raw_u)));
- try testing.expectEqualStrings("(invalid tag 85)", out);
- const S = struct { e: E, u: U, b: bool };
- var raw_s: [@sizeOf(S)]u8 align(@alignOf(S)) = @splat(0xEE);
- const ps: *const S = @ptrCast(&raw_s);
- _ = try renderToBuf(&buf, ps); // no trap
- try testing.expect(std.mem.indexOf(u8, try renderToBuf(&buf, ps), "238") != null);
-}
-
-test "a [*:0]const u8 without a terminator is read at most max_string + 1 bytes" {
- // Only the first max_string + 1 bytes exist; anything beyond is the
- // testing allocator's guard, which a wider scan would touch.
- const mem = try testing.allocator.alloc(u8, max_string + 1);
- defer testing.allocator.free(mem);
- @memset(mem, 'x');
- const z: [*:0]const u8 = @ptrCast(mem.ptr);
- var buf: [1024]u8 = undefined;
- const out = try renderToBuf(&buf, &z);
- try testing.expectEqual(@as(usize, 1 + max_string + 1 + "…".len), out.len);
- try testing.expect(std.mem.endsWith(u8, out, "\"…"));
- // exactly max_string bytes then NUL: no ellipsis
- const mem2 = try testing.allocator.alloc(u8, max_string + 1);
- defer testing.allocator.free(mem2);
- @memset(mem2, 'y');
- mem2[max_string] = 0;
- const z2: [*:0]const u8 = @ptrCast(mem2.ptr);
- const out2 = try renderToBuf(&buf, &z2);
- try testing.expectEqual(@as(usize, 1 + max_string + 1), out2.len);
- // a garbage-length []const u8 still reads at most max_string bytes
- const garbage: []const u8 = mem[0..max_string];
- _ = try renderToBuf(&buf, &garbage);
-}
-
-test "set rejects hostile input without partial writes" {
- var u: u8 = 200;
- for ([_][]const u8{ "-1", "256", "1e3", "0x", "", " ", "1.5", "+", "0b2", "Ù¡", "12abc", "0x100", "\x00", "1 2" }) |bad| {
- try testing.expectError(error.Invalid, set(&u, bad));
- try testing.expectEqual(@as(u8, 200), u);
- }
- try set(&u, "0b1111_1111");
- try testing.expectEqual(@as(u8, 255), u);
- try set(&u, "+0o17");
- try testing.expectEqual(@as(u8, 15), u);
- var i: i64 = 1;
- try set(&i, "-9223372036854775808");
- try testing.expectEqual(std.math.minInt(i64), i);
- try testing.expectError(error.Invalid, set(&i, "9223372036854775808"));
- var w: u128 = 0;
- try set(&w, "340282366920938463463374607431768211455");
- try testing.expectEqual(std.math.maxInt(u128), w);
- try testing.expectError(error.Invalid, set(&w, "340282366920938463463374607431768211456"));
- // floats: exponents, hex floats, inf/nan spellings, and junk
- var f: f32 = 1;
- try set(&f, "1.5e3");
- try testing.expectEqual(@as(f32, 1500), f);
- try set(&f, "-0x1p-2");
- try testing.expectEqual(@as(f32, -0.25), f);
- try set(&f, "1e999");
- try testing.expect(std.math.isInf(f));
- try testing.expectError(error.Invalid, set(&f, "1.5.5"));
- try testing.expectError(error.Invalid, set(&f, "e5"));
- try testing.expectError(error.Invalid, set(&f, ""));
- var h: f16 = 0;
- try set(&h, "65504");
- try testing.expectEqual(@as(f16, 65504), h);
- var q: f128 = 0;
- try set(&q, "2.5");
- try testing.expectEqual(@as(f128, 2.5), q);
- // enums: NULs inside the tag, case, trailing junk; non-exhaustive by integer only when out of names
- const E = enum(u8) { off, on };
- var e: E = .off;
- for ([_][]const u8{ "on\x00x", "On", "on x", "1", "0x1", "" }) |bad| {
- try testing.expectError(error.Invalid, set(&e, bad));
- try testing.expectEqual(E.off, e);
- }
- try set(&e, "\x00on\n");
- try testing.expectEqual(E.on, e);
- const NE = enum(u8) { a, _ };
- var ne: NE = .a;
- try set(&ne, "200");
- try testing.expectEqual(@as(u8, 200), @intFromEnum(ne));
- try testing.expectError(error.Invalid, set(&ne, "256"));
- try testing.expectError(error.Invalid, set(&ne, "-1"));
- try set(&ne, "a");
- try testing.expectEqual(NE.a, ne);
- // bools
- var b: bool = true;
- for ([_][]const u8{ "yes", "TRUE", "2", "", "01" }) |bad| {
- try testing.expectError(error.Invalid, set(&b, bad));
- try testing.expect(b);
- }
- // unsupported types are refused without touching memory
- var opt: ?u8 = 3;
- try testing.expectError(error.Unsupported, set(&opt, "4"));
- try testing.expectEqual(@as(?u8, 3), opt);
- var arr: [2]u8 = .{ 1, 2 };
- try testing.expectError(error.Unsupported, set(&arr, "x"));
- var un: union(enum) { a: u8 } = .{ .a = 1 };
- try testing.expectError(error.Unsupported, set(&un, "a"));
-}
diff --git a/introspect/test/adv_core_hostile.py b/introspect/test/adv_core_hostile.py
deleted file mode 100755
index 56ef5a3..0000000
--- a/introspect/test/adv_core_hostile.py
+++ /dev/null
@@ -1,1018 +0,0 @@
-#!/usr/bin/env python3
-"""Hostile raw-9P2000 client aimed at the introspect *core* (stdlib only).
-
-Complements adv_introspect_hostile.py in this directory (framing, tags, scratch, floods)
-with attacks on the freestanding engine's own paths: the /vars tree and its
-comptime renderers, snapshot slots, the static tree, the fid table at its
-configured maximum, directory-read offsets, msize 24, the ctl staging rule,
-and the demo's debug providers driven as black boxes.
-
-Usage:
- adv_core_hostile.py --server zig-out/bin/introspect # spawns it on a temp unix socket
- adv_core_hostile.py --socket PATH # attacks a running server
-
-Exit status is non-zero if any check fails or the server dies.
-"""
-import argparse
-import os
-import signal
-import struct
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-import adv_introspect_hostile as base # noqa: E402
-from adv_introspect_hostile import ( # noqa: E402
- NOTAG, Tversion, Tflush, Rflush, Twalk, Rwalk, Topen, Ropen, Rcreate,
- Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove, Tstat, Rstat, Twstat, Rwstat,
- Rerror, OREAD, OWRITE, ORDWR, OEXEC, OTRUNC, ORCLOSE, DMDIR,
- Nine, frame, s16, mkstat, parse_stat, ok, healthy, expect_dead,
-)
-
-MAX_FIDS = 32768 # demo/main.zig cfg.max_fids
-SNAPSHOT_SLOTS = 8 # demo/main.zig cfg.snapshot_slots (per connection)
-SCRATCH_BUDGET = 512 << 20
-SCRATCH_MAX_FILE = 64 << 20
-
-
-def records(d):
- """Splits a directory read into (name, raw-record) pairs."""
- out = []
- while d:
- n, = struct.unpack_from("<H", d)
- out.append((parse_stat(d[:n + 2])["name"], d[:n + 2]))
- d = d[n + 2:]
- return out
-
-
-def qid_of_walk(rb):
- n, = struct.unpack_from("<H", rb)
- return [struct.unpack_from("<BIQ", rb, 2 + 13 * i) for i in range(n)]
-
-
-def worker_tid(c):
- """The tid of the demo's worker thread, via /threads/<tid>/name."""
- c.walk_ok(0, 40, [b"threads"])
- c.open(40, OREAD)
- d = c.read_all(40)
- c.clunk(40)
- for name, _ in records(d):
- if c.path_read([b"threads", name, b"name"], fid=41) == b"worker":
- return name
- return None
-
-
-# --------------------------------------------------------------------------- /vars
-
-
-def attack_vars(path):
- print("# /vars: deep walks, hostile names, renderer edge cases, hostile writes")
- c = Nine(path)
- c.session(1 << 20)
- deep = [b"vars", b"state", b"f", b"last_job", b"f", b"id", b".", b"..", b"id", b".", b"..", b"id", b".", b"..", b"id", b"value"]
- assert len(deep) == 16
- ok("16-element walk deep into /vars/state/f/... succeeds", c.walk_ok(0, 1, deep) == 16)
- rt, _, _ = c.open(1, OREAD)
- ok("deep walk lands on a readable value file", rt == Ropen, rt)
- c.clunk(1)
- up = [b"vars", b"state", b"f", b"inner"] if False else [b"vars", b"state", b"f", b"last_job"] + [b".."] * 12
- n = c.walk_ok(0, 1, up)
- ok("12 x '..' from inside /vars climbs to the root and stays there", n == 16, n)
- rt, st = c.stat(1)
- ok("fid after the climb is the root directory", rt == Rstat and st["qid"][2] == 0xFF << 56, st)
- c.clunk(1)
- # names that are hex/decimal edge cases or otherwise hostile: never anything but Rerror/partial walk
- for nm in (b"0", b"-1", b"0x", b"0x0", b"state\x00", b"State", b" state", b"state ", b"a" * 255, b"a" * 65535, b"\xff\xfe", b"..\x00", b"f", b"value"):
- n = c.walk_ok(0, 1, [b"vars", nm])
- ok(f"walk /vars/{nm[:12]!r}{'...' if len(nm) > 12 else ''} is a partial walk (1)", n == 1, n)
- ok(" and newfid stays unbound", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- for nm in (b"0", b"F", b"f\x00", b"ticks", b"value ", b"raw\x00"):
- n = c.walk_ok(0, 1, [b"vars", b"state", nm])
- ok(f"walk /vars/state/{nm!r} is a partial walk (2)", n == 2, n)
- # . and .. on var files and directories
- ok("walk '.' from a var file is 'not a directory'", c.walk_ok(0, 1, [b"vars", b"state", b"value"]) == 3 and c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "not a directory")
- ok("walk '..' from a var file is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory")
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f"])
- rt, _, rb = c.walk(1, 2, [b".", b"..", b"..", b".."])
- q = qid_of_walk(rb) if rt == Rwalk else []
- ok("'.' and '..' through the var tree: f -> state -> /vars -> /", len(q) == 4 and q[3][2] == 0xFF << 56 and (q[1][0] & 0x80), q)
- c.clunk(1)
- c.clunk(2)
- # every file under /vars/state reads; raw reads beyond @sizeOf are empty
- size = int(c.path_read([b"vars", b"state", b"size"]))
- ok("/vars/state/size is a number", size > 0, size)
- raw = c.path_read([b"vars", b"state", b"raw"])
- ok("/vars/state/raw has exactly @sizeOf bytes", raw is not None and len(raw) == size, (len(raw) if raw else raw, size))
- c.walk_ok(0, 1, [b"vars", b"state", b"raw"])
- c.open(1, OREAD)
- rt, d = c.read(1, size, 100)
- ok("raw read at offset @sizeOf is empty", rt == Rread and d == b"", (rt, d))
- rt, d = c.read(1, size - 1, 100)
- ok("raw read at @sizeOf-1 returns one byte", rt == Rread and len(d) == 1, (rt, d))
- rt, d = c.read(1, (1 << 64) - 1, 100)
- ok("raw read at 2^64-1 is empty", rt == Rread and d == b"")
- rt, d = c.read(1, 0, 0xFFFFFFFF)
- ok("raw read with count 2^32-1 is clamped", rt == Rread and len(d) == size, (rt, len(d) if d else d))
- rt, st = c.stat(1)
- ok("raw stat length is @sizeOf and mode 0444", rt == Rstat and st["length"] == size and st["mode"] == 0o444, st)
- ok("raw is read-only", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") is not None)
- c.clunk(1)
- for leaf in (b"type", b"size", b"addr", b"value"):
- c.walk_ok(0, 1, [b"vars", b"state", leaf])
- e = c.err(Topen, struct.pack("<IB", 1, OWRITE))
- ok(f"/vars/state/{leaf.decode()} refuses OWRITE", e == "permission denied", e)
- e = c.err(Topen, struct.pack("<IB", 1, OREAD | OTRUNC))
- ok(f"/vars/state/{leaf.decode()} refuses OTRUNC", e == "permission denied", e)
- c.clunk(1)
- v = c.path_read([b"vars", b"state", b"value"])
- ok("/vars/state/value renders every field", v is not None and all(k in v for k in (b"ticks:", b"phase:", b"last_job:", b"id:", b"cost:")), v)
- ok("nested struct is indented", b"\n id: " in (v or b""), v)
- # dynamic file: read at offset 0 regenerates, offset 1 is the tail of the same snapshot
- c.walk_ok(0, 1, [b"vars", b"state", b"value"])
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 8192)
- rt2, d2 = c.read(1, 1, 8192)
- ok("value read at offset 1 is the tail of the snapshot", rt == Rread and rt2 == Rread and d2 == d[1:], (d, d2))
- rt3, d3 = c.read(1, len(d), 8192)
- ok("value read at the end is empty", rt3 == Rread and d3 == b"")
- rt4, d4 = c.read(1, (1 << 63) + 5, 10)
- ok("value read at 2^63+5 is empty", rt4 == Rread and d4 == b"")
- rt, st = c.stat(1)
- ok("value stat reports length 0 (dynamic)", rt == Rstat and st["length"] == 0, st)
- c.clunk(1)
- # hostile writes to scalar values: garbage, huge, negative, floats with exponents, NULs, empty
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"ticks", b"value"])
- rt, _, _ = c.open(1, OWRITE | OTRUNC)
- ok("open ticks/value OWRITE|OTRUNC", rt == Ropen, rt)
- for bad in (b"abc", b"99999999999999999999999", b"-1", b"1e3", b"", b" ", b"4\x002", b"1.5", b"0x", b"+", b"\xd9\xa1\xd9\xa2", b"12 34", b"0b102"):
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad)
- ok(f"write {bad!r} to u64 value is 'bad value'", e == "bad value", e)
- ok("read on the write-only value fid is 'file not open'", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == "file not open")
- for good, want in ((b" 4200 \n", 4200), (b"0x10", 16), (b"+7", 7), (b"0b1010", 10), (b"0o17", 15), (b"1_000", 1000), (b"18446744073709551615", (1 << 64) - 1)):
- rt, _, rb = c.write(1, (1 << 64) - 1, good) # offset is ignored for values
- got = c.path_read([b"vars", b"state", b"f", b"ticks", b"value"])
- try:
- gv = int(got)
- except (TypeError, ValueError):
- gv = None
- # the worker keeps incrementing (wrapping), so allow a small drift
- ok(f"write {good!r} stores {want}", rt == Rwrite and gv is not None and (gv - want) % (1 << 64) < 100_000, (rt, got))
- c.write(1, 0, b"1")
- c.clunk(1)
- # enum and float and u32 leaves
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"phase", b"value"])
- c.open(1, ORDWR)
- for bad in (b"trap\x00ped", b"IDLE", b"2", b"", b"idle extra", b"\x00idle\x00x", b"idl", b"idle\x00\x00x"):
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad)
- ok(f"enum write {bad!r} is 'bad value'", e == "bad value", e)
- rt, _, _ = c.write(1, 0, b"\n idle \x00")
- rt2, d = c.read(1, 0, 100)
- ok("enum write with surrounding whitespace/NUL is accepted", rt == Rwrite and d in (b"idle", b"working", b"trapped"), (rt, d))
- rt, st = c.stat(1)
- ok("enum value is 0644", rt == Rstat and st["mode"] == 0o644, st)
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"last_job", b"f", b"cost", b"value"])
- c.open(1, ORDWR)
- for bad in (b"abc", b"1.5.5", b"e5", b"", b"0x", b"1e", b"--1"):
- ok(f"float write {bad!r} is 'bad value'", c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad) == "bad value")
- for good in (b"1.5e3", b"-0x1p-2", b"1e999", b"nan", b"-0", b"2.5"):
- rt, _, _ = c.write(1, 0, good)
- rt2, d = c.read(1, 0, 100)
- ok(f"float write {good!r} accepted and renders ({d!r})", rt == Rwrite and rt2 == Rread and d != b"", (rt, d))
- c.write(1, 0, b"0")
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"last_job", b"f", b"id", b"value"])
- c.open(1, OWRITE)
- ok("u32 write 4294967296 is 'bad value'", c.err(Twrite, struct.pack("<IQI", 1, 0, 10) + b"4294967296") == "bad value")
- ok("u32 write -0 is accepted as 0 (std.fmt.parseInt semantics)", c.write(1, 0, b"-0")[0] == Rwrite)
- ok("u32 write -1 is 'bad value'", c.err(Twrite, struct.pack("<IQI", 1, 0, 2) + b"-1") == "bad value")
- rt, _, _ = c.write(1, 0, b"4294967295")
- ok("u32 write 4294967295 accepted", rt == Rwrite)
- c.write(1, 0, b"0")
- c.clunk(1)
- # struct values and the f directory are not writable; the tree admits no create/remove/wstat
- for names in ([b"vars"], [b"vars", b"state"], [b"vars", b"state", b"f"], [b"vars", b"state", b"f", b"last_job"], [b"vars", b"state", b"f", b"last_job", b"f"]):
- c.walk_ok(0, 1, names)
- p = b"/".join(names).decode()
- ok(f"create in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"y"))) == "permission denied")
- ok(f"open {p} for write is 'is a directory'", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == "is a directory")
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok(f" and the fid was clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- for names in ([b"vars", b"state", b"value"], [b"vars", b"state", b"f", b"last_job", b"value"], [b"vars", b"state", b"type"]):
- c.walk_ok(0, 1, names)
- p = b"/".join(names).decode()
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=0))) == "permission denied")
- ok(f"{p} is not writable", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == "permission denied")
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- # directory listing of /vars/state and of f/, exact record boundaries
- c.walk_ok(0, 1, [b"vars", b"state"])
- c.open(1, OREAD)
- d = c.read_all(1)
- names = [n for n, _ in records(d)]
- ok("/vars/state lists value,type,size,addr,raw,f", sorted(names) == sorted([b"value", b"type", b"size", b"addr", b"raw", b"f"]), names)
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f"])
- c.open(1, OREAD)
- d = c.read_all(1)
- names = [n for n, _ in records(d)]
- ok("/vars/state/f lists the three fields", sorted(names) == [b"last_job", b"phase", b"ticks"], names)
- c.clunk(1)
- c.close()
- ok("server healthy after /vars attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- snapshot slots
-
-
-def attack_snapshots(path):
- print("# snapshot slots: exhaustion, hold, release by clunk and by Tversion; per-connection")
- dyn = [[b"runtime", b"pid"], [b"runtime", b"ppid"], [b"runtime", b"uptime"], [b"runtime", b"fn", b"now"], [b"runtime", b"fn", b"fib30"],
- [b"vars", b"state", b"value"], [b"vars", b"state", b"addr"], [b"vars", b"state", b"f", b"ticks", b"value"], [b"vars", b"state", b"f", b"phase", b"value"], [b"runtime", b"fn", b"uname"]]
- c = Nine(path)
- c.session()
- opened = 0
- err = None
- for i, names in enumerate(dyn):
- c.walk_ok(0, 100 + i, names)
- rt, _, rb = c.open(100 + i, OREAD)
- if rt == Ropen:
- opened += 1
- else:
- err = c.err.__self__ and rb
- n, = struct.unpack_from("<H", rb)
- err = rb[2:2 + n].decode()
- break
- ok(f"exactly {SNAPSHOT_SLOTS} dynamic files open per connection", opened == SNAPSHOT_SLOTS, opened)
- ok("the next open is 'too many open dynamic files'", err == "too many open dynamic files", err)
- ok("the refused fid is still unopened (read is 'file not open')", c.err(Tread, struct.pack("<IQI", 100 + opened, 0, 10)) == "file not open")
- # every held snapshot is still readable and consistent at offset 1
- for i in range(opened):
- rt, d = c.read(100 + i, 0, 8192)
- rt2, d2 = c.read(100 + i, 1, 8192)
- ok(f"held snapshot {i} reads and its offset-1 read is the tail", rt == Rread and rt2 == Rread and d2 == d[1:], (rt, rt2))
- # static and provider files need no slot
- ok("static file opens while slots are exhausted", c.path_read([b"build", b"zig_version"]) not in (None, b""))
- ok("/vars/state/type opens while slots are exhausted", c.path_read([b"vars", b"state", b"type"]) not in (None, b""))
- ok("/vars/state/raw opens while slots are exhausted", c.path_read([b"vars", b"state", b"raw"]) not in (None, b""))
- ok("scratch root lists while slots are exhausted", c.walk_ok(0, 50, [b"scratch"]) == 1 and c.open(50, OREAD)[0] == Ropen)
- c.clunk(50)
- # a second connection has its own slots
- c2 = Nine(path)
- c2.session()
- n2 = 0
- for i, names in enumerate(dyn[:SNAPSHOT_SLOTS]):
- c2.walk_ok(0, 100 + i, names)
- n2 += c2.open(100 + i, OREAD)[0] == Ropen
- ok("a second connection opens its own 8 dynamic files", n2 == SNAPSHOT_SLOTS, n2)
- c2.close()
- # clunk one -> the refused one now opens; clunk via Tremove (denied) also frees the slot
- c.clunk(100)
- rt, _, _ = c.open(100 + opened, OREAD)
- ok("after one clunk the refused open succeeds", rt == Ropen, rt)
- ok("remove of an open dynamic file is denied", c.err(Tremove, struct.pack("<I", 101)) == "permission denied")
- c.walk_ok(0, 60, dyn[0])
- rt, _, _ = c.open(60, OREAD)
- ok("the failed-remove fid's slot was released", rt == Ropen, rt)
- # a clone of an open dynamic fid takes no slot and is unopened
- rt, _, _ = c.walk(60, 61, [])
- ok("clone of an open dynamic fid is allowed", rt == Rwalk, rt)
- ok("the clone is not open", c.err(Tread, struct.pack("<IQI", 61, 0, 10)) == "file not open")
- ok("the clone cannot open (slots exhausted again)", c.err(Topen, struct.pack("<IB", 61, OREAD)) == "too many open dynamic files")
- # Tversion releases everything: 8 opens succeed again
- rt, ms, _ = c.version(65536)
- ok("mid-session Tversion", rt == base.Rversion)
- c.attach()
- n3 = 0
- for i, names in enumerate(dyn[:SNAPSHOT_SLOTS]):
- c.walk_ok(0, 100 + i, names)
- n3 += c.open(100 + i, OREAD)[0] == Ropen
- ok("after Tversion all 8 slots are free again", n3 == SNAPSHOT_SLOTS, n3)
- c.close()
- ok("server healthy after snapshot attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- the static tree
-
-
-def attack_static(path):
- print("# static tree: create/remove/wstat everywhere, '.'/'..' on files and provider roots")
- c = Nine(path)
- c.session()
- dirs = [[], [b"build"], [b"comptime"], [b"comptime", b"types"], [b"comptime", b"types", b"Qid"], [b"runtime"], [b"runtime", b"fn"]]
- for names in dirs:
- p = "/" + b"/".join(names).decode()
- ok(f"walk {p}", c.walk_ok(0, 1, names) == len(names))
- ok(f"create in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok(f"mkdir in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"d") + struct.pack("<IB", DMDIR | 0o755, OREAD)) == "permission denied")
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mtime=1))) == "permission denied")
- ok(f"wstat of {p} with all don't-care is denied too", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat())) == "permission denied")
- ok(f"open {p} ORDWR is 'is a directory'", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == "is a directory")
- ok(f"open {p} OEXEC works like OREAD", c.open(1, OEXEC)[0] == Ropen)
- ok(f"write to open {p} is 'is a directory'", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") is not None)
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- files = [[b"README"], [b"build", b"time"], [b"comptime", b"decls"], [b"comptime", b"types", b"Qid", b"fields"], [b"runtime", b"pid"], [b"runtime", b"fn", b"fib30"], [b"runtime", b"ctl"]]
- for names in files:
- p = "/" + b"/".join(names).decode()
- ok(f"walk {p}", c.walk_ok(0, 1, names) == len(names))
- ok(f"'.' from {p} is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "not a directory")
- ok(f"'..' from {p} is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory")
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=0))) == "permission denied")
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- if names[-1] != b"ctl":
- c.walk_ok(0, 1, names)
- ok(f"open {p} OWRITE is denied", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == "permission denied")
- ok(f"open {p} OREAD|OTRUNC is denied", c.err(Topen, struct.pack("<IB", 1, OREAD | OTRUNC)) == "permission denied")
- ok(f"open {p} ORCLOSE alone reads (no removal on clunk)", c.open(1, OREAD | ORCLOSE)[0] == Ropen and c.read(1, 0, 10)[0] == Rread)
- c.clunk(1)
- ok(f"{p} still exists after ORCLOSE clunk", c.walk_ok(0, 1, names) == len(names))
- c.clunk(1)
- # '.' and '..' on provider roots: '.' is the same qid, '..' is the server root
- for prov in (b"scratch", b"threads", b"addr", b"mem", b"hex", b"breakpoints", b"panic"):
- n = c.walk_ok(0, 1, [prov])
- rt, _, rb = c.walk(1, 2, [b".", b".."])
- q = qid_of_walk(rb) if rt == Rwalk else []
- rt2, st = c.stat(1)
- ok(f"/{prov.decode()}: '.' keeps the qid and '..' reaches the root", n == 1 and len(q) == 2 and q[0][2] == st["qid"][2] and q[1][2] == 0xFF << 56, (n, q))
- ok(f"/{prov.decode()}: root stat name is the mount name", rt2 == Rstat and st["name"] == prov, st)
- ok(f"/{prov.decode()}: rename of the provider root is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"other"))) == "permission denied")
- ok(f"/{prov.decode()}: remove of the provider root is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- c.clunk(2)
- # qid path spaces do not collide: static tag 0xFF, vars 0xFE, providers 0..n
- seen = {}
- for names in dirs + files + [[b"vars"], [b"vars", b"state"], [b"vars", b"state", b"value"], [b"scratch"], [b"threads"], [b"panic", b"message"], [b"mem", b"maps"]]:
- c.walk_ok(0, 1, names)
- rt, st = c.stat(1)
- c.clunk(1)
- key = st["qid"][2]
- ok(f"qid path of /{b'/'.join(names).decode()} is unique", key not in seen, (key, seen.get(key)))
- seen[key] = names
- # walks through the whole tree with 16 elements of '..' never leave the root
- ok("16 x '..' from root stays at root", c.walk_ok(0, 1, [b".."] * 16) == 16)
- rt, st = c.stat(1)
- ok(" and it is the root", rt == Rstat and st["qid"][2] == 0xFF << 56)
- c.clunk(1)
- c.close()
- ok("server healthy after static attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- debug providers as black boxes
-
-
-def attack_debug_providers(path):
- print("# debug providers: hostile names, offsets, writes; the server must answer or Rerror, never die")
- c = Nine(path, timeout=15)
- c.session()
- tid = worker_tid(c)
- ok("worker thread found under /threads", tid is not None, tid)
- hostile = [b"0", b"0x", b"0x0", b"-1", b"+1", b"00", b"ffffffffffffffff", b"0xffffffffffffffff", b"1" * 13, b"1" * 12, b"zzz", b"1e5", b" 1", b"1 ", b"0x0000000000000001", b"8", b"0x7fffffffffff", b"ffffffffffff", b"\x00", b"." * 3, b"a" * 255]
- for tree in (b"addr", b"hex", b"mem"):
- for nm in hostile:
- n = c.walk_ok(0, 1, [tree, nm])
- if n == 2:
- rt, _, rb = c.open(1, OREAD)
- if rt == Ropen:
- rt2, d = c.read(1, 0, 4096)
- rt3, d3 = c.read(1, (1 << 64) - 1, 4096)
- rt4, d4 = c.read(1, (1 << 63), 4096)
- ok(f"/{tree.decode()}/{nm[:16]!r}: reads at 0, 2^63 and 2^64-1 are answered", rt2 in (Rread, Rerror) and rt3 in (Rread, Rerror) and rt4 in (Rread, Rerror), (rt2, rt3, rt4))
- else:
- ok(f"/{tree.decode()}/{nm[:16]!r}: open answered", rt == Rerror, rt)
- c.clunk(1)
- else:
- ok(f"/{tree.decode()}/{nm[:16]!r}: walk refused or partial", n in (1, None), n)
- for nm in hostile + [b"1", b"4294967295", b"4294967296", b"99999999999", b"0" + (tid or b"1")]:
- n = c.walk_ok(0, 1, [b"threads", nm])
- if n == 2:
- for leaf in (b"name", b"stat", b"stack", b"regs"):
- rt, _, _ = c.walk(1, 2, [leaf])
- if rt == Rwalk:
- rt, _, _ = c.open(2, OREAD)
- if rt == Ropen:
- c.read(2, 0, 8192)
- c.clunk(2)
- c.clunk(1)
- ok(f"/threads/{nm!r} walked (a live tid) and its files answered", True)
- else:
- ok(f"/threads/{nm!r}: walk refused or partial", n in (1, None), n)
- n = c.walk_ok(0, 1, [b"breakpoints", nm])
- ok(f"/breakpoints/{nm!r}: walk refused (nothing paused)", n in (1, None), n)
- # files under a thread: '.'/'..' and walking through them
- if tid:
- ok("/threads/<tid>/stack/x is 'not a directory'", c.walk_ok(0, 1, [b"threads", tid, b"stack"]) == 3 and c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"x")) == "not a directory")
- ok("'..' from /threads/<tid>/stack is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory")
- c.clunk(1)
- ok("/threads/<tid>/../../<tid>/name walks", c.walk_ok(0, 1, [b"threads", tid, b"..", b"..", b"threads", tid, b"name"]) == 7)
- c.clunk(1)
- c.walk_ok(0, 1, [b"threads", tid])
- ok("create under /threads/<tid> is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok("wstat of /threads/<tid> is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"y"))) == "permission denied")
- ok("remove of /threads/<tid> is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- stack = c.path_read([b"threads", tid, b"stack"])
- ok("worker stack reads", stack is not None and b"workerLoop" in stack, stack)
- # thread stack at offset 1 is the tail (dynamic snapshot in the provider)
- c.walk_ok(0, 1, [b"threads", tid, b"stack"])
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 65000)
- rt2, d2 = c.read(1, 1, 65000)
- ok("thread stack offset-1 read is the tail of the same snapshot", rt == Rread and rt2 == Rread and d2 == d[1:], (len(d), len(d2)))
- c.clunk(1)
- # /mem: read of unmapped memory is an error, write of unmapped memory is an error; writes at wild offsets too
- c.walk_ok(0, 1, [b"mem", b"8"])
- rt, _, _ = c.open(1, ORDWR)
- ok("/mem/8 opens", rt == Ropen, rt)
- rt, d = c.read(1, 0, 16)
- ok("read of unmapped memory is an Rerror", rt == Rerror, rt)
- rt, _, _ = c.write(1, 0, b"x")
- ok("write to unmapped memory is an Rerror", rt == Rerror, rt)
- rt, _, _ = c.write(1, (1 << 64) - 9, b"x")
- ok("write at a wrapping offset is answered", rt in (Rerror, Rwrite), rt)
- rt, _, _ = c.write(1, 0, b"")
- ok("empty write to /mem is answered", rt in (Rerror, Rwrite), rt)
- c.clunk(1)
- addr = c.path_read([b"vars", b"state", b"addr"])
- ok("/vars/state/addr reads", addr is not None and addr.startswith(b"0x"), addr)
- if addr:
- hx = addr[2:]
- size = int(c.path_read([b"vars", b"state", b"size"]))
- c.walk_ok(0, 1, [b"mem", hx])
- c.open(1, OREAD)
- rt, d = c.read(1, 0, size)
- ok("/mem/<state addr> reads @sizeOf bytes", rt == Rread and len(d) == size, (rt, len(d) if d else d))
- rt, d = c.read(1, 0, 0xFFFFFFFF)
- ok("/mem read with count 2^32-1 is clamped and answered", rt in (Rread, Rerror), rt)
- c.clunk(1)
- hexd = c.path_read([b"hex", hx])
- ok("/hex/<state addr> is a hexdump", hexd is not None and len(hexd) > 64, hexd[:40] if hexd else hexd)
- # a value written through /mem must render, not trap: corrupt the phase enum and read /vars/state/value
- phase_addr = int(c.path_read([b"vars", b"state", b"f", b"phase", b"addr"]), 16)
- c.walk_ok(0, 1, [b"mem", b"%x" % phase_addr])
- c.open(1, OWRITE)
- rt, _, _ = c.write(1, 0, b"\xee")
- ok("write a corrupt enum byte through /mem", rt == Rwrite, rt)
- c.clunk(1)
- v = c.path_read([b"vars", b"state", b"value"])
- ok("/vars/state/value renders the corrupt enum as a number instead of trapping", v is not None and b"phase: 238" in v, v)
- pv = c.path_read([b"vars", b"state", b"f", b"phase", b"value"])
- ok("/vars/state/f/phase/value renders 238", pv == b"238", pv)
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"phase", b"value"])
- c.open(1, OWRITE)
- rt, _, _ = c.write(1, 0, b"idle")
- ok("the enum can be repaired through /vars", rt == Rwrite, rt)
- c.clunk(1)
- # /panic: ctl refuses reads and garbage; message/stack read
- ok("/panic/message reads (empty, no panic)", c.path_read([b"panic", b"message"]) == b"")
- ok("/panic/stack reads", c.path_read([b"panic", b"stack"]) is not None)
- c.walk_ok(0, 1, [b"panic", b"ctl"])
- ok("/panic/ctl refuses OREAD", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "permission denied")
- c.clunk(1)
- c.walk_ok(0, 1, [b"panic", b"ctl"])
- rt, _, _ = c.open(1, OWRITE)
- ok("/panic/ctl opens OWRITE", rt == Ropen, rt)
- for bad in (b"garbage", b"", b"continue please", b"\x00continue"):
- rt, _, _ = c.write(1, 0, bad)
- ok(f"/panic/ctl write {bad!r} without a panic is an Rerror", rt == Rerror, rt)
- c.clunk(1)
- # /breakpoints is empty; the debug providers refuse create/wstat/remove
- for prov in (b"threads", b"addr", b"mem", b"hex", b"breakpoints", b"panic"):
- c.walk_ok(0, 1, [prov])
- ok(f"create in /{prov.decode()} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok(f"wstat of /{prov.decode()} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mtime=5))) == "permission denied")
- c.open(1, OREAD)
- d = c.read_all(1)
- ok(f"/{prov.decode()} lists", d is not None)
- c.clunk(1)
- # the provider's snapshot pool (shared by every connection) recovers after exhaustion
- held = []
- err = None
- for i in range(16):
- names = [b"addr", b"%x" % (0x1000 + i)]
- c.walk_ok(0, 200 + i, names)
- rt, _, rb = c.open(200 + i, OREAD)
- if rt == Ropen:
- held.append(200 + i)
- else:
- n, = struct.unpack_from("<H", rb)
- err = rb[2:2 + n].decode()
- break
- ok("debug provider snapshot pool exhausts with an Rerror", err is not None and len(held) >= 1, (len(held), err))
- for f in held:
- c.clunk(f)
- ok("after clunking, /addr opens again", c.path_read([b"addr", b"1000"]) is not None)
- # Tversion with debug files open (hexdumps of the exposed state: mapped memory)
- base_addr = int(addr, 16) if addr else 0
- opened = 0
- for i in range(4):
- c.walk_ok(0, 300 + i, [b"hex", b"%x" % (base_addr + i)])
- opened += c.open(300 + i, OREAD)[0] == Ropen
- ok("four /hex snapshots open", opened == 4, opened)
- rt, _, _ = c.version(65536)
- ok("Tversion with debug snapshots open", rt == base.Rversion)
- c.attach()
- ok("/hex still opens after the reset", c.path_read([b"hex", b"%x" % base_addr]) is not None)
- ok("/hex of unmapped memory is an Rerror at open, not a crash", c.walk_ok(0, 1, [b"hex", b"3000"]) == 2 and c.open(1, OREAD)[0] == Rerror)
- c.clunk(1)
- c.close()
- ok("server healthy after debug provider attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- fids at the maximum
-
-
-def flood(c, ids, names):
- """Pipelines one Twalk per id and counts the Rwalk replies; returns (ok_count, error_count, seconds)."""
- got = [0, 0]
- dead = [False]
-
- def reader():
- try:
- for _ in ids:
- rt, _, _ = c.recv_frame()
- if rt == Rwalk:
- got[0] += 1
- else:
- got[1] += 1
- except (EOFError, OSError):
- dead[0] = True
-
- t = threading.Thread(target=reader)
- t.start()
- t0 = time.time()
- body = b"".join(frame(Twalk, i & 0xFFFE, struct.pack("<IIH", 0, fid, len(names)) + b"".join(s16(n) for n in names)) for i, fid in enumerate(ids))
- c.raw(body)
- t.join(120)
- return got[0], got[1], time.time() - t0, dead[0] or t.is_alive()
-
-
-def attack_fid_table(path):
- print(f"# fid table: {MAX_FIDS} fids, adversarial ids, clunk all, reuse")
- c = Nine(path, timeout=60)
- c.session()
- n = MAX_FIDS - 1 # fid 0 is the attach
- ids = []
- for i in range(n):
- k = i % 3
- ids.append(i * 8192 + 1 if k == 0 else (0x80000000 | i) if k == 1 else 0xFFFFFFFE - i)
- assert len(set(ids)) == n and 0 not in ids
- good, bad, dt, dead = flood(c, ids, [b"scratch"])
- ok(f"{n} walks with adversarial fid numbers all succeed", good == n and bad == 0 and not dead, (good, bad, dead))
- print(f" {n} clones (provider handles) in {dt:.2f}s")
- ok("the next fid is 'too many fids'", c.err(Twalk, struct.pack("<IIH", 0, 7, 0)) == "too many fids")
- ok("attach at the limit is 'too many fids'", c.err(base.Tattach, struct.pack("<II", 7, base.NOFID) + s16(b"u") + s16(b"")) == "too many fids")
- ok("an existing id is 'fid in use'", c.err(Twalk, struct.pack("<IIH", 0, ids[12345], 0)) == "fid in use")
- ok("a self-walk at the limit works", c.walk_ok(ids[5], ids[5], [b".."]) == 1)
- ok("an unknown fid at the limit is 'unknown fid'", c.err(Tstat, struct.pack("<I", 7)) == "unknown fid")
- # clunk all, pipelined, in a hostile order (every third first, then the rest reversed)
- order = ids[::3] + ids[1::3][::-1] + ids[2::3][::-1]
- got = [0]
- dead = [False]
-
- def reader():
- try:
- for _ in order:
- rt, _, _ = c.recv_frame()
- got[0] += rt == Rclunk
- except (EOFError, OSError):
- dead[0] = True
-
- t = threading.Thread(target=reader)
- t.start()
- t0 = time.time()
- c.raw(b"".join(frame(Tclunk, i & 0xFFFE, struct.pack("<I", fid)) for i, fid in enumerate(order)))
- t.join(120)
- ok(f"{n} clunks all answered", got[0] == n and not dead[0] and not t.is_alive(), (got[0], dead[0]))
- print(f" {n} clunks in {time.time() - t0:.2f}s")
- ok("clunk of a clunked fid is 'unknown fid'", c.err(Tclunk, struct.pack("<I", ids[100])) == "unknown fid")
- # reuse: the whole table is available again with dense ids
- good, bad, dt, dead = flood(c, list(range(1, n + 1)), [])
- ok(f"{n} clones with dense ids after the churn all succeed", good == n and bad == 0 and not dead, (good, bad, dead))
- print(f" {n} clones (reuse) in {dt:.2f}s")
- ok("still 'too many fids' at the limit", c.err(Twalk, struct.pack("<IIH", 0, n + 1, 0)) == "too many fids")
- rt, _, _ = c.version(65536)
- ok("Tversion after the fid churn", rt == base.Rversion)
- c.attach()
- good, bad, dt, dead = flood(c, list(range(1, 1001)), [b"scratch"])
- ok("1000 clones after Tversion", good == 1000 and bad == 0, (good, bad))
- c.close()
- ok("server healthy after the fid table attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- flush storm
-
-
-def attack_flush(path):
- print("# Tflush storm")
- c = Nine(path, timeout=30)
- c.session()
- n = 2000
- blob = b"".join(frame(Tflush, i & 0xFFFE, struct.pack("<H", (i * 7919) & 0xFFFF)) for i in range(n))
- got = [0]
-
- def reader():
- try:
- for _ in range(n):
- rt, _, _ = c.recv_frame()
- got[0] += rt == Rflush
- except (EOFError, OSError):
- pass
-
- t = threading.Thread(target=reader)
- t.start()
- c.raw(blob)
- t.join(60)
- ok(f"{n} pipelined Tflush (random oldtags, including own tag) all Rflush", got[0] == n, got[0])
- rt, tag, _ = c.call(Tflush, struct.pack("<H", 5), 5)
- ok("Tflush of its own tag is Rflush", rt == Rflush and tag == 5, (rt, tag))
- rt, st = c.stat(0)
- ok("a normal request after the storm works", rt == Rstat, rt)
- c.close()
- ok("server healthy after the flush storm", healthy(path))
-
-
-# --------------------------------------------------------------------------- msize 24
-
-
-def attack_msize24(path):
- print("# msize 24: everything that fits is served, everything else is an Rerror that fits")
- c = Nine(path)
- rt, ms, ver = c.version(24)
- ok("Tversion 24", rt == base.Rversion and ms == 24, (rt, ms))
- rt, _, _ = c.attach(uname=b"u") # 20 bytes; Rattach is 20
- ok("Tattach at msize 24", rt == base.Rattach, rt)
- e = c.err(Tstat, struct.pack("<I", 0))
- ok("Tstat: Rerror truncated to 15 bytes ('reply too large')", e == "reply too large", e)
- rt, _, rb = c.walk(0, 1, [b"build"]) # Twalk 24, Rwalk 22
- ok("Twalk of one 5-byte name", rt == Rwalk, rt)
- e = c.err(Twalk, struct.pack("<IIH", 0, 2, 2) + s16(b".") + s16(b".")) # 23 bytes; Rwalk would be 35
- ok("Twalk of two names cannot be answered: 'reply too large'", e == "reply too large", e)
- ok("newfid unbound after the refused walk", c.err(Tclunk, struct.pack("<I", 2)) == "unknown fid")
- rt, _, _ = c.open(1, OREAD) # Ropen 24
- ok("Topen at msize 24", rt == Ropen, rt)
- rt, d = c.read(1, 0, 4096) # count clamped to msize - iohdrsz = 0
- ok("Tread of a directory at msize 24 answers an empty Rread (no split record)", rt == Rread and d == b"", (rt, d))
- e = c.err(Tread, struct.pack("<IQI", 1, 1, 4096))
- ok("dir read at offset 1 is 'bad offset'", e == "bad offset", e)
- rt, _, _ = c.clunk(1)
- ok("Tclunk at msize 24", rt == Rclunk, rt)
- rt, _, _ = c.walk(0, 1, [b"vars"]) # Twalk 23
- rt, _, _ = c.walk(1, 1, [b"state"]) # 23
- rt, _, _ = c.walk(1, 1, [b"value"]) # 23
- ok("walk to /vars/state/value in 3 self-walks", rt == Rwalk, rt)
- rt, _, _ = c.open(1, OREAD)
- ok("open a dynamic file at msize 24", rt == Ropen, rt)
- rt, d = c.read(1, 0, 4096)
- ok("read of a dynamic file at msize 24 is an empty Rread", rt == Rread and d == b"", (rt, d))
- rt, _, _ = c.clunk(1)
- for nm in (b"vars", b"state", b"f", b"ticks", b"value"): # each Twalk <= 24 bytes
- rt, _, _ = c.walk(0 if nm == b"vars" else 1, 1, [nm])
- ok("walk to /vars/state/f/ticks/value in 5 self-walks", rt == Rwalk, rt)
- rt, _, _ = c.open(1, OWRITE)
- ok("open a writable value at msize 24", rt == Ropen, rt)
- rt, _, _ = c.write(1, 0, b"5") # Twrite 24, Rwrite 11
- ok("Twrite of one byte at msize 24", rt == Rwrite, rt)
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, 0) + b"")
- ok("empty write to a value at msize 24 is 'bad value'", e == "bad value", e)
- rt, _, _ = c.clunk(1)
- ok("clunk at msize 24", rt == Rclunk, rt)
- rt, ms, _ = c.version(65536)
- ok("renegotiate a big msize on the same connection", rt == base.Rversion and ms == 65536, (rt, ms))
- c.attach()
- ok("normal service resumes", c.path_read([b"build", b"zig_version"]) not in (None, b""))
- c.close()
- # frames larger than 24 after negotiating 24 kill the connection
- c = Nine(path)
- c.version(24)
- c.raw(frame(base.Tattach, 1, struct.pack("<II", 0, base.NOFID) + s16(b"longer-name") + s16(b"")))
- ok("a 30-byte Tattach at msize 24: connection closed", expect_dead(c))
- c.close()
- ok("server healthy after msize-24 attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- directory offsets
-
-
-def attack_dir_offsets(path):
- print("# directory reads: exact record boundaries vs off by one")
- c = Nine(path)
- c.session()
- tag = os.urandom(3).hex().encode()
- root = b"do-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, root, DMDIR | 0o755, OREAD)
- c.clunk(1)
- for nm in (b"alpha", b"beta-with-a-longer-name", b"g"):
- c.walk_ok(0, 1, [b"scratch", root])
- c.create(1, nm, 0o644, OWRITE)
- c.clunk(1)
- for names in ([], [b"vars", b"state"], [b"comptime", b"types"], [b"scratch", root], [b"threads"], [b"panic"]):
- p = "/" + b"/".join(names).decode()
- c.walk_ok(0, 1, names)
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 65000)
- recs = records(d)
- if len(recs) < 2:
- ok(f"{p}: at least two entries", False, len(recs))
- c.clunk(1)
- continue
- r0 = len(recs[0][1])
- r1 = len(recs[1][1])
- rt, d0 = c.read(1, 0, r0)
- ok(f"{p}: count = first record length returns exactly that record", rt == Rread and d0 == recs[0][1], (rt, len(d0) if d0 else d0, r0))
- rt, d1 = c.read(1, r0, r1)
- ok(f"{p}: read at the record boundary returns the next record", rt == Rread and d1 == recs[1][1], (rt, len(d1) if d1 else d1))
- e = c.err(Tread, struct.pack("<IQI", 1, r0 + r1 + 1, 65000))
- ok(f"{p}: offset boundary+1 is 'bad offset'", e == "bad offset", e)
- e = c.err(Tread, struct.pack("<IQI", 1, r0 + r1 - 1, 65000))
- ok(f"{p}: offset boundary-1 is 'bad offset'", e == "bad offset", e)
- e = c.err(Tread, struct.pack("<IQI", 1, r0, 65000))
- ok(f"{p}: re-reading an earlier boundary is 'bad offset'", e == "bad offset", e)
- rt, rest = c.read(1, r0 + r1, 65000)
- ok(f"{p}: after a bad offset the good boundary still continues", rt == Rread and rest == d[r0 + r1:], rt)
- rt, dd = c.read(1, 0, r0 - 1)
- ok(f"{p}: count one short of a record returns nothing (no split)", rt == Rread and dd == b"", (rt, dd))
- rt, dd = c.read(1, 0, 65000)
- ok(f"{p}: offset 0 restarts and yields the same bytes", rt == Rread and dd == d)
- rt, dd = c.read(1, len(d), 65000)
- ok(f"{p}: read at the end is empty", rt == Rread and dd == b"")
- rt, dd = c.read(1, len(d), 65000)
- ok(f"{p}: read at the end twice is empty twice", rt == Rread and dd == b"")
- c.clunk(1)
- for nm in (b"alpha", b"beta-with-a-longer-name", b"g"):
- c.walk_ok(0, 1, [b"scratch", root, nm])
- c.remove(1)
- c.walk_ok(0, 1, [b"scratch", root])
- ok("cleanup of the directory-offset test root", c.remove(1)[0] == Rremove)
- c.close()
- ok("server healthy after directory offset attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- ctl staging
-
-
-def attack_ctl(path):
- print("# ctl: a failed command leaves the previous result, length and qid version untouched")
- c = Nine(path)
- c.session(1 << 20)
- c.walk_ok(0, 1, [b"runtime", b"ctl"])
- c.open(1, ORDWR)
- rt, _, _ = c.write(1, 0, b"echo persist")
- rt, st = c.stat(1)
- v = st["qid"][1]
- ok("echo persist", st["length"] == 7, st)
- for bad in (b"nope", b"fib 94", b"add 1", b"", b"\x00", b"echo\x00hidden"):
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad)
- rt, d = c.read(1, 0, 100)
- rt2, st2 = c.stat(1)
- ok(f"after failed {bad!r}: result still 'persist'", d == b"persist", d)
- ok(f"after failed {bad!r}: length 7 and qid version unchanged", st2["length"] == 7 and st2["qid"][1] == v, (st2["length"], st2["qid"][1], v))
- # a second connection sees the same result and version
- c2 = Nine(path)
- c2.session()
- ok("other connection reads the surviving result", c2.path_read([b"runtime", b"ctl"]) == b"persist")
- c2.walk_ok(0, 1, [b"runtime", b"ctl"])
- rt, st3 = c2.stat(1)
- ok("other connection sees the same version", st3["qid"][1] == v, (st3["qid"][1], v))
- c2.close()
- # a successful command bumps the version and replaces the result; an empty result is a result
- rt, _, _ = c.write(1, 0, b"echo")
- rt, st4 = c.stat(1)
- rt, d = c.read(1, 0, 100)
- ok("echo with no argument yields an empty result with a new version", d == b"" and st4["length"] == 0 and st4["qid"][1] != v, (d, st4))
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, 4) + b"nope")
- rt, d = c.read(1, 0, 100)
- ok("a failure after an empty result keeps it empty", d == b"", d)
- # the largest result: echo of a 60000-byte line
- big = b"echo " + b"y" * 60000
- rt, _, _ = c.write(1, 0, big)
- d = c.read_all(1)
- ok("60000-byte ctl result round-trips", rt == Rwrite and d == b"y" * 60000, (rt, len(d) if d else d))
- rt, _, _ = c.write(1, 0, b"echo " + b"z" * 70000) # exceeds ctl_bytes (64 KiB) -> the handler's writer fails
- rt2, d = c.read(1, 0, 100)
- rt3, st5 = c.stat(1)
- ok("an over-long result is an Rerror and the previous result survives", rt == Rerror and d == b"y" * 100 and st5["length"] == 60000, (rt, d[:10] if d else d, st5["length"]))
- c.clunk(1)
- c.close()
- ok("server healthy after ctl attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- fid state machine on scratch
-
-
-def attack_fid_states(path):
- print("# fid state machine on /scratch")
- c = Nine(path)
- c.session()
- tag = os.urandom(3).hex().encode()
- root = b"fs-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, root, DMDIR | 0o755, OREAD)
- c.clunk(1)
- S = [b"scratch", root]
- c.walk_ok(0, 1, S)
- rt, _, _ = c.create(1, b"f", 0o644, ORDWR)
- ok("create f", rt == Rcreate)
- ok("open of an open fid is 'file already open'", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "file already open")
- ok("walk with names from an open fid is 'file already open'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "file already open")
- ok("create on an open fid is 'file already open'", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == "file already open")
- rt, _, _ = c.walk(1, 2, [])
- ok("clone of an open fid is allowed", rt == Rwalk)
- ok("the clone is not open", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == "file not open")
- rt, _, _ = c.open(2, OREAD)
- ok("the clone opens independently", rt == Ropen)
- c.write(1, 0, b"data")
- rt, d = c.read(2, 0, 10)
- ok("the clone sees the write", rt == Rread and d == b"data", d)
- rt, _, _ = c.wstat(2, mkstat(name=b"renamed"))
- ok("wstat through an open fid works", rt == Rwstat)
- rt, _, _ = c.remove(1)
- ok("remove through the open writer fid", rt == Rremove)
- rt, d = c.read(2, 0, 10)
- ok("the other open fid still reads the removed file", rt == Rread and d == b"data", d)
- ok("stat of the removed file still answers", c.stat(2)[0] == Rstat)
- ok("open of a removed file through a new walk is impossible (not found)", c.walk_ok(0, 3, S + [b"renamed"]) == 2)
- c.clunk(2)
- # newfid == fid walks on unopened fids rebind; on the same fid with a failing later element they do nothing
- c.walk_ok(0, 3, S)
- rt, _, rb = c.walk(3, 3, [b"..", root, b"nope"])
- n = struct.unpack_from("<H", rb)[0] if rt == Rwalk else None
- ok("partial self-walk returns 2 and leaves the fid where it was", n == 2 and c.stat(3)[1]["name"] == root, (n,))
- rt, _, _ = c.walk(3, 3, [b"..", b".."])
- rt, st = c.stat(3)
- ok("self-walk with names rebinds the fid", rt == Rstat and st["qid"][2] == 0xFF << 56, st)
- c.clunk(3)
- # Tversion while a removed-but-held file exists: nothing leaks, the server keeps serving
- c.walk_ok(0, 4, S)
- c.create(4, b"held", 0o644, OWRITE)
- c.write(4, 0, b"x" * 1000)
- c.walk_ok(0, 5, S + [b"held"])
- c.remove(5)
- c.version(65536)
- c.attach()
- ok("after Tversion the removed file is gone", c.walk_ok(0, 1, S + [b"held"]) == 2)
- c.clunk(1)
- c.walk_ok(0, 1, S)
- rt, _, _ = c.remove(1)
- ok("cleanup", rt == Rremove, rt)
- c.close()
- ok("server healthy after fid state attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- scratch budget
-
-
-def attack_scratch_budget(path):
- print("# scratch: the global budget after grow/truncate/rename/remove/failed writes")
- c = Nine(path, timeout=120)
- c.session()
- tag = os.urandom(3).hex().encode()
- root = b"bg-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, root, DMDIR | 0o755, OREAD)
- c.clunk(1)
- S = [b"scratch", root]
- per = SCRATCH_MAX_FILE
- count = SCRATCH_BUDGET // per
- t0 = time.time()
- made = 0
- for i in range(count):
- c.walk_ok(0, 1, S)
- rt, _, _ = c.create(1, b"big%d" % i, 0o644, OWRITE)
- rt, _, _ = c.wstat(1, mkstat(length=per))
- c.clunk(1)
- if rt != Rwstat:
- break
- made += 1
- ok(f"{count} files of {per >> 20} MiB fill the {SCRATCH_BUDGET >> 20} MiB budget exactly", made == count, made)
- print(f" filled the budget in {time.time() - t0:.1f}s")
- c.walk_ok(0, 1, S)
- c.create(1, b"one-more", 0o644, OWRITE)
- ok("one more byte is 'no space left on device'", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") == "no space left on device")
- ok("a failed write leaves the file empty", c.stat(1)[1]["length"] == 0)
- ok("a zero-length write at a huge offset is still fine", c.write(1, (1 << 60), b"")[0] == Rwrite)
- ok("wstat length 1 is 'no space left on device'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=1))) == "no space left on device")
- # rename does not charge; truncate releases exactly its size
- c.walk_ok(0, 2, S + [b"big0"])
- ok("rename of a full file is fine", c.wstat(2, mkstat(name=b"big0-r"))[0] == Rwstat)
- ok("still no space after the rename", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") == "no space left on device")
- ok("truncate big0-r to 1 MiB", c.wstat(2, mkstat(length=1 << 20))[0] == Rwstat)
- c.clunk(2)
- rt, _, _ = c.wstat(1, mkstat(length=per - (1 << 20)))
- ok("exactly the released amount is writable again", rt == Rwstat, rt)
- ok("and not one byte more", c.err(Twrite, struct.pack("<IQI", 1, per - (1 << 20), 1) + b"x") == "no space left on device")
- ok("nor via wstat", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=per - (1 << 20) + 1))) == "no space left on device")
- # overwriting existing bytes costs nothing
- ok("overwrite inside the file is fine", c.write(1, 0, b"y" * 4096)[0] == Rwrite)
- ok("overwrite at the very end is fine", c.write(1, per - (1 << 20) - 4096, b"y" * 4096)[0] == Rwrite)
- # OTRUNC of a full file releases; remove of a held file releases only on the last clunk
- c.walk_ok(0, 3, S + [b"big1"])
- ok("OTRUNC releases", c.open(3, OWRITE | OTRUNC)[0] == Ropen and c.stat(3)[1]["length"] == 0)
- ok("the released space is writable", c.wstat(1, mkstat(length=per))[0] == Rwstat)
- ok("a full file writes into itself", c.write(1, per - 10, b"0123456789")[0] == Rwrite)
- ok("but not past the per-file cap", c.err(Twrite, struct.pack("<IQI", 1, per - 1, 2) + b"xy") == "no space left on device")
- c.clunk(3)
- c.clunk(1)
- # refill the remaining 63 MiB so the budget is exactly full again
- c.walk_ok(0, 7, S)
- c.create(7, b"fill", 0o644, OWRITE)
- ok("the rest of the budget fills exactly", c.wstat(7, mkstat(length=per - (1 << 20)))[0] == Rwstat)
- ok("and is full again", c.err(Twrite, struct.pack("<IQI", 7, per - (1 << 20), 1) + b"x") == "no space left on device")
- c.clunk(7)
- c.walk_ok(0, 4, S + [b"big2"])
- c.walk_ok(0, 5, S + [b"big2"])
- c.open(5, OREAD)
- c.remove(4)
- c.walk_ok(0, 6, S)
- c.create(6, b"after-remove", 0o644, OWRITE)
- ok("space of a removed-but-held file is not released yet", c.err(Twrite, struct.pack("<IQI", 6, 0, 1) + b"x") == "no space left on device")
- c.clunk(5)
- ok("the last clunk releases it", c.write(6, 0, b"x")[0] == Rwrite)
- c.clunk(6)
- # cleanup
- c.walk_ok(0, 1, S)
- c.open(1, OREAD)
- names = [n for n, _ in records(c.read_all(1))]
- c.clunk(1)
- for nm in names:
- if c.walk_ok(0, 1, S + [nm]) == 3:
- c.remove(1)
- c.walk_ok(0, 1, S)
- ok("cleanup removed the budget test root", c.remove(1)[0] == Rremove, names)
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, b"post-" + tag, 0o644, OWRITE)
- ok("the whole budget is back: a 64 MiB file fits", c.wstat(1, mkstat(length=per))[0] == Rwstat)
- c.remove(1)
- c.close()
- ok("server healthy after budget attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- main
-
-
-def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--server")
- ap.add_argument("--socket")
- ap.add_argument("--fast", action="store_true", help="skip the 512 MiB scratch budget fill")
- args = ap.parse_args()
- proc = None
- tmp = None
- if args.server:
- tmp = tempfile.mkdtemp(prefix="adv9pcore.")
- path = os.path.join(tmp, "sock")
- proc = subprocess.Popen([os.path.abspath(args.server), "--unix", path], stderr=subprocess.PIPE)
- for _ in range(200):
- if os.path.exists(path):
- break
- time.sleep(0.02)
- elif args.socket:
- path = args.socket
- else:
- ap.error("--server or --socket")
- try:
- attack_vars(path)
- attack_snapshots(path)
- attack_static(path)
- attack_debug_providers(path)
- attack_dir_offsets(path)
- attack_ctl(path)
- attack_fid_states(path)
- attack_msize24(path)
- attack_flush(path)
- attack_fid_table(path)
- if not args.fast:
- attack_scratch_budget(path)
- if proc is not None:
- ok("server process still running", proc.poll() is None, proc.poll())
- finally:
- if proc is not None:
- proc.send_signal(signal.SIGTERM)
- try:
- _, err = proc.communicate(timeout=5)
- except subprocess.TimeoutExpired:
- proc.kill()
- _, err = proc.communicate()
- lines = [ln for ln in err.decode("utf-8", "replace").splitlines() if "connection ended" not in ln and "read: " not in ln]
- if lines:
- print("# server stderr (filtered):")
- for ln in lines[:40]:
- print(" " + ln)
- if tmp:
- try:
- os.unlink(path)
- os.rmdir(tmp)
- except OSError:
- pass
- print(f"# {base.PASSES} passed, {len(base.FAILS)} failed")
- for f in base.FAILS:
- print("# FAIL " + f)
- sys.exit(1 if base.FAILS else 0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/introspect/test/adv_core_hostile.sh b/introspect/test/adv_core_hostile.sh
deleted file mode 100755
index bf4f812..0000000
--- a/introspect/test/adv_core_hostile.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-# Adversarial raw-9P2000 client tests aimed at the introspect core.
-# Usage: bash introspect/test/adv_core_hostile.sh <introspect> [--fast] (part of zig build introspect-adv)
-# Spawns the server on a temporary unix socket and attacks it with
-# introspect/test/adv_core_hostile.py (Python 3 stdlib). Exit 1 on any failure.
-set -u
-INTROSPECT=$(realpath "${1:?path to introspect}")
-shift
-command -v python3 >/dev/null || { echo "SKIP: python3 missing"; exit 0; }
-exec python3 "$(dirname "$0")/adv_core_hostile.py" --server "$INTROSPECT" "$@"
diff --git a/introspect/test/adv_introspect_hostile.py b/introspect/test/adv_introspect_hostile.py
deleted file mode 100755
index 7dbb5c0..0000000
--- a/introspect/test/adv_introspect_hostile.py
+++ /dev/null
@@ -1,999 +0,0 @@
-#!/usr/bin/env python3
-"""Hostile raw-9P2000 client for the introspect server (stdlib only).
-
-Usage:
- adv_introspect_hostile.py --server zig-out/bin/introspect # spawns it on a temp unix socket
- adv_introspect_hostile.py --socket PATH # attacks a running server
-
-Every attack is followed by a "server still healthy" probe on a fresh connection.
-Exit status is non-zero if any check fails, the server dies, or a probe hangs.
-"""
-import argparse
-import os
-import signal
-import socket
-import struct
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-
-NOTAG = 0xFFFF
-NOFID = 0xFFFFFFFF
-Tversion, Rversion, Tauth, Rauth, Tattach, Rattach, Rerror = 100, 101, 102, 103, 104, 105, 107
-Tflush, Rflush, Twalk, Rwalk, Topen, Ropen, Tcreate, Rcreate = 108, 109, 110, 111, 112, 113, 114, 115
-Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove = 116, 117, 118, 119, 120, 121, 122, 123
-Tstat, Rstat, Twstat, Rwstat = 124, 125, 126, 127
-OREAD, OWRITE, ORDWR, OEXEC, OTRUNC, ORCLOSE = 0, 1, 2, 3, 0x10, 0x40
-DMDIR, DMAPPEND, DMEXCL = 0x80000000, 0x40000000, 0x20000000
-NAMES = {v: k for k, v in globals().items() if k[:1] in "TR" and isinstance(v, int) and 100 <= v <= 127}
-
-FAILS = []
-PASSES = 0
-
-
-def ok(name, cond, detail=""):
- global PASSES
- if cond:
- PASSES += 1
- print(f"ok - {name}")
- else:
- FAILS.append(name)
- print(f"FAIL - {name} {detail}")
-
-
-def s16(b):
- return struct.pack("<H", len(b)) + b
-
-
-def frame(typ, tag, body):
- return struct.pack("<IBH", 7 + len(body), typ, tag) + body
-
-
-def mkstat(name=b"", uid=b"", gid=b"", muid=b"", typ=0xFFFF, dev=0xFFFFFFFF, qtype=0xFF, qvers=0xFFFFFFFF,
- qpath=0xFFFFFFFFFFFFFFFF, mode=0xFFFFFFFF, atime=0xFFFFFFFF, mtime=0xFFFFFFFF,
- length=0xFFFFFFFFFFFFFFFF):
- body = struct.pack("<HIBIQIIIQ", typ, dev, qtype, qvers, qpath, mode, atime, mtime, length)
- body += s16(name) + s16(uid) + s16(gid) + s16(muid)
- return struct.pack("<H", len(body)) + body
-
-
-def parse_stat(b):
- n, = struct.unpack_from("<H", b, 0)
- typ, dev, qtype, qvers, qpath, mode, atime, mtime, length = struct.unpack_from("<HIBIQIIIQ", b, 2)
- off = 2 + 2 + 4 + 13 + 4 + 4 + 4 + 8
- strs = []
- for _ in range(4):
- ln, = struct.unpack_from("<H", b, off)
- strs.append(b[off + 2:off + 2 + ln])
- off += 2 + ln
- assert off == n + 2, (off, n)
- return dict(type=typ, dev=dev, qid=(qtype, qvers, qpath), mode=mode, atime=atime, mtime=mtime,
- length=length, name=strs[0], uid=strs[1], gid=strs[2], muid=strs[3])
-
-
-class Nine:
- """One raw 9P connection; every call returns (type, tag, body) or raises."""
-
- def __init__(self, path, timeout=5.0):
- self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- self.s.settimeout(timeout)
- self.s.connect(path)
- self.tag = 1
- self.buf = b""
-
- def close(self):
- try:
- self.s.close()
- except OSError:
- pass
-
- def raw(self, data):
- try:
- self.s.sendall(data)
- except OSError:
- pass # the server may already have closed; the caller checks with expect_dead()
-
- def recv_frame(self):
- while len(self.buf) < 4:
- d = self.s.recv(65536)
- if not d:
- raise EOFError("server closed")
- self.buf += d
- size, = struct.unpack_from("<I", self.buf)
- while len(self.buf) < size:
- d = self.s.recv(65536)
- if not d:
- raise EOFError("server closed")
- self.buf += d
- f, self.buf = self.buf[:size], self.buf[size:]
- typ, tag = struct.unpack_from("<BH", f, 4)
- return typ, tag, f[7:]
-
- def call(self, typ, body, tag=None):
- if tag is None:
- tag = self.tag
- self.tag = (self.tag + 1) & 0xFFFE
- try:
- self.raw(frame(typ, tag, body))
- rt, rtag, rb = self.recv_frame()
- except (EOFError, OSError):
- return None, None, b""
- return rt, rtag, rb
-
- def expect(self, typ, body, want, tag=None):
- rt, rtag, rb = self.call(typ, body, tag)
- return rt == want, rt, rb
-
- def err(self, typ, body):
- rt, _, rb = self.call(typ, body)
- if rt != Rerror:
- return None
- n, = struct.unpack_from("<H", rb)
- return rb[2:2 + n].decode("utf-8", "replace")
-
- # conveniences
- def version(self, msize=65536, version=b"9P2000"):
- rt, rtag, rb = self.call(Tversion, struct.pack("<I", msize) + s16(version), NOTAG)
- if rt != Rversion:
- return rt, None, None
- ms, = struct.unpack_from("<I", rb)
- n, = struct.unpack_from("<H", rb, 4)
- return rt, ms, rb[6:6 + n]
-
- def attach(self, fid=0, uname=b"hostile", aname=b""):
- return self.call(Tattach, struct.pack("<II", fid, NOFID) + s16(uname) + s16(aname))
-
- def walk(self, fid, newfid, names):
- b = struct.pack("<IIH", fid, newfid, len(names)) + b"".join(s16(n) for n in names)
- return self.call(Twalk, b)
-
- def walk_ok(self, fid, newfid, names):
- rt, _, rb = self.walk(fid, newfid, names)
- if rt != Rwalk:
- return None
- n, = struct.unpack_from("<H", rb)
- return n
-
- def open(self, fid, mode):
- return self.call(Topen, struct.pack("<IB", fid, mode))
-
- def create(self, fid, name, perm, mode):
- return self.call(Tcreate, struct.pack("<I", fid) + s16(name) + struct.pack("<IB", perm, mode))
-
- def read(self, fid, offset, count):
- rt, _, rb = self.call(Tread, struct.pack("<IQI", fid, offset, count))
- if rt != Rread:
- return rt, rb
- n, = struct.unpack_from("<I", rb)
- return rt, rb[4:4 + n]
-
- def write(self, fid, offset, data):
- return self.call(Twrite, struct.pack("<IQI", fid, offset, len(data)) + data)
-
- def clunk(self, fid):
- return self.call(Tclunk, struct.pack("<I", fid))
-
- def remove(self, fid):
- return self.call(Tremove, struct.pack("<I", fid))
-
- def stat(self, fid):
- rt, _, rb = self.call(Tstat, struct.pack("<I", fid))
- if rt != Rstat:
- return rt, rb
- n, = struct.unpack_from("<H", rb)
- return rt, parse_stat(rb[2:2 + n])
-
- def wstat(self, fid, st):
- return self.call(Twstat, struct.pack("<I", fid) + s16(st))
-
- def read_all(self, fid, chunk=8192):
- out = b""
- while True:
- rt, d = self.read(fid, len(out), chunk)
- if rt != Rread:
- return None
- if not d:
- return out
- out += d
-
- def path_read(self, names, fid=77):
- if self.walk_ok(0, fid, names) != len(names):
- return None
- rt, _, _ = self.open(fid, OREAD)
- if rt != Ropen:
- self.clunk(fid)
- return None
- d = self.read_all(fid)
- self.clunk(fid)
- return d
-
- def session(self, msize=65536):
- rt, ms, _ = self.version(msize)
- assert rt == Rversion, rt
- rt, _, _ = self.attach()
- assert rt == Rattach, rt
- return ms
-
-
-def healthy(path):
- """Fresh connection; the tree must still answer and /build/zig_version must be non-empty."""
- try:
- c = Nine(path, timeout=5.0)
- c.session()
- d = c.path_read([b"build", b"zig_version"])
- c.close()
- return bool(d)
- except Exception as e: # noqa: BLE001
- print(f" probe failed: {e!r}")
- return False
-
-
-def expect_dead(c):
- """The server must close the connection (EOF) rather than answer or hang."""
- try:
- c.s.settimeout(5.0)
- d = c.s.recv(4096)
- return d == b""
- except socket.timeout:
- return False
- except OSError:
- return True
-
-
-def rss_kb(pid):
- try:
- with open(f"/proc/{pid}/status") as f:
- for line in f:
- if line.startswith("VmRSS:"):
- return int(line.split()[1])
- except OSError:
- return -1
- return -1
-
-
-def threads(pid):
- try:
- return len(os.listdir(f"/proc/{pid}/task"))
- except OSError:
- return -1
-
-
-# --------------------------------------------------------------------------- attacks
-
-
-def attack_framing(path):
- print("# framing")
- c = Nine(path)
- c.raw(os.urandom(64))
- ok("garbage bytes: connection closed", expect_dead(c))
- c.close()
- for size in (0, 1, 6, 7, 0xFFFFFFFF, (1 << 20) + 1):
- c = Nine(path)
- c.raw(struct.pack("<I", size) + b"\x64\xff\xff" + b"\x00" * 16)
- ok(f"frame size {size}: connection closed", expect_dead(c))
- c.close()
- # exactly 7 bytes claiming size 7 with a bogus type
- c = Nine(path)
- c.raw(struct.pack("<IBH", 7, 0xEE, 1))
- ok("size-7 frame with unknown type: closed", expect_dead(c))
- c.close()
- # Terror (type 106) is reserved
- c = Nine(path)
- c.raw(frame(106, 1, b""))
- ok("Terror frame: closed", expect_dead(c))
- c.close()
- # an R-type sent to the server
- c = Nine(path)
- c.raw(frame(Rversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")))
- ok("R-message sent to server: closed", expect_dead(c))
- c.close()
- # half a frame then disconnect
- c = Nine(path)
- c.raw(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000"))[:9])
- c.close()
- # request before Tversion
- c = Nine(path)
- c.raw(frame(Tattach, 1, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")))
- ok("Tattach before Tversion: closed", expect_dead(c))
- c.close()
- # Tversion with a tag other than NOTAG
- c = Nine(path)
- c.raw(frame(Tversion, 5, struct.pack("<I", 8192) + s16(b"9P2000")))
- ok("Tversion with tag 5: closed", expect_dead(c))
- c.close()
- # Tversion msize below the resource floor
- for ms in (0, 1, 23):
- c = Nine(path)
- rt, _, _ = c.version(ms)
- ok(f"Tversion msize {ms}: no Rversion (closed or Rerror)", rt in (None, Rerror) or expect_dead(c))
- c.close()
- # tiny msize 24 is negotiable (Rversion fits); Tattach cannot fit, so use msize 64 for the rest
- c = Nine(path)
- rt, ms, ver = c.version(24)
- ok("Tversion msize 24 accepted", rt == Rversion and ms == 24 and ver == b"9P2000", f"{rt} {ms} {ver}")
- c.close()
- c = Nine(path)
- rt, ms, ver = c.version(64)
- ok("Tversion msize 64 accepted", rt == Rversion and ms == 64, f"{rt} {ms} {ver}")
- rt, _, _ = c.attach(uname=b"u")
- ok("attach at msize 64", rt == Rattach, rt)
- # Rstat of the root is ~70 bytes and cannot fit: must be an Rerror, not a dead socket
- rt, rb = c.stat(0)
- ok("stat at msize 64 answers Rerror (reply does not fit), socket stays open", rt == Rerror, f"{rt} {rb!r}")
- # Twalk with 5 names is 37 bytes (fits); Rwalk with 5 qids is 74 bytes (does not)
- rt, _, rb = c.walk(0, 1, [b".", b".", b".", b".", b"."])
- ok("5-element walk at msize 64 answers Rerror, socket stays open", rt == Rerror, f"{rt} {rb!r}")
- ok("newfid not bound by the failed walk", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- rt, _, _ = c.walk(0, 1, [b"README"])
- ok("1-element walk at msize 64", rt == Rwalk, rt)
- rt, _, _ = c.open(1, OREAD)
- ok("open at msize 64", rt == Ropen, rt)
- rt, d = c.read(1, 0, 4096)
- ok("read at msize 64 returns <= 40 bytes", rt == Rread and 0 < len(d) <= 40, f"{rt} {d!r}")
- rt, _, _ = c.clunk(1)
- ok("clunk at msize 64 still works", rt == Rclunk, rt)
- c.close()
- # huge msize is clamped to the server's max (1 MiB)
- c = Nine(path)
- rt, ms, ver = c.version(0xFFFFFFFF)
- ok("Tversion msize 2^32-1 clamped to 1 MiB", rt == Rversion and ms == 1 << 20, f"{rt} {ms}")
- # a frame larger than the negotiated msize
- c.attach()
- c.raw(frame(Twrite, 1, struct.pack("<IQI", 0, 0, (1 << 20)) + b"x" * (1 << 20)))
- ok("frame larger than msize: closed", expect_dead(c))
- c.close()
- # unknown version string
- c = Nine(path)
- rt, ms, ver = c.version(8192, b"9P2001")
- ok("unknown version answered 'unknown'", rt == Rversion and ver == b"unknown", f"{rt} {ver}")
- rt, _, _ = c.attach()
- ok("request after unknown version: closed", rt is None or expect_dead(c))
- c.close()
- c = Nine(path)
- rt, ms, ver = c.version(8192, b"9P2000.L")
- ok("9P2000.L falls back to 9P2000", rt == Rversion and ver == b"9P2000", f"{rt} {ver}")
- c.close()
- ok("server healthy after framing attacks", healthy(path))
-
-
-def attack_tags(path):
- print("# tags and flush")
- c = Nine(path)
- c.session()
- # Tflush for a tag that was never used
- rt, tag, _ = c.call(Tflush, struct.pack("<H", 4242), 9)
- ok("Tflush of unknown oldtag is Rflush", rt == Rflush and tag == 9, rt)
- rt, _, _ = c.call(Tflush, struct.pack("<H", NOTAG), 10)
- ok("Tflush of NOTAG is Rflush", rt == Rflush, rt)
- # same tag twice in a row (sequential: fine)
- rt, _, _ = c.call(Tstat, struct.pack("<I", 0), 7)
- rt2, _, _ = c.call(Tstat, struct.pack("<I", 0), 7)
- ok("tag reuse after reply works", rt == Rstat and rt2 == Rstat)
- # two requests with the same tag pipelined: the server is synchronous so both get answered
- c.raw(frame(Tstat, 7, struct.pack("<I", 0)) + frame(Tstat, 7, struct.pack("<I", 0)))
- a = c.recv_frame()
- b = c.recv_frame()
- ok("pipelined duplicate tags: both answered in order", a[0] == Rstat and b[0] == Rstat and a[1] == 7 and b[1] == 7)
- # a request with NOTAG
- c.raw(frame(Tstat, NOTAG, struct.pack("<I", 0)))
- ok("non-version request with NOTAG: closed", expect_dead(c))
- c.close()
- # 100 pipelined requests in one send
- c = Nine(path)
- c.session()
- blob = b"".join(frame(Tstat, i, struct.pack("<I", 0)) for i in range(100))
- c.raw(blob)
- got = [c.recv_frame() for _ in range(100)]
- ok("100 pipelined Tstat all answered in order", all(g[0] == Rstat and g[1] == i for i, g in enumerate(got)))
- c.close()
- ok("server healthy after tag attacks", healthy(path))
-
-
-def attack_walk(path):
- print("# walk")
- c = Nine(path)
- c.session()
- # 17 names is a wire violation -> connection closed
- c.raw(frame(Twalk, 1, struct.pack("<IIH", 0, 1, 17) + s16(b"a") * 17))
- ok("Twalk with 17 names: closed", expect_dead(c))
- c.close()
- c = Nine(path)
- c.session()
- ok("Twalk with 16 names ('.' x16) succeeds", c.walk_ok(0, 1, [b"."] * 16) == 16)
- c.clunk(1)
- ok("walk '..' from root stays at root", c.walk_ok(0, 1, [b"..", b"..", b"build"]) == 3)
- c.clunk(1)
- ok("walk with '/' in name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"build/target")) is not None)
- ok("walk with empty name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"")) is not None)
- ok("walk with NUL name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"bui\x00ld")) is not None)
- ok("walk 300-byte name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"a" * 300)) is not None)
- ok("walk 60000-byte name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"a" * 60000)) is not None)
- # partial walk: newfid not bound
- n = c.walk_ok(0, 1, [b"build", b"nope", b"x"])
- ok("partial walk returns 1 qid", n == 1, n)
- ok("partial walk does not bind newfid", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # walk through a file
- n = c.walk_ok(0, 1, [b"build", b"target", b"x"])
- ok("walk through a file is partial (2)", n == 2, n)
- ok("newfid unbound after partial walk through file", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # walk from a file with nwname>0
- ok("walk to file", c.walk_ok(0, 1, [b"build", b"target"]) == 2)
- ok("walk from file fails 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"x")) == "not a directory")
- # walk from an open fid
- c.open(1, OREAD)
- ok("walk from open fid with names fails", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"x")) is not None)
- # clone (nwname 0) from open fid with newfid == fid must not silently close the fid
- rt, _, _ = c.walk(1, 1, [])
- rt2, d = c.read(1, 0, 100)
- ok("self-walk nwname=0 on open fid does not lose open state", rt == Rerror or (rt2 == Rread and d), f"{rt} {rt2}")
- c.clunk(1)
- # newfid in use
- c.walk_ok(0, 1, [])
- ok("walk to a fid in use", c.err(Twalk, struct.pack("<IIH", 0, 1, 0)) == "fid in use")
- ok("walk from unknown fid", c.err(Twalk, struct.pack("<IIH", 999, 2, 0)) == "unknown fid")
- # attach twice
- ok("attach twice same fid", c.err(Tattach, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")) == "fid in use")
- ok("auth is refused", c.err(Tauth, struct.pack("<I", 5) + s16(b"u") + s16(b"")) is not None)
- c.close()
- ok("server healthy after walk attacks", healthy(path))
-
-
-def attack_io(path):
- print("# open/read/write")
- c = Nine(path)
- ms = c.session()
- c.walk_ok(0, 1, [b"build", b"target"])
- rt, _, _ = c.open(1, OREAD)
- ok("open target", rt == Ropen)
- ok("open twice fails", c.err(Topen, struct.pack("<IB", 1, OREAD)) is not None)
- rt, d = c.read(1, 0, 0xFFFFFFFF)
- ok("read count 2^32-1 clamped", rt == Rread and 0 < len(d) < ms, f"{rt} {len(d) if d else d}")
- rt, d = c.read(1, (1 << 64) - 1, 100)
- ok("read at offset 2^64-1 returns empty", rt == Rread and d == b"", f"{rt} {d!r}")
- rt, d = c.read(1, (1 << 63), 100)
- ok("read at offset 2^63 returns empty", rt == Rread and d == b"")
- ok("write to read-only static file", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") is not None)
- c.clunk(1)
- # read on unopened fid
- c.walk_ok(0, 2, [b"README"])
- ok("read on unopened fid", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == "file not open")
- ok("write on unopened fid", c.err(Twrite, struct.pack("<IQI", 2, 0, 1) + b"x") == "file not open")
- ok("read unknown fid", c.err(Tread, struct.pack("<IQI", 555, 0, 10)) == "unknown fid")
- c.clunk(2)
- # directory: write/trunc/write on a dir
- c.walk_ok(0, 3, [b"build"])
- ok("open dir for write is 'is a directory'", c.err(Topen, struct.pack("<IB", 3, OWRITE)) == "is a directory")
- ok("open dir with OTRUNC is refused", c.err(Topen, struct.pack("<IB", 3, OREAD | OTRUNC)) is not None)
- rt, _, _ = c.open(3, OREAD)
- ok("write on open dir", c.err(Twrite, struct.pack("<IQI", 3, 0, 1) + b"x") is not None)
- rt, d = c.read(3, 0, 8192)
- ok("read dir", rt == Rread and len(d) > 0)
- ok("read dir at bad offset", c.err(Tread, struct.pack("<IQI", 3, 3, 8192)) == "bad offset")
- ok("read dir at 2^64-1 is bad offset", c.err(Tread, struct.pack("<IQI", 3, (1 << 64) - 1, 8192)) == "bad offset")
- rt, d2 = c.read(3, len(d), 8192)
- ok("read dir at end returns empty", rt == Rread and d2 == b"")
- # read of an open write-only file
- c.clunk(3)
- # dynamic file: second read after short read returns 0; nonzero offset works
- c.walk_ok(0, 4, [b"runtime", b"fn", b"uname"])
- c.open(4, OREAD)
- rt, d = c.read(4, 0, 8192)
- rt2, d2 = c.read(4, len(d), 8192)
- rt3, d3 = c.read(4, 1, 8192)
- ok("dynamic read then read-at-end is empty", rt == Rread and d and rt2 == Rread and d2 == b"")
- ok("dynamic read at offset 1 is the tail", rt3 == Rread and d3 == d[1:], f"{d!r} {d3!r}")
- c.clunk(4)
- # ctl
- c.walk_ok(0, 5, [b"runtime", b"ctl"])
- c.open(5, ORDWR)
- rt, _, _ = c.write(5, 0, b"add 9223372036854775807 1")
- rt2, d = c.read(5, 0, 100)
- ok("ctl add overflow wraps, no trap", rt == Rwrite and rt2 == Rread and d == b"-9223372036854775808", f"{rt} {d!r}")
- ok("ctl fib 94 rejected", c.err(Twrite, struct.pack("<IQI", 5, 0, 6) + b"fib 94") == "bad command")
- rt, _, _ = c.write(5, 0, b"fib 93")
- rt, d = c.read(5, 0, 100)
- ok("ctl fib 93", d == b"12200160415121876738", d)
- rt, st = c.stat(5)
- ok("ctl length is last result length", rt == Rstat and st["length"] == 20, st)
- ok("ctl bad command", c.err(Twrite, struct.pack("<IQI", 5, 0, 4) + b"nope") == "bad command")
- rt, st = c.stat(5)
- ok("ctl length unchanged after error", rt == Rstat and st["length"] == 20, st)
- rt, _, _ = c.write(5, 0, b"sleep-ms 99999999999999999999")
- ok("ctl sleep-ms huge number is a bad command (no trap)", rt == Rerror, rt)
- rt, _, _ = c.write(5, 0, b"echo " + b"\xff" * 1000)
- ok("ctl echo binary", rt == Rwrite)
- rt, _, _ = c.write(5, 0, b"")
- ok("ctl empty write is bad command", rt == Rerror)
- c.clunk(5)
- c.close()
- ok("server healthy after io attacks", healthy(path))
-
-
-def attack_scratch(path):
- print("# scratch")
- c = Nine(path)
- c.session()
- tag = os.urandom(4).hex().encode()
- root = b"h-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- rt, _, _ = c.create(1, root, DMDIR | 0o755, OREAD)
- ok("mkdir test root", rt == Rcreate, rt)
- c.clunk(1)
- S = [b"scratch", root]
-
- def fresh(fid, extra=()):
- return c.walk_ok(0, fid, S + list(extra))
-
- fresh(1)
- ok("create name with '/'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a/b") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create '.'", c.err(Tcreate, struct.pack("<I", 1) + s16(b".") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create '..'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"..") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create empty name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create NUL name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a\x00b") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create 256-byte name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a" * 256) + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- rt, _, _ = c.create(1, b"b" * 255, 0o644, OWRITE)
- ok("create 255-byte name ok", rt == Rcreate, rt)
- rt, st = c.stat(1)
- ok("stat of 255-byte name round-trips", rt == Rstat and st["name"] == b"b" * 255)
- c.clunk(1)
- fresh(1)
- ok("create over existing name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"b" * 255) + struct.pack("<IB", 0o644, OWRITE)) == "file already exists")
- ok("mkdir over existing file", c.err(Tcreate, struct.pack("<I", 1) + s16(b"b" * 255) + struct.pack("<IB", DMDIR | 0o755, OREAD)) == "file already exists")
- ok("create DMDIR with OWRITE", c.err(Tcreate, struct.pack("<I", 1) + s16(b"dd") + struct.pack("<IB", DMDIR | 0o755, OWRITE)) is not None)
- ok("create DMDIR with OTRUNC", c.err(Tcreate, struct.pack("<I", 1) + s16(b"dd") + struct.pack("<IB", DMDIR | 0o755, OREAD | OTRUNC)) is not None)
- # create in a file
- rt, _, _ = c.create(1, b"f", 0o644, ORDWR)
- ok("create f (fid becomes open file)", rt == Rcreate)
- ok("create inside open fid", c.err(Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) is not None)
- c.clunk(1)
- fresh(1, [b"f"])
- ok("create inside a file is 'not a directory'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == "not a directory")
- # writes: past the cap, at huge offsets
- rt, _, _ = c.open(1, OWRITE)
- ok("write at 64MiB-1 of 2 bytes is no space", c.err(Twrite, struct.pack("<IQI", 1, (64 << 20) - 1, 2) + b"xy") == "no space left on device")
- ok("write at 2^64-1 is no space", c.err(Twrite, struct.pack("<IQI", 1, (1 << 64) - 1, 1) + b"x") == "no space left on device")
- rt, _, _ = c.write(1, (1 << 64) - 1, b"")
- rt2, st = c.stat(1)
- ok("zero-length write at 2^64-1 does not extend the file", rt == Rwrite and st["length"] == 0, f"{rt} {st}")
- rt, _, _ = c.write(1, (64 << 20) - 1, b"x")
- rt2, st = c.stat(1)
- ok("write at 64MiB-1 of 1 byte allowed (file now 64 MiB)", rt == Rwrite and st["length"] == 64 << 20, f"{rt} {st}")
- st = mkstat(length=0)
- rt, _, _ = c.wstat(1, st)
- ok("truncate back to 0", rt == Rwstat)
- ok("wstat length 64MiB+1 is no space", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=(64 << 20) + 1))) == "no space left on device")
- ok("wstat length 2^64-2 is no space", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=(1 << 64) - 2))) == "no space left on device")
- # read on a write-only fid
- ok("read on OWRITE fid", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == "file not open")
- c.clunk(1)
- # wstat with everything set to the current values: no-op
- fresh(1, [b"f"])
- rt, st = c.stat(1)
- full = mkstat(name=st["name"], uid=st["uid"], gid=st["gid"], muid=st["muid"], typ=st["type"], dev=st["dev"],
- qtype=st["qid"][0], qvers=st["qid"][1], qpath=st["qid"][2], mode=st["mode"], atime=st["atime"],
- mtime=st["mtime"], length=st["length"])
- rt, _, _ = c.wstat(1, full)
- ok("wstat with everything equal to current is ok", rt == Rwstat, rt)
- rt, st2 = c.stat(1)
- ok("stat/wstat round trip fidelity", st2 == st, f"{st}\n{st2}")
- # wstat changing immutable fields
- ok("wstat changing qid.path", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(qpath=12345))) == "permission denied")
- ok("wstat changing uid", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(uid=b"root"))) == "permission denied")
- ok("wstat DMDIR on a file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mode=DMDIR | 0o755))) == "permission denied")
- ok("wstat rename to '.'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"."))) == "bad file name")
- ok("wstat rename to '..'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b".."))) == "bad file name")
- ok("wstat rename to 'a/b'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"a/b"))) == "bad file name")
- ok("wstat rename to existing", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"b" * 255))) == "file already exists")
- rt, _, _ = c.wstat(1, mkstat(name=b"F"))
- rt2, st = c.stat(1)
- ok("rename differing only by case works", rt == Rwstat and st["name"] == b"F")
- rt, _, _ = c.wstat(1, mkstat(mtime=12345))
- rt2, st = c.stat(1)
- ok("wstat mtime is honoured", rt == Rwstat and st["mtime"] == 12345, st)
- c.clunk(1)
- # remove of root / scratch root / static
- ok("remove of attach root", c.err(Tremove, struct.pack("<I", 0)) == "permission denied")
- ok("fid clunked by failed remove", c.err(Tstat, struct.pack("<I", 0)) == "unknown fid")
- c.attach()
- c.walk_ok(0, 1, [b"scratch"])
- ok("remove of /scratch", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok("fid clunked by failed remove of /scratch", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- c.walk_ok(0, 1, [b"build", b"target"])
- ok("remove of static file", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok("clunk unknown fid", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # remove non-empty dir; fid clunked
- fresh(1)
- ok("remove non-empty dir", c.err(Tremove, struct.pack("<I", 1)) == "directory not empty")
- ok("fid clunked after failed remove", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # a fid on a removed file: everything but stat/clunk fails cleanly
- fresh(1, [b"F"])
- fresh(2, [b"F"])
- rt, _, _ = c.remove(2)
- ok("remove F", rt == Rremove)
- ok("open removed file", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "file does not exist")
- ok("walk .. from removed file", c.err(Twalk, struct.pack("<IIH", 1, 3, 1) + s16(b"..")) is not None)
- ok("wstat removed file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"G"))) == "file does not exist")
- rt, st = c.stat(1)
- ok("stat removed file still answers", rt == Rstat)
- ok("remove removed file", c.err(Tremove, struct.pack("<I", 1)) == "file does not exist")
- # fid reuse after clunk
- fresh(1)
- c.clunk(1)
- ok("fid reusable after clunk", fresh(1) == 2)
- c.clunk(1)
- # ORCLOSE
- fresh(1)
- rt, _, _ = c.create(1, b"tmp", 0o644, OWRITE | ORCLOSE)
- c.clunk(1)
- ok("ORCLOSE removed the file on clunk", fresh(1, [b"tmp"]) == 2)
- # DMAPPEND ignores offset; OTRUNC on append file is ignored
- fresh(1)
- rt, _, _ = c.create(1, b"log", DMAPPEND | 0o644, OWRITE)
- c.write(1, 500, b"a")
- c.write(1, 0, b"b")
- c.clunk(1)
- fresh(1, [b"log"])
- rt, _, _ = c.open(1, OWRITE | OTRUNC)
- c.write(1, 0, b"c")
- c.clunk(1)
- fresh(1, [b"log"])
- c.open(1, OREAD)
- d = c.read_all(1)
- ok("append-only file", d == b"abc", d)
- rt, st = c.stat(1)
- ok("append qid bit", st["qid"][0] & 0x40 != 0)
- c.clunk(1)
- # DMEXCL: a second open must fail while the first is open
- fresh(1)
- rt, _, _ = c.create(1, b"lock", DMEXCL | 0o644, OWRITE)
- ok("create DMEXCL", rt == Rcreate)
- fresh(2, [b"lock"])
- e = c.err(Topen, struct.pack("<IB", 2, OREAD))
- ok("second open of DMEXCL file is refused while open", e is not None, e)
- c.clunk(1)
- rt, _, _ = c.open(2, OREAD)
- ok("DMEXCL file opens again after the first fid is clunked", rt == Ropen, rt)
- c.clunk(2)
- # mkdir with DMAPPEND|DMEXCL bits, then create inside it
- fresh(1)
- rt, _, _ = c.create(1, b"weird", DMDIR | DMAPPEND | DMEXCL | 0o755, OREAD)
- c.clunk(1)
- fresh(1, [b"weird"])
- rt, _, _ = c.create(1, b"inner", 0o644, OWRITE)
- ok("create inside DMDIR|DMAPPEND|DMEXCL dir works", rt == Rcreate, rt)
- c.clunk(1)
- # directory read across offsets while the directory changes
- fresh(1)
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 120) # one or two records
- fresh(2, [b"weird", b"inner"])
- c.remove(2)
- fresh(2, [b"weird"])
- c.remove(2)
- fresh(2, [b"log"])
- c.remove(2)
- rt2, d2 = c.read(1, len(d), 8192)
- ok("dir read continues after entries were removed (no crash)", rt == Rread and rt2 == Rread)
- rt3, d3 = c.read(1, 0, 8192)
- ok("dir rewind after change lists current entries", rt3 == Rread)
- c.clunk(1)
- # perm inheritance: 0o777 file in 0o755 dir
- fresh(1)
- rt, _, _ = c.create(1, b"px", 0o777, OREAD)
- rt, st = c.stat(1)
- ok("create perm masked by parent (0o777 & 0o755 & 0o666)", st["mode"] == 0o644, oct(st["mode"]))
- c.clunk(1)
- # mode 0 file: open refused; chmod back via wstat
- fresh(1, [b"px"])
- c.wstat(1, mkstat(mode=0))
- ok("open mode-0 file refused", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "permission denied")
- c.wstat(1, mkstat(mode=0o644))
- rt, _, _ = c.open(1, OREAD)
- ok("open after chmod", rt == Ropen)
- c.clunk(1)
- # Tversion mid-session resets fids (retained scratch nodes released)
- fresh(1, [b"px"])
- fresh(2, [b"px"])
- c.remove(2)
- rt, ms, _ = c.version(65536)
- ok("mid-session Tversion", rt == Rversion)
- ok("fids gone after Tversion", c.err(Tstat, struct.pack("<I", 1)) is not None)
- ok("fids gone after Tversion (0)", c.err(Tstat, struct.pack("<I", 0)) == "unknown fid")
- c.attach()
- # cleanup: remove everything under root
- c.walk_ok(0, 1, S)
- c.open(1, OREAD)
- d = c.read_all(1)
- names = []
- while d:
- n, = struct.unpack_from("<H", d)
- names.append(parse_stat(d[:n + 2])["name"])
- d = d[n + 2:]
- c.clunk(1)
- for nm in names:
- if c.walk_ok(0, 1, S + [nm]) == 3:
- c.remove(1)
- c.walk_ok(0, 1, S)
- rt, _, _ = c.remove(1)
- ok("cleanup removed test root", rt == Rremove, names)
- c.close()
- ok("server healthy after scratch attacks", healthy(path))
-
-
-def attack_many_fids(path):
- print("# many fids")
- c = Nine(path, timeout=30)
- c.session()
- n = 20000
- blob = b"".join(frame(Twalk, i & 0xFFFE, struct.pack("<IIH", 0, i + 1, 0)) for i in range(n))
- got = [0]
- dead = [False]
-
- def reader(): # a pipelining client must read concurrently or it deadlocks itself on socket buffers
- try:
- for _ in range(n):
- rt, _, _ = c.recv_frame()
- got[0] += rt == Rwalk
- except (EOFError, OSError):
- dead[0] = True
-
- t = threading.Thread(target=reader)
- t.start()
- t0 = time.time()
- c.raw(blob)
- t.join(60)
- ok("20000 clones answered", got[0] == n and not dead[0] and not t.is_alive(), f"got={got[0]} dead={dead[0]}")
- print(f" {n} clones in {time.time() - t0:.2f}s")
- rt, _, _ = c.version(65536)
- ok("Tversion after 20000 fids", rt == Rversion)
- c.close()
- ok("server healthy after fid flood", healthy(path))
-
-
-def attack_connections(path, pid, count=500):
- print(f"# {count} idle connections")
- before = rss_kb(pid)
- socks = []
- try:
- for _ in range(count):
- s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- s.connect(path) # blocking connect waits for backlog room instead of failing with EAGAIN
- s.settimeout(5)
- socks.append(s)
- except OSError as e:
- print(f" connect failed after {len(socks)}: {e!r}")
- time.sleep(1.0)
- mid = rss_kb(pid)
- th = threads(pid)
- print(f" rss before={before} KiB, with {len(socks)} idle conns={mid} KiB, threads={th}")
- ok(f"{count} idle connections accepted (or refused cleanly), server alive", healthy(path) and len(socks) == count, len(socks))
- # send one Tversion from each (no reply read), then half a frame, then close
- for s in socks:
- try:
- s.sendall(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")) + b"\x10\x00\x00")
- except OSError:
- pass
- for s in socks:
- s.close()
- time.sleep(1.0)
- after = rss_kb(pid)
- print(f" rss after close={after} KiB, threads={threads(pid)}")
- ok("server alive after mass disconnect", healthy(path))
- ok("thread count returned to baseline (<= 4)", threads(pid) <= 4, threads(pid))
- return before, mid, after
-
-
-def attack_concurrent(path, clients=8, ops=1000):
- print(f"# {clients} clients x {ops} mixed ops on /scratch")
- errors = []
- tag = os.urandom(3).hex().encode()
-
- def worker(k):
- try:
- c = Nine(path, timeout=30)
- c.session()
- me = b"c%d-%s" % (k, tag)
- for i in range(ops):
- op = i % 7
- if op == 0:
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, me, 0o644, OWRITE)
- c.write(1, 0, b"x" * (i % 500))
- c.clunk(1)
- elif op == 1:
- if c.walk_ok(0, 1, [b"scratch", me]) == 2:
- c.open(1, OREAD)
- c.read_all(1)
- c.clunk(1)
- elif op == 2:
- if c.walk_ok(0, 1, [b"scratch", me]) == 2:
- c.wstat(1, mkstat(name=me + b"-r"))
- c.clunk(1)
- elif op == 3:
- if c.walk_ok(0, 1, [b"scratch", me + b"-r"]) == 2:
- c.wstat(1, mkstat(length=7))
- c.clunk(1)
- elif op == 4:
- c.walk_ok(0, 1, [b"scratch"])
- c.open(1, OREAD)
- c.read_all(1, 300)
- c.clunk(1)
- elif op == 5:
- for nm in (me, me + b"-r"):
- if c.walk_ok(0, 1, [b"scratch", nm]) == 2:
- c.remove(1)
- else:
- c.clunk(1)
- else:
- if k % 2 == 0:
- c.version(65536)
- c.attach()
- else:
- c.walk_ok(0, 1, [b"runtime", b"ctl"])
- c.open(1, ORDWR)
- c.write(1, 0, b"add %d 1" % i)
- c.read(1, 0, 100)
- c.clunk(1)
- for nm in (me, me + b"-r"):
- if c.walk_ok(0, 1, [b"scratch", nm]) == 2:
- c.remove(1)
- else:
- c.clunk(1)
- c.close()
- except Exception as e: # noqa: BLE001
- errors.append((k, repr(e)))
-
- ts = [threading.Thread(target=worker, args=(k,)) for k in range(clients)]
- t0 = time.time()
- for t in ts:
- t.start()
- for t in ts:
- t.join(120)
- ok("concurrent clients finished without errors", not errors and all(not t.is_alive() for t in ts), errors)
- print(f" {clients * ops} ops in {time.time() - t0:.1f}s")
- ok("server healthy after concurrency", healthy(path))
-
-
-def attack_sleep(path):
- print("# sleep-ms must not block other clients")
- a = Nine(path, timeout=10)
- a.session()
- a.walk_ok(0, 1, [b"runtime", b"ctl"])
- a.open(1, OWRITE)
- a.raw(frame(Twrite, 3, struct.pack("<IQI", 1, 0, 13) + b"sleep-ms 3000"))
- t0 = time.time()
- b = Nine(path, timeout=10)
- b.session()
- d = b.path_read([b"build", b"zig_version"])
- dt = time.time() - t0
- ok("other client served during sleep-ms", bool(d) and dt < 1.0, f"{dt:.2f}s")
- rt, _, _ = a.recv_frame()
- ok("sleeper got Rwrite", rt == Rwrite)
- a.close()
- b.close()
-
-
-def attack_env(path):
- print("# runtime files")
- c = Nine(path)
- c.session()
- env = c.path_read([b"runtime", b"env"])
- ok("/runtime/env readable", env is not None)
- if env and any(k in env for k in (b"TOKEN", b"SECRET", b"KEY", b"PASS")):
- print(" note: /runtime/env exposes variables that look like secrets")
- for nm in (b"pid", b"ppid", b"uptime", b"argv", b"cwd", b"clients"):
- d = c.path_read([b"runtime", nm])
- ok(f"/runtime/{nm.decode()} readable", d is not None, d)
- for nm in (b"hostname", b"now", b"random", b"uname", b"fib30"):
- d = c.path_read([b"runtime", b"fn", nm])
- ok(f"/runtime/fn/{nm.decode()} readable and non-empty", bool(d), d)
- # random gives different values on each open
- r1 = c.path_read([b"runtime", b"fn", b"random"])
- r2 = c.path_read([b"runtime", b"fn", b"random"])
- ok("random differs across opens", r1 != r2)
- # stat of dynamic file reports 0, static reports real length
- c.walk_ok(0, 1, [b"runtime", b"pid"])
- rt, st = c.stat(1)
- ok("dynamic file length 0", st["length"] == 0 and st["mode"] == 0o444, st)
- c.clunk(1)
- c.walk_ok(0, 1, [b"README"])
- rt, st = c.stat(1)
- d = c.path_read([b"README"])
- ok("static file length matches content", st["length"] == len(d), (st["length"], len(d)))
- # every directory in the static tree: names in listing match walkable names, '.' and '..' absent
- def walk_tree(names, depth=0):
- if depth > 6:
- return
- if c.walk_ok(0, 9, names) != len(names):
- ok("walk " + b"/".join(names).decode(), False)
- return
- rt, st = c.stat(9)
- if st["mode"] & DMDIR:
- c.open(9, OREAD)
- d = c.read_all(9, 512)
- c.clunk(9)
- while d:
- n, = struct.unpack_from("<H", d)
- e = parse_stat(d[:n + 2])
- d = d[n + 2:]
- if e["name"] in (b".", b"..", b""):
- ok("dir listing has no '.'/'..'/empty names", False, names)
- if names == [b"scratch"]:
- continue
- walk_tree(names + [e["name"]], depth + 1)
- else:
- c.clunk(9)
- walk_tree([])
- ok("entire static tree walkable", True)
- c.close()
-
-
-def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--server")
- ap.add_argument("--socket")
- ap.add_argument("--connections", type=int, default=500)
- ap.add_argument("--fast", action="store_true")
- args = ap.parse_args()
- proc = None
- tmp = None
- if args.server:
- tmp = tempfile.mkdtemp(prefix="adv9p.")
- path = os.path.join(tmp, "sock")
- proc = subprocess.Popen([os.path.abspath(args.server), "--unix", path], stderr=subprocess.PIPE)
- for _ in range(200):
- if os.path.exists(path):
- break
- time.sleep(0.02)
- pid = proc.pid
- elif args.socket:
- path = args.socket
- pid = -1
- else:
- ap.error("--server or --socket")
- try:
- rss0 = rss_kb(pid)
- attack_framing(path)
- attack_tags(path)
- attack_walk(path)
- attack_io(path)
- attack_scratch(path)
- attack_env(path)
- attack_sleep(path)
- attack_many_fids(path)
- if not args.fast:
- attack_connections(path, pid, args.connections)
- attack_concurrent(path)
- rss1 = rss_kb(pid)
- print(f"# rss start={rss0} KiB end={rss1} KiB threads={threads(pid)}")
- if pid > 0:
- ok("server process still running", proc.poll() is None, proc.poll())
- finally:
- if proc is not None:
- proc.send_signal(signal.SIGTERM)
- try:
- _, err = proc.communicate(timeout=5)
- except subprocess.TimeoutExpired:
- proc.kill()
- _, err = proc.communicate()
- lines = [ln for ln in err.decode("utf-8", "replace").splitlines() if "connection ended" not in ln and "read: " not in ln]
- if lines:
- print("# server stderr (filtered):")
- for ln in lines[:40]:
- print(" " + ln)
- if tmp:
- try:
- os.unlink(path)
- os.rmdir(tmp)
- except OSError:
- pass
- print(f"# {PASSES} passed, {len(FAILS)} failed")
- for f in FAILS:
- print("# FAIL " + f)
- sys.exit(1 if FAILS else 0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/introspect/test/adv_introspect_hostile.sh b/introspect/test/adv_introspect_hostile.sh
deleted file mode 100755
index 3ee2ecb..0000000
--- a/introspect/test/adv_introspect_hostile.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-# Adversarial raw-9P2000 client tests for the introspect server.
-# Usage: bash introspect/test/adv_introspect_hostile.sh <introspect> [--fast] (part of zig build introspect-adv)
-# Spawns the server on a temporary unix socket and attacks it with
-# introspect/test/adv_introspect_hostile.py (Python 3 stdlib). Exit 1 on any failure.
-set -u
-INTROSPECT=$(realpath "${1:?path to introspect}")
-shift
-command -v python3 >/dev/null || { echo "SKIP: python3 missing"; exit 0; }
-exec python3 "$(dirname "$0")/adv_introspect_hostile.py" --server "$INTROSPECT" "$@"
diff --git a/introspect/test/adv_linux_probe.py b/introspect/test/adv_linux_probe.py
deleted file mode 100755
index 83de771..0000000
--- a/introspect/test/adv_linux_probe.py
+++ /dev/null
@@ -1,421 +0,0 @@
-#!/usr/bin/env python3
-"""Adversarial tests of the introspect Linux layer: probe loop, debug provider,
-signal machinery. Raw 9P2000 over a unix socket, plus one 9player mount.
-Usage: adv_linux_probe.py --player <9player> --server <introspect>
-Reuses the client of adv_introspect_hostile.py. Exit 1 on any failure.
-"""
-import argparse
-import ctypes
-import os
-import signal
-import socket
-import struct
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-from adv_introspect_hostile import ( # noqa: E402
- NOFID, NOTAG, OREAD, OWRITE, Nine, Rerror, Ropen, Rread, Rversion, Rwalk, Rwrite,
- Tattach, Tread, Tversion, Twrite, frame, healthy, ok, parse_stat, s16)
-import adv_introspect_hostile as hostile # noqa: E402
-
-libc = ctypes.CDLL(None, use_errno=True)
-SYS_tgkill = 234 if os.uname().machine == "x86_64" else 131 # aarch64: 131
-
-
-def tgkill(pid, tid, sig):
- return libc.syscall(SYS_tgkill, pid, tid, sig)
-
-
-class Srv:
- def __init__(self, server, extra=()):
- self.tmp = tempfile.mkdtemp(prefix="advlin.")
- self.path = os.path.join(self.tmp, "sock")
- self.proc = subprocess.Popen([server, "--unix", self.path, *extra], stderr=subprocess.PIPE)
- for _ in range(200):
- if os.path.exists(self.path):
- break
- time.sleep(0.02)
- self.pid = self.proc.pid
-
- def alive(self):
- return self.proc.poll() is None
-
- def stop(self):
- if self.proc.poll() is None:
- self.proc.send_signal(signal.SIGTERM)
- try:
- self.proc.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.proc.kill()
- self.proc.wait()
- err = self.proc.stderr.read().decode("utf-8", "replace")
- try:
- os.unlink(self.path)
- except OSError:
- pass
- try:
- os.rmdir(self.tmp)
- except OSError:
- pass
- return err
-
-
-def client(path, timeout=10):
- c = Nine(path, timeout=timeout)
- c.session()
- return c
-
-
-def rd(c, names, fid=50, offset=0, count=8192):
- """walk+open+one read; returns (rtype-or-tag, data-or-error-string)."""
- if c.walk_ok(0, fid, names) != len(names):
- c.clunk(fid)
- return "walkfail", None
- rt, _, rb = c.open(fid, OREAD)
- if rt != Ropen:
- c.clunk(fid)
- return "openfail", rb
- rt, d = c.read(fid, offset, count)
- c.clunk(fid)
- if rt == Rerror:
- n, = struct.unpack_from("<H", d)
- return "err", d[2:2 + n].decode()
- return rt, d
-
-
-def wr(c, names, data, fid=51, offset=0):
- if c.walk_ok(0, fid, names) != len(names):
- c.clunk(fid)
- return "walkfail", None
- rt, _, rb = c.open(fid, OWRITE)
- if rt != Ropen:
- c.clunk(fid)
- return "openfail", rb
- rt, _, rb = c.write(fid, offset, data)
- c.clunk(fid)
- if rt == Rerror:
- n, = struct.unpack_from("<H", rb)
- return "err", rb[2:2 + n].decode()
- return rt, rb
-
-
-def ls(c, names, fid=52):
- c.walk_ok(0, fid, names)
- c.open(fid, OREAD)
- out = c.read_all(fid) or b""
- c.clunk(fid)
- res, i = [], 0
- while i < len(out):
- n, = struct.unpack_from("<H", out, i)
- res.append(parse_stat(out[i:i + 2 + n])["name"])
- i += 2 + n
- return res
-
-
-def thread_by_name(c, pid, name):
- for t in ls(c, [b"threads"]):
- if rd(c, [b"threads", t, b"name"])[1] == name and int(t) != pid:
- return t
- return None
-
-
-def ticks(c):
- return int(rd(c, [b"vars", b"state", b"f", b"ticks", b"value"])[1])
-
-
-def cpu_ticks(pid):
- f = open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()
- return int(f[11]) + int(f[12])
-
-
-def fds(pid):
- return len(os.listdir(f"/proc/{pid}/fd"))
-
-
-def attack_memory(server):
- print("# memory endpoints")
- s = Srv(server)
- c = client(s.path, timeout=5)
- ok("/mem/0 is an error (not a null-pointer trap on the probe thread)", rd(c, [b"mem", b"0"]) == ("err", "i/o error"))
- ok("/hex/0 open is an error", rd(c, [b"hex", b"0"])[0] == "openfail")
- ok("/mem/0 write is an error", wr(c, [b"mem", b"0"], b"x") == ("err", "i/o error"))
- ok("/mem/1 at offset 2^64-1 (wraps to 0) is an error", rd(c, [b"mem", b"1"], offset=(1 << 64) - 1) == ("err", "i/o error"))
- ok("/mem/ffffffffffff near the top of the address space is an error", rd(c, [b"mem", b"ffffffffffff"], offset=(1 << 64) - 256) == ("err", "i/o error"))
- ok("/hex/ffffffffffff is an error", rd(c, [b"hex", b"ffffffffffff"])[0] == "openfail")
- ok("49-bit address does not walk", rd(c, [b"mem", b"1000000000000"])[0] == "walkfail")
- ok("/addr/0 renders ?", rd(c, [b"addr", b"0"])[1] == b"?\n?\n?\n")
- ok("server alive after the zero/wrap probes", s.alive() and healthy(s.path))
- # /mem/maps is the whole file, however long, byte for byte
- c.walk_ok(0, 60, [b"mem", b"maps"])
- c.open(60, OREAD)
- via = c.read_all(60, 4096)
- c.clunk(60)
- real = open(f"/proc/{s.pid}/maps", "rb").read()
- ok("/mem/maps equals /proc/<pid>/maps (read in 4 KiB pieces)", via == real, f"{len(via)} vs {len(real)}")
- maps = real.decode()
- for tag in ("[stack]", "[vdso]", "[heap]"):
- m = [ln for ln in maps.splitlines() if ln.endswith(tag)]
- if not m:
- continue
- lo = int(m[0].split("-")[0], 16) + 0x100
- ok(f"/addr of {tag} renders ? (never handed to std)", rd(c, [b"addr", b"%x" % lo])[1] == b"?\n?\n?\n")
- ok(f"/hex of {tag} dumps", rd(c, [b"hex", b"%x" % lo])[0] == Rread)
- m = [ln for ln in maps.splitlines() if ln.endswith("[stack]")][0]
- hi = int(m.split()[0].split("-")[1], 16)
- rt, d = rd(c, [b"mem", b"%x" % (hi - 16)], count=4096)
- ok("read across the end of a mapping is a short read of 16 bytes", rt == Rread and len(d) == 16, (rt, d and len(d)))
- rt, d = rd(c, [b"hex", b"%x" % (hi - 16)])
- ok("hexdump across the end of a mapping stops at the boundary", rt == Rread and d.count(b"\n") == 1, (rt, d))
- code = [ln for ln in maps.splitlines() if "r-xp" in ln and "introspect" in ln][0]
- clo = int(code.split("-")[0], 16)
- ok("write into read-only code is an error, not a fault", wr(c, [b"mem", b"%x" % (clo + 0x100)], b"\xcc") == ("err", "i/o error"))
- ok("server alive after memory attacks", s.alive() and healthy(s.path))
- # a big write over the demo's own globals (state onwards) must not fault the server path
- addr = rd(c, [b"vars", b"state", b"addr"])[1].decode().strip()[2:]
- # (it zeroes the demo's own globals, this connection's state included, so
- # the reply may never come; the server as a whole must keep working)
- wr(c, [b"mem", addr.encode()], b"\x00" * 65536)
- time.sleep(0.3)
- ok("server alive and serving after a 64 KiB overwrite of its own globals", s.alive() and healthy(s.path))
- s.stop()
-
-
-def attack_signals(server):
- print("# signal machinery")
- s = Srv(server)
- c = client(s.path, timeout=8)
- w = thread_by_name(c, s.pid, b"worker")
- probe = thread_by_name(c, s.pid, b"introspect")
- ok("worker and probe threads found by name", bool(w and probe), (w, probe))
- names = sorted(open(f"/proc/{s.pid}/task/{t}/comm").read().strip() for t in os.listdir(f"/proc/{s.pid}/task"))
- ok("thread names are introspect, introspect, worker", names == ["introspect", "introspect", "worker"], names)
-
- # 4 clients hammer stacks of every thread while trap/continue interleave
- errs, count = [], [0]
- stop = threading.Event()
-
- def hammer(k):
- try:
- cc = client(s.path, timeout=8)
- while not stop.is_set():
- for t in (w, probe, str(s.pid).encode()):
- rt, d = rd(cc, [b"threads", t, b"stack"], fid=10 + k)
- count[0] += 1
- if rt in ("walkfail", "openfail") or (rt == "err" and "i/o" not in d):
- errs.append((t, rt, d))
- except Exception as e: # noqa: BLE001
- errs.append(repr(e))
-
- ths = [threading.Thread(target=hammer, args=(k,)) for k in range(4)]
- for t in ths:
- t.start()
- rounds_ok = True
- for _ in range(5):
- wr(c, [b"runtime", b"ctl"], b"trap")
- time.sleep(0.15)
- rounds_ok &= ls(c, [b"breakpoints"]) == [w]
- rounds_ok &= b"workerLoop" in (rd(c, [b"breakpoints", w, b"stack"])[1] or b"")
- rounds_ok &= rd(c, [b"threads", w, b"stack"])[0] == Rread # capture of a paused thread
- rounds_ok &= wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == Rwrite
- rounds_ok &= wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == "walkfail" # twice: gone
- stop.set()
- for t in ths:
- t.join()
- ok("trap/inspect/continue rounds while 4 clients capture stacks", rounds_ok)
- ok(f"{count[0]} concurrent captures without a wrong answer", count[0] > 50 and not errs, errs[:3])
-
- # SIGTRAP from outside (tgkill, not int3): parks without corrupting the thread
- ok("tgkill SIGTRAP to the worker", tgkill(s.pid, int(w), signal.SIGTRAP) == 0)
- time.sleep(0.3)
- ok("worker listed under /breakpoints after tgkill", ls(c, [b"breakpoints"]) == [w])
- ok("its stack names workerLoop", b"workerLoop" in (rd(c, [b"breakpoints", w, b"stack"])[1] or b""))
- t1 = ticks(c)
- time.sleep(0.3)
- ok("ticks frozen while parked", ticks(c) == t1)
- ok("continue after tgkill", wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == Rwrite)
- time.sleep(0.4)
- ok("ticks advance after continue (no instruction skipped)", ticks(c) > t1)
-
- # SIGTRAP on the probe thread itself: stepped over, the server keeps serving
- ok("tgkill SIGTRAP to the probe thread", tgkill(s.pid, int(probe), signal.SIGTRAP) == 0)
- time.sleep(0.2)
- ok("server serves after a SIGTRAP on its own thread", healthy(s.path))
- ok("probe thread not parked", ls(c, [b"breakpoints"]) == [])
- # process-directed SIGTRAP lands on some thread; whichever it is, it is resumable
- os.kill(s.pid, signal.SIGTRAP)
- time.sleep(0.3)
- ok("alive after kill -TRAP <pid>", s.alive() and healthy(s.path))
- for t in ls(c, [b"breakpoints"]):
- ok(f"thread {t.decode()} parked by kill -TRAP resumes", wr(c, [b"breakpoints", t, b"ctl"], b"continue")[0] == Rwrite)
- ok("continue on a never-paused tid does not walk", wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == "walkfail")
- ok("a bogus tid does not walk", c.walk_ok(0, 31, [b"threads", b"999999"]) == 1)
-
- # panic: held, inspectable, capture of the held thread works, trap meanwhile harmless, continue aborts
- wr(c, [b"runtime", b"ctl"], b"panic")
- time.sleep(0.3)
- ok("panic message published", rd(c, [b"panic", b"message"])[1] == b"demo panic requested over 9p")
- ok("panic stack names workerLoop", b"workerLoop" in rd(c, [b"panic", b"stack"])[1])
- ok("capture of the held panicking thread answers", rd(c, [b"threads", w, b"stack"])[0] == Rread)
- ok("trap request while a panic is held is harmless", wr(c, [b"runtime", b"ctl"], b"trap")[0] == Rwrite and s.alive())
- ok("panic continue", wr(c, [b"panic", b"ctl"], b"continue")[0] == Rwrite)
- ok("second panic continue is an error", wr(c, [b"panic", b"ctl"], b"continue") == ("err", "file does not exist"))
- time.sleep(1.0)
- ok("process aborted after continue", not s.alive() and s.proc.poll() not in (0, None), s.proc.poll())
- s.stop()
-
- s = Srv(server, ["--no-hold"])
- c = client(s.path, timeout=5)
- wr(c, [b"runtime", b"ctl"], b"panic")
- time.sleep(1.0)
- ok("--no-hold: panic aborts at once", not s.alive() and s.proc.poll() not in (0, None), s.proc.poll())
- s.stop()
-
- s = Srv(server)
- c = client(s.path, timeout=5)
- wr(c, [b"runtime", b"ctl"], b"trap")
- time.sleep(0.3)
- ok("worker parked", ls(c, [b"breakpoints"]) != [])
- path = s.path
- s.proc.send_signal(signal.SIGTERM)
- try:
- rc = s.proc.wait(timeout=5)
- except subprocess.TimeoutExpired:
- rc = None
- ok("SIGTERM with a parked thread exits promptly", rc is not None, rc)
- ok("SIGTERM unlinks the unix socket (clean stop path)", not os.path.exists(path))
- s.stop()
-
-
-def attack_probe(player, server):
- print("# probe loop and admission")
- s = Srv(server)
- c0 = cpu_ticks(s.pid)
- time.sleep(5.0)
- ok("0 CPU ticks over 5 s idle", cpu_ticks(s.pid) - c0 == 0, cpu_ticks(s.pid) - c0)
- f0 = fds(s.pid)
- for i in range(1000):
- so = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- so.connect(s.path)
- if i % 3 == 0:
- so.sendall(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")))
- so.recv(100)
- elif i % 3 == 1:
- so.sendall(b"\x10\x00\x00") # half a frame
- so.close()
- time.sleep(1.0)
- ok("no fd leak over 1000 connect/disconnect cycles", fds(s.pid) == f0, (f0, fds(s.pid)))
- held = [client(s.path, timeout=8) for _ in range(14)]
- pl = subprocess.Popen([player, "--unix", s.path, "--", "sh", "-c", "cat /mnt/9p/build/zig_version; echo; sleep 1000"],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- seen = pl.stdout.readline().strip()
- ok("9player mount alongside 14 attached clients", bool(seen), seen)
- time.sleep(1.0) # past evict_idle_ms: everyone is idle now
- slow = []
- for _ in range(40):
- so = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- so.settimeout(3)
- so.connect(s.path)
- slow.append(so)
- time.sleep(1.5)
- tv = frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000"))
- for k in range(6): # slowloris: one byte at a time
- for so in slow:
- try:
- so.send(tv[k:k + 1])
- except OSError:
- pass
- time.sleep(0.2)
- ok("attached clients are never evicted by a flood", all(h.path_read([b"build", b"zig_version"]) for h in held))
- ok("the 9player mount survives the flood", pl.poll() is None)
- pl2 = subprocess.run([player, "--unix", s.path, "--", "cat", "/mnt/9p/build/zig_version"], capture_output=True, timeout=30)
- ok("a new 9player mount is refused cleanly while the table is full", pl2.returncode != 0 or bool(pl2.stdout.strip()), pl2.stderr[-100:])
- for so in slow:
- so.close()
- time.sleep(0.5)
- # output backpressure: a client that never reads must not stall or spin the loop
- nr = client(s.path, timeout=5)
- nr.walk_ok(0, 5, [b"mem", b"maps"])
- nr.open(5, OREAD)
- nr.s.settimeout(1.0)
- try:
- for i in range(300):
- nr.s.sendall(frame(Tread, i & 0xFFFE, struct.pack("<IQI", 5, 0, 65536)))
- except OSError:
- pass # the server stopped reading it (backpressure): that is the point
- t0 = time.time()
- d = held[0].path_read([b"build", b"zig_version"])
- ok("other clients served while one never reads its replies", bool(d) and time.time() - t0 < 1.0, f"{time.time() - t0:.2f}s")
- c0 = cpu_ticks(s.pid)
- time.sleep(2.0)
- ok("no spin with pending output on a stalled client (<= 2 ticks in 2 s)", cpu_ticks(s.pid) - c0 <= 2, cpu_ticks(s.pid) - c0)
- nr.close()
- time.sleep(0.3)
- # two clients sleep at once: a third is still served, both sleepers get their answer on time
- for h in held[2:]:
- h.close()
- time.sleep(0.3)
-
- def sleeper(ms):
- a = client(s.path, timeout=8)
- a.walk_ok(0, 1, [b"runtime", b"ctl"])
- a.open(1, OWRITE)
- cmd = b"sleep-ms %d" % ms
- a.raw(frame(Twrite, 3, struct.pack("<IQI", 1, 0, len(cmd)) + cmd))
- return a
-
- t0 = time.time()
- a = sleeper(1500)
- b = sleeper(1500)
- d = held[1].path_read([b"build", b"zig_version"])
- ok("third client served during two concurrent sleep-ms", bool(d) and time.time() - t0 < 1.0, f"{time.time() - t0:.2f}s")
- ra = a.recv_frame()[0]
- rb = b.recv_frame()[0]
- dt = time.time() - t0
- ok("both sleepers answered after ~1.5 s, not serialized", ra == Rwrite and rb == Rwrite and dt < 2.5, f"{dt:.2f}s")
- a.close()
- b.close()
- ok("healthy after probe attacks", healthy(s.path))
- pl.terminate()
- pl.wait(timeout=10)
- s.stop()
-
- # --stdio: EOF ends the process with exit 0 and no allocator leak report
- p = subprocess.Popen([server, "--stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- p.stdin.write(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")))
- p.stdin.flush()
- h = p.stdout.read(4)
- n, = struct.unpack("<I", h)
- body = p.stdout.read(n - 4)
- ok("--stdio answers Tversion", body[0] == Rversion)
- p.stdin.close()
- try:
- rc = p.wait(timeout=5)
- except subprocess.TimeoutExpired:
- p.kill()
- rc = None
- err = p.stderr.read().decode("utf-8", "replace")
- ok("--stdio exits 0 on EOF", rc == 0, rc)
- ok("no leak report or stack trace on stderr at exit", "leaked" not in err and "in _start" not in err, err[-300:])
-
-
-def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--player", required=True)
- ap.add_argument("--server", required=True)
- args = ap.parse_args()
- attack_memory(args.server)
- attack_signals(args.server)
- if subprocess.run(["unshare", "-Urm", "true"], capture_output=True).returncode == 0 and os.path.exists("/dev/fuse"):
- attack_probe(args.player, args.server)
- else:
- print("# probe/admission: SKIP (namespaces or /dev/fuse unavailable)")
- print(f"# {hostile.PASSES} passed, {len(hostile.FAILS)} failed")
- sys.exit(1 if hostile.FAILS else 0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/introspect/test/adv_linux_probe.sh b/introspect/test/adv_linux_probe.sh
deleted file mode 100755
index 3535aa9..0000000
--- a/introspect/test/adv_linux_probe.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-# Adversarial tests of the introspect Linux layer (probe loop, debug provider,
-# signal machinery) with raw 9P2000 over a unix socket and one 9player mount.
-# Usage: bash introspect/test/adv_linux_probe.sh <9player> <introspect> (part of zig build introspect-adv)
-# Exit 1 on any failure; SKIP (exit 0) without python3.
-set -u
-PLAYER=$(realpath "${1:?path to 9player}")
-INTROSPECT=$(realpath "${2:?path to introspect}")
-command -v python3 >/dev/null || { echo "SKIP: python3 missing"; exit 0; }
-exec python3 "$(dirname "$0")/adv_linux_probe.py" --player "$PLAYER" --server "$INTROSPECT"
diff --git a/introspect/test/adversarial.sh b/introspect/test/adversarial.sh
deleted file mode 100755
index 918bb9a..0000000
--- a/introspect/test/adversarial.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env bash
-# Runs every introspect adversarial suite in sequence: hostile raw-9P clients
-# against the demo (framing, tags, floods; the core's /vars, snapshots, fids)
-# and the Linux layer through one 9player mount (memory, signals, poll loop).
-# Usage: bash introspect/test/adversarial.sh <9player> <introspect> [--fast]
-# `zig build introspect-adv` runs the same suites as separate steps.
-set -u
-PLAYER=${1:?path to 9player}
-INTROSPECT=${2:?path to introspect}
-shift 2
-HERE=$(cd "$(dirname "$0")" && pwd)
-status=0
-for suite in adv_introspect_hostile adv_core_hostile; do
- echo "### $suite"
- if bash "$HERE/$suite.sh" "$INTROSPECT" "$@"; then echo "### $suite: ok"; else echo "### $suite: FAILED"; status=1; fi
-done
-echo "### adv_linux_probe"
-if bash "$HERE/adv_linux_probe.sh" "$PLAYER" "$INTROSPECT"; then echo "### adv_linux_probe: ok"; else echo "### adv_linux_probe: FAILED"; status=1; fi
-exit $status
diff --git a/introspect/test/debug.sh b/introspect/test/debug.sh
deleted file mode 100755
index c3be406..0000000
--- a/introspect/test/debug.sh
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env bash
-# End-to-end test of the introspect debug facilities through 9player:
-# threads and stacks, address resolution, memory, exposed values, breakpoints, panics.
-# Usage: bash introspect/test/debug.sh <9player> <introspect> (zig build introspect-debug-itest)
-set -u
-PLAYER=$(realpath "${1:?path to 9player}")
-INTROSPECT=$(realpath "${2:?path to introspect}")
-TMP=$(mktemp -d /tmp/9pdbg.XXXXXX)
-M=/mnt/9p
-FAILED=0; PASSED=0
-SRV=
-
-cleanup() { [ -n "$SRV" ] && kill "$SRV" 2>/dev/null; rm -rf "$TMP"; }
-trap cleanup EXIT
-if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: namespaces or /dev/fuse unavailable"; exit 0; fi
-
-pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
-fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
-expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; }
-expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; }
-run_in() { timeout 60 "$PLAYER" --unix "$SOCK" -- sh -c "$1" 2>"$TMP/stderr"; }
-
-SOCK=$TMP/dbg.sock
-"$INTROSPECT" --unix "$SOCK" >"$TMP/server.log" 2>&1 &
-SRV=$!
-for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.05; done
-[ -S "$SOCK" ] || { echo "server did not start"; cat "$TMP/server.log"; exit 1; }
-
-echo "# threads"
-expect_eq "threads listed" "yes" "$(run_in "[ \$(ls $M/threads | wc -l) -ge 2 ] && echo yes")"
-WORKER=$(run_in "for t in $M/threads/*; do if grep -q '^worker' \$t/name 2>/dev/null; then basename \$t; fi; done | head -1")
-expect_eq "worker thread found by name" "yes" "$([ -n "$WORKER" ] && echo yes)"
-STACK=$(run_in "cat $M/threads/$WORKER/stack")
-expect_contains "worker stack names workerLoop" "workerLoop" "$STACK"
-expect_contains "worker stack has source locations" "demo/main.zig:" "$STACK"
-expect_contains "worker regs" "0x" "$(run_in "cat $M/threads/$WORKER/regs | head -3")"
-expect_eq "own (server) thread stack works" "yes" "$(run_in "for t in $M/threads/*; do cat \$t/stack >/dev/null 2>&1 || echo bad; done; echo yes")"
-
-echo "# addresses and memory"
-FRAME=$(printf '%s\n' "$STACK" | grep -oE '0x[0-9a-f]+' | head -1)
-expect_contains "addr resolves a stack frame to the demo source" "demo/main.zig" "$(run_in "cat $M/addr/${FRAME#0x}")"
-expect_contains "addr of garbage is an error, not a crash" "No such file" "$(run_in "cat $M/addr/zzz 2>&1")"
-expect_contains "mem/maps readable" "r-xp" "$(run_in "head -c 4000 $M/mem/maps")"
-STATE_ADDR=$(run_in "cat $M/vars/state/addr")
-expect_contains "hexdump of the exposed state" " " "$(run_in "head -2 $M/hex/${STATE_ADDR#0x}")"
-expect_eq "raw bytes of the state match its size" "$(run_in "cat $M/vars/state/size")" "$(run_in "cat $M/mem/${STATE_ADDR#0x} | head -c \$(cat $M/vars/state/size) | wc -c")"
-expect_eq "reading unmapped memory is an error, not a crash" "no" "$(run_in "cat $M/mem/8 >/dev/null 2>&1 && echo yes || echo no")"
-
-echo "# exposed values"
-T1=$(run_in "cat $M/vars/state/f/ticks/value"); sleep 0.4; T2=$(run_in "cat $M/vars/state/f/ticks/value")
-expect_eq "ticks is numeric" "num" "$(printf '%s' "$T1" | grep -Eq '^[0-9]+$' && echo num)"
-expect_eq "ticks advance" "yes" "$([ "$T2" -gt "$T1" ] 2>/dev/null && echo yes)"
-expect_contains "rendered struct value" "ticks" "$(run_in "cat $M/vars/state/value")"
-expect_contains "type name" "State" "$(run_in "cat $M/vars/state/type")"
-T3=$(run_in "echo 5 > $M/vars/state/f/ticks/value && cat $M/vars/state/f/ticks/value")
-expect_eq "writing a scalar changes the live variable" "yes" "$([ "$T3" -lt "$T2" ] 2>/dev/null && echo yes)"
-
-echo "# breakpoints"
-expect_eq "no breakpoints initially" "" "$(run_in "ls $M/breakpoints")"
-run_in "echo trap > $M/runtime/ctl" >/dev/null; sleep 0.6
-PAUSED=$(run_in "ls $M/breakpoints | head -1")
-expect_eq "worker paused at @breakpoint()" "$WORKER" "$PAUSED"
-expect_contains "paused stack names workerLoop" "workerLoop" "$(run_in "cat $M/breakpoints/$WORKER/stack 2>&1")"
-P1=$(run_in "cat $M/vars/state/f/ticks/value"); sleep 0.4; P2=$(run_in "cat $M/vars/state/f/ticks/value")
-expect_eq "ticks frozen while paused" "$P1" "$P2"
-run_in "echo continue > $M/breakpoints/$WORKER/ctl" >/dev/null; sleep 0.4
-expect_eq "breakpoint list empty after continue" "" "$(run_in "ls $M/breakpoints")"
-P3=$(run_in "cat $M/vars/state/f/ticks/value")
-expect_eq "ticks advance after continue" "yes" "$([ "$P3" -gt "$P2" ] 2>/dev/null && echo yes)"
-
-echo "# panic"
-expect_eq "no panic recorded" "" "$(run_in "cat $M/panic/message")"
-run_in "echo panic > $M/runtime/ctl" >/dev/null; sleep 0.6
-expect_contains "panic message published" "demo panic" "$(run_in "cat $M/panic/message")"
-expect_contains "panic stack names the worker" "workerLoop" "$(run_in "cat $M/panic/stack")"
-expect_eq "server still alive while holding the panic" "yes" "$(kill -0 $SRV 2>/dev/null && echo yes)"
-run_in "echo continue > $M/panic/ctl" >/dev/null 2>&1
-for _ in $(seq 1 50); do kill -0 $SRV 2>/dev/null || break; sleep 0.1; done
-if kill -0 $SRV 2>/dev/null; then fail "server exits after panic continue"; else wait $SRV; RC=$?; SRV=; expect_eq "server exit status is non-zero after the panic" "yes" "$([ $RC -ne 0 ] && echo yes)"; fi
-expect_contains "default panic output reached stderr" "demo panic" "$(cat "$TMP/server.log")"
-
-echo
-echo "passed=$PASSED failed=$FAILED"
-[ "$FAILED" -eq 0 ]