diff --git a/lib/services/observer_config_service.dart b/lib/services/observer_config_service.dart index b17ad1d..7025481 100644 --- a/lib/services/observer_config_service.dart +++ b/lib/services/observer_config_service.dart @@ -203,23 +203,41 @@ class ObserverConfigService extends ChangeNotifier { return BrokerConfig.fromWireFields(slot, kv); } - /// Read the broker pool (paginated START → KV → END). Null on timeout. + /// Read the broker pool (paginated START → KV → END). Null on a stall or + /// error. The completion deadline is an INACTIVITY watchdog re-armed on every + /// frame — a large pool streams field-by-field and outlasts any single fixed + /// deadline, so only a genuine stall (no frame for [timeout]) fails (#103). Future?> getBrokers() { return _serialized(() async { final decoder = BrokerListDecoder(); final completer = Completer>(); + Timer? idle; + void armIdle() { + idle?.cancel(); + idle = Timer(timeout, () { + if (!completer.isCompleted) { + completer.completeError( + TimeoutException('broker pool stalled', timeout), + ); + } + }); + } + final sub = _connector.receivedFrames.listen((f) { if (f.isEmpty || f[0] != ObserverConfigClient.respConfig) return; + armIdle(); // re-arm: a steadily-streaming dump must never time out. final list = decoder.add(ObserverConfigClient.parse(f)); if (list != null && !completer.isCompleted) completer.complete(list); }); try { await _connector.sendFrame(ObserverConfigClient.buildBrokers()); - return await completer.future.timeout(timeout); + armIdle(); // initial deadline: if the device never replies at all. + return await completer.future; } catch (e) { _setError('broker list failed: $e'); return null; } finally { + idle?.cancel(); await sub.cancel(); } }); diff --git a/test/services/observer_config_service_test.dart b/test/services/observer_config_service_test.dart index a975da9..fabb63f 100644 --- a/test/services/observer_config_service_test.dart +++ b/test/services/observer_config_service_test.dart @@ -134,6 +134,25 @@ Uint8List _respText(int sub, String text) => Uint8List.fromList([ 0, ]); +Uint8List _brokersStart(int count) => Uint8List.fromList([ + ObserverConfigClient.respConfig, + ObserverConfigClient.rBrokersStart, + count, +]); + +Uint8List _brokerKv(int slot, String key, String value) => Uint8List.fromList([ + ObserverConfigClient.respConfig, + ObserverConfigClient.rBrokerKv, + slot, + ...utf8.encode('$key=$value'), + 0, +]); + +Uint8List _brokersEnd() => Uint8List.fromList([ + ObserverConfigClient.respConfig, + ObserverConfigClient.rBrokersEnd, +]); + Future _tick() => Future.delayed(const Duration(milliseconds: 10)); void main() { @@ -373,4 +392,33 @@ void main() { auto.closeStream(); }, ); + + // ---- #103: a slow-but-steady dump must outlast the fixed timeout ---- + test('getBrokers completes a dump that outlasts the per-frame timeout while ' + 'frames keep arriving (re-armed inactivity watchdog) — #103', () async { + svc.timeout = const Duration(milliseconds: 80); + final future = svc.getBrokers(); + final frames = [ + _brokersStart(2), + _brokerKv(0, 'url', 'mqtt://a'), + _brokerKv(0, 'enabled', '1'), + _brokerKv(1, 'url', 'mqtt://b'), + _brokerKv(1, 'enabled', '0'), + _brokersEnd(), + ]; + // Each 50ms gap is under the 80ms timeout, but the whole dump (~300ms) + // far exceeds it: a fixed deadline kills it, a re-armed watchdog does not. + for (final f in frames) { + await Future.delayed(const Duration(milliseconds: 50)); + c.inject(f); + } + final list = await future; + expect( + list, + isNotNull, + reason: 'a steadily-streaming dump must not time out (#103)', + ); + expect(list!.length, 2); + expect(list.map((b) => b.slot).toList(), [0, 1]); + }); }