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]