diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java index 48dd98a6a..d311ca7fe 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java @@ -95,9 +95,10 @@ public final class FqrController { private TextView overlayPct, overlayTitle; private TextView overlayMin; private boolean overlayMinimized = false; - // ADFA-4896: the Stop/Retry control on the overlay. It drives the server pause/resume endpoints, - // but there is no maps checkpoint yet (resume re-extracts from 0), so we present it honestly as - // Stop -> Retry, not Pause -> Resume. overlayStopped is the last state the poll reported. + // ADFA-4896 / K2GO-317: the Pause/Resume control on the overlay. It drives the server pause/resume + // endpoints; pause stops gracefully (SIGTERM) so pmtiles flushes its resume journal, and resume + // continues from that checkpoint instead of restarting. overlayStopped (true = paused) is the last + // state the poll reported and the SOLE writer of the label. private com.google.android.material.button.MaterialButton overlayStop; private boolean overlayStopped = false; @@ -370,30 +371,29 @@ private void startDownload(String name) { if (box == null) return; showOverlay(name, pendingArchive); client.download(name, box, new MapsRegionClient.DownloadListener() { - @Override public void onProgress(int percent, long speed) { - if (overlayStopped) { // retried/running again: back to Stop, live teal bar + @Override public void onProgress(int percent, long speed, int etaSeconds) { + if (overlayStopped) { // resumed/running again: back to Pause, live teal bar overlayStopped = false; - if (overlayStop != null) overlayStop.setText(R.string.k2go_clone_stop_confirm); + if (overlayStop != null) overlayStop.setText(R.string.k2go_dl_pause); setBarState(cPrimary, true); } - if (overlayStop != null) overlayStop.setEnabled(true); // re-enable after a Stop/Retry tap - updateOverlay(percent, speed); + if (overlayStop != null) overlayStop.setEnabled(true); // re-enable after a Pause/Resume tap + updateOverlay(percent, speed, etaSeconds); } @Override public void onPaused(int percent) { - // Server phase is 'paused', but with no checkpoint this is a full stop: show "Stopped" - // and offer Retry (which re-extracts from 0). No percent -- it would imply a resume - // point that doesn't exist. + // K2GO-317: paused keeps the partial + resume journal (SIGTERM checkpoint), so resume + // continues from here. Show "Paused" at the reached percent and offer Resume; freeze the + // bar at that percent in amber so a paused job never looks live. overlayStopped = true; - if (overlayStop != null) { overlayStop.setText(R.string.k2go_dl_retry); overlayStop.setEnabled(true); } - if (overlayPct != null) overlayPct.setText(R.string.k2go_card_stopped); - // #2: freeze the bar (determinate, no animation) so it doesn't keep animating under "Stopped". + if (overlayStop != null) { overlayStop.setText(R.string.k2go_dl_resume); overlayStop.setEnabled(true); } + if (overlayPct != null) overlayPct.setText(R.string.k2go_dl_paused_notif); if (overlayBar != null && overlayBar.isIndeterminate()) setBarMode(false); if (overlayBar != null && percent >= 0) overlayBar.setProgressCompat(percent, false); - setBarState(cAmber, false); // amber + dimmed: a halted job must not look live + setBarState(cAmber, false); // amber + dimmed: a paused job must not look live } @Override public void onDone() { setBarState(cLeaf, true); // done: a full leaf bar reads as success - updateOverlay(100, 0); + updateOverlay(100, 0, -1); if (overlayTitle != null) overlayTitle.setText(R.string.k2go_fqr_region_added); // may be gone if hidden webView.postDelayed(() -> { hideOverlay(); webView.reload(); }, 1200); } @@ -447,18 +447,22 @@ private void showOverlay(String name, long sizeBytes) { overlayPct = new TextView(themed); overlayPct.setText(R.string.k2go_fqr_starting); M3Text.apply(overlayPct, com.google.android.material.R.style.TextAppearance_Material3_BodyMedium, cOnSurfaceVariant); + // K2GO-317: keep percent/speed/ETA on one line next to the buttons; on a narrow width the + // rightmost (ETA) ellipsizes rather than wrapping and growing the row. + overlayPct.setSingleLine(true); + overlayPct.setEllipsize(android.text.TextUtils.TruncateAt.END); row.addView(overlayPct, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); - // ADFA-4896: Stop/Retry beside Cancel. The label follows the reported state; the tap fires the + // ADFA-4896: Pause/Resume beside Cancel. The label follows the reported state; the tap fires the // matching verb and the poll (onPaused/onProgress) is the source of truth. overlayStopped = false; // K2GO-385: download-controls role ladder (design k2go-download-card-style-sizes-v1) -- the - // keep-the-download primary (Stop/Retry) is the FILLED teal button; Cancel, which discards the + // keep-the-download primary (Pause/Resume) is the FILLED teal button; Cancel, which discards the // transfer, is a TEXT button in clay. Never two equal outline pills. FQR's overlay is a themed // (day/night) surface, so the app button styles' theme teal/clay are right here -- not the fixed // boot tokens. The role look lives only in the styles; no colour is set in Java. ContextThemeWrapper filledCtx = new ContextThemeWrapper(themed, R.style.ThemeOverlay_K2Go_Button_Filled); overlayStop = new MaterialButton(filledCtx, null); - overlayStop.setText(R.string.k2go_clone_stop_confirm); + overlayStop.setText(R.string.k2go_dl_pause); overlayStop.setOnClickListener(v -> toggleStop()); row.addView(overlayStop); ContextThemeWrapper textDangerCtx = new ContextThemeWrapper(themed, R.style.ThemeOverlay_K2Go_Button_Text_Destructive); @@ -477,10 +481,10 @@ private void showOverlay(String name, long sizeBytes) { activity.addContentView(overlay, params); } - /** ADFA-4896: fire Stop (server pause) or Retry (server resume — re-extracts from 0, no maps - * checkpoint). The poll (onPaused/onProgress) is the SOLE writer of the label/state; here we only - * fire the verb and disable the button until the next poll confirms, so the label never flickers - * between the tap and the server catching up. */ + /** ADFA-4896 / K2GO-317: fire Pause (server pause, SIGTERM checkpoint) or Resume (server resume, + * continues from the pmtiles journal). The poll (onPaused/onProgress) is the SOLE writer of the + * label/state; here we only fire the verb and disable the button until the next poll confirms, so + * the label never flickers between the tap and the server catching up. */ private void toggleStop() { if (overlayStop != null) overlayStop.setEnabled(false); if (overlayStopped) client.resume(); else client.pause(); @@ -497,19 +501,28 @@ private void toggleMinimize() { if (overlayMin != null) overlayMin.setText(overlayMinimized ? R.string.k2go_fqr_show : R.string.k2go_hide); } - private void updateOverlay(int percent, long speed) { + private void updateOverlay(int percent, long speed, int etaSeconds) { if (overlayBar == null || overlayPct == null) return; final String rate = speed > 0 ? " · " + humanRate(speed) : ""; + final String eta = etaText(etaSeconds); if (percent < 0) { if (!overlayBar.isIndeterminate()) setBarMode(true); - overlayPct.setText(str(R.string.k2go_fqr_working) + rate); + overlayPct.setText(str(R.string.k2go_fqr_working) + rate + eta); } else { if (overlayBar.isIndeterminate()) setBarMode(false); overlayBar.setProgressCompat(percent, true); - overlayPct.setText(str(R.string.k2go_fqr_percent, percent) + rate); + overlayPct.setText(str(R.string.k2go_fqr_percent, percent) + rate + eta); } } + /** K2GO-317: compact ETA for the progress line (" · 45 s left" / " · ~3 min"); empty when + * unknown (<0) or done (0). Reuses the shared eta strings, so no new copy or l10n debt. */ + private String etaText(int seconds) { + if (seconds <= 0) return ""; + if (seconds < 60) return " · " + str(R.string.k2go_eta_sec, seconds); + return " · " + str(R.string.k2go_eta_minutes_fmt, (seconds + 30) / 60); + } + /** Switch the Material progress indicator between indeterminate/determinate. The indicator forbids * the switch while visible, so toggle visibility around it. */ private void setBarMode(boolean indeterminate) { diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsRegionClient.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsRegionClient.java index 39ce0019d..106717137 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsRegionClient.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsRegionClient.java @@ -46,7 +46,8 @@ public interface EstimateListener { /** Region download progress (single durable job). */ public interface DownloadListener { - void onProgress(int percent, long speedBytesPerSec); // percent may be -1; speed 0 if unknown + // percent may be -1 (indeterminate); speed 0 and etaSeconds -1 when unknown. K2GO-317: eta. + void onProgress(int percent, long speedBytesPerSec, int etaSeconds); void onDone(); void onError(String message); /** ADFA-4894: the job is paused (partial kept on the box); resume() continues it. Default @@ -127,7 +128,8 @@ private void pollOnce() { default: { // queued / downloading / processing: report progress if known final int p = percent; final long sp = j.optLong("speed", 0L); - deliver(() -> dl.onProgress(p, sp)); + final int eta = j.optInt("eta", -1); // K2GO-317: seconds remaining, -1 unknown + deliver(() -> dl.onProgress(p, sp, eta)); } } main.postDelayed(pollTask, POLL_MS); @@ -202,8 +204,8 @@ public void pause() { }); } - /** ADFA-4894: resume a paused download; the server continues it via --continue, and the poll - * reports progress again via onProgress(). */ + /** ADFA-4894: resume a paused download; the server continues it from its on-disk checkpoint + * (K2GO-317: the pmtiles resume journal), and the poll reports progress again via onProgress(). */ public void resume() { final String id = jobId; if (id == null || id.isEmpty()) return; diff --git a/static/dashboard/CHANGELOG.md b/static/dashboard/CHANGELOG.md index 6008dc3a4..d760d82f9 100644 --- a/static/dashboard/CHANGELOG.md +++ b/static/dashboard/CHANGELOG.md @@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i (the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`. +- **1.4.7** - FQR map downloads pause and resume on the durable job engine (K2GO-317). Pause now sends the extract a graceful SIGTERM, so the custom go-pmtiles build flushes its on-disk resume journal (`.resume`); resume re-runs tile-extract, which continues from that journal instead of re-extracting each layer from zero. The maps runner keeps the partial and the journal on pause, purges both on cancel or any other failure, reads the json progress line by line (a split stdout chunk no longer drops a second of progress), and reports whole-job percent with speed and eta. Resume from the journal needs the box rootfs to carry the m16 pmtiles binary and the patched tile-extract.py. Localhost-only. (K2GO-317) - **1.4.6** - Rename the content-exchange sigla cx to conex: the relay file is now conex-proxy and the core-port env var is CONEX_PORT (naming only; same /v1 localhost relay to the core on 127.0.0.1:9500). (chore) - **1.4.5** - Relay to the content-exchange core (conex-core). dash-node now forwards `/v1/*` (reached externally at `/k2go-api/v1/*`) to the in-box conex-core REST service on `127.0.0.1:9500`: GET and POST, including the streamed `GET /v1/export/item`. dash-node only relays; the core owns the content state (one owner per fact). No proxy library (Node core `http`); the core's port is env-overridable via `CONEX_PORT`. Localhost-only. (K2GO-427) - **1.4.4** - Overall progress + total ETA for the content download jobs (K2GO-503). `downloadWithAria2` now aggregates progress across the WHOLE job through aria2's RPC (piece-accurate `completedLength` summed over tellActive/Waiting/Stopped) instead of reporting the last file's percent, so a multi-file download (code-addons / code-assets) shows one monotonic bar. `GET /:type/jobs/:id` gains an `eta` field (whole-job seconds remaining, -1 unknown) alongside `percent`/`speed`. The code-addons runner passes its staged-catalog total as the denominator so the bar and ETA are accurate from the first tick; code-assets uses aria2's learned totals. aria2 is started with `--enable-rpc` on an ephemeral localhost port and shut down on queue drain (it does not exit on its own under RPC); the console readout stays as the fallback when RPC is unreachable, and pause/cancel still kill the process directly. Localhost-only. (K2GO-503) diff --git a/static/dashboard/package.json b/static/dashboard/package.json index 643c933b5..2edfb8cd9 100644 --- a/static/dashboard/package.json +++ b/static/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "dashboard-console", - "version": "1.4.6", + "version": "1.4.7", "description": "", "main": "index.js", "scripts": { diff --git a/static/dashboard/sockets/jobs.ts b/static/dashboard/sockets/jobs.ts index 9e0ca2eb9..9f25e3dc5 100644 --- a/static/dashboard/sockets/jobs.ts +++ b/static/dashboard/sockets/jobs.ts @@ -105,6 +105,9 @@ export function throwIfStopped(ctx: RunnerContext): void { const DB_PATH = process.env.K2GO_JOBS_DB || '/library/dashboard/jobs.db'; const ACTIVE: JobPhase[] = ['queued', 'downloading', 'indexing', 'processing']; +// K2GO-317: grace a paused child gets to flush its resume checkpoint on SIGTERM before killAll +// SIGKILLs a survivor that ignored it. +const PAUSE_GRACE_MS = 2500; class JobManager { private db: Database.Database; @@ -192,10 +195,11 @@ class JobManager { } /** - * ADFA-4894 (control surface): pause a running job. Stops the runner like cancel — kills its - * children, aborts an in-flight fetch — but marks it 'paused' and, crucially, the runner does NOT - * clean its partial (it checks {@link RunnerContext.isPaused}), so {@link #resume} continues from - * the checkpoint. Only an ACTIVE job can be paused. Idempotent on a non-active job. + * ADFA-4894 (control surface): pause a running job. Stops the runner like cancel, but with + * SIGTERM not SIGKILL (K2GO-317), so a child like pmtiles can flush a complete resume checkpoint + * before it exits; it aborts an in-flight fetch, marks it 'paused', and the runner does NOT clean + * its partial (it checks {@link RunnerContext.isPaused}), so {@link #resume} continues from the + * checkpoint. Only an ACTIVE job can be paused. Idempotent on a non-active job. */ pause(id: string): boolean { const job = this.get(id); @@ -204,7 +208,7 @@ class JobManager { const rt = this.runtime.get(id); if (rt) { rt.paused = true; - this.killAll(rt); + this.killAll(rt, 'SIGTERM'); // graceful: let the child flush its resume checkpoint try { rt.ac.abort(); } catch { /* interrupt an in-flight fetch */ } } this.patch(id, { phase: 'paused' }); @@ -213,8 +217,8 @@ class JobManager { /** * ADFA-4894: resume a paused job — re-launch the runner, which continues from its on-disk - * checkpoint (aria2 --continue for kiwix; per-item for books). No-op unless the job is 'paused' - * and nothing is already running for it. + * checkpoint (aria2 --continue for kiwix; per-item for books; the pmtiles resume journal for + * maps). No-op unless the job is 'paused' and nothing is already running for it. */ resume(id: string): boolean { const job = this.get(id); @@ -258,17 +262,33 @@ class JobManager { } /** - * ADFA-4896: SIGKILL a job's children. A runner that shells through a wrapper (e.g. maps via - * `sudo`) spawns detached, so the wrapper is a process-group leader; we kill the whole group + * ADFA-4896: stop a job's children. A runner that shells through a wrapper (e.g. maps via + * `sudo`) spawns detached, so the wrapper is a process-group leader; we signal the whole group * (-pid) so the real worker dies with it, not just the wrapper — otherwise the worker is * reparented and runs to completion in the background (the region ends up added even though the - * job was canceled). For a non-detached child (kiwix's aria2c), -pid names a group that doesn't - * exist and the group kill is a harmless no-op; the direct kill still stops it. + * job was stopped). For a non-detached child (kiwix's aria2c), -pid names a group that doesn't + * exist and the group signal is a harmless no-op; the direct signal still stops it. + * + * K2GO-317: cancel uses SIGKILL (the partial is discarded anyway); pause passes SIGTERM so the + * child flushes a complete resume checkpoint first (a SIGKILL leaves a truncated journal that + * cannot resume). A child that ignores SIGTERM is SIGKILLed after a grace, skipped when the child + * has already exited (p.exitCode/signalCode) so an already-exited process is never re-signaled. */ - private killAll(rt: { procs: Set }): void { + private killAll(rt: { procs: Set }, signal: NodeJS.Signals = 'SIGKILL'): void { for (const p of rt.procs) { - try { if (p.pid) process.kill(-p.pid, 'SIGKILL'); } catch { /* no such group / already gone */ } - try { p.kill('SIGKILL'); } catch { /* already gone */ } + try { if (p.pid) process.kill(-p.pid, signal); } catch { /* no such group / already gone */ } + try { p.kill(signal); } catch { /* already gone */ } + if (signal !== 'SIGKILL') { + // Graceful SIGTERM above let the child flush its resume checkpoint. If it is still + // running after the grace, SIGKILL the group. p.exitCode/signalCode is Node's own + // record of whether this child exited, so a finished child is never re-signaled (no + // reused-PID hit); the group kill is a caught no-op if the group is already gone. + setTimeout(() => { + if (p.exitCode !== null || p.signalCode !== null) return; + try { if (p.pid) process.kill(-p.pid, 'SIGKILL'); } catch { /* gone */ } + try { p.kill('SIGKILL'); } catch { /* gone */ } + }, PAUSE_GRACE_MS).unref(); + } } } diff --git a/static/dashboard/sockets/maps.exec.ts b/static/dashboard/sockets/maps.exec.ts index f3a94a65a..a4755c854 100644 --- a/static/dashboard/sockets/maps.exec.ts +++ b/static/dashboard/sockets/maps.exec.ts @@ -36,13 +36,15 @@ function runMapsCmd(args: string[], log: (l: string) => void): Promise { } /** ADFA-4896: remove a region's half-written pmtiles left in TMP_DIR by a killed extract — the - * script's `delete` doesn't touch this dir. Name-scoped (`*.full-region..pmtiles`), so a - * concurrent extract for another region is untouched. Best-effort. */ + * script's `delete` doesn't touch this dir. Name-scoped (`*.full-region..pmtiles` and its + * K2GO-317 `.resume` journal), so a concurrent extract for another region is untouched. Best-effort. */ function cleanupTmpPartials(name: string, log: (l: string) => void): void { try { - const suffix = `.full-region.${name}.pmtiles`; + const base = `.full-region.${name}.pmtiles`; for (const f of fs.readdirSync(MAPS_TMP_DIR)) { - if (f.endsWith(suffix)) { + // K2GO-317: the partial AND its sibling resume journal (.resume). The journal + // alone would linger and mislead resolve_source / a later extract of the same name. + if (f.endsWith(base) || f.endsWith(base + '.resume')) { try { fs.unlinkSync(path.join(MAPS_TMP_DIR, f)); log(`[maps] pruned partial ${f}`); } catch { /* gone */ } } } @@ -69,67 +71,96 @@ const mapsRunner: (ctx: RunnerContext) => Promise = async (ctx) => { if (!parsed.ok) throw new Error(parsed.error); ctx.update({ phase: 'processing', percent: -1, detail: name }); - const args = [EXTRACT_SCRIPT, 'extract', name, parsed.box, 'noninteractive']; + // K2GO-317: DashNode opts into the custom build's integrator mode. sudo resets the environment + // (Defaults env_reset, only debian_chroot kept), so the vars are passed THROUGH sudo with `env`, + // not on the node-side spawn env (which sudo would strip). json = the machine-readable progress + // parsed below; 8 = the measured throughput knee (tunable upstream via IIAB_PMTILES_DOWNLOAD_THREADS). + const args = ['env', 'IIAB_PMTILES_PROGRESS=json', 'IIAB_PMTILES_DOWNLOAD_THREADS=8', + 'PYTHONUNBUFFERED=1', EXTRACT_SCRIPT, 'extract', name, parsed.box, 'noninteractive']; - // ADFA-4879: tile-extract.py runs three sequential pmtiles extracts (vector, satellite, - // terrain). Each prints an in-layer bar ("fetching chunks NN%") and ends with one - // "Extract transferred ... archive size of ..." line. We derive an OVERALL percent from the - // completed-layer count refined by the current layer's %, so the bar climbs smoothly 0..100 - // instead of resetting three times. If the in-layer % doesn't survive the non-TTY pipe, the - // layer-count alone still advances it (0 -> 33 -> 66), and the runner sets 100 on success. + // tile-extract.py runs three sequential pmtiles extracts (vector, satellite, terrain), each with + // --progress=json (go-pmtiles states commit): one JSON line per second on stdout carrying + // bytes_done/bytes_total, speed_bps, eta_s and a per-extract phase (plan|download|finalize|done). + // We derive ONE overall percent = (completed layers + the current layer's byte fraction) / 3, so + // the bar climbs 0..100 ONCE instead of the old per-layer 0->100 reset (the "dancing" bar). A layer + // is complete on its "phase":"done" line. The within-layer fraction is the byte ratio (not a + // scraped text percent), so it is smooth too. A stop (pause/cancel) is detected from the engine's + // job flags (classifyStop in the catch below), not the JSON; the pmtiles exit code is only a + // secondary signal for a resumable stop that was not engine-initiated. speed and eta come from JSON. const TOTAL_LAYERS = 3; - const RATE: Record = { B: 1, kB: 1e3, MB: 1e6, GB: 1e9 }; // pmtiles unit -> bytes let layersDone = 0; - let lastPct = 0; + let overall = 0; // monotonic across the whole FQR await new Promise((resolve, reject) => { // ADFA-4896: detached so `sudo` is a process-group leader and the engine's group-kill takes // the python worker down with it (otherwise pause/cancel only kill the sudo wrapper and the // extract finishes in the background). - const p = ctx.spawn('sudo', args, { env: { ...process.env, PYTHONUNBUFFERED: '1' }, detached: true }); - const onData = (buf: Buffer) => { - const text = buf.toString(); - ctx.log(text.trim()); - const pm = [...text.matchAll(/fetching chunks\s+(\d+)\s*%/g)]; - if (pm.length) lastPct = parseInt(pm[pm.length - 1][1], 10); - const completed = (text.match(/Extract transferred/g) || []).length; - if (completed) { layersDone = Math.min(TOTAL_LAYERS, layersDone + completed); lastPct = 0; } - const overall = Math.min(99, Math.round((layersDone * 100 + lastPct) / TOTAL_LAYERS)); + const p = ctx.spawn('sudo', args, { env: { ...process.env }, detached: true }); + // pmtiles writes the JSON progress on stdout, one line per second. A 'data' chunk can split a + // line across two events, so buffer stdout and act only on complete '\n'-terminated lines: an + // unbuffered split would drop that second's progress. stderr carries pmtiles/script logs (never + // the JSON), so it is logged as-is. + const handleLine = (line: string) => { + const s = line.trim(); + if (!s) return; + ctx.log(s); + if (!s.startsWith('{') || s.indexOf('"phase"') < 0) return; + let j: { phase?: string; bytes_done?: number; bytes_total?: number; speed_bps?: number; eta_s?: number | null }; + try { j = JSON.parse(s); } catch { return; } + if (typeof j.phase !== 'string') return; + let frac = 0; + if (j.phase === 'download' && (j.bytes_total ?? 0) > 0) { + frac = Math.min(1, (j.bytes_done ?? 0) / (j.bytes_total as number)); + } else if (j.phase === 'finalize' || j.phase === 'done') { + frac = 1; + } + overall = Math.max(overall, Math.min(99, Math.round(((layersDone + frac) / TOTAL_LAYERS) * 100))); const patch: JobUpdate = { phase: 'processing', percent: overall, detail: name }; - // pmtiles prints a live transfer rate ("2.4 MB/s"); surface the latest as job.speed - // (bytes/sec) so the app can show it. Omitted when absent, so it isn't reset to 0. - const sm = [...text.matchAll(/([\d.]+)\s*(B|kB|MB|GB)\/s/g)]; - if (sm.length) { const last = sm[sm.length - 1]; patch.speed = Math.round(parseFloat(last[1]) * (RATE[last[2]] ?? 1)); } + if (typeof j.speed_bps === 'number' && j.speed_bps > 0) patch.speed = j.speed_bps; + if (typeof j.eta_s === 'number' && j.eta_s >= 0) patch.eta = Math.round(j.eta_s); ctx.update(patch); + if (j.phase === 'done') layersDone = Math.min(TOTAL_LAYERS, layersDone + 1); }; - p.stdout?.on('data', onData); - p.stderr?.on('data', onData); + let stdoutBuf = ''; + p.stdout?.on('data', (buf: Buffer) => { + stdoutBuf += buf.toString(); + let nl: number; + while ((nl = stdoutBuf.indexOf('\n')) >= 0) { + handleLine(stdoutBuf.slice(0, nl)); + stdoutBuf = stdoutBuf.slice(nl + 1); + } + }); + p.stderr?.on('data', (buf: Buffer) => { const t = buf.toString().trim(); if (t) ctx.log(t); }); p.on('error', reject); p.on('exit', (code, signal) => { if (code === 0) return resolve(); - // Non-zero or killed: could be a pause/cancel (group-killed by the engine) or a real - // failure. The catch below classifies it via classifyStop(ctx); this message is only - // surfaced for a genuine error. + // Non-zero or killed: an engine pause/cancel (group-killed) or tile-extract's own exit. + // The stop kind is read from the engine's job flags (classifyStop) in the catch, not from + // this code: tile-extract.py remaps every non-zero child exit to 1 (see the catch note). reject(new Error(signal ? `tile-extract killed by ${signal}` : `tile-extract exited with code ${code}`)); }); }).catch(async (e: unknown) => { - // ADFA-4896: any non-success purges the partial (no maps checkpoint -> never keep half-work), - // then re-throws as pause/cancel/error so the engine records the right terminal phase. On - // pause the job is resumable (resume re-extracts clean); on cancel the region is gone. - await purgeRegion(name, ctx.log); + // K2GO-317: the custom go-pmtiles build keeps an on-disk resume journal (.resume, the + // single owner of the checkpoint), so an engine-initiated PAUSE keeps the partial + journal and + // re-running tile-extract resumes from the checkpoint instead of re-extracting from 0. A cancel, + // or any other exit, purges. + // NOTE: tile-extract.py raises on a failed run, so it exits 1 for every pmtiles failure. A + // non-engine resumable stop (pmtiles exit 2 network-gave-up / 3 self-paused) is therefore + // indistinguishable here from a hard failure and is purged. Preserving those needs the script + // to propagate the child exit code; tracked as a follow-up. const stop = classifyStop(ctx); - if (stop === 'paused') throw new PausedError(); - if (stop === 'canceled') throw new CanceledError(); + if (stop === 'canceled') { await purgeRegion(name, ctx.log); throw new CanceledError(); } + if (stop === 'paused') throw new PausedError(); // keep partial: resume continues + await purgeRegion(name, ctx.log); // not an engine stop: purge and surface throw e; }); - // A stop can race in exactly as the extract finishes (code 0). Honor it — purge and stop — - // rather than marking done, so a paused/canceled job never leaves a completed region behind. + // A stop can race in exactly as the extract finishes (code 0). Honor it rather than marking + // done. A cancel purges; a pause keeps the finished partial (resume then re-runs and completes + // at once — the journal was already removed on success, so nothing half-written is left). const lateStop = classifyStop(ctx); - if (lateStop) { - await purgeRegion(name, ctx.log); - throw lateStop === 'paused' ? new PausedError() : new CanceledError(); - } + if (lateStop === 'canceled') { await purgeRegion(name, ctx.log); throw new CanceledError(); } + if (lateStop === 'paused') throw new PausedError(); ctx.update({ phase: 'done', percent: 100 }); };