From fc88938258917926dd08c72c17d35eede2588a97 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 4 Jun 2026 09:29:56 +0200 Subject: [PATCH] fix(bitbox): coalesce overlapping scan ticks onto a single init() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectToBitbox guarded re-entry only with `state is BitboxConnecting`. Once the flow moved past that state (BitboxCheckHash) while the init future was still pending, a late 500ms scan tick re-entered connectToBitbox and started a SECOND init() on the shared BitBox SDK manager — undefined behaviour that wedged pairing. Extend the guard with `_pendingInit != null`, which covers the whole connect window. _pendingInit is cleared on failure/close, so genuine retries after a failed attempt still pass the guard. Regression (device-free): connect_bitbox_cubit_test.dart — a late connectToBitbox while init is pending must not call service.init again (verifyNever) and leaves the BitboxCheckHash state intact. Issue #657 — Part 7, finding F1 (HIGH). --- .../bloc/connect_bitbox_cubit.dart | 8 ++++- .../bloc/connect_bitbox_cubit_test.dart | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index 60170bb6..3ad9b0d3 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -64,7 +64,13 @@ class ConnectBitboxCubit extends Cubit { } Future connectToBitbox(sdk.BitboxDevice device) async { - if (state is BitboxConnecting) return; + // Coalesce overlapping scan ticks onto ONE connect attempt: the state + // guard alone is defeated once the flow moves past BitboxConnecting + // (e.g. BitboxCheckHash) while the init future is still pending — a late + // tick then started a second init() on the shared SDK manager and wedged + // pairing (issue #657 P7 F1). `_pendingInit` covers that whole window; it + // is cleared on failure, so a genuine retry still passes. + if (state is BitboxConnecting || _pendingInit != null) return; emit(BitboxConnecting(device)); try { // Snapshot any hash from a prior pairing on the same BitboxService diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index aa0420f2..6c9cd320 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -116,6 +116,36 @@ void main() { verify(() => authService.ensureSignatureFor(any())).called(1); }); + test('a late scan tick does not start a second init while one is pending ' + '(issue #657 P7 F1)', () async { + final initCompleter = Completer(); + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) => initCompleter.future); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 2 ? '' : 'HASH-1'; + }); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + // First connect runs init() once and settles in BitboxCheckHash — state + // is now past BitboxConnecting while the init future is still pending. + await waitForState(cubit); + verify(() => service.init(any())).called(1); + + // An overlapping/late scan tick re-invokes connectToBitbox. + await cubit.connectToBitbox(device); + + // It must coalesce onto the in-flight init, NOT start a second init on + // the shared SDK manager (which used to wedge pairing). + verifyNever(() => service.init(any())); + expect(cubit.state, isA()); + + initCompleter.complete(true); + }); + test('emits BitboxSignatureFailed when the signature capture throws', () async { var pollCount = 0; when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]);