summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 20:50:48 -0300
committerGabriel Schneider <[email protected]>2026-10-01 22:07:48 -0300
commit406f483288c7689bdd2e1f3f68b276c61ca9412a (patch)
treeb6398fb9974b91441563fd655d18ddaf182a2a5a /src
parentb55cb01d2777760ed79b545dfca7115837591577 (diff)
downloadpardes-406f483288c7689bdd2e1f3f68b276c61ca9412a.tar.gz
pardes-406f483288c7689bdd2e1f3f68b276c61ca9412a.zip
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig73
-rw-r--r--src/Pipe.zig6
-rw-r--r--src/edit_cmd.zig225
-rw-r--r--src/fs.zig65
-rw-r--r--src/ninep/ctl.zig27
-rw-r--r--src/pardes.zig20
-rw-r--r--src/sam_edit.zig138
-rw-r--r--src/selection_pipe.zig120
8 files changed, 568 insertions, 106 deletions
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 <cmd` and `D <cmd` (a file list from a
//! command), and `\1`..`\9` in `s` and sam's longest of alternatives (mvzr
@@ -22,8 +22,9 @@
//! sequence (elog.c:130) and applies them anyway; here the Edit is refused.
//! A command that fails (`|`, `<`, `>`) 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 <command is not supported: give the names", .{});
if (list.len == 0) return fail(ex.why, e_noname, .{});
const w = ex.world orelse return fail(ex.why, "B: no panes to open here", .{});
+ // Every name first: one that cannot open opens none.
+ var checks = std.mem.tokenizeAny(u8, list, " \t");
+ while (checks.next()) |name| if (!w.check(w.ctx, try ex.pathIn(fi, name), ex.why)) return error.Edit;
var names = std.mem.tokenizeAny(u8, list, " \t");
while (names.next()) |name| {
const path = try ex.pathIn(fi, name);
@@ -822,17 +860,28 @@ const Exec = struct {
}
return r;
},
- 'e', 'r' => {
- // 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/</d"));
+}
diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig
index 4fb024bb..fb6587f9 100644
--- a/src/selection_pipe.zig
+++ b/src/selection_pipe.zig
@@ -30,6 +30,8 @@ pub const Request = struct {
commands: []const []const u8 = &.{},
cwds: []const []const u8 = &.{},
shell: []const u8 = "",
+ /// Nonzero: `stop(token)` from another thread kills its commands.
+ token: u32 = 0,
};
/// Worker-owned snapshot. `copy` is intentionally called synchronously while
@@ -44,12 +46,14 @@ pub const Job = struct {
commands: [][]u8 = &.{},
cwds: [][]u8 = &.{},
shell: []u8 = &.{},
+ token: u32 = 0,
pub fn copy(gpa: std.mem.Allocator, request: Request) !*Job {
const job = try gpa.create(Job);
errdefer gpa.destroy(job);
job.* = .{
.id = request.id,
+ .token = request.token,
.command = try gpa.dupe(u8, request.command),
.cwd = &.{},
.inputs = &.{},
@@ -228,9 +232,71 @@ pub fn runOne(
cwd: []const u8,
input: []const u8,
) Outcome {
- return runIn(gpa, io, "", command, cwd, input);
+ return runIn(gpa, io, "", command, cwd, input, 0);
}
+/// The commands running for a stoppable request (`Request.token`), so
+/// another thread can stop them: each is a process group of its own, which
+/// `stop` kills whole, a `sleep` under the shell too.
+const Running = struct {
+ var lock: std.atomic.Mutex = .unlocked;
+ var procs: [64]struct { token: u32, pid: std.posix.pid_t } = undefined;
+ var len: usize = 0;
+ /// Tokens stopped lately: a command not started yet is not.
+ var stopped: [16]u32 = @splat(0);
+ var next: usize = 0;
+
+ fn take() void {
+ while (!lock.tryLock()) std.atomic.spinLoopHint();
+ }
+
+ fn isStopped(token: u32) bool {
+ return std.mem.indexOfScalar(u32, &stopped, token) != null;
+ }
+
+ /// Whether `pid` may run on: false once its token was stopped.
+ fn add(token: u32, pid: std.posix.pid_t) bool {
+ take();
+ defer lock.unlock();
+ if (isStopped(token)) return false;
+ if (len < procs.len) {
+ procs[len] = .{ .token = token, .pid = pid };
+ len += 1;
+ }
+ return true;
+ }
+
+ fn remove(pid: std.posix.pid_t) void {
+ take();
+ defer lock.unlock();
+ for (procs[0..len], 0..) |p, i| if (p.pid == pid) {
+ procs[i] = procs[len - 1];
+ len -= 1;
+ return;
+ };
+ }
+};
+
+/// Stops the request `token` names: its running commands' process groups
+/// are killed, and no command of it starts after. From any thread.
+pub fn stop(token: u32) void {
+ if (token == 0) return;
+ Running.take();
+ defer Running.lock.unlock();
+ Running.stopped[Running.next] = token;
+ Running.next = (Running.next + 1) % Running.stopped.len;
+ for (Running.procs[0..Running.len]) |p| if (p.token == token) {
+ std.posix.kill(-p.pid, .KILL) catch {};
+ };
+}
+
+/// A process-unique token for a stoppable request.
+pub fn newToken() u32 {
+ const t = next_token.fetchAdd(1, .monotonic);
+ return if (t == 0) next_token.fetchAdd(1, .monotonic) else t;
+}
+var next_token: std.atomic.Value(u32) = .init(1);
+
/// `runOne` through `shell -c` (/bin/sh when it is empty).
pub fn runIn(
gpa: std.mem.Allocator,
@@ -239,6 +305,7 @@ pub fn runIn(
command: []const u8,
cwd: []const u8,
input: []const u8,
+ token: u32,
) Outcome {
const fail = struct {
fn k(kind: Failure.Kind) Outcome {
@@ -263,7 +330,10 @@ pub fn runIn(
.stdin = .pipe,
.stdout = .pipe,
.stderr = .pipe,
+ // A group of its own: a stop or a timeout kills what it started too.
+ .pgid = 0,
}) catch return fail.k(.spawn);
+ const pid = child.id.?;
var writer_context: WriterContext = .{
.io = io,
@@ -280,6 +350,10 @@ pub fn runIn(
// stdin, then join the short-lived writer before its borrowed input dies.
defer if (writer) |thread| thread.join();
defer child.kill(io);
+ // Before the shell is reaped, while its group id cannot be another's.
+ defer if (child.id != null) std.posix.kill(-pid, .KILL) catch {};
+ defer if (token != 0) Running.remove(pid);
+ if (token != 0 and !Running.add(token, pid)) return fail.k(.signal);
var multi_reader_buffer: std.Io.File.MultiReader.Buffer(2) = undefined;
var multi_reader: std.Io.File.MultiReader = undefined;
@@ -306,6 +380,7 @@ pub fn runIn(
if (stdout_reader.buffered().len > 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);
+}