summaryrefslogtreecommitdiff
path: root/9proc/src/linux/provider.zig
diff options
context:
space:
mode:
Diffstat (limited to '9proc/src/linux/provider.zig')
-rw-r--r--9proc/src/linux/provider.zig604
1 files changed, 604 insertions, 0 deletions
diff --git a/9proc/src/linux/provider.zig b/9proc/src/linux/provider.zig
new file mode 100644
index 0000000..9952398
--- /dev/null
+++ b/9proc/src/linux/provider.zig
@@ -0,0 +1,604 @@
+//! 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"));
+}