Collect the folders whose lock is held, wait 100 ms once and retry only those,
instead of up to 100 ms per held folder. Recovery::start takes its folder without
waiting again (a held name already falls through to another). Windows doesn't
pass the lock to child processes, so the wait and its test are Unix-only; the
test holds the lock 10 ms against the 100 ms wait.
A child process forked before its exec inherits the run's open descriptors,
and a flock lasts while any descriptor for the file is open. A crashed run
whose folder lock was still held by such a child read as a run that was still
going, so recover_all skipped it and its unsaved documents were not opened at
the next start. The desktop app's tests failed intermittently for the same
reason whenever another test spawned a process in parallel (the reduced-motion
read spawns gsettings on Linux and the BSDs): recovery_tests failed in 23 of
60 runs of the whole binary and never on its own.
Taking a folder's lock now retries try_lock briefly, so a lock that frees
itself is no longer read as a live run while a run that really is running
still keeps its folder.
Regression test: a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going
holds the crashed run's lock on a second descriptor, the way a forked child
does, and frees it shortly after; the copies come back. The recovery tests
that collide with a spawning test went from 8 failures in 10 runs to 0 in 10.
- **Quit (macOS):** `on_exit` now calls `services::shutdown` and then, on macOS only,
`std::process::exit(0)`. This happens before eframe tears the windows down, which is where the
crash in #781 is thrown (same approach as the sibling apps, tracked in craft-libs#3).
- **`services::shutdown(app, dir, recovery)`** replaces `save_state`. It runs on every platform and
everything in it is finished before it returns:
1. the preferences (`state.json`) are written atomically, as before;
2. this run's recovery copies are settled by the new `Recovery::quit(session, asked)`:
- if the unsaved-changes question was asked (`ui.quit`: last window closed through the
question, or `app.quit`), or nothing is unsaved, it works as before: `finish()` removes
them;
- otherwise the unsaved documents are copied (waiting for any copy still being written)
and the copies stay for the next start, as after a crash. This is the macOS Quit menu
(Cmd+Q): winit's menu calls `terminate:`, which reaches `on_exit` without asking. Until
now that path removed the recovery copies of files that had unsaved changes;
3. live sessions end explicitly: a session this app hosts tells everyone and stops; a joined
session is left. Before, this only happened in the app's thread-local destructor at
process exit, if at all.
- If the app can't be reached in `on_exit` (it's busy), the recovery copies are left on disk for
the next start rather than removed.
Quit paths checked:
- **Cmd+Q / the menu bar's Quit (macOS):** `terminate:` → winit `applicationWillTerminate` →
eframe `exiting` → `on_exit`. Covered.
- **Last window closed** (close button, title bar Close, Dock/Finder quit Apple event → `Close`):
`on_close_request` → `begin` sets `ui.quit` → root `Close` → `on_exit`. Covered.
- **`app.quit` (control channel / MCP):** sets `ui.quit` and closes the root window → `on_exit`.
Covered. Its reply goes to the connection thread before the window closes. The process now ends
sooner after that, so a client that reads very slowly could in theory see EOF instead of the
reply (not seen).
- **The frame guard (panic):** isn't a quit. It writes recovery copies and the app keeps running,
so it doesn't need this. A panic outside the guard aborts as before.
- **eframe error mid-run:** eframe also calls `on_exit` before `run_native` returns `Err`. On macOS
that error now only reaches the log (eframe logs it), not the "window couldn't open" box. Startup
failures still show the box: `on_exit` only runs once the app exists.
Part of #781.