fix(viewer): atomic find-or-reserve so an in-flight window is never read as stale

A second click while the first window was still being built removed its
registry entry, giving a broken window, a duplicate and a cap bypass. The
registry now records when a window is built; `reserve` dedupes, prunes only
built entries whose window is gone (any state, so a leak cannot hold a cap
slot), and enforces the cap in one critical section. Choosing a file already
open elsewhere focuses that window and closes the chooser instead of
resolving a second entry. The not-running sentence names the real action.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:23:25 -07:00
co-authored by Claude Opus 5.5
parent 20e60b78a1
commit 804213a517
2 changed files with 294 additions and 51 deletions
@@ -12,7 +12,9 @@ use crate::commands::file_commands::{
}; };
use crate::file_viewer::is_viewer_label; use crate::file_viewer::is_viewer_label;
use crate::file_viewer::poll::{poll_file, ViewerPoll}; use crate::file_viewer::poll::{poll_file, ViewerPoll};
use crate::file_viewer::registry::{Location, ViewerRegistry, ViewerTarget, ViewerTargetState}; use crate::file_viewer::registry::{
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
};
use crate::file_viewer::resolve::{candidate_paths, probe_candidates}; use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
use crate::file_viewer::window::open_viewer_window; use crate::file_viewer::window::open_viewer_window;
use crate::file_viewer::write::{sha256_hex, write_file, MAX_WRITE_BYTES}; use crate::file_viewer::write::{sha256_hex, write_file, MAX_WRITE_BYTES};
@@ -110,17 +112,31 @@ fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
.ok_or_else(|| "This project no longer exists.".to_string()) .ok_or_else(|| "This project no longer exists.".to_string())
} }
async fn running_container_of(project: &Project) -> Result<String, String> { /// `action` completes "Start the project before …", e.g. "saving this file".
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
let container_id = project.container_id.clone().ok_or_else(|| { let container_id = project.container_id.clone().ok_or_else(|| {
"Start the project before opening files — they live in its container.".to_string() format!("Start the project before {} — files live in its container.", action)
})?; })?;
require_running(&container_id, "opening files").await?; require_running(&container_id, action).await?;
Ok(container_id) Ok(container_id)
} }
/// The container of the project a viewer window belongs to, if it is running. /// The container of the project a viewer window belongs to, if it is running.
async fn running_container_for(state: &AppState, target: &ViewerTarget) -> Result<String, String> { async fn running_container_for(
running_container_of(&project_of(state, &target.project_id)?).await state: &AppState,
target: &ViewerTarget,
action: &str,
) -> Result<String, String> {
running_container_of(&project_of(state, &target.project_id)?, action).await
}
/// Raises an existing viewer window and moves it to `location`.
fn focus_viewer(app: &AppHandle, label: &str, location: Location) {
if let Some(existing) = app.get_webview_window(label) {
let _ = existing.unminimize();
let _ = existing.set_focus();
let _ = app.emit_to(label, GOTO_EVENT, location);
}
} }
// Nine parameters are fixed by the IPC contract (P10); four injected by Tauri. // Nine parameters are fixed by the IPC contract (P10); four injected by Tauri.
@@ -139,7 +155,7 @@ pub async fn open_file_viewer(
) -> Result<(), String> { ) -> Result<(), String> {
require_main(window.label())?; require_main(window.label())?;
let project = project_of(&state, &project_id)?; let project = project_of(&state, &project_id)?;
let container_id = running_container_of(&project).await?; let container_id = running_container_of(&project, "opening files").await?;
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect(); let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
let candidates = candidate_paths(&path, &mounts)?; let candidates = candidate_paths(&path, &mounts)?;
@@ -148,35 +164,35 @@ pub async fn open_file_viewer(
let target_state = match matches.len() { let target_state = match matches.len() {
0 => ViewerTargetState::NotFound { tried: candidates }, 0 => ViewerTargetState::NotFound { tried: candidates },
1 => { 1 => ViewerTargetState::Resolved { container_path: matches[0].clone() },
let container_path = matches[0].clone();
if let Some(label) = registry.find_open(&project_id, &container_path) {
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.unminimize();
let _ = existing.set_focus();
let _ = app.emit_to(label.as_str(), GOTO_EVENT, initial);
return Ok(());
}
// Registered but windowless: a stale entry. Drop it and open afresh.
registry.remove(&label);
}
ViewerTargetState::Resolved { container_path }
}
_ => ViewerTargetState::Choose { candidates: matches }, _ => ViewerTargetState::Choose { candidates: matches },
}; };
let title = window_title(&path, &project.name); let title = window_title(&path, &project.name);
let label = registry.reserve(ViewerTarget { let target = ViewerTarget {
project_id, project_id,
project_name: project.name.clone(), project_name: project.name.clone(),
raw_path: path, raw_path: path,
state: target_state, state: target_state,
initial, initial: initial.clone(),
})?; };
// Dedupe, stale pruning and the cap are one registry call, so a second click
// while the first window is still being built finds it rather than reading
// its not-yet-existing window as stale.
let label = match registry.reserve(target, |l| app.get_webview_window(l).is_some())? {
Reservation::Reserved(label) => label,
// Still being built: it opens at its own location in a moment.
Reservation::Existing { built: false, .. } => return Ok(()),
Reservation::Existing { label, built: true } => {
focus_viewer(&app, &label, initial);
return Ok(());
}
};
if let Err(e) = open_viewer_window(&app, &label, &title) { if let Err(e) = open_viewer_window(&app, &label, &title) {
registry.remove(&label); registry.remove(&label);
return Err(e); return Err(e);
} }
registry.mark_built(&label);
Ok(()) Ok(())
} }
@@ -203,9 +219,19 @@ pub async fn viewer_choose_file(
.ok_or_else(|| "That choice is no longer available.".to_string())?, .ok_or_else(|| "That choice is no longer available.".to_string())?,
_ => return Err("This window is not choosing a file.".into()), _ => return Err("This window is not choosing a file.".into()),
}; };
let updated = let app = window.app_handle();
registry.set_state(&label, ViewerTargetState::Resolved { container_path: chosen })?; match registry.choose(&label, chosen, |l| app.get_webview_window(l).is_some())? {
Ok(viewer_state_of(&label, updated)) Choice::Resolved(updated) => Ok(viewer_state_of(&label, updated)),
// Another window already has this file. This window was only ever a
// chooser, so hand over to that one and close this one, as a second
// click on the same path would have. The error is what this window
// shows if the destroy fails.
Choice::AlreadyOpen { label: other, .. } => {
focus_viewer(app, &other, target.initial);
let _ = window.destroy();
Err("This file is already open in another window.".into())
}
}
} }
#[tauri::command] #[tauri::command]
@@ -217,7 +243,7 @@ pub async fn viewer_read_file(
) -> Result<ViewerFile, String> { ) -> Result<ViewerFile, String> {
let (_label, target) = own_target(&window, &registry)?; let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?; let path = resolved_path(&target)?;
let container_id = running_container_for(&state, &target).await?; let container_id = running_container_for(&state, &target, "opening files").await?;
let cap = max_bytes.clamp(1, MAX_READ_BYTES); let cap = max_bytes.clamp(1, MAX_READ_BYTES);
let fetched = fetch_container_file(&container_id, &path, cap).await?; let fetched = fetch_container_file(&container_id, &path, cap).await?;
let (editable, readonly_reason) = match validate_container_write_path("File", &path) { let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
@@ -242,7 +268,7 @@ pub async fn viewer_poll_file(
) -> Result<ViewerPoll, String> { ) -> Result<ViewerPoll, String> {
let (_label, target) = own_target(&window, &registry)?; let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?; let path = resolved_path(&target)?;
let container_id = running_container_for(&state, &target).await?; let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
poll_file(&container_id, &path).await poll_file(&container_id, &path).await
} }
@@ -264,7 +290,7 @@ pub async fn viewer_write_file(
let bytes = BASE64 let bytes = BASE64
.decode(contents_base64.as_bytes()) .decode(contents_base64.as_bytes())
.map_err(|_| "The editor sent malformed content.".to_string())?; .map_err(|_| "The editor sent malformed content.".to_string())?;
let container_id = running_container_for(&state, &target).await?; let container_id = running_container_for(&state, &target, "saving this file").await?;
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
} }
+238 -21
View File
@@ -36,19 +36,87 @@ pub struct ViewerTarget {
pub initial: Location, pub initial: Location,
} }
/// What [`ViewerRegistry::reserve`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Reservation {
/// A window is already registered on this file. `built` is false while that
/// window is still being created: it has no `WebviewWindow` to focus yet, and
/// it will open at its own location, so the caller should simply return.
Existing { label: String, built: bool },
/// A new label, registered and counted against the cap; build its window,
/// then call [`ViewerRegistry::mark_built`] (or `remove` if building failed).
Reserved(String),
}
/// What [`ViewerRegistry::choose`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Choice {
/// The caller's entry now points at the chosen file.
Resolved(ViewerTarget),
/// Another window already has that file; the caller's entry is unchanged.
AlreadyOpen { label: String, built: bool },
}
#[derive(Clone, Debug)]
struct Entry {
target: ViewerTarget,
/// Set once the window's `build()` has returned. Until then the label has no
/// window by design, so "registered but windowless" means "being built", not
/// "stale" — only built entries are ever pruned.
built: bool,
}
#[derive(Default)] #[derive(Default)]
pub struct ViewerRegistry { pub struct ViewerRegistry {
entries: Mutex<HashMap<String, ViewerTarget>>, entries: Mutex<HashMap<String, Entry>>,
next: AtomicU64, next: AtomicU64,
} }
fn same_file(t: &ViewerTarget, project_id: &str, container_path: &str) -> bool {
t.project_id == project_id
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
}
fn open_on(
entries: &HashMap<String, Entry>,
project_id: &str,
container_path: &str,
except: Option<&str>,
) -> Option<(String, bool)> {
entries
.iter()
.find(|(label, e)| Some(label.as_str()) != except && same_file(&e.target, project_id, container_path))
.map(|(label, e)| (label.clone(), e.built))
}
/// Drops built entries whose window is gone, whatever their state. `Destroyed`
/// normally removes an entry; this is the backstop for one it missed, so a leak
/// can never hold a cap slot for good.
fn prune(entries: &mut HashMap<String, Entry>, is_live: &dyn Fn(&str) -> bool) {
entries.retain(|label, e| !e.built || is_live(label));
}
impl ViewerRegistry { impl ViewerRegistry {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, ViewerTarget>> { fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner()) self.entries.lock().unwrap_or_else(|e| e.into_inner())
} }
pub fn reserve(&self, target: ViewerTarget) -> Result<String, String> { /// Finds the window already open on a resolved target, or reserves a label,
/// in one critical section, after pruning built entries `is_live` says are
/// gone. `is_live` runs under the registry lock and must not call back into
/// the registry.
pub fn reserve(
&self,
target: ViewerTarget,
is_live: impl Fn(&str) -> bool,
) -> Result<Reservation, String> {
let mut entries = self.lock(); let mut entries = self.lock();
prune(&mut entries, &is_live);
if let ViewerTargetState::Resolved { container_path } = &target.state {
if let Some((label, built)) = open_on(&entries, &target.project_id, container_path, None) {
return Ok(Reservation::Existing { label, built });
}
}
if entries.len() >= MAX_VIEWER_WINDOWS { if entries.len() >= MAX_VIEWER_WINDOWS {
return Err(format!( return Err(format!(
"{} file windows are already open — close one before opening another.", "{} file windows are already open — close one before opening another.",
@@ -57,12 +125,45 @@ impl ViewerRegistry {
} }
let n = self.next.fetch_add(1, Ordering::SeqCst) + 1; let n = self.next.fetch_add(1, Ordering::SeqCst) + 1;
let label = format!("{}{}", VIEWER_LABEL_PREFIX, n); let label = format!("{}{}", VIEWER_LABEL_PREFIX, n);
entries.insert(label.clone(), target); entries.insert(label.clone(), Entry { target, built: false });
Ok(label) Ok(Reservation::Reserved(label))
}
/// Records that `label`'s window exists. A no-op if it was already removed
/// (a window destroyed the moment it appeared).
pub fn mark_built(&self, label: &str) {
if let Some(e) = self.lock().get_mut(label) {
e.built = true;
}
}
/// Points `label`'s entry at `container_path`, unless another window already
/// has that file open — then the entry is left alone, so no two entries are
/// ever resolved to the same file.
pub fn choose(
&self,
label: &str,
container_path: String,
is_live: impl Fn(&str) -> bool,
) -> Result<Choice, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
let project_id = entries
.get(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?
.target
.project_id
.clone();
if let Some((other, built)) = open_on(&entries, &project_id, &container_path, Some(label)) {
return Ok(Choice::AlreadyOpen { label: other, built });
}
let entry = entries.get_mut(label).expect("checked above under the same lock");
entry.target.state = ViewerTargetState::Resolved { container_path };
Ok(Choice::Resolved(entry.target.clone()))
} }
pub fn get(&self, label: &str) -> Option<ViewerTarget> { pub fn get(&self, label: &str) -> Option<ViewerTarget> {
self.lock().get(label).cloned() self.lock().get(label).map(|e| e.target.clone())
} }
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> { pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
@@ -70,8 +171,8 @@ impl ViewerRegistry {
let entry = entries let entry = entries
.get_mut(label) .get_mut(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?; .ok_or_else(|| "This file window is no longer registered.".to_string())?;
entry.state = state; entry.target.state = state;
Ok(entry.clone()) Ok(entry.target.clone())
} }
pub fn remove(&self, label: &str) { pub fn remove(&self, label: &str) {
@@ -79,13 +180,7 @@ impl ViewerRegistry {
} }
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> { pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> {
self.lock() open_on(&self.lock(), project_id, container_path, None).map(|(label, _)| label)
.iter()
.find(|(_, t)| {
t.project_id == project_id
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
})
.map(|(label, _)| label.clone())
} }
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
@@ -111,15 +206,34 @@ mod tests {
} }
} }
fn all_live(_: &str) -> bool {
true
}
/// Reserves a label that must be new.
fn fresh(r: &ViewerRegistry, t: ViewerTarget) -> String {
match r.reserve(t, all_live).unwrap() {
Reservation::Reserved(label) => label,
other => panic!("expected a new label, got {:?}", other),
}
}
fn choosing(project: &str, candidates: &[&str]) -> ViewerTarget {
ViewerTarget {
state: ViewerTargetState::Choose { candidates: candidates.iter().map(|c| c.to_string()).collect() },
..target(project, "a")
}
}
#[test] #[test]
fn labels_are_sequential_and_never_reused() { fn labels_are_sequential_and_never_reused() {
let r = ViewerRegistry::default(); let r = ViewerRegistry::default();
let a = r.reserve(target("p", "/workspace/a")).unwrap(); let a = fresh(&r, target("p", "/workspace/a"));
let b = r.reserve(target("p", "/workspace/b")).unwrap(); let b = fresh(&r, target("p", "/workspace/b"));
assert_eq!(a, "file-viewer-1"); assert_eq!(a, "file-viewer-1");
assert_eq!(b, "file-viewer-2"); assert_eq!(b, "file-viewer-2");
r.remove(&a); r.remove(&a);
let c = r.reserve(target("p", "/workspace/c")).unwrap(); let c = fresh(&r, target("p", "/workspace/c"));
assert_eq!(c, "file-viewer-3"); assert_eq!(c, "file-viewer-3");
assert_eq!(r.len(), 2); assert_eq!(r.len(), 2);
} }
@@ -128,9 +242,9 @@ mod tests {
fn the_cap_refuses_the_twenty_first_window() { fn the_cap_refuses_the_twenty_first_window() {
let r = ViewerRegistry::default(); let r = ViewerRegistry::default();
for i in 0..MAX_VIEWER_WINDOWS { for i in 0..MAX_VIEWER_WINDOWS {
r.reserve(target("p", &format!("/workspace/{}", i))).unwrap(); fresh(&r, target("p", &format!("/workspace/{}", i)));
} }
let err = r.reserve(target("p", "/workspace/one-more")).unwrap_err(); let err = r.reserve(target("p", "/workspace/one-more"), all_live).unwrap_err();
assert!(err.contains("20"), "{}", err); assert!(err.contains("20"), "{}", err);
assert_eq!(r.len(), MAX_VIEWER_WINDOWS); assert_eq!(r.len(), MAX_VIEWER_WINDOWS);
} }
@@ -138,7 +252,7 @@ mod tests {
#[test] #[test]
fn an_open_resolved_file_is_found_by_project_and_path() { fn an_open_resolved_file_is_found_by_project_and_path() {
let r = ViewerRegistry::default(); let r = ViewerRegistry::default();
let label = r.reserve(target("p", "/workspace/a")).unwrap(); let label = fresh(&r, target("p", "/workspace/a"));
assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone())); assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone()));
assert_eq!(r.find_open("other", "/workspace/a"), None); assert_eq!(r.find_open("other", "/workspace/a"), None);
// A window still choosing is not "open on" any path. // A window still choosing is not "open on" any path.
@@ -159,4 +273,107 @@ mod tests {
let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap(); let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap();
assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#); assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#);
} }
/// I1: a second click while the first window is still being built must find
/// that window, not read it as stale and reserve a second one.
#[test]
fn a_window_being_built_is_found_not_replaced() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
// No window exists yet for `a`: `is_live` says so, and it must not matter.
let second = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(second, Reservation::Existing { label: a.clone(), built: false });
assert!(r.get(&a).is_some());
assert_eq!(r.len(), 1);
r.mark_built(&a);
let third = r.reserve(target("p", "/workspace/a"), all_live).unwrap();
assert_eq!(third, Reservation::Existing { label: a, built: true });
assert_eq!(r.len(), 1);
}
/// A built entry whose window is gone is stale: pruned, and the file reopens.
#[test]
fn a_built_entry_without_a_window_is_pruned_and_the_file_reopens() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.mark_built(&a);
let again = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(again, Reservation::Reserved("file-viewer-2".into()));
assert_eq!(r.get(&a), None);
assert_eq!(r.len(), 1);
}
/// M2: a leaked entry of any state cannot hold a cap slot once built and gone,
/// and an entry still being built always keeps its slot.
#[test]
fn leaked_entries_of_every_state_free_their_cap_slot() {
let r = ViewerRegistry::default();
let mut labels = Vec::new();
for i in 0..MAX_VIEWER_WINDOWS {
let t = match i % 3 {
0 => target("p", &format!("/workspace/{}", i)),
1 => choosing("p", &["/workspace/x", "/workspace/y"]),
_ => ViewerTarget { state: ViewerTargetState::NotFound { tried: vec![] }, ..target("p", "z") },
};
labels.push(fresh(&r, t));
}
// All still being built: none may be pruned, so the cap holds.
assert!(r.reserve(target("p", "/workspace/new"), |_| false).is_err());
for l in &labels {
r.mark_built(l);
}
// Built, and one of each state has lost its window.
let dead = [labels[0].clone(), labels[1].clone(), labels[2].clone()];
let live = |l: &str| !dead.iter().any(|d| d == l);
assert!(matches!(r.reserve(target("p", "/workspace/new"), live), Ok(Reservation::Reserved(_))));
assert_eq!(r.len(), MAX_VIEWER_WINDOWS - 2);
for d in &dead {
assert_eq!(r.get(d), None);
}
}
#[test]
fn mark_built_on_a_removed_label_is_a_no_op() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.remove(&a);
r.mark_built(&a);
assert_eq!(r.get(&a), None);
}
/// M5: choosing a file another window already has leaves the chooser alone,
/// so two entries are never resolved to the same file.
#[test]
fn choosing_a_file_open_elsewhere_does_not_resolve_a_second_entry() {
let r = ViewerRegistry::default();
let open = fresh(&r, target("p", "/workspace/x"));
r.mark_built(&open);
let chooser = fresh(&r, choosing("p", &["/workspace/x", "/workspace/y"]));
r.mark_built(&chooser);
let c = r.choose(&chooser, "/workspace/x".into(), all_live).unwrap();
assert_eq!(c, Choice::AlreadyOpen { label: open.clone(), built: true });
assert!(matches!(r.get(&chooser).unwrap().state, ViewerTargetState::Choose { .. }));
match r.choose(&chooser, "/workspace/y".into(), all_live).unwrap() {
Choice::Resolved(t) => assert_eq!(t.state, ViewerTargetState::Resolved { container_path: "/workspace/y".into() }),
other => panic!("expected Resolved, got {:?}", other),
}
assert_eq!(r.find_open("p", "/workspace/y"), Some(chooser));
}
#[test]
fn choosing_the_same_path_in_another_project_is_not_a_duplicate() {
let r = ViewerRegistry::default();
fresh(&r, target("other", "/workspace/x"));
let chooser = fresh(&r, choosing("p", &["/workspace/x"]));
assert!(matches!(r.choose(&chooser, "/workspace/x".into(), all_live), Ok(Choice::Resolved(_))));
}
#[test]
fn choose_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.choose("file-viewer-9", "/workspace/x".into(), all_live).is_err());
}
} }