todo-app/lib/desktop/port_guard.dart
Krzysztof kuhy Rudnicki 9d2aa1c178 Enforce the 250-line file cap, and split the first four files
Wires the shared gate (~/utils/scripts/check_file_length.sh) into a new
.pre-commit-config.yaml as a local hook, checking only the files in the
commit and failing rather than warning. No baseline, no allowlist.

The repo had a hand-written .git/hooks/pre-commit running two checks;
both are ported verbatim into the config rather than dropped, with the
version bump ordered last so it cannot rewrite pubspec.yaml for a commit
a gate is still about to reject. scripts/install_hooks.sh now installs
pre-commit (and removes the legacy hook) instead of writing its own.

.github/workflows/file-length.yml runs the same check on push, kept
separate from release-apk.yml so an over-long file fails lint rather than
blocking an APK release.

Splits the four cleanest violations, all behaviour-preserving:

  note_template.dart      370 -> 203 + parser (186)
  firebase_backend.dart   308 -> barrel (24) + project/store/client
  port_guard.dart         315 -> 232 + linux_system_probe (100)
  notes_list_screen.dart  698 -> 225 + 5 widget/nav files

Where a file had external callers, the original path stays as a barrel
that re-exports the split parts, so no call site changed.

Verified: 313 tests pass, coverage identical at 99.9% / 1922 lines
(per-file too -- notes_list's 281 covered lines are now 81+75+51+41+22+11),
flutter analyze clean, and a staged 260-line file makes git commit fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:01:24 +02:00

233 lines
9.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
// The real-host probe lives in its own file (size), but PortGuard defaults to
// it and callers expect one import, so it is re-exported here.
import 'package:todo/desktop/linux_system_probe.dart';
export 'package:todo/desktop/linux_system_probe.dart';
/// What the launcher should do when the fixed wrapper port is already bound.
///
/// The port cannot move — the browser keys `localStorage` (the GitHub token)
/// and IndexedDB (the notes) by origin, so falling back to a free port would
/// silently present an empty, logged-out app. The only options are therefore to
/// take the port back, share the server that already owns it, or refuse.
sealed class PortAction {
/// Allows subclasses to be const.
const PortAction();
}
/// The port came free between the failed bind and the probe: just bind again.
final class BindNow extends PortAction {
/// Creates the retry action.
const BindNow();
}
/// A todo wrapper is serving and a browser window is still attached to it.
///
/// Reaping here would pull the server out from under a window the user is
/// looking at, so the launcher joins the running instance instead.
final class AttachToExisting extends PortAction {
/// Creates the attach action for the wrapper running as [pid].
const AttachToExisting(this.pid);
/// Process id of the wrapper already serving the port.
final int pid;
}
/// A todo wrapper is serving with no window attached — a leftover from an
/// earlier session, safe to terminate so the current build can take over.
final class ReapThenBind extends PortAction {
/// Creates the reap action for the stale wrapper running as [pid].
const ReapThenBind(this.pid);
/// Process id of the stale wrapper holding the port.
final int pid;
}
/// The port is held by something this launcher must not kill.
final class AbortWithOwner extends PortAction {
/// Creates the abort action, describing the owner for the error message.
const AbortWithOwner(this.description);
/// Human-readable description of the owning process.
final String description;
}
/// Identifying details of a running process, as read from `/proc`.
class ProcessDescription {
/// Creates a description from an executable path and an argument vector.
const ProcessDescription({required this.exeTarget, required this.cmdline});
/// Target of `/proc/<pid>/exe`; empty when it could not be read.
///
/// Carries a ` (deleted)` suffix when the binary was replaced underneath the
/// running process — exactly what an upgrade leaves behind.
final String exeTarget;
/// Argument vector, already split on the NUL separators `/proc` uses.
final List<String> cmdline;
}
/// Read-only view of the host that [PortGuard] reasons about.
///
/// Injectable so the decision logic can be tested without live processes.
abstract interface class SystemProbe {
/// Process id listening on [port], or null when it cannot be determined.
Future<int?> listenerPid(int port);
/// Describes [pid], or null when the process is gone.
Future<ProcessDescription?> describe(int pid);
/// Argument vectors of every process readable by this user.
Future<List<List<String>>> processCmdlines();
/// Whether [port] currently refuses a loopback bind.
Future<bool> portInUse(int port);
/// Sends [signal] to [pid]; returns whether the signal was delivered.
bool signal(int pid, ProcessSignal signal);
}
/// Extracts the process id listening on [port] from `ss -tlnp` output.
///
/// Returns null when no line matches or the owning pid is hidden, which `ss`
/// does for sockets belonging to other users.
int? parseListenerPid(String ssOutput, int port) {
for (final line in const LineSplitter().convert(ssOutput)) {
final fields = line
.split(RegExp(r'\s+'))
.where((f) => f.isNotEmpty)
.toList();
if (fields.length < 4) continue;
// Field 3 is `Local Address:Port`. Matching on the port alone (rather than
// the address too) keeps IPv6 and wildcard listeners in scope; a wrong
// guess is harmless because the caller still has to prove the process is
// a wrapper of this app before it may kill anything.
if (!fields[3].endsWith(':$port')) continue;
final pid = RegExp(r'pid=(\d+)').firstMatch(line)?.group(1);
if (pid != null) return int.parse(pid);
}
return null;
}
/// Whether the process described by [exeTarget] and [cmdline] is a wrapper.
///
/// Matches both the installed AOT binary (`/opt/todo/bin/todo_desktop`, with or
/// without the ` (deleted)` suffix an upgrade leaves) and a development run out
/// of the repo (`dart run bin/todo_desktop.dart`, as `run.sh` starts it).
bool isTodoWrapper(String exeTarget, List<String> cmdline) {
final exe = p.basename(exeTarget.replaceAll(' (deleted)', ''));
if (exe == 'todo_desktop') return true;
// A plain substring match is deliberate. It is only ever asked about the
// process holding the wrapper port, which no unrelated program does, so the
// looser test cannot reach a bystander — whereas a stricter one would refuse
// to reap a wrapper whose argument vector does not split the way we expect,
// leaving the user unable to start the app at all.
return cmdline.join(' ').contains('todo_desktop');
}
/// Whether any process in [cmdlines] is a browser holding [profileDir] open.
///
/// A live window is the one thing that makes reaping the port owner harmful, so
/// this is the discriminator between "stale leftover" and "in use".
///
/// Chrome rewrites its own argument vector into one space-joined string, so
/// `/proc/<pid>/cmdline` for a browser has no NUL separators at all and the
/// flag has to be found inside a token rather than as one. Getting this wrong
/// is asymmetric: a false negative kills the server under a window the user is
/// using, while a false positive merely opens an extra window, so this errs
/// towards detecting attachment.
bool hasWindowAttached(Iterable<List<String>> cmdlines, String profileDir) {
final flag = '--user-data-dir=$profileDir';
return cmdlines.any((tokens) {
final joined = tokens.join(' ');
final start = joined.indexOf(flag);
if (start == -1) return false;
// Require a boundary so a sibling profile (`…/profile2`) cannot match.
final end = start + flag.length;
return end == joined.length || joined[end] == ' ';
});
}
/// Decides how the launcher should react to a busy wrapper port, and carries
/// out the termination when the owner turns out to be stale.
///
/// Fails closed throughout: anything it cannot positively identify as a todo
/// wrapper is reported rather than killed.
class PortGuard {
/// Creates a guard for the browser profile at [profileDir].
PortGuard({required this.profileDir, SystemProbe? probe})
: probe = probe ?? const LinuxSystemProbe();
/// Browser profile directory whose presence in a command line means a window
/// is still attached to the running wrapper.
final String profileDir;
/// Host probe used to identify the port owner.
final SystemProbe probe;
/// Determines what to do about [port] after a bind failed.
Future<PortAction> resolve(int port) async {
final pid = await probe.listenerPid(port);
if (pid == null) {
// Either the owner released the port, or `ss` hid it because it belongs
// to another user. Only the former is safe to retry.
return await probe.portInUse(port)
? const AbortWithOwner(
'an unidentified process (it may belong to another user)',
)
: const BindNow();
}
final description = await probe.describe(pid);
// The process exited while we were looking at it.
if (description == null) return const BindNow();
if (!isTodoWrapper(description.exeTarget, description.cmdline)) {
final exe = description.exeTarget.isEmpty
? description.cmdline.firstOrNull ?? 'unknown'
: description.exeTarget;
return AbortWithOwner('pid $pid ($exe)');
}
return hasWindowAttached(await probe.processCmdlines(), profileDir)
? AttachToExisting(pid)
: ReapThenBind(pid);
}
/// Terminates [pid] and waits for [port] to come free.
///
/// Escalates from `SIGTERM` to `SIGKILL` rather than waiting indefinitely.
/// The wrapper needs no graceful shutdown: it holds no unflushed state, since
/// every `/backup/*` write completes within its request.
///
/// Returns whether [port] was actually released.
Future<bool> reap(
int pid,
int port, {
Duration termWait = const Duration(seconds: 3),
Duration killWait = const Duration(seconds: 2),
Duration pollInterval = const Duration(milliseconds: 50),
}) async {
probe.signal(pid, ProcessSignal.sigterm);
if (await _awaitFreePort(port, termWait, pollInterval)) return true;
probe.signal(pid, ProcessSignal.sigkill);
return _awaitFreePort(port, killWait, pollInterval);
}
Future<bool> _awaitFreePort(
int port,
Duration limit,
Duration pollInterval,
) async {
final deadline = DateTime.now().add(limit);
do {
if (!await probe.portInUse(port)) return true;
await Future<void>.delayed(pollInterval);
} while (DateTime.now().isBefore(deadline));
return !await probe.portInUse(port);
}
}