Saving: the .lineup file, recovery and damaged files
Files: crates/lineup-core/src/file.rs,
crates/lineup-app/src/app/files.rs,
crates/lineup-app/src/app/recovery.rs,
crates/lineup-app/src/app/recent.rs
and crates/lineup-core/tests/damaged_files.rs
A song is saved as a .lineup file. This chapter is about what is in
that file and why it is shaped that way, how a file from an older or a
newer version is read, how saving avoids ever leaving a half-written
file, and how the app keeps a copy of unsaved work in case it crashes.
The numbered list of format versions is in the reference page Project
file format.
What a project file looks like
A .lineup file is plain text. To see one, open a demo under
File > Demos, use File > Save As…, and open the saved file in a text
editor. File > New beat starts a song with an empty pattern and a drum
kit; this is the whole file it saves:
(
format: 1,
tempo: 130.0,
scale: (
root: C,
kind: Major,
),
channels: [
(
name: "Kick",
kind: Sampler(Builtin(Kick)),
volume: 0.8,
muted: false,
),
(
name: "Clap",
kind: Sampler(Builtin(Clap)),
volume: 0.8,
muted: false,
),
...
(
name: "Lineup Osc",
kind: Synth,
volume: 0.6,
muted: false,
),
],
patterns: [
(
name: "Pattern 1",
length: 16,
notes: [
[],
[],
[],
[],
[],
],
),
],
tracks: [
(
name: "Track 1",
muted: false,
),
],
clips: [],
)
Other songs carry more. The starting song’s synth channel plays a
General MIDI piano, so it carries program: Some(0) and its file says
format: 9. Its chords read as notes, one per line:
(pitch: 48, start: 0, length: 8, velocity: 100),
(pitch: 60, start: 0, length: 8, velocity: 100),
(pitch: 64, start: 0, length: 8, velocity: 100),
(pitch: 67, start: 0, length: 8, velocity: 100),
File > New, an empty chord sheet with a band, saves format: 17 and
ends with the band: band: Some((style: "Pop beat", ...)).
The format is RON, “Rusty Object Notation”. It looks like Rust: a
struct is ( field: value, ... ), a list is [ ... ], and enum variants
are written by name, Sampler(Builtin(Kick)), just as in the code. JSON
would work too, but JSON has no enums, so the same channel would read
{"Sampler": {"Builtin": "Kick"}}. RON is easier to read and to fix by
hand, which matters for a program you learn from: you can open a song in
a text editor and see your own data structures.
serde: describe the data once
Rust’s standard way to turn values into text (or bytes) and back is the
serde crate, short for serialize and deserialize. serde does not
know any format itself. It defines two traits, Serialize and
Deserialize, and format crates such as ron or serde_json use them.
You almost never write those traits by hand. A derive macro writes them for you:
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Channel {
pub name: String,
pub kind: ChannelKind,
pub volume: f32,
pub muted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub program: Option<u8>,
#[serde(default, skip_serializing_if = "is_centred")]
pub pan: f32,
// ...the reverb send, the MIDI channel a drum is sent on, and the
// channel's effects, each left out at its default.
}
}
The macro reads the struct definition at compile time and generates code
that writes each field by name, and reads them back. For this to work,
every field’s type must be Serialize too, so ChannelKind,
SampleSource, Drum, NoteEvent, Scale, PitchClass, Track,
Clip and the rest derive it as well. String, numbers, bool, Vec,
Option and PathBuf come with serde.
The derive is turned on by asking for serde’s derive feature in
Cargo.toml. Features are optional parts of a crate that you switch
on when you need them:
serde = { version = "1", features = ["derive"] }
The #[serde(default, skip_serializing_if = ...)] lines are how the
file format grows quietly. A field that is at its default is not written
at all, and a field missing from an older file is filled in with its
default. That is why the drum kit above, which uses nothing beyond the
first layout, is written exactly as the first version of the format
wrote it: a channel in the middle with all of it in the reverb says
nothing about pan or reverb send.
Keeping the rules while reading: from and into
A Tempo cannot be made outside 10 to 522 BPM, because its field is
private and Tempo::new clamps it. A derived Deserialize would set
the field directly from the file, so a file saying tempo: 9000.0 would
create a tempo the rest of the program promises can never exist.
The fix is to tell serde to go through a type we control:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(from = "f32", into = "f32")]
pub struct Tempo {
bpm: f32,
}
impl From<f32> for Tempo {
fn from(bpm: f32) -> Self {
Tempo::new(bpm)
}
}
impl From<Tempo> for f32 {
fn from(tempo: Tempo) -> Self {
tempo.bpm
}
}
}
#[serde(from = "f32")] means “read an f32, then convert it with
From<f32>”. That conversion is Tempo::new, which clamps. The test
values_out_of_range_are_clamped edits a saved file to say 9000 BPM and
checks that it loads as 522. into = "f32" does the reverse when saving,
which also makes the file say tempo: 130.0 instead of
tempo: (bpm: 130.0). Note does the same with its MIDI number.
Always in letters and English
The app can show note names in letters (C, D, E) or in solfège (Do, Re,
Mi), and its texts in English or Spanish (see Two
languages). A file must not depend on
either: a song saved by someone who reads solfège must open for someone
who reads letters. So to_text and from_text set both for as long as
they run:
#![allow(unused)]
fn main() {
let file = Language::English.with(|| NoteNaming::Letters.with(|| ProjectFile::from(project)));
let mut text = NoteNaming::Letters.with(|| ron::ser::to_string_pretty(&file, style))?;
}
with sets the naming for the thread, runs the closure, and puts the
old naming back, so the window draws in solfège again on its next
frame.
A separate type for the file
Project itself does not derive serde. Its fields are private because
they must agree with each other (one row per channel, clips that point at
real patterns), and a derived Deserialize would fill them straight from
the file, agreeing or not. So the file has its own struct:
#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, Deserialize)]
struct ProjectFile {
format: u32,
#[serde(default, skip_serializing_if = "crate::Mixer::is_default")]
mixer: crate::Mixer,
tempo: Tempo,
scale: Scale,
#[serde(default, skip_serializing_if = "TimeSignature::is_common")]
time_signature: TimeSignature,
channels: Vec<Channel>,
patterns: Vec<PatternFile>,
tracks: Vec<Track>,
clips: Vec<Clip>,
// ...sections, the loop region, time, tempo and key markers, loose
// timing, swing, the chord sheet, the band, where each part came
// from, and the locked channels: each left out when it is empty.
}
#[derive(Debug, Serialize, Deserialize)]
struct PatternFile {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
time: Option<TimeSignature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
player: Option<Player>,
length: usize,
/// One list of notes per channel, in the order of `channels`.
notes: Vec<Vec<NoteEvent>>,
}
}
A ProjectFile accepts anything shaped right. Turning it into a
Project is where the checking happens, and it is written as a
TryFrom implementation, the standard trait for “a conversion that
can fail”:
#![allow(unused)]
fn main() {
impl TryFrom<ProjectFile> for Project {
type Error = FileError;
fn try_from(file: ProjectFile) -> Result<Self, Self::Error> {
let mut project = Project::empty();
// ...checks on the mixer, the time signature and each channel's
// mix, effects and MIDI channel...
for channel in file.channels {
project.add_channel(channel);
}
for (index, saved) in file.patterns.into_iter().enumerate() {
// `Project::empty` already has one pattern; add the others.
if index > 0 {
project.add_pattern();
}
let Some(pattern) = project.pattern_mut(index) else { ... };
pattern.set_length(saved.length);
for (channel, notes) in saved.notes.into_iter().enumerate() {
for note in notes {
if pattern.add_note(channel, note).is_none() {
return invalid(/* where, and what is wrong */);
}
}
}
}
// ...tracks, clips, sections, markers, the chord sheet and the band...
Ok(project)
}
}
}
The project is rebuilt through its front door: add_channel,
add_pattern, add_note, add_clip, the same methods the app calls
when you click. Those methods already keep every rule (see The
project), so a file can never create a project
that the window could not have created. When a method refuses (a note
that does not fit, a clip that plays a pattern that is not there), the
error says where, so a person editing the file by hand can fix it: “the
project file is damaged: clip 1 (pattern 99, track 1) does not fit in
the playlist”.
type Error = FileError; is an associated type: part of the trait
that each implementation chooses. With TryFrom in place, the standard
library also provides the matching try_into, so
let project: Project = file.try_into()?; would work too.
Saving goes the other way with From<&Project> for ProjectFile, which
copies the data out with to_vec() and map. It cannot fail, so it is a
From, not a TryFrom.
The version number
#![allow(unused)]
fn main() {
pub const FORMAT_VERSION: u32 = 29;
}
FORMAT_VERSION is the newest layout this program can read. A file does
not always use it: format_for writes the lowest format that can
hold the project. A project that uses nothing new stays format 1, so an
older Ferrous Lineup can still open it. The starting song needs format
9, because its synth channel plays a General MIDI program; a song with a
chord sheet needs at least format 12; one with a horn section or a
channel effect needs 28, and a band playing another style’s drums 29.
The long comment above FORMAT_VERSION says, for every number, what it
added and what an older program would get wrong without it; Project
file format has the same list as a
table.
A file from a newer version must be refused with a clear message, not read wrongly. The obvious way is to read a tiny struct first:
#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct Header {
format: u32,
}
}
By default, serde skips fields that a struct does not list, so a
Header can be read from the whole file. But to skip the rest of the
file, RON has to look ahead through all of it, number by number. On a
long song such as Boléro, that took over a minute, even in a release
build. So the whole file is read first, and only if that fails does
format_number read the number from the first few bytes:
#![allow(unused)]
fn main() {
pub fn from_text(text: &str) -> Result<Project, FileError> {
// A text editor may start the file with a byte-order mark, which is
// not part of the RON text or of the format number.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let file: ProjectFile = match NoteNaming::Letters.with(|| ron::from_str(text)) {
Ok(file) => file,
Err(err) => {
return Err(match format_number(text) {
Some(format) if format > FORMAT_VERSION => FileError::NewerFormat(format),
_ => err.into(),
});
}
};
if file.format > FORMAT_VERSION {
return Err(FileError::NewerFormat(file.format));
}
NoteNaming::Letters.with(|| Project::try_from(file))
}
/// The format number at the start of a project file, `(format: 25, ...`,
/// read without the RON reader.
fn format_number(text: &str) -> Option<u32> {
let rest = text.trim_start().strip_prefix('(')?.trim_start();
let rest = rest.strip_prefix("format")?.trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let digits = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[..digits].parse().ok()
}
}
to_text always writes format first, so this looks only where the
number must be. A newer file often fails to read at all, because of
fields or names this version does not know; then the format number
alone says why. If the file is so broken that even the number cannot be
read, the original RON error is the one reported. And if the file
parses but still says a newer format, it is refused too, because the
same text may mean something else in that format. A byte-order mark at
the start (some text editors add one) is stripped first, so it cannot
hide the (.
The test files_from_the_future_are_refused reads a file one format
newer than this version, (format: 30, sparkles: true) today, and
expects NewerFormat(30), and the message “this file uses format 30,
made by a newer version of Ferrous Lineup (this one reads up to format
29)”.
Errors as an enum
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum FileError {
/// The file could not be read or written: missing, no permission, disk full...
Io(io::Error),
/// The text is not a valid project file. The error says where.
Parse(ron::error::SpannedError),
/// The project could not be turned into text (should never happen).
Write(ron::Error),
/// The file was saved by a newer version of Ferrous Lineup.
NewerFormat(u32),
/// The file is shaped right, but what it says does not make sense.
Invalid(String),
}
}
This is the same pattern as EngineError (see A custom error
type) and SampleError:
one variant per kind of problem, a Display implementation with a
message for people (in the app’s language), and std::error::Error so
it works with any Rust error handling. Two more things make it pleasant
to use.
From for ?. Inside a function that returns
Result<_, FileError>, the ? operator can only pass an error on if it
knows how to turn it into a FileError. These implementations teach it:
#![allow(unused)]
fn main() {
impl From<io::Error> for FileError {
fn from(err: io::Error) -> Self {
FileError::Io(err)
}
}
}
With one for io::Error, one for ron’s parse error and one for its
write error, a function can mix calls from both libraries and use ?
on all of them:
#![allow(unused)]
fn main() {
pub fn load(path: &Path) -> Result<Project, FileError> {
let text = fs::read_to_string(path)?; // io::Error -> FileError::Io
from_text(&text)
}
}
source. The Error trait has an optional method that returns the
lower-level error a value wraps. Error-reporting tools follow it to print
a chain like “could not load: no such file”. FileError::Io returns its
io::Error; Invalid has nothing underneath and returns None.
#![allow(unused)]
fn main() {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FileError::Io(err) => Some(err),
FileError::Parse(err) => Some(err),
FileError::Write(err) => Some(err),
FileError::NewerFormat(_) | FileError::Invalid(_) => None,
}
}
}
&(dyn Error + 'static) is a trait object: a reference to some
value that implements Error, without saying which type. That lets one
method return an io::Error in one case and a ron error in another.
Saving without losing the old file
#![allow(unused)]
fn main() {
pub fn save(project: &Project, path: &Path) -> Result<(), FileError> {
let text = to_text(project)?;
let temporary = path.with_extension(format!("{EXTENSION}.tmp"));
if let Err(err) = fs::write(&temporary, text) {
let _ = fs::remove_file(&temporary);
return Err(err.into());
}
if let Err(err) = fs::rename(&temporary, path) {
let _ = fs::remove_file(&temporary);
return Err(err.into());
}
Ok(())
}
}
Writing straight over song.lineup has a nasty failure: if the program
crashes or the disk fills up halfway, the file is half old, half new, and
neither version can be loaded. Writing to song.lineup.tmp first and then
renaming it over the original avoids that, because the operating system
replaces a file by renaming in a single step. Either the rename happened
and the new file is complete, or it did not and the old file is
untouched. On Windows, Rust’s fs::rename also replaces an existing file,
so the same code works everywhere. The WAV and video exports and the
recovery copy are written the same way.
to_text always ends lines with \n (RON would use \r\n on
Windows), so a song is the same file on every system, and a project
saved on one computer shows no difference in version control when saved
on another.
The tests save to std::env::temp_dir(), the system’s folder for
temporary files (/tmp on Linux, something under your user folder on
Windows), with the process id in the name so two test runs at once do not
collide, and delete the file afterwards.
Damaged files never crash the app
A project file can be cut short by a full disk, edited by hand, or be
something else under a .lineup name. MIDI files come from every
program there is. Reading a file must end in a project or in an error
message, never in a crash. The test file damaged_files.rs checks this
the way a fuzzer does: it damages good files in many small ways and
reads each result.
The damage comes from a tiny random number generator, xorshift, written in the test:
#![allow(unused)]
fn main() {
struct Damage(u64);
impl Damage {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}
}
It starts from a fixed seed, so every run damages the files the same
way, and a failure shows up again on the next run. It needs no new
dependency. For project files and chord sheets, it replaces numbers
with extreme ones (-1, 65535, 18446744073709551615, NaN), cuts
pieces out, and repeats pieces. For MIDI files, it changes, cuts and
repeats bytes. Each test reads 600 damaged copies. A project that reads
must also save and read back.
The test catches a panic with std::panic::catch_unwind, so a failure
prints the damaged file that caused it, up to its first 4,000
characters:
#![allow(unused)]
fn main() {
fn must_not_panic<T: std::fmt::Debug>(input: T, read: impl FnOnce(&T)) {
if panic::catch_unwind(AssertUnwindSafe(|| read(&input))).is_err() {
let shown = format!("{input:?}");
let shown: String = shown.chars().take(4000).collect();
panic!("reading this damaged file panicked:\n{shown}");
}
}
}
Overflow is the kind of mistake it catches. A file whose key change sat
at step 18446744073709551615, the largest usize, fails validation as
it should, but writing the error message computed marker.start + 1 to
say “step …”, and that overflowed. Rust treats overflow differently in
the two build profiles. In a debug build, + panics on overflow
(“attempt to add with overflow”). In a release build it wraps
around to 0, silently. So a run in release mode over 15,000 damaged
files saw nothing, and the same files in debug mode crashed. The
messages use saturating_add(1), which stops at the largest number
instead of overflowing, and CI runs the tests in debug mode (see How
the code is tested).
In the app: the File menu
The app side is in crates/lineup-app/src/app/files.rs. Its first lines
explain an unusual layout:
#![allow(unused)]
fn main() {
//! This is a child module of `app` (its file is `app/files.rs`), so it can
//! use the private fields of [`LineupApp`]: in Rust, private means "visible
//! in this module and the modules inside it".
}
app.rs declares mod files;, and Rust looks for that module in
app/files.rs. The file holds another impl LineupApp block. A type can
have any number of impl blocks, in any modules of its crate, so a big
type can be split by topic. Being a child of app, files sees the
private fields self.project, self.audio and so on, which a sibling
module like playlist could not.
Choosing a file
File dialogs come from the rfd crate (“Rusty File Dialog”), which
shows the system’s own dialog: GTK or KDE on Linux (through the desktop
portal, or the zenity program), the Finder’s panel on macOS, the
Explorer dialog on Windows.
#![allow(unused)]
fn main() {
fn project_dialog() -> rfd::FileDialog {
rfd::FileDialog::new().add_filter(tr("Ferrous Lineup project"), &[file::EXTENSION])
}
if let Some(path) = project_dialog().pick_file() {
self.open_now(path);
}
}
pick_file waits until the user picks a file or cancels, and returns an
Option<PathBuf>. The window does not redraw while the dialog is open,
but the music keeps playing, because it runs on the audio thread. On a
Linux system with neither a portal nor zenity, pick_file returns
None straight away; the dialog simply does not appear.
Save As gives the file a .lineup extension even if you type another
one, so song.txt is saved as song.lineup, not as something that
another importer would try to open.
“Save changes?”
How does the app know there are unsaved changes? It keeps a copy of the
project as it was last opened or saved, and compares. But Project
derives PartialEq, so != walks every channel, note and clip. That is
cheap once, but a drag edits the project on every frame, and comparing
the whole song every frame is wasted work. FileState therefore caches
the answer:
#![allow(unused)]
fn main() {
/// Whether the project differs from `saved`, as last worked out.
dirty: bool,
/// Set when the project may differ from `saved`; `dirty` is worked
/// out again the next time it is asked for.
check_dirty: bool,
}
An edit sets check_dirty, but only if the project was not already
dirty. While it is dirty, more frames of a drag do not ask for the
whole comparison again:
#![allow(unused)]
fn main() {
pub(super) fn project_edited(&mut self) {
if !self.dirty {
self.check_dirty = true;
}
}
}
A change made in one go (a click), an undo, a redo, and the end of a
drag can all make the project match the saved copy again, so they always
ask for a new comparison with project_reverted (see Telling the audio
thread what changed). The cached answer is read
every frame for the title, but the comparison itself runs only when the
flag is set:
#![allow(unused)]
fn main() {
pub(super) fn has_unsaved_changes(&mut self) -> bool {
if self.file.check_dirty {
self.file.dirty = match &self.file.saved {
Some(saved) => self.project != *saved,
None => true, // recovered work has never been saved
};
self.file.check_dirty = false;
}
self.file.dirty
}
}
So a step clicked on and then off again clears the star, and so does a note dragged away and back, once you let go.
Anything that would throw the project away waits for an answer first:
#![allow(unused)]
fn main() {
pub(super) enum Pending {
/// A chord sheet with a band.
New,
/// An empty pattern and a drum kit.
NewBeat,
Open,
OpenPath(PathBuf),
Demo(Demo),
Import,
ImportMidi,
ImportChordSheet,
Quit,
}
pub(super) fn request(&mut self, ctx: &egui::Context, action: Pending) {
// ...a quit during an export asks about the export first...
if self.has_unsaved_changes() || ... {
self.file.pending = Some(action);
} else {
self.perform(ctx, action);
}
}
}
While pending holds something, unsaved_changes_dialog shows an
egui::Modal, a window that dims everything behind it and takes all the
clicks:
Save saves and then carries out the waiting action, unless saving failed
or the user cancelled the Save As dialog; Don’t save carries it out
straight away; Cancel, Escape or a click outside forgets it. The
pending value is the whole state machine: None means “nothing
waiting”, Some(Pending::Open) means “the user wants to open a file,
once they decide about the changes”.
pub(super) makes the enum visible to the parent module (app), which
needs it for FileState, but no further.
The style editor has its own unsaved draft. Quitting with an edited draft asks Save style changes? as well, so saving the song never silently throws the style away.
Closing the window
Clicking the window’s × asks the operating system to close it. egui reports the request, and the app can say no:
#![allow(unused)]
fn main() {
let close_requested = ctx.input(|input| input.viewport().close_requested());
if close_requested
&& !self.file.quitting
&& (self.has_unsaved_changes() || self.style_editor.has_unsaved_changes())
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.file.pending = Some(Pending::Quit);
}
}
When the user then picks Save or Don’t save, perform(Pending::Quit)
sets quitting and sends ViewportCommand::Close, which is let through
this time.
Quitting while a WAV or video export is still running asks An export is still running, with Stop it and quit and Keep exporting. Stopping waits until the export has stopped and removed its unfinished file, and then closes, so a half-written file is never left behind looking finished.
Shortcuts
#![allow(unused)]
fn main() {
const SAVE: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::S);
const SAVE_AS: KeyboardShortcut =
KeyboardShortcut::new(Modifiers::COMMAND.plus(Modifiers::SHIFT), Key::S);
let pressed = |shortcut| ctx.input_mut(|input| input.consume_shortcut(&shortcut));
if pressed(SAVE_AS) {
self.save_as();
} else if pressed(SAVE) {
self.save();
}
}
Modifiers::COMMANDis Ctrl on Linux and Windows and ⌘ on macOS, so each system gets the shortcut its users expect. The File menu shows the right one, too: “Ctrl+S” or “⌘S”.consume_shortcutchecks the keys and removes them from the input, so a text field does not also see them.- Ctrl+S would also match Ctrl+Shift+S (the extra Shift is ignored), so the longer shortcut is checked first.
These const values call KeyboardShortcut::new while compiling. That
is allowed because egui marked it as a const fn, a function simple
enough to run at compile time.
The title bar
The window title shows the song’s name and a star for unsaved changes,
“song.lineup* · Ferrous Lineup”. A demo shows its own name and says it
is read-only, “Für Elise (demo, read-only) · Ferrous Lineup”: demos are
built into the app, so Save asks for a new file, as Save As does. The
app sends a ViewportCommand::Title only when the text changes, not on
every frame.
Opening a file from outside
#![allow(unused)]
fn main() {
let mut args = std::env::args_os().skip(1);
let first = args.next();
}
ferrous-lineup song.lineup starts with the song open, which is also
what a file manager does when you double-click a file associated with
the app. (The same first argument can instead be --trace, --demos
or --help, which do their job without opening a window; see The
playback trace.) args_os returns the arguments
as OsStrings, exactly as the system gave them. std::env::args would
panic on a file name that is not valid UTF-8, which Linux allows.
skip(1) skips the first argument, which is the program’s own name.
A .lineup file dropped on the window is opened too, after the same
“Save changes?” question, and a MIDI file, a chord sheet or a recording
is imported. Other dropped WAV files go to the channel rack (see
Importing recordings
for which WAV is which).
File > Open recent
File > Open recent lists the last ten projects opened or saved, most recent first. The list is kept in egui’s stored data, so it survives a restart. Each item shows the file’s name, with the full path in its tooltip. Opening a file that no longer loads removes it from the list instead of leaving a dead entry.
The list holds paths, not copies of projects. If a project is moved or renamed outside the app, the old path stays until an attempt to open it fails.
A copy of unsaved work
The app writes a recovery copy of the project while it has unsaved changes. The first change writes one at once, so the first edit of a session is not the one a crash loses; after that it writes every thirty seconds:
#![allow(unused)]
fn main() {
/// How often unsaved work is written while it keeps changing.
const INTERVAL: Duration = Duration::from_secs(30);
}
A window left alone draws no frames, and so would never get round to
writing. So when a copy is due, autosave asks egui for a frame at that
moment with request_repaint_after: the copy catches up within about
half a minute of a change, even if nobody touches the app.
The files are in a recovery folder inside the app’s data folder, the
one eframe::storage_dir(APP_NAME) gives it. On Linux that is usually
~/.local/share/ferrouslineup/recovery. Each run of the app names its
files after itself, such as 4211-1793047912612-0.lineup and
4211-1793047912612-0.info: the process number, the time it started in
milliseconds, and a count. Why each run needs its own is in Two windows
at once below. The copy is not written next
to the project, because a project may be on a read-only medium, and an
autosave file in a folder of songs would surprise anyone looking at it.
The small .info file carries the project’s path and name, so a
recovered song comes back as itself, not as “Untitled”.
The copy is written by file::save, which writes a temporary file and
renames it. A crash in the middle leaves the previous copy whole; the
info file is written after the project, so a crash between them loses
the name rather than giving the project the wrong one.
At startup, if a copy is there, a modal asks Recover unsaved work? and names the song:
- Recover it puts the project back, with its path and name, and removes the copy. It also loads the file on disk, if it still exists, as the “saved” version, so the recovered work correctly shows the unsaved star; undoing back to the file clears it. If the file is gone, the recovered project is simply unsaved.
- Discard it removes the copy and leaves the project the app would otherwise have opened.
A clean exit removes the copy, because there is nothing to recover. The
copy that remains is the sign of a crash, a kill, or a power cut. A copy
whose project will not load is removed at startup rather than offered.
A single recovery.lineup straight in the data folder, as older
versions wrote it, is offered too.
Two windows at once
Nothing stops you from starting Ferrous Lineup twice. If every run wrote the same copy, the second window would find the first one’s copy at startup and offer it as if the first had crashed, while it was still open; and each window’s autosave would replace the other’s copy.
So each run writes its own copy, and next to it a third file,
<run>.lock, which it locks before the first copy is written and
holds while it runs. A file lock is a promise kept by the operating
system: only one open file can hold it, and when the program that holds
it ends, the system lets go of it, even in a crash or a power cut. That
makes it the right question to ask at startup: “is the run that wrote
this copy still going?” If its lock can be taken, it is not, and the
copy is offered; if not, the copy belongs to a window that is still open
and is left alone.
The standard library has had file locks since Rust 1.89 (File::lock,
File::try_lock and friends). try_lock does not wait: it says at once
whether it got the lock, and its error tells “someone else holds it”
apart from “this system cannot lock files”:
#![allow(unused)]
fn main() {
fn take_lock(path: &Path) -> Lock {
// A copy whose lock file is gone was left by a run that closed with
// work unsaved: the file is made again, to be taken.
let Ok(file) = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
else {
return Lock::Unknown;
};
match file.try_lock() {
Ok(()) => Lock::Taken(file),
Err(TryLockError::WouldBlock) => Lock::Held,
Err(TryLockError::Error(_)) => Lock::Unknown,
}
}
}
The lock lasts as long as the File value: dropping it lets go. So a
window that finds a copy keeps the File in the Recovered value until
you answer the question; a third window opened meanwhile cannot take the
lock and does not offer the same copy twice. When the app closes, the
Drop of Recovery removes its lock file; a copy of unsaved work stays,
without its lock, to be offered next time. A system that cannot lock
files (Lock::Unknown) offers the copy anyway: offering it twice loses
nothing, never offering it would.
If two runs crashed, there are two copies. The newest is offered first, and the next waits until the work recovered before it is saved or let go, so recovering one never replaces another.