Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
that read `unverified` are measured, against a workspace provisioned to run
them; the eighth says plainly which half of it was measured and which was not.

- **Two branches that derive the same workspace id are no longer quietly given
the same workspace.** A workspace id is `<repo-slug>-<ref-slug>-<suffix>`, and
only the four-character suffix makes it unique: the owner is not in the readable
half at all, `feature/auth` and `feature-auth` slug to one string, and a long ref
loses its tail to the 47-character cap. Two branches whose ids do land on the
same string share one clone directory and one devpod workspace, because devpod
names workspaces globally rather than per repository.

What that looked like: the second one attached to the first one's container and
said nothing. You got somebody else's checkout, from a command that had no
reason to look wrong, and a later `dl <ws> rm` on either of them deleted a clone
the other still claimed. Rare, roughly one in thirty-seven thousand across ten
near-identical branches in one repository, and the kind of rare that costs a day
when it happens.

`dl` now checks before it asks devpod anything, and refuses:

```
Workspace id 'devlaunch-release-999999999999999999999911-dq8q' is already held by
'blooop/devlaunch@release/999999999999999999999911630'. Launching
'blooop/devlaunch@release/999999999999999999999911783' under it would put both
in one clone directory and one container, so each would open the other's
checkout and 'dl <ws> rm' on either would delete work the other still owns.
Rename one of the two branches and launch it again.
```

Two spellings of one repository are not a collision: `NVIDIA/cuda-samples` and
`nvidia/cuda-samples` derive one id on purpose, and the check compares triples by
the same rule the id is derived under, so the second spelling still opens the
workspace you already have. Refs stay case-sensitive, because `Main` and `main`
really are two branches.

The check reads the records `dl` already keeps, so it needs no round trip and
nothing new on disk, and it fails open in every direction: a record whose branch
is not a legal ref is skipped, a `metadata.json` that cannot be read means "no
collision" rather than an error, and a launch that matches its own record
attaches exactly as it did before. Reading the records on the warm attach path
takes no lock, runs no migration and writes nothing, so the attach costs what it
cost before.

The same hole in the schema 2 to 3 migration is closed with it: two records that
derived one destination both ended up owning it, because the guard against
adopting another record's clone only knew the paths records pointed at before
the run started.

## [0.25.0] - 2026-08-28

### Fixed
Expand Down
1 change: 1 addition & 0 deletions rust/devlaunch-core/public-api.api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ pub const devlaunch_core::api::HANDOFF_VAR: &str
pub const devlaunch_core::api::PREWARM_VAR: &str
pub trait devlaunch_core::api::ColdMachinery<'r>
pub fn devlaunch_core::api::ColdMachinery::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::api::ColdMachinery::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
pub trait devlaunch_core::api::Notices<T>
pub fn devlaunch_core::api::Notices::say(&mut self, T)
pub trait devlaunch_core::api::Provision
Expand Down
14 changes: 14 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::new(&'r dyn devlaunch_ru
pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::records(&mut self) -> core::result::Result<&mut devlaunch_core::flows::records::Records<'r>, devlaunch_core::flows::records::StartupError>
impl<'r> devlaunch_core::flows::launch::ColdMachinery<'r> for devlaunch_core::flows::launch::ColdPath<'r, '_>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
impl<'r> devlaunch_core::flows::listing::CommandContext<'r>
pub fn devlaunch_core::flows::listing::CommandContext<'r>::git(&self) -> devlaunch_core::clients::git::Git<'r>
pub fn devlaunch_core::flows::listing::CommandContext<'r>::new(&'r dyn devlaunch_runner::Runner) -> Self
Expand Down Expand Up @@ -127,6 +128,7 @@ pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::provision_tools(&sel
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
impl<'r> devlaunch_core::flows::launch::ColdMachinery<'r> for devlaunch_core::flows::launch::ColdPath<'r, '_>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
impl<T> devlaunch_core::notices::Notices<T> for alloc::vec::Vec<T>
pub fn alloc::vec::Vec<T>::say(&mut self, T)
impl devlaunch_core::flows::launch::Provision for devlaunch_core::flows::launch::ToolProvisioning<'_>
Expand Down Expand Up @@ -589,6 +591,7 @@ impl core::marker::StructuralPartialEq for devlaunch_core::domain::metadata::Sec
pub struct devlaunch_core::domain::metadata::MetadataStorage
impl devlaunch_core::domain::metadata::MetadataStorage
pub fn devlaunch_core::domain::metadata::MetadataStorage::default_path() -> core::result::Result<std::path::PathBuf, devlaunch_core::domain::xdg::NoHomeDirectory>
pub fn devlaunch_core::domain::metadata::MetadataStorage::look(impl core::convert::Into<std::path::PathBuf>) -> Self
pub fn devlaunch_core::domain::metadata::MetadataStorage::on_metadata_lock_wait(&mut self, impl core::ops::function::Fn() + 'static)
pub fn devlaunch_core::domain::metadata::MetadataStorage::open(impl core::convert::Into<std::path::PathBuf>) -> core::result::Result<(Self, alloc::vec::Vec<devlaunch_core::domain::metadata::Notice>), devlaunch_core::domain::metadata::MetadataError>
impl core::fmt::Debug for devlaunch_core::domain::metadata::MetadataStorage
Expand Down Expand Up @@ -1249,6 +1252,14 @@ pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed
pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed::error: devlaunch_core::flows::launch::BranchNotNamed
pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed::owner: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed::repo: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::branch: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::owner: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::recorded_branch: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::recorded_owner: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::recorded_repo: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::repo: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::workspace_id: alloc::string::String
pub devlaunch_core::flows::launch::LaunchRefusal::NoSession(devlaunch_core::flows::launch::SessionRefused)
pub devlaunch_core::flows::launch::LaunchRefusal::NotPrepared
pub devlaunch_core::flows::launch::LaunchRefusal::NotPrepared::branch: alloc::string::String
Expand Down Expand Up @@ -1420,6 +1431,7 @@ pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::new(&'r dyn devlaunch_ru
pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::records(&mut self) -> core::result::Result<&mut devlaunch_core::flows::records::Records<'r>, devlaunch_core::flows::records::StartupError>
impl<'r> devlaunch_core::flows::launch::ColdMachinery<'r> for devlaunch_core::flows::launch::ColdPath<'r, '_>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
pub struct devlaunch_core::flows::launch::Host
impl devlaunch_core::flows::launch::Host
pub fn devlaunch_core::flows::launch::Host::from_process(impl core::convert::Into<std::path::PathBuf>) -> Self
Expand Down Expand Up @@ -1456,8 +1468,10 @@ pub fn devlaunch_core::flows::launch::UnquotableCommand::fmt(&self, &mut core::f
impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::UnquotableCommand
pub trait devlaunch_core::flows::launch::ColdMachinery<'r>
pub fn devlaunch_core::flows::launch::ColdMachinery::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::flows::launch::ColdMachinery::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
impl<'r> devlaunch_core::flows::launch::ColdMachinery<'r> for devlaunch_core::flows::launch::ColdPath<'r, '_>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
pub trait devlaunch_core::flows::launch::Provision
pub fn devlaunch_core::flows::launch::Provision::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::flows::launch::Provision::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
Expand Down
137 changes: 130 additions & 7 deletions rust/devlaunch-core/src/domain/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,19 @@ impl std::fmt::Debug for MetadataStorage {
}
}

/// Whether a load may act on what it finds, or only look at it.
///
/// The two side effects a load can have -- quarantining an unusable file and
/// backing up one that will not round-trip -- exist to protect bytes from the
/// caller's *next save*. A caller that will never save does not need them and must
/// not take them, because writing to the cache is exactly what the warm launch path
/// is not allowed to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnDisk {
Preserve,
LeaveAlone,
}

impl MetadataStorage {
/// Where the file lives when nothing says otherwise.
pub fn default_path() -> Result<PathBuf, NoHomeDirectory> {
Expand Down Expand Up @@ -400,10 +413,49 @@ impl MetadataStorage {
worktrees: IndexMap::new(),
wait_watcher: None,
};
let notices = storage.load();
let notices = storage.load(OnDisk::Preserve);
Ok((storage, notices))
}

/// Load the store at `metadata_path` to *look* at, changing nothing on disk.
///
/// Three writes separate this from [`open`](Self::open), and every one of them
/// is there for a caller that is about to save: the cache directory is created
/// so the first save has somewhere to land, an unusable file is quarantined to
/// `.corrupt` so that save cannot overwrite bytes nobody has read, and a file
/// that will not round-trip is copied to `.bak` for the same reason. A reader
/// saves nothing, so it takes none of them, and the cache is byte for byte as
/// it was found.
///
/// **That is what lets a read sit on the warm launch path at all**
/// (devlaunch#145): `a_warm_triple_launch_writes_nothing_to_the_cache` seeds the
/// cache with a corrupt document and asserts the launch leaves it and its two
/// siblings exactly as they were. A file that cannot be read or parsed loads as
/// an empty store here, silently -- there is nobody to tell, and the command
/// that actually needs the records will open them properly and report it then.
///
/// No lock either. A reader that raced a writer sees the file as it was a
/// moment ago, which is what any lock-free read of a file that is replaced
/// atomically sees, and is the same staleness a caller would get by opening it
/// one instruction earlier.
#[must_use]
pub fn look(metadata_path: impl Into<PathBuf>) -> Self {
let metadata_path = metadata_path.into();
let file_path = resolve_link(&metadata_path);
let lock_path = sibling(&file_path, ".lock");
let mut storage = Self {
metadata_path,
file_path,
lock_path,
schema_version: SCHEMA_VERSION,
repositories: IndexMap::new(),
worktrees: IndexMap::new(),
wait_watcher: None,
};
let _ = storage.load(OnDisk::LeaveAlone);
storage
}

/// Be told when a mutation is about to queue behind another dl run.
///
/// The one thing a returned notice cannot cover: the point of saying it is
Expand Down Expand Up @@ -654,7 +706,7 @@ impl MetadataStorage {
}
})
.map_err(MetadataError::Lock)?;
let notices = self.load();
let notices = self.load(OnDisk::Preserve);
let value = work(self)?;
drop(guard);
Ok((value, notices))
Expand Down Expand Up @@ -780,7 +832,7 @@ impl MetadataStorage {
.map_err(MetadataError::Lock)?;
// Reload under the lock so the version below reflects any concurrent
// migrator's result, not the copy this process loaded at startup.
let _ = self.load();
let _ = self.load(OnDisk::Preserve);
if self.schema_version >= SCHEMA_VERSION {
return Ok(MigrationCommit::AlreadyCurrent);
}
Expand All @@ -795,12 +847,12 @@ impl MetadataStorage {
// --- loading ----------------------------------------------------------

/// Load from disk, never failing on damaged input.
fn load(&mut self) -> Vec<Notice> {
fn load(&mut self, on_disk: OnDisk) -> Vec<Notice> {
self.schema_version = SCHEMA_VERSION;
self.repositories = IndexMap::new();
self.worktrees = IndexMap::new();

let (data, mut notices) = self.read_file();
let (data, mut notices) = self.read_file(on_disk);
let Some(mut object) = data else {
return notices;
};
Expand Down Expand Up @@ -838,14 +890,16 @@ impl MetadataStorage {
});
}

if notices.iter().any(Notice::implies_lossy_rewrite) {
if let OnDisk::Preserve = on_disk
&& notices.iter().any(Notice::implies_lossy_rewrite)
{
notices.push(self.backup());
}
notices
}

/// Read and sanity-check the file, quarantining it if it is unusable.
fn read_file(&self) -> (Option<serde_json::Map<String, Value>>, Vec<Notice>) {
fn read_file(&self, on_disk: OnDisk) -> (Option<serde_json::Map<String, Value>>, Vec<Notice>) {
if !self.file_path.exists() {
return (None, Vec::new());
}
Expand All @@ -863,6 +917,12 @@ impl MetadataStorage {
},
},
};
// A look reports nothing and moves nothing: an unusable file is an empty
// store to a reader, and the quarantine belongs to the caller that is about
// to write over it.
if let OnDisk::LeaveAlone = on_disk {
return (None, Vec::new());
}
let notice = Notice::FileUnusable {
path: self.file_path.clone(),
problem,
Expand Down Expand Up @@ -1417,6 +1477,69 @@ mod tests {
assert_eq!(storage.schema_version(), SCHEMA_VERSION);
}

// --- looking, which changes nothing -----------------------------------

#[test]
fn a_look_reads_a_corrupt_file_as_empty_and_leaves_it_exactly_as_it_found_it() {
// The difference between `look` and `open`, stated as the thing that made it
// necessary: `open` quarantines an unusable document so its next save cannot
// overwrite bytes nobody has read, and the collision guard on the warm launch
// path (blooop/devlaunch#438) has no next save. A launch that only looks must
// leave the cache byte for byte as it was, garbage included.
let dir = temp_dir();
let path = dir.path().join("metadata.json");
write(&path, "not json");

let looked = MetadataStorage::look(&path);

assert!(looked.worktrees().is_empty(), "unusable reads as empty");
assert_eq!(read(&path), "not json");
assert_eq!(names_in(dir.path()), vec!["metadata.json".to_owned()]);
}

#[test]
fn a_look_at_a_cache_that_is_not_there_creates_nothing() {
// `open` makes the directory so its first save has somewhere to land. A
// reader has no first save, and a warm launch on a machine with no cache yet
// must not leave one behind.
let dir = temp_dir();
let absent = dir.path().join("no-cache-here").join("metadata.json");

let looked = MetadataStorage::look(&absent);

assert!(looked.worktrees().is_empty());
assert_eq!(names_in(dir.path()), Vec::<String>::new());
}

#[test]
fn a_look_reads_the_same_records_an_open_does() {
let dir = temp_dir();
let path = dir.path().join("metadata.json");
{
let mut storage = quiet_storage(dir.path());
storage
.add_worktree(WorktreeInfo::new(
"owner1",
"repo1",
"branch1",
dir.path().join("repos/owner1/repo1/repo1-branch1-abcd"),
"repo1-branch1-abcd",
))
.expect("the record is saved");
}

let looked = MetadataStorage::look(&path);

assert_eq!(
looked
.worktrees()
.values()
.map(|record| record.workspace_id.clone())
.collect::<Vec<_>>(),
vec!["repo1-branch1-abcd".to_owned()]
);
}

#[test]
fn a_repository_can_be_added_read_listed_and_removed() {
let dir = temp_dir();
Expand Down
Loading
Loading