feat: add transport-reset handling for server restarts - #87
Closed
gmaclennan wants to merge 2 commits into
Closed
Conversation
When the process hosting the server dies and restarts while the client stays alive (the Android foreground-service restart case), calls that were in flight hang until timeout, the restarted server has lost every event subscription, and cached per-project clients are bound to a dead server. Add notifyCoreClientTransportReset and notifyServicesClientTransportReset, which reject in-flight calls with a new TransportClosedError (code RPC_TRANSPORT_CLOSED), replay event subscriptions, and hard-close all project wrappers. Project wrappers must be hard-closed rather than revalidated: instance ids come from a counter that restarts with the server, so a restarted server can mint an id identical to the one a cached wrapper is bound to and the instance-id currency check would falsely pass. Stale project references behave like closed projects afterwards; a getProject in flight during the reset rejects with TransportClosedError. Requires rpc-reflector rejectPending/resubscribe (digidem/rpc-reflector#52).
…nsubscribe on closed proxies
Review follow-ups:
- Resubscribing at drop time writes ON frames into a still-down
transport, which can nudge the native connection into a hot retry loop
while the server stays down. notifyCoreClientTransportReset and
notifyServicesClientTransportReset now only reject in-flight calls;
new resubscribeCoreClient / resubscribeServicesClient replay event
subscriptions once the transport is back up. Both are safe to call
repeatedly (the server dedupes subscriptions) and no-ops after close.
- hardClose now fires the project wrapper's 'close' event locally
(via the new createClient.emitLocal) before tearing the client down,
so app-held once('close') teardown listeners and the wrapper's own
cache-eviction listener run, matching a server-initiated close.
- Unsubscribe methods (off / removeListener / removeAllListeners) on a
closed client or project reference are now chainable no-ops instead
of throwing: removing a listener from a dead client is correct
teardown (React effect cleanup runs against stale wrappers).
Subscribe methods still throw.
Member
Author
|
Pushed ff2f4bf addressing the review (companion rpc-reflector change in digidem/rpc-reflector@d64b36d on #52's branch):
Tests cover: reset does not resubscribe, resubscribe replays and is idempotent/no-op-after-close, hard close fires |
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.
This was referenced Aug 15, 2026
gmaclennan
added a commit
to digidem/rpc-reflector
that referenced
this pull request
Aug 15, 2026
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
gmaclennan
added a commit
to digidem/rpc-reflector
that referenced
this pull request
Aug 19, 2026
* feat: add createClient.rejectPending and createClient.resubscribe 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. * feat: add createClient.emitLocal for local-only event delivery 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. * revert: drop createClient.emitLocal — no remaining consumer 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. * docs: document createClient.rejectPending and createClient.resubscribe * test: cover rejectPending and resubscribe via the public API 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.
Member
Author
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.
@comapeo/core-react-native runs the CoMapeo backend 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, calls that were in flight hang until they time out, and cached per-project clients are bound to a server that no longer exists. This PR gives the transport owner the hooks to recover. Desktop/Electron consumers are unaffected (additive API).
The recovery contract is two-phase: notify at drop time, resubscribe once the transport is back up. Resubscribing at drop time would write ON frames into a still-down transport, which can nudge the native connection into a hot retry loop while the backend stays down.
New exports:
TransportClosedError(errors.js) with a stablecodeof'RPC_TRANSPORT_CLOSED'— rejected into calls that were in flight when the transport dropped, so callers can distinguish "backend restarted; a read is safe to retry" from a real failure or a timeout.notifyCoreClientTransportReset(client)— call at drop time. It rejects pending calls on the manager and project-routing clients withTransportClosedError, then hard-closes every open project client and dropsopenProjectClients/currentProjectClients, so a latergetProjectbuilds a fresh wrapper against the new server. Each hard close rejects the wrapper's pending calls, fires its'close'event locally (via rpc-reflector's newcreateClient.emitLocal) so app-heldonce('close')teardown listeners and the wrapper's internal cache-eviction listener run — matching a server-initiated close — and then closes the client and its SubChannel. No-op aftercloseComapeoCoreClient.resubscribeCoreClient(client)— call once the transport has reconnected to the restarted server. Replays manager and project-routing event subscriptions (via rpc-reflector'screateClient.resubscribe). Safe to call repeatedly (the server dedupes subscriptions); no-op after close.notifyServicesClientTransportReset(servicesClient)/resubscribeServicesClient(servicesClient)— the same reject-at-drop / resubscribe-at-recovery pair for the (single, bare) services client.Closed-proxy behaviour is also relaxed for teardown:
off/removeListener/removeAllListenerson a closed client or stale project reference are chainable no-ops instead of throwing, because removing a listener from a dead client is correct cleanup (React effect cleanup in @comapeo/core-react runs against stale wrappers). Subscribe methods (on/once/addListener) still throw synchronously.The hard close of project wrappers is forced by the instance-id scheme: instance ids are minted by a deterministic counter (
projectId:++n) that restarts with the server, so a restarted server can mint the same id a cached wrapper is bound to, and the "is the cached wrapper still current" check inresolveProjectClientwould falsely pass. Stale project references held by the app behave like closed projects afterwards (they reuse the existing closed-proxy behaviour and reject withProjectClosedError, whose "get a fresh reference viagetProject" remedy is exactly right here);close()on a stale reference resolves. AgetProjectin flight during the reset rejects withTransportClosedError(including the narrow race where the routing response arrived just before the reset, guarded by a reset generation counter), and a retry returns a working client.Depends on digidem/rpc-reflector#52 — this PR should merge after that one lands and is released, followed by bumping the
rpc-reflectorrange here (the committedpackage.jsonstill points at the current release, so CI will fail on the new-API tests until then).