diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 12:44:57 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 6fc9f416938b59b665b1fddc052402770a21cc3f (patch) | |
| tree | 632e83ae787b6e1d231683514663188d3e73e4b9 | |
| parent | c8a3ecc3bcb36f0259f4d8ad2e0cdfcdc3183c9f (diff) | |
| download | pardes-6fc9f416938b59b665b1fddc052402770a21cc3f.tar.gz pardes-6fc9f416938b59b665b1fddc052402770a21cc3f.zip | |
A Restore answers its writer before hanging up, and the log records dumps and restores
A client that wrote Restore saw its connection cut with no answer, and could
not tell a Restore from a crash. The listener now lets the writer's answer out
before the cut, and cuts only the old editor's connections, refusing their
requests meanwhile; a client that dials during it is the new editor's and
stays. Dump logs 'dump <path>' and the restored editor's log 'restore <path>'.
Keeping connections across a Restore was weighed and left: the fids name the
old editor's panes and opens, so it would mean carrying serials and open
records into the new one, where acme's Load only adds windows. tty's Restore
also closed its shells' ptys without reaping them; it retires them now.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 3 | ||||
| -rw-r--r-- | docs/fs.md | 20 | ||||
| -rw-r--r-- | src/9p_io.zig | 63 | ||||
| -rw-r--r-- | src/detached/client.zig | 3 | ||||
| -rw-r--r-- | src/detached/server.zig | 8 | ||||
| -rw-r--r-- | src/dump.zig | 8 | ||||
| -rw-r--r-- | src/gui/gui.zig | 4 | ||||
| -rw-r--r-- | src/macos.zig | 2 | ||||
| -rw-r--r-- | src/ninep/events.zig | 23 | ||||
| -rw-r--r-- | src/pardes.zig | 7 | ||||
| -rw-r--r-- | src/tty/tty.zig | 3 | ||||
| -rw-r--r-- | test/fs.py | 12 | ||||
| -rw-r--r-- | test/fs_namespace.zig | 3 |
13 files changed, 123 insertions, 36 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 393b0b0f..5e3bc39c 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -51,7 +51,8 @@ $m/exec write a line = a middle click: an editor command word, or a shell $m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `error ...`, on the same open: exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 (a fresh terminal: waits for its first prompt; if none comes, interrupt the read) -$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text> +$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>, + dump|restore <path>, err <serial|-> <file>: <why> (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not) $m/screen the rendered screen as JSON, frozen per open $m/listeners this session's dial addresses @@ -84,7 +84,8 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /status pid, version and pane count /look write a line: a right click on it at the active pane; read: the serials it touched /exec write a line: a middle click; read the same serials -/log recent events, one a line: new|del|rename|save <serial> <name>, msg <serial|-> <text>; +/log recent events, one a line: new|del|rename|save <serial> <name>, msg <serial|-> <text>, + dump|restore <path>, err <serial|-> <file>: <why>; write follow to that open to wait for more /screen rendered screen JSON; frozen per open handle /listeners the session's dial addresses @@ -106,8 +107,17 @@ builtins, one a line, at whichever pane has the keyboard as each runs -- `Newcol`, `Dump`, `Mount name dial`, `Theme ink`, `Verbose off`; `Exit`, which quits the editor as acme's does (it refuses once, naming each pane with unsaved text, `<name>: Modified (Exit again to discard)`, and a second -`Exit` with nothing edited since quits, throwing that text away; a scratch under 100 bytes is not asked about; `Restore`, which -replaces every pane, asks the same first); and `Kill`, which +`Exit` with nothing edited since quits, throwing that text away; a scratch +under 100 bytes is not asked about; `Restore`, which replaces every pane, +asks the same first -- `Dump` writes `pardes-<date>-<time>.zon` in +`DumpDir` (default `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) +and logs `dump <path>`, and `Restore` with no path takes the last one; a +Restore puts a new editor under every client, so the write of it is +answered and then every connection is hung up, their fids naming the old +editor's panes: dial again, and the new log names the restored panes and +`restore <path>`. Keeping connections across it would mean carrying serials +and opens into the new editor, which acme, whose Load only adds windows, +never needed); and `Kill`, which does not quit but stops commands, as acme's does: bare, every command pardes started, and `Kill make ls`, those whose line begins with one of the words. A command pardes started is a line it typed into a terminal (a word written to @@ -333,7 +343,9 @@ an open renders its frame; stat the entry. `/log` is one ring (64 KiB) that records whether or not anyone reads it: `new`, `del`, `rename` (a terminal's too, as its shell changes directory, -since a terminal is named by its directory) and `save <serial> <name>`, and `msg <serial|-> <text>` +since a terminal is named by its directory) and `save <serial> <name>`, +`dump <path>` when a Dump is written and `restore <path>` in a Restore's +new log after its panes' `new`s, and `msg <serial|-> <text>` for every line the editor says, repeats included (with `verbose` on, that includes each builtin announcing itself as it runs), and `err <serial|-> <file>: <why>` for every write or truncation the tree refused or that diff --git a/src/9p_io.zig b/src/9p_io.zig index d6eef9d4..393b6210 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -174,6 +174,8 @@ pub const Listener = struct { core: *pardes.Pardes, runner: Runner = undefined, stopping: std.atomic.Value(bool) = .init(false), + /// A connection wrote the Restore and has yet to answer it. + restore_writer: std.atomic.Value(bool) = .init(false), tcp_address: ?std.Io.net.IpAddress = null, quic: if (quic_enabled) ?quic.Listener else void = if (quic_enabled) null else {}, quic_address: ?std.Io.net.IpAddress = null, @@ -217,6 +219,12 @@ pub const Listener = struct { } const quiet = pardes.turn.take(); defer pardes.turn.give(); + // A connection a Restore is cutting (`reset`) is the old editor's + // client: its fids name the old editor's panes and opens. + if (conn.user != null) { + const refused = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO); + return conn.reply(&refused, ""); + } if (!quiet and pardes.ctlfs.needsQuiet(req)) { pardes.turn.parked = true; const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again }; @@ -267,13 +275,20 @@ pub const Listener = struct { // and `deinit` lets the answer out before it cuts the connections. if (core.quit) return conn.reply(&reply, ""); 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); + defer if (restoring) l.restore_writer.store(false, .release); if (core.effects_len != 0) pardes.turn.awaitSettled(epoch); + // The Restore's own write is answered once it is done, and before + // `reset` hangs every connection up, this one too, so the writer + // hears that it happened rather than a cut; a failed one leaves the + // connection and says why on the message row and in the log. + if (restoring) { + if (l.core == core) pardes.turn.awaitRestored(restores); + return conn.reply(&reply, ""); + } // Once a wait returns `core` may be gone: a Restore meanwhile put a - // replacement in (`reset`) and is hanging this connection up, which - // is a Restore's answer. Only one that failed leaves this connection - // here to answer. - if (l.core != core) return; - if (restoring) pardes.turn.awaitRestored(restores); + // replacement in and is hanging this connection up. if (l.core != core) return; conn.reply(&reply, ""); } @@ -484,9 +499,9 @@ pub const Listener = struct { /// Hangs every connection up, so that `replacement` starts with no /// client holding anything. From here requests are answered from the /// replacement -- a client that connects meanwhile is a client of the - /// replacement -- and every task waiting on the old core is let go: it - /// finds the core changed and answers nothing, its connection being - /// hung up. The connections pay their releases on their own tasks, so + /// replacement -- and every task waiting on the old core is let go: the + /// Restore's own writer answers, the rest find the core changed and + /// answer nothing, their connections being hung up. The connections pay their releases on their own tasks, so /// the editor rests while they do. pub fn reset(l: *Listener, replacement: *pardes.Pardes) void { for (0..quic_slots) |i| l.drop(@intCast(i)); @@ -496,12 +511,40 @@ pub const Listener = struct { replacement.fs.tcp_address = l.tcp_address; replacement.fs.quic_address = l.quic_address; if (comptime supported) { - l.runner.closeAll(); + // The Restore's writer answers as it wakes (`onServe`), and its + // answer gets 200 ms to leave before the cut; every other request + // from the old editor's clients meanwhile is refused, since the + // fids it names are the old editor's. A client that connects now + // is the replacement's, and stays. + // `user` marks the connections to cut: cloud9 leaves it to us and + // clears it for a new connection in the slot. + for (&l.runner.conns) |*conn| if (conn.live()) { + conn.user = l; + }; pardes.turn.settle(); pardes.turn.restoreSettled(); pardes.turn.rest(); + const flush_by = nowMs() +| 200; + while (nowMs() < flush_by) { + const pending = l.restore_writer.load(.acquire) or for (&l.runner.conns) |*conn| { + if (conn.user == null or !conn.live()) continue; + conn.lock(); + const n = conn.engine.output().len; + conn.unlock(); + if (n != 0) break true; + } else false; + if (!pending) break; + Client.nap(1); + } + for (&l.runner.conns) |*conn| if (conn.user != null and conn.live()) conn.close(); const deadline = nowMs() +| 2000; - while (l.runner.count() != 0 and nowMs() < deadline) Client.nap(1); + while (nowMs() < deadline) { + const left = for (&l.runner.conns) |*conn| { + if (conn.user != null and conn.live()) break true; + } else false; + if (!left) break; + Client.nap(1); + } pardes.turn.wake(); } l.collectOs(); diff --git a/src/detached/client.zig b/src/detached/client.zig index 4468c961..ba533751 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -838,6 +838,7 @@ test "detached Restore keeps attached frontends and follows queued frames with a const fd = h.session.clients[c.slot].fd; try testing.expectError(error.BadDumpMagic, h.session.restore( ".{ .magic = \"not-a-pardes-dump\", .theme = \"dark\", .screen = .{ .cols = 60, .rows = 16 } }", + "test", )); try testing.expectEqual(before, h.session.core); try testing.expectEqual(fd, h.session.clients[c.slot].fd); @@ -848,7 +849,7 @@ test "detached Restore keeps attached frontends and follows queued frames with a defer testing.allocator.free(saved); while (h.core.nextEffect()) |_| {} _ = try h.core.setTestFile("changed after dump\n"); - try h.session.restore(saved); + try h.session.restore(saved, "test"); h.core = h.session.core; try testing.expect(h.core != before); try testing.expectEqual(fd, h.session.clients[c.slot].fd); diff --git a/src/detached/server.zig b/src/detached/server.zig index 5c0e1487..b088d833 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -233,8 +233,8 @@ pub const Session = struct { return batch.len != 0 or batch.status != null; } - pub fn restore(s: *Session, bytes: []const u8) !void { - const replacement = try dump.restore(s.core, bytes); + pub fn restore(s: *Session, bytes: []const u8, from: []const u8) !void { + const replacement = try dump.restore(s.core, bytes, from); s.cancelWorkers(); for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); s.harvest(); @@ -1183,7 +1183,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void break :restore; }; defer gpa.free(bytes); - session.restore(bytes) catch |err| session.core.reportError(session.core.active, "Restore", err); + session.restore(bytes, path) catch |err| session.core.reportError(session.core.active, "Restore", err); } } } @@ -1223,7 +1223,7 @@ test "detached queued results preserve current requests and are discarded before try std.testing.expect(s.pipe_tasks.add(.{ .id = 77, .future = .{ .any_future = null, .result = {} } })); s.mailbox.post(.{ .pipe = .{ .id = 77, .success = true, .outputs = outputs } }); Session.lspStatus(&s, "old status"); - try s.restore(saved); + try s.restore(saved, "test"); try std.testing.expect(s.lsp_task == null); try std.testing.expectEqual(@as(usize, 0), s.pipe_tasks.len); try std.testing.expect(!s.drainCompletions(true)); diff --git a/src/dump.zig b/src/dump.zig index 508e7300..3d0f0f0e 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -671,11 +671,15 @@ pub fn dumpState(p: *Pardes) !void { p.emit(.write_dump); } -pub fn restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { +/// The replacement core for a Restore of the dump at `from`, whose log says +/// so after its panes' `new`s, for a client that reconnects to read. +pub fn restore(p: *Pardes, zon_bytes: []const u8, from: []const u8) !*Pardes { var opts = p.opts; opts.cols = p.screen_w; opts.rows = p.screen_h; - return initDump(p.gpa, opts, zon_bytes, p); + const replacement = try initDump(p.gpa, opts, zon_bytes, p); + pardes.ctlfs.events.notePath(replacement, "restore", from); + return replacement; } pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 6978284b..9ada305b 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1322,7 +1322,7 @@ test "GUI PTY Restore joins a real reader waiting for queue space" { try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); try PtyTests.waitBlocked(&queue); const old_serial = core.panes[0].?.serial; - const replacement = try dump.restore(core, core.dump_out.?); + const replacement = try dump.restore(core, core.dump_out.?, "test"); shell.stopPtys(); core.deinit(); core = replacement; @@ -2561,7 +2561,7 @@ fn localSession( break :blk; }; defer gpa.free(bytes); - const nc = dump.restore(core, bytes) catch |err| { + const nc = dump.restore(core, bytes, rp) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; diff --git a/src/macos.zig b/src/macos.zig index 62858887..7bfa6c49 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1345,7 +1345,7 @@ fn restoreCore(st: *State) bool { return false; }; defer st.gpa.free(bytes); - const replacement = dump.restore(st.core, bytes) catch |err| { + const replacement = dump.restore(st.core, bytes, path) catch |err| { st.core.reportError(st.core.active, "Restore", err); return false; }; diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 69dc271d..1cab5605 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -7,6 +7,7 @@ const look = @import("../look.zig"); const cloud9 = @import("cloud9"); const tree = @import("tree.zig"); const pane_files = @import("pane.zig"); +const dump = @import("../dump.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; @@ -155,6 +156,13 @@ pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void { /// kernel mapped the reply to (`Invalid argument`), and here is the reason. /// The log is the one place for it, as acme's `errors` file takes text and /// answers nothing: a per-pane readable error file would be a second. +/// `dump <path>` when a Dump is written, `restore <path>` first in a +/// Restore's replacement. +pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void { + var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; + pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return); +} + pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno); var name: [16]u8 = undefined; @@ -870,6 +878,21 @@ test "a refused or failed write is an err record in the log, saying which file a _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g }); } +test "a Dump written and a Restore made are in the log, with their files" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + p.setLastDump("/tmp/pardes.dump.zon"); + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); + defer restored.deinit(); + for ([_]*Pardes{ p, restored }, [_][]const u8{ "dump", "restore" }) |core, what| { + const log = try freezeLog(core); + defer core.gpa.free(log.bytes); + var want: [64]u8 = undefined; + try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what}))); + } +} + test "opens of the log share the open records, and a closed one frees its record" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); diff --git a/src/pardes.zig b/src/pardes.zig index 6e78380a..c049fa39 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1770,7 +1770,7 @@ test "owned cwd restore either copies the directory or preserves the old core" { for (0..256) |failure| { allocator.has_induced_failure = false; allocator.fail_index = allocator.alloc_index + failure; - const replacement = dump.restore(p, p.dump_out.?) catch { + const replacement = dump.restore(p, p.dump_out.?, "test") catch { try std.testing.expect(allocator.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); @@ -2722,14 +2722,14 @@ test "editable workspace and column tags are typed into and persist" { try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); - const restored = try dump.restore(p, p.dump_out.?); + const restored = try dump.restore(p, p.dump_out.?, "test"); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); - const empty = try dump.restore(p, p.dump_out.?); + const empty = try dump.restore(p, p.dump_out.?, "test"); defer empty.deinit(); try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } @@ -4095,6 +4095,7 @@ pub const Pardes = struct { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| p.gpa.free(old); p.last_dump = copy; + ctlfs.events.notePath(p, "dump", path); } /// the shell polls this each frame: a pending Restore's dump path, or null diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 32f9138f..4c1e2db7 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -803,7 +803,7 @@ fn localSession( break :blk; }; defer gpa.free(bytes); - const nc = dump.restore(core, bytes) catch |err| { + const nc = dump.restore(core, bytes, rp) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; @@ -816,6 +816,7 @@ fn localSession( for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); + host_io.retireShell(pt.pid); slot.* = null; }; for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( @@ -602,23 +602,23 @@ def test(binary, quic=False): assert old.read_fid(held_screen, count=1) == b'{' old.write('/pane/1/body', b'changed after dump\n', truncate=True) # Unsaved text is asked about once, as Exit asks; the second - # Restore goes ahead. + # Restore goes ahead, and is answered before the hang-up. try: execute(old, control, 'Restore ' + str(saved)) except OSError as refused: assert 'Modified' in str(refused), refused assert old.read('/pane/1/body') == b'changed after dump\n' - try: - execute(old, control, 'Restore ' + str(saved)) - except (EOFError, ConnectionResetError, BrokenPipeError): - pass + execute(old, control, 'Restore ' + str(saved)) + # Refused while the connections are cut, or cut already. try: old.read_fid(held_body) - except (EOFError, ConnectionResetError, BrokenPipeError): + except (EOFError, OSError): pass else: raise AssertionError('Restore left an old body fid usable') with Client(address) as restored: + # The new log names its panes, then where they came from. + assert restored.read('/log').endswith(b'\nrestore ' + str(saved).encode() + b'\n'), restored.read('/log') restored_ids = [int(row.split()[0]) for row in restored.read('/index').splitlines()] assert len(restored_ids) == 2 and old_ids.isdisjoint(restored_ids), restored_ids file_id, control_id = restored_ids diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig index 822577d8..2f78ca1a 100644 --- a/test/fs_namespace.zig +++ b/test/fs_namespace.zig @@ -88,7 +88,7 @@ test "transactional restore preserves host capabilities and advances pane identi p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; p.fs.quic_address = .{ .ip6 = .loopback(5641) }; try pardes.dump.dumpState(p); - const restored = try pardes.dump.restore(p, p.dump_out.?); + const restored = try pardes.dump.restore(p, p.dump_out.?, "test"); defer restored.deinit(); try std.testing.expectEqual(@as(u32, 41), restored.lsp_seq); try std.testing.expectEqual(@as(u32, 73), restored.pipe.seq); @@ -103,6 +103,7 @@ test "transactional restore preserves host capabilities and advances pane identi try std.testing.expectError(error.BadDumpMagic, pardes.dump.restore( p, ".{ .magic = \"wrong\", .screen = .{.cols = 80, .rows = 24} }", + "test", )); try std.testing.expect(p.panes[0] == pane); try std.testing.expectEqualStrings("before\n", pane.file.?.content); |
