Skip to content

Commit bdd3d03

Browse files
committed
feat: recover never-saved Untitled scenes on crash (SYS-W9-07)
performAutoSave() returns immediately while currentFile_ is empty, so a new scene that is worked on and never given a path via Save As has no autosave and no recovery path if the editor or system crashes -- the existing .autosave sibling-file recovery (SYS-W9-02) only covers documents that already have one. Added a single bounded recovery slot in the config directory (meshcraftConfigDir() / "untitled.recovery.mc3.xml"), written on its own autoSaveTickAlg countdown (reusing the existing tested tick function with hasCurrentFile inverted, rather than touching performAutoSave()'s own tested gating). Offered once at startup via a new modal dialog with Recover/Discard/Not Now. Recovery keeps the document untitled and modified, and never touches Recent Files. Removed on successful Save As (plain and Save-as-Library), explicit Discard, and ordinary shutdown -- a real crash skips the destructor, which is what leaves the file for the next startup to find. mc3_untitled_recovery tests the CNA-free filesystem+Mc3Document mechanism directly (App-level methods aren't headlessly testable, same as mc3_autosave_recovery's own established scope): round-trip, discard, crash-marker simulation, corrupt-file handling, and coexistence with the existing named-file .autosave sibling.
1 parent 436849a commit bdd3d03

8 files changed

Lines changed: 364 additions & 15 deletions

File tree

include/MeshCraft/Application/MeshCraftApplication.hpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,23 @@ class MeshCraftApplication : public Microsoft::Xna::Framework::Game {
748748
void performAutoSave();
749749
static std::filesystem::path autoSavePath(const std::filesystem::path& file);
750750

751+
// SYS-W9-07: crash recovery for a modified document that has never been
752+
// saved (currentFile_ empty, so performAutoSave()'s own sibling-file
753+
// scheme has nowhere to place a sibling). Ticks on its own countdown
754+
// (autoSaveTickAlg with hasCurrentFile inverted) so it doesn't disturb
755+
// the tested named-file autosave gating. Offered once per startup, via
756+
// checkForUntitledRecovery() right after a fresh newScene() at launch;
757+
// recoverUntitledScene() keeps the document untitled/modified and never
758+
// touches Recent Files. Cleaned up on successful Save As, explicit
759+
// Discard, and ordinary (non-crash) shutdown -- a real crash skips the
760+
// destructor, which is what leaves the file for the next startup to find.
761+
float autoSaveUntitledCountdown_{60.0f};
762+
bool untitledRecoveryDlgOpen_{false};
763+
void performUntitledRecoverySave();
764+
void checkForUntitledRecovery();
765+
void recoverUntitledScene();
766+
void discardUntitledRecovery();
767+
751768
// Customizable keybindings (H9, SYS-W3-01: extracted into KeybindingManager)
752769
Editor::KeybindingManager keybindings_;
753770
std::string keyCaptureAction_; // non-empty = waiting for next keypress

mc3/CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,15 @@ if(BUILD_TESTING)
450450
add_test(NAME mc3_autosave_recovery COMMAND mc3_autosave_recovery_test)
451451
set_tests_properties(mc3_autosave_recovery PROPERTIES TIMEOUT 15 LABELS "format")
452452

453+
# SYS-W9-07: never-saved ("Untitled") document crash-recovery mechanism
454+
# -- same CNA-coupled-App-methods-not-headlessly-testable limitation as
455+
# mc3_autosave_recovery above; tests the pure filesystem + Mc3 mechanism
456+
# those methods reduce to.
457+
add_executable(mc3_untitled_recovery_test test/untitled_recovery_test.cpp)
458+
target_link_libraries(mc3_untitled_recovery_test PRIVATE Mc3)
459+
add_test(NAME mc3_untitled_recovery COMMAND mc3_untitled_recovery_test)
460+
set_tests_properties(mc3_untitled_recovery PROPERTIES TIMEOUT 15 LABELS "format")
461+
453462
# Seeded property-based round-trip test --
454463
# generates many random documents (RandomMc3DocumentGenerator.hpp) and
455464
# asserts XML round-trip fidelity, complementing mc3_roundtrip's fixed-
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Never-saved ("Untitled") document crash-recovery mechanism test
2+
// (SYS-W9-07).
3+
//
4+
// MeshCraftApplication::performUntitledRecoverySave()/
5+
// checkForUntitledRecovery()/recoverUntitledScene()/discardUntitledRecovery()
6+
// (see src/MeshCraft/Application/FileOps.cpp) are CNA-coupled (SDL/ImGui)
7+
// and not headlessly testable directly -- same limitation as
8+
// autosave_recovery_test.cpp's own header comment for the named-file case.
9+
// What IS pure filesystem + Mc3 and fully testable here is the mechanism
10+
// those methods reduce to: write the in-memory document to a single bounded
11+
// recovery path, later either load it back (recover) or remove it
12+
// (discard), and survive a corrupt/garbage file at that path without
13+
// crashing. `untitledRecoveryPath()` itself (src/MeshCraft/
14+
// MeshCraftPrivate.hpp) lives in the config directory rather than beside a
15+
// real file, since an untitled document has no real path to place a
16+
// sibling next to -- this test uses a stand-in path in the same shape.
17+
18+
#include <MeshCraft/Mc3/Mc3Document.hpp>
19+
20+
#include <filesystem>
21+
#include <fstream>
22+
#include <iostream>
23+
#include <string>
24+
25+
using namespace MeshCraft::Mc3;
26+
27+
static int failures = 0;
28+
static void check(bool cond, const std::string& msg) {
29+
if (cond) { std::cout << "PASS: " << msg << "\n"; }
30+
else { std::cerr << "FAIL: " << msg << "\n"; ++failures; }
31+
}
32+
33+
int main() {
34+
const auto dir = std::filesystem::temp_directory_path();
35+
// Stand-in for meshcraftConfigDir() / "untitled.recovery.mc3.xml" -- same
36+
// shape (a single fixed path, not a sibling of any real document file).
37+
const auto recoveryPath = dir / "mc3_untitled_recovery_test.mc3.xml";
38+
std::filesystem::remove(recoveryPath);
39+
40+
// --- Modified untitled recovery: the periodic save writes real content,
41+
// and loading it back (what recoverUntitledScene() does) yields it. ---
42+
{
43+
Mc3Document doc;
44+
doc.model = "UntitledWorkInProgress";
45+
doc.addObject(Mc3Object::makeSphere("Sphere", 1.0f, 16));
46+
doc.saveToFile(recoveryPath);
47+
48+
check(std::filesystem::exists(recoveryPath),
49+
"performUntitledRecoverySave()'s mechanism: the recovery file exists after saving");
50+
51+
Mc3Document recovered = Mc3Document::loadFromFile(recoveryPath);
52+
check(recovered.model == "UntitledWorkInProgress",
53+
"recoverUntitledScene()'s mechanism: loading the recovery path yields the saved content");
54+
check(recovered.objects.size() == 1 && recovered.objects[0] &&
55+
recovered.objects[0]->name == "Sphere",
56+
"recoverUntitledScene()'s mechanism: the recovered document's objects round-trip");
57+
}
58+
59+
// --- Discard: removing the file makes it as if nothing were ever
60+
// there -- checkForUntitledRecovery()'s existence check next startup
61+
// correctly finds nothing to offer. ---
62+
{
63+
check(std::filesystem::exists(recoveryPath), "discard scenario: recovery file exists before discard");
64+
std::error_code ec;
65+
std::filesystem::remove(recoveryPath, ec);
66+
check(!ec, "discardUntitledRecovery()'s mechanism: remove() succeeds without error");
67+
check(!std::filesystem::exists(recoveryPath),
68+
"discardUntitledRecovery()'s mechanism: the recovery file no longer exists");
69+
}
70+
71+
// --- Clean shutdown / successful Save As cleanup: both reduce to the
72+
// same "no recovery file left behind" postcondition as discard, just
73+
// triggered from a different call site (the destructor, or the Save As
74+
// dialog handler) -- already covered by the discard case above at the
75+
// filesystem-mechanism level; the *triggering condition* (currentFile_
76+
// empty at shutdown, or transitioning away from empty on a successful
77+
// save) is App-level state this test cannot exercise headlessly. ---
78+
79+
// --- Crash-marker simulation: a recovery file surviving without ever
80+
// being cleaned up is exactly what an abnormal termination (crash, kill,
81+
// power loss) looks like, since nothing ran to remove it. Simulated here
82+
// by simply leaving a file in place and confirming existence is
83+
// detectable -- checkForUntitledRecovery()'s own logic is a bare
84+
// exists() check, so this is the whole mechanism. ---
85+
{
86+
Mc3Document doc;
87+
doc.model = "SurvivedACrash";
88+
doc.saveToFile(recoveryPath);
89+
check(std::filesystem::exists(recoveryPath),
90+
"crash-marker simulation: a file left behind by an unclean exit is detectable");
91+
std::filesystem::remove(recoveryPath);
92+
}
93+
94+
// --- Corrupt recovery file: must fail loudly (a catchable exception),
95+
// not crash -- recoverUntitledScene() wraps loadFromFile() in exactly
96+
// this try/catch. ---
97+
{
98+
{ std::ofstream f(recoveryPath, std::ios::binary); f << "not xml at all {{{"; }
99+
bool threw = false;
100+
try {
101+
(void)Mc3Document::loadFromFile(recoveryPath);
102+
} catch (const std::exception&) {
103+
threw = true;
104+
}
105+
check(threw, "corrupt recovery file: loading it throws a catchable exception, not a crash");
106+
std::filesystem::remove(recoveryPath);
107+
}
108+
109+
// --- Coexistence with the existing sibling .autosave recovery
110+
// (SYS-W9-02): the two mechanisms use disjoint path shapes by
111+
// construction (config-directory single slot vs. a real file's own
112+
// "<file>.autosave" sibling), so writing both for the same logical
113+
// session cannot collide. ---
114+
{
115+
const auto namedFile = dir / "mc3_untitled_recovery_test_named.mc3.xml";
116+
const auto namedAutosave = std::filesystem::path(namedFile.string() + ".autosave");
117+
std::filesystem::remove(namedFile);
118+
std::filesystem::remove(namedAutosave);
119+
120+
Mc3Document untitledDoc;
121+
untitledDoc.model = "UntitledSlot";
122+
untitledDoc.saveToFile(recoveryPath);
123+
124+
Mc3Document namedDoc;
125+
namedDoc.model = "NamedSaved";
126+
namedDoc.saveToFile(namedFile);
127+
Mc3Document namedAutosaveDoc;
128+
namedAutosaveDoc.model = "NamedAutosaved";
129+
namedAutosaveDoc.saveToFile(namedAutosave);
130+
131+
check(recoveryPath != namedAutosave && recoveryPath != namedFile,
132+
"coexistence: the untitled-recovery path is distinct from a named file's own "
133+
"path and its .autosave sibling");
134+
check(Mc3Document::loadFromFile(recoveryPath).model == "UntitledSlot" &&
135+
Mc3Document::loadFromFile(namedFile).model == "NamedSaved" &&
136+
Mc3Document::loadFromFile(namedAutosave).model == "NamedAutosaved",
137+
"coexistence: all three files retain their own independent content");
138+
139+
std::filesystem::remove(recoveryPath);
140+
std::filesystem::remove(namedFile);
141+
std::filesystem::remove(namedAutosave);
142+
}
143+
144+
if (failures == 0) { std::cout << "All untitled-recovery tests passed.\n"; return 0; }
145+
std::cerr << failures << " untitled-recovery test(s) failed.\n";
146+
return 1;
147+
}

plan.md

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -415,21 +415,39 @@ time; re-evaluate scope and blockers before starting each item.
415415
loss, and folding them in here would have widened one coherent task into
416416
an unrelated sweep across the editor.
417417

418-
- **SYS-W9-07** `[PROPOSED]` `P1` — Recover never-saved Untitled scenes.
419-
Confirmed at `FileOps.cpp:93`: `performAutoSave()` opens with
420-
`if (currentFile_.empty()) return;`, so a document that has never been
421-
saved once (new scene, worked on, never given a path via Save As) has no
422-
autosave and no recovery path if the editor or system crashes. The existing
423-
`.autosave` sibling-file recovery (`SYS-W9-02`) only covers documents that
424-
already have a path. Add a bounded session-recovery file in the MeshCraft
425-
configuration directory for a modified document that has never been saved.
426-
On next start, offer an explicit Recover / Discard decision. Recovery must
427-
keep the document untitled and modified, must not add a synthetic path to
428-
Recent Files, and must not overwrite an unrelated session; successful
429-
Save As or explicit discard removes the recovery entry. **Tests:** modified
430-
untitled recovery, clean shutdown cleanup, crash-marker simulation,
431-
successful Save As cleanup, discard, corrupt recovery file, and coexistence
432-
with the existing sibling `.autosave` recovery.
418+
- **SYS-W9-07** `[DONE]` `P1` — Added crash recovery for never-saved
419+
("Untitled") documents. `performUntitledRecoverySave()` writes to one
420+
bounded slot, `untitledRecoveryPath()` (`MeshCraftPrivate.hpp`,
421+
`meshcraftConfigDir() / "untitled.recovery.mc3.xml"`), on its own
422+
`autoSaveTickAlg` countdown (`autoSaveUntitledCountdown_`, `hasCurrentFile`
423+
inverted) — reuses the existing tested tick function unchanged rather than
424+
touching `performAutoSave()`'s own tested "no current file never
425+
auto-saves" gating. `checkForUntitledRecovery()` runs once at startup right
426+
after a fresh `newScene()` (`Application.cpp`'s `LoadContent()`, the
427+
no-file-argument branch) and offers a new modal dialog
428+
("Recover Unsaved Scene", `Overlays.cpp`) with Recover / Discard / Not Now.
429+
`recoverUntitledScene()` keeps `currentFile_` empty, sets `modified_ =
430+
true`, and never calls `addRecentFile()`. The recovery file is removed on:
431+
successful Save As (both the plain dialog and Save-as-Library, since either
432+
can be an untitled document's first save), explicit Discard, and ordinary
433+
(non-crash) shutdown (`~MeshCraftApplication()`, unconditional whenever
434+
`currentFile_` is still empty — there is no quit-confirmation gate in this
435+
app, so reaching a clean exit while modified already means the user chose
436+
not to save); a real crash skips the destructor, which is what leaves the
437+
file for the next startup to find.
438+
New `mc3_untitled_recovery` (CNA-free, mirrors `mc3_autosave_recovery`'s
439+
own established "App-level methods aren't headlessly testable, the
440+
filesystem+Mc3 mechanism they reduce to is" scope) covers: a real
441+
document's content round-tripping through the recovery path, discard's
442+
remove()-succeeds postcondition, a crash-marker simulation (a file left
443+
behind is detectable), a corrupt recovery file throwing catchably instead
444+
of crashing, and coexistence with the existing named-file `.autosave`
445+
sibling (disjoint paths, independent content, verified together in one
446+
temp directory). The App-level flow itself (dialog wiring, destructor
447+
timing, `currentFile_`/`modified_` transitions) was verified by full editor
448+
build success and code review only, not a live screenshot — same
449+
CNA-coupled-and-not-headlessly-testable class as this session's F20/F21
450+
precedent, not a lower bar invented for this task.
433451

434452
### W14 — Bounded new work
435453

src/MeshCraft/Application/Application.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,12 @@ void MeshCraftApplication::LoadContent() {
327327
}
328328
} else {
329329
newScene();
330+
// SYS-W9-07: offer recovery for a previous session's never-saved
331+
// document -- checked once, right after the fresh untitled scene
332+
// newScene() just created, matching checkForNewerAutosave()'s own
333+
// "offered whenever a load finishes" placement for the named-file
334+
// case above.
335+
checkForUntitledRecovery();
330336
}
331337

332338
updateWindowTitle();
@@ -448,6 +454,14 @@ void MeshCraftApplication::Update(GameTime& gameTime) {
448454
dt, autoSaveCountdown_)) {
449455
performAutoSave();
450456
}
457+
// SYS-W9-07: same tick logic, own countdown, inverted hasCurrentFile
458+
// so a never-saved document gets its own periodic safety-net save
459+
// without touching autoSaveTickAlg's tested "no current file never
460+
// auto-saves" behavior above.
461+
if (autoSaveTickAlg(currentFile_.empty(), modified_, autoSaveInterval_,
462+
dt, autoSaveUntitledCountdown_)) {
463+
performUntitledRecoverySave();
464+
}
451465
}
452466

453467
// SYS-W14-40: Preview/Play is the only event-execution route. Timers
@@ -1485,6 +1499,19 @@ void MeshCraftApplication::initShadowDebug()
14851499
// are still valid here. Ordering mirrors LoadContent in reverse.
14861500
// ---------------------------------------------------------------------------
14871501
MeshCraftApplication::~MeshCraftApplication() {
1502+
// SYS-W9-07: an ordinary (non-crash) shutdown reaches this destructor,
1503+
// which is exactly the signal used to distinguish "the user closed the
1504+
// app" from "the app crashed" -- a crash skips this entirely, leaving
1505+
// the file for the next startup's checkForUntitledRecovery() to find.
1506+
// Removed unconditionally whenever the document is still untitled,
1507+
// regardless of `modified_`: there is no quit-confirmation gate in this
1508+
// app, so reaching a clean exit while modified already means the user
1509+
// chose to close without saving.
1510+
if (currentFile_.empty()) {
1511+
std::error_code ec;
1512+
std::filesystem::remove(untitledRecoveryPath(), ec);
1513+
}
1514+
14881515
// Remove the event watch first so the callback can never fire against this
14891516
// half-destroyed object. Harmless if it was never added.
14901517
SDL_RemoveEventWatch(reinterpret_cast<SDL_EventFilter>(sdlEventWatch), this);

src/MeshCraft/Application/FileOps.cpp

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,84 @@ void MeshCraftApplication::discardAutosave() {
176176
recoveryDlgOpen_ = false;
177177
}
178178

179+
// SYS-W9-07: silent background safety net for a document that has never
180+
// been saved -- performAutoSave() can't help here since it needs a real
181+
// currentFile_ to place a `.autosave` sibling next to. Only writes while
182+
// still untitled; a later successful Save As switches the document over to
183+
// the named-file autosave scheme and removes this file (see the "Save As"
184+
// dialog handler in Overlays.cpp).
185+
void MeshCraftApplication::performUntitledRecoverySave() {
186+
if (!currentFile_.empty()) return;
187+
try {
188+
document_.saveToFile(untitledRecoveryPath());
189+
} catch (const std::exception& e) {
190+
std::cerr << "[MeshCraft] Untitled-scene recovery save error: " << e.what() << "\n";
191+
}
192+
}
193+
194+
// Called once at startup right after a fresh newScene() -- if a prior
195+
// session's untitled-recovery file is still on disk, it wasn't cleaned up
196+
// by a clean shutdown, which is the same crash signal checkForNewerAutosave()
197+
// uses for named files.
198+
void MeshCraftApplication::checkForUntitledRecovery() {
199+
std::error_code ec;
200+
if (std::filesystem::exists(untitledRecoveryPath(), ec))
201+
untitledRecoveryDlgOpen_ = true;
202+
}
203+
204+
// Recovery keeps the document untitled and modified -- currentFile_ stays
205+
// empty and Recent Files is untouched, unlike recoverFromAutosave()'s
206+
// named-file case. The recovery file itself is intentionally NOT removed
207+
// here (mirrors recoverFromAutosave() leaving its own `.autosave` sibling in
208+
// place): it stays as the safety net until a real Save As succeeds, so a
209+
// second crash right after recovering doesn't lose the recovered content.
210+
void MeshCraftApplication::recoverUntitledScene() {
211+
try {
212+
Mc3::Mc3Validation loadValidation;
213+
document_ = Mc3::Mc3Document::loadFromFile(untitledRecoveryPath(),
214+
Mc3::Mc3LoadPolicy::trusted(), loadValidation);
215+
currentActionName_.clear();
216+
currentActionClipName_.clear();
217+
clearAnimationPreviewTransition();
218+
animTime_ = 0.0f;
219+
animPlaying_ = false;
220+
resetEventPreview();
221+
resetImportHealth();
222+
objectIndex_.invalidate();
223+
if (!loadValidation.empty())
224+
std::cout << "[MeshCraft] Untitled recovery: " << loadValidation.warningCount()
225+
<< " warning(s), " << loadValidation.errorCount() << " error(s)\n";
226+
recordValidation("Untitled recovery", loadValidation);
227+
document_.model = document_.model.empty() ? "Untitled" : document_.model;
228+
currentFile_.clear();
229+
selection_.clear();
230+
undoManager_.clear();
231+
sceneHistory_.clear();
232+
historyReviewSnapshotId_.reset();
233+
historyNotice_.clear();
234+
if (sceneRenderer_) {
235+
sceneRenderer_->setAnimOverrides({});
236+
sceneRenderer_->clearCsgCache();
237+
}
238+
modified_ = true; // recovered content was never saved anywhere
239+
setStatusMsg("Recovered unsaved changes from a previous session", false, 3.0f);
240+
checkRotationConventionNotice();
241+
resolveImports();
242+
updateWindowTitle();
243+
} catch (const std::exception& e) {
244+
std::cerr << "[MeshCraft] Untitled recovery error: " << e.what() << "\n";
245+
setStatusMsg(std::string("Failed to recover: ") + e.what(), true);
246+
}
247+
untitledRecoveryDlgOpen_ = false;
248+
}
249+
250+
void MeshCraftApplication::discardUntitledRecovery() {
251+
std::error_code ec;
252+
std::filesystem::remove(untitledRecoveryPath(), ec);
253+
setStatusMsg("Discarded recovered scene", false, 2.0f);
254+
untitledRecoveryDlgOpen_ = false;
255+
}
256+
179257
// SYS-W1-08: XML, JSON, MCB, and both library forms all populate
180258
// `validation` consistently now -- Mc3JsonParser gained its own
181259
// Mc3Validation-capturing overload (Mc3JsonParser.hpp), closing what used to
@@ -413,6 +491,9 @@ void MeshCraftApplication::saveLibraryFile(const std::filesystem::path& path) {
413491
else
414492
document_.saveToLibraryFile(path);
415493

494+
// SYS-W9-07: same "no longer untitled" transition as the plain Save As
495+
// dialog handler -- see its own comment.
496+
{ std::error_code ec; std::filesystem::remove(untitledRecoveryPath(), ec); }
416497
currentFile_ = path;
417498
document_.sourcePath = path.parent_path();
418499
addRecentFile(currentFile_);

0 commit comments

Comments
 (0)