Skip to content

feat(app): autosave / crash-recovery snapshot (#638) - #777

Merged
otto-link merged 16 commits into
devfrom
feat/autosave-recovery
Sep 20, 2026
Merged

otto-link merged 16 commits into
devfrom
feat/autosave-recovery

Conversation

@barrulus

Copy link
Copy Markdown
Collaborator

Closes #638.

What this does

Hesiod now keeps a crash-recovery snapshot of the open project. Every autosave_interval_s seconds (default 120) the live project is written to a recovery file, but only when something changed since the last snapshot. If Hesiod did not shut down cleanly, the next launch offers to restore it. The snapshot is removed whenever the project is saved, another project is opened, or Hesiod quits normally, so a false prompt never appears.

Startup prompt, per pending file (newest first): Restore (loads the snapshot under its original path and marks the project dirty), Discard (deletes it), Later (parks it as <key>.deferred.autosave.hsd and offers it again at the next launch). A recovery file that fails to parse gets its own Delete / Keep prompt; nothing is deleted silently.

Two settings under Application Settings > Global: Enable autosave and Autosave interval (seconds) (10..3600 in the UI). Both apply live. Older hesiod.json files load the defaults.

Where the files live, and why not beside the project

<QStandardPaths::AppLocalDataLocation>/autosave/, i.e. ~/.local/share/hesiod/autosave on Linux, %LOCALAPPDATA%\hesiod\autosave on Windows, ~/Library/Application Support/hesiod/autosave on macOS; autosave/ beside the executable in portable mode (same detection as hesiod.json). File names: <stem>-<8 hex of the absolute path hash>.autosave.hsd for a named project, untitled-<pid>-<launch token>.autosave.hsd for an unnamed one, <name>.tmp while writing.

The issue thread suggested a hidden .[PROJECT].autosave.hsd next to the project. This PR uses one application-owned directory instead, for these reasons:

  • Startup has to find pending snapshots. Beside-the-project files need a registry of where they are, and the only state Hesiod persists (hesiod.json) is written on clean exit only, so it is stale after exactly the crash we are recovering from. One directory is scanned in one call.
  • Untitled projects and examples opened from the selector have no directory at all.
  • Project folders can be read-only or on a network share; the app-data directory is always writable.
  • A dot-prefixed name is not hidden on Windows, so the project folder would show the file.

A "keep the snapshot beside the project" option could be added later as a setting if you prefer that as the default; the manager only needs a different directory.

Implementation notes

  • New AutosaveManager (hesiod/app/autosave_manager.hpp): GUI-free QObject + QTimer. The application hands it a provider that returns the project JSON; the manager adds an "autosave": {project_path, saved_at} block and writes to a temp sibling, then renames over the final name, so a crash mid-write can never leave a truncated file. It never throws: every std::filesystem call uses the error_code overload, dump() uses error_handler_t::replace, failures are logged and retried on the next tick.
  • The autosave block exists only in snapshot files. The normal loader ignores it, and a real save deletes its target and rebuilds the JSON from the live model, so it cannot leak into a .hsd.
  • Real save and snapshot share one builder, HesiodApplication::project_file_json() (model + UI state + version + saved_at). The save path writes that once instead of three merged writes; the content is identical. The dirty flag is now cleared after the write, and a failed write (json_to_file now returns bool) no longer marks the project clean, discards the snapshot, or reports success.
  • ProjectModel::has_changed is a new every-change callback next to the transition-only is_dirty_changed; it feeds the "changed since last snapshot" flag.
  • The manager is suspended (RAII AutosaveSuspender) for the whole of cleanup(), load_project_model_and_ui() and the batch-export loop, the three places that pump the event loop while the model or UI is inconsistent.
  • The manager only exists in GUI mode; --snapshot, --inventory, batch and the ContextOnly test mode never create it.
  • AppContext::save_project_model and ProjectUI::save_ui_state had no callers left after the refactor and were removed.

Known limits and follow-ups

  • One snapshot per project. Two Hesiod instances editing the same project overwrite each other's snapshot (already unsupported for the .hsd itself).
  • Save a copy discards the snapshot, consistent with it clearing the dirty flag today.
  • Batch export writes its temporary hesiod_bake.hsd through the regular save routine, so it also clears the dirty flag (pre-existing) and discards the snapshot, and a write failure there now shows the save-failure dialog once per variant. A small follow-up would write that temp file with json_to_file(project_file_json(), ...) directly and drop all of those side effects.
  • The FAQ entry "Is the application stable?" documents the feature for users.

Tests

tests/gui/test_autosave.cpp (20 tests, registered like test_graph_editor under HESIOD_ENABLE_TESTS): key naming, atomic write and no .tmp left, changed-flag gating and failure retry, nested suspend, idempotent discard, re-key, adopt, deferred parking, scan ordering with corrupt/temp/unrelated noise, own-file exclusion, timer on/off, settings round trip and missing-key defaults, a ProjectModel round trip through a real snapshot, json_to_file failure reporting. ctest is green (4/4) and clang-format --dry-run --Werror passes on every touched file.

The startup dialog and the lifecycle wiring are not unit-tested. Manual check, with the interval set to 10 s: edit, wait, confirm the file appears; kill -9, relaunch, Restore brings the graph back with a dirty title; Save As removes the file and a later edit re-creates it under the named key; Discard and Later at the prompt behave as described above; a clean quit removes the file; disabling autosave stops the writes.

barrulus and others added 16 commits September 20, 2026 08:18
Extract project_file_json() so the save path and the upcoming autosave
snapshot write identical content, and add a ProjectModel::has_changed
callback that fires on every change (is_dirty_changed only fires on the
clean -> dirty transition).
One snapshot file per project under the app-local data directory
(portable mode: beside the executable), written to a temp sibling and
renamed into place so a crash mid-write never leaves a truncated file.
Writes only when the project changed since the last snapshot.
Use the error_code overload of fs::absolute (via a new normalised_absolute
helper) in snapshot_key/set_project_path instead of the throwing one, and
compute the JSON payload with error_handler_t::replace inside the existing
provider try/catch so invalid UTF-8 in project text can't throw out of
write_snapshot. Also adds the missing <cstdint> include.
Nested suspend for event-loop-pumping operations, idempotent discard on
clean save/exit, snapshot migration when a project gets a new path, and
adopt() to take over a recovered file under the running instance's key.
scan() lists pending snapshots newest first, skips in-progress temp
files and this process's own untitled file, and flags files that fail
to parse so the startup prompt can offer to delete them.
Exposed under Global in the application settings window; bind_int
gains an explicit range and an optional change callback.
Every edit marks the AutosaveManager changed and a timer writes the
snapshot; a real save, a project switch and a clean exit discard it,
batch export suspends it, and the settings rows apply live.
cleanup() and load_project_model_and_ui() both pump the event loop
while the model and UI are being torn down or rebuilt; a queued
autosave timer tick in that window could dereference a half-destroyed
ProjectUI or resurrect a snapshot under the wrong project key. Hold an
AutosaveSuspender for the duration of both, matching on_export_batch.

discard() also now clears the pending flag, so a project that was just
saved or explicitly discarded does not get re-snapshotted on the next
timer tick.
Scans the autosave directory before the command-line file and the
example selector, and prompts Restore / Discard / Later per pending
file. Restore reloads the snapshot under its original path, marks the
project dirty and adopts the file as the live snapshot.
The untitled snapshot was keyed by the pid alone. A relaunch handed the
crashed process's pid (Windows reuses pids aggressively) treated the stale
snapshot as its own: scan() skipped it and the first project load discarded
it, so the unsaved work was lost without ever being offered.

Append a per-process launch token (the launch time in seconds, in hex) to the
untitled key.
Qt::VeryCoarseTimer rounds every interval to whole seconds, so a
sub-second interval does not mean what it says and the tests' negative
assertions would hold even with the timer wrongly running. Qt::CoarseTimer
keeps the production intervals (>= 10 s) just as cheap in wakeups.
json_to_file only logged when it could not open the target, so a failed
save still cleared the dirty flag, discarded the recovery snapshot and
reported success: the work was gone from both the file and the snapshot.

json_to_file and save_project_model_and_ui now return whether the write
succeeded; a failure warns the user and leaves the project dirty, its
snapshot in place and its path untouched.
Choosing Later left the snapshot under the project's live key, so opening
that same project in the session overwrote it on the next tick and Ctrl+S
deleted it: the work the user chose to keep vanished without warning.

Later now renames the file to <key>.deferred.autosave.hsd. The scan still
lists it, no live snapshot can claim that name, and adopt() re-keys it if it
is restored on a later launch.
AppContext::save_project_model and ProjectUI::save_ui_state lost their last
callers when the project file JSON moved into one builder.

Deleting or discarding a recovery file now takes its .tmp sibling with it and
says so when the removal fails. Also document the autosave interval bounds and
cover the pre-feature configuration file, which must keep the defaults.
@otto-link

Copy link
Copy Markdown
Member

good call on <QStandardPaths::AppLocalDataLocation>/autosave/

@otto-link
otto-link merged commit e9ef054 into dev Sep 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants