feat: add createClient.rejectPending and createClient.resubscribe - #52
Merged
Conversation
Give the transport owner hooks to recover from a server restart without tearing down the client: rejectPending(client, error) fails every in-flight call fast with a caller-supplied (distinguishable) error while keeping the client usable, and resubscribe(client) replays an ON message for every event (root and nested sub-clients) that still has listeners, restoring subscriptions a restarted server has lost. Both are no-ops on a closed client.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #52 +/- ##
==========================================
+ Coverage 99.29% 99.33% +0.04%
==========================================
Files 12 12
Lines 1132 1204 +72
==========================================
+ Hits 1124 1196 +72
Misses 8 8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Aug 13, 2026
Closed
Review follow-up: give the transport owner a way to deliver an event that a dead server can no longer send (e.g. firing 'close' teardown listeners when hard-closing a client after the server process died). Listeners are stored under encoded names (propArray + eventName), so a plain emit on the client cannot reach them; emitLocal encodes the root propArray and emits to locally-registered root listeners only, with no wire traffic. No-op (returning false) on a closed client. Also drop the unreachable listenerCount guard in resubscribe: eventemitter3's eventNames() only lists events with listeners.
gmaclennan
added a commit
to digidem/comapeo-core-react-native
that referenced
this pull request
Aug 13, 2026
… backend restart When Android kills and restarts the :ComapeoCore service, the sockets now reconnect (PR #225) — but in-flight RPC calls still hung until the 30s timeout, and the restarted backend had lost every event subscription, so listeners went permanently deaf. The message socket now reports its connection state to JS as a transportStateChange event (declared on iOS for parity; never fires there — in-process Node death ends the app). On a drop, the module calls @comapeo/ipc's transport-reset helpers: in-flight calls reject with TransportClosedError (code RPC_TRANSPORT_CLOSED, re-exported here) so callers can tell "backend restarted, a read is safe to retry" from a real failure; subscriptions on the long-lived channels are re-sent through the native send queue; stale per-project clients are hard-closed so the next getProject mints a working one. subscribeToBackendRestart() fires once the backend is STARTED again after a drop — wire it to @comapeo/core-react's new subscribeToBackendRestart provider prop so its query caches re-fetch. docs/ARCHITECTURE.md §5.8 documents the recovery layers and the host-app state (module-scope captures in comapeo-mobile) that recovery cannot reach. Requires @comapeo/ipc >= the release containing digidem/comapeo-ipc#87 (which itself needs digidem/rpc-reflector#52); the dependency bump lands here once released.
The @comapeo/ipc transport-reset design that needed it (hard-closing project wrappers and firing their 'close' event locally, digidem/comapeo-ipc#87) has been superseded by backend-owned project lifecycle (digidem/comapeo-ipc#88/#89), where project references survive a server restart and nothing emits locally. Reverts d64b36d rather than rewriting history; a squash merge lands this PR with no trace of the API.
RangerMauve
approved these changes
Aug 17, 2026
The tests for these two methods asserted on the over-the-wire message shape on the MessagePort — sniffing msgIds off REQUEST messages to forge a late RESPONSE, and deep-comparing the ON messages resubscribe() emits. Rewrite them against a real client/server pair following the pattern in e2e.test.js, so they run over both a real MessageChannel and the MessagePort-like fake. The late-response case now has the server answer a call that rejectPending() has already given up on, and resubscribe() is checked by restarting the server and asserting the server-side emitters regain exactly the expected listeners and that events reach the client again.
gmaclennan
added a commit
to digidem/comapeo-core-react-native
that referenced
this pull request
Aug 20, 2026
… backend restart When Android kills and restarts the :ComapeoCore service, the sockets now reconnect (PR #225) — but in-flight RPC calls still hung until the 30s timeout, and the restarted backend had lost every event subscription, so listeners went permanently deaf. The message socket now reports its connection state to JS as a transportStateChange event (declared on iOS for parity; never fires there — in-process Node death ends the app). On a drop, the module calls @comapeo/ipc's transport-reset helpers: in-flight calls reject with TransportClosedError (code RPC_TRANSPORT_CLOSED, re-exported here) so callers can tell "backend restarted, a read is safe to retry" from a real failure; subscriptions on the long-lived channels are re-sent through the native send queue; stale per-project clients are hard-closed so the next getProject mints a working one. subscribeToBackendRestart() fires once the backend is STARTED again after a drop — wire it to @comapeo/core-react's new subscribeToBackendRestart provider prop so its query caches re-fetch. docs/ARCHITECTURE.md §5.8 documents the recovery layers and the host-app state (module-scope captures in comapeo-mobile) that recovery cannot reach. Requires @comapeo/ipc >= the release containing digidem/comapeo-ipc#87 (which itself needs digidem/rpc-reflector#52); the dependency bump lands here once released.
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.
In @comapeo/core-react-native the CoMapeo backend runs in an Android foreground service. Android's low-memory killer can kill and restart that service while the app keeps running: the Unix-socket transport reconnects, but the restarted server has lost every event subscription, and calls that were in flight when it died hang until they time out. Today
createClient.close()is the only way to reject pending calls, and it is terminal — the client can't be reused after reconnect.This adds two static methods on
createClient, implemented the same way ascreateClient.close(a Symbol-keyed method on the root proxy):createClient.rejectPending(client, error)rejects every in-flight method call with the caller-supplied error, clears the pending and collector maps, and returns the number of calls rejected. Unlikeclose, the client stays fully usable. Rejecting the inner promise settles the p-timeout wrapper (clearing its timer), so nothing double-settles and no timers leak; a response arriving later for a rejected msgId missespendingand is ignored with a warning, as with any unknown msgId.createClient.resubscribe(client)re-sends[ON, eventName, propArray]for every event — root and nested sub-client events — that still has at least one listener, so a restarted server starts emitting subscribed events again. Re-sending ON is protocol-compatible (the client already sends ON on every subscribe call). Returns the number of ON messages sent.Both are no-ops (returning 0) on a closed client. The API is additive, so existing consumers (e.g. desktop/Electron, where the server process doesn't restart independently) are unaffected.
Why these methods, and why they live here
rejectPendingexists because calls in flight when the server dies can never complete: the dead process consumed (or never received) their request frames, so a restarted server has no record of them. Holding them means a guaranteed hang untiloptions.timeout; rejecting them promptly with a caller-supplied, typed error lets the app retry as soon as the transport is back.resubscribeexists because server-side subscription state dies with the process, and only the client knows what should be re-subscribed.Recovering from a restart needs three pieces of knowledge, each held by exactly one layer, and this PR adds only the piece that is rpc-reflector's:
createClient's closure — the emitter, keyed by a private encoded event-name format. Nothing outside the client can enumerate subscriptions without duplicating that state and the unexported wire format.@comapeo/ipcfans these calls out over its manager, routing, and per-project clients (feat: transport-reset recovery for server restarts comapeo-ipc#89).Alternatives considered and rejected:
handleEmitwhen an emit finds no listeners, and an external mirror would have to observe every such path correctly, forever.close()is terminal, so this invalidates every held reference and pushes listener re-registration up into the app layer — the churn that backend-owned lifecycle (feat!: backend-owned project lifecycle over stable per-project channels comapeo-ipc#88) exists to eliminate.One deliberate limitation:
rejectPendingis a point-in-time sweep, not a "transport down" state. Calls made after the sweep while the transport is still down stay pending — which is the desired behavior for a brief outage (they complete normally once the transport reconnects, if the transport delivers frames written during the outage), with the per-call timeout as the backstop. A stateful pause/resume mode that fails such calls fast was considered and left out as scope creep.An earlier revision also added
createClient.emitLocal; it was dropped (added then reverted on this branch) once its only consumer — the hard-close of project wrappers in digidem/comapeo-ipc#87 — was superseded by backend-owned project lifecycle (digidem/comapeo-ipc#88/#89), which needs no local event delivery.