//! Event streams: per-pane `event` records, the editor-wide `log`, and the //! bounded queues that park a read until something happens. const std = @import("std"); const pardes = @import("../pardes.zig"); const exec = @import("../exec.zig"); const look = @import("../look.zig"); const cloud9 = @import("cloud9"); const tree = @import("tree.zig"); const ctl = @import("ctl.zig"); const pane_files = @import("pane.zig"); const dump = @import("../dump.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const MAX_PANES = pardes.MAX_PANES; const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; pub const queue_cap = 64 * 1024; /// Wall-clock seconds, or zero where the platform has no clock. pub fn now() u32 { if (comptime !pardes.hosted) return 0; var ts: std.c.timespec = undefined; if (std.c.clock_gettime(.REALTIME, &ts) != 0) return 0; return std.math.cast(u32, ts.sec) orelse 0; } pub const Queue = struct { buf: std.ArrayList(u8) = .empty, head: usize = 0, cap: usize = queue_cap, /// Records pushed out of the front to make room: the sequence number of /// the oldest one still held. dropped: u64 = 0, pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { q.buf.deinit(gpa); q.head = 0; } pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { if (record.len > std.math.maxInt(u32)) return; while (q.buf.items.len - q.head + record.len + 4 > q.cap) { if (q.peek() == null) return; q.pop(); q.dropped += 1; } q.compact(); var head: [4]u8 = undefined; std.mem.writeInt(u32, &head, @intCast(record.len), .little); q.buf.appendSlice(gpa, &head) catch return; q.buf.appendSlice(gpa, record) catch { q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); return; }; } pub fn peek(q: *const Queue) ?[]const u8 { const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; if (rest.len < 4) return null; const len = std.mem.readInt(u32, rest[0..4], .little); if (rest.len < 4 + len) return null; return rest[4 .. 4 + len]; } pub fn pop(q: *Queue) void { const record = q.peek() orelse return; q.head += 4 + record.len; if (q.head == q.buf.items.len) { q.buf.clearRetainingCapacity(); q.head = 0; } } pub fn popFront(q: *Queue, n: usize) void { const record = q.peek() orelse return; if (n >= record.len) return q.pop(); q.head += n; std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); } fn compact(q: *Queue) void { if (q.head == 0 or q.head * 2 < q.buf.items.len) return; const rest = q.buf.items.len - q.head; std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); q.buf.shrinkRetainingCapacity(rest); q.head = 0; } pub fn empty(q: *const Queue) bool { return q.peek() == null; } pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { q.buf.clearAndFree(gpa); q.head = 0; } }; /// One record per read; `.again` holds the read until a record arrives. pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; // A read shorter than the record takes its front, and the next read the // rest, as acme's event file and pty/data allow: a shell's `read` // takes a byte at a time. const n = @min(record.len, req.size); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, record[0..n]) catch return Reply.fail(req.tag, E.NOMEM); q.popFront(n); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } // ---- the editor-wide log ---- /// `unsaved`: a pane a refusal (Exit, Restore, Del, Delcol) is about, /// logged before the write's err so a script reads which, whole. pub const LogKind = enum { new, del, rename, save, unsaved }; /// A pane leaving, before its state goes with it; one never announced /// leaves silently. pub fn noteRetire(p: *Pardes, pane: *Pane) void { tree.pty.shellGone(p, pane, false); p.fs.news = true; // a read held on its event or pty/data hears it went p.fs.listeners -|= pane.fs.readers; if (pane.fs.unannounced) { pane.fs.unannounced = false; return; } // A question still standing ends with it, so a follower of its `ask` // hears the end. if (pane.prompt == .repl_choice or pane.prompt == .del_side or pane.prompt == .save) { var rec: [32]u8 = undefined; notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} -", .{pane.serial}) catch ""); } noteLog(p, .del, pane); } /// Called at the end of every update: reports the panes installed by it. pub fn announce(p: *Pardes) void { for (p.panes) |slot| { const pane = slot orelse continue; if (!pane.fs.unannounced) continue; pane.fs.unannounced = false; noteLog(p, .new, pane); // A file, PDF or image opened (a look, a Restore, a boot): first in // Recent. if (pardes.Pardes.recentPlace(pane)) |d| if (pardes.Pardes.recentKeeps(d.path)) p.recent.opened(p.recentGpa(), d.path); } } /// The panes present at boot are the log's first records: something said /// before the first update ends (an init file's notice) waits for them. fn bootFirst(p: *Pardes) void { if (newest(p) == null) announce(p); } /// Records ` `. pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void { // Nothing about a pane comes before its `new`: one not announced yet // is announced first, and a rename of it is no record, as its `new` // carries the name it has by then. if (pane.fs.unannounced and kind != .new) { if (kind == .rename) return; announce(p); } noteLogAs(p, kind, pane, pane_files.nameOf(p, pane)); } /// noteLog naming `path` rather than the pane's name: a `Save path` that /// writes a copy elsewhere is `save `. pub fn noteLogAs(p: *Pardes, kind: LogKind, pane: *Pane, path: []const u8) void { var buf: [4 * 4096 + 64]u8 = undefined; var name_buf: [4 * 4096]u8 = undefined; // As /index shows it: a newline in the name is `\n`. const name = shown(path, &name_buf); pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return); } /// Records `msg ` for what the editor said, `-` for no pane. /// Text past LoggedMessage.cap bytes is cut between words with an ellipsis, /// as an err record's reason is. pub fn noteMessage(p: *Pardes, serial: u32, said_stamped: []const u8) void { // The message row's clock (`04:39:41 dumped themes`) is not the // record's: the log has no times, and a script reads the words. const said = pardes.Messages.Message.body(said_stamped); var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined; const kept = pardes.Messages.clip(said, pardes.Messages.LoggedMessage.cap); const cut = if (kept.len < said.len) "…" else ""; bootFirst(p); pushCounting(p, (if (serial == 0) std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut }) else std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return, said); } /// Records `err : ` for a write that was refused or /// failed: through a mount a shell sees only the errno the /// kernel mapped the reply to (`Invalid argument`), and here is the reason. /// The log is the one place for it, as acme's `errors` file takes text and /// answers nothing: a per-pane readable error file would be a second. /// `dump ` when a Dump is written, `restore ` first in a /// Restore's replacement. /// `notePath` for an `err` record: the same one again straight after is /// counted, `(x2)`, as every err is. pub fn noteErrCounted(p: *Pardes, text: []const u8) void { bootFirst(p); var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return, text); } pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void { bootFirst(p); var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return); } pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno); var name: [32]u8 = undefined; var serial: []const u8 = "-"; var file: []const u8 = "os"; if (req.node & tree.sources.archive_node != 0) file = "src"; if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) { if (tree.Node.target(req.node)) |t| switch (t) { .top => |f| { file = f.fileName(); // The root's exec or look ran at the keyboard's pane: its // failure is that pane's, as a click there would be. if ((f == .exec or f == .look) and p.fs.ran_at != 0) serial = std.fmt.bufPrint(&name, "{d}", .{p.fs.ran_at}) catch "-"; }, .col => |c| { serial = "-"; file = (if (c.file == .dir) std.fmt.bufPrint(&name, "col/{d}", .{c.serial}) else std.fmt.bufPrint(&name, "col/{d}/{s}", .{ c.serial, @tagName(c.file) })) catch "col"; }, .pane => |pt| { serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-"; file = switch (pt.file) { .pty_ctl => "pty/ctl", .pty_status => "pty/status", .pty_data => "pty/data", .pty_run => "pty/run", else => pt.file.fileName(), }; }, }; } var buf: [256]u8 = undefined; // A reason that names its file already (`look: no space...` from a // look) is not prefixed twice: `err 3 look: no space...`. const reason = if (why.len > file.len + 2 and std.ascii.startsWithIgnoreCase(why, file) and std.mem.startsWith(u8, why[file.len..], ": ")) why[file.len + 2 ..] else why; // A long reason is cut between words, with an ellipsis, never mid-word. const kept = pardes.Messages.clip(reason, 200); const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < reason.len) "…" else "" }) catch return; for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') { c.* = ' '; }; pushCounting(p, record, reason); } /// Drops the newest record when it is a msg saying `text` (of any pane) /// that no follower has read: a failure its write's err record will say. pub fn dropMessage(p: *Pardes, text: []const u8) void { const last = newest(p) orelse return; if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return; const rest = last.text["msg ".len..]; const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return; if (std.mem.eql(u8, rest[sp + 1 ..], text)) p.fs.log.buf.shrinkRetainingCapacity(last.at); } /// Pushes `record`, or, when it repeats the newest record word for word, /// counts that one instead: ` (x2)`, the count being every time it /// was said. One a follower has already read is not rewritten: the repeat /// is a new line carrying the running count, `(x3)`. /// `full` is the text before any cut: two records that agree only in what /// was cut to fit are no repeat (`fs.log_full_hash`). fn pushCounting(p: *Pardes, raw: []u8, full: []const u8) void { const full_hash = std.hash.Wyhash.hash(0, full); defer p.fs.log_full_hash = full_hash; // A client retrying a write that fails the same way would fill the ring // with one line, so a repeat of the newest record is that record counted, // `(x3)`, as +Messages counts its repeats; unless a follower has read it // already and so waits on the repeat as a line of its own. var clean: [4 * 4096 + 256]u8 = undefined; const record = sanitize(raw, &clean); const last = newest(p) orelse return pushLog(p, record); var said = last.text; var times: u32 = 1; if (std.mem.endsWith(u8, said, ")")) if (std.mem.lastIndexOf(u8, said, " (x")) |x| { if (std.fmt.parseInt(u32, said[x + 3 .. said.len - 1], 10)) |n| { times = n; said = said[0..x]; } else |_| {} }; if (!std.mem.eql(u8, said, record[0 .. record.len - 1]) or p.fs.log_full_hash != full_hash) return pushLog(p, record); var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined; const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return; if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at); pushLog(p, again); } /// The newest record in the ring: where it starts, its sequence number and /// its text without the newline. fn newest(p: *Pardes) ?struct { at: usize, seq: u64, text: []const u8 } { const q = &p.fs.log; var at = q.head; var last: ?usize = null; var seq = q.dropped; while (at + 4 <= q.buf.items.len) : (seq += 1) { last = at; at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little); } const last_at = last orelse return null; const text = q.buf.items[last_at + 4 ..]; return .{ .at = last_at, .seq = seq - 1, .text = text[0 .. text.len - 1] }; } /// Whether a follower has read the record `seq`, or is part way through it. fn followerRead(p: *Pardes, seq: u64) bool { for (p.fs.opens) |o| if (o.node != 0 and o.what == .log) { const l = o.what.log; if (l.follow and (l.next > seq or (l.next == seq and l.part > 0))) return true; }; return false; } /// The log is one ring that records whether or not anyone reads it. A record /// is one line: a newline in a message or a name would read as two records. /// A record as one line of text (shown): the record's own newline, last, /// is kept. fn sanitize(record: []const u8, out: []u8) []u8 { // A newline in a message is a space (its words go on); a name's own // newline is escaped before it gets here (noteLog). const w = shownAs(record[0 .. record.len - 1], out[0 .. out.len - 1], false).len; out[w] = '\n'; return out[0 .. w + 1]; } /// `text` as one line of UTF-8 a reader can split and decode, as /log and /// /index show names: a backslash is `\\`, a newline `\n` (a name with one /// stays readable as what it is, not run into the next), and any other /// control character, DEL, C1 control (U+0080-U+009F) or byte that is not /// UTF-8 `\xNN`, so a name read there decodes to the bytes it is. `out` of /// 4 bytes a byte of `text` holds it all. pub fn shown(text: []const u8, out: []u8) []u8 { return shownAs(text, out, true); } fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { var w: usize = 0; var i: usize = 0; const body = text; while (i < body.len and w + 4 <= out.len) { const c = body[i]; // A name's own backslash is `\\`, so `\n` and `\xNN` read back as // the escapes they are, never as a name that held them. if (c == '\\' and escape_newline) { @memcpy(out[w..][0..2], "\\\\"); w += 2; i += 1; continue; } if (c == '\n' and escape_newline) { @memcpy(out[w..][0..2], "\\n"); w += 2; i += 1; continue; } if (c < ' ' or c == 0x7f) { // A name's (escape_newline) is `\xNN`, so it reads back as the // byte it was; a message's a space. if (escape_newline) { _ = std.fmt.bufPrint(out[w..], "\\x{x:0>2}", .{c}) catch break; w += 4; } else { out[w] = ' '; w += 1; } i += 1; continue; } const n = std.unicode.utf8ByteSequenceLength(c) catch 0; if (n == 0 or i + n > body.len or !std.unicode.utf8ValidateSlice(body[i .. i + n])) { _ = std.fmt.bufPrint(out[w..], "\\x{x:0>2}", .{c}) catch break; w += 4; i += 1; continue; } if (n == 2 and c == 0xC2 and body[i + 1] <= 0x9F) { if (escape_newline) { if (w + 8 > out.len) break; _ = std.fmt.bufPrint(out[w..], "\\xc2\\x{x:0>2}", .{body[i + 1]}) catch break; w += 8; i += 2; continue; } out[w] = ' '; w += 1; i += 2; continue; } if (w + n > out.len) break; @memcpy(out[w..][0..n], body[i..][0..n]); w += n; i += n; } return out[0..w]; } fn pushLog(p: *Pardes, raw: []u8) void { // A record not counted: the next one is no repeat of a counted one. p.fs.log_full_hash = 0; var clean: [4 * 4096 + 256]u8 = undefined; const record = sanitize(raw, &clean); // One record larger than the ring would push every other out and then // not fit itself; cut it to what fits instead, on a character boundary. var end = @min(record.len, p.fs.log.cap - 4) - 1; while (end > 0 and record[end] & 0xC0 == 0x80) end -= 1; record[end] = '\n'; p.fs.log.push(p.gpa, record[0 .. end + 1]); p.fs.news = true; } /// What an open of /log holds. pub const LogOpen = struct { /// The ring's text when it was opened. bytes: []const u8 = &.{}, /// This open waits for records after `bytes`, from `next`. follow: bool = false, next: u64 = 0, /// How much of record `next` a short read already took. part: u32 = 0, /// How much of `bytes` this open has read. Reads walk it as a stream once /// following and ignore their offset, since `exec 3<>log; echo follow /// >&3` leaves the shared offset past the word it wrote. pos: u64 = 0, }; /// An open freezes the ring's text, so `cat log` answers what happened lately /// and ends; `next` is where a follower picks up after it. pub fn freezeLog(p: *Pardes) !LogOpen { var text: std.ArrayList(u8) = .empty; errdefer text.deinit(p.gpa); var at = p.fs.log.head; const items = p.fs.log.buf.items; var n: u64 = 0; while (at + 4 <= items.len) : (n += 1) { const len = std.mem.readInt(u32, items[at..][0..4], .little); try text.appendSlice(p.gpa, items[at + 4 ..][0..len]); at += 4 + len; } return .{ .bytes = try text.toOwnedSlice(p.gpa), .next = p.fs.log.dropped + n }; } /// Past the frozen text, a following open reads newer records one per read, /// held until one arrives; one that fell behind the ring hears `lost N`. pub fn readLog(p: *Pardes, req: Req) Reply { const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.log; const frozen = slot.bytes; const out = p.fs.stage(p.gpa); if (!slot.follow or slot.pos < frozen.len) { // Until `follow`, the frozen text is a file like any other: `tail -n` // seeks near its end, `less` seeks back. After it, a stream. const off = @min(if (slot.follow) slot.pos else req.off, frozen.len); const len = @min(frozen.len - off, req.size); out.appendSlice(p.gpa, frozen[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); slot.pos = off + len; return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; } const q = &p.fs.log; if (slot.next < q.dropped) { out.print(p.gpa, "lost {d}\n", .{q.dropped - slot.next}) catch return Reply.fail(req.tag, E.NOMEM); slot.next = q.dropped; slot.part = 0; return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } // ponytail: walks the ring from its oldest record; it holds at most log_bytes. var at = q.head; var seq = q.dropped; while (at + 4 <= q.buf.items.len) : (seq += 1) { const len = std.mem.readInt(u32, q.buf.items[at..][0..4], .little); if (seq == slot.next) { // A short read (a shell's `read` takes a few bytes at a time) // gets the record in pieces, as pty/data does. const rest = q.buf.items[at + 4 + slot.part ..][0 .. len - slot.part]; const n = @min(rest.len, req.size); out.appendSlice(p.gpa, rest[0..n]) catch return Reply.fail(req.tag, E.NOMEM); slot.part += @intCast(n); if (slot.part == len) { slot.next += 1; slot.part = 0; } // This is the last read a Restore lets through: it takes every // whole record that fits, or they were lost with the hangup. if (p.fs.hanging_up and slot.part == 0) { at += 4 + len; while (at + 4 <= q.buf.items.len) : (slot.next += 1) { const more = std.mem.readInt(u32, q.buf.items[at..][0..4], .little); if (out.items.len + more > req.size) break; out.appendSlice(p.gpa, q.buf.items[at + 4 ..][0..more]) catch break; at += 4 + more; } } return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } at += 4 + len; } return .{ .tag = req.tag, .status = .again }; } /// `follow`, written to an open of the log, makes that open wait for newer /// records instead of ending; closing it is the way back. pub fn writeLog(p: *Pardes, req: Req) Reply { const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.log; const word = std.mem.trim(u8, req.data, " \t\r\n"); // `follow` reads what the open froze, then waits; `follow new` skips // what was there and waits for what comes after, as tail -n0 -f does. if (std.mem.eql(u8, word, "follow new")) { slot.pos = slot.bytes.len; } else if (!std.mem.eql(u8, word, "follow")) return tree.failText(req.tag, E.INVAL, "invalid write to log: it takes `follow` or `follow new`"); slot.follow = true; return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } // ---- per-pane event records ---- pub const max_record_text = 256; pub const Action = enum(u8) { body_delete = 'D', tag_delete = 'd', body_insert = 'I', tag_insert = 'i', body_look = 'L', tag_look = 'l', body_exec = 'X', tag_exec = 'x', pub fn char(a: Action) u8 { return @intFromEnum(a); } pub fn fromChar(c: u8) ?Action { return std.enums.fromInt(Action, c); } pub fn onTag(a: Action) bool { return @intFromEnum(a) >= 'a'; } }; pub const flag_builtin: u32 = 1; pub const flag_expansion: u32 = 2; pub const flag_filename: u32 = 4; pub const flag_chorded: u32 = 8; pub fn formatRecord( buf: []u8, origin: u8, action: Action, q0: u32, q1: u32, flag: u32, text: []const u8, ) []const u8 { const sent = if (text.len >= max_record_text) text[0..0] else text; return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ origin, action.char(), q0, q1, flag, sent.len, sent, }) catch buf[0..0]; } pub const Span = struct { at: u32, removed: u32, inserted: u32 }; pub fn diffSpan(old: []const u8, new: []const u8) Span { const both = @min(old.len, new.len); const stride = 64; var head: usize = 0; while (head + stride <= both and std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; while (head < both and old[head] == new[head]) head += 1; var tail: usize = 0; const rest = both - head; while (tail + stride <= rest and std.mem.eql( u8, old[old.len - tail - stride ..][0..stride], new[new.len - tail - stride ..][0..stride], )) tail += stride; while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; return .{ .at = @intCast(head), .removed = @intCast(old.len - tail - head), .inserted = @intCast(new.len - tail - head), }; } pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { const pane = p.panes[id] orelse return; if (pane.fs.readers == 0) return; const span = diffSpan(old, new); if (span.removed == 0 and span.inserted == 0) return; if (span.removed > 0) _ = noteAction( p, id, if (on_tag) .tag_delete else .body_delete, span.at, span.at + span.removed, 0, "", ); if (span.inserted > 0) _ = noteAction( p, id, if (on_tag) .tag_insert else .body_insert, span.at, span.at + span.inserted, 0, new[span.at..][0..span.inserted], ); } pub fn noteAction( p: *Pardes, id: usize, action: Action, q0: u32, q1: u32, flag: u32, text: []const u8, ) bool { const pane = p.panes[id] orelse return false; if (pane.fs.readers == 0) return false; var buf: [max_record_text + 64]u8 = undefined; const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); pane.fs.events.push(p.gpa, record); p.fs.news = true; return true; } /// A record that follows another with no place of its own, `0 0 0 /// `: a chorded exec's argument and its location (acme exec.c:182). pub fn noteFollow(p: *Pardes, id: usize, action: Action, text: []const u8) void { const pane = p.panes[id] orelse return; if (pane.fs.readers == 0) return; var buf: [max_record_text + 64]u8 = undefined; pane.fs.events.push(p.gpa, formatRecord(&buf, p.fs.origin, action, 0, 0, 0, text)); p.fs.news = true; } pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { if (id >= MAX_PANES or bytes.len == 0) return; const pf = &(p.panes[id] orelse return).fs; if (!pf.pty_reader) return; var off: usize = 0; while (off < bytes.len) { const n = @min(bytes.len - off, queue_cap / 2); pf.pty_out.push(p.gpa, bytes[off..][0..n]); off += n; } p.fs.news = true; } const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, flag: u32 = 0, text: ?[]const u8 = null }; const EventReader = struct { data: []const u8, i: usize = 0, /// A number past what a range can hold: said as such, not clamped. overflow: bool = false, fn next(er: *EventReader) ?EventRecord { if (er.i >= er.data.len) return null; var i = er.i; if (i + 2 > er.data.len) return null; const origin = er.data[i]; i += 1; const action = Action.fromChar(er.data[i]) orelse return null; i += 1; const q0 = scanNumber(er.data, &i, &er.overflow) orelse return null; const q1 = scanNumber(er.data, &i, &er.overflow) orelse return null; while (i < er.data.len and er.data[i] == ' ') i += 1; // acme takes only `origin action q0 q1`; the record whole, as read, // is taken too, so a click with no place in the text (one written // to a pane's own look or exec) can be written back and done. var text: ?[]const u8 = null; var flag: u32 = 0; if (i < er.data.len and er.data[i] != '\n') { flag = scanNumber(er.data, &i, &er.overflow) orelse return null; const n = scanNumber(er.data, &i, &er.overflow) orelse return null; if (i >= er.data.len or er.data[i] != ' ' or er.data.len - (i + 1) < n) return null; text = er.data[i + 1 ..][0..n]; i += 1 + n; } if (i >= er.data.len or er.data[i] != '\n') return null; er.i = i + 1; return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .flag = flag, .text = text }; } }; fn scanNumber(data: []const u8, i: *usize, overflow: *bool) ?u32 { while (i.* < data.len and data[i.*] == ' ') i.* += 1; const s = i.*; var n: u64 = 0; while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) { n = n * 10 + (data[i.*] - '0'); if (n > std.math.maxInt(u32)) { overflow.* = true; return null; } } if (i.* == s) return null; return @intCast(n); } /// Writing a Look or Exec record back performs the action it names. /// The word a click at `at` expands to: the file name or word run under it /// on its line (look.expandedWord), else nothing. /// An empty range written back: what a click at `at` takes, as the mouse's /// does (exec.pointerOperand). In a diff pane's body that is look.diffClick's /// answer, the line's address or its code's words. pub fn expandAt(p: *Pardes, pane: *Pane, whole: []const u8, at: usize, in_body: bool) []const u8 { const start = if (std.mem.lastIndexOfScalar(u8, whole[0..at], '\n')) |nl| nl + 1 else 0; const end = std.mem.indexOfScalarPos(u8, whole, at, '\n') orelse whole.len; var word_from: usize = 0; if (in_body) diff: { const row = std.mem.count(u8, whole[0..start], "\n"); // Only when the served line is the one the diff reads there. const lines = look.diffLines(p, pane) orelse break :diff; if (row >= lines.len or !std.mem.eql(u8, std.mem.trimEnd(u8, lines[row], "\r"), std.mem.trimEnd(u8, whole[start..end], "\r"))) break :diff; var buf: [4096 + 32]u8 = undefined; const click = look.diffClick(p, pane, row, at - start, &buf) orelse break :diff; if (click.address) |address| return p.scratch.allocator().dupe(u8, address) catch ""; word_from = click.word_from; } const line = whole[start..end]; if (at - start < word_from or word_from > line.len) return ""; const b = look.expandedWord(line[word_from..], at - start - word_from) orelse return ""; return whole[start + word_from + b.lo .. start + word_from + b.hi]; } pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { const pane0 = p.panes[id] orelse return Reply.fail(asked.tag, E.NOENT); // Its last record needs no newline, as every other line file's last // line does not (`printf 'MX0 10' > event`). var req = asked; if (req.data.len > 0 and req.data[req.data.len - 1] != '\n') { const whole = p.scratch.allocator().alloc(u8, req.data.len + 1) catch return Reply.fail(req.tag, E.NOMEM); @memcpy(whole[0..req.data.len], req.data); whole[req.data.len] = '\n'; req.data = whole; } const serial = pane0.serial; { const body = pane_files.bodyOf(pane0); const tag = pane_files.tagOf(p, pane0); var check: EventReader = .{ .data = req.data }; while (check.next()) |r| { // Who made it: E, F, K or M, as acme's records say. if (std.mem.indexOfScalar(u8, "EFKM", r.origin) == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": its origin is E, F, K or M"); switch (r.action) { .body_look, .tag_look, .body_exec, .tag_exec => {}, else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": only a look or exec (L l X x) is written back"), } const n = if (r.action.onTag()) tag.len else body.len; if (r.q0 > r.q1) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": its range ends before it starts"); // Well formed, but not a range of this text: say which. if (r.q1 > n) return tree.failText(req.tag, E.INVAL, if (r.action.onTag()) "range past end of tag" else "range past end of body"); } if (check.overflow) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": bad number: past what a range holds"); if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": a record is , a line each"); } // What a record written back runs fails the write as a click written // to exec would (a refused Del: EIO and its err), not said and lost. p.fs.capturing = true; p.fs.failure_len = 0; defer p.fs.capturing = false; var run: EventReader = .{ .data = req.data }; while (run.next()) |r| { const live = p.paneBySerial(serial) orelse break; const pane = p.panes[live].?; const whole = if (r.action.onTag()) pane_files.tagOf(p, pane) else pane_files.bodyOf(pane); const lo = @min(@as(usize, r.q0), whole.len); const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); // An empty range with no text is a click at that point: expanded as // the click would be, to the word or file name under it (acme's // expand, look.c). const said = if (r.q0 == r.q1) r.text orelse expandAt(p, pane, whole, lo, !r.action.onTag()) else whole[lo..hi]; const text = p.scratch.allocator().dupe(u8, said) catch continue; switch (r.action) { // A body's click written back is done as the click would have // been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for // it takes it. One that came from a write to exec (`F`) was // never a click, and runs as the command it was. // The chorded exec a reader was told of runs with its argument // (mouse.reportGesture), when it is that range written back. .body_exec, .tag_exec => { const pf = &pane.fs; // The two records that follow a chorded exec (its argument // and where it came from), written back after it, are its: // consumed, never run as commands of their own. if (pf.chord_follow > 0 and r.q0 == 0 and r.q1 == 0 and r.flag == 0) { pf.chord_follow -= 1; // The argument a command written back alone waited for. if (pf.chord_pending) |command| { pf.chord_pending = null; defer p.gpa.free(command); const line = exec.withArg(p, command, r.text orelse ""); if (pf.chord_pending_tag) _ = exec.execute(p, live, line) else _ = exec.executeFrom(p, live, line, true); } continue; } var arg = if (pf.chord_arg) |a| (if (pf.chord_at.q0 == r.q0 and pf.chord_at.q1 == r.q1) a else null) else null; if (r.flag & flag_chorded != 0) { // Its argument is the next record when it came in the // same write; the rest that follow are consumed. pf.chord_follow = 2; const save = run.i; if (run.next()) |following| if (following.q0 == 0 and following.q1 == 0 and following.flag == 0) { if (following.text) |t| arg = p.scratch.allocator().dupe(u8, t) catch arg; pf.chord_follow = 1; } else { run.i = save; }; // Alone, with no argument kept: it waits for its own. if (arg == null) { if (pf.chord_pending) |old| p.gpa.free(old); pf.chord_pending = p.gpa.dupe(u8, text) catch null; pf.chord_pending_tag = r.action == .tag_exec; continue; } } if (r.action == .tag_exec) _ = exec.execute(p, live, exec.withArg(p, text, arg)) else _ = exec.executeFrom(p, live, exec.withArg(p, text, arg), r.origin != 'F'); if (arg != null) if (p.paneBySerial(serial)) |still| { const spf = &p.panes[still].?.fs; if (spf.chord_arg) |a| p.gpa.free(a); spf.chord_arg = null; }; }, // What it found is what the look file reads back next, as a // look written there would leave (ctl.run). .body_look, .tag_look => { p.look_missed = false; look.lookAt(p, live, text); p.fs.results_len = 0; if (!p.look_missed) if (p.panes[p.active]) |found| { p.fs.results[0] = found.serial; p.fs.results_len = 1; }; }, else => unreachable, } } if (p.fs.failure_len > 0) { const failure = p.fs.failure[0..p.fs.failure_len]; return tree.failText(req.tag, ctl.failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); } return .{ .tag = req.tag, .written = @intCast(asked.data.len) }; } // ---- tests ---- const testing = std.testing; const th = @import("testing.zig"); const call = th.call; const rd = th.rd; const wr = th.wr; const withFile = th.withFile; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; test "a stream read at an offset inside its last answer gets the rest of it again, as bash's read seeks back" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); p.update(.tick); while (p.nextEffect()) |_| {} const log = @intFromEnum(tree.TopFile.log); const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const frozen: u64 = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len; _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); notePath(p, "dump", "/a"); notePath(p, "dump", "/b"); const Read = struct { fn at(pp: *Pardes, h: u32, off: u64) []const u8 { return call(pp, .{ .tag = 4, .op = .read, .node = @intFromEnum(tree.TopFile.log), .handle = h, .off = off, .size = 128 }).bytes; } }; try testing.expectEqualStrings("dump /a\n", Read.at(p, f, frozen)); // bash kept "dump" and seeked back to just past it. try testing.expectEqualStrings(" /a\n", Read.at(p, f, frozen + 4)); // At the end of what it was given: the next record. try testing.expectEqualStrings("dump /b\n", Read.at(p, f, frozen + 8)); // A client that reads every record at one offset still gets new ones. notePath(p, "dump", "/c"); try testing.expectEqualStrings("dump /c\n", Read.at(p, f, frozen + 8)); } test "event records are acme's bytes, one per read, and .again when empty" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg fs-ran\n"); defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); try testing.expect(p.panes[0].?.fs.events.empty()); const h = call(p, .{ .tag = 10, .op = .open, .node = event }); try testing.expect(h.reply.handle != 0); try testing.expectEqual(@as(u16, 1), p.fs.listeners); try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); p.fs.origin = 'M'; _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); // A short read takes the record's front; the next, the rest. try testing.expectEqualStrings("ML0 ", rd(p, event, 0, 4).bytes); try testing.expectEqualStrings("3 4 3 one\n", rd(p, event, 0, 4096).bytes); const big = "z" ** max_record_text; _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); try testing.expectEqual(@as(u16, 0), p.fs.listeners); } fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { var used: usize = 0; var n: usize = 0; while (n < out.len) { const a = rd(p, node, 0, 4096); if (a.reply.status != .ok) break; @memcpy(store[used..][0..a.bytes.len], a.bytes); out[n] = store[used..][0..a.bytes.len]; used += a.bytes.len; n += 1; } return out[0..n]; } test "a write through the filesystem is reported once, attributed to the file it came through" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); _ = call(p, .{ .tag = 40, .op = .open, .node = event }); var store: [4096]u8 = undefined; var slots: [16][]const u8 = undefined; _ = drainEvents(p, event, &store, &slots); _ = wr(p, Node.of(serial, .body), "three\n"); const body_recs = drainEvents(p, event, &store, &slots); try testing.expect(body_recs.len >= 1); try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); for (body_recs[1..]) |r| { try testing.expectEqual(@as(u8, 'E'), r[0]); try testing.expect(Action.fromChar(r[1]).?.onTag()); } _ = wr(p, Node.of(serial, .addr), "1"); _ = wr(p, Node.of(serial, .data), "ONE\n"); const data_recs = drainEvents(p, event, &store, &slots); try testing.expectEqual(@as(usize, 2), data_recs.len); try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); } test "one event reader at a time, and a writer still holds the pane" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const event = Node.of(serialOf(p), .event); const reader = call(p, .{ .tag = 12, .op = .open, .node = event }); try testing.expectEqual(Status.ok, reader.reply.status); // Reads consume, so a second reader would see half the clicks: refused. const second = call(p, .{ .tag = 13, .op = .open, .node = event, .omode = 2 }); try testing.expectEqual(Status.err, second.reply.status); try testing.expectEqualStrings(tree.e_in_use, second.reply.ename); const writer = call(p, .{ .tag = 13, .op = .open, .node = event, .omode = 1 }); try testing.expectEqual(Status.ok, writer.reply.status); try testing.expectEqual(@as(u16, 2), p.panes[0].?.fs.readers); try testing.expectEqual(@as(u16, 2), p.fs.listeners); _ = call(p, .{ .tag = 14, .op = .release, .node = event, .handle = writer.reply.handle }); try testing.expectEqual(@as(u16, 1), p.panes[0].?.fs.readers); try testing.expect(p.panes[0].?.fs.readers != 0); _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); try testing.expectEqual(@as(u16, 1), p.fs.listeners); _ = call(p, .{ .tag = 16, .op = .release, .node = event, .handle = reader.reply.handle }); try testing.expectEqual(@as(u16, 0), p.fs.listeners); try testing.expect(p.panes[0].?.fs.readers == 0); _ = call(p, .{ .tag = 17, .op = .release, .node = event }); try testing.expectEqual(@as(u16, 0), p.fs.listeners); // Once it closed, the next reader gets in. const next = call(p, .{ .tag = 18, .op = .open, .node = event }); try testing.expectEqual(Status.ok, next.reply.status); _ = call(p, .{ .tag = 19, .op = .release, .node = event, .handle = next.reply.handle }); } test "an event write-back refuses an origin not E, F, K or M, a number past a range's, and a range past the end, EINVAL" { const p = try withFile(testing.allocator, "hello world\n"); defer p.deinit(); const event = Node.of(serialOf(p), .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); for ([_][2][]const u8{ .{ "QX0 1\n", "its origin is E, F, K or M" }, .{ "MX18446744073709551616 1\n", "bad number" }, .{ "MX99999 99999\n", "range past end of body" }, }) |c| { const r = wr(p, event, c[0]); try testing.expectEqual(E.INVAL, r.errno()); try testing.expect(std.mem.indexOf(u8, r.reply.ename, c[1]) != null); } } test "an event record read a byte at a time arrives whole, in order, as a shell's read takes it" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); _ = wr(p, Node.of(serial, .exec), "Msg piecewise\n"); _ = wr(p, Node.of(serial, .exec), "Msg second\n"); var got: [128]u8 = undefined; var n: usize = 0; while (n < got.len) { const piece = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .off = n, .size = 1 }); if (piece.reply.status != .ok or piece.bytes.len == 0) break; got[n] = piece.bytes[0]; n += 1; if (std.mem.count(u8, got[0..n], "\n") == 2) break; } try testing.expectEqualStrings("FX0 0 1 13 Msg piecewise\nFX0 0 1 10 Msg second\n", got[0..n]); } test "a pane deleted while its event file is open leaves no suppression behind" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); _ = try th.newPane(p); const a = call(p, .{ .tag = 18, .op = .open, .node = event }); const b = call(p, .{ .tag = 19, .op = .open, .node = event, .omode = 1 }); try testing.expectEqual(@as(u16, 2), p.fs.listeners); // Through ctl: a line written to its exec would go to the reader. _ = wr(p, Node.of(serial, .ctl), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); // The reader's next read is its end: no more records, no error. const shut = call(p, .{ .tag = 19, .op = .read, .node = event, .handle = a.reply.handle, .size = 64 }); try testing.expectEqual(tree.Status.ok, shut.reply.status); try testing.expectEqualStrings("", shut.bytes); _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); try testing.expectEqual(@as(u16, 0), p.fs.listeners); try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .dirty), "0\n").errno()); try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); } test "writing an event record back performs the action it names" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg fs-ran\n"); defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); const pane = p.panes[0].?; const w = wr(p, event, "FX0 10\n"); try testing.expectEqual(Status.ok, w.reply.status); try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); pane.msg_len = 0; try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); // The last record needs no newline, as any line file's last line. pane.msg_len = 0; const bare = wr(p, event, "FX0 10"); try testing.expectEqual(Status.ok, bare.reply.status); try testing.expectEqual(@as(u32, 6), bare.reply.written); try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); pane.msg_len = 0; for ([_][]const u8{ "FX0 10\nFQ0 1\n", // unknown type character "FX0 999\n", // out of range "FX5 1\n", // q0 > q1 "FD0 3\n", // a report, not a request "F\n", }) |bad| { try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); try testing.expectEqual(@as(usize, 0), pane.msg_len); } // A look written back sets what look reads back. _ = wr(p, event, "FL0 3\n"); var want_serial: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want_serial, "{d}\n", .{serial}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes); // Well formed but past the text: said so, not "bad event syntax". try testing.expectEqualStrings("range past end of body", wr(p, event, "MX0 999\n").reply.ename); _ = call(p, .{ .tag = 23, .op = .open, .node = event }); p.fs.origin = 'K'; _ = wr(p, event, "KX0 10\n"); try testing.expectEqual(@as(u8, 'F'), p.fs.origin); } test "the log records whether or not anyone reads, and an open that follows waits" { const gpa = testing.allocator; const p = try withFile(gpa, "logged\n"); defer p.deinit(); const log = @intFromEnum(tree.TopFile.log); var expected: [4200]u8 = undefined; // Nobody holds /log open, and the pane is recorded anyway. const early = try th.newPane(p); const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p, p.panes[p.paneBySerial(early).?].?) }); const early_owned = try gpa.dupe(u8, early_line); defer gpa.free(early_owned); // A plain open answers what happened so far, and ends: `cat log` never hangs. const plain = call(p, .{ .tag = 1, .op = .open, .node = log }); try testing.expectEqual(Status.ok, plain.reply.status); const first = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = plain.reply.handle, .size = 8192 }); try testing.expect(std.mem.endsWith(u8, first.bytes, early_owned)); const past = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len, .size = 8192 }); try testing.expectEqual(Status.ok, past.reply.status); try testing.expectEqual(@as(usize, 0), past.bytes.len); const tail = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len - early_owned.len, .size = 8192 }); try testing.expectEqualStrings(early_owned, tail.bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = log, .handle = plain.reply.handle }); // `follow` on an open makes it wait past what it froze, one record a read. const f = call(p, .{ .tag = 5, .op = .open, .node = log }); const fh = f.reply.handle; const frozen = call(p, .{ .tag = 6, .op = .read, .node = log, .handle = fh, .size = 8192 }).bytes.len; try testing.expectEqual(Status.err, call(p, .{ .tag = 7, .op = .write, .node = log, .handle = fh, .data = "tail\n" }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = log, .handle = fh, .data = "follow\n" }).reply.status); // A stream: after `echo follow >&3` a shell reads at offset 7, and gets // what comes next, not the frozen text from byte 7. Before `follow` the // offset is honoured, so `tail -n` works (checked below on `plain`). const rdf = struct { fn next(pp: *Pardes, node: u64, h: u32, off: u64) th.Answer { return call(pp, .{ .tag = 9, .op = .read, .node = node, .handle = h, .off = off, .size = 8192 }); } }; // The word it refused is on record, and then there is nothing new. try testing.expect(std.mem.startsWith(u8, rdf.next(p, log, fh, frozen).bytes, "err - log: ")); try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status); const serial = try th.newPane(p); const id = p.paneBySerial(serial).?; try testing.expectEqualStrings( try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p, p.panes[id].?) }), rdf.next(p, log, fh, frozen).bytes, ); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status); try testing.expectEqualStrings( try std.fmt.bufPrint(&expected, "rename {d} /tmp/logged.txt\n", .{serial}), rdf.next(p, log, fh, frozen).bytes, ); // What the editor says is in the same stream, one line even when the // message had several. p.setMessage(id, "save: two\nlines"); try testing.expectEqualStrings( try std.fmt.bufPrint(&expected, "msg {d} save: two lines\n", .{serial}), rdf.next(p, log, fh, frozen).bytes, ); // A builtin announces itself on the message row only: the log has // what it did. try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status); try testing.expectEqualStrings( try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}), rdf.next(p, log, fh, frozen).bytes, ); try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status); // A shell's `read` asks for a few bytes at a time: the record comes in // pieces rather than failing. A repeat the follower waits on, having // read the first, is its own line. const said_by = p.panes[0].?.serial; p.setMessage(0, "same failure"); var pieced: [64]u8 = undefined; var got: usize = 0; while (got == 0 or pieced[got - 1] != '\n') { const piece = call(p, .{ .tag = 9, .op = .read, .node = log, .handle = fh, .off = frozen, .size = 3 }).bytes; @memcpy(pieced[got..][0..piece.len], piece); got += piece.len; } try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), pieced[0..got]); p.setMessage(0, "same failure"); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure (x2)\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes); // A follower the ring outran hears how much it missed, then carries on. var filler: [200]u8 = @splat('x'); for (0..p.fs.log.cap / filler.len + 8) |i| { _ = std.fmt.bufPrint(&filler, "{d:0>6}", .{i}) catch unreachable; // a repeat would collapse p.setMessage(0, &filler); } const lost = rdf.next(p, log, fh, frozen).bytes; try testing.expect(std.mem.startsWith(u8, lost, "lost ")); try testing.expect(std.mem.startsWith(u8, rdf.next(p, log, fh, frozen).bytes, "msg ")); _ = call(p, .{ .tag = 10, .op = .release, .node = log, .handle = fh }); for (p.fs.opens) |o| try testing.expect(o.node == 0); // A record bigger than the whole ring is cut to fit, not dropped with // everything else pushed out ahead of it. p.fs.log.cap = 128; var huge: [300]u8 = @splat('y'); p.setMessage(0, &huge); const cut = call(p, .{ .tag = 11, .op = .open, .node = log }); const kept = call(p, .{ .tag = 12, .op = .read, .node = log, .handle = cut.reply.handle, .size = 512 }).bytes; try testing.expectEqual(@as(usize, 124), kept.len); try testing.expect(std.mem.startsWith(u8, kept, "msg ") and kept[kept.len - 1] == '\n'); _ = call(p, .{ .tag = 13, .op = .release, .node = log, .handle = cut.reply.handle }); } test "a refused or failed write is an err record in the log, saying which file and why" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); p.update(.tick); // the pane's `new` is logged before the log is opened while (p.nextEffect()) |_| {} const serial = serialOf(p); const log = @intFromEnum(tree.TopFile.log); const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len; _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); var want: [128]u8 = undefined; const next = struct { fn record(pp: *Pardes, h: u32, off: usize) []const u8 { return call(pp, .{ .tag = 4, .op = .read, .node = @intFromEnum(tree.TopFile.log), .handle = h, .off = off, .size = 4096 }).bytes; } }; _ = wr(p, Node.of(serial, .addr), "/nowhere/"); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen)); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus"); try testing.expectEqualStrings("err - ctl: unknown control message \"Bogus\"\n", next.record(p, f, frozen)); _ = wr(p, @intFromEnum(tree.TopFile.focus), "99999"); try testing.expectEqualStrings("err - focus: no such pane\n", next.record(p, f, frozen)); // A write that succeeds says nothing. _ = wr(p, Node.of(serial, .addr), "#0"); try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status); // A follower that has read an err waits on its repeat: a line of its // own, with the running count. _ = wr(p, Node.of(serial, .addr), "/nowhere/"); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen)); _ = wr(p, Node.of(serial, .addr), "/nowhere/"); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp (x2)\n", .{serial}), next.record(p, f, frozen)); _ = call(p, .{ .tag = 6, .op = .release, .node = log, .handle = f }); // With the follower gone, repeats are the newest record counted on: // its two, and three more. for (0..3) |_| _ = wr(p, Node.of(serial, .addr), "/elsewhere/"); const g = call(p, .{ .tag = 7, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 8, .op = .read, .node = log, .handle = g, .size = 1 << 16 }).bytes; try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x5)\n", .{serial}))); _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g }); } test "a Dump written and a Restore made are in the log, with their files" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); p.setLastDump("/tmp/pardes.dump.zon"); try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); defer restored.deinit(); for ([_]*Pardes{ p, restored }, [_][]const u8{ "dump", "restore" }) |core, what| { const log = try freezeLog(core); defer core.gpa.free(log.bytes); var want: [64]u8 = undefined; try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what})) != null); } // The replacement's last says which serial each pane had. const log = try freezeLog(restored); defer restored.gpa.free(log.bytes); var want: [64]u8 = undefined; try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })) != null); // ...and each column's: restoredcol . try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restoredcol {d} {d}\n", .{ pardes.layout.columnSerial(p, 0), pardes.layout.columnSerial(restored, 0) }))); } test "a Restore gives columns fresh serials, past every serial the old session handed out" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const first = pardes.layout.columnSerial(p, 0); // Serials spent on columns since deleted. p.next_column_serial += 5; const spent = p.next_column_serial; try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); defer restored.deinit(); for (0..restored.ncol) |c| try testing.expect(pardes.layout.columnSerial(restored, c) >= spent); try testing.expect(pardes.layout.columnSerial(restored, 0) != first); } test "a pane named before it is announced has its new under that name, and no rename before it" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const pane = p.panes[0].?; // As a pane Config makes and names in one update. pane.fs.unannounced = true; try pane_files.nameBuffer(p, 0, "/tmp/named-first.txt", false); announce(p); const log = try freezeLog(p); defer p.gpa.free(log.bytes); var want: [64]u8 = undefined; try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} /tmp/named-first.txt\n", .{pane.serial}))); try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{pane.serial})) == null); // A shell given no directory is named by the session's from the first, // and saying it is there is no rename. const free = p.freeSlot().?; const shell = try p.newShell(free, ""); try testing.expectEqualStrings(p.sessionDir(), pane_files.nameOf(p, shell)); announce(p); p.setCwd(free, p.sessionDir()); const after = try freezeLog(p); defer p.gpa.free(after.bytes); try testing.expect(std.mem.indexOf(u8, after.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{shell.serial})) == null); } test "an empty range written back is expanded as a click at that point would be" { const p = try withFile(testing.allocator, "abc def abc\nx Newcol y\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[p.paneBySerial(serial).?].?; const event = Node.of(serial, .event); // A look at 1 is a look at `abc`: from dot, past the first, the next. pane_files.setDot(pane, .{ .q0 = 4, .q1 = 4 }); try testing.expectEqual(Status.ok, wr(p, event, "ML1 1\n").reply.status); try testing.expectEqual(pane_files.State.Range{ .q0 = 8, .q1 = 11 }, pane_files.dotOf(pane)); // An exec at 15 runs the word there, `Newcol`. const cols = p.ncol; try testing.expectEqual(Status.ok, wr(p, event, "MX15 15\n").reply.status); try testing.expectEqual(cols + 1, p.ncol); } test "opens of the log share the open records, and a closed one frees its record" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const log = @intFromEnum(tree.TopFile.log); var handles: [tree.open_slots]u32 = undefined; for (&handles) |*h| h.* = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = log }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = handles[5] }); const again = call(p, .{ .tag = 4, .op = .open, .node = log }); try testing.expectEqual(Status.ok, again.reply.status); handles[5] = again.reply.handle; for (handles) |h| _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h }); for (p.fs.opens) |o| try testing.expect(o.node == 0); } test "a click in the body reports the offsets of the word it takes, so the record can be written back" { const config = @import("../config.zig"); const gpa = testing.allocator; const text = "alpha qty beta\n qty\n\tqty\ndef f(qty): return qty.x\n" ++ "w" ** 100 ++ " qty\n"; const p = try withFile(gpa, text); defer p.deinit(); // A scratch made over 9P, clicked while another pane has the keyboard. const scratch = try th.newPane(p); _ = wr(p, Node.of(scratch, .body), text); for ([_]u32{ serialOf(p), scratch }) |serial| { const id = p.paneBySerial(serial).?; p.active = p.paneBySerial(if (serial == scratch) serialOf(p) else scratch).?; const event = Node.of(serial, .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[id]; const x0 = rect.x + config.GUTTER + config.PREFIX_W; const y0 = if (p.settings.tag_bottom) rect.y else rect.y + p.panes[id].?.tag_rows; const tab: u16 = config.tab_width; const Click = struct { x: u16, y: u16, look: []const u8, exec: []const u8, scroll: i32 = 0 }; // Where the wrapped line's `qty` landed, as drawn. const surface = try p.render(frame.allocator()); var wrap_col: u16 = 0; while (wrap_col < rect.w and !std.mem.eql(u8, surface.at(x0 + wrap_col, y0 + 5).grapheme(), "q")) wrap_col += 1; for ([_]Click{ .{ .x = x0 + 7, .y = y0, .look = "ML6 9 0 3 qty\n", .exec = "MX6 9 0 3 qty\n" }, .{ .x = x0 + 5, .y = y0 + 1, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n" }, .{ .x = x0 + tab + 1, .y = y0 + 2, .look = "ML24 27 0 3 qty\n", .exec = "MX24 27 0 3 qty\n" }, .{ .x = x0 + 7, .y = y0 + 3, .look = "ML34 37 0 3 qty\n", .exec = "MX34 37 0 3 qty\n" }, // Scrolled a line, the first row shows the second. .{ .x = x0 + 5, .y = y0, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n", .scroll = 1 }, // Wrapped: the word on the continuation row. .{ .x = x0 + wrap_col, .y = y0 + 5, .look = "ML154 157 0 3 qty\n", .exec = "MX154 157 0 3 qty\n" }, }) |c| for ([_]pardes.Mouse.Button{ config.look_button, config.exec_button }, [_][]const u8{ c.look, c.exec }) |button, want| { // The pointer rests on the word first, as a hand's does, and // the hover preview comes up under it. p.panes[id].?.scrollBy(c.scroll); _ = try p.render(frame.allocator()); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = c.x, .row = c.y } }); for (0..200) |_| p.update(.tick); p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = c.x, .row = c.y } }); p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = c.x, .row = c.y } }); try testing.expectEqualStrings(want, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); p.panes[id].?.scrollBy(-c.scroll); }; // A click on nothing expands to nothing, and acme then says // nothing (look.c:37, exec.c:141). p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x0 + 60, .row = y0 + 1 } }); p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x0 + 60, .row = y0 + 1 } }); try testing.expectEqual(Status.again, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).reply.status); _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); } } test "follow new skips what the log held and waits for what comes after" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); p.update(.tick); // the pane's `new` is logged first while (p.nextEffect()) |_| {} p.setMessage(0, "before the open"); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = log, .handle = h, .data = "follow new\n" }).reply.status); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = log, .handle = h, .size = 4096 }).reply.status); p.setMessage(0, "after it"); try testing.expect(std.mem.endsWith(u8, call(p, .{ .tag = 4, .op = .read, .node = log, .handle = h, .size = 4096 }).bytes, " after it\n")); _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h }); } test "a long msg record is cut between words at its cap, with an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const long = "word " ** 60; // 300 bytes _ = wr(p, Node.of(serialOf(p), .ctl), "Msg " ++ long ++ "\n"); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; const at = std.mem.lastIndexOf(u8, text, "msg ").?; const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1]; try testing.expect(std.mem.endsWith(u8, record, " word…\n")); try testing.expect(record.len <= "msg 1 ".len + pardes.Messages.LoggedMessage.cap + "…\n".len); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are escaped" { var out: [64]u8 = undefined; var name: [64]u8 = undefined; try testing.expectEqualStrings("/tmp/two\\nlines", shown("/tmp/two\nlines", &name)); // A backslash of the name's own is doubled: `\n` in a name differs from // a newline in one. try testing.expectEqualStrings("/tmp/a\\\\nb", shown("/tmp/a\\nb", &name)); try testing.expectEqualStrings("msg - a b c \\xff d\n", sanitize("msg - a\x7fb\xc2\x85c \xff d\n", &out)); try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out)); } test "a long err record keeps its end, its middle given up to an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus" ++ "abcdefgh" ** 40 ++ "\n"); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?; const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1]; try testing.expect(std.mem.indexOf(u8, record, "…") != null); try testing.expect(std.mem.startsWith(u8, record, "err - ctl: ")); try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } test "two failures that agree only in what their records cut are two records, not one counted" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const long = "why " ** 80; p.setMessage(0, long ++ "first"); p.setMessage(0, long ++ "second"); try testing.expect(!th.logHas(p, "(x2)")); p.setMessage(0, long ++ "second"); try testing.expect(th.logHas(p, "(x2)")); } test "a message the host stamped with its clock is recorded without it" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); noteMessage(p, 7, "04:39:41 dumped themes /tmp/t"); try testing.expect(th.logHas(p, "msg 7 dumped themes /tmp/t\n")); }