Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
37c2ffe
fix(rpc): harden mobile JSI bridge against reader races and stream de…
chrisnojima Jul 24, 2026
3f71bd9
fix(rpc): clear module instance on destroy, cover write failures
chrisnojima Jul 24, 2026
426c073
fix(rpc): drain autorelease pool per ReadArr iteration
chrisnojima Jul 24, 2026
bd73350
fix(rpc): guard rpcOnJs batch dispatch, volatile Android module instance
chrisnojima Jul 25, 2026
ed5c543
fix(rpc): spell out weak_ptr type in bindings installer capture
chrisnojima Jul 25, 2026
91f0fb2
chore(rpc): add native sync script and review fix plan
chrisnojima Jul 27, 2026
2f25ca3
fix(rpc): only clear the iOS bridge if it is still the installed one
chrisnojima Jul 27, 2026
7682e02
fix(rpc): stop clearing the Android module instance on host destroy
chrisnojima Jul 27, 2026
4f76a22
fix(rpc): clear kbSharedInstance on iOS invalidate
chrisnojima Jul 27, 2026
450ad67
fix(rpc): make kbSharedInstance compare-and-clear atomic
chrisnojima Jul 27, 2026
6f85fc2
fix(rpc): tear down the Android bridge and globals on module destroy
chrisnojima Jul 27, 2026
eb4eace
fix(rpc): hold the Android adapter strongly in the bindings installer
chrisnojima Jul 27, 2026
e8c79af
fix(rpc): emit engine-reset when the read path drops the connection
chrisnojima Jul 27, 2026
f8d52bb
fix(rpc): require frame content to consume exactly the declared length
chrisnojima Jul 27, 2026
19be6e2
fix(rpc): fix frame-length check to not rely on parsed_size()
chrisnojima Jul 27, 2026
7594686
fix(rpc): don't let one undecodable message strand a whole batch
chrisnojima Jul 27, 2026
0ed435d
fix(rpc): latch after a desync so one bad frame is one reset
chrisnojima Jul 27, 2026
2546936
Revert "fix(rpc): latch after a desync so one bad frame is one reset"
chrisnojima Jul 27, 2026
d107097
fix(engine): fail in-flight RPCs on a mobile account switch
chrisnojima Jul 27, 2026
408d1f3
test(engine): exercise the reentrancy hazard failAllOutstanding's swa…
chrisnojima Jul 27, 2026
3ee7877
fix(engine): app-code throws must not reset the packetizer
chrisnojima Jul 27, 2026
3c7e51a
docs(rpc): withdraw the Task 9 latch and correct the Task 7 arithmetic
chrisnojima Jul 27, 2026
05b67be
fix(engine): answer the caller when an incoming invoke handler throws
chrisnojima Jul 27, 2026
07057c5
fix(engine): make RPC response settlement idempotent
chrisnojima Jul 27, 2026
0ace12e
fix(engine): report the disconnect on a mobile engine reset
chrisnojima Jul 27, 2026
14e1e9b
fix(rpc): clear the pending JNI exception when NewByteArray fails
chrisnojima Jul 27, 2026
508f85a
fix(bind): reset the connection on a partial WriteArr
chrisnojima Jul 27, 2026
acbfff7
docs(rpc): correct the reader-loop idle comments
chrisnojima Jul 27, 2026
53f0bdc
fix(rpc): route Android native bridge errors into the uploadable log
chrisnojima Jul 27, 2026
fd5963d
fix(rpc): reset the read-error rate-limit counter on a successful read
chrisnojima Jul 27, 2026
6e2da28
fix(rpc): release the receive buffer peak and fix the size-limit margin
chrisnojima Jul 27, 2026
6dddc5c
fix(bind): return a copy from ReadArr instead of a shared-buffer view
chrisnojima Jul 27, 2026
189748a
fix(engine): fail loudly when rpcOnJs count and payload disagree
chrisnojima Jul 27, 2026
def5fb4
fix(engine): surface failed writes on the response and renderer paths
chrisnojima Jul 27, 2026
bba933a
fix(rpc): wrap batch reply by converted count, not original batch size
chrisnojima Jul 27, 2026
99f7a07
fix(rpc): escalate to onFatal when an entire batch fails to convert
chrisnojima Jul 27, 2026
15647bb
fix(android): move nativeInvalidate to invalidate(), not onHostDestroy
chrisnojima Jul 27, 2026
bdbeb39
fix(android): guard nativeInvalidate's bridge clear with identity check
chrisnojima Jul 27, 2026
9f6c4d7
fix(rpc): throttle the kb-engine-reset emit on a persistent read error
chrisnojima Jul 27, 2026
0abb9db
cleanup(rpc): resetRecv on the fatal path + comment/blank-line fixes
chrisnojima Jul 27, 2026
71f0801
fix(rnmodules): use monotonic clock for engine-reset emit backoff
chrisnojima Jul 27, 2026
aeedcc1
fix(ios): guard myBridge_ ivar with kbBridgeMutex
chrisnojima Jul 27, 2026
abf7029
fix(rpc): throttle EOF reads and only advance reset-backoff on delivery
chrisnojima Jul 27, 2026
8493d55
fix(rpc): reset-ordering, catch-all, and dead-code robustness pass
chrisnojima Jul 27, 2026
1c841a2
test(engine): extract dispatchRpcBatch and cover the mobile rpcOnJs path
chrisnojima Jul 27, 2026
dd9f7d1
fix(engine): don't log a false double-settle when a response already …
chrisnojima Jul 27, 2026
d4470ca
fix(engine): isolate disconnect/connect callbacks on kb-engine-reset
chrisnojima Jul 27, 2026
57da73a
fix(bind): make failure-driven resets compare-and-close
chrisnojima Jul 27, 2026
7ab4a31
fix(ios): re-check liveness inside the dispatched emit block
chrisnojima Jul 27, 2026
847c3ac
fix(android): give non-EOF read errors their own log throttle counter
chrisnojima Jul 27, 2026
72e8b30
fix(engine): guard failOutstanding's per-callback invocation
chrisnojima Jul 27, 2026
c379922
fix(rpc): drop unused CurrentConnEpoch, document why fatal paths stay…
chrisnojima Jul 27, 2026
03de2b5
cleanup(rpc): drop stale half of the unused-engineReset comment
chrisnojima Jul 27, 2026
3bda702
fix(android): keep writeToGo's OOM path returning false on log failure
chrisnojima Jul 27, 2026
0bd389a
fix(engine): preserve the Error object in the batch-guard log
chrisnojima Jul 27, 2026
4d3303a
test(engine): exercise production's dispatchOne instead of a re-imple…
chrisnojima Jul 27, 2026
abaf9f0
feat(bind): re-add CurrentEpoch accessor for the fatal-path reset
chrisnojima Jul 27, 2026
1a54702
feat(rpc): thread the read epoch through onDataFromGo/onFatal_
chrisnojima Jul 27, 2026
3b59a44
fix(rpc): platform fatal handlers reset by epoch, not unconditionally
chrisnojima Jul 27, 2026
b252125
fix(bind): make epoch capture exact via LastReadEpoch
chrisnojima Jul 27, 2026
fa04cc0
fix(cgo): give the two quarantineFile C shims internal linkage
chrisnojima Jul 27, 2026
f69b879
test(bind): add first test suite for go/bind
chrisnojima Jul 27, 2026
3797cd0
refactor(rpc): extract FrameParser, the pure msgpack framing state ma…
chrisnojima Jul 27, 2026
d6f36c1
test(rpc): add the first unit test suite for the msgpack framing logic
chrisnojima Jul 27, 2026
5819557
test(engine): cover the reset-cycle seqid isolation
chrisnojima Jul 27, 2026
047b640
test(engine): cover response-settlement ordering and no-retry-after-f…
chrisnojima Jul 27, 2026
0afa022
test(engine): cover malformed-frame recovery and every split-point bo…
chrisnojima Jul 27, 2026
36eb28f
test(engine): cover the mobile connect/disconnect isolation and rpcOn…
chrisnojima Jul 27, 2026
04f4d99
fix(cgo): give the quarantineFile shims package-specific names
chrisnojima Jul 27, 2026
0a23578
feat(rpc): report whether ResetIfCurrent actually reset the connection
chrisnojima Jul 27, 2026
0357256
fix(rpc): gate parser reset on whether the Go reset actually ran
chrisnojima Jul 27, 2026
e51ac7a
refactor(rpc): drop dead RecvState::epoch, reset parser in place
chrisnojima Jul 27, 2026
e80705a
test(rpc): cover the production header encoding, fix overclaiming com…
chrisnojima Jul 27, 2026
5fb01c1
test(engine): fix test-quality gaps in the RPC bridge test suite
chrisnojima Jul 27, 2026
9b4d8c9
chore(rpc): move the native sync and framing-test scripts out of plans/
chrisnojima Jul 27, 2026
20af0db
revert(engine): drop the mobile transport reset from Engine.reset
chrisnojima Jul 27, 2026
3d41f27
test(android): guard nativeInvalidate() wiring against the back-butto…
chrisnojima Jul 27, 2026
e224fd5
test(e2e): cover the Android back-button RPC wedge (Activity restart)
chrisnojima Jul 27, 2026
400ec39
test(e2e): make appium relaxedSecurity opt-in for the activity-restar…
chrisnojima Jul 27, 2026
9a8229d
docs(podspec): fix stale path to framing test script
chrisnojima Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,6 @@ CLAUDE.md
docs
.codegraph
.mcp.json

# Scratch plans (not part of the codebase)
plans/2*.md
144 changes: 129 additions & 15 deletions go/bind/keybase.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,22 @@ var (
var (
jsReadyOnce sync.Once
jsReadyCh = make(chan struct{})
connMutex sync.Mutex // Protects conn operations
connMutex sync.Mutex // Protects conn and connEpoch
)

// connEpoch increments every time ensureConnection successfully (re)dials.
// Callers that observed a failure on a particular conn capture the epoch
// alongside it (while holding connMutex) and pass it to ResetIfCurrent,
// which only tears down the connection if nothing has re-dialed since —
// otherwise some other caller already recovered and this call is a stale,
// no-op complaint. Protected by connMutex.
var connEpoch int64

// lastReadEpoch is the epoch of the connection the most recent ReadArr call
// read (or is about to read) from, captured atomically with the connection
// reference itself. See LastReadEpoch. Protected by connMutex.
var lastReadEpoch int64

func describeConn(c net.Conn) string {
if c == nil {
return "<nil>"
Expand Down Expand Up @@ -571,6 +584,7 @@ func WriteArr(b []byte) (err error) {
}
}
currentConn := conn
currentEpoch := connEpoch
connMutex.Unlock()

if currentConn == nil {
Expand All @@ -586,7 +600,17 @@ func WriteArr(b []byte) (err error) {
if n != len(bytes) {
log("Go: WriteArr short write conn=%s wrote=%d expected=%d appState=%s",
describeConn(currentConn), n, len(bytes), appStateForLog())
return errors.New("Did not write all the data")
// Not reachable through LoopbackConn today, whose Write is
// all-or-nothing. If a future transport can short-write, the peer's
// framer is left holding a partial frame and every later write would
// be consumed as its remainder, so drop the connection rather than
// corrupt the stream indefinitely. ResetIfCurrent (rather than Reset)
// so this doesn't tear down a connection some concurrent caller has
// already redialed since currentConn was captured above.
if ierr := ResetIfCurrent(currentEpoch); ierr != nil {
log("failed to Reset after short write: %v", ierr)
}
return fmt.Errorf("Did not write all the data: wrote %d of %d", n, len(bytes))
}
return nil
}
Expand All @@ -595,7 +619,12 @@ func WriteArr(b []byte) (err error) {
var buffer []byte

// ReadArr is a blocking read for msgpack rpc data.
// It is called serially by the mobile run loops.
// It must still be called serially by the mobile run loops: conn.Read
// ordering depends on it, and the msgpack::unpacker behind onDataFromGo
// on the C++ side is not thread-safe. The returned slice is this call's own
// copy, which only protects the caller of ReadArr — a second concurrent
// caller would still interleave its Read into the shared package-level
// buffer above, garbling frame content, not just ordering.
func ReadArr() (data []byte, err error) {
defer func() { err = flattenError(err) }()

Expand All @@ -611,6 +640,8 @@ func ReadArr() (data []byte, err error) {
}
}
currentConn := conn
currentEpoch := connEpoch
lastReadEpoch = currentEpoch
connMutex.Unlock()

if currentConn == nil {
Expand All @@ -633,15 +664,21 @@ func ReadArr() (data []byte, err error) {
}
// Deliver data even if err != nil (allowed by the net.Conn
// contract); a persistent failure is returned by a later call.
// Returning a view of the shared buffer is safe because gomobile
// copies the bytes across the boundary and ReadArr is called
// serially.
return buffer[0:n], nil
//
// Copy out of the shared buffer rather than returning a view of it:
// gomobile copies again at the JNI/ObjC boundary regardless, so this
// costs one extra memcpy of a few KB on a call already crossing a
// language boundary, and it removes buffer aliasing as a hazard.
out := make([]byte, n)
copy(out, buffer[:n])
return out, nil
}

if err != nil {
// Attempt to fix the connection
if ierr := Reset(); ierr != nil {
// Attempt to fix the connection. ResetIfCurrent so a stale failure
// on an already-superseded conn doesn't tear down one a concurrent
// WriteArr just redialed.
if ierr := ResetIfCurrent(currentEpoch); ierr != nil {
log("failed to Reset: %v", ierr)
}
return nil, fmt.Errorf("Read error: %s", err)
Expand Down Expand Up @@ -677,21 +714,98 @@ func ensureConnection() error {
log("ensureConnection: Dial failed after restart: %v", err)
return fmt.Errorf("failed to dial after loopback restart: %s", err)
}
log("ensureConnection: loopback server restarted successfully conn=%s appState=%s",
describeConn(conn), appStateForLog())
connEpoch++
log("ensureConnection: loopback server restarted successfully conn=%s epoch=%d appState=%s",
describeConn(conn), connEpoch, appStateForLog())
return nil
}
log("Go: Established loopback connection conn=%s appState=%s",
describeConn(conn), appStateForLog())
connEpoch++
log("Go: Established loopback connection conn=%s epoch=%d appState=%s",
describeConn(conn), connEpoch, appStateForLog())
return nil
}

// Reset resets the socket connection
// Reset unconditionally resets the socket connection. Use this only when the
// caller genuinely means "tear down whatever connection is current" (e.g.
// iOS invalidate, Android destroy/engineReset) — it will happily close a
// connection some concurrent failure-driven caller never saw fail. Callers
// reacting to a failure on a specific connection should use ResetIfCurrent
// instead so a stale complaint can't clobber a connection that has already
// been recovered.
func Reset() error {
connMutex.Lock()
defer connMutex.Unlock()
return resetLocked()
}

// LastReadEpoch returns the epoch of the connection the most recent ReadArr
// call read its bytes from. A caller that is about to hand off a batch of
// bytes it just got back from ReadArr (e.g. a platform reader loop, right
// before passing the data into the native bridge for parsing) should call
// this immediately after ReadArr returns and capture the result there, not
// later once some asynchronous handler for that batch actually runs —
// ensureConnection may have re-dialed by then, and passing the wrong
// (newer) epoch to ResetIfCurrent would tear down a good connection
// instead of leaving it alone.
//
// This is exact only because there is exactly one permanent ReadArr caller
// for the life of the process (enforced on both platforms: a single serial
// reader loop). With one reader, nothing can start a second ReadArr call
// between this one's internal epoch capture and this accessor being read
// afterward, so the value can never be stale by the time the caller reads
// it. A design with concurrent readers would need to thread the epoch
// through ReadArr's return value instead.
func LastReadEpoch() int64 {
connMutex.Lock()
defer connMutex.Unlock()
return lastReadEpoch
}

// ResetIfCurrent closes the connection only if epoch (obtained implicitly by
// whoever last dialed) still matches the live connection's epoch. If
// ensureConnection has re-dialed since, the
// epoch has moved on and this call is a stale no-op: some other caller
// already recovered, and closing the newer connection would silently
// discard whatever was just written to it.
func ResetIfCurrent(epoch int64) error {
_, err := resetIfCurrent(epoch)
return err
}

// ResetIfCurrentDidReset behaves exactly like ResetIfCurrent, additionally
// reporting whether the reset actually ran (epoch matched connEpoch) as
// opposed to being a stale no-op (epoch was already superseded).
//
// Platform readers need this distinction: they also reset their local parser
// state (resetRecv/nativeResetRecv) alongside the Go connection, and doing so
// unconditionally after a stale no-op drops bytes already in flight on a
// connection nothing here actually touched. See Kb.mm and KbModule.kt.
func ResetIfCurrentDidReset(epoch int64) bool {
didReset, err := resetIfCurrent(epoch)
if err != nil {
log("Go: ResetIfCurrentDidReset: reset error: %v", err)
}
return didReset
}

// resetIfCurrent is the shared implementation behind ResetIfCurrent and
// ResetIfCurrentDidReset. didReset reports whether epoch matched connEpoch
// (i.e. whether resetLocked actually ran), independent of whether resetLocked
// itself returned an error.
func resetIfCurrent(epoch int64) (didReset bool, err error) {
connMutex.Lock()
defer connMutex.Unlock()
if epoch != connEpoch {
log("Go: ResetIfCurrent skipped, stale epoch=%d current=%d appState=%s",
epoch, connEpoch, appStateForLog())
return false, nil
}
return true, resetLocked()
}

log("Go: Reset start conn=%s appState=%s", describeConn(conn), appStateForLog())
// resetLocked does the actual teardown. Must be called with connMutex held.
func resetLocked() error {
log("Go: Reset start conn=%s epoch=%d appState=%s", describeConn(conn), connEpoch, appStateForLog())
if conn != nil {
conn.Close()
conn = nil
Expand Down
Loading