Skip to content

ENG-704: keypad no longer toasts ERR_MULTI_REQ after backing out of a card charge - #91

Merged
islandbitcoin merged 4 commits into
mainfrom
jabariennis/eng-704
Oct 14, 2026
Merged

islandbitcoin merged 4 commits into
mainfrom
jabariennis/eng-704

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On Android, backing out of a card charge (sub-cent sat amount → CashuCardCharge) to the keypad could toast "You can only issue one request at a time".

The keypad arms a card request as soon as it regains focus. On the JS stack navigator, that focus fires before the popped charge screen unmounts, and the charge screen only cancelled its own pending request in its unmount cleanup. So the keypad's requestTechnology landed on a live request. react-native-nfc-manager rejected it with ERR_MULTI_REQ (NfcManager.java:61), and runSession toasted the literal string. The loop re-armed 750 ms later and worked, so the toast was pure noise. CashuCardSpend, CashuCardDebug and Invoice had the same unmount-only cancel.

The unmount cancel had a second, worse effect. It lands at the end of the close transition (about 350 to 400 ms), after the keypad has armed. If a card is mid-read on the keypad by then, cancelCardSession closes the keypad's tag under its transceive and the merchant sees a card-failure toast.

Change

Three layers. Layer 0 removes the cause at the card screens; layers 1 and 2 in the keypad are the backstop for any screen that still leaves a request behind.

  1. Card screens release from beforeRemove (new src/hooks/useCardSessionRelease.ts). release cancels the screen's request as the stack lets it go, before the keypad's focus effect runs, so the keypad arms against a clear radio. Once released, the hook's unmount cleanup does nothing: whatever is pending by then belongs to the next screen. A screen removed with no beforeRemove (a parent unmounting, a test) still cancels at unmount.
    • CashuCardSpend, CashuCardDebug and Invoice pass navigation and use the hook's own listener. Spend and Debug set their cancelledRef in onRelease, so the read they cut is treated as their own cancel.
    • CashuCardCharge has its own beforeRemove listener that can refuse the pop (held during the burn, or settled and routed to Done). It calls release only from the let-through branches, so a refused pop keeps the charge's session. A plain pop before the burn also sets cancelledRef, so the cut read is never painted as an error on the way out.
    • docs/13-cashu-card.md now tells new card screens to use the hook instead of an unmount cancel.
  2. Flush before arming (src/hooks/useKeypadCardReader.ts, useFocusEffect), now a backstop. On Android, an awaited, generation-guarded cancelCardSession() runs ahead of armLoop in the loopRef chain. It catches a request that layer 0 did not release (a screen removed without the event, a session opened in the gap). The chain still waits for the previous loop to end (the blur's cancel), so the flush never cancels the keypad's own request, and the "never arms twice" guard holds. When nothing is pending, the flush is a no-op.
  3. Classify ERR_MULTI_REQ as busy (src/services/cashuCardNfc.ts adds MULTI_REQUEST_MESSAGE + isMultiRequestError; runSession returns 'busy' for it). No toast; the loop re-arms after the existing REARM_AFTER_ERROR_MS breather, and the finally cancel clears the stray request. Every other error still toasts.

Decided, and pinned by a test: the keypad cannot tell a foreign cancel that cuts a read mid-flight from a card leaving the field. Both close the tag under the transceive, so the keypad reports a failed read and re-arms. Layer 0 is what keeps card screens from causing that.

iOS is untouched (no focus arm). No user-facing copy changed.

How it was tested

New tests:

  • __tests__/hooks/useCardSessionRelease.test.tsx (layer 0): cancels on beforeRemove and not again at unmount; still cancels at unmount with no beforeRemove; runs onRelease before the cancel; with no navigation, installs no listener and hands release to the screen; stops listening once unmounted.
  • __tests__/screens/CashuCardCharge.test.tsx (layer 0):
    • "a pop before the burn releases the session as the stack lets it go, quietly, and the unmount cancels nothing more": one cancel on beforeRemove, no error pill when the cut read rejects, none more at unmount.
    • "a refused pop releases nothing: the charge keeps its session": a pop during the burn cancels nothing and the charge still completes.
    • "releases only on Done's own reset, never on the pop it refused, and not again at unmount (ENG-704)": the settled screen.
  • __tests__/screens/CashuCardDebug.test.tsx › "cancels an in-flight read as the stack lets the screen go, and not again at unmount".
  • __tests__/screens/CashuCardSpend.test.tsx › "CashuCardSpend session release (ENG-704)" › "cancels as the stack lets the screen go, and not again at unmount".
  • __tests__/screens/Invoice.test.tsx › "releases the card session as the stack lets the screen go, and not again at unmount" and "still cancels the card session at unmount when no beforeRemove came first".
  • __tests__/support/navigationStub.ts: a shared stub with emitBeforeRemove for the Debug, Spend and Invoice tests.
  • __tests__/services/cashuCardNfc.test.ts › isMultiRequestError (layer 2): pins the native string; true for NfcErrorBase(MULTI_REQUEST_MESSAGE) and Error(MULTI_REQUEST_MESSAGE); false for UserCancel, TagConnectionLost, NfcErrorBase('no tech request available'), a string, null.
  • __tests__/hooks/useKeypadCardReader.test.tsx:
    • "ERR_MULTI_REQ on arm is not a card failure: no toast, re-arms after a breather" (layer 2).
    • "a focus that lands on a stray request flushes it and arms without a toast" (layer 1). Uses a native-faithful requestTechnology scoped to this test: refuses with ERR_MULTI_REQ while a request is pending. Asserts the flush cancel precedes the keypad's arm and no toast. It also asserts that a late unmount cancel from a screen with no beforeRemove leads to an immediate silent re-arm while no card is in the field.
    • "a foreign cancel that cuts a read mid-flight is reported as a failed read": pins the decided behaviour above. One describeCardFailure toast, no navigation, then a re-arm after the breather.

Mutation checks (source reverted, new tests kept):

  • Layer 0 reverted (the four screens at d491bc5, hook file kept), run at 5bd7099: 5 failures, the new Charge (2 of 3), Debug, Spend and Invoice release tests. "A refused pop releases nothing" and Invoice's no-beforeRemove fallback still pass, as they should: the old unmount-only code never cancelled on a refused pop and did cancel at unmount.
  • Layers 1 and 2, run at d491bc5 (the hook change since then is comment-only):
    • Both src files at 718c700: 7 failures. All 4 isMultiRequestError tests and both layer-1/2 hook tests fail, plus "says quietly that a card without the applet…". That last one is collateral: the failing ERR_MULTI_REQ test leaves its loop's breather running into the next test, and it passes alone on 718c700.
    • Hook at 718c700 only: both layer-1/2 hook tests fail.
    • Layer 1 only (layer 2 removed): the ERR_MULTI_REQ test fails; the flush test passes.
    • Layer 2 only (commit 864b3a3): the flush test fails.

Adjusted existing test: "writes the owed change onto a PIN-less card in the same session and re-reads the balance" (ENG-630) took cancelTechnologyRequest.mock.invocationCallOrder[0] as the session close. With the flush, the first cancel is now the focus flush, so the test counted a cancel on focus. It now takes the first cancel after the keypad's arm as the session close; the "write before close" assertion is unchanged in strength.

CI commands at a8cef6f, all green:

  • yarn lint --max-warnings 99999: 0 errors, 1 pre-existing warning (src/hooks/useSatPrice.tsx)
  • yarn typecheck: clean
  • yarn test --runInBand: 72 suites passed (1 skipped), 1558 tests passed, 9 skipped
  • node_modules/.bin/prettier --check on the changed source and test files: clean (docs/13-cashu-card.md has pre-existing table drift outside the edited paragraph)
  • typos on the added lines: clean

Follow-ups

  • Device check on the Pixel (not done here; the ship rebuild puts it on the phone): Keypad, 8 sats, Charge. Back out before the tap and confirm no toast on the keypad. Then tap a card on the keypad within about 1 s and confirm it reads, with no failure toast.
  • The offline any-amount eCash charge from the same report is ENG-703, separate.
  • isMultiRequestError matches a third-party literal. If an upgrade of react-native-nfc-manager rewords it, layer 2 goes quiet. Layers 0 and 1 still prevent the race, and the classifier test pins the string.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6

Dread and others added 2 commits October 14, 2026 11:40
Another screen's still-pending NFC request makes the native module refuse
the keypad's requestTechnology with "You can only issue one request at a
time". runSession toasted that literal string. Classify it with a new
isMultiRequestError and return 'busy': no toast, and the loop re-arms after
the existing breather.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
A popped card screen (CashuCardCharge, CashuCardSpend, CashuCardDebug,
Invoice) cancels its request only on unmount, after its close transition.
On the JS stack the keypad regains focus first, so its arm hit the live
request and the native module refused it with ERR_MULTI_REQ, which the
keypad toasted. Await a generation-guarded cancelCardSession() ahead of
armLoop in the loop chain. The previous loop has already ended (the blur
cancelled the keypad's own request), so this never cancels the keypad's
own request; with nothing pending it is a no-op.

The departing screen's late unmount cancel then hits the keypad's fresh
request, which runSession reads as 'cancelled' and re-arms at once,
silently; the new test locks that in. The ENG-630 write-ordering test now
picks the session-close cancel as the first cancel after the arm, since
the focus flush is the first cancel overall.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
@linear

linear Bot commented Oct 14, 2026

Copy link
Copy Markdown

ENG-704

Dread and others added 2 commits October 14, 2026 11:55
…unmount

Review follow-up on ENG-704. A popped card screen cancelled its request
only on unmount, at the end of the close transition. By then the keypad
had armed its own request, and a card could be mid-read: the late cancel
closed the keypad's tag under its transceive and toasted a card failure.

useCardSessionRelease cancels as the stack lets the screen go, before the
keypad's focus effect runs, and skips the unmount cancel once released
(whatever is pending then belongs to the next screen). A removal with no
beforeRemove still cancels at unmount. CashuCardCharge calls release from
its own listener's let-through branches only, so a refused pop (held or
settled) keeps the charge's session. Spend, Debug and Invoice use the
hook's listener.

The keypad's focus flush stays as the backstop. Its test no longer
promises "never", and a new test pins what the merchant sees if a foreign
cancel ever cuts a read mid-flight: a failed-read toast, then a re-arm.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
Review follow-up on ENG-704. docs/13-cashu-card.md still told screens to
cancel their session on unmount, which 5bd7099 replaced: an unmount cancel
lands after the keypad underneath has armed, and closes its tag mid-read.
Point screens at useCardSessionRelease, say why, and note the
CashuCardCharge pattern for a listener that can refuse the pop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
@islandbitcoin
islandbitcoin merged commit 02da4f3 into main Oct 14, 2026
5 checks passed
@islandbitcoin
islandbitcoin deleted the jabariennis/eng-704 branch October 14, 2026 20:25
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.

1 participant