fix(rpc): harden mobile JSI bridge against reader races and stream desync#29464
Open
chrisnojima wants to merge 5 commits into
Open
fix(rpc): harden mobile JSI bridge against reader races and stream desync#29464chrisnojima wants to merge 5 commits into
chrisnojima wants to merge 5 commits into
Conversation
…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).
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.
The reader block never returns, so the queue's pool never drains and autoreleased objects accumulate for the life of the process.
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.
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
ReadArrreturns 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+awaitTerminationon Android,readQueuenil-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::unpackerbehind 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_ptraccess between the reader thread andinstallJSIBindings/invalidate.Related:
teardown()(which destroys jsi handles, JS-thread-only) is split frommarkTornDown()(atomic flag, any thread). Module invalidation was calling the former from the main thread.Stream desync
The incoming framing state machine alternated
needSize/needContentwith 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 reportsisConnected()unconditionally andEngine.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
onFatalpath that resets the Go connection and emitskb-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-resetnow fails outstanding invocations before signalling reconnect,rpcOnGoreports write failures back to JS, andinvokeNowfails the caller if the message never left.Conversion
ArrayBuffer.isViewbehind a cheapbyteLengthprobe. 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.nilinstead of packing nothing, which had been corrupting the enclosing map (N keys, N-1 values).PropNameIDcache no longer clears itself while entries are referenced; the outgoing scratch buffer is released after an oversized frame;rpcOnJsis 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 —
ReadArrreturns nothing when the connection is idle, which was spinning a core.Testing
yarn tsc,yarn lint,yarn jest engine(22 pass) — green:react-native-kb:externalNativeBuildDebug+:compileDebugKotlin— buildsxcodebuild -scheme react-native-kb -sdk iphonesimulator— BUILD SUCCEEDEDNot runtime-tested. The reader-lifecycle rework is the riskiest piece and deserves a real device pass: reload, background/foreground, and account switch.