mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
Launching the app died with an unhandled SocketException whenever anything already held the wrapper port -- from `todo` and from `./run.sh` alike, since run.sh execs the same entry point. What was holding it was a wrapper from an earlier session that had outlived its window: /proc/<pid>/exe read "/opt/todo/bin/todo_desktop (deleted)", i.e. install_arch.sh had replaced the binary underneath a process that kept serving anyway. That orphan is manufactured by the handoff branch in bin/todo_desktop.dart. When the launched browser exits within 5s the launcher assumes it handed the URL to an existing window and keeps serving rather than pulling the server out from under it -- right at that moment, but nothing ever reaps the server once that window closes. It then survives an upgrade and blocks every later launch. The port cannot simply move: the browser keys localStorage (the GitHub token) and IndexedDB (the notes) by origin, so a fallback port would present an empty, logged-out app. lib/desktop/port_guard.dart therefore decides who owns 8730 and acts: a wrapper with no window attached is stale, so it is terminated (SIGTERM, escalating to SIGKILL) and the port rebound with the current build; a wrapper that still has a window is joined, leaving exactly one server; anything else is named and refused. Reaping is what makes an upgrade self-healing, so install_arch.sh needs no restart step of its own -- one mechanism, not two. It fails closed at every step. `ss` hides the owning pid for another user's socket, and that is a real branch rather than an impossible one: it resolves to "refuse", never to a blind kill or a retry loop. Two things measurement contradicted, both of which had to change the design: Re-running the Chrome command does not focus the existing --app window, it opens a second one (verified on Thorium: the process returns in ~180ms and the window count goes 1 -> 2). So the join path opens a window instead of printing and exiting; both windows share the one server and one IndexedDB, and typing `todo` to get nothing visible would be the worse outcome. Chrome also rewrites its own argument vector into a single space-joined string, so /proc/<pid>/cmdline for a browser contains no NUL separators at all. Exact token matching therefore never fired, and the first run of the live-window case reaped the server under an open window. Both matchers now tolerate either layout. The direction of that error matters and is commented as such: a false negative kills a server someone is using, a false positive merely opens a spare window. The decision logic lives in lib/ rather than in bin/, which is coverage:ignore-file -- otherwise the one path that has to work would be the only untested code in the repo. Host access sits behind an injected SystemProbe, and the ss parser is additionally exercised against genuine `ss` output by binding a real socket, since a fixture cannot catch a format change. Verified by running it, not only by tests: orphaned dev wrapper, orphaned AOT wrapper from the installed package, live window, a foreign owner (python http.server -- named, exit 1, and confirmed still alive afterwards), ./run.sh, and plain `todo`. 238 tests, 100% line coverage held. CLAUDE.md picks up the port guard, and drops two stale claims: the suite is 238 tests taking 1-2min, not 191 tests in ~5s -- that drift predates this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| todo_desktop.dart | ||