@@ -160,6 +160,10 @@ class PbleConnectionManager implements ConnectionManager {
160160 ScanHit ? _selected;
161161 Object ? _lastError;
162162 bool _disposed = false ;
163+ int _intent = 0 ;
164+ Future <void >? _disposeFuture;
165+ final Set <Future <void >> _retirements = < Future <void >> {};
166+ final Map <String , Future <void >> _openings = < String , Future <void >> {};
163167
164168 @override
165169 Connection get connection => _facade;
@@ -181,6 +185,7 @@ class PbleConnectionManager implements ConnectionManager {
181185
182186 @override
183187 Future <void > startScan ({Duration ? timeout}) async {
188+ _ensureActive ();
184189 // Fire-and-forget: a broadcast-subscription cancel() future never completes
185190 // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in
186191 // every widget test. The listener is removed synchronously either way; the
@@ -199,6 +204,7 @@ class PbleConnectionManager implements ConnectionManager {
199204
200205 @override
201206 Future <void > stopScan () async {
207+ _ensureActive ();
202208 // Fire-and-forget: a broadcast-subscription cancel() future never completes
203209 // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in
204210 // every widget test. The listener is removed synchronously either way; the
@@ -208,75 +214,170 @@ class PbleConnectionManager implements ConnectionManager {
208214 await _scanner.stopScan ();
209215 // Only fall back to idle when no board is live (a connected session keeps
210216 // its connected/connecting phase).
211- if (_liveBoard == null && _phase.value == ConnectPhase .scanning) {
217+ if (! _disposed &&
218+ _liveBoard == null &&
219+ _phase.value == ConnectPhase .scanning) {
212220 _phase.value = ConnectPhase .idle;
213221 }
214222 }
215223
216224 @override
217225 Future <void > connect (String id) async {
226+ _ensureActive ();
227+ final int intent = ++ _intent;
228+ _detachAndRetire ();
218229 // Scanning and GATT do not run together — stop the scan first (FR-BLE-5).
219230 // Fire-and-forget: a broadcast-subscription cancel() future never completes
220231 // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in
221232 // every widget test. The listener is removed synchronously either way; the
222233 // completion signal is not needed here (matches _StableFacade.detach below).
223234 unawaited (_scanSub? .cancel ());
224235 _scanSub = null ;
225- await _scanner.stopScan ();
226-
227236 _selected = _hits[id] ?? ScanHit (id: id, name: '' , rssi: 0 );
228237 _lastError = null ;
229238 _phase.value = ConnectPhase .connecting;
230239 try {
231- final Connection board = await _connectionFactory (id);
232- _liveBoard = board;
233- _facade.attach (board);
234- // Derive the phase from the freshly attached board's live state (the
235- // listener also fires on the sync-up, but re-derive to cover a board that
236- // attaches already-ready with no state change).
237- _phase.value = _phaseForConn (_facade.state.value);
240+ await _scanner.stopScan ();
241+ // A previous Disconnect/replacement may still own a physical close.
242+ // Do not open another board until those exact retirements complete.
243+ await Future .wait (_retirements.toList ());
244+ if (! _isCurrent (intent)) return ;
245+ if (_openings[id] case final Future <void > previous) {
246+ // Native wrappers with the same ID share one physical link. Finish
247+ // the stale acquisition AND its teardown before opening that ID again.
248+ // Its factory error belongs to its caller; a failed physical close is
249+ // independently retained in _retirements and must still block us.
250+ try {
251+ await previous;
252+ } catch (_) {}
253+ await Future .wait (_retirements.toList ());
254+ if (! _isCurrent (intent)) return ;
255+ }
256+ final Future <void > opening = Future <void >.microtask (
257+ () => _openAndPublish (id, intent),
258+ );
259+ _openings[id] = opening;
260+ try {
261+ await opening;
262+ } finally {
263+ if (identical (_openings[id], opening)) _openings.remove (id);
264+ }
238265 } catch (error) {
239- _lastError = error;
240- _phase.value = ConnectPhase .failed;
266+ if (_isCurrent (intent)) {
267+ _lastError = error;
268+ _phase.value = ConnectPhase .failed;
269+ }
241270 rethrow ;
242271 }
243272 }
244273
274+ Future <void > _openAndPublish (String id, int intent) async {
275+ if (! _isCurrent (intent)) return ;
276+ final Connection board = await _connectionFactory (id);
277+ if (! _isCurrent (intent)) {
278+ await _retire (board);
279+ return ;
280+ }
281+ _liveBoard = board;
282+ _facade.attach (board);
283+ _phase.value = _phaseForConn (_facade.state.value);
284+ }
285+
245286 @override
246287 Future <void > disconnect () async {
247- final Connection ? board = _liveBoard;
248- // Null the live board BEFORE detaching so the facade's reset to
249- // disconnected does not drive the phase (we set it to idle explicitly).
250- _liveBoard = null ;
251- _facade.detach ();
252- await board? .dispose ();
288+ _ensureActive ();
289+ final int intent = ++ _intent;
290+ _detachAndRetire ();
253291 _selected = null ;
254- _phase.value = ConnectPhase .idle;
292+ try {
293+ await Future .wait (_retirements.toList ());
294+ if (_isCurrent (intent)) {
295+ _lastError = null ;
296+ _phase.value = ConnectPhase .idle;
297+ }
298+ } catch (error) {
299+ if (_isCurrent (intent)) {
300+ _lastError = error;
301+ _phase.value = ConnectPhase .failed;
302+ }
303+ rethrow ;
304+ }
255305 }
256306
257307 @override
258- Future <void > dispose () async {
259- if (_disposed ) return ;
308+ Future <void > dispose () {
309+ if (_disposeFuture case final Future < void > pending ) return pending ;
260310 _disposed = true ;
311+ _intent++ ;
312+ return _disposeFuture = Future <void >.microtask (_disposeResources);
313+ }
314+
315+ Future <void > _disposeResources () async {
316+ Object ? firstError;
317+ StackTrace ? firstStack;
318+ Future <void > attempt (FutureOr <void > Function () action) async {
319+ try {
320+ await action ();
321+ } catch (error, stack) {
322+ firstError ?? = error;
323+ firstStack ?? = stack;
324+ }
325+ }
326+
261327 // Fire-and-forget: a broadcast-subscription cancel() future never completes
262328 // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in
263329 // every widget test. The listener is removed synchronously either way; the
264330 // completion signal is not needed here (matches _StableFacade.detach below).
265331 unawaited (_scanSub? .cancel ());
266332 _scanSub = null ;
267333 _facade.state.removeListener (_onFacadeState);
334+ _detachAndRetire ();
335+ _selected = null ;
336+ await Future .wait (< Future <void >> [
337+ attempt (_scanner.stopScan),
338+ attempt (() => Future .wait (_retirements.toList ())),
339+ ]);
340+ await attempt (_scanResults.close);
341+ await attempt (_facade.dispose);
342+ await attempt (_phase.dispose);
343+ if (firstError != null ) {
344+ Error .throwWithStackTrace (firstError! , firstStack! );
345+ }
346+ }
347+
348+ bool _isCurrent (int intent) => ! _disposed && _intent == intent;
349+
350+ void _ensureActive () {
351+ if (_disposed) throw StateError ('connection manager disposed' );
352+ }
353+
354+ void _detachAndRetire () {
268355 final Connection ? board = _liveBoard;
269356 _liveBoard = null ;
270- await board? .dispose ();
271- await _scanResults.close ();
272- await _facade.dispose ();
273- _phase.dispose ();
357+ _facade.detach ();
358+ if (board != null ) _retire (board);
359+ }
360+
361+ Future <void > _retire (Connection board) {
362+ final Future <void > closing = Future <void >.sync (board.dispose);
363+ _retirements.add (closing);
364+ // Remove only successful retirements. A failed physical close is not proof
365+ // that another link can safely be opened; later operations must still see
366+ // that failure. Consume this bookkeeping branch's error without replacing
367+ // the original future or issuing an automatic disconnect retry.
368+ unawaited (
369+ closing.then <void >(
370+ (_) => _retirements.remove (closing),
371+ onError: (Object _, StackTrace _) {},
372+ ),
373+ );
374+ return closing;
274375 }
275376
276377 // Track the session phase off the stable facade's live ConnState. Only
277378 // meaningful while a board is attached; scan/failure phases own the idle path.
278379 void _onFacadeState () {
279- if (_liveBoard == null ) return ;
380+ if (_disposed || _liveBoard == null ) return ;
280381 _phase.value = _phaseForConn (_facade.state.value);
281382 }
282383
0 commit comments