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