From b04c4b37f4bbf12b33765d089775d13f077e7adc Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 08:01:44 +0300 Subject: [PATCH] fix(desktop): detach hosted destination for invalid local source --- crates/oa-desktop/src/app.rs | 4 + crates/oa-desktop/src/app/hosted/tests.rs | 115 ++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 25ce58d3..2564a670 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -3064,6 +3064,10 @@ impl App { } } Err(error) => { + // Installing a local repair workspace changes its destination, + // even while the last valid graphical model stays visible. A + // failed/cancelled Open that installs nothing retains its binding. + self.hosted.leave_document(); self.canvas_document = Id::new_v4(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.path = Some(path); diff --git a/crates/oa-desktop/src/app/hosted/tests.rs b/crates/oa-desktop/src/app/hosted/tests.rs index 545dab3f..22a8633f 100644 --- a/crates/oa-desktop/src/app/hosted/tests.rs +++ b/crates/oa-desktop/src/app/hosted/tests.rs @@ -443,3 +443,118 @@ async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reloa .unwrap() .unwrap(); } + +#[tokio::test] +async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() { + let directory = tempfile::tempdir().unwrap(); + let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (stop, done) = tokio::sync::oneshot::channel::<()>(); + let server = tokio::spawn(async move { + axum::serve(listener, oa_server::app(store, None)) + .with_graceful_shutdown(async { + let _ = done.await; + }) + .await + .unwrap(); + }); + let mut app = App::with_project(Project::empty()); + app.hosted.base = base.clone(); + dispatch(&mut app, Message::Hosted(Event::Publish)).await; + let session = app.hosted.session.clone().unwrap(); + let accepted = app.document.snapshot(); + let original_identity = app.canvas_document; + let external = Connection::new(&base, Secret::default()).unwrap(); + let old = external.open(session.resource_id).await.unwrap(); + + let local = tempfile::tempdir().unwrap(); + let path = local.path().join("repair.arcadia"); + let invalid = "arcadia 5; ? // unfinished local work"; + std::fs::write(&path, invalid).unwrap(); + let path = path.canonicalize().unwrap(); + dispatch( + &mut app, + Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), + ) + .await; + assert_ne!(app.canvas_document, original_identity); + assert_eq!(app.path.as_ref(), Some(&path)); + assert!(!app.source_workspace.applied); + assert_eq!(app.source_editor.text(), invalid); + assert!( + Arc::ptr_eq(&accepted, &app.document.snapshot()), + "A repair workspace must preserve the last valid graphical model" + ); + assert!(!app.source_diagnostics.is_empty()); + assert!( + app.hosted.session.is_none(), + "The installed local repair workspace must own its save destination" + ); + + let mut repaired = Project::empty(); + repaired.name = "Independent local repair".into(); + let repaired_source = language::format(&repaired); + dispatch( + &mut app, + Message::SourceEdit(text_editor::Action::SelectAll), + ) + .await; + dispatch( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( + Arc::new(repaired_source), + ))), + ) + .await; + dispatch(&mut app, Message::ApplySource).await; + assert_eq!(app.project(), &repaired); + assert!(!app.source_dirty()); + dispatch(&mut app, Message::Save(false)).await; + + let remote_after = external.open(session.resource_id).await.unwrap(); + assert_eq!( + remote_after.etag, old.etag, + "Saving a repaired local workspace must never target the old hosted resource" + ); + assert_eq!(remote_after.compiled.bundle, old.compiled.bundle); + assert!(app.hosted.session.is_none()); + assert!(app.hosted.review.is_none()); + assert!(app.hosted.uncertain.is_none()); + assert!(app.hosted.request.is_none()); + assert_eq!(app.path.as_ref(), Some(&path)); + assert!(!app.dirty()); + let saved = source_files::load_project(&path, LIMITS).unwrap(); + assert_eq!(saved.reachable_bundle(), *app.document.bundle()); + assert_eq!( + app.hosted.base, base, + "Connection settings remain available in memory" + ); + drop(external); + drop(app); + let _ = stop.send(()); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); +} + +#[test] +fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() { + let mut app = App::with_project(Project::empty()); + let resource = bind(&mut app); + let accepted = app.document.snapshot(); + let identity = app.canvas_document; + let source = app.source_workspace.bundle(); + let directory = tempfile::tempdir().unwrap(); + let unreadable = directory.path().join("missing.json"); + let error = read_snapshot(unreadable).unwrap_err(); + for result in [Ok(None), Err(error)] { + let _ = app.update(Message::Loaded(result)); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.canvas_document, identity); + assert_eq!(app.source_workspace.bundle(), source); + assert!(app.path.is_none()); + assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource); + } +}