//! Debug-build log redirection. //! //! Debug builds emit a *lot* of `std.log.debug` traffic — most usefully the //! raw JSON of every provider API request and response. Writing that to stderr //! makes the interactive TUI unusable (every line scribbles over the frame). //! //! This module installs a custom `std.Options.logFn` (wired up via //! `std_options` in `main.zig`). In **Debug** builds it routes the entire log //! stream to a per-session file under //! //! ($PANTO_HOME | $XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/.log //! //! and writes *nothing* to the terminal, leaving the TUI pristine. In any //! other build mode it delegates to `std.log.defaultLog` (stderr, level-gated) //! so release behavior is unchanged. //! //! The file is opened with `O_TRUNC`, so each session starts fresh; the file //! holds exactly one session's logs and never grows across runs. (A *new* //! session id per invocation already gives a fresh file; truncation guards the //! resume case where the same id is reused.) //! //! The log function may be called from any thread (the `Io.Threaded` worker //! pool as well as the main loop), so all writes are serialized behind a //! mutex. Writes go straight to the fd via `std.c.write` — no `Io` handle is //! needed (and none is available at a `logFn` call site), matching the raw-fd //! approach already used by `tui_terminal.zig`. const std = @import("std"); const builtin = @import("builtin"); const Io = std.Io; const Allocator = std.mem.Allocator; const panto_home = @import("panto_home.zig"); /// True only in Debug builds; gates the file-redirect path at comptime. const enabled = builtin.mode == .Debug; /// A tiny atomic spinlock serializing writes. `logFn` can be called from any /// thread but has no `Io` handle, so `Io.Mutex` (which needs one) is out; /// contention is rare and each critical section is a single bounded write. var log_lock = std.atomic.Value(bool).init(false); fn lockLog() void { while (log_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) { std.atomic.spinLoopHint(); } } fn unlockLog() void { log_lock.store(false, .release); } /// The open log file descriptor, or -1 before `init` (or if init failed). var log_fd: std.c.fd_t = -1; /// Monotonic per-line counter. Wall-clock time needs an `Io` handle (not /// available at a `logFn` call site), so lines are stamped with a sequence /// number instead — enough to order events within the session. var seq: u64 = 0; /// Buffer backing the per-call `Writer`. Guarded by `log_mutex`. var writer_buf: [16 * 1024]u8 = undefined; /// Open the per-session debug log file and arm `logFn` to write to it. No-op /// in non-Debug builds. Best-effort: any failure leaves `log_fd == -1`, and /// `logFn` silently drops messages (debug logging must never break startup). /// /// Safe to call once, after the session id is known. `environ_map` is used to /// resolve `$PANTO_HOME`; `io` is used only to create the `debug/` directory. pub fn init( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, session_id: []const u8, ) void { if (!enabled) return; var layout = panto_home.resolve(allocator, environ_map) catch return; defer layout.deinit(); const debug_dir = std.fs.path.join(allocator, &.{ layout.home, "debug" }) catch return; defer allocator.free(debug_dir); Io.Dir.cwd().createDirPath(io, debug_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return, }; const file_name = std.fmt.allocPrint(allocator, "{s}.log", .{session_id}) catch return; defer allocator.free(file_name); const path = std.fs.path.joinZ(allocator, &.{ debug_dir, file_name }) catch return; defer allocator.free(path); const flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }; const fd = std.c.open(path.ptr, flags, @as(std.c.mode_t, 0o600)); if (fd < 0) return; lockLog(); defer unlockLog(); log_fd = fd; seq = 0; } /// Custom `std.Options.logFn`. Debug builds capture everything to the session /// file (terminal stays clean); other builds use the stderr default. pub fn logFn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { if (!enabled) { std.log.defaultLog(level, scope, format, args); return; } lockLog(); defer unlockLog(); const fd = log_fd; // Before the file is open (early bootstrap), drop the message rather than // letting it reach the terminal — keeping the TUI pristine is the point. if (fd < 0) return; var fw: FdWriter = .{ .fd = fd, .interface = .{ .vtable = &fd_vtable, .buffer = &writer_buf }, }; const w = &fw.interface; seq += 1; w.print("#{d:>6} {s}", .{ seq, level.asText() }) catch {}; if (scope != .default) w.print("({t})", .{scope}) catch {}; w.writeAll(": ") catch {}; w.print(format, args) catch {}; w.writeAll("\n") catch {}; w.flush() catch {}; } /// A minimal `std.Io.Writer` whose sink is a raw file descriptor. Writes are /// best-effort (logging must never fault): short writes are looped, `EINTR` is /// retried, and any other error silently drops the remaining bytes. const FdWriter = struct { fd: std.c.fd_t, interface: std.Io.Writer, }; const fd_vtable: std.Io.Writer.VTable = .{ .drain = drainFd }; fn drainFd(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { const self: *FdWriter = @alignCast(@fieldParentPtr("interface", w)); const fd = self.fd; // 1. Flush any buffered bytes first (they were logically written already). if (w.end != 0) { writeAllFd(fd, w.buffer[0..w.end]); w.end = 0; } // 2. Write each data slice in order; the last is repeated `splat` times. if (data.len == 0) return 0; var consumed: usize = 0; for (data[0 .. data.len - 1]) |slice| { writeAllFd(fd, slice); consumed += slice.len; } const last = data[data.len - 1]; var i: usize = 0; while (i < splat) : (i += 1) { writeAllFd(fd, last); consumed += last.len; } return consumed; } fn writeAllFd(fd: std.c.fd_t, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = std.c.write(fd, bytes.ptr + off, bytes.len - off); if (n < 0) { if (std.posix.errno(n) == .INTR) continue; return; // best-effort: drop the rest } if (n == 0) return; off += @intCast(n); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; test "FdWriter: buffered + splat writes reach the fd intact" { const path = "/tmp/panto_debug_log_drain_test.txt"; const wr_flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }; const fd = std.c.open(path, wr_flags, @as(std.c.mode_t, 0o600)); try testing.expect(fd >= 0); { // A deliberately tiny buffer forces several drains mid-message, // exercising the buffer-flush path and the splat repetition. var buf: [8]u8 = undefined; var fw: FdWriter = .{ .fd = fd, .interface = .{ .vtable = &fd_vtable, .buffer = &buf }, }; const w = &fw.interface; try w.print("hello {d} world {s}", .{ 42, "xyz" }); try w.splatBytesAll("ab", 3); // splat: "ababab" try w.flush(); } _ = std.c.close(fd); const rfd = std.c.open(path, std.c.O{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0)); try testing.expect(rfd >= 0); defer _ = std.c.close(rfd); var rbuf: [128]u8 = undefined; const n = std.c.read(rfd, &rbuf, rbuf.len); try testing.expect(n > 0); try testing.expectEqualStrings("hello 42 world xyzababab", rbuf[0..@intCast(n)]); }