diff --git a/app/main.js b/app/main.js index 3c07d2b..7d77675 100644 --- a/app/main.js +++ b/app/main.js @@ -149,7 +149,7 @@ async function startServer() { const target = `http://127.0.0.1:${port}/`; for (let i = 0; i < 100; i++) { if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`); - if (await ping(target)) { url = target; return; } + if (await ping(target)) { url = target; serverStarted = Date.now(); return; } await new Promise((r) => setTimeout(r, 100)); } if (server === child) server = null; @@ -175,18 +175,27 @@ function stopServer() { if (server) endServer(server); } -function errorPage(message) { +function errorPage(message, title = "Frame Control couldn't start") { const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); const html = ` -

Frame Control couldn't start

-

${esc(message)}

Fix it, then choose Frame → Restart Server.

`; +

${esc(title)}

+

${esc(message)}

+

+

Frame → Restart Server does the same.

`; return "data:text/html;charset=utf-8," + encodeURIComponent(html); } +// A server that had been running starts again by itself (something stopped it: a +// signal, a crash). One that stops again within a minute shows the error instead, +// so a server that can't stay up doesn't restart forever. +let serverStarted = 0; function serverDied(why) { url = null; - if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`)); + if (!win) return; + if (Date.now() - serverStarted > 60000) restartServer(); + else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped")); } // Restarts that overlap share one: two could each start a server, and the one @@ -263,6 +272,11 @@ function fromUi(e) { } catch { return false; } } +// The error page's Try Again button. The error page is the only data: page the window +// shows (`url` can still be set then: the server answered but the page failed to load). +ipcMain.handle("server:restart", (e) => { + if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer(); +}); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); // A PNG or JPEG (a screenshot) onto the clipboard as an image. ipcMain.handle("clipboard:writeImage", (e, bytes) => { diff --git a/app/preload.js b/app/preload.js index fb86f63..6dcec17 100644 --- a/app/preload.js +++ b/app/preload.js @@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld("frameApp", { readClipboard: () => ipcRenderer.invoke("clipboard:read"), writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes), setUpConnection: () => ipcRenderer.invoke("connection:setup"), + restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again // The Frame menu's headset switcher: the page tells it the headsets, and hears picks. devicesChanged: (list) => ipcRenderer.send("devices:changed", list), onUseDevice: (cb) => { diff --git a/tests/test_server.py b/tests/test_server.py index 99dc85a..b82034c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -285,6 +285,18 @@ class OneServer(unittest.TestCase): r = conn.getresponse() self.assertEqual(r.status, 403, r.read()) + @unittest.skipIf(os.name == "nt", "no SIGTERM on Windows") + def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self): + """The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit.""" + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"), + "FRAME_ALIAS": "frame-control-test.invalid"} + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"], + env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close())) + self.assertIn("Frame Control on", proc.stdout.readline()) + proc.terminate() + self.assertEqual(proc.wait(30), 0, proc.stdout.read()) + class ArtworkSettings(unittest.TestCase): """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" diff --git a/ui/server.py b/ui/server.py index 6427d9d..47fec2d 100755 --- a/ui/server.py +++ b/ui/server.py @@ -2667,12 +2667,16 @@ def main(): signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt)) if args.exit_on_eof: def watch_stdin(): - sys.stdin.buffer.read() + # os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock, + # and if a signal stops the server first, Python aborts (SIGABRT) at exit + # when it can't take that lock back from this thread. + while os.read(0, 4096): + pass threading.Thread(target=httpd.shutdown, daemon=True).start() threading.Thread(target=watch_stdin, daemon=True).start() - # The real port, which --port 0 leaves to the system (the iPhone app reads it from here). - print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True) try: + # The real port, which --port 0 leaves to the system (the iPhone app reads it from here). + print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True) httpd.serve_forever() except KeyboardInterrupt: pass