diff --git a/app/watch/SettingsWatcher.zig b/app/watch/SettingsWatcher.zig index d6c79691..15aaacb7 100644 --- a/app/watch/SettingsWatcher.zig +++ b/app/watch/SettingsWatcher.zig @@ -58,6 +58,8 @@ const Impl = if (have_impl) struct { /// `@fieldParentPtr` alignment dance from the nested `impl` field. raw_dirty: ?*std.atomic.Value(bool) = null, binary_dirty: ?*std.atomic.Value(bool) = null, + /// The watched folder, so an event can be placed in it (`classify`). + config_folder: []const u8 = "", const vtable = Handler.VTable{ .change = onChange, @@ -66,7 +68,7 @@ const Impl = if (have_impl) struct { fn note(h: *Handler, path: []const u8) void { const impl: *Impl = @fieldParentPtr("handler", h); - const flag = switch (classify(path)) { + const flag = switch (classify(impl.config_folder, path)) { .ignored => return, .binary => impl.binary_dirty, .other => impl.raw_dirty, @@ -96,8 +98,16 @@ const Kind = enum { binary, ignored, other }; /// same. The `.part` itself, and fizzy's own load copies (`.load-copy/`, `.load-tmp/`, written when /// it loads a plugin), say nothing new. Everything else — `settings.zon` above all, which an editor /// may write in several bursts — waits for the coalesce. -fn classify(path: []const u8) Kind { +fn classify(config_folder: []const u8, path: []const u8) Kind { if (std.mem.indexOf(u8, path, ".load-copy") != null or std.mem.indexOf(u8, path, ".load-tmp") != null) return .ignored; + // The app's own state beside its settings, written while it runs: a crash checkpoint every few + // seconds while something is unsaved, a restart's session, a handover's signals. None of it is + // settings or a plugin, and reconciling on each write would re-read `settings.zon` for nothing. + if (std.mem.startsWith(u8, path, config_folder) and path.len > config_folder.len and std.fs.path.isSep(path[config_folder.len])) { + const rest = path[config_folder.len + 1 ..]; + const top = rest[0 .. std.mem.indexOfAny(u8, rest, "/\\") orelse rest.len]; + for (state_folders) |name| if (std.mem.eql(u8, top, name)) return .ignored; + } const ext = switch (builtin.os.tag) { .windows => ".dll", .macos => ".dylib", @@ -108,18 +118,29 @@ fn classify(path: []const u8) Kind { return .other; } +/// Top-level folders of the config folder that hold the app's running state, not settings. +const state_folders = [_][]const u8{ "checkpoint", "checkpoint.new", "session", "handover" }; + test classify { const ext = switch (builtin.os.tag) { .windows => ".dll", .macos => ".dylib", else => ".so", }; - try std.testing.expectEqual(Kind.binary, classify("/cfg/plugins/hello/hello" ++ ext)); - try std.testing.expectEqual(Kind.ignored, classify("/cfg/plugins/hello/hello" ++ ext ++ ".part")); - try std.testing.expectEqual(Kind.ignored, classify("/cfg/plugins/hello/.load-copy/12-34-hello" ++ ext)); - try std.testing.expectEqual(Kind.ignored, classify("/cfg/plugins/hello/.load-tmp/3-hello" ++ ext)); - try std.testing.expectEqual(Kind.other, classify("/cfg/settings.zon")); - try std.testing.expectEqual(Kind.other, classify("/cfg/plugins/hello")); + try std.testing.expectEqual(Kind.binary, classify("/cfg", "/cfg/plugins/hello/hello" ++ ext)); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/plugins/hello/hello" ++ ext ++ ".part")); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/plugins/hello/.load-copy/12-34-hello" ++ ext)); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/plugins/hello/.load-tmp/3-hello" ++ ext)); + try std.testing.expectEqual(Kind.other, classify("/cfg", "/cfg/settings.zon")); + try std.testing.expectEqual(Kind.other, classify("/cfg", "/cfg/plugins/hello")); + // The app's own state: never settings, never a plugin. + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/checkpoint/3-0.state")); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/checkpoint")); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/checkpoint.new/session.zon")); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/session/session.zon")); + try std.testing.expectEqual(Kind.ignored, classify("/cfg", "/cfg/handover/ready")); + // A plugin that happens to share a name is still a plugin. + try std.testing.expectEqual(Kind.binary, classify("/cfg", "/cfg/plugins/checkpoint/checkpoint" ++ ext)); } /// Sets up bookkeeping but does **not** start nightwatch yet — see `start`'s doc comment. @@ -144,6 +165,7 @@ pub fn start(self: *SettingsWatcher) !void { const nightwatch = @import("nightwatch"); self.impl.raw_dirty = &self.raw_dirty; self.impl.binary_dirty = &self.binary_dirty; + self.impl.config_folder = self.config_folder; var nw = try nightwatch.Default.init(dvui.io, self.gpa, &self.impl.handler); errdefer nw.deinit(); try nw.watch(self.config_folder); diff --git a/src/editor/Checkpoint.zig b/src/editor/Checkpoint.zig new file mode 100644 index 00000000..190aa41c --- /dev/null +++ b/src/editor/Checkpoint.zig @@ -0,0 +1,213 @@ +//! Unsaved work that survives a crash (plans/RESTART_AND_CRASHES_PLAN.md, step 4). While a +//! document has unsaved changes, every open document is written as a restart writes them +//! (`KeptDocuments`) to `/checkpoint/`, every few seconds after the person has done +//! something and at least every half minute regardless (an agent's edits come with no input). A +//! clean quit empties it, so one found at launch means the last run ended without one: a crash, a +//! kill, a power cut. Its documents then open from it, unsaved changes and all, the way a restart's +//! session opens (`Editor.session`). +//! +//! The state is captured on the UI thread, since only the owners can capture it, and written on a +//! thread of its own, so a large document never costs a frame its write. Each checkpoint is a new +//! generation of files in the same folder (`KeptDocuments.saveGeneration`), counted from when +//! `session.zon` is renamed over the last: a crash mid-write leaves the last whole one. The folder +//! is never swapped: the settings watcher watches the config folder, and a folder that appears +//! and is renamed away before it is watched made it log an error on Linux (inotify's NOENT). +const Checkpoint = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const Editor = @import("Editor.zig"); +const KeptDocuments = @import("KeptDocuments.zig"); + +/// The soonest a checkpoint follows the last one, after input. +const after_input_ns: i96 = 3 * std.time.ns_per_s; +/// The longest unsaved work goes without one. +const at_most_ns: i96 = 30 * std.time.ns_per_s; +/// How often the documents are looked at for unsaved changes: a dirty flag each, no capture. +const look_ns: i96 = std.time.ns_per_s; + +pub const supported = builtin.target.cpu.arch != .wasm32; + +/// When the last checkpoint was taken. +last_ns: i96 = 0, +/// When the documents were last looked at. +looked_ns: i96 = 0, +/// Whether they had unsaved changes then. +unsaved: bool = false, +/// Input arrived since the last checkpoint. +touched: bool = false, +/// A checkpoint is on disk from this run. +written: bool = false, +/// The write in flight, if any. +job: ?*Job = null, +/// Said once that a document's unsaved changes cannot be kept, not every few seconds. +warned: bool = false, +/// The last checkpoint's generation in this run (`KeptDocuments.saveGeneration`). +generation: u32 = 0, + +const Job = struct { + kept: KeptDocuments, + dir: []u8, + generation: u32, + thread: std.Thread = undefined, + done: std.atomic.Value(bool) = .init(false), + ok: bool = false, +}; + +/// `/checkpoint`, owned by `gpa`. +pub fn dir(gpa: std.mem.Allocator, config_folder: []const u8) ![]u8 { + return std.fs.path.join(gpa, &.{ config_folder, "checkpoint" }); +} + +/// At launch: the documents a run that did not end cleanly left, read once (the folder is +/// deleted). Null when the last run quit cleanly, or `skip` (a restart's session, which is newer, +/// was found) — the checkpoint is deleted either way. +pub fn recover(gpa: std.mem.Allocator, config_folder: []const u8, skip: bool) ?KeptDocuments { + if (comptime !supported) return null; + const path = dir(gpa, config_folder) catch return null; + defer gpa.free(path); + const next = std.fmt.allocPrint(gpa, "{s}.new", .{path}) catch return null; + defer gpa.free(next); + const cwd = std.Io.Dir.cwd(); + if (skip) { + cwd.deleteTree(dvui.io, path) catch {}; + cwd.deleteTree(dvui.io, next) catch {}; + return null; + } + // An older build wrote the next checkpoint here before swapping it in. + cwd.deleteTree(dvui.io, next) catch {}; + // Without its `session.zon`, the first checkpoint was cut off mid-write: of no use to anyone. + if (!complete(path)) { + cwd.deleteTree(dvui.io, path) catch {}; + return null; + } + var kept = KeptDocuments.loadSession(gpa, path) orelse return null; + if (kept.docs.items.len == 0) { + kept.deinit(); + return null; + } + return kept; +} + +fn complete(path: []const u8) bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const zon = std.fmt.bufPrint(&buf, "{s}/session.zon", .{path}) catch return false; + std.Io.Dir.cwd().access(dvui.io, zon, .{}) catch return false; + return true; +} + +/// Once a frame. +pub fn tick(self: *Checkpoint, editor: *Editor) void { + if (comptime !supported) return; + if (dvui.events().len > 0) self.touched = true; + if (self.job) |job| if (job.done.load(.acquire)) { + job.thread.join(); + if (job.ok) self.written = true; + self.freeJob(editor, job); + }; + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + // Looked up every frame, which keeps a pending timer alive (dvui drops one nobody asks after). + const timer_id = dvui.currentWindow().data().id.update("checkpoint"); + const timer_free = dvui.timerDoneOrNone(timer_id); + + if (now - self.looked_ns >= look_ns) { + self.looked_ns = now; + self.unsaved = for (editor.app.open_files.values()) |doc| { + if (doc.owner.isDirty(doc)) break true; + } else false; + if (!self.unsaved) { + // Everything saved (or closed): nothing to recover, so no checkpoint to recover it from. + if (self.written and self.job == null) self.remove(editor); + self.touched = false; + } else if (self.job == null and self.due(now)) { + self.last_ns = now; + self.touched = false; + self.start(editor) catch |err| { + if (!self.warned) dvui.log.warn("checkpoint: unsaved work is not kept against a crash ({t})", .{err}); + self.warned = true; + }; + } + } + + // fizzy draws a frame only when something happens: one is asked for when the next look is due, + // or input that came just after a look would wait for the next unrelated event to be kept. + if (timer_free) if (self.nextLook()) |at| { + const micros = @divFloor(@max(at - now, std.time.ns_per_ms), std.time.ns_per_us); + dvui.timer(timer_id, @intCast(@min(micros, std.math.maxInt(i32)))); + }; +} + +/// Whether unsaved work should be written now: not within a few seconds of the last checkpoint, +/// and then after input, when there is none yet, or when the last is half a minute old. +fn due(self: *const Checkpoint, now: i96) bool { + const since = now - self.last_ns; + if (since < after_input_ns) return false; + return self.touched or !self.written or since >= at_most_ns; +} + +/// When the documents should next be looked at, or null when nothing could need keeping. +fn nextLook(self: *const Checkpoint) ?i96 { + if (self.touched or self.job != null or (self.unsaved and !self.written)) return self.looked_ns + look_ns; + if (self.unsaved) return self.last_ns + at_most_ns; + return null; +} + +fn start(self: *Checkpoint, editor: *Editor) !void { + const gpa = editor.app.gpa; + // A document whose owner cannot capture its unsaved changes (no state hook) fails the + // capture, as it stops a restart from keeping documents: then there is no checkpoint. + var kept = try KeptDocuments.capture(editor, null); + errdefer kept.deinit(); + const path = try dir(gpa, editor.app.config_folder); + errdefer gpa.free(path); + const job = try gpa.create(Job); + errdefer gpa.destroy(job); + self.generation += 1; + job.* = .{ .kept = kept, .dir = path, .generation = self.generation }; + job.thread = try std.Thread.spawn(.{}, write, .{job}); + self.job = job; +} + +/// The worker: the next generation, written in place. +fn write(job: *Job) void { + defer job.done.store(true, .release); + job.kept.saveGeneration(job.dir, job.generation) catch return; + job.ok = true; +} + +fn freeJob(self: *Checkpoint, editor: *Editor, job: *Job) void { + job.kept.deinit(); + editor.app.gpa.free(job.dir); + editor.app.gpa.destroy(job); + self.job = null; +} + +/// Empty the checkpoint: its files go, and the folder stays, for the same reason it is never +/// swapped (a folder that vanishes under the settings watcher as it is noticed). Empty, it is +/// nothing to recover; the next launch deletes it before any watcher starts (`recover`). +fn remove(self: *Checkpoint, editor: *Editor) void { + const path = dir(editor.app.gpa, editor.app.config_folder) catch return; + defer editor.app.gpa.free(path); + self.written = false; + var d = std.Io.Dir.cwd().openDir(dvui.io, path, .{ .iterate = true }) catch return; + defer d.close(dvui.io); + // `session.zon` first: without it, what is left is not a checkpoint. + d.deleteFile(dvui.io, "session.zon") catch {}; + var it = d.iterate(); + while (it.next(dvui.io) catch null) |entry| { + if (entry.kind == .file) d.deleteFile(dvui.io, entry.name) catch {}; + } +} + +/// A clean quit: wait for a write in flight, then empty the checkpoint, so the next launch +/// recovers nothing. Whatever the quit decided about unsaved documents (saved, discarded, kept +/// for a restart) stands. +pub fn deinit(self: *Checkpoint, editor: *Editor) void { + if (comptime !supported) return; + if (self.job) |job| { + job.thread.join(); + self.freeJob(editor, job); + } + self.remove(editor); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 1426aefe..f1f0f093 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -79,6 +79,7 @@ const FolderWatcher = Watch.FolderWatcher; pub const Workspace = workbench_mod.Workspace; pub const KeptDocuments = @import("KeptDocuments.zig"); +pub const Checkpoint = @import("Checkpoint.zig"); pub const Explorer = @import("explorer/Explorer.zig"); pub const IgnoreRules = @import("explorer/IgnoreRules.zig"); pub const Sidebar = @import("Sidebar.zig"); @@ -134,6 +135,8 @@ session: ?KeptDocuments = null, /// Frames the session has been read for: its documents open in the first, its panes' selections /// apply in the next. session_frames: u8 = 0, +/// Unsaved work kept against a crash (`Checkpoint`). +checkpoint: Checkpoint = .{}, /// The executable watcher could not start: not tried again every frame (`syncExecutableWatcher`). executable_watch_failed: bool = false, /// The `plugins` service, registered at this address in `postInit`. @@ -422,6 +425,12 @@ pub fn init( defer app.allocator.free(dir); editor.session = KeptDocuments.loadSession(editor.app.gpa, dir); } else |_| {} + // The last run ended without quitting (a crash, a kill): what it had unsaved comes back + // the way a restart's documents do. A restart's session is newer, and wins. + if (Checkpoint.recover(editor.app.gpa, editor.app.config_folder, editor.session != null)) |kept| { + editor.session = kept; + KeptDocuments.noteRecoveredFromCheckpoint(); + } } // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`). @@ -3839,6 +3848,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (editor.app.settings_watcher) |*w| w.tick(editor.configWatchSink()); // fizzy itself rebuilt: start again as the new build, every document kept. editor.syncExecutableWatcher(); + // Unsaved work kept against a crash. + editor.checkpoint.tick(editor); if (editor.app.executable_watcher) |*w| if (w.tick() and !restart.requested()) { dvui.log.info("restart: this executable was rebuilt; restarting into the new build", .{}); restart.request(); @@ -3974,6 +3985,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { kept.finishDocuments(editor); editor.session_frames += 1; dvui.refresh(null, @src(), null); + if (KeptDocuments.recoveredFromCheckpoint()) + dvui.toast(@src(), .{ .message = "fizzy did not quit cleanly last time. Its unsaved changes are back." }); } } } @@ -5984,6 +5997,8 @@ pub fn deinit(editor: *Editor) !void { } if (editor.app.executable_watcher) |*w| w.deinit(); editor.app.executable_watcher = null; + // A clean quit: nothing to recover at the next launch. + editor.checkpoint.deinit(editor); // Rebuilds still opening: their threads joined, what they opened dropped unregistered. editor.plugin_reloads.deinit(editor.app.gpa); if (editor.session) |*kept| kept.deinit(); diff --git a/src/editor/KeptDocuments.zig b/src/editor/KeptDocuments.zig index 73da2f1e..1dbc4402 100644 --- a/src/editor/KeptDocuments.zig +++ b/src/editor/KeptDocuments.zig @@ -202,9 +202,12 @@ fn loadInto(editor: *Editor, plugin: *sdk.Plugin, d: Kept, grouping: u64) !sdk.D } /// What `save` writes as `session.zon`, beside a file per document's state and unsaved contents -/// (named by its index, so no path has to be made safe as a file name). +/// (named by its index, so no path has to be made safe as a file name; and by `generation` when +/// the folder is rewritten in place, `saveGeneration`). const Saved = struct { version: u32 = session_version, + /// The files' generation, `-.`; 0 for plain `.`. + generation: u32 = 0, active: []const u8 = "", docs: []const Doc = &.{}, @@ -227,6 +230,18 @@ const Saved = struct { /// misread: its documents open from disk. const session_version = 1; +/// This run opened documents a crashed run left (`Checkpoint.recover`): its unsaved changes are +/// back. For a notice at launch to say so. +var recovered_from_checkpoint = false; + +pub fn recoveredFromCheckpoint() bool { + return recovered_from_checkpoint; +} + +pub fn noteRecoveredFromCheckpoint() void { + recovered_from_checkpoint = true; +} + /// `/session`, owned by `gpa`. pub fn sessionDir(gpa: std.mem.Allocator, config_folder: []const u8) ![]u8 { return std.fs.path.join(gpa, &.{ config_folder, "session" }); @@ -235,17 +250,56 @@ pub fn sessionDir(gpa: std.mem.Allocator, config_folder: []const u8) ![]u8 { /// Write these documents to `dir`, replacing any session there. Only documents with state are /// worth keeping: the rest open from disk as any launch does. pub fn save(self: *const KeptDocuments, dir: []const u8) !void { - const gpa = self.gpa; const cwd = std.Io.Dir.cwd(); cwd.deleteTree(dvui.io, dir) catch {}; try cwd.createDirPath(dvui.io, dir); + const text = try self.writeFiles(dir, 0); + defer self.gpa.free(text); + const file = try std.fs.path.join(self.gpa, &.{ dir, "session.zon" }); + defer self.gpa.free(file); + try cwd.writeFile(dvui.io, .{ .sub_path = file, .data = text }); +} + +/// Write these documents into `dir` in place, as generation `generation` (above 0, a new one each +/// time): its files beside the last one's, then `session.zon` renamed over the last, which is the +/// moment the new one counts, then the last one's files removed. A crash at any point leaves one +/// whole generation to read. The folder itself is never replaced: a file watcher on a tree around +/// it (`SettingsWatcher`) sees files come and go, not folders appearing and vanishing under it. +pub fn saveGeneration(self: *const KeptDocuments, dir: []const u8, generation: u32) !void { + const gpa = self.gpa; + const cwd = std.Io.Dir.cwd(); + try cwd.createDirPath(dvui.io, dir); + const text = try self.writeFiles(dir, generation); + defer gpa.free(text); + const part = try std.fs.path.join(gpa, &.{ dir, "session.zon.part" }); + defer gpa.free(part); + const file = try std.fs.path.join(gpa, &.{ dir, "session.zon" }); + defer gpa.free(file); + try cwd.writeFile(dvui.io, .{ .sub_path = part, .data = text }); + try std.Io.Dir.renameAbsolute(part, file, dvui.io); + // The last generation's files: everything but this one's and `session.zon`. + var d = cwd.openDir(dvui.io, dir, .{ .iterate = true }) catch return; + defer d.close(dvui.io); + var prefix_buf: [16]u8 = undefined; + const prefix = std.fmt.bufPrint(&prefix_buf, "{d}-", .{generation}) catch return; + var it = d.iterate(); + while (it.next(dvui.io) catch null) |entry| { + if (entry.kind != .file or std.mem.eql(u8, entry.name, "session.zon") or std.mem.startsWith(u8, entry.name, prefix)) continue; + d.deleteFile(dvui.io, entry.name) catch {}; + } +} + +/// Each document's files, written into `dir` as `generation`'s; `session.zon`'s text, owned by +/// the caller, which writes it. +fn writeFiles(self: *const KeptDocuments, dir: []const u8, generation: u32) ![]u8 { + const gpa = self.gpa; var docs: std.ArrayListUnmanaged(Saved.Doc) = .empty; defer docs.deinit(gpa); for (self.docs.items) |d| { const state = d.state orelse continue; const i = docs.items.len; - try writeBlob(gpa, dir, i, "state", state); - if (d.unsaved) |u| try writeBlob(gpa, dir, i, "unsaved", u); + try writeBlob(gpa, dir, generation, i, "state", state); + if (d.unsaved) |u| try writeBlob(gpa, dir, generation, i, "unsaved", u); try docs.append(gpa, .{ .path = d.path, .owner = d.owner, @@ -260,23 +314,26 @@ pub fn save(self: *const KeptDocuments, dir: []const u8) !void { }); } var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - try std.zon.stringify.serialize(Saved{ .active = self.active orelse "", .docs = docs.items }, .{}, &aw.writer); - const file = try std.fs.path.join(gpa, &.{ dir, "session.zon" }); - defer gpa.free(file); - try cwd.writeFile(dvui.io, .{ .sub_path = file, .data = aw.written() }); + errdefer aw.deinit(); + try std.zon.stringify.serialize(Saved{ .generation = generation, .active = self.active orelse "", .docs = docs.items }, .{}, &aw.writer); + return aw.toOwnedSlice(); +} + +fn blobName(gpa: std.mem.Allocator, generation: u32, i: usize, kind: []const u8) ![]u8 { + if (generation == 0) return std.fmt.allocPrint(gpa, "{d}.{s}", .{ i, kind }); + return std.fmt.allocPrint(gpa, "{d}-{d}.{s}", .{ generation, i, kind }); } -fn writeBlob(gpa: std.mem.Allocator, dir: []const u8, i: usize, kind: []const u8, bytes: []const u8) !void { - const name = try std.fmt.allocPrint(gpa, "{d}.{s}", .{ i, kind }); +fn writeBlob(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, kind: []const u8, bytes: []const u8) !void { + const name = try blobName(gpa, generation, i, kind); defer gpa.free(name); const file = try std.fs.path.join(gpa, &.{ dir, name }); defer gpa.free(file); try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = file, .data = bytes }); } -fn readBlob(gpa: std.mem.Allocator, dir: []const u8, i: usize, kind: []const u8) ![]u8 { - const name = try std.fmt.allocPrint(gpa, "{d}.{s}", .{ i, kind }); +fn readBlob(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, kind: []const u8) ![]u8 { + const name = try blobName(gpa, generation, i, kind); defer gpa.free(name); const file = try std.fs.path.join(gpa, &.{ dir, name }); defer gpa.free(file); @@ -301,7 +358,7 @@ pub fn loadSession(gpa: std.mem.Allocator, dir: []const u8) ?KeptDocuments { if (saved.version != session_version) return null; var self: KeptDocuments = .{ .gpa = gpa }; for (saved.docs, 0..) |d, i| { - const kept = readKept(gpa, dir, i, d) catch |err| { + const kept = readKept(gpa, dir, saved.generation, i, d) catch |err| { dvui.log.warn("restart: could not keep {s} ({t}); it opens from disk", .{ d.path, err }); continue; }; @@ -314,14 +371,14 @@ pub fn loadSession(gpa: std.mem.Allocator, dir: []const u8) ?KeptDocuments { return self; } -fn readKept(gpa: std.mem.Allocator, dir: []const u8, i: usize, d: Saved.Doc) !Kept { +fn readKept(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, d: Saved.Doc) !Kept { const path = try gpa.dupe(u8, d.path); errdefer gpa.free(path); const owner = try gpa.dupe(u8, d.owner); errdefer gpa.free(owner); - const state: ?[]u8 = if (d.has_state) try readBlob(gpa, dir, i, "state") else null; + const state: ?[]u8 = if (d.has_state) try readBlob(gpa, dir, generation, i, "state") else null; errdefer if (state) |s| gpa.free(s); - const unsaved: ?[]u8 = if (d.has_unsaved) try readBlob(gpa, dir, i, "unsaved") else null; + const unsaved: ?[]u8 = if (d.has_unsaved) try readBlob(gpa, dir, generation, i, "unsaved") else null; return .{ .path = path, .owner = owner, diff --git a/tests/integration.zig b/tests/integration.zig index 1adee115..805c0824 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6926,6 +6926,53 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes editor.session = null; } + // A checkpoint (`Checkpoint`): what a run that did not quit cleanly left is read once at the + // next launch. Each write is a generation of files in the same folder (`saveGeneration`), the + // last one's removed once the new `session.zon` is in place; one cut off before any + // `session.zon` is neither read nor kept; and a restart's session, which is newer, wins. + { + const Checkpoint = fizzy.Editor.Checkpoint; + const config = editor.app.config_folder; + const dir = try Checkpoint.dir(gpa, config); + defer gpa.free(dir); + const cwd = std.Io.Dir.cwd(); + { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + defer kept.deinit(); + try kept.saveGeneration(dir, 1); + try kept.saveGeneration(dir, 2); + } + // Only the second generation's files are left, beside its `session.zon`. + { + var d = try cwd.openDir(dvui.io, dir, .{ .iterate = true }); + defer d.close(dvui.io); + var it = d.iterate(); + while (try it.next(dvui.io)) |file| { + try std.testing.expect(std.mem.eql(u8, file.name, "session.zon") or std.mem.startsWith(u8, file.name, "2-")); + } + } + var recovered = Checkpoint.recover(gpa, config, false) orelse return error.TestUnexpectedResult; + try std.testing.expect(recovered.docs.items.len > 0); + try std.testing.expect(recovered.docs.items[0].state != null); + recovered.deinit(); + try std.testing.expectError(error.FileNotFound, cwd.access(dvui.io, dir, .{})); + try std.testing.expect(Checkpoint.recover(gpa, config, false) == null); + + // Cut off before its first `session.zon`: nothing read, and nothing left. + try cwd.createDirPath(dvui.io, dir); + try std.testing.expect(Checkpoint.recover(gpa, config, false) == null); + try std.testing.expectError(error.FileNotFound, cwd.access(dvui.io, dir, .{})); + + // A restart's session found: the checkpoint is older, and goes unread. + { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + defer kept.deinit(); + try kept.saveGeneration(dir, 1); + } + try std.testing.expect(Checkpoint.recover(gpa, config, true) == null); + try std.testing.expectError(error.FileNotFound, cwd.access(dvui.io, dir, .{})); + } + try editor.deinit(); }