Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204//! A copy of the log in the state directory ($XDG_STATE_HOME/parcel/log),//! for runs whose stderr goes nowhere, as from a desktop launcher. Each//! line is stamped with the time in UTC. Past about a megabyte the log//! moves to log.1, in place of the one before, and starts over, so two//! megabytes at most are kept. It is the user's alone: subjects and//! addresses can show up in it.//!//! It is written from inside std.log, which a panic can reach, so it//! takes no lock std or a panic handler takes, and none of its own: it//! uses system calls, not Io, and each line is one write(2) to a file//! opened to append. Parcel logs from the event loop's thread alone.
const std = @import("std");const linux = std.os.linux;const mem = std.mem;const posix = std.posix;const epoch = std.time.epoch;const Io = std.Io;const Level = std.log.Level;const testing = std.testing;
const LogFile = @This();
/// The state directory, to start the log over in; its owner keeps it open.dir: posix.fd_t,file: posix.fd_t,/// How long the file is, as far as this run knows.size: u64,
pub const name = "log";const old_name = "log.1";/// Past this, the log starts over.const max_bytes = 1 << 20;const mode = 0o600;
/// How long a line's stamp is: "2026-11-07T21:04:05Z ".pub const stamp_len = "YYYY-MM-DDThh:mm:ssZ ".len;const cut = "…\n";
pub const OpenError = error{ AccessDenied, ReadOnlyFileSystem, NoSpaceLeft, Unexpected };
/// Opens the log in `dir` to append to, creating it. Not posix.openat,/// which prints a stack trace for an errno it does not expect, through/// std's stderr lock.pub fn open(dir: posix.fd_t) OpenError!LogFile { const flags: linux.O = .{ .ACCMODE = .WRONLY, .CREAT = true, .APPEND = true, .CLOEXEC = true }; const rc = linux.openat(dir, name, flags, mode); const file: posix.fd_t = switch (linux.errno(rc)) { .SUCCESS => @intCast(rc), .ACCES, .PERM => return error.AccessDenied, .ROFS => return error.ReadOnlyFileSystem, .NOSPC, .DQUOT => return error.NoSpaceLeft, else => return error.Unexpected, }; // A log left with other permissions, or made under a umask that took // some away. _ = linux.fchmod(file, mode); const end = linux.lseek(file, 0, linux.SEEK.END); return .{ .dir = dir, .file = file, .size = if (linux.errno(end) == .SUCCESS) end else 0 };}
pub fn close(self: *LogFile) void { _ = linux.close(self.file); self.* = undefined;}
/// Appends `bytes`, a line or more, starting the log over first if it/// would grow past its limit. A failure goes untold: the log is where it/// would be told.pub fn write(self: *LogFile, bytes: []const u8) void { if (self.size > 0 and self.size + bytes.len > max_bytes) self.startOver(); writeAll(self.file, bytes); self.size += bytes.len;}
/// Whether a line goes in the file: warnings and errors, the sessions'/// news of the mail, such as how much they read and how far the walk/// for search has got, how a click on a notification showed the window,/// and when the reader made a thread again, which no terminal sees.pub fn keeps(comptime level: Level, comptime scope: @EnumLiteral()) bool { return switch (level) { .err, .warn => true, .info => scope == .session or scope == .jmap_session or scope == .notifier or scope == .reader or scope == .corpus or scope == .harvest, .debug => false, };}
/// A log line in `buf`, `stamp_len` bytes of the time `seconds` since/// the epoch then std's own form, "error(scope): text\n", cut short/// when it does not fit.pub fn line( buf: []u8, seconds: u64, comptime level: Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype,) []const u8 { const prefix = comptime level.asText() ++ (if (scope == .default) "" else "(" ++ @tagName(scope) ++ ")") ++ ": "; const day: epoch.EpochSeconds = .{ .secs = seconds }; const date = day.getEpochDay().calculateYearDay(); const month = date.calculateMonthDay(); const time = day.getDaySeconds(); const stamp = mem.print(buf[0..stamp_len], "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z ", .{ date.year, month.month.numeric(), month.day_index + 1, time.getHoursIntoDay(), time.getMinutesIntoHour(), time.getSecondsIntoMinute(), }); // Past the year 9999; a panic here would take the log down with it. if (stamp) |_| {} else |_| @memset(buf[0..stamp_len], '?'); var w: Io.Writer = .fixed(buf[stamp_len..]); w.print(prefix ++ format ++ "\n", args) catch { @memcpy(buf[buf.len - cut.len ..], cut); return buf; }; return buf[0 .. stamp_len + w.end];}
/// Seconds since the epoch, or 0 when the clock cannot be read.pub fn now() u64 { var ts: linux.timespec = undefined; if (linux.errno(linux.clock_gettime(.REALTIME, &ts)) != .SUCCESS) return 0; return if (ts.sec < 0) 0 else @intCast(ts.sec);}
/// Writes all of `bytes` to `fd`, as much as it takes.pub fn writeAll(fd: posix.fd_t, bytes: []const u8) void { var rest = bytes; while (rest.len > 0) { const n = linux.write(fd, rest.ptr, rest.len); switch (linux.errno(n)) { .SUCCESS => rest = rest[n..], .INTR => continue, else => return, } }}
/// Moves the log to log.1 and opens a new one; if it cannot, the old/// one goes on growing.fn startOver(self: *LogFile) void { _ = linux.renameat(self.dir, name, self.dir, old_name); const next = open(self.dir) catch return; _ = linux.close(self.file); self.* = next;}
test "a line is stamped in UTC and named for its level and scope; one too long is cut short" { var buf: [64]u8 = undefined; // 2026-11-07 21:04:05 UTC, a Saturday. const at = 1_794_085_445; try testing.expectEqualStrings( "2026-11-07T21:04:05Z error(session): loading mail: Timeout\n", line(&buf, at, .err, .session, "loading mail: {t}", .{error.Timeout}), ); try testing.expectEqualStrings("1970-01-01T00:00:00Z info: 7\n", line(&buf, 0, .info, .default, "{d}", .{7})); const xs: [80]u8 = @splat('x'); const long = line(&buf, at, .warn, .http, "{s}", .{&xs}); try testing.expectEqual(buf.len, long.len); try testing.expectStringStartsWith(long, "2026-11-07T21:04:05Z warning(http): xxx"); try testing.expectStringEndsWith(long, "x…\n");}
test "the log is the user's alone, goes on where the last run left it, and starts over past a megabyte" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = testing.io; const gpa = testing.allocator; const chunk = try gpa.alloc(u8, 600 * 1024); defer gpa.free(chunk);
@memset(chunk, 'a'); chunk[chunk.len - 1] = '\n'; var log = try open(tmp.dir.handle); log.write(chunk); log.close(); const stat = try tmp.dir.statFile(io, name, .{}); try testing.expectEqual(mode, @backingInt(stat.permissions) & 0o777);
// A second run knows how long the first left the file, so the two // chunks pass a megabyte and the second starts a new file. @memset(chunk, 'b'); chunk[chunk.len - 1] = '\n'; log = try open(tmp.dir.handle); log.write(chunk); log.write("last\n"); log.close(); const old = try tmp.dir.readFileAlloc(io, old_name, gpa, .limited(max_bytes * 2)); defer gpa.free(old); try testing.expectEqual(chunk.len, old.len); try testing.expectEqual('a', old[0]); const new = try tmp.dir.readFileAlloc(io, name, gpa, .limited(max_bytes * 2)); defer gpa.free(new); try testing.expectEqual(chunk.len + "last\n".len, new.len); try testing.expectEqual('b', new[0]); try testing.expectStringEndsWith(new, "last\n"); const restarted = try tmp.dir.statFile(io, name, .{}); try testing.expectEqual(mode, @backingInt(restarted.permissions) & 0o777);}