Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions app/watch/SettingsWatcher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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.
Expand All @@ -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);
Expand Down
213 changes: 213 additions & 0 deletions src/editor/Checkpoint.zig
Original file line number Diff line number Diff line change
@@ -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 `<config>/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,
};

/// `<config>/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);
}
15 changes: 15 additions & 0 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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." });
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading