From 8244fb06d64d4e82726a8e78e78a05cdea1dd483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Thu, 5 Nov 2026 23:04:36 -0500 Subject: [PATCH 01/15] test(ui): downloading tests take turns for the tile cache's fetcher A test that replaces the process's one fetcher (parity_raster_tiles) answered other tests' downloads with a 404, so their local servers never got a request: one failed and two hung. The tests that download through the cache now take the turn the rule in lib.rs asks for, and their servers wait 10 s at most for a request (first_request), so a lost one fails instead of hanging. --- crates/ui-egui/src/connectiontree.rs | 2 ++ crates/ui-egui/src/lib.rs | 40 +++++++++++++++++----------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index c2b478864..a136cac78 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -820,6 +820,7 @@ mod tests { #[test] fn a_service_dialog_download_keeps_the_browsed_connection_not_the_current_selection() { + let _turn = crate::fetching::turn(); let base = serve(|_| (200, br#"{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[1,2]}}"#.to_vec())); let mut h = harness(); let app = h.state_mut(); @@ -837,6 +838,7 @@ mod tests { #[test] fn url_downloads_use_the_explicit_basic_or_header_connection_and_record_it() { + let _turn = crate::fetching::turn(); for basic in [false, true] { let base = serve(move |head| { let head = head.to_ascii_lowercase(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a34fba37c..de5e5b331 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4953,6 +4953,25 @@ mod record_tests { use super::{DEFAULTS_TO_ACTIVE_LAYER, RustGisApp}; use serde_json::json; + /// The first request `server` gets, waited for up to 10 s: a request that never comes fails the + /// test instead of hanging it. + fn first_request(server: &std::net::TcpListener) -> std::net::TcpStream { + server.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + match server.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + return stream; + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => panic!("no request reached the test server: {e}"), + } + } + } + /// Terrain run with nothing named (from command search) records the DEM it took, so the /// step replays on it; a change of exaggeration on a terrain already on records none. #[test] @@ -5145,10 +5164,11 @@ mod record_tests { fn an_anonymous_download_never_acquires_a_saved_login() { use rustgis_engine::render::rustgis_tiles::{cache, logins}; use std::io::{Read, Write}; + let _turn = crate::fetching::turn(); let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let origin = format!("http://{}", server.local_addr().unwrap()); let reply = std::thread::spawn(move || { - let (mut stream, _) = server.accept().unwrap(); + let mut stream = first_request(&server); stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut request = Vec::new(); let mut chunk = [0; 1024]; @@ -5195,24 +5215,13 @@ mod record_tests { fn a_landed_download_says_how_it_went_in_the_dialog() { use rustgis_engine::render::rustgis_tiles::cache; use std::io::{Read, Write}; + let _turn = crate::fetching::turn(); // A server answering one request with `status` and a point's GeoJSON. let serve = |status: &'static str| { let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let origin = format!("http://{}", server.local_addr().unwrap()); let reply = std::thread::spawn(move || { - // A request that never comes fails the test instead of hanging it. - server.set_nonblocking(true).unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - let mut stream = loop { - match server.accept() { - Ok((stream, _)) => break stream, - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => { - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(e) => panic!("no request reached the test server: {e}"), - } - }; - stream.set_nonblocking(false).unwrap(); + let mut stream = first_request(&server); stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut request = Vec::new(); let mut chunk = [0; 1024]; @@ -5289,11 +5298,12 @@ mod record_tests { fn whitespace_downloads_keep_their_login_choice_and_read_options() { use rustgis_engine::render::rustgis_tiles::{cache, logins}; use std::io::{Read, Write}; + let _turn = crate::fetching::turn(); for anonymous in [false, true] { let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let origin = format!("http://{}", server.local_addr().unwrap()); let reply = std::thread::spawn(move || { - let (mut stream, _) = server.accept().unwrap(); + let mut stream = first_request(&server); stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut request = Vec::new(); let mut chunk = [0; 1024]; From 00d8e58607039c5c94014265614b4bf36e3397a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Thu, 5 Nov 2026 23:04:36 -0500 Subject: [PATCH 02/15] test(ui): tests that log in, or wait for what the cache read, take turns too A login replaced or ended empties the tile cache, which dropped the tiles and archive headers other tests had just waited for: parity_vector_tile_meshes drew no tiles beside connectiontree, and the source layers test lost its archive's header. The tests that log in, and those that wait for what the cache read, take the same turn. --- crates/ui-egui/src/connectiontree.rs | 1 + crates/ui-egui/src/connectionui.rs | 1 + crates/ui-egui/src/lib.rs | 4 +++- crates/ui-egui/src/mapgpu.rs | 1 + crates/ui-egui/src/panes.rs | 1 + crates/ui-egui/src/s3ui.rs | 1 + crates/ui-egui/src/serviceui.rs | 1 + 7 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index a136cac78..25af0ba3d 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -1016,6 +1016,7 @@ mod tests { #[test] fn a_foreign_followup_does_not_adopt_that_origins_saved_login() { + let _turn = crate::fetching::turn(); let mut app = RustGisApp::with_sample(); let foreign = "https://foreign-ui-followup.invalid/points.geojson"; let c = app diff --git a/crates/ui-egui/src/connectionui.rs b/crates/ui-egui/src/connectionui.rs index 3543bd219..a67a9f0b0 100644 --- a/crates/ui-egui/src/connectionui.rs +++ b/crates/ui-egui/src/connectionui.rs @@ -1309,6 +1309,7 @@ mod tests { #[test] fn invalid_service_replacement_preserves_session_login_and_definition() { + let _turn = crate::fetching::turn(); let mut app = RustGisApp::with_sample(); let c = app .run( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index de5e5b331..2ba300a95 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4828,7 +4828,9 @@ pub(crate) fn click_in_one_frame(h: &mut egui_kittest::Harness<'_, RustGisApp>, } /// Tests that fetch through the tile cache take turns: it has one fetcher for the whole process, -/// and a test that replaces it (`set_fetcher`) would answer another's downloads. +/// and a test that replaces it (`set_fetcher`) would answer another's downloads. So do tests that +/// log in to a connection, as a login replaced or ended empties the cache (`cache::clear`), and +/// tests that wait for what it reads (tiles, an archive's header), which that would drop. #[cfg(test)] pub(crate) mod fetching { use std::sync::{Mutex, MutexGuard}; diff --git a/crates/ui-egui/src/mapgpu.rs b/crates/ui-egui/src/mapgpu.rs index b23daaffe..ca94e8c70 100644 --- a/crates/ui-egui/src/mapgpu.rs +++ b/crates/ui-egui/src/mapgpu.rs @@ -2160,6 +2160,7 @@ mod tests { use rustgis_engine::map::{TileKind, TileLayer}; use rustgis_engine::render::rustgis_tiles::mvt::{Feature, GeomKind, Layer, Tile, Value, encode}; use rustgis_engine::render::rustgis_tiles::pmtiles::{NewArchive, TileType, write}; + let _turn = crate::fetching::turn(); // Zoom 1: each tile's park, see-through, reaching past the tile into its buffer (as real // tiles do; drawn twice where tiles overlap unless each is clipped to its square), a road // and a dashed path in it. diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 8173a33ca..33de04e6c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -7191,6 +7191,7 @@ mod tests { fn a_vector_archives_source_layers_open_under_it() { use egui_kittest::kittest::{NodeT, Queryable}; use rustgis_engine::render::rustgis_tiles::{mvt, pmtiles}; + let _turn = crate::fetching::turn(); let new = pmtiles::NewArchive { tile_type: pmtiles::TileType::Mvt, gzipped_tiles: false, diff --git a/crates/ui-egui/src/s3ui.rs b/crates/ui-egui/src/s3ui.rs index aefaab2a9..b8d8e2d27 100644 --- a/crates/ui-egui/src/s3ui.rs +++ b/crates/ui-egui/src/s3ui.rs @@ -780,6 +780,7 @@ mod tests { fn s3_saved_picker_and_connection_entry_reauthenticate_a_foreign_session_login() { use rustgis_engine::data::secrets::MemorySecrets; use std::sync::Arc; + let _turn = crate::fetching::turn(); let app = || { RustGisApp::with_sample().with_services(crate::Services { secrets: Some(Arc::new(MemorySecrets::new())), ..Default::default() }) }; diff --git a/crates/ui-egui/src/serviceui.rs b/crates/ui-egui/src/serviceui.rs index 947c32e6e..87751d7e5 100644 --- a/crates/ui-egui/src/serviceui.rs +++ b/crates/ui-egui/src/serviceui.rs @@ -547,6 +547,7 @@ mod tests { /// address comes bound to the connection its layers record). #[test] fn a_refresh_reads_through_the_connection_its_layers_record() { + let _turn = crate::fetching::turn(); let mut app = RustGisApp::with_sample(); let url = "https://127.0.0.1:9/collections"; let new = |app: &mut RustGisApp, name: &str, key: &str| { From f562410a650e3814f7d5dfeb566d81b1e6e4b396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Thu, 5 Nov 2026 23:04:36 -0500 Subject: [PATCH 03/15] fix(engine): a COG whose header was just read is framed even if the cache was emptied since add_cog asked for the header without waiting, so one dropped since the probe (the tile cache emptied by a login ending) left a first COG unframed, as adding_a_layer_keeps_the_view saw on CI. It now waits for it natively when the caller has just read it (layer.addUrl, a dated address, embeddings); a project's GeoLibre import still doesn't wait for each file. --- crates/engine/src/cmd/embeddings.rs | 3 ++- crates/engine/src/cmd/geolibre.rs | 2 +- crates/engine/src/cmd/layer.rs | 35 ++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/embeddings.rs b/crates/engine/src/cmd/embeddings.rs index 6aba842b8..66bc05b20 100644 --- a/crates/engine/src/cmd/embeddings.rs +++ b/crates/engine/src/cmd/embeddings.rs @@ -153,7 +153,8 @@ fn add(s: &mut Session, p: &Value) -> CmdResult { if !crate::render::rustgis_tiles::cog::probe_blocking(file)? { return Err(format!("{file}: not a cloud-optimised GeoTIFF")); } - super::layer::add_cog(s, &json!({"name": name}), &crate::render::rustgis_tiles::cog::with_look(file, &look)) + let look = crate::render::rustgis_tiles::cog::with_look(file, &look); + super::layer::add_cog(s, &json!({"name": name}), &look, cfg!(not(target_arch = "wasm32"))) } "earth-index" => add_points(s, p, name), _ => add_tessera(s, p, d, name), diff --git a/crates/engine/src/cmd/geolibre.rs b/crates/engine/src/cmd/geolibre.rs index 15dc218c0..542f8ea27 100644 --- a/crates/engine/src/cmd/geolibre.rs +++ b/crates/engine/src/cmd/geolibre.rs @@ -369,7 +369,7 @@ fn add_layer(s: &mut Session, r: &Value, dir: Option<&str>, report: &mut Report, return Ok(Added::Feature(id)); } if kind == "cog" && remote { - super::layer::add_cog(s, &q, &address)? + super::layer::add_cog(s, &q, &address, false)? } else if kind == "zarr" { if let Some(v) = src.get("variable") { q["variable"] = v.clone(); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 993c8d026..0cea071f8 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -3148,7 +3148,7 @@ fn add_url_as_given(s: &mut Session, p: &Value) -> CmdResult { } let web = lower.starts_with("https://") || lower.starts_with("http://"); return if is_tiff_url(url) && web { - add_cog(s, p, url) + add_cog(s, p, url, false) } else { Err(format!( "{url}: dated addresses work for cloud-optimised GeoTIFFs on the web (http, https) here, and for tile layers (map.addTiles)" @@ -3189,7 +3189,7 @@ fn add_url_as_given(s: &mut Session, p: &Value) -> CmdResult { { // Nothing may wait here: a GeoTIFF is taken to be cloud-optimised (the layer says if not). if tiff && !scan && !super::b(p, "whole").unwrap_or(false) { - return add_cog(s, p, url); + return add_cog(s, p, url, false); } // Downloaded by the app off the frame and added when it lands. s.request_url(p) @@ -3213,7 +3213,7 @@ fn add_url_as_given(s: &mut Session, p: &Value) -> CmdResult { Err(e) => return Err(named(e)), }; if cog { - return add_cog(s, p, url); + return add_cog(s, p, url, true); } } let bytes = if rustgis_data::io::shapefile_part_urls(url).is_some() { @@ -3533,8 +3533,10 @@ pub fn is_unnamed_url(url: &str) -> bool { } /// Adds a cloud-optimised GeoTIFF by its address as a layer read in place (its tiles by range, -/// as the view needs them), framed when its header is in. -pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str) -> CmdResult { +/// as the view needs them), framed when its header is in. `probed`: its header was just read +/// (natively), so it is waited for if something emptied the tile cache since (a login ended): the +/// layer would go unframed. One not read yet isn't waited for (a project opening its files). +pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> CmdResult { let stem = cog_name(url); let name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(&stem); let empty_before = super::map_is_empty(s); @@ -3547,16 +3549,29 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str) -> CmdResult { } let mut r = super::tiles::add(s, &q)?; // A dated address is framed on the file of the date it shows. - let url = match r.get("url").and_then(Value::as_str) { + let (url, probed) = match r.get("url").and_then(Value::as_str) { Some(dated) => { // Its header read now (natively) as an undated address's is before it is added. #[cfg(not(target_arch = "wasm32"))] - let _ = crate::render::rustgis_tiles::cog::probe_blocking(dated); - dated.to_string() + let read = crate::render::rustgis_tiles::cog::probe_blocking(dated) == Ok(true); + #[cfg(target_arch = "wasm32")] + let read = false; + (dated.to_string(), read) } - None => url.to_string(), + None => (url.to_string(), probed), }; - if let Ok(Some(info)) = crate::render::rustgis_tiles::cog::info(&url) { + #[cfg(not(target_arch = "wasm32"))] + let info = if probed { + crate::render::rustgis_tiles::cog::info_blocking(&url).map(Some) + } else { + crate::render::rustgis_tiles::cog::info(&url) + }; + #[cfg(target_arch = "wasm32")] + let info = { + let _ = probed; + crate::render::rustgis_tiles::cog::info(&url) + }; + if let Ok(Some(info)) = info { let [w, so, e, n] = info.bounds; // A cloud-optimised GeoTIFF read in place is framed when it is the first content on the // map, as other data is; `"zoom"` says otherwise (`true`, or `false` to keep the view). From dde997259bac52b9ddbf9420b2cd1fc766c592a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Thu, 5 Nov 2026 23:04:36 -0500 Subject: [PATCH 04/15] test(ui): the plugin tab's harness takes the passes a new row needs A widget shown for the first time asks for one more pass to take its style, so the rows that change after Retry settle in six passes, past the harness's default four. --- crates/ui-egui/src/pluginsui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/pluginsui.rs b/crates/ui-egui/src/pluginsui.rs index 7ac349f56..469f2c10d 100644 --- a/crates/ui-egui/src/pluginsui.rs +++ b/crates/ui-egui/src/pluginsui.rs @@ -1314,7 +1314,8 @@ mod tests { let mut app = app_fetching(|_| Err("still unreachable".into())); app.plugins.unloaded.push(Unloaded { saved: record("demo", "0"), reason: "unreachable".into() }); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(560.0, 600.0)).build_ui_state( + // A widget shown for the first time asks for one more pass to take its style. + let mut h = egui_kittest::Harness::builder().with_size(vec2(560.0, 600.0)).with_max_steps(8).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); From 9b5a638817b6656550f73e40aa7f660f016dad1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Thu, 5 Nov 2026 23:04:36 -0500 Subject: [PATCH 05/15] test(engine): cover a lock's neighbour anchors and a lock left empty What the review of #1484 noted: a deleted feature keeps its neighbour's lock entry and line anchor, and a lock whose last layer goes stays on, empty. The pruning's comment says layer ids are compared too. --- crates/engine/src/lib.rs | 3 ++- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index afd26537d..ee459b272 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -856,7 +856,8 @@ fn fresh_source_ids(before: &Project, after: &mut Project) { /// layer holds only the window in view, so its other features aren't gone. It compares ids, so an /// id a single command both removes and hands out stays locked: only restoring from an archive can. /// A command that removes a layer removes its locks and line anchors too, or each frame would read -/// them for nothing. +/// them for nothing. It compares layer ids too: a layer id one command frees and hands out again +/// keeps the old layer's entries, which the map does only once its ids run out (`u32::MAX`). fn forget_label_state_of_removed_features(before: &Project, after: &mut Project) { for (i, m) in after.maps.iter_mut().enumerate() { let was = before.maps.get(i).filter(|o| o.name == m.name); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e957a9d12..5c98b33b9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4160,6 +4160,9 @@ fn a_removed_layers_locked_labels_go_with_it() { assert_eq!(locked_layers(&s), (vec![a, b], vec![a, b])); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(locked_layers(&s), (vec![b], vec![b])); + // The last locked layer gone, the lock holds, with nothing in it. + s.run("layer.remove", &json!({"layer": b})).unwrap(); + assert!(s.map().label_lock.as_ref().is_some_and(Vec::is_empty)); } /// A deleted feature's lock entry and line anchor go with it, not those of another layer's feature @@ -4176,6 +4179,36 @@ fn a_deleted_feature_leaves_another_layers_lock_alone() { assert_eq!(locked_layers(&s), (vec![b], vec![b])); } +/// Deleting one of a layer's locked features keeps the lock entry and line anchor of the other. +#[test] +fn a_deleted_feature_leaves_its_neighbours_lock_alone() { + use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; + let mut s = Session::new(Project::default()); + let mut d = Dataset::new("A", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + for name in ["one", "two"] { + d.push(Some(Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![V::Text(name.into())]); + } + let a = s.map_mut().add_dataset(d); + let keys = [(a, 1, 0, 0), (a, 2, 0, 0)]; + s.map_mut().label_lock = Some(keys.map(|k| (k, Default::default())).to_vec()); + s.map_mut().label_lock_line_anchors = keys.map(|k| (k, Default::default())).into_iter().collect(); + // The feature ids the lock and the anchors name, each sorted. + let ids = |s: &Session| { + let mut lock: Vec = s.map().label_lock.iter().flatten().map(|((_, f, ..), _)| *f).collect(); + let mut anchors: Vec = s.map().label_lock_line_anchors.keys().map(|(_, f, ..)| *f).collect(); + lock.sort_unstable(); + anchors.sort_unstable(); + (lock, anchors) + }; + s.map_mut().layer_mut(a).unwrap().selection = vec![0]; + s.run("edit.delete", &Value::Null).unwrap(); + assert_eq!(ids(&s), (vec![2], vec![2])); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(ids(&s), (vec![1, 2], vec![1, 2])); + s.run("edit.redo", &Value::Null).unwrap(); + assert_eq!(ids(&s), (vec![2], vec![2])); +} + /// A streamed layer holds only the window in view: a lock's entry for a feature outside it stays /// when the window changes (#1135). #[test] From 8cdfacda8c5e904b76cde7255091b94bca50118f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 06:57:46 -0500 Subject: [PATCH 06/15] fix(engine): a COG whose header couldn't be read again says the map wasn't moved to it --- crates/engine/src/cmd/layer.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 0cea071f8..6477307b0 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -3567,10 +3567,7 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm crate::render::rustgis_tiles::cog::info(&url) }; #[cfg(target_arch = "wasm32")] - let info = { - let _ = probed; - crate::render::rustgis_tiles::cog::info(&url) - }; + let info = crate::render::rustgis_tiles::cog::info(&url); if let Ok(Some(info)) = info { let [w, so, e, n] = info.bounds; // A cloud-optimised GeoTIFF read in place is framed when it is the first content on the @@ -3599,8 +3596,8 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm for line in said { s.notify(format!("{shown}: {line}"), false); } - } else if r.get("dated").is_some() { - // Added, but its first date's file couldn't be read: the view stays where it was. + } else if probed || r.get("dated").is_some() { + // Added, but its (first date's) file couldn't be read: the view stays where it was. r["warning"] = json!(format!("{url} couldn't be read yet, so the map wasn't moved to it")); } r["inPlace"] = json!(true); From fea7e5f9011359b8b400ac2115c5aa03611c8d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 06:57:46 -0500 Subject: [PATCH 07/15] fix(engine): a plugin's dated COG isn't waited for on the frame --- crates/engine/src/cmd/layer.rs | 5 +++-- crates/engine/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 6477307b0..5bc20f52c 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -3551,9 +3551,10 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm // A dated address is framed on the file of the date it shows. let (url, probed) = match r.get("url").and_then(Value::as_str) { Some(dated) => { - // Its header read now (natively) as an undated address's is before it is added. + // Its header read now (natively) as an undated address's is before it is added. A + // plugin's command in the app doesn't wait (it runs on the frame), as in the browser. #[cfg(not(target_arch = "wasm32"))] - let read = crate::render::rustgis_tiles::cog::probe_blocking(dated) == Ok(true); + let read = !s.leave_downloads && crate::render::rustgis_tiles::cog::probe_blocking(dated) == Ok(true); #[cfg(target_arch = "wasm32")] let read = false; (dated.to_string(), read) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5c98b33b9..4ff9449d2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15646,6 +15646,25 @@ fn a_trend_over_a_stac_collection() { assert_eq!(s.map().layers.len(), layers); } +/// A plugin's command in the app (`leave_downloads`) runs on the frame: a dated GeoTIFF's first +/// file isn't waited for, and the answer says the map wasn't moved to it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_plugins_dated_cog_is_added_without_waiting() { + // A server that takes the connection and never answers: a probe would wait its minute. + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", server.local_addr().unwrap()); + let mut s = Session::new(Project::default()); + s.leave_downloads = true; + let start = std::time::Instant::now(); + let r = s.run("layer.addUrl", &json!({"url": format!("{base}/{{date:YYYY}}.tif"), "start": 2020, "end": 2021})).unwrap(); + s.leave_downloads = false; + assert!(start.elapsed() < std::time::Duration::from_secs(10), "waited {:?}", start.elapsed()); + assert_eq!(r["inPlace"], true, "{r}"); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("wasn't moved")), "{r}"); + drop(server); +} + /// A cloud-optimised GeoTIFF by its address is read in place: its header, then only the tiles a /// map tile needs, by range; a GeoTIFF in strips is downloaded whole as a raster layer. #[test] From ab7485ab8b19a78efb736b5d204d4025a0fb7bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 06:57:46 -0500 Subject: [PATCH 08/15] test(engine): the last locked layer takes its anchors too; when a layer id comes back --- crates/engine/src/lib.rs | 4 ++-- crates/engine/src/tests.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ee459b272..2ff95a73a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -856,8 +856,8 @@ fn fresh_source_ids(before: &Project, after: &mut Project) { /// layer holds only the window in view, so its other features aren't gone. It compares ids, so an /// id a single command both removes and hands out stays locked: only restoring from an archive can. /// A command that removes a layer removes its locks and line anchors too, or each frame would read -/// them for nothing. It compares layer ids too: a layer id one command frees and hands out again -/// keeps the old layer's entries, which the map does only once its ids run out (`u32::MAX`). +/// them for nothing. It compares layer ids too: a layer id the map hands out again (after `u32::MAX`, +/// or once a failed add rewinds the counter) keeps the old layer's entries. fn forget_label_state_of_removed_features(before: &Project, after: &mut Project) { for (i, m) in after.maps.iter_mut().enumerate() { let was = before.maps.get(i).filter(|o| o.name == m.name); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4ff9449d2..4af6be2fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4163,6 +4163,7 @@ fn a_removed_layers_locked_labels_go_with_it() { // The last locked layer gone, the lock holds, with nothing in it. s.run("layer.remove", &json!({"layer": b})).unwrap(); assert!(s.map().label_lock.as_ref().is_some_and(Vec::is_empty)); + assert!(s.map().label_lock_line_anchors.is_empty()); } /// A deleted feature's lock entry and line anchor go with it, not those of another layer's feature From 90cb02d652c218de596a15b60ac0ff07cf4d589b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 06:57:46 -0500 Subject: [PATCH 09/15] test(tiles): tests that fetch through the cache or end a login take turns --- crates/tiles/src/cache.rs | 33 +++++++++++++++++++++++++++++++++ crates/tiles/src/logins.rs | 5 +++++ crates/tiles/src/pmtiles.rs | 1 + crates/tiles/src/source.rs | 1 + crates/tiles/src/stac.rs | 1 + 5 files changed, 41 insertions(+) diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs index 2d0b4bf1d..de2a0e2fa 100644 --- a/crates/tiles/src/cache.rs +++ b/crates/tiles/src/cache.rs @@ -140,6 +140,30 @@ pub fn clear_fetcher() { *fetcher().lock().unwrap_or_else(|e| e.into_inner()) = None; } +/// Tests that fetch through the cache take turns. It has one fetcher for the whole process, so a +/// test that replaces it ([`set_fetcher`]) would answer another's fetches. It also starts fetches +/// on whichever thread is starting them already, so one test's fetch may run on another's thread, +/// later. Tests that end a login take turns too: that empties the cache ([`clear`]). The turn puts +/// the native fetcher back when it ends. +#[cfg(test)] +pub(crate) fn turn() -> Turn { + static TURN: Mutex<()> = Mutex::new(()); + Turn { _held: TURN.lock().unwrap_or_else(|e| e.into_inner()) } +} + +/// A test's turn at the cache ([`turn`]). +#[cfg(test)] +pub(crate) struct Turn { + _held: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl Drop for Turn { + fn drop(&mut self) { + clear_fetcher(); + } +} + /// Replaces how tile pictures (PNG, JPEG, WebP) are decoded: the browser's own decoder works /// off the page's thread, where decoding them here held up every frame tiles arrived in. `done` /// must be called once, with the picture as [`RAW`] pixels, or with the bytes given back. @@ -2396,6 +2420,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn logins_headers_follow_same_origin_redirects() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; let (base, asked) = serve_recorded( [ @@ -2418,6 +2443,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn logins_redirects_never_pick_up_another_origins_login() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; let (other, asked) = serve_recorded(vec![b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\ntile".to_vec()]); let base = serve(vec![format!("HTTP/1.1 302 Found\r\nLocation: {other}/tile\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")]); @@ -2437,6 +2463,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn logins_diagnostics_never_record_the_binding_or_the_secret() { + let _turn = crate::cache::turn(); use crate::failure::Reason; use crate::logins::{self, Method}; use rustgis_data::diagnostics::{Filter, History}; @@ -2483,6 +2510,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn delayed_head_refusal_after_cross_origin_redirect_is_status_only() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; use rustgis_data::diagnostics::{Capture, Filter, History}; use std::io::{Read, Write}; @@ -2546,6 +2574,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn logins_head_and_post_carry_the_login() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; let answer = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(); let (base, asked) = serve_recorded(vec![answer.clone(), answer]); @@ -2564,6 +2593,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn live_connection_bindings_separate_requests_and_cache_entries() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; let answer = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".to_vec(); let (base, asked) = serve_recorded(vec![answer.clone(), answer.clone(), answer]); @@ -2595,6 +2625,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn logout_during_pending_download_discards_old_authenticated_body() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -2634,6 +2665,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn derived_style_tilejson_sprite_and_feature_requests_carry_the_binding() { + let _turn = crate::cache::turn(); use crate::{ logins::{self, Method}, source, @@ -2903,6 +2935,7 @@ mod tests { #[test] fn fetches_through_the_fetcher_once() { + let _turn = crate::cache::turn(); static CALLS: AtomicUsize = AtomicUsize::new(0); set_fetcher(|url, done| { CALLS.fetch_add(1, Ordering::Relaxed); diff --git a/crates/tiles/src/logins.rs b/crates/tiles/src/logins.rs index 38a7099d4..b5d916f4b 100644 --- a/crates/tiles/src/logins.rs +++ b/crates/tiles/src/logins.rs @@ -417,6 +417,7 @@ mod tests { #[test] fn http_requires_permission_and_bindings_reject_scheme_host_and_port_changes() { + let _turn = crate::cache::turn(); assert!(activate("http://insecure-login.test", "http", Method::Header("X-Key".into()), "short", false).is_err()); let binding = activate("https://scoped-login.test", "scope", Method::Header("X-Key".into()), "scope-secret", false).unwrap(); let bound = bind(&binding, "https://scoped-login.test/tile").unwrap(); @@ -432,6 +433,7 @@ mod tests { #[test] fn bindings_isolate_connections_and_revoke_generations() { + let _turn = crate::cache::turn(); let target = "https://binding-login.example"; let a = activate(target, "session-a/connection", Method::Header("X-Key".into()), "a", false).unwrap(); let b = activate(target, "session-b/connection", Method::Header("X-Key".into()), "b", false).unwrap(); @@ -451,6 +453,7 @@ mod tests { #[test] fn failed_random_source_installs_nothing_and_preserves_a_live_login() { + let _turn = crate::cache::turn(); let target = "https://random-failure-login.example"; let old = activate(target, "random-replacement", Method::Header("X-Key".into()), "old-credential", false).unwrap(); let address = bind(&old, &format!("{target}/tile")).unwrap(); @@ -473,6 +476,7 @@ mod tests { #[test] fn collisions_retry_without_overwriting_and_exhaust_after_four_attempts() { + let _turn = crate::cache::turn(); let target = "https://collision-login.example"; let method = || Method::Header("X-Key".into()); let old = activate_with_random(target, "collision-old", method(), "old-key", false, |bytes| { @@ -521,6 +525,7 @@ mod tests { #[test] fn scrubs_header_and_basic_payload() { + let _turn = crate::cache::turn(); let value = basic("scrub-user", "scrub-password"); let binding = activate("https://scrub-login.example", "scrub", Method::Header("Authorization".into()), &value, false).unwrap(); let payload = value.strip_prefix("Basic ").unwrap(); diff --git a/crates/tiles/src/pmtiles.rs b/crates/tiles/src/pmtiles.rs index 90ea8ee01..c6215d99c 100644 --- a/crates/tiles/src/pmtiles.rs +++ b/crates/tiles/src/pmtiles.rs @@ -1039,6 +1039,7 @@ pub(crate) mod tests { #[test] fn a_raster_archive_resolves_and_decodes_and_truncations_fail_cleanly() { + let _turn = crate::cache::turn(); let dir = std::env::temp_dir().join(format!("rustgis-pmtiles-raster-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut png = Vec::new(); diff --git a/crates/tiles/src/source.rs b/crates/tiles/src/source.rs index 02e6eba25..eddfde482 100644 --- a/crates/tiles/src/source.rs +++ b/crates/tiles/src/source.rs @@ -1039,6 +1039,7 @@ mod tests { /// origin inherits it, one at another origin goes without, and a revoked login reads none. #[test] fn every_sprite_at_the_styles_origin_inherits_its_login() { + let _turn = crate::cache::turn(); use crate::logins::{self, Method}; let origin = "https://sprites-login.example.com"; let binding = logins::activate(origin, "sprites", Method::Header("X-Key".into()), "sprites-secret", false).unwrap(); diff --git a/crates/tiles/src/stac.rs b/crates/tiles/src/stac.rs index 55ff6b799..911905256 100644 --- a/crates/tiles/src/stac.rs +++ b/crates/tiles/src/stac.rs @@ -726,6 +726,7 @@ mod tests { #[test] fn bound_stac_api_catalog_and_assets_follow_only_same_origin_links() { + let _turn = crate::cache::turn(); let origin = "https://stac-bound.test"; // A binding is authority only while its runtime owns it, so install a live one. let token = crate::logins::activate(origin, "test-owner", crate::logins::Method::Header("X-Key".into()), "secret", false).unwrap(); From e57307a0ad5631fecc4d2641fd1118d163d44fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 08:13:48 -0500 Subject: [PATCH 10/15] fix(engine): a plugin's command waits for no COG header on the frame, AlphaEarth's included --- crates/engine/src/cmd/embeddings.rs | 5 +++-- crates/engine/src/cmd/layer.rs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/embeddings.rs b/crates/engine/src/cmd/embeddings.rs index 66bc05b20..ccb303448 100644 --- a/crates/engine/src/cmd/embeddings.rs +++ b/crates/engine/src/cmd/embeddings.rs @@ -148,9 +148,10 @@ fn add(s: &mut Session, p: &Value) -> CmdResult { }); // Its header read first, so its bands are known; the layer is framed only when it is the // map's first content (no "zoom" is passed, so add_cog defaults it to that). In the - // browser nothing may wait, and it is read as the map draws. + // browser nothing may wait, nor in a plugin's command in the app (it runs on the + // frame), and it is read as the map draws. #[cfg(not(target_arch = "wasm32"))] - if !crate::render::rustgis_tiles::cog::probe_blocking(file)? { + if !s.leave_downloads && !crate::render::rustgis_tiles::cog::probe_blocking(file)? { return Err(format!("{file}: not a cloud-optimised GeoTIFF")); } let look = crate::render::rustgis_tiles::cog::with_look(file, &look); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 5bc20f52c..80026430e 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -3548,6 +3548,8 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm } } let mut r = super::tiles::add(s, &q)?; + // A plugin's command in the app doesn't wait (it runs on the frame), as in the browser. + let probed = probed && !s.leave_downloads; // A dated address is framed on the file of the date it shows. let (url, probed) = match r.get("url").and_then(Value::as_str) { Some(dated) => { From ed81da4c593a4c505e432a3f6929d6e3b655ec2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 08:13:48 -0500 Subject: [PATCH 11/15] fix(engine): a COG that couldn't be read says why the map wasn't moved to it --- crates/engine/src/cmd/layer.rs | 36 +++++++++++++++++++++++----------- crates/engine/src/tests.rs | 24 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 80026430e..0572c4d87 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -3550,18 +3550,17 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm let mut r = super::tiles::add(s, &q)?; // A plugin's command in the app doesn't wait (it runs on the frame), as in the browser. let probed = probed && !s.leave_downloads; - // A dated address is framed on the file of the date it shows. - let (url, probed) = match r.get("url").and_then(Value::as_str) { + // A dated address is framed on the file of the date it shows; why its file couldn't be read. + let (url, probed, mut why) = match r.get("url").and_then(Value::as_str) { Some(dated) => { - // Its header read now (natively) as an undated address's is before it is added. A - // plugin's command in the app doesn't wait (it runs on the frame), as in the browser. + // Its header read now (natively) as an undated address's is before it is added. #[cfg(not(target_arch = "wasm32"))] - let read = !s.leave_downloads && crate::render::rustgis_tiles::cog::probe_blocking(dated) == Ok(true); + let probe = if s.leave_downloads { Ok(false) } else { crate::render::rustgis_tiles::cog::probe_blocking(dated) }; #[cfg(target_arch = "wasm32")] - let read = false; - (dated.to_string(), read) + let probe: Result = Ok(false); + (dated.to_string(), probe == Ok(true), probe.err()) } - None => (url.to_string(), probed), + None => (url.to_string(), probed, None), }; #[cfg(not(target_arch = "wasm32"))] let info = if probed { @@ -3571,7 +3570,14 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm }; #[cfg(target_arch = "wasm32")] let info = crate::render::rustgis_tiles::cog::info(&url); - if let Ok(Some(info)) = info { + let info = match info { + Ok(info) => info, + Err(e) => { + why.get_or_insert(e); + None + } + }; + if let Some(info) = info { let [w, so, e, n] = info.bounds; // A cloud-optimised GeoTIFF read in place is framed when it is the first content on the // map, as other data is; `"zoom"` says otherwise (`true`, or `false` to keep the view). @@ -3600,8 +3606,16 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm s.notify(format!("{shown}: {line}"), false); } } else if probed || r.get("dated").is_some() { - // Added, but its (first date's) file couldn't be read: the view stays where it was. - r["warning"] = json!(format!("{url} couldn't be read yet, so the map wasn't moved to it")); + // Added, but its (first date's) file couldn't be read: the view stays where it was, and + // the answer says why when the read said (its keys left out). + r["warning"] = json!(match why { + Some(e) => format!( + "{} couldn't be read ({}), so the map wasn't moved to it", + crate::sources::redact(&url), + crate::sources::redact_text(&e) + ), + None => format!("{url} couldn't be read yet, so the map wasn't moved to it"), + }); } r["inPlace"] = json!(true); Ok(r) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4af6be2fe..e849d54b3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15666,6 +15666,30 @@ fn a_plugins_dated_cog_is_added_without_waiting() { drop(server); } +/// A GeoTIFF whose header was just read is framed even if the cache forgot it since (a login +/// ended), and one that can't be read again says why the map wasn't moved to it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_probed_cog_is_framed_after_the_cache_forgets_it() { + use crate::render::rustgis_tiles::{cache, cog}; + let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 128)); + let url = format!("{base}/scene.tif"); + assert_eq!(cog::probe_blocking(&url), Ok(true)); + cache::forget(&url); + let mut s = Session::new(Project::default()); + let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &url, true).unwrap(); + assert!(r["bounds"].is_array(), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + // Nothing answers at this address any more. + let gone = { + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + format!("http://{}/gone.tif", server.local_addr().unwrap()) + }; + let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, true).unwrap(); + let warning = r["warning"].as_str().unwrap_or_default(); + assert!(warning.contains("couldn't be read (") && warning.contains("wasn't moved"), "{r}"); +} + /// A cloud-optimised GeoTIFF by its address is read in place: its header, then only the tiles a /// map tile needs, by range; a GeoTIFF in strips is downloaded whole as a raster layer. #[test] From 5180047c443ea66777f334730248f4a3595c77a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 08:13:48 -0500 Subject: [PATCH 12/15] test(tiles): tests that wait for or plant what the cache holds take turns too --- crates/tiles/src/cache.rs | 5 +++-- crates/tiles/src/draw.rs | 2 ++ crates/tiles/src/source.rs | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs index de2a0e2fa..5ce30d1fd 100644 --- a/crates/tiles/src/cache.rs +++ b/crates/tiles/src/cache.rs @@ -143,8 +143,9 @@ pub fn clear_fetcher() { /// Tests that fetch through the cache take turns. It has one fetcher for the whole process, so a /// test that replaces it ([`set_fetcher`]) would answer another's fetches. It also starts fetches /// on whichever thread is starting them already, so one test's fetch may run on another's thread, -/// later. Tests that end a login take turns too: that empties the cache ([`clear`]). The turn puts -/// the native fetcher back when it ends. +/// later. Tests that end a login take turns too, as that empties the cache ([`clear`]), and so do +/// tests that wait for or plant what the cache holds (an archive read, a style resolved), which +/// that would drop. The turn puts the native fetcher back when it ends. #[cfg(test)] pub(crate) fn turn() -> Turn { static TURN: Mutex<()> = Mutex::new(()); diff --git a/crates/tiles/src/draw.rs b/crates/tiles/src/draw.rs index 86557178b..0e4fc2280 100644 --- a/crates/tiles/src/draw.rs +++ b/crates/tiles/src/draw.rs @@ -1285,6 +1285,7 @@ pub(crate) mod tests { #[test] fn picks_the_vector_tile_features_under_a_point() { + let _turn = crate::cache::turn(); let tile = crate::mvt::encode(&crate::mvt::Tile { layers: vec![ mvt_layer("parks", GeomKind::Polygon, &[[1024.0, 1024.0], [3072.0, 1024.0], [3072.0, 3072.0], [1024.0, 3072.0]], "Central"), @@ -1413,6 +1414,7 @@ pub(crate) mod tests { #[test] fn picks_a_feature_across_a_tile_edge() { + let _turn = crate::cache::turn(); // Zoom 1: a point at the right edge of the western tile (1/0/0); the click is just over // the edge, in the eastern one (1/1/0). let tile = crate::mvt::encode(&crate::mvt::Tile { layers: vec![mvt_layer("pois", GeomKind::Point, &[[4094.0, 2048.0]], "Edge")] }); diff --git a/crates/tiles/src/source.rs b/crates/tiles/src/source.rs index eddfde482..8b4671a04 100644 --- a/crates/tiles/src/source.rs +++ b/crates/tiles/src/source.rs @@ -811,6 +811,7 @@ mod tests { #[test] fn an_elevation_source_on_its_way_holds_back_only_its_own_layers() { + let _turn = crate::cache::turn(); // Its TileJSON isn't here yet (the first fetch of an address is on its way). let doc = serde_json::json!({"version": 8, "sources": { "dem": {"type": "raster-dem", "url": "https://on-its-way.example.com/dem.json"}, @@ -850,6 +851,7 @@ mod tests { #[test] fn a_relief_layer_whose_source_was_left_out_draws_nothing() { + let _turn = crate::cache::turn(); // Lit from every side with white, a hillshade would show on any slope drawn. let doc = serde_json::json!({"version": 8, "sources": {"dem": {"type": "raster-dem", "url": "http://169.254.169.254/dem.json"}}, "layers": [ @@ -882,6 +884,7 @@ mod tests { #[test] fn a_style_that_left_something_out_draws_the_rest_and_says_so() { + let _turn = crate::cache::turn(); let doc = serde_json::json!({"version": 8, "sources": {}, "layers": [{"id": "bg", "type": "background", "paint": {"background-color": "#f00"}}]}); let left_out = vec!["elevation source dem left out, so its relief isn't drawn: gone".to_owned()]; let r = Resolved { From 1ffbeac2543b666c1259e5d5b6d609162850d9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 11:01:48 -0500 Subject: [PATCH 13/15] fix(engine): AlphaEarth's tile is probed again in a plugin's command, so a file that isn't a COG is refused --- crates/engine/src/cmd/embeddings.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/embeddings.rs b/crates/engine/src/cmd/embeddings.rs index ccb303448..66bc05b20 100644 --- a/crates/engine/src/cmd/embeddings.rs +++ b/crates/engine/src/cmd/embeddings.rs @@ -148,10 +148,9 @@ fn add(s: &mut Session, p: &Value) -> CmdResult { }); // Its header read first, so its bands are known; the layer is framed only when it is the // map's first content (no "zoom" is passed, so add_cog defaults it to that). In the - // browser nothing may wait, nor in a plugin's command in the app (it runs on the - // frame), and it is read as the map draws. + // browser nothing may wait, and it is read as the map draws. #[cfg(not(target_arch = "wasm32"))] - if !s.leave_downloads && !crate::render::rustgis_tiles::cog::probe_blocking(file)? { + if !crate::render::rustgis_tiles::cog::probe_blocking(file)? { return Err(format!("{file}: not a cloud-optimised GeoTIFF")); } let look = crate::render::rustgis_tiles::cog::with_look(file, &look); From 0fe500817045c9b1b21fff74e70d6666171ce841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 11:01:48 -0500 Subject: [PATCH 14/15] fix(engine): a COG whose failure is recorded says why the map wasn't moved to it --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/tests.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 0572c4d87..fa487bfcd 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -3605,7 +3605,7 @@ pub(crate) fn add_cog(s: &mut Session, p: &Value, url: &str, probed: bool) -> Cm for line in said { s.notify(format!("{shown}: {line}"), false); } - } else if probed || r.get("dated").is_some() { + } else if probed || why.is_some() || r.get("dated").is_some() { // Added, but its (first date's) file couldn't be read: the view stays where it was, and // the answer says why when the read said (its keys left out). r["warning"] = json!(match why { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e849d54b3..c535b8744 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15688,6 +15688,14 @@ fn a_probed_cog_is_framed_after_the_cache_forgets_it() { let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, true).unwrap(); let warning = r["warning"].as_str().unwrap_or_default(); assert!(warning.contains("couldn't be read (") && warning.contains("wasn't moved"), "{r}"); + // Not waited for, its failure (recorded, not yet tried again) is said too. Another test's + // login ending forgets the record, so it is made again for each try. + let said = (0..3).any(|_| { + let _ = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, true); + let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, false).unwrap(); + r["warning"].as_str().is_some_and(|w| w.contains("couldn't be read (") && w.contains("wasn't moved")) + }); + assert!(said, "a recorded failure went unsaid"); } /// A cloud-optimised GeoTIFF by its address is read in place: its header, then only the tiles a From d2a8b0553dbd2ea56bfdb4be65bffff6e1de8d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gregorio=20Rinc=C3=B3n?= Date: Fri, 6 Nov 2026 11:01:48 -0500 Subject: [PATCH 15/15] test(engine): the plugin's dated COG test runs as a plugin's command does --- crates/engine/src/tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c535b8744..9be9b8b37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15656,10 +15656,9 @@ fn a_plugins_dated_cog_is_added_without_waiting() { let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", server.local_addr().unwrap()); let mut s = Session::new(Project::default()); - s.leave_downloads = true; let start = std::time::Instant::now(); - let r = s.run("layer.addUrl", &json!({"url": format!("{base}/{{date:YYYY}}.tif"), "start": 2020, "end": 2021})).unwrap(); - s.leave_downloads = false; + let r = + s.run_leaving_downloads("layer.addUrl", &json!({"url": format!("{base}/{{date:YYYY}}.tif"), "start": 2020, "end": 2021})).unwrap(); assert!(start.elapsed() < std::time::Duration::from_secs(10), "waited {:?}", start.elapsed()); assert_eq!(r["inPlace"], true, "{r}"); assert!(r["warning"].as_str().is_some_and(|w| w.contains("wasn't moved")), "{r}");