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
47 changes: 46 additions & 1 deletion crates/data/src/mbtiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,21 @@ mod file {
all.clear();
}

/// Forgets the open connections to the files `keep` says no to. Windows won't move or delete a
/// file SQLite has open, so a file nothing reads any more mustn't stay in the pool.
pub fn close_unless(keep: impl Fn(&Path) -> bool) {
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
let mut all = pool().lock().unwrap_or_else(|e| e.into_inner());
if !all.keys().all(|path| keep(path)) {
GENERATION.fetch_add(1, Ordering::SeqCst);
all.retain(|path, _| keep(path));
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
}
}

/// Whether a connection to `path` is kept open.
pub fn is_open(path: &Path) -> bool {
pool().lock().unwrap_or_else(|e| e.into_inner()).get(path).is_some_and(|open| !open.is_empty())
}

/// The metadata of the MBTiles file at `path`.
pub fn open_path(path: &Path) -> Result<Info, String> {
with(path, |c| {
Expand Down Expand Up @@ -414,10 +429,40 @@ mod file {
}
})
}

#[cfg(test)]
mod tests {
use super::*;

/// Only the files `keep` says no to lose their connections, and one left alone stays open.
#[test]
fn close_unless_leaves_the_files_it_keeps() {
struct Cleanup(PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
// Also clean up if an assertion fails with the files still open.
close_unless(|path| !path.starts_with(&self.0));
let _ = std::fs::remove_dir_all(&self.0);
}
}
let dir = std::env::temp_dir().join(format!("rustgis-mbtiles-close-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let _cleanup = Cleanup(dir.clone());
let (kept, gone) = (dir.join("kept.mbtiles"), dir.join("gone.mbtiles"));
for path in [&kept, &gone] {
std::fs::write(path, b"").unwrap();
pool().lock().unwrap().entry(path.clone()).or_default().push(open(path).unwrap());
}
close_unless(|path| path != gone);
assert!(is_open(&kept));
assert!(!is_open(&gone));
std::fs::remove_file(&gone).unwrap();
}
}
}

#[cfg(not(target_arch = "wasm32"))]
pub use file::{close, close_all, open_path, tile_path};
pub use file::{close, close_all, close_unless, is_open, open_path, tile_path};

#[cfg(test)]
mod tests {
Expand Down
23 changes: 23 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,17 @@ impl Session {
current
}

/// The MBTiles files the project's layers read.
#[cfg(not(target_arch = "wasm32"))]
fn mbtiles_files(&self) -> impl Iterator<Item = &str> {
self.project
.maps
.iter()
.flat_map(|m| &m.tiles)
.filter(|t| t.kind == rustgis_map::TileKind::MbTiles)
.filter_map(|t| t.archive_file())
}

/// Runs a command as [`Session::run`] does, except that the downloads `layer.addUrl` would
/// wait for (on the desktop) are left to the front end, as in the browser: the app runs the
/// commands of plugins this way, so a plugin adding data by address doesn't freeze it. The
Expand Down Expand Up @@ -1425,7 +1436,19 @@ impl Session {
// one entry too, as it is one step to undo.
let joins = self.merge.is_some() && self.merge == self.merged && !self.undo.is_empty();
let (at, start) = (project_log::now_ms(), web_time::Instant::now());
#[cfg(not(target_arch = "wasm32"))]
let files_before: Vec<String> = self.mbtiles_files().map(str::to_string).collect();
let res = run(self);
// Whichever command took its last layer away (removed, undone, the project closed):
// the file can now be moved or replaced, so what was read of it is forgotten too.
#[cfg(not(target_arch = "wasm32"))]
{
let files: Vec<&str> = self.mbtiles_files().collect();
for gone in files_before.iter().filter(|f| !files.contains(&f.as_str())) {
render::rustgis_tiles::cache::forget(gone);
}
render::rustgis_tiles::mbtiles::close_unused(files.into_iter());
}
// Pixels the command read in place (a project, an import) say what they lost.
self.notice_landed_rasters();
// A plugin's command waiting for its fetches answers `pending` and runs again when they
Expand Down
107 changes: 107 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28797,6 +28797,113 @@ fn geolibre_tile_sources_keep_visibility_opacity_and_order() {
assert_eq!(s.map().tiles[3].name, "xyz");
}

/// A file is let go of with its last layer, however that went (Windows won't move or delete a file
/// SQLite has open), and one still on a map keeps its connections for the next tile.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn mbtiles_files_are_let_go_of_with_their_last_layer() {
use rustgis_data::mbtiles::{close, is_open, tile_path};
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
// Also clean up if an assertion fails with the file still open.
close(&self.0);
let _ = std::fs::remove_file(&self.0);
}
}
let path = std::env::temp_dir().join(format!("rustgis-mbtiles-release-{}.mbtiles", std::process::id()));
let _ = std::fs::remove_file(&path);
let _cleanup = Cleanup(path.clone());
let db = rusqlite::Connection::open(&path).unwrap();
db.execute_batch("CREATE TABLE metadata(name TEXT,value TEXT); CREATE TABLE tiles(zoom_level INTEGER,tile_column INTEGER,tile_row INTEGER,tile_data BLOB); INSERT INTO metadata VALUES('format','png'),('minzoom','0'),('maxzoom','0'); INSERT INTO tiles VALUES(0,0,0,x'00');").unwrap();
drop(db);
let mut s = Session::new(Project::default());
s.run("map.addTiles", &json!({"url": path.display().to_string(), "kind": "mbtiles"})).unwrap();
// The pool is the process's, and another test's command can close the file at any moment, so
// this asks only that a command leave it open once: it fails if every command closes it.
let stays_open = |s: &mut Session| {
(0..100).any(|_| {
tile_path(&path, 0, 0, 0).unwrap();
s.run("map.graticule", &json!({})).unwrap();
is_open(&path)
})
};
assert!(stays_open(&mut s), "a layer on the map keeps its connections");
s.run("map.addTiles", &json!({"url": path.display().to_string(), "kind": "mbtiles", "overlay": true})).unwrap();
s.run("map.removeTiles", &json!({"index": 0})).unwrap();
assert!(stays_open(&mut s), "the file's other layer keeps them");
s.run("map.removeTiles", &json!({})).unwrap();
assert!(!is_open(&path), "the layer removed");
s.run("edit.undo", &json!({})).unwrap();
assert!(stays_open(&mut s), "the layer back");
s.run("project.new", &json!({})).unwrap();
assert!(!is_open(&path), "the project closed");
std::fs::remove_file(&path).unwrap();
}

/// A file replaced after its last layer went is read again: its metadata, the style made from them
/// and its tiles, not what was kept of the old one.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_replaced_mbtiles_file_is_read_again() {
use crate::render::rustgis_tiles::{cache, mbtiles, source};
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
rustgis_data::mbtiles::close(&self.0);
let _ = std::fs::remove_file(&self.0);
}
}
let path = std::env::temp_dir().join(format!("rustgis-mbtiles-replaced-{}.mbtiles", std::process::id()));
let _ = std::fs::remove_file(&path);
let _cleanup = Cleanup(path.clone());
let write = |size: u32, max_zoom: u8| {
if path.exists() {
std::fs::remove_file(&path).unwrap();
}
let mut png = Vec::new();
image::RgbaImage::new(size, size).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let db = rusqlite::Connection::open(&path).unwrap();
db.execute_batch("CREATE TABLE metadata(name TEXT,value TEXT); CREATE TABLE tiles(zoom_level INTEGER,tile_column INTEGER,tile_row INTEGER,tile_data BLOB);").unwrap();
db.execute("INSERT INTO metadata VALUES('format','png'),('minzoom','0'),('maxzoom',?1),('attribution',?1)", [max_zoom.to_string()])
.unwrap();
db.execute("INSERT INTO tiles VALUES(0,0,0,?1)", [png]).unwrap();
};
let url = path.display().to_string();
let tile = mbtiles::template(&url).replace("{z}", "0").replace("{x}", "0").replace("{y}", "0");
let tile_width = || {
for _ in 0..1000 {
match cache::get_tile(&tile, cache::Want::Image) {
cache::State::Ready(payload) => match &*payload {
cache::Payload::Image(image) => return image.width(),
_ => panic!("not an image"),
},
cache::State::Failed(e) => panic!("{e}"),
cache::State::Pending => std::thread::sleep(std::time::Duration::from_millis(10)),
}
}
panic!("the tile never came");
};
let credits = |s: &Session| {
for _ in 0..1000 {
match source::resolve(&crate::render::tile_spec(&s.map().tiles[0])) {
source::Resolve::Ready(style) => return style.attribution.clone(),
source::Resolve::Failed(e) => panic!("{e}"),
_ => std::thread::sleep(std::time::Duration::from_millis(10)),
}
}
panic!("the style never came");
};
let mut s = Session::new(Project::default());
write(2, 1);
s.run("map.addTiles", &json!({"url": url, "kind": "mbtiles"})).unwrap();
assert_eq!((mbtiles::info(&url).unwrap().max_zoom, credits(&s), tile_width()), (1, vec!["1".to_string()], 2));
s.run("map.removeTiles", &json!({})).unwrap();
write(4, 5);
s.run("map.addTiles", &json!({"url": url, "kind": "mbtiles"})).unwrap();
assert_eq!((mbtiles::info(&url).unwrap().max_zoom, credits(&s), tile_width()), (5, vec!["5".to_string()], 4));
}

/// Sources authored by this test exercise the dispatch for every supported local data kind.
#[cfg(not(target_arch = "wasm32"))]
#[test]
Expand Down
Loading
Loading