Add iOS support & test infrastructure#6
Merged
Conversation
iOS does not support foreground services like Android, so the Node.js backend is shut down gracefully when the app enters background or is terminated. Uses UIApplication.beginBackgroundTask to request additional execution time for clean shutdown via the same length-prefixed JSON protocol over Unix domain sockets. Key additions: - NodeJSIPC.swift: Unix domain socket client with length-prefixed framing - NodeJSService.swift: Node.js lifecycle management with graceful shutdown - AppLifecycleDelegate.swift: Expo lifecycle hooks for background/terminate - ComapeoCoreModule.swift: Real Expo module replacing stub (postMessage, getState, events matching Android API) - Swift Package test suite (MessageFraming, Semaphore/shutdown patterns, file watching, IPC socket integration tests) - CI workflow (.github/workflows/ios-tests.yml) running swift test on macOS https://claude.ai/code/session_0121j34VvA2xbvumSPvG3AEf
… files from package - Use withUnsafeMutableBytes instead of nested withUnsafeMutablePointer to avoid overlapping access to addr.sun_path - Exclude AppLifecycleDelegate, ComapeoCoreModule, NodeJSService from Swift Package target (they depend on ExpoModulesCore/UIKit) https://claude.ai/code/session_0121j34VvA2xbvumSPvG3AEf
Refactor NodeJSService to be testable without UIKit by:
- Extracting stopWithBackgroundTask into NodeJSService+BackgroundTask.swift
- Adding init(filesDir:) for injecting a test directory
- Exposing socket paths and cleanup() for test access
New NodeJSServiceTests verify intended behavior:
- Start transitions STOPPED → STARTING → STARTED
- Stop sends {"type":"shutdown"} over state IPC socket
- Stop transitions STARTED → STOPPING → STOPPED
- Shutdown timeout triggers cleanup
- Socket files deleted after stop
- Service can restart after stop
- Concurrent stop() calls are safe
- Full lifecycle state transition ordering
https://claude.ai/code/session_0121j34VvA2xbvumSPvG3AEf
- Use withUnsafeMutableBytes in NodeJSIPCTests.createMockServer (same fix as NodeJSIPC.swift) - Change unused var to let in SemaphoreShutdownTests https://claude.ai/code/session_0121j34VvA2xbvumSPvG3AEf
Source code fixes: - Fix thread-leak bug in NodeJSService: replace single shutdownSemaphore with separate nodeShutdownSemaphore (stop signals node to exit) and nodeCompletionSemaphore (node signals it has exited). The old design overwrote the semaphore runNode() was waiting on, leaking the thread. - Remove unused buffer properties from NodeJSIPC (receiveLengthBuffer, sendLengthBuffer, receiveMessageBuffer) that were declared but never used. - Make waitForFile, connectWithRetry, connectSocket internal (were private free functions) so tests can call the real implementations via @testable import instead of duplicating them. - Change waitForFile timeoutSeconds from Int to TimeInterval for sub-second test timeouts. - Fix misleading doc comment on waitForFile (claimed DispatchSource, actually polls). Test infrastructure: - Add shared MockNodeServer test helper (Tests/Helpers/) eliminating ~100 lines of duplicated mock server boilerplate across test files. - Rewrite WatchForFileTests to test the real waitForFile function via @testable import instead of a duplicated reimplementation. - Delete SemaphoreShutdownTests (tested DispatchSemaphore stdlib behavior and hand-written state machines, not actual code). Replaced by behavioral tests in NodeJSServiceTests. - Add behavioral tests: cleanup idempotency, cleanup from started state (background task expiration), connect-when-already-connected guard, disconnect-when-already-disconnected guard, stop-completes-quickly. - Add end-to-end IPCLifecycleTests exercising the full integration: service start → mock server → bidirectional IPC messages → graceful shutdown → restart cycle. CI: - Split ios-tests.yml into two jobs: fast unit tests (swift test, macOS) and integration tests (xcodebuild on iOS Simulator), mirroring the Android CI structure. - Fix artifact upload to not upload entire .build/ directory. https://claude.ai/code/session_01PKvomnFyTYzSo5xn4k69DH
- Rename SPM package from "ComapeoCoreTests" to "ComapeoCore" so the auto-generated xcodebuild scheme matches (-scheme ComapeoCore) - Add `set -o pipefail` so xcodebuild errors propagate through the pipe instead of being swallowed by `tail` - Add "Discover xcodebuild schemes" step to debug scheme name issues - Increase tail output from 50 to 100 lines for better error visibility https://claude.ai/code/session_01PKvomnFyTYzSo5xn4k69DH Co-authored-by: Claude <noreply@anthropic.com>
- Use /tmp with short prefixes instead of NSTemporaryDirectory() to stay within sockaddr_un.sun_path's 104-byte limit (bind EADDRINUSE errors) - Create mock servers after service.start() so deleteSocketFiles() doesn't remove their socket files (test hangs from 30s waitForFile polling) - Add socket path length validation in MockNodeServer for clear errors - Save errno before close() overwrites it in error paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extend download-nodejs-mobile.sh to fetch iOS NodeMobile.xcframework alongside Android binaries, with --platform flag support - Implement NodeJSService.runNode() via ObjC++ bridge (NodeMobileBridge) that calls node_start() with contiguous argv memory as required by libUV - Bundle nodejs-project/ files in iOS app bundle via podspec resources with npm install script phase for dependencies - Update podspec with vendored_frameworks, bitcode disabled, and NODE_MOBILE_AVAILABLE compile flag for conditional compilation - Update tests to use injectable nodeEntryPoint for mock blocking behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
iOS does not support foreground services like Android, so the Node.js backend is shut down gracefully when the app enters background or is terminated. Uses UIApplication.beginBackgroundTask to request additional execution time for clean shutdown via the same length-prefixed JSON protocol over Unix domain sockets. Key additions: - NodeJSIPC.swift: Unix domain socket client with length-prefixed framing - NodeJSService.swift: Node.js lifecycle management with graceful shutdown - AppLifecycleDelegate.swift: Expo lifecycle hooks for background/terminate - ComapeoCoreModule.swift: Real Expo module replacing stub (postMessage, getState, events matching Android API) - Swift Package test suite (MessageFraming, Semaphore/shutdown patterns, file watching, IPC socket integration tests) - CI workflow (.github/workflows/ios-tests.yml) running swift test on macOS https://claude.ai/code/session_0121j34VvA2xbvumSPvG3AEf --------- Co-authored-by: Claude <noreply@anthropic.com>
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
NodeMobileStartNode can only be called once per process. The previous tests stopped and restarted Node in setUp/tearDown, causing crashes. - Merge ServiceLifecycleTest and IPCIntegrationTest into one class - Use alphabetical naming (test01_, test99_) to ensure shutdown runs last - Never stop/restart Node mid-test; wait for app lifecycle to start it - Fix CocoaPods resource copying: ensure node_modules exists before pod install so nodejs-project is treated as a directory resource - Add npm install step to CI before pod install - Revert Android disable-animations to match working main config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These tests encode six invariants uncovered by review that current iOS
code violates. They are expected to FAIL on this commit; fixes land in
follow-up commits, each flipping its tests green.
Testable seams in ComapeoCoreModule (`resolveSocketPath`,
`stateString(for:ipc:)`) currently preserve the buggy production
behavior — they exist only to make the assertions reachable from
tests. NodeJSIPC.socket was widened from `private` to module-internal
so the partial-write test can tune kernel buffer options via
`@testable import`.
Tests added:
- ComapeoCoreModuleTests (example app)
- testModuleSocketPathMatchesServicePath — module builds its socket
path from Documents; service binds under /tmp. Client never finds
the socket.
- testStateStringReflectsServiceState — getState reads IPC state,
but stateChange event emits service state. They must agree.
- NodeJSServiceTests
- testStopTimeoutTransitionsToErrorNotStopped — cleanup() on a
timed-out stop declares .stopped while the node thread is still
alive, enabling a second NodeMobileStartNode call.
- testStartFromErrorStateIsRejected — guard only checks .stopped,
allowing restart from a broken state.
- NodeJSIPCTests
- testMessagesSentBeforeConnectAreBuffered — postMessage during
connecting silently drops at the fd guard.
- testLargeMessageIsDeliveredIntactUnderBackpressure — single-shot
Darwin.write on the body is treated as fatal on short returns,
desyncing the framed protocol.
- ServiceLifecycleTest (example app)
- test05_LateStateIPCReceivesStartedEvent — Node's index.js posts
started/ready before any iOS client finishes waitForFile+connect,
so late-connecting clients receive nothing.
- test98_BackgroundDoesNotStopNode — applicationDidEnterBackground
stops Node, but NodeMobileStartNode can only be called once per
process; every foreground/background cycle breaks the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two closely related issues, surfaced by the ultrareview and proven by the red tests in the prior commit: 1. The module was computing its IPC socket path from the app's Documents directory, while NodeJSService binds under filesDir (/tmp/comapeo on iOS to respect the 104-byte sun_path limit). The client therefore polled for a socket that would never appear; after waitForFile timed out and connectWithRetry exhausted, the IPC pinned to .error and every postMessage call silently dropped. Source the path from AppLifecycleDelegate.shared.nodeService.comapeoSocketPath — the same value Node is launched with. 2. getState() returned a string derived from the IPC connection state while the stateChange event emits the service state. The two lifecycles frequently diverge (transient IPC read errors, pre-connection startup races, graceful shutdown). Align the pull API with the push API by reading service.state.rawValue directly; IPC connection status is no longer the authoritative source for "is the Node runtime up". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gmaclennan
added a commit
that referenced
this pull request
Apr 21, 2026
These five files carried Android-specific changes that had been authored on the iOS branch but logically belong to this PR. PR #6 has reverted them to origin/main state; the next commit re-applies them here so each change is explicitly owned by the Android PR rather than inherited through the shared merge-base. This intermediate commit restores the origin/main content so the re-apply diff is meaningful. No user-visible behavior changes between this commit and the next one — they cancel each other for these paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gmaclennan
added a commit
that referenced
this pull request
Apr 21, 2026
These changes had been authored on the iOS branch but are Android-only; PR #6 has reverted them so this PR can own them explicitly. Paired with the previous "rewind" commit so the diff is meaningful against the post-revert origin/main. - .github/workflows/android-tests.yml — Node 18 → 20 upgrade; replace emulator-runner's `input keyevent 82` (which races snapshot-restore and crashes the emulator) with `settings put` to disable animations; add per-test `-i` gradle flag and a 15-minute timeout - android/src/main/assets/nodejs-project/index.js — track a `readinessPhase` in the state IPC server and replay `started`/`ready` to late-connecting clients (same fix already applied to the iOS copy at ios/nodejs-project/index.js) - android/src/main/assets/nodejs-project/lib/control-rpc.js — delete unused stub - example/tests/android/ServiceLifecycleTest.kt — add a PID-stability check to `waitForServiceRunning` so a `:ComapeoCore` process that dies at startup is surfaced instead of letting the test hang - example/tests/android/ShutdownPathTest.kt — same PID-stability check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9baf548 to
68863c4
Compare
…lock
NodeJSIPC: replace the ad-hoc close path with a cancel-and-join sequence
mirroring Android's NodeJSIPC.kt. disconnect() now (1) shutdown(2)s the
socket to wake any blocked read, (2) joins the receive worker — skipped
when called re-entrantly from inside it, via a DispatchSpecificKey
marker — (3) drains sendQueue with sync {}, (4) close(2)s the fd. This
eliminates the TOCTOU window where a closed fd could be reassigned to
an unrelated open under an in-flight read or write. performConnect
gains a state-still-.connecting guard so a connect that races with
disconnect orphans its fd cleanly. connect() now rejects .disconnecting
in its guard (a sendMessage during disconnect could otherwise flip
state back to .connecting). Equatable is auto-synthesized; socket is
private(set).
NodeJSService: a transitionState(to:) helper acquires the lock,
mutates state, releases the lock, then fires onStateChange — observers
that re-enter locked methods (e.g. cleanup() from inside the callback)
no longer deadlock. The state-IPC's onMessage handler drops the dead
[weak self] / _ = self pattern. cleanup() drops the redundant
state != targetState guard (didSet already gates the log on change).
Stale NodeJSService+BackgroundTask.swift reference removed.
example/with-ios-tests: execSync → execFileSync (no shell quoting).
ComapeoCore.podspec: skip npm install when node_modules already exists
so incremental builds don't pay npm's startup cost on every compile.
example/.gitignore: anchor the `ios` rule to `/ios` so the prebuild
exclusion no longer also matches example/tests/ios/, which is the
source of truth for the XCTest sources.
Tests: testConnectFromErrorStateRetries covers the .error → connect()
path. testDisconnectFromMessageCallbackDoesNotDeadlock asserts the
re-entrance fix. testDisconnectDuringConnectionAttemptIsHonored
asserts the performConnect race guard. testConcurrentSendsAndDisconnect
AreSafe is a stress smoke test for the cancel-and-join refactor.
testObserverCanReenterLockedMethodFromCallback asserts onStateChange
runs outside the service lock. ServiceLifecycleTest converts the
"double start" and "state socket listening" Thread.sleep waits to
waitUntil + transition capture; "background does not stop" becomes a
fail-fast poll. The "late state-IPC" sleep stays — it's intentional
(be late on purpose) — with a comment explaining why.
All 51 SPM tests pass on macOS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ServiceLifecycleTest.swift uses waitUntil, but the helper at ios/Tests/Helpers/XCTestCase+Polling.swift lives in the SPM test target — invisible to the example/ios XCTest target that with-ios-tests populates from example/tests/ios/. The integration job failed with "Cannot find 'waitUntil' in scope". Mirror the helper into example/tests/ios/ so the with-ios-tests plugin's copyTestSources picks it up alongside the test files. The file is identical to its SPM counterpart with a header comment calling out the duplication and why it's necessary (two compilation units, no shared code path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er, doc cleanup with-ios-tests: switch the Podfile patch from a regex-anchor `replace` to `mergeContents` (the same idempotent-injection helper the Android plugin uses). The injected target stanza is now wrapped in `# @generated begin/end with-ios-tests:test-target` markers — re-running prebuild becomes a true no-op, and a future Expo template change fails loudly via mergeContents' ERR_NO_MATCH rather than silently mis-injecting. Header comment now states explicitly that this plugin is example-app-internal and not part of the public module surface. NodeJSService: precondition that filesDir + socket-filename ≤ 104 bytes at init time. A path that overflows sockaddr_un.sun_path otherwise silently truncates and surfaces as a mysterious connect-refused much later. Fail loud, fail at the boundary. NodeJSIPC: TODO comments for two known unbounded-growth paths flagged in review — pendingMessages (pre-connect send buffer) and inbound messageLength (4-byte LE prefix taken at face value). Both deliberately unbounded today for Android parity; the TODOs document why and where to revisit. ComapeoCore.podspec: TODO that the script_phase npm install will move once the iOS/Android nodejs-project sync follow-up lands (potentially to a `prepare_command` once the new flow guarantees ordering). scripts/download-nodejs-mobile.sh: collapse the per-platform branches into one helper. 165 → 103 lines, identical extracted output. The iOS release zip ships node-side headers under top-level `include/` that NodeMobile.framework supplies via its module.modulemap — the helper's optional `-x` exclude pattern keeps them out of the source tree. Tests: delete MessageFramingTests (its encode/decode were a parallel reimplementation of NodeJSIPC's framing, so a real framing regression wouldn't fail those tests; round-trip integration is covered by NodeJSIPCTests). Port the unicode case to NodeJSIPCTests so the byte-length-vs-char-count contract still has explicit coverage. Add testDisconnectFromErrorState (state-machine gap). Add testRapidStopAfterStartIsSafe (the most plausible production failure mode — stop before state IPC connects, shutdown frame buffers, stop times out cleanly to .error). Rename testStateStringReflectsServiceState → testStateStringDerivesFromServiceArgumentNotIPC to match what it actually asserts. Docs: README gains a contributing section delineating the public module (`src/`, `android/`, `ios/`) from the example app (`example/`) and explicitly notes that the with-ios-tests / with-android-tests config plugins are example-app-only — not something a consumer of @comapeo/core-react-native ever installs or registers. agents.md mirrors the same clarification next to the plugin descriptions and links the Podfile-modification reasoning to the PR #6 review discussion. All 43 SPM tests pass on macOS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
The merges from main pulled Expo 55 / RN 0.83 onto this branch, whose ExpoModulesCore now fails to compile on Xcode 16.2 (Swift 6.0.3) with: Main actor-isolated instance method 'updateProps' cannot be used to satisfy nonisolated protocol requirement (and similarly for getContentView / getProps / getWrappedView) This is a known Swift 6.0.x strict-actor-isolation regression — Swift 6.1 (Xcode 16.3+) downgrades the diagnostic back to a warning, which is what the upstream Expo project relies on. macos-14 GitHub-hosted runners cap at Xcode 16.2; macos-15 ships 16.4 by default. Switch the integration-tests job accordingly. The Swift Package job stays on macos-14 + Xcode 15.4 — it builds only the UIKit-free files which have no Expo dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expo 55's prebuild now generates ExpoModulesProvider.swift in the Pods-corereactnativeexampleTests target with Swift 5.9+'s explicit `internal import X` access-level-on-import syntax (SE-0409). Two follow-on knock-ons: - Mixing that explicit-internal with our `@testable import ComapeoCore` (which has implicit access level) triggers "ambiguous implicit access level for import of 'ComapeoCore'; it is imported as 'internal' elsewhere" in the test target. The fix is to make the access level on the testable imports explicit too. - The new syntax — `@testable internal import X` — requires Swift 5.9 language mode minimum. The test target was previously pinned to Swift 5.0 by add-test-target.rb. Bump it to 5.9 and document why inline. Verified locally: xcodebuild build-for-testing succeeds against the freshly merged-from-main branch with macos-15 / Xcode 16.4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Xcode 16.4 (Swift 6.1) still rejects ExpoModulesCore's `@MainActor` on protocol conformances and the surrounding Swift Concurrency patterns — same root cause as expo/expo#42525. Xcode 26.x (Swift 6.2) is what the Expo team's local builds use and what compiles these cleanly. macos-15 runners ship 26.0.1 through 26.3; pinning 26.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under Expo 55, BaseExpoAppDelegateSubscriber.init() — the superclass of
our AppLifecycleDelegate — is @MainActor-isolated. Combined with Xcode
26's Swift 6 strict-isolation runtime check, accessing
`AppLifecycleDelegate.shared` from any non-main thread now traps:
Thread 9 Crashed: com.facebook.react.runtime.JavaScript
_swift_task_checkIsolatedSwift
_checkExpectedExecutor
@objc BaseExpoAppDelegateSubscriber.init()
AppLifecycleDelegate.init()
AppLifecycleDelegate.shared.unsafeMutableAddressor
static ComapeoCoreModule.resolveSocketPath()
closure #1 in ComapeoCoreModule.definition()
ComapeoCoreModule's `OnCreate` block runs on Expo's React Native JS
thread, so the lazy init of `.shared` from inside it tripped the check
during XCTest's host-app launch — manifesting as the "test crashed
with signal trap before establishing connection" failure on Xcode 26.
The actually-shared resource was always the static `_nodeService`, not
the AppLifecycleDelegate instance. Add a static `nodeService` accessor
on AppLifecycleDelegate and route ComapeoCoreModule's three call sites
(resolveSocketPath, OnCreate's onStateChange wiring, getState) through
it. The instance accessor stays for tests, which run on the main thread
and can safely materialise `.shared`.
Verified locally: xcodebuild test against iPhone 16 / iOS 26.2 — all
3 example-app integration tests (testModuleSocketPathMatchesServicePath,
testStateStringDerivesFromServiceArgumentNotIPC, testFullServiceLifecycle)
pass cleanly. testFullServiceLifecycle runs the real Node.js process
end-to-end in 6 seconds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups from the post-fix review of ea36a38: (1) Migrate the example-app test helpers off `AppLifecycleDelegate.shared.nodeService` onto the static `AppLifecycleDelegate.nodeService`. The old form was "safe" only because XCTest's synchronous test methods run on the main thread, but `async` tests, `Task { }`, and `XCTContext.runActivity` blocks wrapping async work can resume off-main and would silently re-introduce the SIGTRAP fixed in ea36a38. The one remaining `.shared` reference is in ServiceLifecycleTest's "background does not stop Node" phase, which legitimately needs an instance to invoke `applicationDidEnterBackground(_:)`. (2) Gate `static let shared` to `#if DEBUG`. With every production callsite now routing through the static, `.shared` is purely test machinery — no reason to ship that surface area in release builds where a future contributor could inadvertently reach it from off-main code and re-introduce the actor-isolation crash. (3) Collapse `private static let _nodeService` + `static var nodeService` + `var nodeService` into a single `static let nodeService`. The two-step indirection only earned its keep when an instance accessor existed for production lifecycle hooks; with those rewritten to `Self.nodeService`, the underscore-prefixed private storage is dead weight. The instance-level `var nodeService` accessor is gone entirely. Updated agents.md to describe the new shape and explain why production callsites must use the static, not `.shared`. Verified: - xcodebuild test on iPhone 16 / iOS 26.2 — all 3 example-app integration tests pass (testFullServiceLifecycle 6.1s). - swift test (SPM, macOS) — 43 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI failed on a macos-15 runner with:
xcodebuild: error: Unable to find a device matching the provided
destination specifier:
{ platform:iOS Simulator, OS:latest, name:iPhone 16 }
Available destinations for the "corereactnativeexample" scheme:
{ platform:macOS, ... My Mac }
{ platform:iOS, ... Any iOS Device }
{ platform:iOS Simulator, ... Any iOS Simulator Device }
Per the macos-15 runner image readme, iPhone 16 *is* pre-installed for
both iOS 18.x and iOS 26.x. But some runner instances boot with no
simulator devices registered (only the placeholder destination listed)
— transient provisioning state. The previous run on the same workflow
succeeded with the same destination, so it isn't a code issue.
Make the workflow self-healing: a new "Resolve iOS Simulator" step
walks `xcrun simctl list devices available --json`, prefers the
highest-version iOS runtime, and picks the first available iPhone
simulator's UDID. If somehow no iPhone simulator is registered at all,
falls back to creating one (`xcrun simctl create "ci-iphone" "iPhone 17" $RUNTIME`).
xcodebuild then targets that UDID via `id=...` instead of `name=...,OS=latest`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gmaclennan
added a commit
that referenced
this pull request
May 6, 2026
Addresses the high- and medium-priority issues from the review subagent's pass over Phase 1+2a+2b: Boot transaction lifecycle (was: review #1, #15) - applyAndEmit now closes bootTx and drains in-flight phase spans on STOPPING / STOPPED transitions too — not just STARTED / ERROR. stop()-from-STARTING transitions to STOPPING (rule 3 of deriveLifecycleState) and bypassed both terminals; destroy() forcing STOPPED-via-stopRequested did the same. Status mapped: STARTED→ok, ERROR→ internal_error, STOPPING/STOPPED→cancelled. - startBootTransaction now passes a TransactionContext carrying TracesSamplingDecision(true, 1.0). The previous TransactionOptions-only setup didn't actually force sampling, so with the SDK default tracesSampleRate=0.0 the boot transaction was dropped before reaching the wire. SentryConfig misconfig handling (was: review #3) - Both Kotlin and Swift readers used to crash on (DSN-set, environment-missing) — meant to be "fail loud" but a stale prebuild from before the validation was added would crash every cold start with no recovery. Now log loud (System.err on Android since android.util.Log isn't mocked on JVM tests; NSLog on iOS) and return null (Sentry off). Updated test renamed to assert "returns null, doesn't throw". Span op/description ordering (was: review #19) - transaction.startChild(op, description) — op is the indexed dashboard column. Was passing ("boot", "boot.<phase>"), swapped to ("boot.<phase>", human-readable description) so the dashboard groups by the phase taxonomy that matches the bench backend's boot-spans.js helper. Plugin idempotency (was: review #7) - Previously, dropping `props.sentry` from the plugin registration left stale meta-data / plist entries from a previous prebuild (with `expo prebuild --no-clean`). Plugin now passes through a no-Sentry cleanup mod that strips every key it owns; consumer-owned keys (e.g. io.sentry.* set by @sentry/react-native's plugin) are untouched. messageerror payload truncation (was: review #8) - src/sentry.ts now truncates the wrapped error message to 256 chars before forwarding to captureException. The control-frame parser surfaces offending input verbatim, which can include arbitrary bytes from a corrupted frame — truncating keeps Sentry events small and readable. IPC + SEND_ERROR_NATIVE breadcrumbs/events (was: review #9, #10) - NodeJSIPC's onConnectionStateChange callback wired in the FGS-side controlIpc construction; emits comapeo.ipc breadcrumbs at info (warning on Error). Per §7.4.5. - SEND_ERROR_NATIVE_TIMEOUT_MS firing now captures a level=warning event with timeout:errorNativeForward tag. Per §7.4.4. Logging swallowed surprises (was: review low-priority) - SentryFgsBridge's empty `catch (t: Throwable) {}` blocks now Log.w so debug builds notice swallowed bridge / SDK bugs. Post-init bridge tests (was: review #6) - New SentryFgsBridgeImplTest spins up a real Sentry hub via the cross-platform Sentry.init(SentryOptions) path with an in-memory ITransport. Covers: addBreadcrumb (no envelope on its own), captureException + captureMessage (envelope enqueued), startBootTransaction with global tracesSampleRate=0.0 (must still reach transport thanks to the TracesSamplingDecision override — regression test for the §15 bug above), boot span lifecycle, finishSpan with cancelled status, unknown level fallback to INFO. All Sentry-related tests pass: 25 cases across SentryConfigTest (8), SentryFgsBridgeTest (10), SentryFgsBridgeImplTest (7). Verified locally: - npm run lint clean - npx tsc --noEmit clean - ./gradlew :comapeo-core-react-native:testDebugUnitTest passes - ./gradlew :comapeo-core-react-native:compileDebugKotlin succeeds with sentry-android on the compile classpath
gmaclennan
added a commit
that referenced
this pull request
Jun 22, 2026
## Optic Release Automation This **draft** PR is opened by Github action [optic-release-automation-action](https://github.com/nearform-actions/optic-release-automation-action). A new **draft** GitHub release [v1.0.0-pre.2](https://github.com/digidem/comapeo-core-react-native/releases/tag/untagged-c499977757c9745e56b2) has been created. Release author: @gmaclennan #### If you want to go ahead with the release, please merge this PR. When you merge: - The GitHub release will be published - The npm package with tag pre will be published according to the publishing rules you have configured - No major or minor tags will be updated as configured #### If you close the PR - The new draft release will be deleted and nothing will change ## What's Changed * Android Testing Infrastructure & Bug Fixes by @gmaclennan in #3 * chore: prebuild example/android; harden instrumented tests by @gmaclennan in #10 * Integrate @comapeo/core via IPC over Unix sockets by @gmaclennan in #5 * chore: adjust repo setup by @achou11 in #12 * chore: minor fixes based on expo-doctor by @achou11 in #13 * Add iOS support & test infrastructure by @gmaclennan in #6 * chore: add architecture docs & plans by @gmaclennan in #11 * update some native deps used in backend by @achou11 in #14 * iOS Phase 1: unified JS bundle + smoke test (simulator-only) by @gmaclennan in #15 * iOS Phase 2: xcframework Embed & Sign for native addons by @gmaclennan in #16 * Phase 2 Android: jniLibs packaging + unified rollup loader plugin by @gmaclennan in #17 * chore: post-Phase-2 cleanup — comments, plan docs, agents.md by @gmaclennan in #33 * android: read abiFilters from reactNativeArchitectures (#30) by @gmaclennan in #35 * refactor: simplify build-backend.ts; rollup writes directly to native asset trees by @gmaclennan in #34 * chore: fix eslint configuration by @achou11 in #41 * android: audit 16 KB page alignment on every shipped .so by @gmaclennan in #43 * Add rootkey persistence and lifecycle state management by @gmaclennan in #36 * chore: move example app into apps directory by @achou11 in #18 * refactor: per-component lifecycle state with derived ComapeoState by @gmaclennan in #47 * android: fold waitForFile into connect retry loop by @gmaclennan in #52 * chore: add e2e testing app by @achou11 in #49 * fix(android): drop setUnlockedDeviceRequired from rootkey wrapper key by @gmaclennan in #57 * fix(backend): cache stopping/error frames for late joiners by @gmaclennan in #58 * fix(ios-tests): wait for STOPPING before signalling node exit by @gmaclennan in #59 * fix(android): drain JNI stdio pumps before returning from node::Start by @gmaclennan in #60 * Sentry integration: Phase 1 + Phase 2a + Phase 2b by @gmaclennan in #54 * feat(backend): polywasm-backed undici on iOS, re-enable maps plugin by @gmaclennan in #62 * ci: drop unreliable Android emulator snapshot caching by @gmaclennan in #64 * feat(sentry): land Phase 3 — backend loader + RPC tracing by @gmaclennan in #63 * fix(ios-tests): serialise STOPPING/STOPPED observers in testFullLifecycleStateTransitions by @gmaclennan in #71 * use npm list instead of custom traversal to get native module versions by @achou11 in #70 * feat(sentry): land Phases 6 + 7a — Android exit reasons & iOS MetricKit app-exit telemetry by @gmaclennan in #72 * fix(sentry): make exit telemetry lossless and stop cross-process clobbering by @gmaclennan in #84 * chore(e2e): add e2e tests on browserstack via Maestro by @achou11 in #56 * feat(sentry): migrate to @sentry/react-native v8; exit telemetry as Application Metrics by @gmaclennan in #73 * Map server integration by @gmaclennan in #86 * chore(deps): upgrade to Expo SDK 56 (React Native 0.85) by @gmaclennan in #87 * chore(ci): add release workflow by @gmaclennan in #90 * chore: fix npm script and release build script by @gmaclennan in #91 * chore(pack): don't try to package build files by @gmaclennan in #92 * fix: start fastify listening by @gmaclennan in #93 * perf(backend): switch bundler from rollup to rolldown by @gmaclennan in #94 * fix(ci): ignore-scripts in ios npm installs by @gmaclennan in #96 * fix(ci): replace --ignore-scripts with npm strict-allow-scripts allowlist by @gmaclennan in #106 * feat(config): let the consuming app supply the default project config by @gmaclennan in #95 * chore(release): merge prerelease branch. by @gmaclennan in #110 ## New Contributors * @achou11 made their first contribution in #12 **Full Changelog**: https://github.com/digidem/comapeo-core-react-native/commits/v1.0.0-pre.2 <!-- <release-meta>{"id":342868678,"version":"v1.0.0-pre.2","npmTag":"pre","opticUrl":"https://optic-zf3votdk5a-ew.a.run.app/api/generate/"}</release-meta> -->
gmaclennan
added a commit
that referenced
this pull request
Jun 22, 2026
## Optic Release Automation This **draft** PR is opened by Github action [optic-release-automation-action](https://github.com/nearform-actions/optic-release-automation-action). A new **draft** GitHub release [v1.0.0-pre.2](https://github.com/digidem/comapeo-core-react-native/releases/tag/untagged-352a6c41c12fd02dec37) has been created. Release author: @gmaclennan #### If you want to go ahead with the release, please merge this PR. When you merge: - The GitHub release will be published - The npm package with tag pre will be published according to the publishing rules you have configured - No major or minor tags will be updated as configured #### If you close the PR - The new draft release will be deleted and nothing will change <!-- Release notes generated using configuration in .github/release.yml at 7fe80b4 --> ## What's Changed ### 🚀 Features * Integrate @comapeo/core via IPC over Unix sockets by @gmaclennan in #5 * Add iOS support & test infrastructure by @gmaclennan in #6 * iOS Phase 1: unified JS bundle + smoke test (simulator-only) by @gmaclennan in #15 * iOS Phase 2: xcframework Embed & Sign for native addons by @gmaclennan in #16 * Phase 2 Android: jniLibs packaging + unified rollup loader plugin by @gmaclennan in #17 * android: read abiFilters from reactNativeArchitectures (#30) by @gmaclennan in #35 * Add rootkey persistence and lifecycle state management by @gmaclennan in #36 * Sentry integration: Phase 1 + Phase 2a + Phase 2b by @gmaclennan in #54 * feat(backend): polywasm-backed undici on iOS, re-enable maps plugin by @gmaclennan in #62 * feat(sentry): land Phase 3 — backend loader + RPC tracing by @gmaclennan in #63 * feat(sentry): land Phases 6 + 7a — Android exit reasons & iOS MetricKit app-exit telemetry by @gmaclennan in #72 * feat(sentry): migrate to @sentry/react-native v8; exit telemetry as Application Metrics by @gmaclennan in #73 * Map server integration by @gmaclennan in #86 * feat(config): let the consuming app supply the default project config by @gmaclennan in #95 ### 🐛 Bug Fixes * fix(android): drop setUnlockedDeviceRequired from rootkey wrapper key by @gmaclennan in #57 * fix(backend): cache stopping/error frames for late joiners by @gmaclennan in #58 * fix(ios-tests): wait for STOPPING before signalling node exit by @gmaclennan in #59 * fix(android): drain JNI stdio pumps before returning from node::Start by @gmaclennan in #60 * fix(ios-tests): serialise STOPPING/STOPPED observers in testFullLifecycleStateTransitions by @gmaclennan in #71 * fix(sentry): make exit telemetry lossless and stop cross-process clobbering by @gmaclennan in #84 * fix: start fastify listening by @gmaclennan in #93 * fix(ci): ignore-scripts in ios npm installs by @gmaclennan in #96 * fix(ci): replace --ignore-scripts with npm strict-allow-scripts allowlist by @gmaclennan in #106 * fix(release): stop `npm pack --dry-run` leaking dry-run into backend install by @gmaclennan in #129 ### ⚡ Performance * perf(backend): switch bundler from rollup to rolldown by @gmaclennan in #94 ### ⬆️ Dependencies * update some native deps used in backend by @achou11 in #14 * chore(deps): upgrade to Expo SDK 56 (React Native 0.85) by @gmaclennan in #87 ### 🏗️ Maintenance * Android Testing Infrastructure & Bug Fixes by @gmaclennan in #3 * chore: prebuild example/android; harden instrumented tests by @gmaclennan in #10 * chore: adjust repo setup by @achou11 in #12 * chore: minor fixes based on expo-doctor by @achou11 in #13 * chore: add architecture docs & plans by @gmaclennan in #11 * chore: post-Phase-2 cleanup — comments, plan docs, agents.md by @gmaclennan in #33 * refactor: simplify build-backend.ts; rollup writes directly to native asset trees by @gmaclennan in #34 * chore: fix eslint configuration by @achou11 in #41 * android: audit 16 KB page alignment on every shipped .so by @gmaclennan in #43 * chore: move example app into apps directory by @achou11 in #18 * refactor: per-component lifecycle state with derived ComapeoState by @gmaclennan in #47 * android: fold waitForFile into connect retry loop by @gmaclennan in #52 * chore: add e2e testing app by @achou11 in #49 * ci: drop unreliable Android emulator snapshot caching by @gmaclennan in #64 * use npm list instead of custom traversal to get native module versions by @achou11 in #70 * chore(e2e): add e2e tests on browserstack via Maestro by @achou11 in #56 * chore(ci): add release workflow by @gmaclennan in #90 * chore: fix npm script and release build script by @gmaclennan in #91 * chore(pack): don't try to package build files by @gmaclennan in #92 * chore(release): merge prerelease branch. by @gmaclennan in #110 * ci(e2e): retry BrowserStack builds on infra-class flakes by @gmaclennan in #113 ### Other Changes * ci: derive changelog labels from PR titles + add Dependabot by @gmaclennan in #114 ## New Contributors * @achou11 made their first contribution in #12 * @optic-release-automation[bot] made their first contribution in #112 **Full Changelog**: https://github.com/digidem/comapeo-core-react-native/commits/v1.0.0-pre.2 <!-- <release-meta>{"id":342970724,"version":"v1.0.0-pre.2","npmTag":"pre","opticUrl":"https://optic-zf3votdk5a-ew.a.run.app/api/generate/"}</release-meta> -->
17 tasks
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.
Summary
Complete iOS support for the module including iOS test infrastructure.
iOS runtime
NodeMobile.xcframeworkis downloaded during install; the JS entry point is bundled into the app. Node.js runs on a dedicated 2 MB-stack thread inside the host process (no foreground-service equivalent on iOS).ios/NodeJSIPC.swift): Unix domain socket client with 4-byte little-endian length prefix + UTF-8 JSON frames — the same protocol Android'sNodeJSIPCspeaks. Exponential-backoff connect with retry,waitForFilehelper for socket-creation races, pre-connect send buffering sopostMessagecalls never drop. Receive loop feeds back via a callback;sendMessageSyncis used at shutdown so the shutdown frame is written before the node thread exits.ios/NodeJSService.swift):STOPPED → STARTING → STARTED → STOPPING → STOPPEDstate machine, with anERRORterminal state reached on timed-out shutdowns so a secondstart()can't violateNodeMobileStartNode's once-per-process constraint.NodeEntryPointandresolveJSEntryPointare injected so tests don't need the real NodeMobile binary.AppLifecycleDelegate.applicationDidEnterBackgroundis deliberately a no-op — the node thread cannot be restarted in the same process. OnlyapplicationWillTerminatestops the service.ComapeoCoreModule.swiftforwardspostMessage/getState/"message"/"stateChange"to the RN layer, matching the Android module's API surface.iOS test infrastructure
Two layers, two CI jobs:
swift teston macOSios/Tests/package-testsxcodebuild teston iOS Simulatorexample/tests/ios/integration-testsComapeoCoreSwift Package target compiles only UIKit-free files (NodeJSIPC,NodeJSService,Log). The test suite runs on macOS viaswift testwith no simulator, no code signing, no NodeMobile.ios/Tests/Helpers/:MockNodeServer(Unix-socket mock Node.js server),MockNodeService(factory forNodeJSServicewith a blocking-semaphore entry point),TestPaths(short-path/tmphelper for the 104-bytesockaddr_un.sun_pathlimit),XCTestCase+Polling(waitUntilhelper that replaces fragileThread.sleepwaits).ServiceLifecycleTestinexample/tests/ios/is a singletestFullServiceLifecyclemethod usingXCTContext.runActivityblocks for per-phase reporting — the tests in the lifecycle need to run sequentially and can't be run independently.example/tests/ios/; theexample/plugins/with-ios-tests/config plugin copies them into the prebuilt Xcode project, patches thePodfile, and registers a new target via a Ruby script. The generatedexample/ios/tree is gitignored.CI
.github/workflows/ios-tests.yml— two jobs:package-tests(macOS nativeswift test, ~2 min) andintegration-tests(Xcode 16.2 on iOS Simulator with real NodeMobile)..github/workflows/android-tests.yml— Node 20, emulator-flakiness fix for snapshot-restore race viainput keyevent.Test plan
cd ios && swift test— 46 SPM tests pass on macOSpackage-testsCI job greenintegration-testsCI job green (Xcode 16.2, iOS Simulator, real NodeMobile)android-testsCI job greenxcodebuild build-for-testing— example app + test target compile afterexpo prebuild