feat(app): autosave / crash-recovery snapshot (#638) - #777
Merged
Merged
Conversation
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.
Member
|
good call on |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #638.
What this does
Hesiod now keeps a crash-recovery snapshot of the open project. Every
autosave_interval_sseconds (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.hsdand 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.jsonfiles load the defaults.Where the files live, and why not beside the project
<QStandardPaths::AppLocalDataLocation>/autosave/, i.e.~/.local/share/hesiod/autosaveon Linux,%LOCALAPPDATA%\hesiod\autosaveon Windows,~/Library/Application Support/hesiod/autosaveon macOS;autosave/beside the executable in portable mode (same detection ashesiod.json). File names:<stem>-<8 hex of the absolute path hash>.autosave.hsdfor a named project,untitled-<pid>-<launch token>.autosave.hsdfor an unnamed one,<name>.tmpwhile writing.The issue thread suggested a hidden
.[PROJECT].autosave.hsdnext to the project. This PR uses one application-owned directory instead, for these reasons: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.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
AutosaveManager(hesiod/app/autosave_manager.hpp): GUI-freeQObject+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: everystd::filesystemcall uses theerror_codeoverload,dump()useserror_handler_t::replace, failures are logged and retried on the next tick.autosaveblock 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.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_filenow returnsbool) no longer marks the project clean, discards the snapshot, or reports success.ProjectModel::has_changedis a new every-change callback next to the transition-onlyis_dirty_changed; it feeds the "changed since last snapshot" flag.AutosaveSuspender) for the whole ofcleanup(),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.--snapshot,--inventory, batch and theContextOnlytest mode never create it.AppContext::save_project_modelandProjectUI::save_ui_statehad no callers left after the refactor and were removed.Known limits and follow-ups
.hsditself).hesiod_bake.hsdthrough 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 withjson_to_file(project_file_json(), ...)directly and drop all of those side effects.Tests
tests/gui/test_autosave.cpp(20 tests, registered liketest_graph_editorunderHESIOD_ENABLE_TESTS): key naming, atomic write and no.tmpleft, 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, aProjectModelround trip through a real snapshot,json_to_filefailure reporting.ctestis green (4/4) andclang-format --dry-run --Werrorpasses 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.