Fix the Android Zano freeze, deadlock, and file recovery - #20
Open
peachbits wants to merge 4 commits into
Open
Conversation
2 tasks
peachbits
marked this pull request as ready for review
August 27, 2026 00:11
j0ntz
approved these changes
Aug 27, 2026
j0ntz
reviewed
Aug 27, 2026
j0ntz
left a comment
Contributor
There was a problem hiding this comment.
Neither of these blocks the PR. RnMoneroModule in react-native-monero-lwsf uses the identical executor pattern, so both apply there too.
peachbits
force-pushed
the
zano-android-executor
branch
from
August 27, 2026 21:07
bc65a54 to
40c3ad5
Compare
j0ntz
reviewed
Aug 27, 2026
j0ntz
left a comment
Contributor
There was a problem hiding this comment.
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.
peachbits
force-pushed
the
zano-android-executor
branch
from
August 27, 2026 23:02
c64eece to
67a8fce
Compare
j0ntz
approved these changes
Aug 27, 2026
j0ntz
left a comment
Contributor
There was a problem hiding this comment.
Just fix the double dashes -- in the committed code. Can just do a single dash -
Bugbot is paused — on-demand spend limit reachedBugbot 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. |
2 tasks
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
force-pushed
the
zano-android-executor
branch
from
August 28, 2026 07:20
ea83adc to
e6a328e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
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
@ReactMethodruns on the singleshared
mqt_native_modulesthread, andcallZanoran its JNI synchronouslythere. 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
tryPullResultpolls that would have observed the closefinishing), 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
callZanonow hops to a dedicated single-thread executor and settles thepromise from there. Argument extraction from the
ReadableArraystays on thecaller 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, andmainall stay idle and renderingcontinues.
Per review: the executor is
static— the Zano SDK is one instance perprocess 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 infuture dumps, and the catch widens to
Throwable, since off the bridgethread an escaping
Errorwould kill the process with the promiseunsettled. 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-synccheckpoint save. The pinned SDK implementation holds
m_wallets_lockexclusively 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_lockshared (the SDK documents the ordering hazard in itson_sync_progresscomment), and every other API call takes it shared upfront — 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-sourcesnow rewritesclose_walletin 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()'svector clear. The transform requires the found function to match the pinned
original exactly (modulo trailing whitespace), so a pin bump that changes
close_walletin any way fails the build for a human to re-evaluate thepatch 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
wallet2still 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 theentry 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-walletxcframework and is byte-for-byte unchanged. iOS hasnot 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_FILEbefore any password is consulted(
wallet2's header read maps toAPI_RETURN_CODE_INVALID_FILE).startWallet's recovery ladder is keyed onWRONG_PASSWORD, so the errorpropagated 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_FILEnow routes to the same policy as a file no known passwordopens, 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 hexpayment id) replaces the flat
subtransferslist and thepayment_idfield, and
remote_addressesis exposed. Verified with a live on-chainspend between two wallets carrying payment id
a1b2c3d4e5f60718: thereceiver'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_idfield came backprecision-mangled by JSON —
1731624048724783900for the true uint64 —which is why the type documents it as a presence signal only.)
Verification. Unit suite green (55 tests). A full
update-sourcesrunfrom a fresh checkout applies the patch and rebuilds all four Android ABIs
and the iOS xcframework cleanly, with
wallets_manager.cpprecompiled perABI. 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.