Skip to content

Add iOS support & test infrastructure#6

Merged
gmaclennan merged 59 commits into
mainfrom
claude/ios-support-graceful-shutdown-GCAXb
Apr 27, 2026
Merged

Add iOS support & test infrastructure#6
gmaclennan merged 59 commits into
mainfrom
claude/ios-support-graceful-shutdown-GCAXb

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Mar 12, 2026

Copy link
Copy Markdown
Member

Summary

Complete iOS support for the module including iOS test infrastructure.

iOS runtime

  • Embedded Node.js via nodejs-mobile: NodeMobile.xcframework is 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).
  • IPC layer (ios/NodeJSIPC.swift): Unix domain socket client with 4-byte little-endian length prefix + UTF-8 JSON frames — the same protocol Android's NodeJSIPC speaks. Exponential-backoff connect with retry, waitForFile helper for socket-creation races, pre-connect send buffering so postMessage calls never drop. Receive loop feeds back via a callback; sendMessageSync is used at shutdown so the shutdown frame is written before the node thread exits.
  • Service lifecycle (ios/NodeJSService.swift): STOPPED → STARTING → STARTED → STOPPING → STOPPED state machine, with an ERROR terminal state reached on timed-out shutdowns so a second start() can't violate NodeMobileStartNode's once-per-process constraint. NodeEntryPoint and resolveJSEntryPoint are injected so tests don't need the real NodeMobile binary.
  • Once-per-process behavior: AppLifecycleDelegate.applicationDidEnterBackground is deliberately a no-op — the node thread cannot be restarted in the same process. Only applicationWillTerminate stops the service.
  • Expo integration: ComapeoCoreModule.swift forwards postMessage / getState / "message" / "stateChange" to the RN layer, matching the Android module's API surface.

iOS test infrastructure

Two layers, two CI jobs:

Layer Tool Location CI job
Swift Package tests (mocked Node.js) swift test on macOS ios/Tests/ package-tests
Example-app tests (real Node.js) xcodebuild test on iOS Simulator example/tests/ios/ integration-tests
  • The ComapeoCore Swift Package target compiles only UIKit-free files (NodeJSIPC, NodeJSService, Log). The test suite runs on macOS via swift test with no simulator, no code signing, no NodeMobile.
  • Shared helpers under ios/Tests/Helpers/: MockNodeServer (Unix-socket mock Node.js server), MockNodeService (factory for NodeJSService with a blocking-semaphore entry point), TestPaths (short-path /tmp helper for the 104-byte sockaddr_un.sun_path limit), XCTestCase+Polling (waitUntil helper that replaces fragile Thread.sleep waits).
  • ServiceLifecycleTest in example/tests/ios/ is a single testFullServiceLifecycle method using XCTContext.runActivity blocks for per-phase reporting — the tests in the lifecycle need to run sequentially and can't be run independently.
  • Example-app XCTest sources live at example/tests/ios/; the example/plugins/with-ios-tests/ config plugin copies them into the prebuilt Xcode project, patches the Podfile, and registers a new target via a Ruby script. The generated example/ios/ tree is gitignored.

CI

  • .github/workflows/ios-tests.yml — two jobs: package-tests (macOS native swift test, ~2 min) and integration-tests (Xcode 16.2 on iOS Simulator with real NodeMobile).
  • .github/workflows/android-tests.yml — Node 20, emulator-flakiness fix for snapshot-restore race via input keyevent.

Test plan

  • cd ios && swift test — 46 SPM tests pass on macOS
  • package-tests CI job green
  • integration-tests CI job green (Xcode 16.2, iOS Simulator, real NodeMobile)
  • android-tests CI job green
  • xcodebuild build-for-testing — example app + test target compile after expo prebuild

claude and others added 17 commits March 12, 2026 01:04
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>
@gmaclennan gmaclennan changed the title Add iOS graceful shutdown support with IPC and test infrastructure Add iOS support: graceful shutdown, IPC, integration tests, and CI Mar 12, 2026
@gmaclennan gmaclennan changed the title Add iOS support: graceful shutdown, IPC, integration tests, and CI Add iOS support & test infrastructure Mar 12, 2026
@socket-security

socket-security Bot commented Mar 12, 2026

Copy link
Copy Markdown

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.

View full report

gmaclennan and others added 9 commits March 12, 2026 23:17
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>
@gmaclennan gmaclennan force-pushed the claude/ios-support-graceful-shutdown-GCAXb branch from 9baf548 to 68863c4 Compare April 21, 2026 13:19
gmaclennan and others added 8 commits April 21, 2026 15:24
…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>
@socket-security

socket-security Bot commented Apr 27, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedtype-fest@​4.33.010010010091100

View full report

gmaclennan and others added 7 commits April 27, 2026 16:29
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>
@gmaclennan gmaclennan enabled auto-merge (squash) April 27, 2026 17:19
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 gmaclennan merged commit 840a544 into main Apr 27, 2026
6 checks passed
@gmaclennan gmaclennan deleted the claude/ios-support-graceful-shutdown-GCAXb branch April 27, 2026 17:38
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 gmaclennan added the feature New feature (changelog) label Jun 22, 2026
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>
-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature (changelog)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants