diff --git a/CHANGELOG.md b/CHANGELOG.md index d502592..7f1fd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ This file owns the release history for `ScanLink`. +## v0.8.0b1 + +- **Atomic PDF Completion**: Keep child completion provisional until the process exits and the parent stores result bytes; publish terminal errors with their details. +- **Parent Scan Deadline**: Enforce the configured scan deadline even when the child keeps reporting progress, independently of GUI and idle limits. +- **Scan Payload Validation**: Reject non-object JSON, invalid metadata/callback types and non-JSON protocol requests before queueing hardware work. +- **Single-Use Result Metadata**: Free consumed PDF bytes immediately while retaining job metadata until its TTL so racing downloads consistently receive 410. +- **One Scan POST Per Attempt**: Negotiate transport through health checks and report `scan_request_uncertain` after a lost scan response instead of resubmitting over another transport. +- **Real Worker Regressions**: Exercise actual child-process completion, parent timeout enforcement, malformed requests and competing PDF downloads with only the scanner child simulated. + ## v0.7.5b1 - **Windows x86 Crypto Wheels**: Resolve cryptography from compatible binary wheels during executable builds, avoiding an unsupported latest-source Rust/OpenSSL compilation on 32-bit Python. diff --git a/VERSION b/VERSION index 728cb3b..55d8321 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.5b1 +0.8.0b1 diff --git a/docs/api.md b/docs/api.md index 10754c6..a474977 100644 --- a/docs/api.md +++ b/docs/api.md @@ -84,6 +84,8 @@ Errors: | HTTP | Code | Meaning | | --- | --- | --- | | 400 | `invalid_json` | Body could not be parsed as JSON. | +| 400 | `invalid_payload` | JSON root was not an object. | +| 400 | `invalid_callback` | Callback URL was not a string or could not be parsed. | | 400 | `invalid_metadata` | `metadata` was provided but is not an object. | | 409 | `busy` | Another scan job is queued or running. | | 415 | `json_required` | Request did not use JSON. | @@ -144,7 +146,7 @@ Errors: ## GET /scan//result -Downloads the scanned PDF for a completed job. +Downloads the scanned PDF for a completed job. `completed` is published only after the child has exited and the parent has stored the PDF. Concurrent downloads are single-use: exactly one receives the bytes; later requests return `410` until the job metadata expires. PDF bytes are freed immediately after retrieval, while metadata remains until `job_ttl_seconds` from completion. Response: @@ -158,10 +160,12 @@ Errors: | --- | --- | --- | | 404 | `job_not_found` | Job does not exist or expired. | | 409 | `result_not_ready` | Job is not completed yet. | -| 410 | `result_unavailable` | Result was already consumed or expired. | +| 410 | `result_unavailable` | Result was already consumed. | ## POST /internal/protocol/scan +Requires an `application/json` object and applies the same metadata/callback validation as `/scan`; malformed requests never queue a scan. The optional `action` must be `scan`. + Internal endpoint used by `scanlink://` launches to forward commands into an already-running ScanLink instance. Application integrations should normally use `POST /scan`. ## POST /shutdown @@ -215,3 +219,11 @@ runs, `ready` after trusted HTTPS starts, and `failed` after a setup/listener failure. Existing `http_enabled` and `https_enabled` remain listener flags. Older clients may ignore the new field; newer clients must tolerate its absence on older helpers. Existing HTTP scan jobs remain usable while setup runs. + +## Browser transport retries + +The bundled JavaScript client negotiates a healthy transport before its first scan. +A scan POST is sent once on that transport. If the response or its body is lost, +it rejects with `scan_request_uncertain` instead of resending the request over +the other transport; the helper may already be scanning. Check ScanLink before +starting another scan. Health and status reads retain transport fallback. diff --git a/docs/architecture.md b/docs/architecture.md index 07989c3..274d5a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -204,3 +204,16 @@ When launched with a `scanlink://` URL: 4. If no instance is running, the new process becomes the tray instance and queues the protocol scan after startup. This prevents duplicate tray apps and competing scanner sessions. + +## Worker completion and deadlines + +Child progress is provisional. A child `completed` update remains +`generating_pdf` to API clients until the process exits and the parent stores the +PDF atomically with the final `completed` status. Child failure states are also +published by the parent with their error details after process exit. + +The parent enforces both `scan_timeout_seconds` and `gui_task_timeout_seconds`; +the first enabled deadline terminates the child, even if it keeps reporting +progress. Idle timeout remains an independent progress guard. Downloading frees +the PDF immediately and retains consumed-job metadata until the normal job TTL, +so repeated or racing downloads report `410` while that metadata exists. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index a616cbd..3bd3c80 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -215,3 +215,12 @@ HTTP remains available during setup; clients should not block working HTTP scans while waiting for HTTPS. A native Retry dialog and tray repair action handle failed setup without a command prompt. See the architecture guide for DPAPI-protected keys, trust verification, hourly checks and renewal. + +### Lost scan responses + +The JavaScript client health-checks a transport before scanning and never +automatically retries a scan POST. Handle `scan_request_uncertain` by asking the +operator to check the tray app before starting another scan. A lost response can +mean the request was accepted; retrying automatically could trigger another +scanner dialog. Both public and protocol scan endpoints require a JSON object +and validate metadata/callback types before queueing hardware work. diff --git a/server.py b/server.py index 1b51325..1f2a6fc 100644 --- a/server.py +++ b/server.py @@ -3,6 +3,7 @@ import ssl import threading import time +from urllib.parse import urlparse from flask import Flask, Response, jsonify, request, send_file from werkzeug.serving import make_server @@ -56,6 +57,42 @@ def error_response(status_code, code, message, **extra): response.status_code = status_code return apply_cors(response) + def scan_payload(): + if not request.is_json: + return None, error_response( + 415, "json_required", "Scan requests must use an application/json body." + ) + data = request.get_json(silent=True) + if data is None: + return None, error_response( + 400, "invalid_json", "Request body could not be parsed as JSON." + ) + if not isinstance(data, dict): + return None, error_response( + 400, "invalid_payload", "Scan requests must contain a JSON object." + ) + if data.get("metadata") is not None and not isinstance(data["metadata"], dict): + return None, error_response( + 400, "invalid_metadata", "metadata must be a JSON object when provided." + ) + for name in ("callback_url", "callback"): + if data.get(name) is not None and not isinstance(data[name], str): + return None, error_response( + 400, + "invalid_callback", + "callback_url must be a string when provided.", + ) + if isinstance(data.get(name), str): + try: + urlparse(data[name]) + except ValueError: + return None, error_response( + 400, + "invalid_callback", + "callback_url could not be parsed as a URL.", + ) + return data, None + @app.after_request def add_cors_headers(response): if is_browser_endpoint(request.path): @@ -88,22 +125,9 @@ def start_scan(): if request.method == "OPTIONS": return empty_options_response() - if not request.is_json: - return error_response( - 415, "json_required", "Scan requests must use an application/json body." - ) - - data = request.get_json(silent=True) - if data is None: - return error_response( - 400, "invalid_json", "Request body could not be parsed as JSON." - ) - - metadata = data.get("metadata") - if metadata is not None and not isinstance(metadata, dict): - return error_response( - 400, "invalid_metadata", "metadata must be a JSON object when provided." - ) + data, error = scan_payload() + if error is not None: + return error doc_id = data.get("doc_id") or data.get("docId") callback_url = data.get("callback_url") or data.get("callback") @@ -112,7 +136,7 @@ def start_scan(): job = worker.submit_job( doc_id=doc_id, callback_url=callback_url, - metadata=metadata, + metadata=data.get("metadata"), source="api", ) except BusyError as exc: @@ -176,7 +200,9 @@ def get_scan_result(job_id): @app.route("/internal/protocol/scan", methods=["POST"]) def internal_protocol_scan(): - data = request.get_json(silent=True) or {} + data, error = scan_payload() + if error is not None: + return error if data.get("action", "scan") != "scan": return ( jsonify( diff --git a/static/scanlink.js b/static/scanlink.js index 4ff931d..26101fc 100644 --- a/static/scanlink.js +++ b/static/scanlink.js @@ -86,6 +86,9 @@ class ScanLink { if (options.metadata && typeof options.metadata === "object") payload.metadata = options.metadata; try { + if (!this.preferredUrl && !await this.checkHealth()) { + throw this.connectionError; + } const response = await this._fetchResponse("/scan", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -97,7 +100,13 @@ class ScanLink { throw await this._responseToError(response, "Failed to start scan."); } - const data = await response.json(); + let data; + try { + data = await response.json(); + if (!data || !data.job_id) throw new Error("Missing scan job ID"); + } catch (error) { + throw this._uncertainRequest(error); + } this.currentJobId = data.job_id; this.onStatus(data); return data; @@ -215,8 +224,14 @@ class ScanLink { window.location.href = url; } + _uncertainRequest(cause) { + return new ScanLinkError("The scan request lost its connection. Check ScanLink before starting another scan.", "scan_request_uncertain", { cause }); + } + async _fetchResponse(path, options = {}) { - const candidates = this._candidateUrls(false); + const readOnly = ["GET", "HEAD"].includes((options.method || "GET").toUpperCase()); + const urls = this._candidateUrls(false); + const candidates = readOnly ? urls : urls.slice(0, 1); let lastError = null; for (const baseUrl of candidates) { @@ -231,7 +246,7 @@ class ScanLink { } this.onConnectionChange(false); - this.connectionError = this._connectionFailure(lastError); + this.connectionError = readOnly ? this._connectionFailure(lastError) : this._uncertainRequest(lastError); throw this.connectionError; } diff --git a/tests/fixtures/scan_child.py b/tests/fixtures/scan_child.py new file mode 100644 index 0000000..6528a30 --- /dev/null +++ b/tests/fixtures/scan_child.py @@ -0,0 +1,26 @@ +"""Hardware boundary fixture: the parent worker, process lifecycle and API stay real.""" + +import json +from pathlib import Path +import sys +import time + +directory = Path(sys.argv[1]) +scenario = sys.argv[2] + + +def state(status, **extra): + pending = directory / "state.tmp" + pending.write_text(json.dumps({"status": status, **extra})) + pending.replace(directory / "state.json") + + +state("acquiring", page_count=0) +if scenario == "heartbeat": + for count in range(100): + state("page_scanned", page_count=count) + time.sleep(0.1) +else: + (directory / "result.pdf").write_bytes(b"%PDF-1.4\nregression\n") + state("completed", page_count=1) + time.sleep(0.6) diff --git a/tests/test_stress_regressions.py b/tests/test_stress_regressions.py new file mode 100644 index 0000000..36de6b5 --- /dev/null +++ b/tests/test_stress_regressions.py @@ -0,0 +1,134 @@ +import concurrent.futures +import json +from pathlib import Path +import queue +import sys +import threading +import tempfile +import time +import unittest +import uuid +from unittest.mock import patch + +from server import create_app +from worker import ScanWorker, TERMINAL_STATUSES + + +class RealWorkerRegressions(unittest.TestCase): + def setUp(self): + self.artifacts = ( + Path(__file__).resolve().parents[1] + / ".xclude" + / "worker-tests" + / uuid.uuid4().hex + ) + self.artifacts.mkdir(parents=True) + self.worker = ScanWorker(queue.Queue()) + self.worker.scan_idle_timeout_seconds = 0 + self.worker._build_scan_subprocess_command = lambda directory: [ + sys.executable, + str(Path(__file__).with_name("fixtures") / "scan_child.py"), + directory, + self.scenario, + ] + self.scenario = "complete" + make_directory = tempfile.mkdtemp + self.temporary = patch( + "worker.tempfile.mkdtemp", + side_effect=lambda **options: make_directory(dir=self.artifacts, **options), + ) + self.temporary.start() + self.cleanup = patch("worker.shutil.rmtree", side_effect=self.preserve) + self.cleanup.start() + self.app = create_app(self.worker, threading.Event(), {"http_enabled": True}) + self.client = self.app.test_client() + self.worker.start() + + def preserve(self, directory, **kwargs): + source = Path(directory) + self.assertEqual(source.parent, self.artifacts) + + def tearDown(self): + self.worker.stop() + self.worker.join(6) + self.cleanup.stop() + self.temporary.stop() + self.assertFalse(self.worker.is_alive()) + + def finish(self, job_id): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + job = self.worker.get_job(job_id) + if job["status"] in TERMINAL_STATUSES: + return job + time.sleep(0.01) + self.fail("Worker did not finish") + + def test_completion_requires_pdf_available_and_child_exited(self): + job_id = self.client.post("/scan", json={}).get_json()["job_id"] + self.assertEqual(self.finish(job_id)["status"], "completed") + self.assertTrue( + self.worker.active_scan_process is None + or self.worker.active_scan_process.poll() is not None + ) + self.assertEqual(self.client.get(f"/scan/{job_id}/result").status_code, 200) + + def test_parent_scan_deadline_applies_despite_child_progress(self): + self.scenario = "heartbeat" + self.worker.scan_timeout_seconds = 1 + self.worker.gui_task_timeout_seconds = 3 + started = time.monotonic() + job = self.finish(self.client.post("/scan", json={}).get_json()["job_id"]) + self.assertEqual(job["status"], "timed_out") + self.assertEqual(job["error"]["code"], "scan_timeout") + self.assertLess(time.monotonic() - started, 2) + + def test_parallel_result_downloads_consume_pdf_once(self): + job_id = self.client.post("/scan", json={}).get_json()["job_id"] + self.finish(job_id) + self.worker.scan_queue.join() + + def download(_): + with self.app.test_client() as client: + return client.get(f"/scan/{job_id}/result").status_code + + with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool: + statuses = list(pool.map(download, range(24))) + self.assertEqual(statuses.count(200), 1) + self.assertEqual(statuses.count(410), 23) + self.assertIsNone(self.worker.jobs[job_id]["result_bytes"]) + self.worker.job_ttl_seconds = 0 + self.assertIsNone(self.worker.get_job(job_id)) + + def test_invalid_requests_never_start_hardware(self): + for endpoint in ("/scan", "/internal/protocol/scan"): + for value in ( + [], + True, + 42, + "bad", + {"metadata": []}, + {"callback_url": True}, + {"callback_url": "http://["}, + {"callback": "http://["}, + ): + with self.subTest(endpoint=endpoint, value=value): + response = self.client.post( + endpoint, + data=json.dumps(value), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + self.client.post( + endpoint, data="not-json", content_type="text/plain" + ).status_code, + 415, + ) + self.assertEqual( + self.client.post( + endpoint, data="{", content_type="application/json" + ).status_code, + 400, + ) + self.assertEqual(self.worker.jobs, {}) diff --git a/tests/test_worker.py b/tests/test_worker.py index b97b2af..642a4b9 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -29,9 +29,11 @@ def test_submit_job_rejects_second_active_job(self): def test_process_job_marks_completion_and_stores_result(self): worker = ScanWorker(queue.Queue()) - worker._run_scan_subprocess = lambda job_id, on_progress: FakeScannerManager().scan_to_pdf( - job_id, - progress_callback=on_progress, + worker._run_scan_subprocess = ( + lambda job_id, on_progress: FakeScannerManager().scan_to_pdf( + job_id, + progress_callback=on_progress, + ) ) queued = worker.submit_job(doc_id="abc") @@ -46,7 +48,7 @@ def test_process_job_marks_completion_and_stores_result(self): self.assertEqual(completed_job["status"], "completed") self.assertEqual(result_bytes, b"%PDF-1.4\nfake\n") - def test_consumed_result_is_cleaned_up(self): + def test_consumed_result_keeps_metadata_until_job_ttl(self): worker = ScanWorker(queue.Queue()) now = time.time() worker.jobs["job-1"] = { @@ -68,6 +70,9 @@ def test_consumed_result_is_cleaned_up(self): _job, result_bytes = worker.consume_result("job-1") self.assertEqual(result_bytes, b"pdf") + self.assertIsNone(worker.jobs["job-1"]["result_bytes"]) + self.assertEqual(worker.get_job("job-1")["status"], "completed") + worker.job_ttl_seconds = 0 self.assertIsNone(worker.get_job("job-1")) diff --git a/tracker.md b/tracker.md index ecc40fd..71ed4fc 100644 --- a/tracker.md +++ b/tracker.md @@ -2,13 +2,14 @@ ## Part 1: Project-specific ### Current Snapshot -- Patch candidate 0.7.5b1 on `0.7/automatic_https`, checkout `app-scanlink/automatic_https`; base main 4bf0c05. Latest verified tag v0.7.4. Owner authorized beta publication and testbed stress acceptance on 2026-10-25; PR/Windows gates pending. +- Unpublished candidate 0.8.0b1 complete on `0.8/stress_fixes` (2026-10-25): real worker completion/deadline, public/internal input validation, single POST transport behavior and result-race fixes. Published0.7.5b1 (tagd74bf90) and stable Latest0.7.4 unchanged; no push/tag/publication. - Windows tray app uses TWAIN/pytwain; scan jobs run through child `main.py --scan-worker-dir` / `scan_runtime.py`. - Normal startup serves HTTP :5000 while automatically provisioning trusted localhost HTTPS :5443; failed setup offers native Retry and a tray repair action. - `certificates.py`: per-user localhost-only self-signed server identity, Windows CurrentUser Root trust, encrypted PKCS#8 key with DPAPI-protected password; hourly checks/30-day renewal and live TLS context selection. -- Paired with DjangoLux 1.15.1b1 (`1.15/scanlink_https`); REST remains compatible with older clients. New optional health field `https_setup` = preparing/ready/failed. +- Candidate pairs with DjangoLux1.15.1b2 (`1.15/scanlink_stress`); existing REST clients/helpers remain compatible. Published testbed server stays1.15.1b1; candidate browser assets/template fixture were tested through routing. ### Adopted Standards & Rules +- Owner decision 2026-10-25: the next ScanLink beta targets 0.8.0b1, incorporating any findings from further paired-beta/hardware stress testing. Keep published 0.7.5b1 unchanged; start follow-up implementation on a fresh 0.8 workstream from verified main. - One patch/workstream per release-line branch; VERSION is the version source. Do not tag or publish without owner approval. - App data/config/logs live under `%LOCALAPPDATA%\ScanLink`; identity pointer is `https/active.json`, immutable bundles `https//`, superseded identities `.xclude/https//`. - Certificate covers only localhost/127.0.0.1/::1, CA=False/serverAuth/no certificate-signing usage; no downloaded/shared CA or machine-wide trust. Existing mkcert credentials stay untouched. @@ -16,14 +17,16 @@ - Never delete workspace files; relocate under `.xclude/` preserving relative paths. Match executable architecture to the TWAIN driver. ### Known Bugs & Unsolved Issues -- Windows CryptoAPI/DPAPI, x86/x64 packaged startup and browser trust require real Windows acceptance; local host is macOS. CI installed-app smoke now requires normal verified HTTPS and identity reuse after restart. +- Six stress defects fixed locally: premature completion/PDF unavailable, parent deadline ignored, malformed public payload500/callback parsing, non-JSON protocol requests queueing, ambiguous POST retries, consumed results returning404. Candidate Windows CI/hardware remains pending. +- Windows x64/x86 unit/build/install gates passed, including real certificate consent, DPAPI/trust, 120 verified HTTPS requests per architecture and restart identity reuse. Real Windows browser/scanner and enterprise-policy acceptance remain manual. - Target PaperStream fi-6230Z may expose only 32-bit TWAIN; live driver compliance/output quality remain unverified. Back-only and blank-page discard remain unsupported. - Organization/browser policies may refuse user-installed trust. A denied setup is reported as failure and retried with the same identity, without repeated automatic dialogs. ### Active & Pending Tasks -- [ ] Run x86/x64 Windows installer smoke for 0.7.5b1; verify first launch, CurrentUser-only trust, DPAPI key, restart, policy denial and Retry with the real DjangoLux client. +- [x] Paired deeper stress and real-worker regressions pass; candidate0.8.0b1 prepared locally. No new publication authorized this turn. +- [x] Windows x64/x86 installed-app gates and normally verified HTTPS load passed before and during beta publication; stable Latest preserved. - [ ] Verify 0.7.5b1 with target scanner hardware; preserve ADF/front/200 DPI/color/paper protection off defaults. -- [ ] Rebase/integrate the paired DjangoLux patch around the open 1.15 release; owner approval required for publication. +- [x] Paired DjangoLux 1.15.1b1 published (PR14, 3bda457); installed on testbed-dlux-ocr :8097 through Composer, rollback/reapply and published-file checks passed. Real installers are distributed there. - [ ] Archive-hosted helper update path and detailed TWAIN capability diagnostics remain separate work. ### Completed Recently - [x] Automatic setup/repair/renewal and plain native recovery; removed optional mkcert packaging requirement and retained `--generate-ssl` for support compatibility. @@ -31,8 +34,8 @@ - [x] Verified real TLS handshakes, hostname rejection and live identity renewal; setup failure preserves HTTP and Retry recovers HTTPS. ### Validation & Docs Log -- 2026-10-25: exact unittest discovery 36 tests OK/1 Windows DPAPI skip; paired JS 12 scenarios; Chrome155 HTTPS/local-HTTP fallback and insecure-LAN guidance pass; Win32 cryptography wheel resolves. Windows installer build/smoke pending (Mac build wrapper cannot use Windows Scripts/python.exe). -- 2026-10-25: README, architecture, API, developer/build guides and CHANGELOG v0.7.5b1 describe automatic setup, protected identities, renewal, retry and compatibility. +- 2026-10-25: candidate40 unit tests OK/1 Windows skip; pairedJS24 PASS; real worker1000 health (500 verified TLS)/100 hashed PDFs/50 busy/40 result races PASS; timeouts/crash/cancel/callback/shutdown recovery PASS.8097 UI300 health/30 scans/20 simultaneous, PDF save/Arabic lost-response guidance/one POST PASS. Dlux3064 OK/6skips +79 focused; doctor0error. Published native Windows gates passed previously; candidate native/hardware unverified. +- 2026-10-25: API/architecture/developer guide, VERSION0.8.0b1 and CHANGELOG document parent-owned completion, hard deadline, strict payloads, retained metadata and scan_request_uncertain. Local report `.xclude/validation/paired-stress-2026-10-25.md`. ## Part 2: Global & Handoff ### Global Helpers & Rules @@ -40,5 +43,5 @@ - Test stacks belong under `~/Desktop/depy/testbed-*`, use Composer `--dev` and application admin/db123123; preserve testbed-dlux. ### Agent Handoff - Both patch checkouts are authorized by the owner from the app-mock-pdf chat; preserve that checkout's unrelated uncommitted changes. -- PR1 Windows binaries built; initial installed-app gates stalled at first-launch trust. Added scoped native Yes-prompt handling and 120 verified HTTPS stress requests; rerun pending. Do not claim native/browser trust was verified on this Mac. Beta publication and testbed stress testing authorized on 2026-10-25; preserve stable releases and existing testbed data. +- Deeper stress complete;0.8.0b1/paired1.15.1b2 are local candidates only. Next needs owner-authorized push/release and native Windows candidate gates; prior PR14 bypass applies only to the published beta. Temporary real-worker helper shut down,8097 published server/data preserved (synthetic60 now7 pages);8088/8096 untouched. Preserve app-mock-pdf dirty changes. - Evidence: `.xclude/validation/`; paired Chrome evidence is in `pkg-django-lux/scanlink_https/.xclude/validation/chrome-loopback.log`. diff --git a/worker.py b/worker.py index c647cca..7688e11 100644 --- a/worker.py +++ b/worker.py @@ -170,12 +170,12 @@ def _process_job(self, job_id): logger.info("Processing job %s", job_id) emit_status(job_id, "processing") - scan_deadline = None - if self.scan_timeout_seconds > 0: - scan_deadline = time.monotonic() + self.scan_timeout_seconds - try: def on_progress(status, **data): + if status == "completed": + status = "generating_pdf" + elif status in TERMINAL_STATUSES: + return updates = {"status": status, "updated_at": time.time()} if "page_count" in data: updates["page_count"] = data["page_count"] @@ -304,7 +304,10 @@ def _run_scan_subprocess(self, job_id, on_progress): break now = time.monotonic() - if self.gui_task_timeout_seconds > 0 and now - started_at > self.gui_task_timeout_seconds: + if any( + limit > 0 and now - started_at >= limit + for limit in (self.scan_timeout_seconds, self.gui_task_timeout_seconds) + ): self._terminate_scan_process(process) raise ScanProcessTimeoutError( "scan_timeout", @@ -472,13 +475,6 @@ def _cleanup_expired_jobs_locked(self): completed_at = job.get("completed_at") or job.get("updated_at") or now age_seconds = now - completed_at - result_missing = job.get("result_bytes") is None - result_retrieved_at = job.get("result_retrieved_at") - - if result_missing and result_retrieved_at: - expired_job_ids.append(job_id) - continue - ttl_seconds = self.job_ttl_seconds if status == "completed" and job.get("result_bytes") is not None: ttl_seconds = self.result_ttl_seconds