Skip to content
Open
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
10 changes: 7 additions & 3 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3080,13 +3080,17 @@ fn add_url_as_given(s: &mut Session, p: &Value) -> CmdResult {
if ["geolibreProject", "geolibreLayer", "geolibreZarr"].iter().any(|key| p.get(key).is_some()) {
return Err("GeoLibre download state is internal; use project.importGeolibre".into());
}
// Marks a service's document, read and kept for `service.add`: as a download, no layer would come.
if super::plugins::fetch::is_document(p) {
return Err("\"document\" is internal to service.add: leave it out".into());
}
let url = ps(p, "url").map(str::trim).filter(|u| !u.is_empty()).ok_or("url is required")?;
// A plugin command run again once the download it left to the app was added (#1202): what
// adding it answered, as often as the run asks for it (the app downloaded and added it once).
// Answering it changes nothing (the layer is its own undo step).
let wanted = s.asked_for(p);
if s.leave_downloads
&& let Some((_, answer, added)) = s.rerun_landed.iter().find(|(asked, ..)| super::plugins::fetch::same_request(asked, &wanted))
&& let Some((answer, added)) = s.rerun_answer(&wanted)
{
s.request_only = true;
// Added to another map than the one shown now: the command's next steps would act on this
Expand All @@ -3111,10 +3115,10 @@ fn add_url_as_given(s: &mut Session, p: &Value) -> CmdResult {
if missing || added.gone(s.map()) {
return Err(format!("{url}: the layer its download added was taken away (undone) before the command ran again"));
}
if super::plugins::fetch::table_gone(answer, &s.project) {
if super::plugins::fetch::table_gone(&answer, &s.project) {
return Err(format!("{url}: the table its download added was taken away (undone) before the command ran again"));
}
return added.answer_now(s.map(), answer);
return added.answer_now(s.map(), &answer);
}
// An address read through a saved S3 connection is judged by the address it wraps; the whole
// address stays what is fetched and kept.
Expand Down
252 changes: 250 additions & 2 deletions crates/engine/src/cmd/plugin_fetch.rs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions crates/engine/src/cmd/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,10 +726,17 @@ impl Session {
// The undone call took its download requests with it: they go to the app still, once,
// unless the command gives up now (it has run again too often): then nothing waits for them.
let giving_up = self.retry_tries >= fetch::MAX_RETRIES;
let mut documents = Vec::new();
for (n, asked) in requested.iter().filter(|_| !giving_up) {
// Queued already: this number for this address (another caller may give the number).
let taken = self.url_requests.iter().any(|q| q["request"].as_u64() == Some(*n) && fetch::same_request(q, asked));
if !taken && !self.url_waits.has(*n) {
// A service's document is read here, once the run is kept: the app adds nothing
// for it, and with no command waiting nothing would take it.
if fetch::is_document(asked) {
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
documents.push((*n, asked.clone()));
continue;
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
}
let mut q = asked.clone();
q["request"] = json!(n);
self.url_requests.push(q);
Expand All @@ -744,6 +751,10 @@ impl Session {
self.rerun_said.truncate(earlier);
if let Err(e) = &res {
self.notify(e.clone(), true);
} else {
for (n, asked) in &documents {
self.url_waits.read_document(*n, asked);
}
}
res
}
Expand Down
155 changes: 138 additions & 17 deletions crates/engine/src/cmd/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,26 @@ impl Discovery {

const MAX_SERVICE_DOCUMENTS: usize = 4;

/// Whether what this `service.add` would wait for is left off the frame: it is a plugin command's,
/// run by the desktop app, which runs the command again when it has come (#1319).
fn leaves_waits(s: &Session) -> bool {
cfg!(not(target_arch = "wasm32")) && s.leave_downloads && s.call_requests.is_some()
}

/// What a `service.add` waiting for the document at `url` answers: its command runs again.
fn waiting_for(s: &mut Session, url: &str) -> CmdResult {
s.request_only = true;
// (Without its keys: a saved login's may be in it, and the plugin gave only the service.)
Ok(json!({"pending": true, "url": crate::sources::redact(url)}))
}

fn capabilities(p: &Value) -> Result<Discovery, String> {
discover(p, None)
}

/// Reads a service's documents, fetching what `p` doesn't give: waiting for them, or with `left`,
/// off the frame ([`Session::left_document`]), when a document not in yet is its `next`.
fn discover(p: &Value, mut left: Option<&mut Session>) -> Result<Discovery, String> {
let original = url_of(p)?;
let mut address =
crate::render::rustgis_tiles::logins::inherit(original, &rustgis_data::ogc::capabilities_url(original, ps(p, "type")));
Expand Down Expand Up @@ -153,6 +172,12 @@ fn capabilities(p: &Value) -> Result<Discovery, String> {
return Ok(result);
};
rustgis_data::ogc::parse(&address, text.as_bytes())
} else if let Some(s) = left.as_deref_mut() {
let Some(read) = s.left_document(&address) else {
result.next = Some(address);
return Ok(result);
};
read.and_then(|bytes| rustgis_data::ogc::parse(&address, &bytes)).map_err(|e| format!("{original}: {e}"))
} else {
fetch_capabilities(original, &address)
};
Expand Down Expand Up @@ -372,6 +397,35 @@ fn advertised_template(list: &str, vector: bool) -> Option<String> {
None
}

/// [`advertised_template`] read off the frame: `Err` names the document it waits for.
fn left_template(s: &mut Session, list: &str, vector: bool) -> Result<Option<String>, String> {
use rustgis_data::ogc::{TilesStep, resolve_tiles};
let mut doc = list.to_string();
for _ in 0..2 {
if crate::render::rustgis_tiles::address::check(&doc, Some(list)).is_err() {
return Ok(None);
}
let Some(read) = s.left_document(&doc) else { return Err(doc) };
let shown = crate::sources::redact(&doc);
let body = match read {
Ok(body) => body,
Err(e) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence): the 16-read cap is treated as a failed tile-set read. left_document returns Some(Err("16 documents are already being read…")) when the cap is hit (plugin_fetch.rs, the reading >= crate::MAX_URL_REQUESTS check). This Err(e) arm turns that refusal into "its tile sets couldn't be read, so the standard tile address is used" and adds the layer on the standard address without waiting. The capabilities path refuses the same error, since discover propagates it and service.add fails, so the two paths disagree.

Scenario: one plugin run calls service.add for 17 OGC API layers that each have a tile list, ignoring pending. Layers 1–16 queue their reads. Layer 17 gets the cap error, and its layer lands on the standard tile address with only an informational notice.

Suggested direction: keep the cap refusal as an error for the command. For example, have left_template return the left_document error as Err-that-fails rather than falling back, or check the cap before calling it. The fallback should stay only for a document that was actually read and failed or named no tiles.

A test would be: 17 tile-list layers in one run, expecting the command to fail on the 17th rather than add it on the standard address.

s.notify(format!("{shown}: its tile sets couldn't be read, so the standard tile address is used: {e}"), false);
return Ok(None);
}
};
match resolve_tiles(list, &doc, &body, vector) {
Some(TilesStep::Template(t)) => return Ok(checked_template(list, &t).ok()),
Some(TilesStep::Next(next)) => doc = crate::render::rustgis_tiles::logins::inherit(&doc, &next),
None => {
s.notify(format!("{shown}: it names no tiles of this layer, so the standard tile address is used"), false);
return Ok(None);
}
}
}
Ok(None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS.md, Never silent (low–medium confidence): the tile-list loop in left_template ends after two documents with Ok(None) and no notice. The layer then falls back to the standard tile address without telling the user, which the earlier thread on this area asked to avoid. checked_template(list, &t).ok() on line 418 is silent for the same reason: an invalid advertised template is dropped without a notice. The blocking advertised_template has the same gaps, so this is carried over. The new path should still say what it did.

Suggested change
Ok(None)
s.notify(format!("{}: its tile sets lead on past two documents, so the standard tile address is used", crate::sources::redact(list)), false);
Ok(None)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed here. The two gaps you name, a list running past two documents and an invalid advertised template, are the same in the blocking advertised_template, so this PR doesn't introduce them. The failed read and the list naming no tiles are said since the last round. It would make more sense to say all of them in both paths in one change, and a maintainer can ask for that here if they prefer.

}

/// In the browser nothing waits: the app reads the template itself and passes it as `"tiles"`.
#[cfg(target_arch = "wasm32")]
fn advertised_template(_: &str, _: bool) -> Option<String> {
Expand Down Expand Up @@ -442,7 +496,12 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult {
let p = bound.as_ref();
let url = url_of(p)?.to_string();
let want = ps(p, "layer").map(str::trim).filter(|l| !l.is_empty()).ok_or("layer is required")?;
let found = capabilities(p)?;
let leaving = leaves_waits(s);
let reads_off_frame = leaving && ps(p, "text").is_none();
let found = discover(p, reads_off_frame.then_some(&mut *s))?;
if let Some(next) = found.next.as_deref().filter(|_| reads_off_frame) {
return waiting_for(s, next);
}
let candidates = || {
found.service.layers.iter().zip(&found.sources).filter(|(_, source)| ps(p, "source_url").is_none_or(|s| same_address(s, source)))
};
Expand Down Expand Up @@ -516,6 +575,10 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult {
// the usual path.
let advertised = match (&layer.tilesets, ps(p, "tiles")) {
(Some(_), Some(t)) => Some(checked_template(source, t)?),
(Some(list), None) if leaving => match left_template(s, list, matches!(layer.offer, Offer::VectorTiles { .. })) {
Ok(template) => template,
Err(doc) => return waiting_for(s, &doc),
},
(Some(list), None) => advertised_template(list, matches!(layer.offer, Offer::VectorTiles { .. })),
_ => None,
};
Expand Down Expand Up @@ -613,14 +676,30 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult {
Err(format!("{url}: '{want}' is a folder or service the directory lists: list its layers (service.layers with url {open})"))
}
Offer::Coverage(c) => {
let (get, _) = coverage_request(s, p, c)?;
let described = crate::render::rustgis_tiles::logins::inherit(&c.endpoint, &rustgis_data::ogc::describe_coverage_url(c));
let info = match ps(p, "describe") {
Some(text) => rustgis_data::ogc::parse_describe(c, text.as_bytes())?,
None if leaving => match s.left_document(&described) {
Some(read) => {
let bytes = read.map_err(|e| format!("{}: {e}", c.id))?;
rustgis_data::ogc::parse_describe(c, &bytes)?
}
None => return waiting_for(s, &described),
},
None => fetch_describe(c, &described)?,
};
let (get, _) = coverage_request_from(s, p, c, &info)?;
#[cfg(target_arch = "wasm32")]
{
let _ = get;
Err(format!("{url}: in the web app, Home › Add › Add Data › From Service downloads the coverage"))
}
#[cfg(not(target_arch = "wasm32"))]
{
if leaving {
// (Whole: a coverage's address may end in .tif, and it is read as a raster.)
return super::layer::add_url(s, &json!({"url": get, "name": name, "coverage": true, "whole": true}));
}
let bytes = crate::render::rustgis_tiles::cache::download_blocking(&get).map_err(|e| format!("{name}: {e}"))?;
coverage_check(&name, &bytes)?;
let r = super::raster::add_named_bytes(s, &json!({"name": name}), format!("{name}.tif"), bytes, None)?;
Expand All @@ -641,24 +720,46 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult {
// An area asked for, whether or not the request could carry it: a slow whole layer
// then says so, and doesn't suggest the `bbox` already given.
let given = p.get("bbox").is_some_and(|b| !b.is_null() && *b != "full");
if given && asked.is_none() {
let why = if area.is_none() { "an area across the antimeridian" } else { "its request can't carry an area" };
s.notify(format!("{name}: {why}, so the whole layer is asked for"), false);
}
let max = crate::render::rustgis_tiles::cache::MAX_DOWNLOAD;
let from = asked.as_deref().unwrap_or(items);
// An area request keeps the view until the requested box is applied below.
let keep_view = asked.is_some().then_some(false);
let mut r = match download_layer(s, &name, from, max, true).map_err(|e| slow_whole_layer(e, given))? {
Downloaded::Bytes(bytes) => {
let bytes = features_answer(s, &name, bytes, layer.bbox);
super::raster::add_named_bytes(s, &json!({"name": name}), format!("{name}.geojson"), bytes, keep_view)?
// A plugin's command: downloaded off the frame as Add From Service does, added, and
// its command run again, which is answered here and goes on (#1319).
let left = leaving.then(|| {
let mut q = json!({"url": from, "name": name, "service": true});
if let Some(bounds) = layer.bbox {
q["bounds"] = json!(bounds);
}
if let Some(zoom) = keep_view {
q["zoom"] = json!(zoom);
}
// Read in protocol buffer pages: their positions as the service's system
// places them, so there are no axes to check.
Downloaded::Read(read) => super::layer::add_read(s, &json!({"name": name}), *read, keep_view)?,
q
});
// (By the run that has its answer: a run that waits is undone, and this goes with it.)
if given && asked.is_none() && left.as_ref().is_none_or(|q| s.rerun_has(q)) {
let why = if area.is_none() { "an area across the antimeridian" } else { "its request can't carry an area" };
s.notify(format!("{name}: {why}, so the whole layer is asked for"), false);
}
let mut r = if let Some(q) = left {
let r = super::layer::add_url(s, &q).map_err(|e| slow_whole_layer(e, given))?;
if r.get("pending").is_some() {
return Ok(r);
}
r
} else {
let max = crate::render::rustgis_tiles::cache::MAX_DOWNLOAD;
let r = match download_layer(s, &name, from, max, true).map_err(|e| slow_whole_layer(e, given))? {
Downloaded::Bytes(bytes) => {
let bytes = features_answer(s, &name, bytes, layer.bbox);
super::raster::add_named_bytes(s, &json!({"name": name}), format!("{name}.geojson"), bytes, keep_view)?
}
// Read in protocol buffer pages: their positions as the service's system
// places them, so there are no axes to check.
Downloaded::Read(read) => super::layer::add_read(s, &json!({"name": name}), *read, keep_view)?,
};
mark_service_source(s, &r, from);
r
};
mark_service_source(s, &r, from);
// Over an area, the features crossing it: the view stays, or goes to a box given
// (the whole layer's bounds would show a country to fill one state).
match (asked.is_some(), area, p.get("bbox")) {
Expand Down Expand Up @@ -720,8 +821,18 @@ fn coverage_request(s: &Session, p: &Value, c: &Coverage) -> Result<(String, [f6
Some(text) => rustgis_data::ogc::parse_describe(c, text.as_bytes())?,
None => fetch_describe(c, &describe)?,
};
let area = coverage_area(s, p, &info, &c.id)?;
let get = crate::render::rustgis_tiles::logins::inherit(&c.endpoint, &rustgis_data::ogc::get_coverage_url(c, &info, area));
coverage_request_from(s, p, c, &info)
}

/// [`coverage_request`] with its description read.
fn coverage_request_from(
s: &Session,
p: &Value,
c: &Coverage,
info: &rustgis_data::ogc::CoverageInfo,
) -> Result<(String, [f64; 4]), String> {
let area = coverage_area(s, p, info, &c.id)?;
let get = crate::render::rustgis_tiles::logins::inherit(&c.endpoint, &rustgis_data::ogc::get_coverage_url(c, info, area));
crate::render::rustgis_tiles::address::check(&get, Some(&c.endpoint))?;
Ok((get, area))
}
Expand Down Expand Up @@ -842,4 +953,14 @@ mod tests {
assert_eq!(super::slow_whole_layer(slow.clone(), true), slow);
assert_eq!(super::slow_whole_layer("404 Not Found".into(), false), "404 Not Found");
}

/// What a command is told it waits for has no keys in it: a saved login's may be in the address.
#[test]
fn a_document_waited_for_is_named_without_its_keys() {
let mut s = crate::Session::new(crate::sample::world_project());
let r = super::waiting_for(&mut s, "https://x.org/wfs?request=GetCapabilities&token=hunter2").unwrap();
let url = r["url"].as_str().unwrap();
assert!(url.starts_with("https://x.org/wfs") && !url.contains("hunter2"), "{r}");
assert_eq!(r["pending"], true);
}
}
3 changes: 3 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,8 @@ pub struct Session {
/// While a plugin command runs again for its downloads: what each one asked for, and what
/// adding it answered, for its `layer.addUrl` to answer once.
pub(crate) rerun_landed: Vec<(serde_json::Value, CmdResult, cmd::plugins::fetch::Added)>,
/// Which of those are failures already told in this run (see [`Session::rerun_answer`]).
pub(crate) rerun_told: Vec<usize>,
/// While a plugin command runs again: the errors its earlier runs said, not said again.
pub(crate) rerun_said: Vec<String>,
/// While plugin commands whose downloads are in run again: the downloads they wait for, still
Expand Down Expand Up @@ -1038,6 +1040,7 @@ impl Session {
next_request: 0,
call_requests: None,
rerun_landed: Vec::new(),
rerun_told: Vec::new(),
rerun_said: Vec::new(),
rerun_requests: Vec::new(),
retry_tries: 0,
Expand Down
Loading
Loading