Skip to content

Fix the Android Zano freeze, deadlock, and file recovery - #20

Open
peachbits wants to merge 4 commits into
mainfrom
zano-android-executor
Open

Fix the Android Zano freeze, deadlock, and file recovery#20
peachbits wants to merge 4 commits into
mainfrom
zano-android-executor

Conversation

@peachbits

@peachbits peachbits commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none — supersedes EdgeApp/edge-currency-accountbased#1094: with the deadlock
fixed at its source here, accountbased needs no changes at all, and
checkpointing stays enabled with identical code on both platforms.

Description

Fixes the Android freeze QA reported — about seven minutes after login, taps
and buttons die while scrolling and the drawer keep working, and the Zano
sync banner stops updating; iOS is unaffected — and modernizes the wallet
history query for HF6, in four commits: one contains the blast radius of any
blocked Zano call, one removes the deadlock that produced the blocked call,
one fixes a wallet-file recovery gap the deadlock's verification uncovered,
and one moves history to the HF6-ready endpoint so per-output payment ids
are visible at all.

Commit 1 — keep Zano calls off the RN bridge thread.

On the legacy Android architecture every @ReactMethod runs on the single
shared mqt_native_modules thread, and callZano ran its JNI synchronously
there. That thread also executes UIManager's view commands, so any Zano call
that blocks in C++ freezes every view update in the app: buttons dead
(UIManager), sync banner frozen (every native call queued behind the blocker,
including the tryPullResult polls that would have observed the close
finishing), scroll and drawer alive (pure UI thread). QA's screen recording
matches this signature exactly — the sync banner reads the same block count
across four minutes.

Repro evidence (API 35 emulator, checkpoints tortured to a 30s interval,
4 wallets in deep catch-up): a JDWP thread dump captured mid-freeze shows

mqt_native_modules:
  [1] app.edge.rnzano.RnZanoModule.callZanoJNI (native method)
  [2] app.edge.rnzano.RnZanoModule.callZano (RnZanoModule.java:46)

callZano now hops to a dedicated single-thread executor and settles the
promise from there. Argument extraction from the ReadableArray stays on the
caller thread, and a single thread preserves the strict global call ordering
the shared bridge thread provided. Re-running the identical torture scenario
with this change, the blocked call sits quarantined on the executor thread
while mqt_native_modules, mqt_js, and main all stay idle and rendering
continues.

Per review: the executor is static — the Zano SDK is one instance per
process while React module instances are not, so a JS reload would otherwise
run the old instance's still-draining call concurrently with the new
instance's calls (with commit 2, that overlap could double-open a wallet
file mid-store). The thread is named "zano" so it identifies itself in
future dumps, and the catch widens to Throwable, since off the bridge
thread an escaping Error would kill the process with the promise
unsettled. The change needs no architecture detection: it never blocks the
thread that called it, which is correct under both the legacy bridge and
the new-arch interop dispatch.

Commit 2 — patch the SDK's close-during-scan deadlock.

The call that blocked is close_wallet, issued by the engine's mid-sync
checkpoint save. The pinned SDK implementation holds m_wallets_lock
exclusively across two waits on the wallet being closed: the store() call,
which needs the per-wallet lock the refresh worker holds for the whole of a
scan chunk, and the map erase, whose destructor joins the worker thread. The
refresh path re-enters the manager through wallet callbacks that take
m_wallets_lock shared (the SDK documents the ordering hazard in its
on_sync_progress comment), and every other API call takes it shared up
front — so one close issued mid-catch-up wedges the worker, the close, and
then every Zano call in the process, permanently. In two of two torture runs
the wedge never cleared and no checkpoint ever landed again.

update-sources now rewrites close_wallet in the downloaded sources:
detach the map node while holding the lock, then do the store and the
implicit worker join with no manager lock held. Node extraction keeps the
element at its address, so the worker's references stay valid, and wallet
ids are never reused. The stop flags, the store-then-log-prefix order, and
the return codes are kept verbatim; the log-prefix write gains a bounds
check, since the manager lock no longer serializes it against reset()'s
vector clear. The transform requires the found function to match the pinned
original exactly (modulo trailing whitespace), so a pin bump that changes
close_wallet in any way fails the build for a human to re-evaluate the
patch instead of silently keeping or dropping it
(scripts/utils/closeWalletPatch.ts, unit-tested).

Two deliberate semantics changes, documented in the transform: during the
store/join window the wallet is absent from the map while its wallet2
still writes the file — safe for this bridge, which strictly sequences
close-before-reopen per wallet on one executor thread, and called out for
any upstream submission. And when store() throws, the original left the
entry in the map as a zombie (stop flags set, never able to sync again),
while the rewrite reports the same error with the wallet gone.

Only Android builds from these sources; iOS links the prebuilt
libzano-plain-wallet xcframework and is byte-for-byte unchanged. iOS has
not exhibited the wedge across hundreds of observed checkpoint cycles,
though the lock inversion exists in its prebuilt code too — the durable fix
for both platforms is handing this patch upstream with the next pin-bump
request.

Commit 3 — rebuild unreadable wallet files.

Found while verifying the deadlock fix: a crash during a wallet file's very
first write leaves a file the SDK cannot parse — typically zero bytes — and
opening it fails with INVALID_FILE before any password is consulted
(wallet2's header read maps to API_RETURN_CODE_INVALID_FILE).
startWallet's recovery ladder is keyed on WRONG_PASSWORD, so the error
propagated as-is and the engine retried the same doomed open every second,
forever — a full native init and load per attempt, per wallet, with no path
back to health. Observed live after an emulator was hard-killed mid-restore:
two of four wallets left zero-byte files and spun at 28 failed opens per 20
seconds while the other two synced.

INVALID_FILE now routes to the same policy as a file no known password
opens, skipping the pointless password ladder: with no seed passphrase set,
the file is deleted and rebuilt from the mnemonic, recreating the identical
wallet at the cost of a re-scan; with a passphrase set, it refuses with a
clear error, since an unreadable file cannot corroborate the passphrase and
a wrong one would rebuild a different wallet. This half is platform-neutral
JS. On-device: both stuck wallets logged the new path, rebuilt, and synced;
the error spam went to zero.

Commit 4 — query history with get_recent_txs_and_info3.

The v2 endpoint predates HF6's per-output payment ids: it reports only the
deprecated transaction-wide id — an empty string for every id created since
the fork — and its legacy serializer asserts on entries carrying more than
one distinct per-output id, the exact multi-payment transactions the HF6
migration guide says to expect. Deposits to integrated addresses were
therefore unidentifiable from history.

v3 takes the identical request and returns the identical envelope; only the
transfer entries change shape: subtransfers_by_pid (amounts grouped by hex
payment id) replaces the flat subtransfers list and the payment_id
field, and remote_addresses is exposed. Verified with a live on-chain
spend between two wallets carrying payment id a1b2c3d4e5f60718: the
receiver's entry carries the id verbatim as a hex group before and after
confirmation, and the sender's entry nets its spend in the empty-id group
with the recipient's id structurally absent — the sender-side id lives only
in the record saved at broadcast, which the accountbased side now preserves.
(The numeric employed_entries.payment_id field came back
precision-mangled by JSON — 1731624048724783900 for the true uint64 —
which is why the type documents it as a presence signal only.)

Verification. Unit suite green (55 tests). A full update-sources run
from a fresh checkout applies the patch and rebuilds all four Android ABIs
and the iOS xcframework cleanly, with wallets_manager.cpp recompiled per
ABI. The emulator torture repro that wedged 2/2 before the patch — 30-second
checkpoint interval, four wallets in deep catch-up — ran 25 minutes against
the rebuilt library: 41 completed checkpoint cycles, zero stalls, the
app process alive and interactive throughout, with each cycle also
re-opening the file the previous patched close wrote. A later session added
stock-interval (5-minute) checkpoint cycles landing on schedule.

@j0ntz j0ntz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither of these blocks the PR. RnMoneroModule in react-native-monero-lwsf uses the identical executor pattern, so both apply there too.

Comment thread android/src/main/java/app/edge/rnzano/RnZanoModule.java Outdated
Comment thread android/src/main/java/app/edge/rnzano/RnZanoModule.java Outdated
@peachbits peachbits changed the title Keep Zano calls off the RN bridge thread Fix the Android Zano freeze and close deadlock Aug 27, 2026
@peachbits
peachbits force-pushed the zano-android-executor branch from bc65a54 to 40c3ad5 Compare August 27, 2026 21:07
@peachbits peachbits changed the title Fix the Android Zano freeze and close deadlock Fix the Android Zano freeze, deadlock, and file recovery Aug 27, 2026

@j0ntz j0ntz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the executor change and the INVALID_FILE recovery too, and both look right to me. The notes below are all on the close_wallet patch.

Comment thread scripts/utils/closeWalletPatch.ts Outdated
Comment thread scripts/utils/closeWalletPatch.ts
Comment thread scripts/utils/closeWalletPatch.ts Outdated
@peachbits
peachbits force-pushed the zano-android-executor branch from c64eece to 67a8fce Compare August 27, 2026 23:02

@j0ntz j0ntz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just fix the double dashes -- in the committed code. Can just do a single dash -

@cursor

cursor Bot commented Aug 28, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

On the legacy Android architecture every @ReactMethod runs on the
single shared mqt_native_modules thread, which also executes UIManager's
view commands, so a Zano call that blocked in C++ froze every view
update in the app: taps dead, screens frozen, native scrolling alive. A
JDWP dump of the frozen app shows callZano parked on that thread inside
callZanoJNI.

callZano now hops to a dedicated single-thread executor and settles the
promise from there. The executor is static: the Zano SDK is one
instance per process while React module instances are not, so a JS
reload would otherwise run the old instance's still-draining call
concurrently with the new instance's calls. A single process-wide
thread preserves the strict global call ordering across reloads too,
and is named "zano" so it identifies itself in future dumps. The catch
widens to Throwable: off the bridge thread an escaping Error would hit
the default uncaught handler and kill the process with the promise
unsettled, where RN's dispatcher previously caught it.

Argument extraction from the ReadableArray stays on the caller thread.
The SDK's close_wallet holds m_wallets_lock exclusively across two waits
on the wallet being closed: the store() call, which needs the per-wallet
lock the refresh worker holds for the whole of a scan chunk, and the map
erase, whose destructor joins the worker thread. The refresh path
re-enters the manager through wallet callbacks that take m_wallets_lock
shared, so a close issued while the wallet is catching up wedges the
worker, the close, and then every Zano call in the process, permanently.
The app's periodic mid-sync checkpoint saves trip this within minutes of
importing a wallet on Android.

update-sources now rewrites close_wallet in the downloaded sources to
detach the map node under the lock and do the store and the join with no
manager lock held. Everything that can throw stays inside the try, so
failures keep reporting as return codes rather than escaping into the
JNI catch-all, which callers do not expect; the node is declared outside
it so the worker join in its destructor runs at function exit rather
than during unwinding. The log-prefix write gains a bounds check: the
manager lock no longer serializes it against reset()'s vector clear, so
the blind vector[wallet_id] store could go out of bounds. The transform
requires the found function to match the pinned original exactly, modulo
trailing whitespace and line endings, so a pin bump that changes
close_wallet in any way fails the build for a human to re-evaluate the
patch instead of silently keeping or dropping it. Only Android builds
from these sources; iOS links the prebuilt xcframework and keeps the
original blocking semantics.
A crash during a wallet file's very first write leaves a file the SDK
cannot parse -- typically zero bytes -- and opening it fails with
INVALID_FILE before any password is consulted. startWallet's recovery
ladder is keyed on WRONG_PASSWORD, so the error propagated as-is and the
engine retried the same doomed open every second, forever: a full native
init and load per attempt, per wallet, with no path back to health.
Observed live after an emulator was hard-killed mid-restore: two of four
wallets left zero-byte files and spun in the retry loop while the other
two synced.

Route INVALID_FILE to the same policy as a file no known password opens,
skipping the pointless password ladder: with no seed passphrase set,
delete the file and rebuild it from the mnemonic, which recreates the
identical wallet at the cost of a re-scan; with a passphrase set, refuse
with a clear error, since an unreadable file cannot corroborate the
passphrase and a wrong one would rebuild a different wallet.
The v2 endpoint predates HF6's per-output payment ids: it reports only
the deprecated transaction-wide id, which is an empty string for every
id created since the fork, and its legacy serializer asserts on entries
carrying more than one distinct per-output id -- the exact multi-payment
transactions the HF6 migration guide says to expect. Deposits to
integrated addresses were therefore unidentifiable from history, and the
always-present empty payment_id string leaked into consumers as if it
were a real id.

get_recent_txs_and_info3 takes the identical request and returns the
identical envelope; only the transfer entries change shape. They now
carry subtransfers_by_pid -- amounts grouped by hex payment id, empty
string for amounts with none -- in place of the flat subtransfers list
and the payment_id field. A sender's own spent inputs and change always
land in the empty-id group, so a sent transfer's recipient ids do not
appear; remote_addresses, recorded by the sending wallet, is exposed for
callers that need the destination.
@peachbits
peachbits force-pushed the zano-android-executor branch from ea83adc to e6a328e Compare August 28, 2026 07:20
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.

2 participants