Ferrite implements crash-safe session persistence that saves and restores the full editor session state (open tabs, active tab, scroll positions, cursor positions, and unsaved content) across restarts and after crashes.
The session persistence system consists of three main components:
-
SessionState - Top-level session state containing:
- Schema version (for migration support)
- Timestamp of last save
- Clean shutdown flag
- List of open tabs (SessionTabState)
- Active tab index
- Application mode (single file or workspace)
-
SessionTabState - Per-tab state including:
- Tab ID
- File path (if saved)
- Display title
- View mode (Raw/Rendered)
- Cursor position (character index and line/column)
- Selection range
- Scroll offset
- Has unsaved content flag
- File modification time (for conflict detection)
- Original content hash
-
RecoveryContent - Stored separately for tabs with unsaved changes:
- Tab ID
- Full document content
- Save timestamp
path(added in task 106) — file path the recovery is anchored to (Nonefor untitled tabs)original_content_hash(task 106) — hash of the on-disk content the tab was loaded from when the recovery was writtenschema_version(task 106) — defaults to1for legacy files via serde defaults; future schema bumps are rejected at the loader boundary
-
AutoSaveMetadata - JSON header for
<file>.autosavetemp files:- Tab ID, original path, save timestamp, content hash
disk_content_hash(task 106) — hash of the on-disk content the tab was loaded from at the moment this autosave was written; defaults toNonefor legacy files via serde defaults
-
RecoveryConflict (task 106.5) - Lives on
AppState.recovery_conflictskeyed bytab_idwhile the central panel banner is visible:recovered_content— buffer applied during restoreon_disk_content— current disk content captured at restore time
All session files are stored in the user's config directory:
- Windows:
%APPDATA%\ferrite\ - macOS:
~/Library/Application Support/ferrite/ - Linux:
~/.config/ferrite/
Files:
session.json- Clean session state (saved on normal shutdown)session.recovery.json- Crash recovery state (saved periodically)session.lock- Lock file (indicates app is running)recovery/- Directory containing per-tab recovery content files
- Create lock file to detect future crashes
- Check for crash recovery file and lock file presence
- If crash detected (lock file existed from previous session):
- Load crash recovery state
- If unsaved changes exist, show recovery dialog
- User can choose to restore or start fresh
- If clean shutdown (no lock file):
- Silently restore session from session.json
- Session save throttle (5 second debounce)
- On content changes, mark session as dirty
- Periodically save crash recovery snapshot
- Save recovery content for tabs with unsaved changes
- Capture session state
- Mark as clean shutdown
- Save to session.json
- Clear crash recovery data
- Remove lock file
┌─────────────────┐
│ AppState │
│ (Runtime) │
└────────┬────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ capture_session │ │restore_from_ │
│ _state() │ │session_result() │
└────────┬────────┘ └────────▲────────┘
│ │
▼ │
┌─────────────────┐ ┌────────┴────────┐
│ SessionState │◄─────────►│ load_session_ │
│ (Serializable) │ │ state() │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ session.json / │
│ .recovery.json │
└─────────────────┘
When crash recovery is detected with unsaved changes, a modal dialog appears offering:
- Restore Session: Restores all tabs from the previous session, including recovered unsaved content
- Start Fresh: Discards the recovery data and starts with an empty editor
If Ferrite was launched with file paths (e.g. double-click while the app was closed), those paths are deferred until the user answers the dialog, then opened afterward (existing tabs with the same path are focused, not duplicated).
When an identity-validated recovery file is applied but its buffer differs from the current disk content, the central panel renders a non-blocking banner above the editor for the active tab. Editing is allowed while the banner is visible — only the two action buttons clear it:
- Keep Recovered (
AppState::keep_recovered_buffer) — drops the conflict entry; the buffer stays modified so the user can save manually. - Reload from Disk (
AppState::apply_reload_from_disk_for_conflict) — replaces the tab buffer with the on-disk content captured at restore time and marks the tab saved.
Closing the tab also clears any pending conflict so the banner cannot resurrect on a future tab whose runtime id collides with the cleared one.
Recovery files in recovery/<tab_id>.json and autosave files under autosave/
persist across launches, but the per-session tab_id namespace is reset to 0
on every launch and re-issued monotonically. Without identity checks, a
leftover recovery file from a previous session could silently apply to an
unrelated tab in the current session that happened to be assigned the same id
— a data-loss hazard ("cross-tab bleed"; the original repro was an untitled
buffer named asdasd for tab_id=10 overwriting
task_50_table_inline_formatting.md on save).
AppState::try_apply_recovery (src/state.rs) gates application of recovery
content in three layers, in order:
- Legacy bypass. Pre-task-106 files have neither
pathnororiginal_content_hashset. They fall back to historical "tab id only" matching so users upgrading from older Ferrite versions don't lose recovered text. No conflict banner can be raised in this branch. - Path equality.
recovered.pathmust equalsession_tab.path(covers untitled tabs viaNone == None). A mismatch indicates a reused tab id and is rejected with thesession_recovery_identity_mismatchdiag event. - Disk hash. When the tab is path-backed and the file exists on disk,
the current disk content is hashed (same algorithm as
crate::config::hash_content) and compared againstrecovered.original_content_hash. A mismatch means the file was edited externally between sessions; the recovery is rejected and the caller falls through to a fresh disk load. Encoding-failure onread_to_stringis tolerated (path identity is trusted).
When all three layers pass and the recovered buffer differs from current disk
content, try_apply_recovery returns
ResolvedContent::RecoveredWithDiskDivergence { content, on_disk_content }
and restore_from_session_result populates AppState.recovery_conflicts so
the banner described above is rendered.
Invariant: after restore_from_session_result applies a divergent
recovery, the new Tab must keep original_content (and therefore
disk_content_hash()) anchored to the actual on-disk text, not to the
recovered buffer.
If the anchor is set to the recovered buffer instead, the bug manifests on the second crash/restore cycle:
- Edit → kill → Restore.
Tab::with_file(path, recovered)was used, sooriginal_content == content,is_modified() == false, anddisk_content_hash() == hash(recovered). - New edits happen →
is_modified()flips totrue→ the next crash snapshot writesrecovery/<id>.jsonwithoriginal_content_hash = hash(recovered). - Kill → restart. Disk hash check in
try_apply_recoverycompareshash(disk) != hash(recovered)and rejects the recovery file. The tab silently falls back to disk content — every edit since the previous recovery is gone.
restore_from_session_result handles this by using on_disk_content for
the constructor call when the resolver returned
ResolvedContent::RecoveredWithDiskDivergence, then calling set_content
to swap in the recovered buffer (which bumps content_version, records one
undo step, and leaves original_content pointing at disk). Regression
tests: test_restore_with_divergence_anchors_original_to_disk and
test_restore_then_edit_keeps_disk_hash_anchor in state.rs.
The autosave path enforces equivalent identity in
config::session::check_auto_save_identity:
metadata.original_path != tab_path is rejected outright; when
metadata.disk_content_hash is Some(want) the disk is re-hashed and a
mismatch is rejected. Both sides emit the same
session_recovery_identity_mismatch diag key (with source=autosave in the
message).
AppState::prune_stale_recovery_files runs once after session restore and
deletes:
recovery/<tab_id>.jsonwhose id is not in the live tab set (config::prune_recovery_dir)autosave/untitled_<tab_id>.md.autosavewhose id is not in the live tab set (config::prune_auto_save_dir)
Path-backed autosaves (<stem>_<pathhash>.md.autosave) are not pruned by id
because they are keyed by file path; identity for those is enforced at
recovery time by check_auto_save_identity.
The system tracks file modification time to detect conflicts:
- NoConflict: File hasn't changed since last read
- ModifiedOnDisk: File was modified externally
- FileDeleted: File no longer exists
- NoFile: Tab has no associated file
This is independent of the identity-gated recovery banner above: mtime-based conflict detection runs at restore time for every tab, while the recovery banner only appears for tabs whose recovered buffer differs from current disk after passing the identity gate.
src/config/session.rs- Session state model, persistence functions, and identity helpers (check_auto_save_identity,prune_recovery_dir,prune_auto_save_dir)src/config/mod.rs- Module exportssrc/state.rs-capture_session_state(),restore_from_session_result(),try_apply_recovery(), conflict-banner action methods (keep_recovered_buffer,apply_reload_from_disk_for_conflict)src/app/mod.rs- Lifecycle integration (startup, periodic saves, shutdown), autosave loop withdisk_content_hashpropagationsrc/app/central_panel.rs-render_recovery_conflict_banner(task 106.5)
The session system stores the workspace root path when in workspace mode:
-
On Save: The workspace path is canonicalized before saving to ensure consistent storage across restarts and to resolve any relative paths or symlinks.
-
On Restore: The system:
- Validates the saved path is non-empty
- Attempts to canonicalize the path for consistency
- Checks if the path exists on disk
- Verifies it's actually a directory
- Falls back to single-file mode if any validation fails
Comprehensive debug logging is available for troubleshooting session persistence issues. Enable debug logging with --log-level debug to see:
- Session file selection (recovery vs. session.json)
- File modification time comparisons
- Workspace path during save/load operations
- Path canonicalization results
- Validation failures and reasons
The system handles various error conditions gracefully:
- Empty path: Session starts in single-file mode
- Non-existent path: Warning logged, starts in single-file mode
- Path not a directory: Warning logged, starts in single-file mode
- Workspace open failure: Warning logged with error details, starts in single-file mode
- Session state serialization/deserialization roundtrip
- Content hashing consistency
- Save throttle timing logic
- Open multiple documents, make edits, close cleanly
- Reopen - verify all tabs restored with correct positions
- Open documents, make unsaved changes, force-kill process
- Reopen - verify recovery dialog appears
- Test both "Restore" and "Start Fresh" options
- Verify session files are created in correct location
- Verify lock file is removed on clean exit
- Verify periodic saves occur while editing
- Test with large files and many tabs
- Diff View in Conflict Banner: Show a side-by-side diff of recovered vs. on-disk inside the banner before the user picks
Keep Recovered/Reload from Disk(task 106.5 just exposes the strings viaRecoveryConflict). - Rendered Scroll Sync: Track rendered mode scroll offset separately
- Multi-window Support: Track multiple window states
- Session History: Keep history of previous sessions
- Selective Restore: Allow restoring individual tabs from recovery