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