handling reverse proxy nuisance
This commit is contained in:
parent
8b33e0d213
commit
9fc9057dfe
2 changed files with 80 additions and 13 deletions
22
index.html
22
index.html
|
|
@ -816,6 +816,21 @@
|
||||||
// state under run()'s own control.
|
// state under run()'s own control.
|
||||||
let solvingHere = false;
|
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() {
|
async function run() {
|
||||||
const errBox = document.getElementById("error");
|
const errBox = document.getElementById("error");
|
||||||
const out = document.getElementById("output");
|
const out = document.getElementById("output");
|
||||||
|
|
@ -831,11 +846,14 @@
|
||||||
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
|
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
|
||||||
solvingHere = true;
|
solvingHere = true;
|
||||||
setSolveDisabled(true, "Solving…");
|
setSolveDisabled(true, "Solving…");
|
||||||
|
const token = (crypto.randomUUID ? crypto.randomUUID()
|
||||||
|
: String(Date.now()) + Math.random());
|
||||||
|
activeToken = token;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/solve", {
|
const resp = await fetch("/solve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
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) {
|
if (resp.status === 429) {
|
||||||
pending.remove();
|
pending.remove();
|
||||||
|
|
@ -846,7 +864,7 @@
|
||||||
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
|
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
|
||||||
renderSolution(data, pending);
|
renderSolution(data, pending);
|
||||||
} catch (e) {pending.remove(); errBox.textContent = e.message;}
|
} 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`
|
// Enable/disable the Solve button with an optional note beside it. `kind`
|
||||||
|
|
|
||||||
71
main.py
71
main.py
|
|
@ -19,6 +19,7 @@ import socket
|
||||||
import threading
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
from solve import problem_from_dict, solve, solution_to_dict
|
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).
|
# the busy state by polling /status).
|
||||||
_solve_lock = threading.Lock()
|
_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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def _send(self, code, body, content_type="application/json", no_cache=False):
|
def _send(self, code, body, content_type="application/json", no_cache=False):
|
||||||
|
|
@ -65,7 +83,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def do_POST(self):
|
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"}))
|
self._send(404, json.dumps({"error": "not found"}))
|
||||||
return
|
return
|
||||||
# Reject immediately if another viewer is already solving.
|
# 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"}))
|
self._send(429, json.dumps({"error": "Another solve is already in progress"}))
|
||||||
return
|
return
|
||||||
released = False
|
released = False
|
||||||
|
token = None
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
problem = problem_from_dict(payload.get("problem", {}))
|
problem = problem_from_dict(payload.get("problem", {}))
|
||||||
max_time = float(payload.get("max_time_seconds", 30.0))
|
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
|
# 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
|
# to watch the socket. solver_sink hands us the CpSolver so both the
|
||||||
# abort the search if the client disconnects.
|
# local disconnect watcher and /cancel can abort the search.
|
||||||
result = {}
|
result = {}
|
||||||
solver_box = {}
|
|
||||||
|
def register(s):
|
||||||
|
with _active_lock:
|
||||||
|
if _active["token"] == token:
|
||||||
|
_active["solver"] = s
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
result["sol"] = solve(
|
result["sol"] = solve(
|
||||||
problem, max_time_seconds=max_time,
|
problem, max_time_seconds=max_time, solver_sink=register,
|
||||||
solver_sink=lambda s: solver_box.setdefault("s", s),
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result["err"] = exc
|
result["err"] = exc
|
||||||
|
|
@ -98,12 +131,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
worker.start()
|
worker.start()
|
||||||
while worker.is_alive():
|
while worker.is_alive():
|
||||||
worker.join(0.25)
|
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():
|
if worker.is_alive() and self._client_gone():
|
||||||
# Tab closed: stop the search and free the lock immediately
|
with _active_lock:
|
||||||
# so the next viewer can start. The worker unwinds on its own.
|
if _active["solver"] is not None:
|
||||||
s = solver_box.get("s")
|
_stop_search(_active["solver"])
|
||||||
if s is not None:
|
|
||||||
s.StopSearch()
|
|
||||||
_solve_lock.release()
|
_solve_lock.release()
|
||||||
released = True
|
released = True
|
||||||
return
|
return
|
||||||
|
|
@ -114,9 +147,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
except Exception as exc: # surface errors to the browser
|
except Exception as exc: # surface errors to the browser
|
||||||
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
||||||
finally:
|
finally:
|
||||||
|
with _active_lock:
|
||||||
|
if _active["token"] == token:
|
||||||
|
_active["token"] = None
|
||||||
|
_active["solver"] = None
|
||||||
if not released:
|
if not released:
|
||||||
_solve_lock.release()
|
_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
|
def log_message(self, fmt, *args): # quieter console
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue