From 406f483288c7689bdd2e1f3f68b276c61ca9412a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 20:50:48 -0300 Subject: An Edit's held write: < | > commands no longer block their 9P connection (answered like a held read, so a status read or a filter reading the session's own mount runs beside it), a Tflush or hang-up of the write kills the commands' process groups and changes nothing; e loads by Get's way (asked once, clean after, undo puts the name back); ~ in e r w f B; B checks every name first; an Edit that runs commands is a write of its own, refused up front with other lines; Edit's +Errors output keeps the keyboard where it was; X goes in pane order; a refused write open says its errno; the reference's Coming from acme rows say what Get file's undo, failure and directory refusal, Putall's answer, Zerox across Dump and Incl's directories now do Co-Authored-By: Claude Opus 5.5 --- src/9p_io.zig | 73 ++++++++++++++-- src/Pipe.zig | 6 +- src/edit_cmd.zig | 225 +++++++++++++++++++++++++++++++++++++++---------- src/fs.zig | 65 +++++++++++++- src/ninep/ctl.zig | 27 ++++-- src/pardes.zig | 20 ++--- src/sam_edit.zig | 138 ++++++++++++++++++++++++------ src/selection_pipe.zig | 120 +++++++++++++++++++++++++- 8 files changed, 568 insertions(+), 106 deletions(-) (limited to 'src') diff --git a/src/9p_io.zig b/src/9p_io.zig index 46bbb2ba..a01eb491 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -180,6 +180,10 @@ pub const Listener = struct { restore_writer: std.atomic.Value(bool) = .init(false), /// Clients turned away with every slot taken, since the log last said so. refused: std.atomic.Value(u32) = .init(0), + /// Writes being served right now, a retry of a parked one included: a + /// held Edit write a retry has taken out of its park is not flushed + /// (watchEdit). + writes_out: std.atomic.Value(u32) = .init(0), /// Connections accepted so far, and the count each slot's connection /// was accepted at: a Restore cuts those accepted before it (`reset`), /// and a new client in a freed slot has a later stamp. Written on the @@ -241,6 +245,10 @@ pub const Listener = struct { fn onServe(ctx: ?*anyopaque, conn: *Runner.Conn, req: pardes.ctlfs.Req) void { const l = of(ctx); + if (req.op == .write) _ = l.writes_out.fetchAdd(1, .acq_rel); + defer if (req.op == .write) { + _ = l.writes_out.fetchSub(1, .acq_rel); + }; // Once `stop` has begun the editor is tearing down and may never // rest again: answer without waiting on it. if (l.stopping.load(.acquire)) { @@ -268,9 +276,11 @@ pub const Listener = struct { const changes = pardes.ctlfs.changesPane(core, req); const fills = pardes.ctlfs.handsOff(core, req); const reply = core.serveFs(req); - // Taken now: a request served while this one waits starts afresh. - const edit_at = core.fs.edit_answer_at; - core.fs.edit_answer_at = null; + const edit_started = core.fs.edit_started; + core.fs.edit_started = false; + // The held Edit write asked again by a retry, its Edit still + // running: parked again, as it was. + if (reply.status == .again and req.op == .write) return conn.reply(&reply, ""); if (req.op == .release and quiet) l.collectOs(); // A close's held last line runs now when no step is out, so its err // is logged before the close is answered, as before; with a step @@ -335,6 +345,19 @@ pub const Listener = struct { // is on its way out and will settle nothing this could wait for, // and `deinit` lets the answer out before it cuts the connections. if (core.quit) return conn.reply(&reply, ""); + // An Edit whose `<`, `|` or `>` commands run off the loop: the write + // is held, as a read with nothing yet is, and answered when the Edit + // is done (answerHeld), so the connection serves on meanwhile -- a + // filter that reads this session through a mount among what it + // serves. A flush of it, or a hang-up, stops the Edit (watchEdit). + if (edit_started and core.pipe.edit_run != null) { + const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again }; + const ticket = conn.hold(&later) orelse return; + core.fs.edit_hold = .{ .asker = conn, .slot = ticket.slot, .seq = ticket.seq, .tag = req.tag, .node = req.node, .handle = req.handle, .written = reply.written }; + const watcher = std.Thread.spawn(.{}, watchEdit, .{ l, conn, ticket }) catch return; + watcher.detach(); + return; + } const restoring = core.restore_req != null; // Set with the turn still held, so `reset` sees it and waits. if (restoring) l.restore_writer.store(true, .release); @@ -360,10 +383,6 @@ pub const Listener = struct { } }; } - // An Edit whose `<`, `|` or `>` commands run off the loop: the write - // is answered once they have and the Edit is applied, or failed - // (late_failure), as `echo Edit , | sort > ctl` must return sorted. - if (edit_at) |n| if (l.core == core) pardes.turn.awaitEdit(n); core.fs.write_waits = false; // ponytail: one slot, so a failure of another client's effects that // settle in the same wait is told to this write too. @@ -418,6 +437,46 @@ pub const Listener = struct { const conn: *Runner.Conn = @ptrCast(@alignCast(held.asker)); if (!conn.answerWith(held.ticket, Held{ .core = core, .req = held.req }, Held.make)) o.held = null; } + // An Edit's held write, its Edit done. Not waiting: a retry has it + // out, and its asking answers it (Pardes.serveFs); or it was + // flushed, and watchEdit lets the record go. + if (core.fs.edit_hold) |h| if (h.done) { + const conn: *Runner.Conn = @ptrCast(@alignCast(h.asker)); + _ = conn.answerWith(.{ .slot = h.slot, .seq = h.seq }, core, struct { + fn make(c: *pardes.Pardes) ?Runner.Conn.Answer { + return .{ .reply = pardes.filesystem.EditHold.answer(&c.fs) }; + } + }.make); + }; + } + + /// While an Edit's write is held: when nobody waits for it any more -- + /// flushed (an interrupted writer), its fid clunked, its connection + /// hung up -- the Edit is stopped, its commands killed, nothing + /// changed (edit_cmd.cancel). The engine tells the backend nothing of a + /// flush, so it is looked for, every 100 ms; a park out for a retry is + /// not waiting either, so only three looks in a row with no write being + /// served count. + fn watchEdit(l: *Listener, conn: *Runner.Conn, ticket: cloud9.fs.Ticket) void { + const io = pardes.turn.io orelse return; + var misses: u8 = 0; + while (!l.stopping.load(.acquire)) { + io.sleep(.fromMilliseconds(100), .awake) catch return; + _ = pardes.turn.take(); + defer pardes.turn.give(); + const core = l.core; + const h = core.fs.edit_hold orelse return; + if (h.slot != ticket.slot or h.seq != ticket.seq or h.asker != @as(*anyopaque, conn)) return; + if (conn.waiting(ticket) or l.writes_out.load(.acquire) != 0) { + misses = 0; + continue; + } + misses += 1; + if (misses < 3) continue; + if (h.done) core.fs.edit_hold = null else @import("edit_cmd.zig").cancel(core); + l.kick(); + return; + } } /// A held read asked again, to make its answer while its park waits. diff --git a/src/Pipe.zig b/src/Pipe.zig index 6981c023..850f9c64 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -26,11 +26,7 @@ writes: std.ArrayList(edit_cmd.Write) = .empty, pub fn deinit(pipe: *Pipe, gpa: std.mem.Allocator) void { if (pipe.wait) |*wait| wait.deinit(gpa); - if (pipe.edit_run) |pending| { - pending.deinit(gpa); - // A 9P write waiting on it is answered (the core is going). - pardes.turn.editAnswered(); - } + if (pipe.edit_run) |pending| pending.deinit(gpa); for (pipe.writes.items) |*w| w.deinit(gpa); pipe.writes.deinit(gpa); } diff --git a/src/edit_cmd.zig b/src/edit_cmd.zig index 7f5c6279..4e2c09fd 100644 --- a/src/edit_cmd.zig +++ b/src/edit_cmd.zig @@ -11,7 +11,8 @@ //! Shell, and the Edit is applied when they have all answered -- or not at //! all, when one fails or a file it changes was edited meanwhile. One Edit //! waits at a time; a 9P write that ran it is answered when it is done -//! (9p_io, `fs.edit_answer_at`). +//! (9p_io, `fs.edit_hold`), the connection serving on meanwhile; a flush +//! of that write stops the commands and changes nothing. const std = @import("std"); const pardes = @import("pardes.zig"); const sam = @import("sam_edit.zig"); @@ -67,6 +68,27 @@ const World = struct { p: *Pardes, from: usize, + fn check(ctx: *anyopaque, path: []const u8, why: *sam.Why) bool { + const w: *World = @ptrCast(@alignCast(ctx)); + for (w.p.panes) |slot| { + const pane = slot orelse continue; + const f = ninep_pane.fileOf(pane) orelse continue; + if (std.mem.eql(u8, f.path, path)) return true; + } + if (comptime !pardes.hosted) return true; + const local = filesystem.localPath(path) orelse return true; + if (@import("exec.zig").isDirectory(local)) return said(bool, why, "B: {s} is a directory", .{path}) orelse false; + var buf: [4096]u8 = undefined; + if (filesystem.resolveOs(local, &buf) == null) return said(bool, why, "B: {s}: no such file", .{path}) orelse false; + return true; + } + + fn refuseGet(ctx: *anyopaque, id: usize, why: *sam.Why) bool { + const w: *World = @ptrCast(@alignCast(ctx)); + const words = @import("ninep/ctl.zig").getRefused(w.p, w.p.panes[id] orelse return false, "e") orelse return false; + return said(bool, why, "{s}", .{words}) orelse true; + } + fn open(ctx: *anyopaque, path: []const u8, why: *sam.Why) ?sam.File { const w: *World = @ptrCast(@alignCast(ctx)); const p = w.p; @@ -135,20 +157,27 @@ pub fn run(p: *Pardes, id: usize, command: []const u8) void { defer if (!keep) pd.deinit(p.gpa); const arena = pd.arena.allocator(); - // Every text pane, column by column as acme's allwindows goes, then any - // not in a column; the Edit starts in its own. + // Every text pane in the order they were made, as /index lists them, + // so X goes the same way whatever the layout; the Edit starts in its own. var files: std.ArrayList(sam.File) = .empty; - var cur: usize = 0; - var seen: [pardes.MAX_PANES]bool = @splat(false); - for (0..p.ncol) |c| { - for (p.col_panes[c][0..p.col_n[c]]) |slot| addFile(p, &files, arena, slot, id, &cur, &seen) catch |err| return p.reportError(id, "Edit", err); + for (p.panes, 0..) |slot, i| { + const q = slot orelse continue; + if (q.file == null) continue; + files.append(arena, fileOf(q, i)) catch |err| return p.reportError(id, "Edit", err); } - for (0..pardes.MAX_PANES) |slot| addFile(p, &files, arena, slot, id, &cur, &seen) catch |err| return p.reportError(id, "Edit", err); + std.mem.sort(sam.File, files.items, p, struct { + fn before(core: *Pardes, a: sam.File, b: sam.File) bool { + return core.panes[a.id].?.serial < core.panes[b.id].?.serial; + } + }.before); + const cur = for (files.items, 0..) |f, i| { + if (f.id == id) break i; + } else unreachable; var world: World = .{ .p = p, .from = id }; var why: sam.Why = .{}; var text: [260]u8 = undefined; - const res = sam.run(arena, files.items, cur, command, .{ .ctx = &world, .open = World.open, .read = World.read }, &why) catch |err| switch (err) { + const res = sam.run(arena, files.items, cur, command, .{ .ctx = &world, .check = World.check, .open = World.open, .read = World.read, .refuseGet = World.refuseGet }, &why) catch |err| switch (err) { error.Edit => return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: {s}", .{why.text()}) catch "Edit: failed"), error.OutOfMemory => return p.reportError(id, "Edit", err), }; @@ -190,21 +219,45 @@ pub fn run(p: *Pardes, id: usize, command: []const u8) void { .commands = commands, .cwds = cwds, .shell = arena.dupe(u8, p.shellBin()) catch |err| return p.reportError(id, "Edit", err), + .token = selection_pipe.newToken(), }; keep = true; p.pipe.edit_run = pd; p.emit(.{ .pipe = .{ .id = pd.id } }); - // A 9P write that ran it is answered once it is done. - if (p.fs.serving) p.fs.edit_answer_at = pardes.turn.edit_answers; + // A 9P write that ran it is held, and answered once it is done + // (9p_io, fs.edit_hold); the connection serves on meanwhile. + if (p.fs.serving) p.fs.edit_started = true; +} + +/// The Edit waiting on its commands, stopped: its write was flushed, or +/// its connection hung up (9p_io). Its commands are killed, and nothing +/// changes. +pub fn cancel(p: *Pardes) void { + p.fs.edit_hold = null; + const pd = p.pipe.edit_run orelse return; + p.pipe.edit_run = null; + selection_pipe.stop(pd.request.token); + if (p.panes[pd.origin]) |pane| if (pane.serial == pd.origin_serial) + p.setMessage(pd.origin, "Edit: interrupted; nothing changed"); + pd.deinit(p.gpa); } -fn addFile(p: *Pardes, files: *std.ArrayList(sam.File), arena: std.mem.Allocator, slot: usize, from: usize, cur: *usize, seen: *[pardes.MAX_PANES]bool) !void { - if (seen[slot]) return; - seen[slot] = true; - const pane = p.panes[slot] orelse return; - if (pane.file == null) return; - if (slot == from) cur.* = files.items.len; - try files.append(arena, fileOf(pane, slot)); +/// The held 9P write that ran the Edit has its answer: done, failed in the +/// words said on pane `at` when `failed`. +fn answerHold(p: *Pardes, at: usize, failed: bool) void { + const h = if (p.fs.edit_hold) |*held| held else return; + h.done = true; + p.fs.news = true; + if (!failed) return; + const words = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0) + p.fs.said[0..p.fs.said_len] + else if (p.panes[at]) |pane| pane.msg[0..pane.msg_len] else "Edit: failed"; + const kept = @import("Messages.zig").clip(words, h.said.len); + @memcpy(h.said[0..kept.len], kept); + h.said_len = @intCast(kept.len); + h.errno = @import("ninep/ctl.zig").failureErrno(kept); + // The write's err record, as a refusal answered at once has. + pardes.ctlfs.events.noteError(p, .{ .tag = h.tag, .op = .write, .node = h.node, .handle = h.handle }, @import("ninep/tree.zig").failText(h.tag, h.errno, h.said[0..h.said_len])); } /// The commands answered (pipe_resp): the Edit applied, or why not. @@ -212,11 +265,10 @@ pub fn finish(p: *Pardes, success: bool, outputs: []const []const u8, failure: ? const pd = p.pipe.edit_run orelse return; p.pipe.edit_run = null; defer pd.deinit(p.gpa); - defer pardes.turn.editAnswered(); const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active; // Whatever fails from here fails the 9P write that ran it too. const failures = p.fs.failures; - defer if (p.fs.failures != failures) p.noteLateFailure(at); + defer answerHold(p, at, p.fs.failures != failures); if (success and outputs.len == pd.res.jobs.len) return commit(p, pd, outputs); // Which command, in +Errors with its own words; the Edit changed nothing. @@ -232,7 +284,9 @@ pub fn finish(p: *Pardes, success: bool, outputs: []const []const u8, failure: ? Pipe.writeFailure(w, failure) catch break :report; const why = std.mem.sliceTo(out.written()[why_at..], '\n'); const said = std.fmt.bufPrint(&line, "Edit: {c} {s}: {s}", .{ job.c, job.command[0..@min(job.command.len, 120)], why }) catch "Edit: a command failed"; + const was = p.active; _ = ninep_pane.appendErrors(p, at, out.written()); + if (p.panes[was] != null) p.active = was; return p.reportFailure(at, said); } p.reportFailure(at, "Edit: a command failed"); @@ -245,12 +299,21 @@ fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void { const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active; var text: [320]u8 = undefined; for (res.files, 0..) |f, i| { - if (f.ops.items.len == 0 and !f.close and !f.renamed and !f.moved) continue; + if (f.ops.items.len == 0 and !f.close and !f.renamed and !f.moved and f.get == null) continue; const pane = p.panes[f.id] orelse return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed"); if (pane.serial != pd.serials[i]) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed"); - if (f.ops.items.len > 0 and pane.file.?.revision != pd.revisions[i]) + if ((f.ops.items.len > 0 or f.get != null) and pane.file.?.revision != pd.revisions[i]) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} changed while its commands ran; nothing changed", .{f.name}) catch "Edit: a file changed"); } + // e first, Get's way: read before anything is changed, so one that + // cannot be read changes nothing; its own undo step puts the old name + // back with the old text, and it leaves the pane clean. + for (res.files) |f| { + const path = f.get orelse continue; + var failed: anyerror = error.Unexpected; + const errno = @import("ninep/ctl.zig").getFile(p, p.panes[f.id].?, path, &failed); + if (errno != 0) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: e {s}: {s}", .{ path, if (errno == @import("ninep/tree.zig").E.NOENT) "no such file" else "cannot be read" }) catch "Edit: e failed"); + } // `w`'s bytes, written after this step, before anything else is. for (res.writes) |wr| { const f = res.files[wr.file]; @@ -291,11 +354,6 @@ fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void { panes.File.pushUndo(p, pane); panes.File.setContent(p, pf, new); } - // e read its own file over it: what is on disk again. - if (f.clean) { - pf.saved_revision = pf.revision; - pf.disk_gone = false; - } } if (ops.len > 0 or f.moved) ninep_pane.setDot(pane, sam.moveDot(f.dot, ops)); if (f.renamed) ninep_pane.nameBuffer(p, f.id, f.name, false) catch |err| p.reportError(f.id, "Edit", err); @@ -312,7 +370,11 @@ fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void { from = job.at; } out.appendSlice(p.gpa, res.printed[from..]) catch return; + // Printed, the keyboard stays where it was: a +Errors made for it + // does not take it, so the next Edit lands where this one did. + const was = p.active; if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items); + if (p.panes[was] != null) p.active = was; } // D's panes last, each as Del closes one: an edited one is warned about // once, and closes on the next. @@ -344,6 +406,17 @@ fn runReal(p: *Pardes) !void { p.update(.{ .pipe_resp = .{ .id = pd.id, .success = response.success, .outputs = response.outputs, .failure = response.failure } }); } +/// Whether any +Errors holds `needle`. +fn errorsHas(p: *Pardes, needle: []const u8) bool { + for (p.panes) |slot| { + const q = slot orelse continue; + const qf = q.file orelse continue; + const o = qf.output orelse continue; + if (std.meta.activeTag(o.from) == .errors and std.mem.indexOf(u8, qf.content, needle) != null) return true; + } + return false; +} + fn errorsText(p: *Pardes) ?[]const u8 { for (p.panes) |slot| { const q = slot orelse continue; @@ -358,8 +431,9 @@ test "Edit , | sort runs off the loop, through the session's shell in the file's const p = try th.withFile(testing.allocator, "b\na\nc\n"); defer p.deinit(); const serial = th.serialOf(p); - const answers = pardes.turn.edit_answers; try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\n").reply.status); + // The listener is told to hold the write. + try testing.expect(p.fs.edit_started); // Nothing changed yet: the command is out. try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content); const req = p.pipe.pipeRequest(p.pipe.edit_run.?.id).?; @@ -371,17 +445,55 @@ test "Edit , | sort runs off the loop, through the session's shell in the file's try testing.expectEqual(tree.E.IO, th.wr(p, tree.Node.of(serial, .ctl), "Edit ,d\n").errno()); answer(p, &.{"a\nb\nc\n"}); try testing.expect(p.pipe.edit_run == null); - try testing.expectEqual(answers +% 1, pardes.turn.edit_answers); try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Undo\n").reply.status); try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content); - // What follows it in the same write would run before its commands - // answer: refused, and the Edit goes on. - const after = th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\nSave\n"); - try testing.expectEqual(tree.E.INVAL, after.errno()); - try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename); - answer(p, &.{"a\nb\nc\n"}); - try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content); + // Another line in its write would run before its commands answer: the + // whole write is refused before any line runs, the one before it too. + for ([_][]const u8{ "Edit , | sort\nSave\n", "Msg before\nEdit , | sort\nMsg after\n" }) |two| { + const after = th.wr(p, tree.Node.of(serial, .ctl), two); + try testing.expectEqual(tree.E.INVAL, after.errno()); + try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename); + try testing.expect(p.pipe.edit_run == null and !th.logHas(p, "before")); + } + try testing.expectEqual(tree.E.INVAL, th.wr(p, @intFromEnum(tree.TopFile.exec), "Msg before\nEdit , | sort\n").errno()); +} + +test "the held write asked again is parked again, then answered once, and a flush stops the Edit" { + const p = try th.withFile(testing.allocator, "b\na\n"); + defer p.deinit(); + const ctl = tree.Node.of(th.serialOf(p), .ctl); + const req: tree.Req = .{ .tag = 7, .op = .write, .node = ctl, .handle = 0, .data = "Edit , | sort\n" }; + try testing.expectEqual(tree.Status.ok, p.serveFs(req).status); + // As 9p_io holds it. + p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 7, .node = ctl, .handle = 0, .written = @intCast(req.data.len) }; + // A retry of its park: still waiting, never run a second time. + try testing.expectEqual(tree.Status.again, p.serveFs(req).status); + try testing.expect(p.pipe.edit_run != null); + answer(p, &.{"a\nb\n"}); + try testing.expect(p.fs.edit_hold.?.done); + const done = p.serveFs(req); + try testing.expectEqual(tree.Status.ok, done.status); + try testing.expectEqual(@as(u32, @intCast(req.data.len)), done.written); + try testing.expect(p.fs.edit_hold == null); + try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content); + // A failure is the held write's answer, in the Edit's words. + _ = p.serveFs(.{ .tag = 8, .op = .write, .node = ctl, .data = "Edit , | false\n" }); + p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 2, .tag = 8, .node = ctl, .handle = 0, .written = 15 }; + p.update(.{ .pipe_resp = .{ .id = p.pipe.edit_run.?.id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 1 } } }); + const failed = filesystem.EditHold.answer(&p.fs); + try testing.expectEqual(tree.Status.err, failed.status); + try testing.expectEqualStrings("Edit: | false: exit status 1", failed.ename); + // Flushed (or hung up): its commands stopped, nothing changed, and the + // next Edit is free to run; the stopped one's late answer is no one's. + _ = p.serveFs(.{ .tag = 9, .op = .write, .node = ctl, .data = "Edit , | sort -r\n" }); + const stale = p.pipe.edit_run.?.id; + p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 3, .tag = 9, .node = ctl, .handle = 0, .written = 18 }; + cancel(p); + try testing.expect(p.pipe.edit_run == null and p.fs.edit_hold == null); + p.update(.{ .pipe_resp = .{ .id = stale, .success = true, .outputs = &.{"b\na\n"} } }); + try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content); + try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status); } test "Edit's < | and > with a real shell: a command per address, > printed to +Errors" { @@ -399,6 +511,8 @@ test "Edit's < | and > with a real shell: a command per address, > printed to +E try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , > wc -l\n").reply.status); try runReal(p); + // The +Errors it opened did not take the keyboard. + try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content); try testing.expectEqualStrings("1", std.mem.trim(u8, errorsText(p).?, " \n")); } @@ -409,11 +523,12 @@ test "a failing command changes nothing and says why, its stderr in +Errors" { defer p.deinit(); const ctl = tree.Node.of(th.serialOf(p), .ctl); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | echo boom >&2; exit 3\n").reply.status); - p.fs.late_failure_len = 0; + p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 }; try runReal(p); try testing.expectEqualStrings("keep\n", p.panes[0].?.file.?.content); try testing.expectEqual(@as(usize, 0), p.panes[0].?.file.?.history.undo_len); - try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", p.fs.late_failure[0..p.fs.late_failure_len]); + // The held write's answer is the failure, in its words. + try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", filesystem.EditHold.answer(&p.fs).ename); const report = errorsText(p).?; try testing.expect(std.mem.indexOf(u8, report, "Edit: | echo boom") != null); try testing.expect(std.mem.indexOf(u8, report, "boom\n") != null); @@ -422,12 +537,13 @@ test "a failing command changes nothing and says why, its stderr in +Errors" { test "an Edit whose file is edited while its commands run is refused whole" { const p = try th.withFile(testing.allocator, "abc\n"); defer p.deinit(); - try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit , | cat\n").reply.status); + const ctl = tree.Node.of(th.serialOf(p), .ctl); + try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status); + p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 }; panes.File.setContent(p, &p.panes[0].?.file.?, try testing.allocator.dupe(u8, "typed\n")); - p.fs.late_failure_len = 0; answer(p, &.{"ABC\n"}); try testing.expectEqualStrings("typed\n", p.panes[0].?.file.?.content); - try testing.expectEqualStrings("Edit: /test.txt changed while its commands ran; nothing changed", p.fs.late_failure[0..p.fs.late_failure_len]); + try testing.expectEqualStrings("Edit: /test.txt changed while its commands ran; nothing changed", filesystem.EditHold.answer(&p.fs).ename); } test "X over open files, w, e, r, f, B and D on panes and the disk" { @@ -439,6 +555,7 @@ test "X over open files, w, e, r, f, B and D on panes and the disk" { try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.h", .data = "foo\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.c", .data = "foo bar\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "data", .data = "DATA\n" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "n.c", .data = "new\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; const p = try th.withFile(gpa, "scratch\n"); @@ -453,6 +570,11 @@ test "X over open files, w, e, r, f, B and D on panes and the disk" { try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X/\\.[ch]$/ ,s/foo/FOO/g\n").reply.status); try testing.expectEqualStrings("int FOO;\n", p.panes[a].?.file.?.content); try testing.expectEqualStrings("scratch\n", p.panes[0].?.file.?.content); + // X alone lists them in the order they were opened, as /index does. + try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X\n").reply.status); + const listed = errorsText(p).?; + try testing.expect(std.mem.indexOf(u8, listed, "/test.txt").? < std.mem.indexOf(u8, listed, "/a.c").?); + try testing.expect(std.mem.indexOf(u8, listed, "/a.c").? < std.mem.indexOf(u8, listed, "/b.h").?); // B opens a file, which a `"re"` address then edits, from pane 0. var cmd: [4400]u8 = undefined; try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/c.c\n", .{dir})).reply.status); @@ -469,11 +591,26 @@ test "X over open files, w, e, r, f, B and D on panes and the disk" { try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?)); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit 1 r data\n").reply.status); try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content); - // e of an edited file is refused; f names one and prints it. - try testing.expectEqual(tree.E.IO, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit e\n").errno()); + // e of an edited file is asked about once, as Get asks; again, it is + // read in Get's way, which leaves the pane clean and named after it. + const c_ctl = tree.Node.of(p.panes[c].?.serial, .ctl); + try testing.expect(th.wr(p, c_ctl, "Edit e data\n").reply.status == .err); + try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content); + try testing.expect(ninep_pane.dirtyOf(p.panes[c].?)); + try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit e data\n").reply.status); + try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/data")); + try testing.expect(!ninep_pane.dirtyOf(p.panes[c].?)); + // B checks every name first: a directory among them opens none. + const before = p.next_serial; + try testing.expect(th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/n.c {s}\n", .{ dir, dir })).reply.status == .err); + try testing.expectEqual(before, p.next_serial); + // f names one and prints it. + try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit 1 c/edited/\n").reply.status); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit f d.c\n").reply.status); try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/d.c")); - try testing.expect(std.mem.indexOf(u8, errorsText(p).?, "'+. ") != null); + // In its directory's +Errors, or the log when the screen has no room + // left for one. + try testing.expect(errorsHas(p, "'+. ") or th.logHas(p, "'+. ")); // D closes a pane by name, an edited one (b.h, by the X) on the // second asking, as Del does. const b_serial = for (p.panes) |slot| { diff --git a/src/fs.zig b/src/fs.zig index 2a5102f4..db07672e 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -85,6 +85,14 @@ pub const WriteError = error{ NoSpaceLeft, OpenFailed, WriteFailed, + // The rest are open(2)'s errno, named so errorWords says them. + NoSuchFileOrDirectory, + NotDir, + InvalidArgument, + FileBusy, + SymLinkLoop, + NoSuchDeviceOrAddress, + FileTooBig, }; /// The host takes the bytes. The caller has given the turn up (`write`), @@ -102,6 +110,13 @@ pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void { .ROFS => error.ReadOnlyFilesystem, .NOSPC, .DQUOT => error.NoSpaceLeft, .NAMETOOLONG => error.PathTooLong, + .NOENT => error.NoSuchFileOrDirectory, + .NOTDIR => error.NotDir, + .INVAL => error.InvalidArgument, + .BUSY, .TXTBSY => error.FileBusy, + .LOOP => error.SymLinkLoop, + .NXIO, .NODEV => error.NoSuchDeviceOrAddress, + .FBIG => error.FileTooBig, else => error.OpenFailed, }; var off: usize = 0; @@ -1554,6 +1569,36 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { return buf; } +/// A 9P write of an Edit held while its commands run (Namespace.edit_hold). +pub const EditHold = struct { + /// The listener's: the connection that asked, and its park. + asker: *anyopaque, + slot: u16, + seq: u64, + tag: u64, + node: u64, + handle: u32, + written: u32, + /// The Edit is done: applied, or failed with `said`. + done: bool = false, + errno: u16 = 0, + said: [256]u8 = undefined, + said_len: u16 = 0, + + pub fn names(h: *const EditHold, req: tree.Req) bool { + return req.op == .write and req.tag == h.tag and req.node == h.node and req.handle == h.handle; + } + + /// Its answer, the held record let go: the words copied out first. + pub fn answer(ns: *Namespace) tree.Reply { + const h = ns.edit_hold.?; + ns.edit_hold = null; + if (h.errno == 0) return .{ .tag = h.tag, .written = h.written }; + @memcpy(ns.edit_said[0..h.said_len], h.said[0..h.said_len]); + return tree.failText(h.tag, h.errno, ns.edit_said[0..h.said_len]); + } +}; + pub const Namespace = struct { socket_path: []const u8 = "", tcp_address: ?std.Io.net.IpAddress = null, @@ -1613,9 +1658,15 @@ pub const Namespace = struct { /// A 9P write asked a language server something: the answer count it /// waits to change (`turn.lsp_answers`), so its failure is the write's. lsp_answer_at: ?u64 = null, - /// A write ran an Edit whose `<`, `|` or `>` commands are running: it is - /// answered once `turn.edit_answers` passes this, the Edit done. - edit_answer_at: ?u64 = null, + /// The request being served started an Edit whose `<`, `|` or `>` + /// commands run off the loop (edit_cmd.zig): the listener holds it. + edit_started: bool = false, + /// That write, held: answered once `done` (9p_io answerHeld), or + /// asked again by a retry of its park (Pardes.serveFs); a flush or a + /// hang-up stops the Edit (9p_io watchEdit). + edit_hold: ?EditHold = null, + /// The words a held Edit write was refused with, kept for its answer. + edit_said: [256]u8 = undefined, /// The pane a language answer filled (a Rename's preview): what the /// waiting 9P write answers once it is in (9p_io). lsp_result: ?u32 = null, @@ -1757,3 +1808,11 @@ test { test "the tree's README lists src/ only when the build serves it" { try std.testing.expectEqual(limits.embedded_sources, std.mem.indexOf(u8, help, "src/") != null); } + +test "a write open(2) refuses says its errno in words" { + if (comptime @import("builtin").os.tag != .linux or !pardes.hosted) return error.SkipZigTest; + // /proc takes no new file: ENOENT, which used to read "open failed". + try std.testing.expectError(error.NoSuchFileOrDirectory, writeFile("/proc/pardes-no-such-file", "x")); + var words: [128]u8 = undefined; + try std.testing.expectEqualStrings("no such file or directory", pardes.Messages.errorWords(error.NoSuchFileOrDirectory, &words)); +} diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 274caf88..8ece7ead 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -193,6 +193,7 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep // The root's lines run at the keyboard's pane: a refusal before any runs // is that pane's too, in its err record. p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 0; + if (editNotAlone(p, req.data)) return tree.failText(req.tag, E.INVAL, e_after_edit); for ([2]bool{ false, true }) |apply| { if (apply) p.fs.results_len = 0; var it = Messages.init(p, req.data); @@ -203,7 +204,6 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; - if (p.fs.edit_answer_at != null) return tree.failText(req.tag, E.INVAL, e_after_edit); var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; // No pane at all: the root's look and exec act in the session's // directory. An exec runs as the workspace tag's; a look from a @@ -260,10 +260,25 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply { pub const e_control = "invalid command line: it holds a control character (or DEL) other than a tab"; -/// An Edit whose `<`, `|` or `>` commands are running answers its write -/// when they are done (edit_cmd.zig); a line after it in the same write -/// would run before them, on the text they are about to change. -pub const e_after_edit = "invalid command line: an Edit running commands ends its write; write what follows after it"; +/// An Edit whose `<`, `|` or `>` commands run answers its write when they +/// are done (edit_cmd.zig); a line after it in the same write would run +/// before them, on the text they are about to change. Refused up front, +/// before any line runs. +pub const e_after_edit = "invalid command line: an Edit that runs commands is a write of its own"; + +/// Whether `data` holds an Edit that runs commands and any other line. +fn editNotAlone(p: *Pardes, data: []const u8) bool { + const sam = @import("../sam_edit.zig"); + var lines: usize = 0; + var runs = false; + var it = Messages.init(p, data); + while (it.next()) |line| { + if (line.len == 0 or exec_line.isComment(line)) continue; + lines += 1; + if (std.mem.startsWith(u8, line, "Edit") and sam.runsCommands(p.scratch.allocator(), line["Edit".len..])) runs = true; + } + return runs and lines > 1; +} /// Runs one click (`run`) as a 9P write's: a builtin that fails there fails /// the write, with its words and an err record, and no msg, as a ctl line @@ -959,13 +974,13 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { const serial = pane.serial; // Checked whole before anything applies, so a write that must wait for // the lock has done nothing yet when it goes again. + if (editNotAlone(p, req.data)) return tree.failText(req.tag, E.INVAL, e_after_edit); for ([2]bool{ false, true }) |apply| { if (apply) p.fs.results_len = 0; var held = !other and pane.fs.lock != null; var it = Messages.init(p, req.data); while (it.next()) |line_in| { if (line_in.len == 0 or exec_line.isComment(line_in)) continue; - if (apply and p.fs.edit_answer_at != null) return tree.failText(req.tag, E.INVAL, e_after_edit); // A line before this one may have closed the pane (`Del`). if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT); // acme's ctl words: done by the file that replaces each, or the diff --git a/src/pardes.zig b/src/pardes.zig index cf3d5128..e92f9623 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -123,9 +123,6 @@ pub const Turn = struct { /// Bumped each time a language server's answer (or its lack) is taken /// in (`lspResponse`): a write that asked one waits for this. lsp_answers: u64 = 0, - /// Bumped each time an Edit's commands (`< | >`) have answered and the - /// Edit is done, or failed (edit_cmd.zig): a write that ran one waits. - edit_answers: u64 = 0, /// All of the above, and `out` reaching zero. settled: std.Io.Condition = .init, @@ -223,15 +220,6 @@ pub const Turn = struct { while (t.lsp_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return; } - pub fn editAnswered(t: *Turn) void { - t.edit_answers +%= 1; - const io = t.io orelse return; - t.settled.broadcast(io); - } - - pub fn awaitEdit(t: *Turn, answers: u64) void { - while (t.edit_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return; - } pub fn awaitRestored(t: *Turn, restores: u64) void { while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return; @@ -5918,6 +5906,12 @@ pub const Pardes = struct { /// that changes a pane costs a frame, which is why a round trip on a /// local socket costs microseconds and not a vsync. pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply { + // The held write of an Edit whose commands run, asked again by a + // retry of its park: still waiting, or its answer, never run twice. + if (p.fs.edit_hold) |*h| if (h.names(req)) { + if (!h.done) return .{ .tag = req.tag, .status = .again }; + return filesystem.EditHold.answer(&p.fs); + }; defer panes.File.syncTwins(p); if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req); // An address, a limit or the mark flag is nothing drawn: a script's @@ -5928,7 +5922,7 @@ pub const Pardes = struct { look.cancelLookHover(p); p.fs.serving = true; p.fs.lsp_answer_at = null; - p.fs.edit_answer_at = null; + p.fs.edit_started = false; const reply = ctlfs.handle(p, req); p.fs.serving = false; // The one-failure rule is a write's: a refused open (its OTRUNC's diff --git a/src/sam_edit.zig b/src/sam_edit.zig index 3f23aeff..d0c51bdb 100644 --- a/src/sam_edit.zig +++ b/src/sam_edit.zig @@ -12,8 +12,8 @@ //! What touches more than the text is the caller's (src/EditCmd.zig): `<`, //! `|` and `>` are collected as jobs, a command to run per address, whose //! output fills the change they hold the place of; `w` as the bytes to -//! write; `D`, and the names `e` and `f` set, as marks on the file; `B` and -//! the reads of `e` and `r` through `World`, at once, as acme does them. +//! write; `D`, `e`'s load and the name `f` sets, as marks on the file; `B` +//! and the reads of `e` and `r` through `World`, at once, as acme does them. //! //! Left out: the `'` address, `B `) refuses the whole Edit, where sam //! warns and puts in what it wrote; `D` of a file the Edit changed is -//! refused rather than drop the changes; `e` of an edited file is refused -//! every time, not once. +//! refused rather than drop the changes; `e` loads its file once the Edit +//! is done, by Get's way (ninep/ctl.zig getFile), so the file takes no +//! other command in the same Edit. const std = @import("std"); const regexp = @import("regexp.zig"); const addr_lang = @import("ninep/addr.zig"); @@ -58,8 +59,10 @@ pub const File = struct { renamed: bool = false, /// `D` closes it once the Edit is done. close: bool = false, - /// `e` read its own file whole: it is clean again once applied. - clean: bool = false, + /// `e` loads this file into it once the Edit is done (the editor's Get + /// path, which renames the pane and leaves it clean); the file takes no + /// more commands in the Edit. + get: ?[]const u8 = null, /// A command left a dot in it. moved: bool = false, }; @@ -75,11 +78,17 @@ pub const Write = struct { file: u32, path: []const u8, bytes: []const u8, whole /// What the editor does for `B`, `e` and `r` while an Edit runs. pub const World = struct { ctx: *anyopaque, + /// Whether `B` could open the absolute `path`: false, and `why` set, + /// when it could not, before any is opened. + check: *const fn (ctx: *anyopaque, path: []const u8, why: *Why) bool, /// The text pane holding the absolute `path`, opened if it is not open: /// `B`. Null, and `why` set, when it cannot be. open: *const fn (ctx: *anyopaque, path: []const u8, why: *Why) ?File, /// The bytes of the absolute `path`, in `arena`: `e` and `r`. read: *const fn (ctx: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *Why) ?[]const u8, + /// `e` over unsaved edits in the pane `id`: asked once, as Get asks; + /// true, and `why` set, when it is refused this time. + refuseGet: *const fn (ctx: *anyopaque, id: usize, why: *Why) bool, }; /// What an Edit came to: every file (those `B` opened after the ones it @@ -221,6 +230,28 @@ pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool { } } +/// Whether `command` holds a `<`, `|` or `>`: its write is answered only +/// when they have run (ninep/ctl.zig refuses other lines beside it). +pub fn runsCommands(arena: std.mem.Allocator, command: []const u8) bool { + var why: Why = .{}; + const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false; + var ps: Parser = .{ .arena = arena, .src = src, .why = &why }; + const Walk = struct { + fn any(c: ?*Cmd) bool { + var it = c; + while (it) |each| : (it = each.next) { + if (std.mem.indexOfScalar(u8, "<|>", each.c) != null) return true; + if (any(each.sub)) return true; + } + return false; + } + }; + while (true) { + const c = (ps.parse(0) catch return false) orelse return false; + if (Walk.any(c)) return true; + } +} + fn fail(why: *Why, comptime fmt: []const u8, args: anytype) Failure { why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0; return error.Edit; @@ -642,7 +673,10 @@ const Exec = struct { /// `name` as a path: itself when absolute, else in `fi`'s directory /// (acme's dirname). - fn pathIn(ex: *Exec, fi: usize, name: []const u8) Failure![]const u8 { + fn pathIn(ex: *Exec, fi: usize, name_in: []const u8) Failure![]const u8 { + // `~` and `~user` as Get, Incl and Save read them. + var home: [4096]u8 = undefined; + const name = try ex.arena.dupe(u8, @import("fs.zig").expandHome(name_in, &home)); if (name.len > 0 and name[0] == '/') return name; const dir = std.fs.path.dirnamePosix(ex.files.items[fi].name) orelse "/"; return std.fs.path.resolvePosix(ex.arena, &.{ dir, name }); @@ -683,6 +717,7 @@ const Exec = struct { var fi = fi_in; var dot = dot_in; if (ex.files.items[fi].close) return fail(ex.why, "no current window", .{}); + if (ex.files.items[fi].get) |path| return fail(ex.why, "{s} is read in by e at the Edit's end: no more commands in it", .{path}); // A `"re"` address: the command runs in that file, from its dot. if (c.file_re) |pat| { fi = try ex.matchFile(pat); @@ -797,6 +832,9 @@ const Exec = struct { if (list.len > 0 and list[0] == '<') return fail(ex.why, "B { - // ecmd.c e_cmd: e reads a file over all of this one, r over - // dot; e names this file after it. + 'r' => { + // ecmd.c e_cmd: r reads a file over dot. + const path = (try ex.cmdName(fi, c.text, false)) orelse return fail(ex.why, e_noname, .{}); + try ex.log(fi, r.q0, r.q1, try ex.readFile(path), null); + return r; + }, + 'e' => { + // ecmd.c e_cmd: a file read over all of this one, which takes + // its name: Get's way (ctl.getFile), once the Edit is done, + // asking first, once, over unsaved edits. const f = &ex.files.items[fi]; - if (c.c == 'e' and f.dirty and !f.scratch) return fail(ex.why, "{s} modified", .{f.name}); - const own = f.name; - const path = (try ex.cmdName(fi, c.text, c.c == 'e')) orelse return fail(ex.why, e_noname, .{}); - const bytes = try ex.readFile(path); - const q: Range = if (c.c == 'e') .{ .q0 = 0, .q1 = clip(txt.len) } else r; - try ex.log(fi, q.q0, q.q1, bytes, null); - if (c.c == 'e' and std.mem.eql(u8, path, own)) ex.files.items[fi].clean = true; + if (f.ops.items.len > 0) return fail(ex.why, "e: {s} has changes pending in this Edit", .{f.name}); + const given = std.mem.trim(u8, c.text, " \t"); + const path = if (given.len == 0) + (if (f.scratch) return fail(ex.why, e_noname, .{}) else f.name) + else + try ex.pathIn(fi, given); + const w = ex.world orelse return fail(ex.why, "can't open {s}: no files here", .{path}); + if (w.refuseGet(w.ctx, f.id, ex.why)) return error.Edit; + _ = try ex.readFile(path); + f.get = path; + f.name = path; return r; }, 'w' => { @@ -893,7 +942,7 @@ const Exec = struct { /// in this Edit is refused rather than have them dropped. fn closeFile(ex: *Exec, fi: usize) Failure!void { const f = &ex.files.items[fi]; - if (f.ops.items.len > 0) return fail(ex.why, "D: {s} has changes pending in this Edit", .{f.name}); + if (f.ops.items.len > 0 or f.get != null) return fail(ex.why, "D: {s} has changes pending in this Edit", .{f.name}); f.close = true; } @@ -1166,7 +1215,23 @@ const Fixture = struct { .{ .name = "/src/+Errors", .text = "foo\n", .id = 3, .scratch = true }, }; + var opened: usize = 0; + + fn check(_: *anyopaque, path: []const u8, why: *Why) bool { + if (std.mem.eql(u8, path, "/src/new.c")) return true; + why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0; + return false; + } + + /// The edited file, b.h, is asked about: refused. + fn refuseGet(_: *anyopaque, id: usize, why: *Why) bool { + if (id != 1) return false; + why.len = (std.fmt.bufPrint(&why.buf, "/src/b.h: Modified (e again to discard)", .{}) catch unreachable).len; + return true; + } + fn open(_: *anyopaque, path: []const u8, why: *Why) ?File { + opened += 1; if (std.mem.eql(u8, path, "/src/new.c")) return .{ .name = "/src/new.c", .text = "new foo\n", .id = 9 }; why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0; return null; @@ -1188,7 +1253,7 @@ const Fixture = struct { fn edit(fx: *Fixture, from: usize, command: []const u8) !Result { var ctx: u8 = 0; - const world: World = .{ .ctx = &ctx, .open = open, .read = read }; + const world: World = .{ .ctx = &ctx, .check = check, .open = open, .read = read, .refuseGet = refuseGet }; return run(fx.arena_state.allocator(), &files, from, command, world, &fx.why); } @@ -1247,17 +1312,23 @@ test "b moves the current file, \"re\" addresses another, and f names one" { test "e and r read a file into the text, w writes it, D and B close and open panes" { var fx: Fixture = .init(); defer fx.deinit(); - // r replaces dot; e the whole text, and names the file after it. + // r replaces dot; e loads a file over all of it at the end, by Get's + // way, which names the pane after it. const r = try fx.edit(0, "2 r data"); try std.testing.expectEqualStrings("int foo;\nDATA\n", try fx.after(r, 0)); try std.testing.expect(!r.files[0].renamed); - const e = try fx.edit(0, "e /src/data"); - try std.testing.expectEqualStrings("DATA\n", try fx.after(e, 0)); - try std.testing.expectEqualStrings("/src/data", e.files[0].name); - try std.testing.expect(!e.files[0].clean); - // e of an edited file is refused, as acme's winclean refuses it. + const e = try fx.edit(0, "e data"); + try std.testing.expectEqualStrings("/src/data", e.files[0].get.?); + try std.testing.expectEqual(@as(usize, 0), e.files[0].ops.items.len); + // ...and takes no more commands, nor follows another change. + try std.testing.expectError(error.Edit, fx.edit(0, "e data\n,d")); + try std.testing.expectError(error.Edit, fx.edit(0, "1d\ne data")); + // e of an edited file is asked about, as Get asks. try std.testing.expectError(error.Edit, fx.edit(1, "e")); - try std.testing.expectEqualStrings("/src/b.h modified", fx.why.text()); + try std.testing.expectEqualStrings("/src/b.h: Modified (e again to discard)", fx.why.text()); + // `~` is the home directory, as Get and Save read it. + const home = try fx.edit(0, "w ~/t.txt"); + try std.testing.expect(home.writes[0].path[0] == '/' and std.mem.indexOfScalar(u8, home.writes[0].path, '~') == null); try std.testing.expectError(error.Edit, fx.edit(0, "r nothing")); try std.testing.expectEqualStrings("can't open /src/nothing: file does not exist", fx.why.text()); // w: all of it by default, to its own name; an address and a name. @@ -1282,6 +1353,10 @@ test "e and r read a file into the text, w writes it, D and B close and open pan try std.testing.expectEqualStrings("old foo\n", try fx.after(b, 4)); try std.testing.expectError(error.Edit, fx.edit(0, "B gone.c")); try std.testing.expectEqualStrings("B: cannot open /src/gone.c", fx.why.text()); + // Every name is checked first: one that cannot open opens none. + Fixture.opened = 0; + try std.testing.expectError(error.Edit, fx.edit(0, "B new.c gone.c")); + try std.testing.expectEqual(@as(usize, 0), Fixture.opened); } test "<, | and > are jobs: a command per address, its output the change" { @@ -1309,3 +1384,14 @@ test "<, | and > are jobs: a command per address, its output the change" { try std.testing.expectEqual(@as(usize, 2), x.jobs.len); try std.testing.expectEqual(@as(u32, 2), x.jobs[1].file); } + +test "an Edit that runs commands is told from one that does not" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + try std.testing.expect(runsCommands(a, ", | sort")); + try std.testing.expect(runsCommands(a, ",x/a/{\n< date\n}")); + try std.testing.expect(runsCommands(a, "X/c/ , > wc")); + try std.testing.expect(!runsCommands(a, ",s/|/>/g")); + try std.testing.expect(!runsCommands(a, ",x/ max_stdout_bytes or stderr_reader.buffered().len > max_stderr_bytes) return fail.k(.too_large); multi_reader.checkAnyError() catch return fail.k(.io); + if (token != 0) Running.remove(pid); const term = child.wait(io) catch return fail.k(.io); writer.?.join(); writer = null; @@ -339,6 +414,12 @@ pub fn runIn( return .{ .ok = stdout }; } +fn tokenStopped(token: u32) bool { + Running.take(); + defer Running.lock.unlock(); + return Running.isStopped(token); +} + /// Invoke the command independently for every selection. The response is all /// or nothing: one failure frees every earlier stdout and returns a failed, /// empty answer for the core to ignore. @@ -350,7 +431,11 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { for (job.inputs, 0..) |input, i| { const command = if (job.commands.len > 0) job.commands[i] else job.command; const cwd = if (job.cwds.len > 0) job.cwds[i] else job.cwd; - const output = switch (runIn(gpa, io, job.shell, command, cwd, input)) { + if (job.token != 0 and tokenStopped(job.token)) { + response.failure = .{ .kind = .signal, .index = @intCast(i) }; + break; + } + const output = switch (runIn(gpa, io, job.shell, command, cwd, input, job.token)) { .ok => |bytes| bytes, .failed => |f| { // WHICH selection, because with several cursors "it failed" is @@ -468,3 +553,34 @@ test "a request may give each input its own command and directory, run by the sh try std.testing.expectEqualStrings("/tmp\n", response.outputs[0]); try std.testing.expectEqualStrings("ABC", response.outputs[1]); } + +test "stop kills a running request's commands, the whole group, and starts none after" { + const gpa = std.testing.allocator; + const token = newToken(); + const job = try Job.copy(gpa, .{ + .id = 1, + .command = "", + .cwd = "", + .inputs = &.{ .{ .bytes = "" }, .{ .bytes = "" } }, + .commands = &.{ "sleep 30; echo late", "echo never" }, + .token = token, + }); + defer job.deinit(gpa); + const Run = struct { + fn go(j: *const Job, out: *Response) void { + out.* = runJob(std.testing.allocator, std.testing.io, j); + } + }; + var response: Response = undefined; + const started = std.Io.Clock.awake.now(std.testing.io); + const thread = try std.Thread.spawn(.{}, Run.go, .{ job, &response }); + std.Io.sleep(std.testing.io, .fromMilliseconds(300), .awake) catch {}; + stop(token); + thread.join(); + defer response.deinit(gpa); + try std.testing.expect(!response.success); + try std.testing.expectEqual(Failure.Kind.signal, response.failure.?.kind); + try std.testing.expectEqual(@as(u32, 0), response.failure.?.index); + const took = started.durationTo(std.Io.Clock.awake.now(std.testing.io)); + try std.testing.expect(took.toMilliseconds() < 5000); +} -- cgit v1.3