Skip to content

fix(nfc): stop leaking sessions that wedge polling with a 408 - #2556

Open
davidcreated wants to merge 2 commits into
SatoshiPortal:developfrom
davidcreated:fix/nfc-session-leak-2552
Open

fix(nfc): stop leaking sessions that wedge polling with a 408#2556
davidcreated wants to merge 2 commits into
SatoshiPortal:developfrom
davidcreated:fix/nfc-session-leak-2552

Conversation

@davidcreated

Copy link
Copy Markdown

Closes #2552.

Root cause

flutter_nfc_kit keeps its session state in process-global fields — pollingTimeoutTask, tagTechnology, ndefTechnology all live on the plugin's companion object. pollTag arms a 20 s timer that disables reader mode and returns error("408", "Polling tag timeout"), registers Android reader mode, and never disables reader mode on a successful poll. The "finish" handler is the only code that cancels the timer, closes the tag and disables reader mode.

Two paths polled without ever finishing:

  • NfcScannerWidget._scan — no finish() on the error path, and none on the success path either (only dispose() did it).
  • BroadcastSignedTxCubit.onNfcScanned — called readNDEFRecords() and never finished at all.

So after an aborted or PushTx scan, reader mode stayed registered and the tag stayed connected. The next poll re-registered a callback waiting for a tag-discovery event, which cannot fire for a card that never left the field — 20 s later, 408, and every subsequent attempt failed the same way until the app restarted. That is the reported "works a few times, then always fails".

Three more failure modes fall out of the same root cause:

  1. "finish" cancels pollingTimeoutTask unconditionally, so a finish() fired by a disposing widget (unawaited(_finishNfcSession())) could land after a new poll started and kill that poll's timer and its reader mode. That one does not even 408 — the platform reply is never delivered, so the spinner runs forever.
  2. The static entry points had no re-entrancy guard, so a double tap opened overlapping sessions.
  3. An abandoned poll's MethodChannel reply is never delivered, leaving a dangling Dart future with no timeout on our side.

On iOS the same missing finish() is worse: poll returns 406 when session != nil, and the plugin's tagReaderSession(_:didInvalidateWithError:) returns early on guard result != nil before clearing session. One missed finish() wedges every later scan at 406 permanently, until the app is force-quit. That is an upstream plugin bug; this PR cannot fix it, only make missing a finish() structurally hard.

What changed

One owner for the NFC session. New lib/core/nfc/:

File Role
data/nfc_kit_datasource.dart The only file in the app that names FlutterNfcKit. Also the seam the tests inject.
data/nfc_session_impl.dart The fix: serializes every operation behind a Lock, runs finish() in a finally on every path, guards cancel() with a generation counter, watchdogs each step.
data/nfc_error_mapper.dart PlatformException → typed failure, code-first with iOS message fallbacks.
domain/nfc_failure.dart Sealed NfcFailure family (AGENTS.md rule #11), Flutter-free.
domain/nfc_session.dart Three-method interface returning Result<T, NfcFailure>.
presentation/nfc_failure_l10n.dart toTranslated(BuildContext); returns null for a cancelled session so "show nothing on cancel" is enforced by the type.
nfc_locator.dart Registers NfcSession as a lazy singleton — one Lock per process, to match the plugin's process-global state. A registerFactory here would silently restore the bug.
  • Dart-side watchdog per step, because the platform timer is exactly what a stale finish() cancels — the platform 408 cannot be relied on. Poll 20 s on Android / 75 s on iOS (above the OS's own 60 s invalidation), transfer 30 s, finish 3 s so a hung finish degrades to a logged warning instead of holding the lock. Exactly one value leaves the lock body, so there is no double-completion path.
  • Poll timeout is now explicit at 15 s instead of relying on the plugin's 20 s default. All timings are public constants so tests can elapse them exactly.
  • nfcError is deleted. It was context.loc.nfcError(error.toString()), i.e. NFC error: PlatformException(408, Polling tag timeout, null, null) in a snackbar. Removing the key makes that leak un-reintroducible; raw detail now goes to log.warning only, and logMessage never reaches the UI.
  • A timeout is now retryable in place — error icon, nfcTimeout text, working "Try Again". The old retry button only rendered when _tag != null, i.e. only after a success, so it was unreachable after a failure. ScanNfcPage passed no onError at all and silently swallowed every poll failure; it now renders the typed failure.
  • The two divergent call paths are unified behind one NfcScanFlow driver plus a presentational NfcScanView; NfcScannerWidget is deleted. A widget owning poll + finish while the bottom sheet also owned poll + finish over one process-global state was the structural cause of the divergence. NfcBottomSheet.showReadNfc / showWriteNfc keep their exact signatures, so the three call sites are untouched.
  • Lifecycle: backgrounding mid-poll cancels the session. On Android reader mode is tied to a resumed activity, so an in-flight poll across a background transition is already dead — cancelling turns a guaranteed 20 s dead end into an immediate, honest retry.
  • The scan UI is now the same on both platforms, so an iOS timeout has a reachable "Try Again" instead of an after-the-fact snackbar. This removes the last Platform.isIOS from the UI layer.
  • PushTx stops parsing NDEF in the cubit. onNfcScanned(NFCTag)onPushTxPayload(String), dropping the records.first.toString() + RegExp('uri=([^ ]+)') hack and the direct datasource call from a cubit (AGENTS.md rule Sync in Isolate #2). pushTxUriFromNdefRecords handles it as a pure, tested function.

New l10n keys: nfcTimeout, nfcBusy, nfcUnsupportedTag, nfcScanInstructions (en only; other locales follow the usual chore(l10n) pass). Added via tools/arb.dart, not by hand.

Testing

65 new/extended tests, none needing a Coldcard:

  • finish() is always called — table-driven over every failure injection point (poll 408, poll 409, tag removed mid-read, unparseable payload, write error, NDEF-unsupported tag), each asserting the mapped failure and verify(finish).called(1). Plus the negative: NFC off / no NFC hardware → verifyNever(poll) and verifyNever(finish), because no session was opened.
  • The 408 cascade, deterministically — with fake_async, a second scan during an in-flight poll returns NfcBusyFailure with poll called exactly once (overlapping polls are unreachable, so the stale-timer cascade cannot happen), the first then times out via the watchdog, and a following scan succeeds. The reported symptom asserted in reverse: the session self-heals instead of staying wedged.
  • A hung finish cannot deadlock the next scan; cancel() ends an in-flight scan and closes once, is a no-op when idle, and cannot touch a later operation.
  • iOS tag-lost retry restarts polling and succeeds on a later attempt, gives up after the bounded attempts with the right iOS error message, and does not retry on Android.
  • Failure-code mapping table, translation exhaustiveness (every variant has a non-empty message that does not contain its logMessage), parser, and the cubit's PushTx validation paths.

Verified on device.

All CI gates run locally on this branch, on top of current develop:

Gate Result
make analyze (--fatal-warnings --fatal-infos) No issues found
make bull-ui-check clean
make fix-check Nothing to fix!
make format-check 0 of 1632 files changed
make unit-test 929 tests, all passed

Belt-and-braces greps: FlutterNfcKit appears in exactly one file, and nfcError appears nowhere in lib/ or localization/.

Notes for reviewers

  • AbsoluteUriRecord is deliberately unsupported in pushTxUriFromNdefRecords. In ndef 0.4.0, AbsoluteUriRecord.uri returns decodedType and decodedType returns uri, so touching either recurses until the stack overflows. The Coldcard PushTx tag is a well-known URI record, and the previous regex-over-toString() approach would have hit the same recursion, so this is parity, not a regression.
  • Poll technology flags are unchanged (readIso15693: true, readIso18092: false, 14443A/B left at the plugin defaults). Narrowing to ISO-15693-only would want hardware QA on both Mk4 and Q; not worth bundling that risk here.
  • The two upstream flutter_nfc_kit bugs — no disableReaderMode after a successful poll, and the iOS invalidation-ordering guard — are worked around, not fixed. Worth filing upstream.
  • transferWatchdog = 30 s is an estimate for large-PSBT writes over ISO-15693. It is a public constant precisely so it is easy to tune if a big PSBT ever trips it.
  • Instrumentation is permanent and log.warning-only: one line per operation with elapsed ms and mapped failure class, one when a watchdog trips ("platform never replied" — the smoking gun for the dangling-future mode), one when finish fails or cancel is skipped by the generation guard. No payload, PSBT, tx, NDEF bytes or tag id is ever logged, since logs are user-shareable.

Closes SatoshiPortal#2552.

flutter_nfc_kit keeps its session state in process-global fields
(pollingTimeoutTask, tagTechnology, ndefTechnology) and its finish
handler is the only code that cancels the poll timer, closes the tag and
disables Android reader mode — pollTag never disables reader mode on a
successful poll. Two paths polled without finishing: NfcScannerWidget on
both its success and error paths, and BroadcastSignedTxCubit.onNfcScanned
which read NDEF records and never finished. Reader mode therefore stayed
registered and the next poll waited for a tag-discovery event that cannot
fire for a card already held, so it failed with 408 Polling tag timeout
and kept failing until the app restarted.

Three more failure modes fell out of the same root cause: a finish() from
a disposing widget could land after a new poll started and cancel its
timer and reader mode, leaving a spinner that never resolved and never
errored; the static entry points had no re-entrancy guard, so a double
tap opened overlapping sessions; and an abandoned poll's platform reply
was never delivered, leaving a dangling Dart future with no timeout.

NFC now goes through one locator-registered NfcSession singleton that
serializes every operation behind a Lock, runs finish() in a finally on
every path, and guards cancel() with a generation counter so a late
cancel cannot touch a later operation. Each step has a Dart-side
watchdog, because the platform timer is exactly what a stale finish
cancels. FlutterNfcKit is now named in exactly one file.

Errors become a sealed NfcFailure family mapped from platform codes
(408/406/405/404/503/500/409/400) with iOS message fallbacks, translated
in a presentation extension. The nfcError key that printed raw
PlatformException text into a snackbar is deleted so it cannot come back.
A timeout now renders in place with a working Try Again button; the old
retry button only appeared after a success, so it was unreachable
after a failure. ScanNfcPage previously passed no onError at all and
swallowed every poll failure.

The PushTx path stops reading NDEF records in the cubit and parsing them
with a regex over record.toString(); pushTxUriFromNdefRecords handles it
as a pure function. AbsoluteUriRecord is deliberately not supported —
ndef 0.4.0 has uri and decodedType delegating to each other, so touching
either recurses until the stack overflows.

Poll timeout is explicit at 15s instead of the plugin's 20s default, and
the scan UI is now the same on both platforms so an iOS timeout has a
reachable retry instead of an after-the-fact snackbar.

Covered by unit tests that need no hardware: finish() is asserted on
every failure injection point, the overlap/timeout/self-heal sequence is
driven with fake_async, and the iOS tag-lost retry, failure mapping,
translation exhaustiveness, parser and cubit paths each have their own.

(cherry picked from commit 13d60a21cdd4412826a238fcf365557c4ebfadaa)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NFC: intermittent polling tag timeout needs QA and hardening

1 participant