Skip to content

fix(rpc): harden mobile JSI bridge against reader races and stream desync#29464

Open
chrisnojima wants to merge 5 commits into
masterfrom
nojima/HOTPOT-rpc-fixes
Open

fix(rpc): harden mobile JSI bridge against reader races and stream desync#29464
chrisnojima wants to merge 5 commits into
masterfrom
nojima/HOTPOT-rpc-fixes

Conversation

@chrisnojima

Copy link
Copy Markdown
Contributor

The mobile RPC layer had several failure modes that leave the app alive but permanently unable to talk to the service. This fixes them across the C++ JSI bridge, both platform layers, and the JS transport.

Reader lifecycle

Go's ReadArr returns a view of one shared global buffer and is documented as "called serially by the mobile run loops" — but a thread parked inside it cannot be cancelled (Java interrupts and dispatch cancellation are both no-ops there). The old stop-and-restart design (shutdownNow + awaitTermination on Android, readQueue nil-ing on iOS) returned while the previous reader was still live, so a resume or reload could leave two readers racing over that buffer and over the (not thread safe) msgpack::unpacker behind it.

Both platforms now run exactly one reader for the life of the process, forwarding to whichever bridge is currently installed via a mutex-guarded global instead of an instance member. That also removes the unsynchronized shared_ptr access between the reader thread and installJSIBindings/invalidate.

Related: teardown() (which destroys jsi handles, JS-thread-only) is split from markTornDown() (atomic flag, any thread). Module invalidation was calling the former from the main thread.

Stream desync

The incoming framing state machine alternated needSize/needContent with no validation and left the unpacker untouched on error. One malformed frame flipped the parity permanently and every later message was silently swallowed as a "size" — with no way back, since mobile's transport reports isConnected() unconditionally and Engine.reset() is a no-op there. The app looks alive; every RPC hangs.

The size prefix is now checked to be a msgpack uint within the frame limit, and a failure resets the unpacker and drives a new onFatal path that resets the Go connection and emits kb-engine-reset.

Hanging invocations

Nothing failed the outstanding invocation map on mobile, so an engine reset or a dropped native write left every in-flight RPC waiting forever. kb-engine-reset now fails outstanding invocations before signalling reconnect, rpcOnGo reports write failures back to JS, and invokeNow fails the caller if the message never left.

Conversion

  • Both msgpack↔JSI walks are now iterative, so nesting depth costs heap instead of native stack.
  • Typed arrays are detected with ArrayBuffer.isView behind a cheap byteLength probe. This replaces a first-key-is-a-digit heuristic that could route a plain object into an unvalidated out-of-bounds read of the backing buffer. Every range is now bounds-checked.
  • Symbols and BigInts pack as nil instead of packing nothing, which had been corrupting the enclosing map (N keys, N-1 values).
  • The PropNameID cache no longer clears itself while entries are referenced; the outgoing scratch buffer is released after an oversized frame; rpcOnJs is re-read per batch so a recreated engine client is never fed to a dead handle.

Also

Per-message try/catch in the batch dispatch loop (one bad message no longer drops the rest of the batch), and idle-read sleeps on both platforms — ReadArr returns nothing when the connection is idle, which was spinning a core.

Testing

  • yarn tsc, yarn lint, yarn jest engine (22 pass) — green
  • Android: :react-native-kb:externalNativeBuildDebug + :compileDebugKotlin — builds
  • iOS: xcodebuild -scheme react-native-kb -sdk iphonesimulatorBUILD SUCCEEDED

Not runtime-tested. The reader-lifecycle rework is the riskiest piece and deserves a real device pass: reload, background/foreground, and account switch.

…sync

The mobile RPC layer had several failure modes that leave the app alive but
permanently unable to talk to the service.

Reader lifecycle. Go's ReadArr returns a view of one shared global buffer and
is documented as "called serially by the mobile run loops", but a thread
parked inside it cannot be cancelled — Java interrupts and dispatch
cancellation are both no-ops there. The old stop-and-restart design
(shutdownNow + awaitTermination on Android, readQueue nil-ing on iOS) returned
while the previous reader was still live, so a resume or reload could leave
two readers racing over that buffer and over the (not thread safe)
msgpack::unpacker behind it. Both platforms now run exactly one reader for the
life of the process, forwarding to whichever bridge is currently installed via
a mutex-guarded global rather than an instance member — which also removes the
unsynchronized shared_ptr access between the reader thread and
installJSIBindings/invalidate.

Stream desync. The incoming framing state machine alternated needSize and
needContent with no validation, and left the unpacker untouched on error. One
malformed frame flipped the parity permanently and every later message was
silently swallowed as a "size", with no way back: mobile's transport reports
isConnected() unconditionally and Engine.reset() is a no-op there. The size
prefix is now checked to be a msgpack uint within the frame limit, and a
failure resets the unpacker and drives a new onFatal path that resets the Go
connection and emits kb-engine-reset.

Hanging invocations. Nothing failed the outstanding invocation map on mobile,
so an engine reset or a dropped native write left every in-flight RPC waiting
forever. kb-engine-reset now fails outstanding invocations before signalling
reconnect, rpcOnGo reports write failures back to JS, and invokeNow fails the
caller if the message never left.

Conversion. Both msgpack<->JSI walks are now iterative, so nesting depth costs
heap instead of native stack. Typed arrays are detected with
ArrayBuffer.isView behind a cheap byteLength probe, replacing a
first-key-is-a-digit heuristic that could route a plain object into an
unvalidated out-of-bounds read of the backing buffer; every range is now
bounds-checked. Symbols and BigInts pack as nil instead of packing nothing,
which had been corrupting the enclosing map. The PropNameID cache no longer
clears itself while entries are referenced, the outgoing scratch buffer is
released after an oversized frame, and rpcOnJs is re-read per batch so a
recreated engine client is never fed to a dead handle.

Also: per-message try/catch in the batch dispatch loop so one bad message
cannot drop the rest of the batch, and idle-read sleeps on both platforms
(ReadArr returns nothing when the connection is idle, which was spinning a
core).

This comment was marked as outdated.

The process-wide read loop forwards to KbModule.instance, so a torn-down
module kept receiving deliveries and pinned its ReactContext. Clear it in
destroy(), guarded so a reload that installs a newer module first wins.

Add transport tests for native write failures: invoke fails the caller,
leaves no outstanding seqid, doesn't steal a later invoke's response, and
send() reports false.

This comment was marked as outdated.

The reader block never returns, so the queue's pool never drains and
autoreleased objects accumulate for the life of the process.

This comment was marked as outdated.

This comment was marked as outdated.

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