From 92b8f461abc772916f777805164b979a4ccc58ea Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 1 Sep 2026 15:59:41 -0700 Subject: [PATCH] [eric] electron: the packaged backend runs as backend.serve, so an agent's pkill -f uvicorn aimed at its own app cannot take ours down Co-Authored-By: Claude Fable 5.1 --- backend/serve.py | 37 +++++++++++++++++++++++++++++++ electron/backendSpawnName.test.js | 30 +++++++++++++++++++++++++ electron/main.js | 3 ++- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 backend/serve.py create mode 100644 electron/backendSpawnName.test.js diff --git a/backend/serve.py b/backend/serve.py new file mode 100644 index 00000000..a4e59b62 --- /dev/null +++ b/backend/serve.py @@ -0,0 +1,37 @@ +"""The packaged backend's entry point: `python -m backend.serve --port N`. + +Why not `python -m uvicorn backend.main:app`: agents building FastAPI apps restart their own dev +server with `pkill -f "uvicorn backend.main"` (the app template is uvicorn + backend/main.py too), +and that pattern matched the OpenSwarm backend's own command line. Measured 2026-09-01 on a packaged +soak: one agent's restart command took down the host backend, a second instance's backend and the +user's production app backend, twelve seconds apart, twice. A command line that contains neither +"uvicorn" nor "backend.main" cannot be caught by the patterns an app-building agent reaches for. +""" +import argparse +import asyncio +import os +import uvicorn +from typeguard import typechecked + + +class ReadyServer(uvicorn.Server): + """Prints a machine-readable READY line once the socket is bound, like the dev runner does.""" + async def startup(self, sockets=None): + await super().startup(sockets) + print(f"READY:PORT={self.config.port}", flush=True) + + +@typechecked +def main() -> None: + parser = argparse.ArgumentParser(description="OpenSwarm backend server") + parser.add_argument("--port", type=int, default=int(os.environ.get("OPENSWARM_PORT", "8324"))) + parser.add_argument("--host", default=os.environ.get("OPENSWARM_HOST", "127.0.0.1")) + parser.add_argument("--timeout-graceful-shutdown", type=int, default=8) + args = parser.parse_args() + os.environ["OPENSWARM_PORT"] = str(args.port) + config = uvicorn.Config("backend.main:app", host=args.host, port=args.port, timeout_graceful_shutdown=args.timeout_graceful_shutdown) + asyncio.run(ReadyServer(config).serve()) + + +if __name__ == "__main__": + main() diff --git a/electron/backendSpawnName.test.js b/electron/backendSpawnName.test.js new file mode 100644 index 00000000..03a22a22 --- /dev/null +++ b/electron/backendSpawnName.test.js @@ -0,0 +1,30 @@ +// The packaged backend's command line must not look like the app an agent is building. Agents restart +// their FastAPI dev servers with `pkill -f "uvicorn backend.main"` (the app template is uvicorn + +// backend/main.py), and on 2026-09-01 that command, run by ONE agent, SIGTERMed the host backend, a +// second instance's backend and the user's production app backend, twice. Sessions mid-turn were +// flushed as "stopped" with nothing saying why: silent work loss caused by a name collision. +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const SRC = fs.readFileSync(path.join(__dirname, 'main.js'), 'utf8'); + +function backendSpawnArgs() { + const m = SRC.match(/backendProcess = spawn\(\s*pythonPath,\s*\[([^\]]*)\]/); + assert.ok(m, 'the backend spawn call must be findable'); + return m[1]; +} + +test('the backend runs as backend.serve, never as the uvicorn CLI on backend.main', () => { + const args = backendSpawnArgs(); + assert.match(args, /'-m',\s*'backend\.serve'/); + assert.doesNotMatch(args, /uvicorn/, 'an agent pkill -f uvicorn would catch the host backend'); + assert.doesNotMatch(args, /backend\.main/, 'an agent pkill -f backend.main would catch the host backend'); +}); + +test('backend/serve.py exists and is the only module spelled in the spawn', () => { + const serve = fs.readFileSync(path.join(__dirname, '..', 'backend', 'serve.py'), 'utf8'); + assert.match(serve, /uvicorn\.Config\("backend\.main:app"/, 'serve.py hands the app to uvicorn programmatically, keeping the name off argv'); + assert.match(serve, /READY:PORT=/, 'the readiness line the shell may wait on'); +}); diff --git a/electron/main.js b/electron/main.js index aa111a04..08bcf601 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1287,9 +1287,10 @@ async function startBackend() { console.log(`Starting backend: ${pythonPath} (exists=${pythonExists}) on port ${backendPort}`); console.log(`Project root: ${projectRoot}`); + // backend.serve, not `-m uvicorn backend.main:app`: an agent's `pkill -f "uvicorn backend.main"` aimed at its own app must not match ours. backendProcess = spawn( pythonPath, - ['-m', 'uvicorn', 'backend.main:app', '--host', '127.0.0.1', '--port', String(backendPort), '--timeout-graceful-shutdown', '8'], + ['-m', 'backend.serve', '--host', '127.0.0.1', '--port', String(backendPort), '--timeout-graceful-shutdown', '8'], { cwd: projectRoot, env,