From 7a83d1548493b043e2e77022c1c87d348377ac7b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 6 Jul 2026 18:00:54 -0300 Subject: posix portability: linux syscalls -> libc (std.c) in one shared path. All raw std.os.linux.* call sites converted with the errno-convention flip (libc returns -1 + thread-local errno, not -errno): open/close/read/write/rename in tty.zig, gui.zig, look.zig; harness too (snapshot.zig: getcwd/mkdir/chdir/fork+waitpid/getdents64->opendir-readdir, e2e_harness.zig: clock_gettime/write/close), so the parity suite itself is POSIX-portable. Per-OS branches kept only where platforms genuinely diverge: shellCwd in look.zig (linux /proc//cwd readlink; darwin libproc proc_pidinfo PROC_PIDVNODEPATHINFO with a hand-declared extern; other POSIX degrades to null), bash path (/usr/bin/bash on linux, /bin/bash elsewhere), TIOCSWINSZ constant (absent from std.c.T on darwin). forkpty/execv/chdir externs were already libc and are portable (fork-safety preserved: absolute-path execv, no malloc between fork and exec, initial spawns before worker threads). build.zig: forward target/optimize to the ghostty dep for native/cross (web keeps its no-target fetch), disable ghostty simd for darwin cross-builds (apple_sdk needs xcrun; scalar fallback = web config), link libutil on the BSDs. Pinned ghostty needs two one-line zig-0.16 darwin fixes, applied in the zig-pkg cache and documented in build.zig. Verified: aarch64-macos + x86_64-macos + x86_64-freebsd cross-compiles green; linux native tty + gui + web green; snapshot suite 18/18 with goldens untouched. Out of scope: gui-on-macos needs Metal shaders (SDL GPU rejects our SPIR-V there). --- build.zig | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) (limited to 'build.zig') diff --git a/build.zig b/build.zig index 22dd7158..f9aa1070 100644 --- a/build.zig +++ b/build.zig @@ -59,6 +59,10 @@ pub fn build(b: *std.Build) void { // static archives the emcc link needs beyond the zig object (grammars, SDL) var wasm_libs: std.ArrayList(std.Build.LazyPath) = .empty; + // forkpty: in libc proper on glibc>=2.34 and darwin; the BSDs keep it in libutil + if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) + root_mod.linkSystemLibrary("util", .{}); + const opts = b.addOptions(); opts.addOption(Platform, "platform", platform); opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled); @@ -171,7 +175,26 @@ pub fn build(b: *std.Build) void { }).module("vaxis"); var ghostty_vt_for_snap: ?*std.Build.Module = null; - if (b.lazyDependency("ghostty", .{ .simd = target.result.os.tag != .emscripten })) |dep| { + // ghostty's simd libs (simdutf/highway, C++) locate the Apple SDK via + // xcrun on darwin targets, so cross-compiling to macOS from elsewhere + // uses the scalar fallback (the same configuration the web shell ships). + // Native/cross builds hand ghostty the real target (it defaults to the + // host otherwise); the web path keeps its original no-target fetch, whose + // zig object never uses ghostty's artifacts. + // NOTE: darwin targets also need two one-line zig-0.16 fixes in the + // pinned ghostty (applied in the zig-pkg cache; re-apply after a fresh + // fetch, or bump the pin once upstream carries them): + // src/os/mach.zig — std.heap.next_mmap_addr_hint is gone (make the hint + // var module-local) and posix.mmap prot is now a packed struct + // (.{ .READ = true, .WRITE = true }); + // src/terminal/kitty/graphics_image.zig:185 — shm_open's variadic mode + // literal 0 must be @as(std.c.mode_t, 0). + const ghostty_simd = !target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin(); + const ghostty_dep = if (is_emscripten) + b.lazyDependency("ghostty", .{ .simd = false }) + else + b.lazyDependency("ghostty", .{ .target = target, .optimize = optimize, .simd = ghostty_simd }); + if (ghostty_dep) |dep| { const ghostty_vt = dep.module("ghostty-vt"); ghostty_vt_for_snap = ghostty_vt; ghostty_vt.addImport("uucode", uucode_mod); @@ -309,6 +332,8 @@ pub fn build(b: *std.Build) void { }), }); if (ghostty_vt_for_snap) |vt| snap.root_module.addImport("ghostty-vt", vt); + if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) + snap.root_module.linkSystemLibrary("util", .{}); b.installArtifact(snap); const run_snap = b.addRunArtifact(snap); run_snap.addArtifactArg(exe); -- cgit v1.3 From a43dc216f03c0bfed51ca539d027a90adc689ac8 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 6 Jul 2026 13:50:54 -0300 Subject: tty: coalesce queued input events into one frame; tracy zones behind -Dtracy The tty shell rendered (core.render + full vaxis repaint + tree-sitter re-highlight of the visible window) after EVERY event, so a wheel flick over a highlighted file was 150 re-parses and 150 terminal repaints; the gui shell drains the SDL queue before drawing and never had the issue. Now the tty loop applies every queued input event, then renders once (batch cap 64; pty output keeps a frame per chunk so progress paints and transient-state waits behave exactly as before). Measured with tracy on a 2400-line .zig scroll script: 150 highlightFileRange calls at 1.85ms mean -> 4; render pipeline total 730ms -> 64ms; 364 frames -> 39. Snap suite 18/18. Tracy: -Dtracy= compiles TracyClient.cpp into the binary and turns src/tracy.zig zones live (default off, all no-ops). Sampling, callstacks, system tracing and the crash handler are disabled: tracy's symbol worker SIGSEGVs on this binary's debug info and its crash handler then parks every thread before the first frame. Capture: tracy-capture -o t.tracy; export: tracy-csvexport t.tracy. --- .gitignore | 2 +- build.zig | 25 ++++++++++++ src/pardes.zig | 9 +++++ src/syntax.zig | 3 ++ src/tracy.zig | 50 +++++++++++++++++++++++ src/tty.zig | 126 ++++++++++++++++++++++++++++++++++----------------------- 6 files changed, 163 insertions(+), 52 deletions(-) create mode 100644 src/tracy.zig (limited to 'build.zig') diff --git a/.gitignore b/.gitignore index c006b9dd..e78d336f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ zig-pkg -zig-out +zig-out* .zig-cache/ .zig-cache/* assets/MapleMono-NF-Regular.ttf diff --git a/build.zig b/build.zig index 22dd7158..41f4c0ec 100644 --- a/build.zig +++ b/build.zig @@ -16,6 +16,7 @@ pub fn build(b: *std.Build) void { const optimize = if (is_emscripten) .ReleaseSmall else requested_optimize; const default_grammars: TreeSitterGrammars = if (is_emscripten) .minimal else .full; const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, minimal, full") orelse default_grammars; + const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout"); // the browser shell: zig object + emcc link + html, installed to zig-out/web const web_step = b.step("web", "build the browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-emscripten -Ddump=)"); @@ -64,8 +65,32 @@ pub fn build(b: *std.Build) void { opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled); opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars != .disabled); opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full); + opts.addOption(bool, "enable_tracy", tracy != null); root_mod.addOptions("pardes_config", opts); + // Tracy zones (src/tracy.zig): compile the client into the binary only when + // -Dtracy= names a Tracy checkout; otherwise every zone is a no-op. + // Sampling/callstacks/system tracing stay off: tracy's symbol worker + // SIGSEGVs on this binary's debug info, and its crash handler then parks + // every thread — the app wedges before the first frame. Zones don't need + // any of it. + if (tracy) |tracy_path| { + root_mod.addIncludePath(.{ .cwd_relative = tracy_path }); + root_mod.addCSourceFile(.{ + .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) }, + .flags = &.{ + "-DTRACY_ENABLE=1", + "-DTRACY_NO_SAMPLING", + "-DTRACY_NO_CALLSTACK", + "-DTRACY_NO_SYSTEM_TRACING", + "-DTRACY_NO_CRASH_HANDLER", + "-DTRACY_NO_CODE_TRANSFER", + "-fno-sanitize=undefined", + }, + }); + root_mod.link_libcpp = true; + } + // tree-sitter: the zig bindings + C runtime, one static lib per grammar, // and each grammar's highlights.scm slurped at build time into the // ts_queries options module (codegen consumed by comptime in syntax.zig). diff --git a/src/pardes.zig b/src/pardes.zig index c4893b25..63943c04 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -14,6 +14,7 @@ const uucode = @import("uucode"); const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); @@ -3077,6 +3078,8 @@ pub const Pardes = struct { /// re-highlight the visible window of any file whose syntax went stale /// (edit, scroll, load) — visible-range-first so big files stay snappy fn refreshDirtyHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshDirtyHighlights"); + defer tz.end(); for (p.panes) |slot| { const pane = slot orelse continue; if (pane.file == null) continue; @@ -3226,6 +3229,8 @@ pub const Pardes = struct { } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void { + const tz = tracy.zone(@src(), "renderPane"); + defer tz.end(); if (r.w <= GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); @@ -3278,7 +3283,9 @@ pub const Pardes = struct { .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; + const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); + tz_body.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { @@ -3303,6 +3310,8 @@ pub const Pardes = struct { // tree-sitter style byte; content starts after the lineno gutter if (p.colors_on) if (pane.file) |*f| { if (f.highlights.len > 0) { + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); var flines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) { diff --git a/src/syntax.zig b/src/syntax.zig index a9914fd7..0d2fa291 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -4,6 +4,7 @@ //! compiles queries at startup; `full` adds ~23 languages lazily on first use. const std = @import("std"); const config = @import("pardes_config"); +const tracy = @import("tracy.zig"); pub const enabled = config.syntax_highlighting; const minimal_grammars = config.syntax_minimal_grammars; @@ -140,6 +141,8 @@ pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const /// One Syn byte per content byte in [start, end). Caller frees. pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { + const tz = tracy.zone(@src(), "highlightFileRange"); + defer tz.end(); if (!enabled) return &.{}; const ext = std.fs.path.extension(path); const selected = (forExt(ext) catch return &.{}) orelse return &.{}; diff --git a/src/tracy.zig b/src/tracy.zig new file mode 100644 index 00000000..3b0b8621 --- /dev/null +++ b/src/tracy.zig @@ -0,0 +1,50 @@ +//! Minimal Tracy client (the Zig compiler's pattern): `zone(@src(), "name")` +//! ... `.end()`, plus `frameMark()`. Everything compiles to a no-op unless the +//! build sets -Dtracy=, which also compiles and links +//! TracyClient.cpp. Capture with tracy-capture, inspect with tracy-csvexport. +const std = @import("std"); +const builtin = @import("builtin"); + +pub const enable = if (builtin.is_test) false else @import("pardes_config").enable_tracy; + +const SourceLocation = extern struct { + name: ?[*:0]const u8, + function: [*:0]const u8, + file: [*:0]const u8, + line: u32, + color: u32, +}; + +pub const Ctx = if (enable) extern struct { + id: u32, + active: i32, + + pub inline fn end(c: @This()) void { + ___tracy_emit_zone_end(c); + } +} else struct { + pub inline fn end(_: @This()) void {} +}; + +pub inline fn zone(comptime src: std.builtin.SourceLocation, comptime name: [:0]const u8) Ctx { + if (!enable) return .{}; + const static = struct { + const loc: SourceLocation = .{ + .name = name.ptr, + .function = src.fn_name.ptr, + .file = src.file.ptr, + .line = src.line, + .color = 0, + }; + }; + return ___tracy_emit_zone_begin(&static.loc, 1); +} + +pub inline fn frameMark() void { + if (!enable) return; + ___tracy_emit_frame_mark(null); +} + +extern fn ___tracy_emit_zone_begin(srcloc: *const SourceLocation, active: c_int) Ctx; +extern fn ___tracy_emit_zone_end(ctx: Ctx) void; +extern fn ___tracy_emit_frame_mark(name: ?[*:0]const u8) void; diff --git a/src/tty.zig b/src/tty.zig index d9821343..3290cde9 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -7,6 +7,7 @@ const posix = std.posix; const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); +const tracy = @import("tracy.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -87,61 +88,77 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var pending: ?@TypeOf(Command.value) = .tick; while (!core.quit) { - const event = if (pending) |ev| blk: { + var event = if (pending) |ev| blk: { pending = null; break :blk ev; } else try loop.nextEvent(); - switch (event) { - .nop, .tick => {}, - .quit => break, - .winsize => |ws| { - try vx.resize(gpa, tty.writer(), ws); - core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } }); - }, - .pty_read => |pr| { - core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); - gpa.free(pr.bytes); - }, - .pty_eof => |id| { - if (ptys[id]) |*pt| { - _ = linux.close(pt.file.handle); - ptys[id] = null; - } - core.update(.{ .eof = .{ .pane = @intCast(id) } }); - }, - .key_press => |key| core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = key.text orelse "", - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - } }), - .mouse => |m| { - const button: ?pardes.Mouse.Button = switch (m.button) { - .left => .left, - .middle => .middle, - .right => .right, - .wheel_up => .wheel_up, - .wheel_down => .wheel_down, - .none => .none, // button-less motion: hover tracking - else => null, - }; - if (button) |b| core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(m.col), - .row = @intCast(m.row), - } }); - }, - .paste => |bytes| { - core.update(.{ .paste = bytes }); - gpa.free(@constCast(bytes)); - }, + const tz_event = tracy.zone(@src(), "event"); + // Apply every queued INPUT event, then render ONCE — the gui shell + // drains SDL's queue the same way. Without this a wheel flick is fifty + // full render+repaint (and re-highlight) cycles instead of one. + // pty output keeps a frame per chunk: progress paints as it arrives. + var stop = false; + var batch: usize = 0; + while (true) { + var output = false; + switch (event) { + .nop, .tick => {}, + .quit => stop = true, + .winsize => |ws| { + try vx.resize(gpa, tty.writer(), ws); + core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } }); + }, + .pty_read => |pr| { + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + gpa.free(pr.bytes); + output = true; + }, + .pty_eof => |id| { + if (ptys[id]) |*pt| { + _ = linux.close(pt.file.handle); + ptys[id] = null; + } + core.update(.{ .eof = .{ .pane = @intCast(id) } }); + }, + .key_press => |key| core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + } }), + .mouse => |m| { + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .none => .none, // button-less motion: hover tracking + else => null, + }; + if (button) |b| core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + } }); + }, + .paste => |bytes| { + core.update(.{ .paste = bytes }); + gpa.free(@constCast(bytes)); + }, + } + batch += 1; + if (stop or output or batch >= 64) break; + event = (try loop.tryEvent()) orelse break; } + tz_event.end(); + if (stop) break; drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true); if (core.quit) break; @@ -158,7 +175,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // ---- render: surface -> vaxis, cell for cell ---- _ = frame_arena.reset(.retain_capacity); + const tz_core = tracy.zone(@src(), "core.render"); const surface = try core.render(frame_arena.allocator()); + tz_core.end(); + const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); win.clear(); var y: u16 = 0; @@ -173,6 +193,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { }); } } + tz_cells.end(); // pixel attachments (kitty graphics): transmit once per pane, then // re-place every frame (placements aren't persistent) for (surface.images[0..surface.nimages]) |maybe| { @@ -191,7 +212,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } } if (surface.cursor) |cur| win.showCursor(cur.x, cur.y); + const tz_render = tracy.zone(@src(), "vx.render"); try vx.render(tty.writer()); + tz_render.end(); + tracy.frameMark(); } } -- cgit v1.3