From ba996acfcad1698adbf4a1834fe50e73b1c6cab9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 23:28:22 -0300 Subject: 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 --- 9proc/src/linux/provider.zig | 604 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 9proc/src/linux/provider.zig (limited to '9proc/src/linux/provider.zig') 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//{name,stat,stack,regs} (lists only live tids) +//! /addr/ "fn\nfile:line:col\nmodule\n" +//! /mem/maps, /mem/ /proc/self/maps; raw bytes at address+offset (writable) +//! /hex/ hexdump of 256 bytes at the address +//! /breakpoints//{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/` +//! 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/. +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//stat + \\ stack "#n 0x in (::)" per frame + \\ regs general registers captured while the thread was stopped + \\ +; +const readme_addr = + \\Walk any hex address: /addr/ reads as "fn\nfile:line:col\nmodule\n". + \\ +; +const readme_mem = + \\maps /proc/self/maps + \\ raw process memory at that address (+ file offset); writable + \\ +; +const readme_hex = + \\Walk any hex address: /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/ 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/ 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/ 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")); +} -- cgit v1.3