Skip to content

perf(scan): keep the js thread free while scanning - #213

Open
theanmolsharma wants to merge 4 commits into
theanmolsharma/sync-screen-feedbackfrom
theanmolsharma/scan-js-thread
Open

theanmolsharma wants to merge 4 commits into
theanmolsharma/sync-screen-feedbackfrom
theanmolsharma/scan-js-thread

Conversation

@theanmolsharma

@theanmolsharma theanmolsharma commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Third of four stacked PRs, on top of #212. Taps on the Sync screen, Pause especially, could freeze during a scan because the scan held the JS thread.

What changes

Smaller Rust calls. Each 50-block window went to Rust in one synchronous JSI call (spScanTransactions), which holds the JS thread for its whole run.

  • Measured on the iOS simulator against signet:

    Window size Held for
    2,230 txs 27 ms
    14,361 txs 173 ms
    27,578 txs 286 ms

    Phones are several times slower, and mainnet windows are bigger.

  • Windows now go in slices of 1,000 transactions. Once slices have held the thread for 30 ms, the scan yields with a macrotask (setTimeout(0)); a microtask or the setImmediate shim would run before touches do.

  • The longest hold on the same simulator dropped to 5–37 ms.

  • Why yield only every 30 ms: a yield isn't free on iOS. RCTTiming runs any timer under 18 ms on the next display frame, so each yield idles for up to a frame. Yielding after every slice made a fast device sit idle about as long as it scanned. Android runs zero-delay timers immediately. A slow device still yields after every slice, because one slice takes that long on its own.

  • A cancel between slices throws SCAN_CANCELLED rather than returning what was found so far. The caller marks the whole window scanned once the batch resolves, so a partial result would skip the rest of the window for good.

Linear Tor buffering.

  • Before: socks5Fetch appended every packet to one growing Buffer and re-decoded the whole chunked body on every packet. That is quadratic, on the JS thread: over a gigabyte copied for a 2 MB body.
  • Now:
    • The headers are buffered until their terminator.
    • The body is kept as the packets that carried it and joined once at the end.
    • A chunked body is decoded as it arrives.
  • Unchanged:
    • the 20 MB cap;
    • completion at the terminal chunk, without trailers;
    • a malformed or truncated body never completing, so close rejects it;
    • bodies delimited by the connection closing.

Runtime version

JS only, so it ships over the air. Moving the scan off the JS thread entirely needs an async JSI call on a worker thread, which means a native change and a new binary. That's a follow-up.

Testing

  • rustTransactionProcessor.test.ts:
    • slicing returns what one call would, in order;
    • slices that hold the thread for 30 ms let a waiting task run, quick slices run back to back with no yield, and a slow slice yields every time;
    • a cancel between slices throws and makes no further calls.
  • sp-scan-lifecycle: a cancel in the middle of a window leaves lastScannedBlock unchanged.
  • socks5Fetch.test.ts:
    • bodies arriving a byte at a time;
    • a chunked body split at every offset, with multi-byte characters straddling chunks;
    • the terminal chunk without trailers;
    • a malformed size line;
    • Content-Length: 0;
    • the cap applied across packets;
    • a linear-copy guard, which fails against the old code.
  • Mutation-checked: yielding after every slice, never yielding, or returning [] on cancel each fails tests.
  • Full suite: 76 suites, 756 tests. Lint and tsc are clean.
  • iOS simulator: a real signet catch-up scan with per-call timing logs, which are removed in the PR.

Stack

  1. fix(scan): make payment checks awaitable and self-healing #211 engine
  2. fix(sync): acknowledge every tap and keep the sync screen still #212 Sync screen
  3. This PR
  4. Stopping scan updates from re-rendering the whole app

🤖 Generated with Claude Code

theanmolsharma and others added 3 commits October 19, 2026 12:39
Each 50-block window went to Rust in one synchronous JSI call, which holds the JS thread for its
whole run: taps, timers and JS-driven animation all wait on it. On the simulator a 27,578-transaction
signet window held it for 286ms, and phones are several times slower.

Windows now go in slices of 1,000 transactions with a macrotask between them, so the longest hold is
5-37ms on the same simulator, for about the same total time. A cancel between slices throws instead
of returning what was found so far: the caller marks the whole window scanned once the batch
resolves, so a partial result would skip the rest of the window for good.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every packet of a response was appended to one growing Buffer, and a chunked body was re-decoded
from its start on every packet: work quadratic in the response's size, on the JS thread, for each
window a scan fetches over Tor (over a gigabyte copied for a 2MB body).

The headers are buffered until their terminator, and the body is kept as the packets that carried it
and joined once at the end. A chunked body is decoded as it arrives. Everything else behaves as it
did: the size cap, a terminal chunk completing without trailers, a malformed or truncated body never
completing (so the socket closing rejects it), and close-delimited bodies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@theanmolsharma
theanmolsharma force-pushed the theanmolsharma/scan-js-thread branch from 8373c50 to d1f44f1 Compare October 19, 2026 07:10
@theanmolsharma
theanmolsharma added this pull request to stack #215 October 19, 2026 08:22
A yield costs up to a frame on iOS: RCTTiming runs timers under 18ms on the next display frame. So
yielding after every 1,000-transaction slice left a fast device idle about as long as it scanned.
Slices now run back to back until they've held the JS thread for 30ms, then yield. A slow device
still yields after every slice, since one takes that long on its own. A cancel is checked between
every two slices, yield or not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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