diff --git a/docs/mla-next/VERIFY.md b/docs/mla-next/VERIFY.md index 68566c4..a312ebd 100644 --- a/docs/mla-next/VERIFY.md +++ b/docs/mla-next/VERIFY.md @@ -461,6 +461,51 @@ unabhängig reproduziert). **Task-5-Frischlauf (2026-09-30, auf Handoff-Stand):** `check-versions.sh` ok · `dart format` 123 Dateien 0 geändert · `flutter analyze` 0 Findings · `flutter test` +208 · additional/python 53 OK · GTK-Token-Gate 10 OK · la_core format 0 geändert / analyze clean / +61. **Rückfallplan.** `git revert` der #91-Commits (`f8a080e` … Handoff-Commit) genügt: neue Dateien (TOKENS.md, tokens.css, tokens-dark.css, test_tokens.py, Plan-Datei) plus kleine Änderungen (mla_app.py, build.yml, VERIFY.md); kein Datenpfad, keine Unit, kein Packaging, polkit-Trinität unberührt. +## Handoff #110-A — Registry-Race-Härtung (Nachtlauf 2026-09-30, abgeschlossen 2026-10-01) + +**Status:** Pool-Position A aus Issue #110 (Registry-Race-Härtung la_core) umgesetzt auf Branch `night/pool-a` (Basis-SHA `506eb88`). Umsetzung aus dem Nachtlauf (Automation) vom 2026-09-30 (`b13bdd7`, 2 Dateien, +298/−26); Review-Fix `530ea0f`, Handoff-Sektion, Gate-Frischlauf, RED-Belege, Ledger und Push/PR am 2026-10-01 interaktiv ergänzt. + +**Failing-Test/Fixture.** `packages/la_core/test/module_registry_ordering_test.dart` (neu, 6 Fälle) mit **echten `Completer`-Gates** (keine Fake-Async), gemäß Repo-Lektion. Testnamen: + +1. `activate nach laufendem deactivate derselben ID endet started` (geordnetes Last-Wins) +2. `deactivate nach laufendem activate derselben ID endet stopped` (Gegenrichtung) +3. `activate eines Abhaengigen startet den Dep nach dessen laufendem deactivate neu, statt ueber ihm zu starten` +4. `gestartetes Modul ueber inzwischen gestopptem Dep wird zurueckgerollt und wirft` +5. `paralleles activate zweier Abhaengiger startet den gemeinsamen Dep genau einmal` (Bestandsverhalten / Single-Flight) +6. `deactivateAll stoppt ein Modul im Uebergang, statt es zu ueberspringen (Last-Wins)` (Regressionstest zum Review-Fix `530ea0f`) + +**RED-Belege.** +- Gegen Basis `506eb88` (nur die Testdatei, `module_registry.dart` auf Basis getauscht): `+1 -4: Some tests failed.` → Tests 1–4 rot, Test 5 Basis-Verhalten grün. +- Gegen `b13bdd7` (vor dem Review-Fix): Test 6 rot — `Expected: ModuleState.stopped / Actual: ModuleState.started`. + +**Basis-Entscheidungen / gewählte Semantik.** +- `_Inflight` trägt die Richtung (`_Direction {activate, deactivate}`); nur gleichgerichtete Aufrufe teilen sich eine Future (Single-Flight). Ein Gegenrichtungs-Aufruf reiht sich hinter den laufenden Vorgang ein — geordnetes Last-Wins. +- Re-Check der Dep-States nach jedem `await` (Requires-Invariante): Ein Start über einem inzwischen gestoppten Dep rollt sich zurück (`_rollbackStart`) und wirft `ModuleRegistryError`. +- `_inflight` wird ausschließlich von `_run` geräumt (`identical`-Guard). +- **Review-Fix:** `deactivateAll`-Guard von `_states[id] == started` auf `_states[id] == started || _inflight.containsKey(id)` erweitert, damit ein Modul im `stopping`-Übergang (mit eingequeuter Gegenrichtungs-Aktivierung) nicht übersprungen wird (Last-Wins). + +**Gates — tatsächlich ausgeführt (2026-10-01, frisch auf `530ea0f`):** + +| Gate | Ausgabe | +|---|---| +| la_core `dart analyze` | `No issues found!` / Exit 0 | +| la_core `dart format --output=none --set-exit-if-changed .` | `Formatted 25 files (0 changed)` / Exit 0 | +| la_core `dart test` | `+67: All tests passed!` (61 Basis + 5 ordering + 1 Fix-Regression) / Exit 0 | +| `bash tool/check-versions.sh` | `version 0.8.0 is consistent` / Exit 0 | +| Root `dart format --output=none --set-exit-if-changed lib test` | `Formatted 123 files (0 changed)` / Exit 0 | +| `flutter analyze` | `No issues found!` / Exit 0 | +| `flutter test` | `+208: All tests passed!` / Exit 0 | +| `additional/python` unittest discover | `Ran 53 tests` / `OK` | + +**Rote/übersprungene Gates.** Keine roten. Übersprungen: `build-deb.sh` (kein Packaging-Bezug; CI baut beim PR), Branch-CI (läuft mit dem PR), GTK-Token-Gate (#91-Dateien, nicht im Scope), manuelle Gate-0-Checks (bleiben Basti). + +**Reviewer (Dual-Review, 2 parallele Subagenten auf `506eb88..b13bdd7`, 2026-10-01).** +- **A (Korrektheit): CHANGES_REQUIRED** — 1 major: `deactivateAll` übersprang Module im `stopping`-Übergang und verletzte damit Last-Wins (reproduzierbares Interleaving, Regression gegen Basis `506eb88`). → Fix `530ea0f` → **scoped Re-Review: ADDRESSED, keine neue Breakage** (Guard-Wirkung, Unreachability des settled-`stopped`-Falls und Testschärfe eigenständig nachgeprüft; Gegentest: Guard zurückgesetzt ⇒ Test 6 rot) ⇒ **APPROVED**. +- **B (Sicherheit/Scope/Spec): CHANGES_REQUIRED → nach Ergänzung dieser Sektion READY_FOR_PR** — einziges Finding war der noch fehlende Handoff-Abschnitt (Vertragspflicht), kein Code-Befund. Belegt: Scope exakt die 2 la_core-Dateien; öffentliche API-Fläche unverändert (nur neue Private); `lib/` nutzt `ModuleRegistry` nicht (einziger Konsument `test/hub_module_registry_test.dart`, kompatibel); keine Flutter-/`dart:io`-Kopplung; polkit-Trinität und Command-Queue unberührt; keine Secrets. + +**Rückfallplan.** `git revert 530ea0f b13bdd7` genügt: 2 la_core-Dateien (1 Implementierung + 1 neuer Test), kein Produktionscode außerhalb la_core, keine Unit, kein Packaging, polkit-Trinität unberührt. + +**Grenzen (bewusst offen).** `deactivateAll` deckt Module, die beim Aufruf `starting` sind (noch nicht in `_activationOrder`), weiterhin nicht ab — pre-existing, außerhalb des Diff-Scopes (Reviewer 1 + Fixer-Scopenotiz). Der Kommentar am erweiterten Guard nennt „stopping/starting"; erreichbar ist nur der `stopping`-Fall (Re-Review-Minor, reiner Kommentar-Wortlaut). Issue #110 Position C (Spawn-PATH-Fixierung) bleibt offen und braucht eine menschliche Sicherheitsentscheidung. Der Cross-ID-Race-Teil der #110-A-Checkbox ist über Test 3/5 abgedeckt (gemeinsamer Dep), nicht als eigener Cross-ID-Stresstest. ## Agenten-Handoff diff --git a/packages/la_core/lib/src/module_registry.dart b/packages/la_core/lib/src/module_registry.dart index f153dc2..3e3a305 100644 --- a/packages/la_core/lib/src/module_registry.dart +++ b/packages/la_core/lib/src/module_registry.dart @@ -1,8 +1,34 @@ +import 'dart:async'; + import 'event_bus.dart'; import 'module_descriptor.dart'; enum ModuleState { registered, starting, started, stopping, stopped } +/// Richtung der unter [ModuleRegistry._inflight] angemeldeten Operation. +/// Nur gleichgerichtete Aufrufe teilen sich eine Future; Gegenrichtungen +/// laufen nacheinander (geordnetes Last-Wins, Issue #110). +enum _Direction { activate, deactivate } + +/// Laufender Vorgang einer Modul-ID. Der Vorgang wird ueber [complete] bzw. +/// [fail] abgeschlossen; Nachfolger in Gegenrichtung warten auf [operation]. +class _Inflight { + _Inflight(this.direction); + + final _Direction direction; + final Completer _done = Completer(); + + Future get operation => _done.future; + + void complete() { + if (!_done.isCompleted) _done.complete(); + } + + void fail(Object error, StackTrace stackTrace) { + if (!_done.isCompleted) _done.completeError(error, stackTrace); + } +} + abstract interface class ModuleActivator { Future start(ModuleDescriptor module); Future stop(ModuleDescriptor module); @@ -30,7 +56,12 @@ class ModuleRegistry { final Map _modules = {}; final Map _states = {}; final Map _visible = {}; - final Map> _inflight = {}; + + /// Laufende Operation je Modul-ID. Vorher lag hier nur eine Future, wodurch + /// ein `activate` eine laufende Deaktivierung still als eigenes Ergebnis + /// teilte und ein Modul ueber einem bereits gestoppten Abhaengigen starten + /// konnte (Issue #110). + final Map _inflight = {}; final List _activationOrder = []; bool _validated = false; @@ -90,24 +121,105 @@ class ModuleRegistry { throw ModuleRegistryError('unknown module id: $id'); } if (!_validated) validate(); - return _inflight[id] ??= _activateSubtree(id); + return _enqueue(id, _Direction.activate); + } + + /// Reiht eine Operation fuer [id] ein. Gleichgerichtete Aufrufe teilen sich + /// die laufende Future (Single-Flight); ein Gegenrichtungs-Aufruf wartet auf + /// den laufenden Vorgang und startet danach — geordnetes Last-Wins, damit + /// `activate` nach einem `deactivate` tatsaechlich wieder startet (Issue + /// #110). + Future _enqueue(String id, _Direction direction) { + final existing = _inflight[id]; + if (existing == null) { + final entry = _Inflight(direction); + _inflight[id] = entry; + unawaited(_run(id, direction, entry)); + return entry.operation; + } + if (existing.direction == direction) return existing.operation; + + final entry = _Inflight(direction); + _inflight[id] = entry; + unawaited( + existing.operation + // Das Ergebnis des Vorgaengers ist nur fuer die Reihenfolge + // relevant; ein Fehler dort gehoert seinem Aufrufer, nicht dem + // Nachfolger. + .then((_) {}, onError: (Object _, StackTrace _) {}) + .then((_) => _run(id, direction, entry)), + ); + return entry.operation; + } + + /// Fuehrt die angemeldete Operation aus und schliesst [entry] ab. Fehler + /// laufen auf die Future des Eintrags, damit der `unawaited`-Start in + /// [_enqueue] keinen unbehandelten Fehler erzeugt. + Future _run(String id, _Direction direction, _Inflight entry) async { + try { + if (direction == _Direction.activate) { + await _activateSubtree(id); + } else { + await _deactivateWithDependents(id); + } + entry.complete(); + } catch (error, stackTrace) { + entry.fail(error, stackTrace); + } finally { + // Nur den eigenen Eintrag raeumen: eine bereits eingereihte + // Gegenrichtung haengt unter demselben Key und wartet auf uns. + if (identical(_inflight[id], entry)) { + _inflight.remove(id); + } + } } Future _activateSubtree(String id) async { for (final depId in _dependenciesOf(id)) { - if (_states[depId] != ModuleState.started) { - await (_inflight[depId] ??= _activateSubtree(depId)); + // Auch bei laufendem Vorgang anmelden: ist das eine Deaktivierung, + // reiht sich der Start dahinter ein und startet das Dep danach neu, + // statt still ueber einem gestoppten Dep zu starten (Issue #110). + final pending = _inflight[depId]; + if (pending != null || _states[depId] != ModuleState.started) { + await _enqueue(depId, _Direction.activate); } + _requireStarted(id, depId); } if (_states[id] == ModuleState.started) return; _states[id] = ModuleState.starting; - try { - await _activator.start(_modules[id]!); - _states[id] = ModuleState.started; - _activationOrder.add(id); - } finally { - _inflight.remove(id); + await _activator.start(_modules[id]!); + // Re-Check nach dem Await: waehrend des Starts kann ein Dep gestoppt + // worden sein. Dann nicht als started stehen bleiben, sondern den Start + // zurueckrollen und laut werfen (Requires-Invariante). + for (final depId in _dependenciesOf(id)) { + final pending = _inflight[depId]; + final depWillStop = + pending != null && pending.direction == _Direction.deactivate; + if (_states[depId] != ModuleState.started || depWillStop) { + await _rollbackStart(id); + throw ModuleRegistryError( + '$id requires $depId, which is no longer started', + ); + } } + _states[id] = ModuleState.started; + _activationOrder.add(id); + } + + void _requireStarted(String id, String depId) { + if (_states[depId] != ModuleState.started) { + throw ModuleRegistryError('$id requires $depId, which is not started'); + } + } + + /// Rollt einen Start zurueck, dessen Abhaengigkeiten waehrend des Starts + /// gestoppt wurden: das Modul laeuft, darf aber nicht als gestartet gefuehrt + /// werden. + Future _rollbackStart(String id) async { + _states[id] = ModuleState.stopping; + await _activator.stop(_modules[id]!); + _states[id] = ModuleState.stopped; + _activationOrder.remove(id); } List _dependenciesOf(String id) { @@ -129,7 +241,7 @@ class ModuleRegistry { if (!_modules.containsKey(id)) { throw ModuleRegistryError('unknown module id: $id'); } - return _inflight[id] ??= _deactivateWithDependents(id); + return _enqueue(id, _Direction.deactivate); } Future _deactivateWithDependents(String id) async { @@ -139,29 +251,21 @@ class ModuleRegistry { if (active == id) break; if (_states[active] == ModuleState.started && _transitivelyDependsOn(active, id)) { - await (_inflight[active] ??= _stopModule(active)); + await _stopModule(active); } } - // Direkt stoppen: _inflight[id] enthaelt bereits die Future DIESES - // Deactivate-Laufs (Eintrag in deactivate()); `??=` wuerde sonst auf - // uns selbst warten -> Deadlock. Single-Flight bleibt am EntryPoint - // von deactivate() gewaehrleistet. + // _inflight raeumt ausschliesslich _run: der eigene Eintrag traegt die + // Future, auf die eine bereits eingereihte Gegenrichtung wartet. if (_states[id] == ModuleState.started) { await _stopModule(id); - } else { - _inflight.remove(id); } } Future _stopModule(String id) async { _states[id] = ModuleState.stopping; - try { - await _activator.stop(_modules[id]!); - _states[id] = ModuleState.stopped; - _activationOrder.remove(id); - } finally { - _inflight.remove(id); - } + await _activator.stop(_modules[id]!); + _states[id] = ModuleState.stopped; + _activationOrder.remove(id); } bool _transitivelyDependsOn(String m, String target, [Set? seen]) { @@ -177,8 +281,13 @@ class ModuleRegistry { Future deactivateAll() async { for (final id in List.of(_activationOrder).reversed) { - if (_states[id] == ModuleState.started) { - await (_inflight[id] ??= _stopModule(id)); + // Auch Module im Uebergang (stopping/starting) erfassen: haengt dort + // bereits eine Gegenrichtungs-Aktivierung als Nachfolger unter + // _inflight, muss sich das "alles stoppen" dahinter einreihen — sonst + // gewinnt die gequeute Aktivierung und das Modul endet started, obwohl + // deactivateAll der zeitlich letzte Aufruf war (Issue #110). + if (_states[id] == ModuleState.started || _inflight.containsKey(id)) { + await _enqueue(id, _Direction.deactivate); } } } diff --git a/packages/la_core/test/module_registry_ordering_test.dart b/packages/la_core/test/module_registry_ordering_test.dart new file mode 100644 index 0000000..984a0f9 --- /dev/null +++ b/packages/la_core/test/module_registry_ordering_test.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:la_core/la_core.dart'; +import 'package:test/test.dart'; + +ModuleDescriptor mod(String id, {Set? requires}) { + return ModuleDescriptor( + id: id, + titleKey: 'title.$id', + viewId: 'view.$id', + kind: ModuleKind.tool, + requires: requires ?? const {}, + ); +} + +/// Aktivator mit Gates je `':'`. Echte Completer statt Fake-Async: +/// dart:io-Streams stallen unter Fake-Async (Repo-Lektion aus E.4). +class GatedActivator implements ModuleActivator { + GatedActivator({this.gates = const >{}}); + + final Map> gates; + final List log = []; + + @override + Future start(ModuleDescriptor module) async { + log.add('start ${module.id}'); + final gate = gates['start:${module.id}']; + if (gate != null) { + await gate.future; + } + } + + @override + Future stop(ModuleDescriptor module) async { + log.add('stop ${module.id}'); + final gate = gates['stop:${module.id}']; + if (gate != null) { + await gate.future; + } + } +} + +void main() { + test('activate nach laufendem deactivate derselben ID endet started ' + '(geordnetes Last-Wins)', () async { + final stopGate = Completer(); + final activator = GatedActivator(gates: {'stop:a': stopGate}); + final registry = ModuleRegistry(activator: activator); + registry.register(mod('a')); + + await registry.activate('a'); + expect(activator.log, ['start a']); + + final stopping = registry.deactivate('a'); + expect(registry.stateOf('a'), ModuleState.stopping); + + // Reiht sich hinter das laufende deactivate ein, statt dessen Future + // still als eigenes Ergebnis zu teilen. + final restarting = registry.activate('a'); + stopGate.complete(); + await stopping; + await restarting; + + expect(registry.stateOf('a'), ModuleState.started); + expect(activator.log, ['start a', 'stop a', 'start a']); + }); + + test( + 'deactivate nach laufendem activate derselben ID endet stopped', + () async { + final startGate = Completer(); + final activator = GatedActivator(gates: {'start:a': startGate}); + final registry = ModuleRegistry(activator: activator); + registry.register(mod('a')); + + final starting = registry.activate('a'); + expect(registry.stateOf('a'), ModuleState.starting); + expect(activator.log, ['start a']); + + final stopping = registry.deactivate('a'); + startGate.complete(); + await starting; + await stopping; + + expect(registry.stateOf('a'), ModuleState.stopped); + expect(activator.log, ['start a', 'stop a']); + }, + ); + + test('activate eines Abhaengigen startet den Dep nach dessen laufendem ' + 'deactivate neu, statt ueber ihm zu starten', () async { + final stopGate = Completer(); + final activator = GatedActivator(gates: {'stop:b': stopGate}); + final registry = ModuleRegistry(activator: activator); + registry.register(mod('a', requires: {'b'})); + registry.register(mod('b')); + + await registry.activate('b'); + expect(activator.log, ['start b']); + + final stopping = registry.deactivate('b'); + expect(registry.stateOf('b'), ModuleState.stopping); + + final activating = registry.activate('a'); + stopGate.complete(); + await stopping; + await activating; + + expect(registry.stateOf('b'), ModuleState.started); + expect(registry.stateOf('a'), ModuleState.started); + expect(activator.log, ['start b', 'stop b', 'start b', 'start a']); + }); + + test('gestartetes Modul ueber inzwischen gestopptem Dep wird zurueckgerollt ' + 'und wirft', () async { + final startGate = Completer(); + final stopGate = Completer(); + final activator = GatedActivator( + gates: {'start:a': startGate, 'stop:b': stopGate}, + ); + final registry = ModuleRegistry(activator: activator); + registry.register(mod('a', requires: {'b'})); + registry.register(mod('b')); + + await registry.activate('b'); + + // a haengt im Start, ist damit noch nicht in _activationOrder — ein + // deactivate von b findet es dort nicht als Abhaengigen. + final activating = registry.activate('a'); + expect(registry.stateOf('a'), ModuleState.starting); + + final stopping = registry.deactivate('b'); + expect(registry.stateOf('b'), ModuleState.stopping); + stopGate.complete(); + await stopping; + expect(registry.stateOf('b'), ModuleState.stopped); + + // Der Start laeuft jetzt durch, findet aber einen gestoppten Dep vor. + startGate.complete(); + await expectLater(activating, throwsA(isA())); + + expect(registry.stateOf('a'), ModuleState.stopped); + expect(activator.log, ['start b', 'start a', 'stop b', 'stop a']); + }); + + test('paralleles activate zweier Abhaengiger startet den gemeinsamen Dep ' + 'genau einmal', () async { + final startGate = Completer(); + final activator = GatedActivator(gates: {'start:c': startGate}); + final registry = ModuleRegistry(activator: activator); + registry.register(mod('a', requires: {'c'})); + registry.register(mod('b', requires: {'c'})); + registry.register(mod('c')); + + final first = registry.activate('a'); + final second = registry.activate('b'); + expect(activator.log, ['start c']); + + startGate.complete(); + await first; + await second; + + expect(activator.log.where((entry) => entry == 'start c'), hasLength(1)); + expect(registry.stateOf('c'), ModuleState.started); + expect(registry.stateOf('a'), ModuleState.started); + expect(registry.stateOf('b'), ModuleState.started); + }); + + test('deactivateAll stoppt ein Modul im Uebergang, statt es zu ' + 'ueberspringen (Last-Wins)', () async { + final stopGate = Completer(); + final activator = GatedActivator(gates: {'stop:a': stopGate}); + final registry = ModuleRegistry(activator: activator); + registry.register(mod('a')); + + await registry.activate('a'); + expect(activator.log, ['start a']); + + // Deaktivierung laeuft, Gate haelt sie auf: Modul ist stopping, aber + // weiterhin in _activationOrder. + final stopping = registry.deactivate('a'); + expect(registry.stateOf('a'), ModuleState.stopping); + + // Gegenrichtung reiht sich richtungsbewusst hinter der Deaktivierung ein. + final restarting = registry.activate('a'); + + // Zeitlich letzter Aufruf: alles stoppen. Das Modul ist im Uebergang + // (stopping + gequeute Aktivierung) und darf nicht uebersprungen werden. + final allStopped = registry.deactivateAll(); + + stopGate.complete(); + await stopping; + await restarting; + await allStopped; + + expect(registry.stateOf('a'), ModuleState.stopped); + expect(activator.log, ['start a', 'stop a', 'start a', 'stop a']); + }); +}