-
Notifications
You must be signed in to change notification settings - Fork 0
fix(android): shutdown IPC socket on JS reload so backend cleans up subscriptions #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -139,8 +139,13 @@ class ComapeoCoreModule : Module() { | |
| } | ||
|
|
||
| OnDestroy { | ||
| ipc.disconnect() | ||
| controlIpc.disconnect() | ||
| // OnCreate/OnDestroy bind to the Expo AppContext (JS-runtime | ||
| // lifetime), so they fire on every JS reload while this process | ||
| // stays alive. Close synchronously: the backend must see EOF on the | ||
| // old socket before the next OnCreate connects, otherwise the FD | ||
| // lingers and rpc-reflector listeners leak onto MapeoManager. | ||
| ipc.close() | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. xhigh review — lifecycle implications of
For the steady-state reload (OnDestroy→OnCreate) both are benign — listeners are recreated. They only bite the foreground-before-recreate and final-teardown orderings. |
||
| controlIpc.close() | ||
| } | ||
|
|
||
| OnActivityEntersForeground { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope | |
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.cancel | ||
| import kotlinx.coroutines.cancelAndJoin | ||
| import kotlinx.coroutines.channels.Channel | ||
| import kotlinx.coroutines.delay | ||
|
|
@@ -188,11 +189,15 @@ class NodeJSIPC( | |
| } | ||
| } | ||
| } | ||
| // `shutdown` before `cancelAndJoin`: the receive loop is parked in a | ||
| // blocking `readFully` that `cancelAndJoin` cannot interrupt, so without | ||
| // first waking it the join blocks until the node backend sends a message | ||
| // or closes the socket — a deadlock when node is connected but idle. | ||
| // Same fix as close(). | ||
| shutdownSocket() | ||
| connectJob?.cancelAndJoin() | ||
| connectJob = null | ||
| try { dataOutputStream?.close() } catch (_: Exception) {} | ||
| try { dataInputStream?.close() } catch (_: Exception) {} | ||
| try { socket.close() } catch (_: Exception) {} | ||
| closeStreamsAndSocket() | ||
| } | ||
| disconnectJob.invokeOnCompletion { cause -> | ||
| state.value = when (cause) { | ||
|
|
@@ -217,6 +222,45 @@ class NodeJSIPC( | |
| connect() | ||
| sendChannel.trySend(message) | ||
| } | ||
|
|
||
| /** | ||
| * Synchronous, terminal teardown for JS reload (process stays alive, so the | ||
| * fd must be closed here or it leaks until process death). `shutdown` must | ||
| * precede `close`: the receive loop is parked in a blocking `readFully` that | ||
| * holds the socket open until it returns, so `close()` alone never reaches | ||
| * the node backend — `shutdownInput` wakes the read, `shutdownOutput` sends FIN. | ||
| * Not reusable after close; construct a new instance. | ||
| */ | ||
| fun close() { | ||
| scope.cancel() | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. xhigh review —
All three resolve if |
||
| // Mark terminal before shutdownSocket() wakes the receive loop's blocking | ||
| // readFully: its IOException handler calls disconnect(), which then | ||
| // short-circuits on this state guard instead of relaunching teardown on | ||
| // the now-cancelled scope. Set after scope.cancel() so the (already | ||
| // cancelled) state collector still doesn't forward this transition, | ||
| // matching the prior terminal-close semantics. | ||
| state.value = State.Disconnected | ||
| sendChannel.close() | ||
| shutdownSocket() | ||
| closeStreamsAndSocket() | ||
| } | ||
|
|
||
| // `shutdown` (not `close`) wakes the receive loop parked in a blocking | ||
| // readFully and sends FIN; it must precede closeStreamsAndSocket(). | ||
| private fun shutdownSocket() { | ||
| if (::socket.isInitialized) { | ||
| try { socket.shutdownInput() } catch (_: Exception) {} | ||
| try { socket.shutdownOutput() } catch (_: Exception) {} | ||
| } | ||
| } | ||
|
|
||
| private fun closeStreamsAndSocket() { | ||
| try { dataOutputStream?.close() } catch (_: Exception) {} | ||
| try { dataInputStream?.close() } catch (_: Exception) {} | ||
| if (::socket.isInitialized) { | ||
| try { socket.close() } catch (_: Exception) {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
xhigh review — test asserts peer EOF but not client teardown (PLAUSIBLE)
closeReleasesSocketSynchronouslychecks the server read unblocked, but never asserts the client receive coroutine actually terminated. Becauseclose()cancels the scope without joining, the receive coroutine can still be unwinding (woken into itsIOException→disconnect()path) whentearDown()closesserverSocket/boundSocketand deletes the socket file. A regression whereclose()returns before the client coroutine unwinds still passes here; the leaked coroutine racing teardown would surface only as a flaky failure in a later test. Consider asserting client-side teardown (a post-close settle + state assertion) as the sibling tests do.