From cb6f294299e7e34312a49964a3515013ac0f47d7 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:17:43 +1000 Subject: [PATCH] Devices: one Frame Control server per user Two servers each connected, reconnected and edited the headsets on their own, and several review findings were ways one could move the other's install to a different headset. A lock file in the data folder now refuses a second server with a plain message; FRAME_CONTROL_DATA_DIR still gives a separate one. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/devices.md | 8 ++++++++ tests/test_server.py | 23 +++++++++++++++++++++++ ui/server.py | 20 +++++++++++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/devices.md b/docs/devices.md index 8e09e7d..1ed3200 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -125,6 +125,14 @@ the key it shows, as Set Up Connection does. After reinstalling SteamOS the headset has a new key; **Forget identity** on the Devices tab lets the next connection save the new one. +## One server at a time + +Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's +data folder). Two would each connect, reconnect and edit the headsets on their own, and +one could move the other's install to a different headset. A second one, say +`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already +running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets. + ## API All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header). diff --git a/tests/test_server.py b/tests/test_server.py index 31167d3..f5c9d06 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -237,6 +237,29 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.post("/api/nope", {})[0], 404) +class OneServer(unittest.TestCase): + """Two servers for one user would each connect and edit headsets on their own.""" + + def start(self, env): + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close())) + return proc + + 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"} + 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, + capture_output=True, text=True, timeout=60) + self.assertEqual(second.returncode, 1) + self.assertIn("already running", second.stderr) + first.terminate() + first.wait(10) + self.assertIn("Frame Control on", self.start(env).stdout.readline()) + + @unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script") class LocalMode(unittest.TestCase): """FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key diff --git a/ui/server.py b/ui/server.py index 19846f6..da13a1b 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1654,6 +1654,23 @@ class Handler(BaseHTTPRequestHandler): shutil.rmtree(tmp, ignore_errors=True) +_ONE_SERVER = None + + +def one_server(): + """Only one Frame Control server per user: two would each connect, reconnect and + 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 + try: + lock.__enter__() + except OSError: + sys.exit("Frame Control is already running on this computer (the app, or a server started " + "from a terminal). Quit it, then try again.") + return lock + + def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810))) @@ -1663,8 +1680,9 @@ def main(): args = ap.parse_args() httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) sweep_tmp() - global LINK + global LINK, _ONE_SERVER if not LOCAL: + _ONE_SERVER = one_server() LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None, mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable) LINK.work_lock, LINK.work = _work_lock, lambda: _work[0]