mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 13:07:48 +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>
196 lines
7.8 KiB
Dart
196 lines
7.8 KiB
Dart
// coverage:ignore-file
|
|
// Entry point for the desktop wrapper: resolves real paths, starts the server,
|
|
// and launches the browser. The serving logic it delegates to is covered by
|
|
// test/wrapper_server_test.dart, and the busy-port recovery it delegates to by
|
|
// test/port_guard_test.dart. Keep this file thin wiring for that reason.
|
|
import 'dart:io';
|
|
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:todo/desktop/port_guard.dart';
|
|
import 'package:todo/desktop/wrapper_server.dart';
|
|
|
|
/// Port must stay fixed: the browser keys `localStorage` (the GitHub token) and
|
|
/// IndexedDB (the notes) by origin, so a different port looks like a different
|
|
/// app with no token and no notes. Kept in step with lib/sync/desktop_wrapper.
|
|
const _port = 8730;
|
|
|
|
/// `errno` for EADDRINUSE. The wrapper is Linux-only (the desktop app is the
|
|
/// web build served locally), so the numeric value is stable here.
|
|
const _addressInUse = 98;
|
|
|
|
Future<void> main(List<String> args) async {
|
|
final home = Platform.environment['HOME'];
|
|
if (home == null) {
|
|
stderr.writeln('HOME is not set; cannot resolve the backlog path.');
|
|
exit(1);
|
|
}
|
|
|
|
// `dart build cli` emits bundle/bin/<exe> alongside bundle/lib/, so the
|
|
// installed layout is /opt/todo/bin/todo-desktop with the web assets one
|
|
// level up at /opt/todo/web. --web-root overrides for development runs
|
|
// straight out of the repo.
|
|
final webRoot =
|
|
_argValue(args, '--web-root') ??
|
|
p.normalize(p.join(p.dirname(Platform.resolvedExecutable), '..', 'web'));
|
|
if (!Directory(webRoot).existsSync()) {
|
|
stderr.writeln('web assets not found at $webRoot');
|
|
exit(1);
|
|
}
|
|
|
|
// Overridable so a verification run cannot point at the real backlog: an
|
|
// app that starts with an empty store would export over it.
|
|
final server = WrapperServer(
|
|
webRoot: webRoot,
|
|
backlogPath:
|
|
_argValue(args, '--backlog-path') ?? p.join(home, 'todo', 'BACKLOG.md'),
|
|
logPath:
|
|
_argValue(args, '--log-path') ??
|
|
p.join(home, '.local', 'share', 'todo-desktop', 'todo_notes.json'),
|
|
);
|
|
|
|
if (!await _bind(server)) {
|
|
// Something already holds the port. It cannot simply move — the notes and
|
|
// the token are keyed by origin — so work out who owns it and either take
|
|
// it back, join it, or refuse.
|
|
if (!await _recoverPort(server, home, args)) return;
|
|
}
|
|
stdout.writeln('todo desktop serving on http://localhost:$_port');
|
|
|
|
// A bare flag, not a valued option: requiring a dummy value meant passing a
|
|
// stray positional, which the AOT runtime tries to interpret as a snapshot.
|
|
if (!args.contains('--no-browser')) {
|
|
final ranLongEnough = await _launchBrowser(home);
|
|
if (!ranLongEnough) {
|
|
// Chrome exits immediately when it hands the URL to an instance that
|
|
// already owns the profile directory (or when a stale SingletonLock is
|
|
// left behind). Shutting down here would pull the server out from under
|
|
// a window that is still open, so keep serving instead.
|
|
stdout.writeln(
|
|
'Browser returned immediately (handed off to an existing window). '
|
|
'Still serving on http://localhost:$_port — Ctrl-C to stop.',
|
|
);
|
|
return;
|
|
}
|
|
// Otherwise the browser owned the session: its window closed, so we exit.
|
|
await server.stop();
|
|
}
|
|
}
|
|
|
|
/// Binds [server] to the fixed port, reporting failure instead of throwing.
|
|
///
|
|
/// Anything other than "address in use" is fatal and reported without a stack
|
|
/// trace: a Dart traceback tells the user nothing they can act on.
|
|
Future<bool> _bind(WrapperServer server) async {
|
|
try {
|
|
await server.start(_port);
|
|
return true;
|
|
} on SocketException catch (e) {
|
|
if (e.osError?.errorCode == _addressInUse) return false;
|
|
stderr.writeln('could not serve on port $_port: ${e.osError?.message}');
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
/// Resolves a busy port and, where possible, gets [server] listening.
|
|
///
|
|
/// Returns true once the server is bound. Returns false when this process has
|
|
/// deliberately not taken the port — it joined a running instance instead — in
|
|
/// which case the caller must not touch [server]: it owns no socket, and
|
|
/// falling through to the browser-lifetime logic would leave a second orphan.
|
|
/// Exits the process when the port cannot safely be had.
|
|
Future<bool> _recoverPort(
|
|
WrapperServer server,
|
|
String home,
|
|
List<String> args,
|
|
) async {
|
|
final guard = PortGuard(profileDir: _profileDir(home));
|
|
switch (await guard.resolve(_port)) {
|
|
case AbortWithOwner(:final description):
|
|
stderr.writeln('port $_port is already in use by $description.');
|
|
stderr.writeln(
|
|
'todo needs this exact port — its notes and GitHub token are stored '
|
|
'per origin. Free the port, then run todo again.',
|
|
);
|
|
exit(1);
|
|
case AttachToExisting(:final pid):
|
|
// Re-running the browser command opens a second window rather than
|
|
// raising the first (measured on Thorium: the process returns in ~180ms
|
|
// and window count goes 1 -> 2). A duplicate window is the right
|
|
// trade-off anyway: both share this one server and one IndexedDB, and
|
|
// typing `todo` to get nothing visible would be worse.
|
|
final opening = !args.contains('--no-browser');
|
|
stdout.writeln(
|
|
'todo is already running (pid $pid)'
|
|
'${opening ? '; opening a window.' : '.'}',
|
|
);
|
|
if (opening) await _launchBrowser(home);
|
|
return false;
|
|
case ReapThenBind(:final pid):
|
|
stdout.writeln('clearing a stale todo wrapper (pid $pid) on port $_port');
|
|
if (!await guard.reap(pid, _port)) {
|
|
stderr.writeln('could not free port $_port from pid $pid.');
|
|
exit(1);
|
|
}
|
|
case BindNow():
|
|
break;
|
|
}
|
|
if (await _bind(server)) return true;
|
|
stderr.writeln('port $_port was taken again while todo was starting.');
|
|
exit(1);
|
|
}
|
|
|
|
/// Browser profile directory for the desktop app.
|
|
///
|
|
/// Must stay stable: the GitHub token (localStorage) and the notes (IndexedDB)
|
|
/// live inside it, so a changing path silently logs the user out and hides
|
|
/// their notes. It doubles as the marker for "a window is still attached".
|
|
String _profileDir(String home) =>
|
|
p.join(home, '.local', 'share', 'todo-desktop', 'profile');
|
|
|
|
/// Launches the app in a Chrome-family browser with a **stable** profile
|
|
/// directory, since the token and notes live in that profile.
|
|
/// Returns true when the browser ran long enough to have owned the session.
|
|
Future<bool> _launchBrowser(String home) async {
|
|
// Ordered by preference, and deliberately broad: this machine runs Thorium
|
|
// behind /opt/google/chrome, and has a policy that uninstalls the `chromium`
|
|
// package, so assuming any single browser is wrong. TODO_BROWSER overrides.
|
|
final candidates = [
|
|
Platform.environment['TODO_BROWSER'] ?? '',
|
|
'/opt/google/chrome/chrome',
|
|
'/opt/thorium-browser/thorium-browser',
|
|
'/usr/bin/google-chrome-stable',
|
|
'/usr/bin/chromium',
|
|
'/usr/bin/brave',
|
|
];
|
|
final browser = candidates.firstWhere(
|
|
(path) => path.isNotEmpty && File(path).existsSync(),
|
|
orElse: () => '',
|
|
);
|
|
if (browser.isEmpty) {
|
|
stderr.writeln(
|
|
'No Chrome-family browser found; open '
|
|
'http://localhost:$_port manually.',
|
|
);
|
|
return false;
|
|
}
|
|
final process = await Process.start(browser, [
|
|
'--app=http://localhost:$_port',
|
|
'--user-data-dir=${_profileDir(home)}',
|
|
// Sets WM_CLASS, which the .desktop entry matches on via StartupWMClass.
|
|
// Without it the window inherits the browser's class and the taskbar shows
|
|
// a browser icon instead of todo's.
|
|
'--class=todo',
|
|
'--no-first-run',
|
|
]);
|
|
|
|
final started = DateTime.now();
|
|
await process.exitCode;
|
|
return DateTime.now().difference(started) > const Duration(seconds: 5);
|
|
}
|
|
|
|
String? _argValue(List<String> args, String name) {
|
|
final index = args.indexOf(name);
|
|
if (index == -1 || index + 1 >= args.length) return null;
|
|
return args[index + 1];
|
|
}
|