Repository navigation
fix(repo): ws refresh crash-retry edges (temp sweep before .scignore, emptied directory) #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
72d83a5
16c67bb
55f3cc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -325,11 +325,23 @@ fn plan_checkout( | |
| identity: Option<&scl_crypto::SecretKey>, | ||
| sparse: &Sparse, | ||
| cache: &mut ProtectedCache, | ||
| resuming: bool, | ||
| prior_writers: Option<&[u32]>, | ||
| ) -> Result<Plan> { | ||
| let resuming = prior_writers.is_some(); | ||
| // Bounded by the view on both sides: a partial clone does not hold the rest. | ||
| let old = tree_file_entries_with_perms_sparse(store, old_root, sparse)?; | ||
| let new = tree_file_entries_with_perms_sparse(store, new_root, sparse)?; | ||
| // A killed writer may have left staging files with protected plaintext beside | ||
| // the paths it was writing (any path the result changes, skipped ones | ||
| // included: the first attempt may have had a key). They go first: the ignore | ||
| // comparison below would count one as a file the agent added. | ||
| if let Some(writers) = prior_writers { | ||
| for (p, _) in new.iter().filter(|(p, e)| old.get(*p) != Some(e)) { | ||
| if !under_symlink(root, p) { | ||
| sweep_stale_temps(&safe_join(root, p)?, writers); | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| hidden_additions( | ||
| store, | ||
| root, | ||
|
|
@@ -437,7 +449,17 @@ fn plan_checkout( | |
| // empty one the refresh did not empty is the user's (as in any | ||
| // forward op), whose working directory a process may hold. | ||
| let under = format!("{p}/"); | ||
| let emptied = removals.iter().any(|r| r.0.starts_with(&under)); | ||
| // On a retry the husk may be the work of the first attempt: the | ||
| // candidate tracked files under it, the result drops them all and | ||
| // none is left on disk to remove (`dir_clearable` below still | ||
| // refuses anything else inside). | ||
| let emptied = removals.iter().any(|r| r.0.starts_with(&under)) | ||
| || (resuming | ||
| && old.iter().any(|(o, e)| { | ||
| // A protected file may never have been laid out (no key at | ||
| // fork), so it proves nothing about what was on disk. | ||
| o.starts_with(&under) && !new.contains_key(o) && e.2 & PROTECTED == 0 | ||
|
Comment on lines
+457
to
+461
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the process dies after writing the pending marker but before removing AGENTS.md reference: AGENTS.md:L3-L5 Useful? React with 👍 / 👎. |
||
| })); | ||
| if !emptied || !dir_clearable(&full, p, &|c| removable.contains(c)) { | ||
| return Err(Error::InvalidArgument(format!( | ||
| "cannot write {p}: a directory with untracked or ignored content \ | ||
|
|
@@ -897,7 +919,7 @@ impl Repo { | |
| identity, | ||
| &sparse, | ||
| &mut prep.cache, | ||
| prior_pending.is_some(), | ||
| prior_pending.as_ref().map(|p| &p.writers[..]), | ||
| ) | ||
| }; | ||
| match plan { | ||
|
|
@@ -942,23 +964,6 @@ impl Repo { | |
| }); | ||
| } | ||
| Some(p) => { | ||
| // A killed writer may have left staging files with protected | ||
| // plaintext: remove those of the pids that wrote before. | ||
| for job in &jobs { | ||
| // Paths skipped for lack of a key included: the first attempt | ||
| // may have had one. | ||
| let paths = job | ||
| .plan | ||
| .writes | ||
| .iter() | ||
| .map(|w| &w.0) | ||
| .chain(&job.plan.skipped); | ||
| for path in paths { | ||
| if !under_symlink(&job.dir, path) { | ||
| sweep_stale_temps(&safe_join(&job.dir, path)?, &p.writers); | ||
| } | ||
| } | ||
| } | ||
| if !p.writers.contains(&me) { | ||
| p.writers.push(me); | ||
| } | ||
|
|
@@ -1696,6 +1701,95 @@ mod tests { | |
| cleanup(repo, &root); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_stale_staging_file_the_new_ignore_rules_expose_is_swept_before_they_are_compared() { | ||
| let root = tmp_root("stale-tmp-scignore"); | ||
| let repo = Repo::init(&root).unwrap(); | ||
| std::fs::write(root.join(".scignore"), "*.tmp\n").unwrap(); | ||
| std::fs::write(root.join("a.txt"), LINES).unwrap(); | ||
| repo.commit("t", "base").unwrap(); | ||
| let s = repo.ws_fork(None, 1, "t", None).unwrap(); | ||
| let dir = s.workspaces[0].dir.clone(); | ||
| // The tip stops hiding `*.tmp` and adds b.txt; the apply phase dies after | ||
| // .scignore, in the middle of b.txt's write. | ||
| host_commit( | ||
| &repo, | ||
| &root, | ||
| &[(".scignore", Some("other\n")), ("b.txt", Some("b\n"))], | ||
| ); | ||
| { | ||
| let _g = FailGuard::after(1); | ||
| repo.ws_refresh(None, None, None).unwrap_err(); | ||
| } | ||
| assert_eq!(read(&dir, ".scignore"), "other\n"); | ||
| // The killed writer's staging file: hidden by the rules the checkout was | ||
| // read under, visible under the new ones. | ||
| let stale = dir.join("b.4000000.7.tmp"); | ||
| std::fs::write(&stale, "half\n").unwrap(); | ||
| let mut session = repo.ws_session(None).unwrap().unwrap(); | ||
| session | ||
| .pending_refresh | ||
| .as_mut() | ||
| .unwrap() | ||
| .writers | ||
| .push(4_000_000); | ||
| write_manifest(repo.layout(), &session).unwrap(); | ||
| repo.ws_refresh(None, None, None).unwrap(); | ||
| assert!(!stale.exists()); | ||
| assert_eq!(read(&dir, "b.txt"), "b\n"); | ||
| cleanup(repo, &root); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_retry_replaces_the_empty_directory_its_own_removals_left() { | ||
| let (repo, root) = init("dir-replaced"); | ||
| host_commit(&repo, &root, &[("dir/file", Some("f\n"))]); | ||
| let s = repo.ws_fork(None, 1, "t", None).unwrap(); | ||
| let dir = s.workspaces[0].dir.clone(); | ||
| // The host replaces the directory with a file. | ||
| std::fs::remove_dir_all(root.join("dir")).unwrap(); | ||
| std::fs::write(root.join("dir"), "now a file\n").unwrap(); | ||
| let tip = repo.commit("t", "host").unwrap(); | ||
| { | ||
| // dir/file is removed, then the write of `dir` dies. | ||
| let _g = FailGuard::after(1); | ||
| repo.ws_refresh(None, None, None).unwrap_err(); | ||
| } | ||
| assert!(dir.join("dir").is_dir() && !dir.join("dir/file").exists()); | ||
| // Something of the user's in the husk is not ours to clear. | ||
| std::fs::write(dir.join("dir/mine"), "mine\n").unwrap(); | ||
| let err = repo.ws_refresh(None, None, None).unwrap_err().to_string(); | ||
| assert!(err.contains("cannot write dir"), "{err}"); | ||
| assert_eq!(read(&dir, "dir/mine"), "mine\n"); | ||
| std::fs::remove_file(dir.join("dir/mine")).unwrap(); | ||
| repo.ws_refresh(None, None, None).unwrap(); | ||
| assert_eq!(read(&dir, "dir"), "now a file\n"); | ||
| assert_eq!(base_of(&repo, None), tip); | ||
| cleanup(repo, &root); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_retry_still_refuses_a_users_empty_directory_the_candidate_never_tracked() { | ||
| let (repo, root) = init("dir-users-empty"); | ||
| let s = repo.ws_fork(None, 1, "t", None).unwrap(); | ||
| let dir = s.workspaces[0].dir.clone(); | ||
| host_commit( | ||
| &repo, | ||
| &root, | ||
| &[("b.txt", Some("b\n")), ("mine", Some("a file\n"))], | ||
| ); | ||
| { | ||
| // b.txt is written, then the write of `mine` dies. | ||
| let _g = FailGuard::after(1); | ||
| repo.ws_refresh(None, None, None).unwrap_err(); | ||
| } | ||
| std::fs::create_dir(dir.join("mine")).unwrap(); | ||
| let err = repo.ws_refresh(None, None, None).unwrap_err().to_string(); | ||
| assert!(err.contains("cannot write mine"), "{err}"); | ||
| assert!(dir.join("mine").is_dir()); | ||
| cleanup(repo, &root); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_convergent_protected_file_edited_after_the_plan_is_not_overwritten() { | ||
| let (repo, root) = init("late-convergent"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a retry where the old
.scignorehid*.tmpand the result exposes it, this pre-check sweep silently deletes any regular sibling named likeb.<recorded-dead-pid>.<n>.tmpbeforehidden_additionscan recognize it as a user file. The filename and dead PID are not proof of ownership: such an ignored file may have existed independently or been created after the crash, so retrying can lose user data instead of refusing as it previously did. Record the actual staging paths or otherwise verify ownership before removing them.AGENTS.md reference: AGENTS.md:L3-L5
Useful? React with 👍 / 👎.