@@ -341,10 +341,13 @@ class ReportServer(HTTPServer):
341341 (payload included) is built once and cached; /api/reload re-reads the stores
342342 and invalidates it -- wired to the page's refresh button.
343343
344- Deliberately single-threaded: the stores' sqlite connections are bound to the
345- thread that created them (check_same_thread), and every request is fast (the
346- page is cached, the extras are the same cheap per-session queries the TUI runs
347- on drill-in), so serializing a single local user's requests costs nothing."""
344+ Deliberately single-threaded: the stores forbid concurrent access to a
345+ connection, and every request is fast (the page is cached, the extras are the
346+ same cheap per-session queries the TUI runs on drill-in), so serializing a
347+ single local user's requests on one serve thread costs nothing. serve_command
348+ runs serve_forever on a background thread (all requests still one at a time on
349+ it) so the main thread can catch a Ctrl-C that Windows won't deliver to
350+ serve_forever's select()."""
348351
349352 def __init__ (self , address : tuple [str , int ], app : App ):
350353 super ().__init__ (address , _Handler )
@@ -393,19 +396,33 @@ def serve_command(app: App, args: argparse.Namespace) -> int:
393396 host = "localhost" if bind in ("127.0.0.1" , "::1" ) else bind
394397 url = f"http://{ host } :{ server .server_address [1 ]} /"
395398 print (f"opentab report at { url } (Ctrl-C to stop)" )
399+ import threading
400+
396401 if getattr (args , "web" , False ):
397402 # --web: pop the browser now the socket is listening (bound in __init__, so a
398403 # request racing serve_forever just queues in the backlog). In a daemon thread
399404 # so a console-browser fallback that runs in the foreground can't block
400405 # serve_forever; it only calls webbrowser, never the sqlite-bound store, so
401406 # the single-threaded-request design still holds.
402- import threading
403-
404407 threading .Thread (target = open_report , args = (url ,), daemon = True ).start ()
408+ # Serve on a background thread and block the main thread on an interruptible join --
409+ # NOT serve_forever() on the main thread. On Windows a Ctrl-C never wakes the
410+ # select() inside serve_forever (Winsock select ignores the SIGINT event, so the
411+ # KeyboardInterrupt stays pending until some request happens to return control to the
412+ # eval loop), which left --serve/--web unkillable from the keyboard. Thread.join()
413+ # instead waits on a lock whose acquire IS wired to the SIGINT event on Windows, so
414+ # Ctrl-C interrupts it at once. Requests still run one at a time on the single serve
415+ # thread, so the store's no-concurrent-access invariant holds; daemon=True is a
416+ # backstop so the process can still exit even mid-request.
417+ server_thread = threading .Thread (target = server .serve_forever , name = "opentab-serve" , daemon = True )
418+ server_thread .start ()
405419 try :
406- server .serve_forever ()
420+ while server_thread .is_alive ():
421+ server_thread .join (0.5 ) # interruptible by Ctrl-C on Windows, unlike select()
407422 except KeyboardInterrupt :
408423 pass
409424 finally :
425+ server .shutdown () # off the serve thread, so it stops the loop without deadlock
410426 server .server_close ()
427+ server_thread .join (timeout = 2 )
411428 return 0
0 commit comments