From 1a5f089e57e3710b9a56237be22ce57a5f84c06f Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:12:47 +1000 Subject: [PATCH 1/3] Server: a stop signal no longer crashes it (SIGABRT) while the app holds stdin The --exit-on-eof watcher read stdin with a buffered read, which holds stdin's lock. When SIGTERM stopped the server first, Python aborted at exit trying to take that lock back, and the app showed "The server stopped unexpectedly (SIGABRT)". It now uses os.read. The startup line is printed inside the try, so a signal that arrives while it's printed still runs the cleanup. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_server.py | 12 ++++++++++++ ui/server.py | 10 +++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 9805806..93087b4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -279,6 +279,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 7771b72..b25975a 100755 --- a/ui/server.py +++ b/ui/server.py @@ -2656,12 +2656,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 From a0f810c0181f62a6a09af886a982eb9c2e2ebaa2 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:17:24 +1000 Subject: [PATCH 2/3] App: restart the server by itself when it stops, and a Try Again button on the error page A server that had been up for a minute starts again without asking. One that stops sooner shows the error page, now headed "Frame Control stopped", with a Try Again button (the menu item was the only way, and hard to find). Co-Authored-By: Claude Opus 5.5 (1M context) --- app/main.js | 21 ++++++++++++++++----- app/preload.js | 1 + 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/main.js b/app/main.js index 4497136..e06812d 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,8 @@ function fromUi(e) { } catch { return false; } } +// The error page's Try Again button (there is no server page while it shows). +ipcMain.handle("server:restart", (e) => { if (win && e.sender === win.webContents && !url) restartServer(); }); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); }); diff --git a/app/preload.js b/app/preload.js index 8ab5b8f..05d3a6d 100644 --- a/app/preload.js +++ b/app/preload.js @@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld("frameApp", { notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request), readClipboard: () => ipcRenderer.invoke("clipboard:read"), 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) => { From 6abc765e22d9cc3d903c0ad3d1e2a6e7afe444f5 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:51:24 +1000 Subject: [PATCH 3/3] App: Try Again also works when the server is up but its page failed to load The button was accepted only while no server was known. If the server answered and the page then failed to load, the error page showed with the server still known, and the button did nothing. It's now accepted from the error page itself (the window's only data: page). Co-Authored-By: Claude Opus 5.5 (1M context) --- app/main.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/main.js b/app/main.js index 3f9dcbc..7d77675 100644 --- a/app/main.js +++ b/app/main.js @@ -272,8 +272,11 @@ function fromUi(e) { } catch { return false; } } -// The error page's Try Again button (there is no server page while it shows). -ipcMain.handle("server:restart", (e) => { if (win && e.sender === win.webContents && !url) restartServer(); }); +// 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) => {