Skip to content

Commit 6597457

Browse files
authored
fix(vector): stop a failed restore from deleting multi-layer URL layers (#1761)
* fix(vector): stop a failed restore from deleting multi-layer URL layers A multi-layer container (a KMZ of KML folders, a GeoPackage of tables) becomes one store layer per source layer, and every one of them keeps the container's URL. Three problems compounded from there, reported against NASA FIRMS fire-footprint KMZ feeds in discussion #1757. Restoring such a project replays each layer independently, and any layer whose replay failed was then pruned by the closing store sync, which reads "the control does not have this layer" as "the user removed it". A feed that was briefly unreachable therefore deleted the layers from the project outright, and the next save wrote that loss to disk. Restore now records which replays failed and keeps those layers, and refresh (the button and the auto-refresh tick) falls through to a new replay path so the next successful fetch brings the layer back instead of leaving a dead entry. Each of those sibling layers also downloaded the container separately: on desktop a six-layer KMZ pulled the same archive six times over on every project open and every refresh interval, and unzipped and registered it into DuckDB six times. Add Vector Layer downloads are now collapsed to one in-flight request per URL, handing every sibling the identical File so the control's per-source unzip and registration caches collapse with them. Those six concurrent downloads then ran into the native fetch command's tile-sized 8s budget, which is what produced the reporter's "Could not read response body" failures at exactly 8.4s. fetch_url_bytes now accepts a caller-supplied timeout, clamped so it can only be raised and never removed, and the vector loader asks for a download-sized budget. Finally, a store layer that outlives its map layers no longer has its style synced, which was filling the Diagnostics panel with "Cannot get style of non-existing layer" the user could do nothing about. Ref: #1757 * Address CodeRabbit and Claude review feedback - Do not fall back to a browser fetch when the native command rejected the URL by policy. The webview is not subject to the backend SSRF guard, so a link-local or metadata URL it refused could still be reached through the fallback. New isBlockedUrlError mirrors the guard's messages and re-throws those, leaving the fallback for genuine transport failures. - Give both browser fetch paths the same 180s budget via AbortSignal.timeout. Siblings now await one shared download, so a stalled fetch would have held all of them pending rather than only itself. - Correct the timeoutSecs doc: the backend clamps into [8, 600] rather than falling back to the default, and move the field onto a fetchUrlBytes-only options type since resolve_url_redirect hardcodes its own timeout. - Drop the embedded-source branch from replayVectorControlLayerById. Refresh is gated on isVectorControlRefreshLayer, which requires an HTTP URL, so it was unreachable. Restore still preserves such layers. - Guard replayVectorControlLayerById against concurrent calls for one id. The getLayer check cannot hold across the addData await, so two callers would both pass it and the second would throw on the duplicate id. * Address Claude review feedback on the review fixes - Give each download attempt its own budget instead of sharing one signal. The shared deadline started before the native call, so in the exact case the fallbacks exist for (a slow origin exhausting the native timeout) they were handed an already-aborted signal and rejected instantly. Each attempt now gets a fresh AbortSignal.timeout. - Claim replaying ids during project restore too, not only in replayVectorControlLayerById. The control does not register a layer until its data has loaded, so an auto-refresh tick landing mid-restore saw the id as absent and started a second addData for a layer restore was already loading. restoreVectorLayers now tracks its in-flight replays through the same registry, which closes the window for all three restore branches. * Address CodeRabbit review feedback - Preserve a local-file layer whose replay fails, matching the URL and embedded branches. The file had already been read successfully, so the failure was in the load rather than a missing source, and reopening the project re-reads the same path. Only a failed read still drops the layer, which is the case where the file is genuinely gone; replayVectorLayer settles its own rejection, so that outer catch never saw replay failures anyway and no restructuring was needed. * Cover the missing-native-layer sync guard with regression tests Locks in that a store layer whose map layers are absent (a preserved failed restore) makes no style, visibility, or ordering calls, and that a stale id alongside a live one skips only the stale one rather than dropping the whole sync. Both fail without the getLayer guard. * Address review feedback - Tighten GeoLibreAppAPI.fetchVectorUrl to Promise<File | null>. The whole cache-collapsing benefit depends on returning a File rather than a bare Blob, and the type now enforces that instead of only documenting it. - Fix a test fixture comment that named the wrong layer id. * Guard restoreVectorLayers against replaying an in-flight layer Project loading can invoke the restore pass more than once before the first pass finishes. addData does not expose a layer through getLayer until its async ingest completes, so getLayer alone let a second pass replay the same id and race the first when adding its MapLibre source ("Source ... already exists"). Skip ids already claimed in replayingLayerIds, which also blocks refresh replays for the same in-flight id. * Address Claude review feedback - Close a redirect/DNS SSRF bypass in the native fetch path. The guarded redirect policy stopped a blocked hop with `attempt.stop()`, which reqwest documents as returning the 30x response as `Ok` — so it reached the frontend as "Request failed with status 302", which `isBlockedUrlError` does not match, and `fetchVectorUrl` then retried the URL with an unguarded webview `fetch` that followed the very redirect the policy refused. A blocked hop now fails via `attempt.error(SSRF_BLOCKED_MESSAGE)`, and `request_error_message` walks the error source chain so both that and `GuardedDnsResolver`'s rejection (which reqwest's connector buries the same way) surface with the guard's own wording. The 10-hop cap keeps `stop()`: an over-long chain is not an SSRF rejection. Covered by a new `is_ssrf_guard_error` unit test. - Hoist `failedLayerIds` to module scope beside `replayingLayerIds`. The two were mismatched in lifetime: overlapping restore passes divide the layers between them via the shared claim, but each pass ran its own closing `syncVectorLayersToStore`, and the last to run — which the shared suspension counter decides — knew only its own pass's failures and pruned its sibling's, reintroducing the exact layer loss this PR fixes. `trackReplay` clears an id when a fresh attempt starts so a successful refresh drops a stale mark, and the closing sync prunes ids the store no longer knows about.
1 parent 6d54b35 commit 6597457

13 files changed

Lines changed: 844 additions & 75 deletions

File tree

apps/geolibre-desktop/src-tauri/src/lib.rs

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ const UV_INSTALL_BASE_URL: &str = "https://astral.sh/uv";
136136
const REMOTE_TILE_TIMEOUT_SECS: u64 = 8;
137137
const REMOTE_TILE_CONNECT_TIMEOUT_SECS: u64 = 4;
138138
const URL_RESOLVE_TIMEOUT_SECS: u64 = 15;
139+
/// Ceiling for a caller-supplied `fetch_url_bytes` budget. The default suits a
140+
/// tile; callers that download a whole dataset (Add Vector Layer) ask for more,
141+
/// but not without bound, so a bad value cannot wedge a request indefinitely.
142+
const MAX_FETCH_TIMEOUT_SECS: u64 = 600;
139143

140144
#[cfg(all(unix, not(feature = "mas")))]
141145
const SIGTERM: i32 = 15;
@@ -821,18 +825,59 @@ fn ensure_fetchable_url(url: &str) -> Result<(), String> {
821825

822826
/// A redirect policy that re-applies [`url_is_fetchable`] to every hop, so a
823827
/// public URL that 3xx-redirects to an internal address is not followed.
828+
///
829+
/// A blocked hop fails the request via `attempt.error` rather than
830+
/// `attempt.stop`: stopping makes reqwest hand the 30x response back as `Ok`,
831+
/// which reaches the frontend as a bland "Request failed with status 302" that
832+
/// [`is_ssrf_guard_error`] cannot recognise — and an unrecognised failure is
833+
/// retried by the webview's unguarded `fetch`, which would follow the very
834+
/// redirect this policy refused. Failing with [`SSRF_BLOCKED_MESSAGE`] in the
835+
/// error's source chain keeps that fallback closed. The hop cap still uses
836+
/// `stop`: an over-long but otherwise allowed chain is not an SSRF rejection
837+
/// and must not be reported as one.
824838
fn guarded_redirect_policy() -> reqwest::redirect::Policy {
825839
reqwest::redirect::Policy::custom(|attempt| {
826840
if attempt.previous().len() >= 10 {
827841
return attempt.stop();
828842
}
829843
match url_is_fetchable(attempt.url()) {
830844
Ok(()) => attempt.follow(),
831-
Err(_) => attempt.stop(),
845+
Err(_) => attempt.error(SSRF_BLOCKED_MESSAGE),
832846
}
833847
})
834848
}
835849

850+
/// True when a reqwest failure was caused by the SSRF guard rather than by the
851+
/// network.
852+
///
853+
/// Neither guard's rejection is visible in the top-level `Display`: the redirect
854+
/// policy's error is wrapped in reqwest's "error following redirect", and
855+
/// [`GuardedDnsResolver`]'s is wrapped by the connector. Both keep
856+
/// [`SSRF_BLOCKED_MESSAGE`] in the source chain, so walk it.
857+
fn is_ssrf_guard_error(error: &dyn std::error::Error) -> bool {
858+
let mut current = Some(error);
859+
while let Some(error) = current {
860+
if error.to_string().contains(SSRF_BLOCKED_MESSAGE) {
861+
return true;
862+
}
863+
current = error.source();
864+
}
865+
false
866+
}
867+
868+
/// Renders a request failure for the frontend, preserving
869+
/// [`SSRF_BLOCKED_MESSAGE`] verbatim when the guard was what refused it.
870+
///
871+
/// `isBlockedUrlError` in `src/lib/vector-url-fetch.ts` matches on that wording
872+
/// to decide whether retrying in the webview is safe, so a guard rejection that
873+
/// reaches it as a generic transport error fails *open* into an unguarded fetch.
874+
fn request_error_message(error: &reqwest::Error) -> String {
875+
if is_ssrf_guard_error(error) {
876+
return SSRF_BLOCKED_MESSAGE.to_string();
877+
}
878+
format!("Request failed: {error}")
879+
}
880+
836881
/// A DNS resolver that drops any address in a blocked range, so reqwest connects
837882
/// only to IPs that passed [`is_disallowed_ip`].
838883
///
@@ -1035,23 +1080,41 @@ fn build_guarded_http_client() -> Result<reqwest::blocking::Client, String> {
10351080
.map_err(|error| format!("Could not create HTTP client: {error}"))
10361081
}
10371082

1083+
/// Fetches a URL's bytes, bypassing browser CORS.
1084+
///
1085+
/// `timeout_secs` overrides the tile-sized default for callers that download a
1086+
/// whole dataset rather than a tile; it is clamped to
1087+
/// `[REMOTE_TILE_TIMEOUT_SECS, MAX_FETCH_TIMEOUT_SECS]`, so the budget can only
1088+
/// ever be raised and never removed.
10381089
#[tauri::command]
1039-
async fn fetch_url_bytes(url: String) -> Result<Vec<u8>, String> {
1040-
tauri::async_runtime::spawn_blocking(move || fetch_url_bytes_blocking(url))
1090+
async fn fetch_url_bytes(url: String, timeout_secs: Option<u64>) -> Result<Vec<u8>, String> {
1091+
tauri::async_runtime::spawn_blocking(move || fetch_url_bytes_blocking(url, timeout_secs))
10411092
.await
10421093
.map_err(|error| format!("Tile fetch task failed: {error}"))?
10431094
}
10441095

1045-
fn fetch_url_bytes_blocking(url: String) -> Result<Vec<u8>, String> {
1096+
/// Resolves the request budget for a fetch, defaulting to the tile timeout and
1097+
/// clamping a caller-supplied value into `[REMOTE_TILE_TIMEOUT_SECS,
1098+
/// MAX_FETCH_TIMEOUT_SECS]`. A caller can therefore only ever raise the budget,
1099+
/// and never past the ceiling or down to zero (which reqwest reads as "no
1100+
/// timeout" and would let a stalled request hang forever).
1101+
fn resolve_fetch_timeout_secs(timeout_secs: Option<u64>) -> u64 {
1102+
timeout_secs
1103+
.unwrap_or(REMOTE_TILE_TIMEOUT_SECS)
1104+
.clamp(REMOTE_TILE_TIMEOUT_SECS, MAX_FETCH_TIMEOUT_SECS)
1105+
}
1106+
1107+
fn fetch_url_bytes_blocking(url: String, timeout_secs: Option<u64>) -> Result<Vec<u8>, String> {
10461108
ensure_fetchable_url(&url)?;
10471109

10481110
let client = guarded_http_client()?;
1111+
let timeout = resolve_fetch_timeout_secs(timeout_secs);
10491112

10501113
let response = client
10511114
.get(&url)
1052-
.timeout(Duration::from_secs(REMOTE_TILE_TIMEOUT_SECS))
1115+
.timeout(Duration::from_secs(timeout))
10531116
.send()
1054-
.map_err(|error| format!("Request failed: {error}"))?;
1117+
.map_err(|error| request_error_message(&error))?;
10551118
let status = response.status();
10561119
if !status.is_success() {
10571120
return Err(format!("Request failed with status {status}"));
@@ -1552,7 +1615,7 @@ fn resolve_url_redirect_blocking(url: String) -> Result<String, String> {
15521615
.header("accept", "application/json, text/plain;q=0.9, */*;q=0.8")
15531616
.timeout(timeout)
15541617
.send()
1555-
.map_err(|error| format!("Request failed: {error}"))?;
1618+
.map_err(|error| request_error_message(&error))?;
15561619
if has_xyz_placeholders(response.url().as_str()) {
15571620
return Ok(response.url().to_string());
15581621
}
@@ -4002,7 +4065,8 @@ mod tests {
40024065
use super::{
40034066
client_cert_is_pkcs12, client_cert_password_without_path, ensure_fetchable_url,
40044067
is_allowed_local_vector_path, is_allowed_project_path, is_disallowed_ip,
4005-
is_safe_absolute_path, path_is_under, tcp_table_port,
4068+
is_safe_absolute_path, is_ssrf_guard_error, path_is_under, resolve_fetch_timeout_secs,
4069+
tcp_table_port, MAX_FETCH_TIMEOUT_SECS, REMOTE_TILE_TIMEOUT_SECS, SSRF_BLOCKED_MESSAGE,
40064070
};
40074071
#[cfg(target_os = "linux")]
40084072
use super::{linux_uses_nvidia_renderer, nvidia_is_primary_gpu};
@@ -4258,6 +4322,35 @@ mod tests {
42584322
assert!(ensure_fetchable_url("http://[::1]:8081/data.pmtiles").is_ok());
42594323
}
42604324

4325+
#[test]
4326+
fn ssrf_guard_error_is_detected_through_the_source_chain() {
4327+
use std::error::Error;
4328+
use std::fmt;
4329+
4330+
#[derive(Debug)]
4331+
struct Wrapper(Box<dyn Error + Send + Sync>);
4332+
impl fmt::Display for Wrapper {
4333+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4334+
// Deliberately hides the cause, the way reqwest's "error
4335+
// following redirect" / connector errors hide theirs.
4336+
write!(f, "error following redirect for url (https://example.com/)")
4337+
}
4338+
}
4339+
impl Error for Wrapper {
4340+
fn source(&self) -> Option<&(dyn Error + 'static)> {
4341+
Some(self.0.as_ref())
4342+
}
4343+
}
4344+
4345+
let blocked = Wrapper(SSRF_BLOCKED_MESSAGE.into());
4346+
assert!(!blocked.to_string().contains(SSRF_BLOCKED_MESSAGE));
4347+
assert!(is_ssrf_guard_error(&blocked));
4348+
4349+
// A genuine transport failure must stay fallback-eligible.
4350+
let transport = Wrapper("connection reset by peer".into());
4351+
assert!(!is_ssrf_guard_error(&transport));
4352+
}
4353+
42614354
#[test]
42624355
fn project_path_guard_allows_projects_and_blocks_secrets() {
42634356
assert!(is_allowed_project_path("/home/u/map.geolibre.json"));
@@ -4661,6 +4754,26 @@ mod tests {
46614754
assert_eq!(tcp_table_port(0x0000_223E), 0x3E22);
46624755
}
46634756

4757+
// Add Vector Layer downloads a whole dataset through the same command that
4758+
// fetches tiles, so it asks for a longer budget. The clamp is what keeps
4759+
// that override from becoming a way to disable the timeout entirely.
4760+
#[test]
4761+
fn clamps_the_fetch_timeout_into_the_allowed_range() {
4762+
// No override: the tile-sized default.
4763+
assert_eq!(resolve_fetch_timeout_secs(None), REMOTE_TILE_TIMEOUT_SECS);
4764+
// A dataset-sized budget is honored as asked.
4765+
assert_eq!(resolve_fetch_timeout_secs(Some(180)), 180);
4766+
// Zero would mean "no timeout" to reqwest, so it is raised to the floor
4767+
// rather than letting a stalled request hang forever.
4768+
assert_eq!(resolve_fetch_timeout_secs(Some(0)), REMOTE_TILE_TIMEOUT_SECS);
4769+
// Below the floor is raised; above the ceiling is capped.
4770+
assert_eq!(resolve_fetch_timeout_secs(Some(1)), REMOTE_TILE_TIMEOUT_SECS);
4771+
assert_eq!(
4772+
resolve_fetch_timeout_secs(Some(u64::MAX)),
4773+
MAX_FETCH_TIMEOUT_SECS
4774+
);
4775+
}
4776+
46644777
// The image-path guard is what keeps the reaper from killing a Jupyter the
46654778
// user installed themselves: only executables under our own runtime
46664779
// directory are ours to terminate.

apps/geolibre-desktop/src/components/panels/LayerPanel.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
materializeEmbeddableVectorLayers,
5656
RASTER_SOURCE_KIND,
5757
reloadVectorControlLayer,
58+
replayVectorControlLayerById,
5859
SKETCHES_SOURCE_KIND,
5960
TIME_SLIDER_PLUGIN_ID,
6061
type TimePropertyCandidate,
@@ -1376,10 +1377,16 @@ export function LayerPanel({
13761377
return;
13771378
}
13781379
if (isVectorControlRefreshLayer(layer)) {
1379-
const info = await reloadVectorControlLayer(layer.id);
1380+
// A layer whose restore failed stays in the project but never made it
1381+
// into the control, so reloadLayer cannot find it. Replaying it is
1382+
// what brings such a layer back once the source is reachable again,
1383+
// which is why refresh (manual and automatic) tries that second.
1384+
const info =
1385+
(await reloadVectorControlLayer(layer.id)) ??
1386+
(await replayVectorControlLayerById(layer.id));
13801387
if (!info) {
13811388
// The control is unavailable (panel never opened, or torn down
1382-
// and not yet replayed) or no longer knows this layer id.
1389+
// and not yet replayed) or the replay above did not succeed.
13831390
// Automatic ticks fire on a timer the user didn't initiate, so
13841391
// skip silently and clear the transient note instead of surfacing
13851392
// an error every interval until the control comes back.

apps/geolibre-desktop/src/hooks/usePlugins.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ import { appendDiagnostic } from "../lib/diagnostics";
121121
import { pickZarrDirectory, zarrDirectoryPickerSupported } from "../lib/zarr-directory-picker";
122122
import { openExternalLink } from "../lib/open-external";
123123
import { fetchUrlBytes } from "../lib/native-http";
124+
import {
125+
dedupeVectorUrlFetch,
126+
isBlockedUrlError,
127+
vectorDownloadFileName,
128+
} from "../lib/vector-url-fetch";
124129
import { partitionProjectPluginManifestUrls } from "../lib/plugin-trust";
125130
import { setTimeSliderOpenedByBinding, shouldCloseTimeSliderDock } from "../lib/time-slider-dock";
126131
import { createWmsTileUrl, normalizeWmsVersion } from "../components/layout/add-data/helpers";
@@ -1033,35 +1038,57 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
10331038
// presence to auto-discover shapefile sidecars instead of forcing the user
10341039
// to select every component, and to capture the file's path for restore.
10351040
pickVectorFilesWithSidecars: isTauriRuntime() ? pickVectorFilesWithSidecars : undefined,
1036-
fetchVectorUrl: async (url: string) => {
1037-
if (isTauriRuntime()) {
1038-
try {
1039-
const bytes = await fetchUrlBytes(url, { context: "Add Vector Layer" });
1040-
const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
1041-
return new Blob([array as Uint8Array<ArrayBuffer>]);
1042-
} catch {
1043-
// The shared native command has a short, tile-oriented timeout.
1044-
// Preserve the former browser path for large CORS-enabled datasets.
1041+
// Shared across the sibling layers of one multi-layer container, which all
1042+
// carry the container's URL: without this a six-layer KMZ downloaded itself
1043+
// six times over on every project open and every refresh tick.
1044+
fetchVectorUrl: (url: string) =>
1045+
dedupeVectorUrlFetch(url, async () => {
1046+
const name = vectorDownloadFileName(url);
1047+
// Each attempt gets its own budget rather than sharing one across all
1048+
// three. A shared deadline would be spent by the native call in exactly
1049+
// the case the fallbacks exist for (a slow origin), leaving them to
1050+
// reject instantly on an already-aborted signal. Sibling layers now
1051+
// await a single download, so an unbounded fetch would hold all of them
1052+
// pending, which is why each attempt is bounded at all.
1053+
const budget = () => AbortSignal.timeout(VECTOR_DOWNLOAD_TIMEOUT_SECS * 1000);
1054+
if (isTauriRuntime()) {
10451055
try {
1046-
const response = await fetch(url);
1047-
if (!response.ok) {
1048-
throw new Error(`HTTP ${response.status} ${response.statusText}`);
1056+
const bytes = await fetchUrlBytes(url, {
1057+
context: "Add Vector Layer",
1058+
// The default budget on this command is tile-sized (8s). A vector
1059+
// dataset is not a tile. A few megabytes from a slow origin
1060+
// routinely needs longer, and timing out here used to drop the
1061+
// layer entirely, so ask for a download-sized budget instead.
1062+
timeoutSecs: VECTOR_DOWNLOAD_TIMEOUT_SECS,
1063+
});
1064+
const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
1065+
return new File([array as Uint8Array<ArrayBuffer>], name);
1066+
} catch (error) {
1067+
// The webview is not subject to the backend's SSRF guard, so a URL
1068+
// the native command refused by policy must not be retried here.
1069+
if (isBlockedUrlError(error)) throw error;
1070+
// Keep the browser path as a fallback for CORS-enabled origins the
1071+
// native command could not reach.
1072+
try {
1073+
const response = await fetch(url, { signal: budget() });
1074+
if (!response.ok) {
1075+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
1076+
}
1077+
return new File([await response.blob()], name);
1078+
} catch {
1079+
// GitHub's /raw route rejects browser CORS, so fall through to the
1080+
// same guarded proxy used by the web build.
10491081
}
1050-
return response.blob();
1051-
} catch {
1052-
// GitHub's /raw route rejects browser CORS, so fall through to the
1053-
// same guarded proxy used by the web build.
10541082
}
10551083
}
1056-
}
1057-
const proxyUrl = githubRawVectorProxyUrl(url);
1058-
if (!proxyUrl) return null;
1059-
const response = await fetch(proxyUrl);
1060-
if (!response.ok) {
1061-
throw new Error(`HTTP ${response.status} ${response.statusText}`);
1062-
}
1063-
return response.blob();
1064-
},
1084+
const proxyUrl = githubRawVectorProxyUrl(url);
1085+
if (!proxyUrl) return null;
1086+
const response = await fetch(proxyUrl, { signal: budget() });
1087+
if (!response.ok) {
1088+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
1089+
}
1090+
return new File([await response.blob()], name);
1091+
}),
10651092
readLocalVectorFile: readVectorFileWithSidecars,
10661093
exportTextFile: (filename: string, content: string, options?: GeoLibreFileDialogOptions) => {
10671094
const description = options?.description ?? "GeoJSON";
@@ -1333,6 +1360,14 @@ function isTauriRuntime(): boolean {
13331360

13341361
const GITHUB_RAW_VECTOR_PROXY = "https://tiles.geolibre.app/github-raw";
13351362

1363+
/**
1364+
* Budget for a native Add Vector Layer download, in seconds. Deliberately far
1365+
* above `fetch_url_bytes`'s tile-sized default: this command carries whole
1366+
* datasets, not 256px tiles, and a timeout here is not a slow tile that resolves
1367+
* next frame but a layer that fails to restore.
1368+
*/
1369+
const VECTOR_DOWNLOAD_TIMEOUT_SECS = 180;
1370+
13361371
function githubRawVectorProxyUrl(value: string): string | null {
13371372
let url: URL;
13381373
try {

apps/geolibre-desktop/src/lib/native-http.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ interface NativeHttpOptions {
2222
context?: string;
2323
}
2424

25+
/**
26+
* Options for {@link fetchUrlBytes}. `timeoutSecs` lives here rather than on
27+
* {@link NativeHttpOptions} because only the `fetch_url_bytes` command accepts
28+
* a timeout argument; `resolve_url_redirect` hardcodes its own, so offering the
29+
* field there would invite a caller to set it and silently have no effect.
30+
*/
31+
interface FetchUrlBytesOptions extends NativeHttpOptions {
32+
/**
33+
* Request budget in seconds, overriding the command's tile-sized default.
34+
* Set it for calls that carry a whole dataset rather than a tile. The backend
35+
* clamps it into `[8, 600]`, so a smaller value is raised to 8 and a larger
36+
* one is capped at 600; the timeout can be raised but never removed.
37+
*/
38+
timeoutSecs?: number;
39+
}
40+
2541
function recordSource(command: NativeHttpCommand, context?: string): string {
2642
return context ? `native ${command}${context}` : `native ${command}`;
2743
}
@@ -79,11 +95,16 @@ export function nativeHttpFailureRecord(
7995
async function invokeNativeHttp<T>(
8096
command: NativeHttpCommand,
8197
url: string,
82-
options?: NativeHttpOptions,
98+
options?: FetchUrlBytesOptions,
8399
): Promise<T> {
84100
const startedAt = performance.now();
85101
try {
86-
const result = await invoke<T>(command, { url });
102+
// `timeoutSecs` is omitted rather than sent as undefined so the Rust side
103+
// sees an absent argument and applies its own default.
104+
const result = await invoke<T>(command, {
105+
url,
106+
...(options?.timeoutSecs === undefined ? {} : { timeoutSecs: options.timeoutSecs }),
107+
});
87108
appendDiagnostic(
88109
nativeHttpSuccessRecord(
89110
command,
@@ -112,12 +133,13 @@ async function invokeNativeHttp<T>(
112133
* subject to browser CORS), recording the request in the diagnostics log.
113134
*
114135
* @param url - The absolute HTTP(S) URL to fetch.
115-
* @param options - Optional context label for the diagnostics record.
136+
* @param options - Optional context label for the diagnostics record, and an
137+
* optional request budget for callers downloading more than a tile.
116138
* @returns The response body bytes (Tauri may hand back a plain number array).
117139
*/
118140
export function fetchUrlBytes(
119141
url: string,
120-
options?: NativeHttpOptions,
142+
options?: FetchUrlBytesOptions,
121143
): Promise<number[] | Uint8Array> {
122144
return invokeNativeHttp<number[] | Uint8Array>("fetch_url_bytes", url, options);
123145
}

0 commit comments

Comments
 (0)