From a9740ad6f2e2f0d15ee89637d1b25a6c967a7052 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:27:52 +1000 Subject: [PATCH] Devices: the app waits for its old server before starting the new one; clipboard sends keep their headset (review round 31) Co-Authored-By: Claude Opus 5.5 (1M context) --- app/main.js | 10 ++++++++-- tests/test_server.py | 3 ++- ui/index.html | 3 ++- ui/server.py | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/main.js b/app/main.js index 5d308f5..c7b76ef 100644 --- a/app/main.js +++ b/app/main.js @@ -154,10 +154,16 @@ async function startServer() { // Closing stdin lets server.py close its SSH connections and exit (the only clean // way on Windows); SIGTERM does the same elsewhere. +// Resolves once it has exited (or after 20 s), so a replacement can take the server +// lock: server.py allows one per user. function endServer(child) { + const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() + : new Promise((resolve) => child.once("exit", resolve)); try { child.stdin.end(); } catch {} if (!IS_WIN) child.kill("SIGTERM"); - setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref(); + // server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill. + setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref(); + return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]); } function stopServer() { @@ -182,7 +188,7 @@ async function restartServer() { const old = server; server = null; url = null; - if (old) endServer(old); + if (old) await endServer(old); await load(); } diff --git a/tests/test_server.py b/tests/test_server.py index f5c9d06..dee25cf 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -248,7 +248,8 @@ class OneServer(unittest.TestCase): def test_a_second_server_is_refused_until_the_first_exits(self): data = tempfile.mkdtemp(prefix="frame-one-server-") - env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid"} + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", + "FRAME_CONTROL_SERVER_WAIT": "1"} first = self.start(env) self.assertIn("Frame Control on", first.stdout.readline()) second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, diff --git a/ui/index.html b/ui/index.html index 2dcb0a3..d38fa28 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1503,9 +1503,10 @@ $("clipSend").onclick = () => { // In the app, Electron reads the clipboard; in a browser, the server does. async function sendComputerClipboard() { if (!window.frameApp) return api("/api/clipboard", { fromComputer: true }); + const device = window.connDevice; // the headset it was sent to, not one switched to while reading const text = await window.frameApp.readClipboard(); if (!text) throw new Error("The clipboard is empty (or holds something other than text)"); - return api("/api/clipboard", { text }); + return api("/api/clipboard", { text }, device); } $("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac")); diff --git a/ui/server.py b/ui/server.py index da13a1b..da9ec0a 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1662,7 +1662,7 @@ def one_server(): edit the headsets on their own, and could move each other's installs to another headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second, separate one, as the tests do.)""" - lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=8) # the app restarting its server + lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it try: lock.__enter__() except OSError: