Skip to content

Commit 5f7e43f

Browse files
committed
fix(profiles): finish the fourth review round (SPEC-50)
Completes the CodeRabbit/Macroscope round begun in 288b7b2. - desktop_controller: serialize start/stop/restart. They share the daemon's PID file and control socket (and restart is a stop+start), so overlapping requests could drop a fresh PID, launch a second daemon, or stop one another action had just started. Each action now chains after the previous, with a regression test asserting they never overlap. - profile_lifecycle: keep the pid-file read synchronous. Making it async added real filesystem microtasks that a widget test's pumpAndSettle can never settle, which hung the delete tests. Only `processAlive` (polled ~100x per stop) needed to be async, and it stays async. - test doubles: override `withLock` in the two "writes nothing" FileSystemAdapter fakes. They inherited the real implementation, which created `<path>.lock` on the real disk under a non-existent /Users/test — the actual cause of the hang. - profiles_section_test: replace `await Future.delayed(...)` with `tester.pump(...)`; inside testWidgets the clock only advances when pumping, so the bare delay deadlocked. - profile_registry: keep the already-persisted profile's port when reconciling a post-merge collision (its daemon may be running on it) and reassign the newcomer instead; test asserts which side moves. - profile_runtime_test: assert dispose() disposes profilesController. - server_devices_section_test: reuse `_pump` via an optional NavigatorObserver. - docs/mockup: record the 64-code-point cap on the pair-URL `n` param, fix the delta cross-references (18→19, 17→18), use `pnpm typecheck` as the documented server check, and correct two stale "explicitly not changed" claims.
1 parent 288b7b2 commit 5f7e43f

11 files changed

Lines changed: 161 additions & 61 deletions

app/lib/desktop/daemon/profile_lifecycle.dart

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@ class ProfileLifecycle {
6161
bool Function(String path)? socketExists,
6262
Future<bool> Function(ServerProfile profile)? statusProbe,
6363
Future<void> Function(Duration duration)? sleep,
64-
Future<int?> Function(ServerProfile profile)? readPid,
64+
int? Function(ServerProfile profile)? readPid,
6565
Future<bool> Function(int pid)? processAlive,
6666
}) : run = run ?? _defaultRun,
6767
_socketExists = socketExists ?? _fileExists,
6868
_statusProbe = statusProbe ?? _connectProbe,
6969
_sleep = sleep ?? Future<void>.delayed,
70-
_readPid = readPid ?? _readPidFileAsync,
70+
_readPid = readPid ?? _readPidFile,
7171
_processAlive = processAlive ?? _posixProcessAlive;
7272

7373
/// Locates the `makit` executable.
@@ -79,7 +79,7 @@ class ProfileLifecycle {
7979
final bool Function(String path) _socketExists;
8080
final Future<bool> Function(ServerProfile profile) _statusProbe;
8181
final Future<void> Function(Duration duration) _sleep;
82-
final Future<int?> Function(ServerProfile profile) _readPid;
82+
final int? Function(ServerProfile profile) _readPid;
8383
final Future<bool> Function(int pid) _processAlive;
8484

8585
/// Runs `MAKIT_HOME=<profile.home> makit start`.
@@ -123,7 +123,7 @@ class ProfileLifecycle {
123123
ServerProfile profile, {
124124
Duration timeout = _kDefaultStopTimeout,
125125
}) async {
126-
final pid = await _readPid(profile);
126+
final pid = _readPid(profile);
127127
final stopResult = await stop(profile);
128128
if (stopResult.outcome == DaemonActionOutcome.failed ||
129129
stopResult.outcome == DaemonActionOutcome.cliNotFound) {
@@ -188,12 +188,16 @@ class ProfileLifecycle {
188188

189189
/// Reads the daemon pid from `$MAKIT_HOME/makit.pid`, or `null` when the file
190190
/// is absent or unparseable. Read before `makit stop`, which deletes it.
191-
static Future<int?> _readPidFileAsync(ServerProfile profile) async {
191+
///
192+
/// Synchronous on purpose: it is one small read, once per stop — unlike
193+
/// [_posixProcessAlive], which is polled up to ~100 times and therefore must
194+
/// be async. Making this async introduced real filesystem microtasks that a
195+
/// widget test's `pumpAndSettle` could never settle, hanging the delete tests.
196+
static int? _readPidFile(ServerProfile profile) {
192197
try {
193198
final file = File(profile.pidFilePath);
194-
if (!await file.exists()) return null;
195-
final raw = await file.readAsString();
196-
return int.tryParse(raw.trim());
199+
if (!file.existsSync()) return null;
200+
return int.tryParse(file.readAsStringSync().trim());
197201
} on FileSystemException {
198202
return null;
199203
}

app/lib/desktop/desktop_controller.dart

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -121,26 +121,50 @@ class DesktopController extends ChangeNotifier {
121121
Future<DaemonActionResult> Function() action, {
122122
DaemonState? transient,
123123
}) async {
124-
if (transient != null) {
125-
// Counts as newer than any refresh already in flight: an action's
126-
// `starting`/`stopping` state has to survive until the action's own
127-
// refresh replaces it, or a poll that left before the button was pressed
128-
// repaints it as stopped mid-start.
129-
++_refreshGeneration;
130-
_summary = DaemonSummary(
131-
state: transient,
132-
pid: _summary.pid,
133-
pairedDevices: _summary.pairedDevices,
134-
runningSessions: _summary.runningSessions,
135-
);
136-
notifyListeners();
124+
// Serialize lifecycle actions. `start`/`stop`/`restart` share the daemon's
125+
// PID file and control socket (and `restart` is a `stop` then `start`), so
126+
// overlapping requests — from mashing Start/Stop/Restart or a reachability
127+
// change racing a manual action — can remove a freshly written PID file,
128+
// launch a second daemon, or stop one another action just started. Chaining
129+
// each action after the previous one makes them strictly sequential.
130+
final prior = _actionTail;
131+
final done = Completer<void>();
132+
_actionTail = done.future;
133+
if (prior != null) {
134+
try {
135+
await prior;
136+
} catch (_) {
137+
// A prior action's failure must not cancel the ones queued behind it.
138+
}
139+
}
140+
try {
141+
if (transient != null) {
142+
// Counts as newer than any refresh already in flight: an action's
143+
// `starting`/`stopping` state has to survive until the action's own
144+
// refresh replaces it, or a poll that left before the button was pressed
145+
// repaints it as stopped mid-start.
146+
++_refreshGeneration;
147+
_summary = DaemonSummary(
148+
state: transient,
149+
pid: _summary.pid,
150+
pairedDevices: _summary.pairedDevices,
151+
runningSessions: _summary.runningSessions,
152+
);
153+
notifyListeners();
154+
}
155+
final result = await action();
156+
_cliMissing = result.outcome == DaemonActionOutcome.cliNotFound;
157+
await refresh();
158+
return result;
159+
} finally {
160+
if (identical(_actionTail, done.future)) _actionTail = null;
161+
done.complete();
137162
}
138-
final result = await action();
139-
_cliMissing = result.outcome == DaemonActionOutcome.cliNotFound;
140-
await refresh();
141-
return result;
142163
}
143164

165+
/// Tail of the serialized lifecycle-action chain, or null when idle.
166+
Future<void>? _actionTail;
167+
144168
/// Starts periodic polling: refreshes immediately, then every [interval]
145169
/// while the window is visible, dropping to [hiddenInterval] while it is not.
146170
/// Cancels any previous poll. Owned here so the app can stop it cleanly on

app/lib/desktop/desktop_controller_test.dart

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,53 @@ void main() {
206206
expect(calls.single, ['/x/makit', 'stop']);
207207
});
208208

209+
test('lifecycle actions are serialized, never overlapping', () async {
210+
// start/stop/restart share the PID file and control socket, so overlapping
211+
// them can drop a fresh PID or launch a second daemon. They must run
212+
// strictly one at a time.
213+
var active = 0;
214+
var maxActive = 0;
215+
final gates = <Completer<void>>[];
216+
final lifecycle = DaemonLifecycle(
217+
resolver: MakitCliResolver(
218+
candidatePaths: const ['/x/makit'],
219+
exists: (_) => true,
220+
shellLookup: () async => null,
221+
),
222+
run: (exe, args) async {
223+
active++;
224+
if (active > maxActive) maxActive = active;
225+
final gate = Completer<void>();
226+
gates.add(gate);
227+
await gate.future;
228+
active--;
229+
return ProcessResult(0, 0, '', '');
230+
},
231+
);
232+
final c = DesktopController(
233+
client: _FakeControlClient(),
234+
lifecycle: lifecycle,
235+
);
236+
237+
// Fire two actions without awaiting them.
238+
final f1 = c.start();
239+
final f2 = c.stop();
240+
await pumpEventQueue();
241+
242+
// Only the first action has begun; the second is queued behind it.
243+
expect(gates.length, 1);
244+
expect(maxActive, 1);
245+
246+
gates[0].complete();
247+
await pumpEventQueue();
248+
// Now the second action runs — still alone.
249+
expect(gates.length, 2);
250+
gates[1].complete();
251+
await Future.wait([f1, f2]);
252+
253+
expect(maxActive, 1, reason: 'lifecycle actions must never overlap');
254+
});
255+
209256
test('notifies listeners on refresh', () async {
210257
var notes = 0;
211258
final c = DesktopController(

app/test/desktop/chat/server_profile_badge_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class _NoWriteFs extends FileSystemAdapter {
4545
String? readOrNull(String path) => null;
4646
@override
4747
void writeAtomic(String path, String contents) {}
48+
// Without this, the base withLock creates `<path>.lock` on the real disk.
49+
@override
50+
T withLock<T>(String path, T Function() body) => body();
4851
}
4952

5053
ProfileLifecycle _lifecycle({required bool targetRunning}) => ProfileLifecycle(

app/test/desktop/daemon/profile_lifecycle_test.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ void main() {
168168
statusProbe: (_) async => true,
169169
// No pid file exists for this fake home, so confirmation rests on the
170170
// socket going away — exactly what this test covers.
171-
readPid: (_) async => null,
171+
readPid: (_) => null,
172172
sleep: (_) async {},
173173
);
174174

@@ -248,7 +248,7 @@ void main() {
248248
resolver: _resolver(),
249249
run: _RecordingRunner().run,
250250
socketExists: (_) => false, // socket already gone
251-
readPid: (_) async => 4242,
251+
readPid: (_) => 4242,
252252
// Alive for the first two polls, then the process exits.
253253
processAlive: (pid) async {
254254
expect(pid, 4242);
@@ -278,7 +278,7 @@ void main() {
278278
resolver: _resolver(),
279279
run: _RecordingRunner().run,
280280
socketExists: (_) => false, // socket gone...
281-
readPid: (_) async => 99,
281+
readPid: (_) => 99,
282282
processAlive: (_) async => true, // ...but the process never exits
283283
sleep: (_) async {},
284284
);
@@ -306,7 +306,7 @@ void main() {
306306
probed = true;
307307
return false;
308308
},
309-
readPid: (_) async => null,
309+
readPid: (_) => null,
310310
sleep: (_) async {},
311311
);
312312

@@ -328,7 +328,7 @@ void main() {
328328
resolver: _resolver(path: null),
329329
run: _RecordingRunner().run,
330330
socketExists: (_) => false,
331-
readPid: (_) async => null,
331+
readPid: (_) => null,
332332
sleep: (_) async {},
333333
);
334334

app/test/desktop/daemon/profile_registry_test.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,9 +631,10 @@ void main() {
631631
final pa = await a.createUserProfile(name: 'Alpha');
632632
final pb = await b.createUserProfile(name: 'Beta');
633633
expect(pa.port, pb.port, reason: 'the scenario needs them to collide');
634+
final contestedPort = pa.port;
634635

635-
a.save();
636-
b.save();
636+
a.save(); // Alpha is persisted first, on the contested port.
637+
b.save(); // Beta collides; the already-persisted Alpha must keep it.
637638

638639
final onDisk = ProfileRegistry.load(
639640
makitRoot: kRoot,
@@ -646,6 +647,10 @@ void main() {
646647
ports.length,
647648
reason: 'no two profiles may share a port after the merge',
648649
);
650+
// The on-disk (possibly-running) profile keeps its port; the newcomer
651+
// yields.
652+
expect(onDisk.byId(pa.id)!.port, contestedPort);
653+
expect(onDisk.byId(pb.id)!.port, isNot(contestedPort));
649654
},
650655
);
651656
// The id is interpolated into a filesystem path (the secure-store namespace

app/test/desktop/daemon/profile_runtime_test.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// ignore_for_file: depend_on_referenced_packages, invalid_use_of_visible_for_testing_member
1010
import 'dart:io';
1111

12+
import 'package:flutter/foundation.dart' show FlutterError;
1213
import 'package:flutter_test/flutter_test.dart';
1314
import 'package:makit/desktop/daemon/daemon_lifecycle.dart';
1415
import 'package:makit/desktop/daemon/profile_lifecycle.dart';
@@ -189,5 +190,21 @@ void main() {
189190
);
190191
await expectLater(runtime.dispose(), completes);
191192
});
193+
194+
test('dispose disposes the profilesController (no leak per switch)', () async {
195+
// profilesController is injected via overrideWithValue, which Riverpod does
196+
// not dispose, so the runtime must. A disposed ChangeNotifier throws when a
197+
// listener is added.
198+
final runtime = ProfileRuntime.create(
199+
profile: _target,
200+
registry: _registry(),
201+
prefs: await _prefs(),
202+
);
203+
await runtime.dispose();
204+
expect(
205+
() => runtime.profilesController.addListener(() {}),
206+
throwsA(isA<FlutterError>()),
207+
);
208+
});
192209
});
193210
}

app/test/desktop/settings/profiles_section_test.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ class _MemFs extends FileSystemAdapter {
2323
String? readOrNull(String path) => null;
2424
@override
2525
void writeAtomic(String path, String contents) {}
26+
// Without this, the base withLock creates `<path>.lock` on the REAL disk
27+
// (under a non-existent `/Users/test`), which breaks the no-disk guarantee and
28+
// stalls the widget test's pumpAndSettle.
29+
@override
30+
T withLock<T>(String path, T Function() body) => body();
2631
}
2732

2833
/// An in-memory [ProfileFileSystem] for the deleter: only the paths seeded in
@@ -442,6 +447,11 @@ void main() {
442447
await tester.pumpAndSettle();
443448
await tester.tap(find.widgetWithText(FilledButton, 'Delete profile'));
444449
await tester.pumpAndSettle();
450+
// Advance the binding's (fake) clock so the delete's async work settles.
451+
// A bare `await Future.delayed(...)` deadlocks here: inside `testWidgets`
452+
// the clock only moves when the tester pumps.
453+
await tester.pump(const Duration(milliseconds: 100));
454+
await tester.pumpAndSettle();
445455

446456
final successes = center.events.where(
447457
(e) => e.severity == StatusSeverity.success,

app/test/desktop/settings/server_devices_section_test.dart

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Future<void> _pump(
7777
MakitConnState? connection,
7878
StatusCenter? statusCenter,
7979
ServerProfile? profile,
80+
NavigatorObserver? observer,
8081
bool tall = false,
8182
}) async {
8283
if (tall) {
@@ -100,7 +101,10 @@ Future<void> _pump(
100101
if (statusCenter != null)
101102
statusCenterProvider.overrideWithValue(statusCenter),
102103
],
103-
child: const MaterialApp(home: Scaffold(body: ServerDevicesSection())),
104+
child: MaterialApp(
105+
navigatorObservers: observer == null ? const [] : [observer],
106+
home: const Scaffold(body: ServerDevicesSection()),
107+
),
104108
),
105109
);
106110
await tester.pump();
@@ -408,29 +412,13 @@ void main() {
408412
testWidgets('nav rows disclose their content inline (no page push)', (
409413
tester,
410414
) async {
411-
final config = await makeConfig();
412415
final observer = _RecordingObserver();
413-
await tester.pumpWidget(
414-
ProviderScope(
415-
overrides: [
416-
serverConfigProvider.overrideWith((ref) => config),
417-
desktopControllerProvider.overrideWithValue(_controller()),
418-
connectionProvider.overrideWithValue(MakitConnState()),
419-
controlClientProvider.overrideWithValue(
420-
FakeControlClient(sessions: const []),
421-
),
422-
],
423-
child: MaterialApp(
424-
navigatorObservers: [observer],
425-
home: const Scaffold(body: ServerDevicesSection()),
426-
),
427-
),
416+
await _pump(
417+
tester,
418+
config: await makeConfig(),
419+
observer: observer,
420+
tall: true,
428421
);
429-
tester.view.physicalSize = const Size(1200, 2400);
430-
tester.view.devicePixelRatio = 1.0;
431-
addTearDown(tester.view.resetPhysicalSize);
432-
addTearDown(tester.view.resetDevicePixelRatio);
433-
await tester.pump();
434422
observer.pushed.clear();
435423

436424
expect(find.text('No running sessions'), findsNothing);

0 commit comments

Comments
 (0)