What this prevents
- I can quit the desktop app and immediately relaunch it without the second launch failing on "address already in use".
- I can kill the desktop app via the tray "Quit" item without leaving a process running in the background that I cannot find.
When the Tauri host exits (cmd+Q, tray Quit, force-kill), it calls kill_on_exit on the sidecar handle, which kills the parent sidecar process. The uvicorn worker that the parent spawned is in a separate process. When the parent dies, on macOS the SIGTERM does not propagate to the worker, so the worker survives, holds port 43111, and keeps the SQLite file open. The dev launcher hides this with a preflight pkill workaround. In a packaged build the user has no such cleanup, and quick relaunches fail with "address already in use".
Surface
- The desktop host calls
child.kill() on the parent sidecar CommandChild only.
- The sidecar entrypoint calls
uvicorn.run(...) in-process within the sidecar's main thread. PyInstaller's bootloader spawns a worker subprocess that owns the actual port bind.
- The dev launcher script masks the bug with a preflight
fuser -k -9 ${port}/tcp plus pkill -9 -f "tesslate-studio-orchestrator$".
Repro
bash desktop/scripts/dev.sh
# wait for "[host] sidecar ready on http://127.0.0.1:43111"
# close the Tauri window with cmd+Q (or force-kill the host process)
# in another shell:
lsof -nP -iTCP:43111 -sTCP:LISTEN
# expected: empty
# actual: tesslate-studio-orchestrator still listening on 127.0.0.1:43111
pgrep -fl "tesslate-studio-orchestrator"
# actual: a single PID lingers, parented to init(1)
The next bash desktop/scripts/dev.sh would fail without the script's preflight pkill.
Expected vs Actual
Expected: when the host exits, the entire sidecar process tree exits. Port 43111 is free immediately.
Actual: the uvicorn worker survives, port stays bound, SQLite stays open, and packaged builds have no script-based safety net.
Suggested fix
Two options, either fixes the orphan:
- Have the host put the spawned sidecar into its own process group and send the kill signal to the whole group on exit. On macOS that means
setpgid on the child plus killpg on shutdown.
- Have the sidecar entrypoint install a SIGTERM handler that propagates to its uvicorn worker (and waits for it) before exiting. This makes the sidecar correct regardless of what the host does.
Either fix removes the need for the dev launcher's preflight pkill. The cleanup must still be correct in packaged builds where the dev script's safety net does not exist.
What this prevents
When the Tauri host exits (cmd+Q, tray Quit, force-kill), it calls
kill_on_exiton the sidecar handle, which kills the parent sidecar process. The uvicorn worker that the parent spawned is in a separate process. When the parent dies, on macOS the SIGTERM does not propagate to the worker, so the worker survives, holds port 43111, and keeps the SQLite file open. The dev launcher hides this with a preflightpkillworkaround. In a packaged build the user has no such cleanup, and quick relaunches fail with "address already in use".Surface
child.kill()on the parent sidecarCommandChildonly.uvicorn.run(...)in-process within the sidecar's main thread. PyInstaller's bootloader spawns a worker subprocess that owns the actual port bind.fuser -k -9 ${port}/tcppluspkill -9 -f "tesslate-studio-orchestrator$".Repro
The next
bash desktop/scripts/dev.shwould fail without the script's preflight pkill.Expected vs Actual
Expected: when the host exits, the entire sidecar process tree exits. Port 43111 is free immediately.
Actual: the uvicorn worker survives, port stays bound, SQLite stays open, and packaged builds have no script-based safety net.
Suggested fix
Two options, either fixes the orphan:
setpgidon the child pluskillpgon shutdown.Either fix removes the need for the dev launcher's preflight
pkill. The cleanup must still be correct in packaged builds where the dev script's safety net does not exist.