diff --git a/index.html b/index.html index 35d7748..608a6b4 100644 --- a/index.html +++ b/index.html @@ -816,6 +816,21 @@ // state under run()'s own control. let solvingHere = false; + // Token identifying this tab's in-flight solve. When the tab closes mid + // solve we beacon /cancel?token=... so the server stops it and frees the + // lock immediately — reliable even behind a reverse proxy, which keeps + // the upstream socket open and so hides the disconnect from the server. + let activeToken = null; + + function cancelActiveSolve() { + if (activeToken && navigator.sendBeacon) { + navigator.sendBeacon("/cancel?token=" + encodeURIComponent(activeToken)); + } + } + // pagehide fires on tab close / navigation away (not on mere tab switch, + // so we don't cancel a solve the user backgrounds and comes back to). + window.addEventListener("pagehide", cancelActiveSolve); + async function run() { const errBox = document.getElementById("error"); const out = document.getElementById("output"); @@ -831,11 +846,14 @@ } catch (e) {errBox.textContent = e.message; pending.remove(); return;} solvingHere = true; setSolveDisabled(true, "Solving…"); + const token = (crypto.randomUUID ? crypto.randomUUID() + : String(Date.now()) + Math.random()); + activeToken = token; try { const resp = await fetch("/solve", { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({problem, max_time_seconds: time}), + body: JSON.stringify({problem, max_time_seconds: time, token}), }); if (resp.status === 429) { pending.remove(); @@ -846,7 +864,7 @@ if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;} renderSolution(data, pending); } catch (e) {pending.remove(); errBox.textContent = e.message;} - finally {solvingHere = false; setSolveDisabled(false);} + finally {solvingHere = false; activeToken = null; setSolveDisabled(false);} } // Enable/disable the Solve button with an optional note beside it. `kind` diff --git a/main.py b/main.py index 432931d..a48bc6f 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ import socket import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from urllib.parse import parse_qs, urlsplit from solve import problem_from_dict, solve, solution_to_dict @@ -31,6 +32,23 @@ INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding # the busy state by polling /status). _solve_lock = threading.Lock() +# Tracks the in-flight solve so /cancel can stop it. When a tab closes, the +# browser fires navigator.sendBeacon("/cancel?token=...") — a normal small +# request that survives the close and passes cleanly through reverse proxies, +# unlike a dropped upstream socket (which the proxy keeps alive). Guarded by +# _active_lock; only one solve runs at a time, but the lock keeps the token / +# solver handoff race-free against a concurrent /cancel. +_active_lock = threading.Lock() +_active = {"token": None, "solver": None} + + +def _stop_search(solver): + # StopSearch() exists in OR-Tools 9.x+; degrade gracefully on older builds + # (the solve then just runs to max_time_seconds). + stop = getattr(solver, "StopSearch", None) + if callable(stop): + stop() + class Handler(BaseHTTPRequestHandler): def _send(self, code, body, content_type="application/json", no_cache=False): @@ -65,7 +83,11 @@ class Handler(BaseHTTPRequestHandler): return False def do_POST(self): - if self.path != "/solve": + path = urlsplit(self.path) + if path.path == "/cancel": + self._handle_cancel(parse_qs(path.query)) + return + if path.path != "/solve": self._send(404, json.dumps({"error": "not found"})) return # Reject immediately if another viewer is already solving. @@ -73,23 +95,34 @@ class Handler(BaseHTTPRequestHandler): self._send(429, json.dumps({"error": "Another solve is already in progress"})) return released = False + token = None try: length = int(self.headers.get("Content-Length", 0)) payload = json.loads(self.rfile.read(length) or b"{}") problem = problem_from_dict(payload.get("problem", {})) max_time = float(payload.get("max_time_seconds", 30.0)) + token = str(payload.get("token") or "") + + # Mark this token as the in-flight solve so a matching /cancel beacon + # (sent when the requesting tab closes) can stop it. + with _active_lock: + _active["token"] = token + _active["solver"] = None # Run the (blocking) solve in a worker thread so this thread is free - # to watch the socket. solver_sink hands us the CpSolver so we can - # abort the search if the client disconnects. + # to watch the socket. solver_sink hands us the CpSolver so both the + # local disconnect watcher and /cancel can abort the search. result = {} - solver_box = {} + + def register(s): + with _active_lock: + if _active["token"] == token: + _active["solver"] = s def run(): try: result["sol"] = solve( - problem, max_time_seconds=max_time, - solver_sink=lambda s: solver_box.setdefault("s", s), + problem, max_time_seconds=max_time, solver_sink=register, ) except Exception as exc: result["err"] = exc @@ -98,12 +131,12 @@ class Handler(BaseHTTPRequestHandler): worker.start() while worker.is_alive(): worker.join(0.25) + # Direct connections (no proxy) still get fast release via socket + # EOF; behind a proxy the /cancel beacon is what stops the solve. if worker.is_alive() and self._client_gone(): - # Tab closed: stop the search and free the lock immediately - # so the next viewer can start. The worker unwinds on its own. - s = solver_box.get("s") - if s is not None: - s.StopSearch() + with _active_lock: + if _active["solver"] is not None: + _stop_search(_active["solver"]) _solve_lock.release() released = True return @@ -114,9 +147,25 @@ class Handler(BaseHTTPRequestHandler): except Exception as exc: # surface errors to the browser self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) finally: + with _active_lock: + if _active["token"] == token: + _active["token"] = None + _active["solver"] = None if not released: _solve_lock.release() + def _handle_cancel(self, query): + # Stop the in-flight solve iff the beacon's token matches it, so a closing + # tab can't cancel a *different* viewer's solve. The running /solve handler + # then returns normally and releases the lock. + token = (query.get("token") or [""])[0] + stopped = False + with _active_lock: + if token and token == _active["token"] and _active["solver"] is not None: + _stop_search(_active["solver"]) + stopped = True + self._send(200, json.dumps({"cancelled": stopped}), no_cache=True) + def log_message(self, fmt, *args): # quieter console pass