diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index 08c008c..b2f7e7e 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -196,15 +196,11 @@ class FirmwareUpdateService { ); _emitProgress(state: DfuUpdateState.finishing); - final finishStatus = await _writeControlAndWaitForStatus( - BootloaderDfuProtocol.encodeFinishPayload(normalizedSessionId), - timeout: effectiveStatusTimeout, - ); - _requireOkStatus( - finishStatus, + await _writeFinishAndWaitForReset( sessionId: normalizedSessionId, expectedOffset: imageBytes.length, - operation: 'FINISH', + statusTimeout: effectiveStatusTimeout, + resetTimeout: effectivePostFinishResetTimeout, ); await _statusSubscription?.cancel(); @@ -212,15 +208,6 @@ class FirmwareUpdateService { _emitProgress( state: DfuUpdateState.rebooting, sentBytes: imageBytes.length); - final resetDisconnectResult = - await _transport.waitForBootloaderDisconnect( - timeout: effectivePostFinishResetTimeout, - ); - if (resetDisconnectResult.isErr()) { - throw _DfuFailure( - 'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}', - ); - } if (!verifyAfterFinish) { _emitProgress( @@ -554,6 +541,76 @@ class FirmwareUpdateService { ); } + Future _writeFinishAndWaitForReset({ + required int sessionId, + required int expectedOffset, + required Duration statusTimeout, + required Duration resetTimeout, + }) async { + final eventCount = _statusEventCount; + final result = await _transport.writeControl( + BootloaderDfuProtocol.encodeFinishPayload(sessionId), + ); + if (result.isErr()) { + throw _DfuFailure( + 'Failed to write bootloader control command: ${result.unwrapErr()}', + ); + } + + final deadline = DateTime.now().add(resetTimeout); + var observedEvents = eventCount; + while (true) { + _throwIfCancelled(); + _throwIfStatusStreamErrored(); + + if (_statusEventCount > observedEvents && _latestStatus != null) { + final status = _latestStatus!; + _requireOkStatus( + status, + sessionId: sessionId, + expectedOffset: expectedOffset, + operation: 'FINISH', + ); + final remaining = deadline.difference(DateTime.now()); + final resetDisconnectResult = + await _transport.waitForBootloaderDisconnect( + timeout: remaining > Duration.zero ? remaining : Duration.zero, + ); + if (resetDisconnectResult.isErr()) { + throw _DfuFailure( + 'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}', + ); + } + return; + } + + final disconnectResult = await _transport.waitForBootloaderDisconnect( + timeout: Duration.zero, + ); + if (disconnectResult.isOk()) { + return; + } + + final remaining = deadline.difference(DateTime.now()); + if (remaining <= Duration.zero) { + throw _DfuFailure( + 'Bootloader did not perform the expected post-FINISH reset disconnect within ${resetTimeout.inMilliseconds}ms.', + ); + } + + final waitDuration = + remaining < statusTimeout ? remaining : statusTimeout; + final gotEvent = await _waitForNextStatusEvent( + afterEventCount: observedEvents, + timeout: waitDuration, + recoverable: false, + ); + if (gotEvent) { + observedEvents = _statusEventCount - 1; + } + } + } + Future _waitForStatus({ required int afterEventCount, required Duration timeout, diff --git a/test/service/firmware_update_service_test.dart b/test/service/firmware_update_service_test.dart index 5a49493..9a14cc9 100644 --- a/test/service/firmware_update_service_test.dart +++ b/test/service/firmware_update_service_test.dart @@ -140,6 +140,89 @@ void main() { await transport.dispose(); }); + test('completes when FINISH status is lost but bootloader disconnects', + () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + suppressFinishStatus: true, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 20), + defaultPostFinishResetTimeout: const Duration(milliseconds: 100), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 15, + ); + + expect(result.isOk(), isTrue); + expect( + transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]); + expect(transport.steps, contains('reconnectForVerification')); + expect(transport.steps, contains('verifyDeviceReachable')); + expect(service.currentProgress.state, DfuUpdateState.completed); + + await service.dispose(); + await transport.dispose(); + }); + + test('fails when FINISH status is lost and bootloader stays connected', + () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + suppressFinishStatus: true, + disconnectAfterFinish: false, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 10), + defaultPostFinishResetTimeout: const Duration(milliseconds: 30), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 16, + ); + + expect(result.isErr(), isTrue); + expect(result.unwrapErr().toString(), contains('post-FINISH reset')); + expect(service.currentProgress.state, DfuUpdateState.failed); + expect(transport.steps, isNot(contains('reconnectForVerification'))); + + await service.dispose(); + await transport.dispose(); + }); + + test('fails when FINISH returns explicit bootloader error', () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + finishStatusCode: DfuBootloaderStatusCode.flashError, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 20), + defaultPostFinishResetTimeout: const Duration(milliseconds: 100), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 17, + ); + + expect(result.isErr(), isTrue); + expect(result.unwrapErr().toString(), contains('flash error')); + expect(service.currentProgress.state, DfuUpdateState.failed); + expect(transport.steps, isNot(contains('reconnectForVerification'))); + + await service.dispose(); + await transport.dispose(); + }); + test('reconnects and resumes from status after transient data failure', () async { final image = _validImage(130); @@ -297,6 +380,9 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { this.suppressFirstDataStatus = false, this.failDataWriteAtOffsetOnce, this.resetSessionOnRecoveryStatus = false, + this.suppressFinishStatus = false, + this.disconnectAfterFinish = true, + this.finishStatusCode = DfuBootloaderStatusCode.ok, this.onDataWrite, }); @@ -308,6 +394,9 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { final bool suppressFirstDataStatus; final int? failDataWriteAtOffsetOnce; final bool resetSessionOnRecoveryStatus; + final bool suppressFinishStatus; + final bool disconnectAfterFinish; + final DfuBootloaderStatusCode finishStatusCode; final void Function()? onDataWrite; final StreamController> _statusController = @@ -323,6 +412,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { bool _sentDataFailure = false; bool _sentQueueFull = false; bool _suppressedDataStatus = false; + bool _finishDisconnectAvailable = false; @override Future> isConnectedToBootloader() async { @@ -389,7 +479,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { } _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); } else if (opcode == universalShifterDfuOpcodeFinish) { - _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); + if (suppressFinishStatus) { + _finishDisconnectAvailable = disconnectAfterFinish; + } else { + _scheduleStatus(finishStatusCode, payload[1], totalBytes); + } } else if (opcode == universalShifterDfuOpcodeAbort) { _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0); } @@ -431,7 +525,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { @override Future> waitForBootloaderDisconnect( {required Duration timeout}) async { + if (timeout == Duration.zero && !_finishDisconnectAvailable) { + return bail('still connected'); + } steps.add('waitForBootloaderDisconnect'); + _finishDisconnectAvailable = true; return Ok(null); }