Skip to content

fix(connectivity): harden WCSession delegate registration and reply handling#84

Merged
leogdion merged 3 commits into
v2.0.0-alpha.3from
atleast-beta.6
Jun 10, 2026
Merged

fix(connectivity): harden WCSession delegate registration and reply handling#84
leogdion merged 3 commits into
v2.0.0-alpha.3from
atleast-beta.6

Conversation

@leogdion

@leogdion leogdion commented Jun 7, 2026

Copy link
Copy Markdown
Member

Summary

Hardens WatchConnectivitySession so WatchConnectivity delivery is reliable and reply handlers behave correctly. No transferUserInfo is involved — all messaging stays on the existing dictionary / application-context paths.

Changes

  • Register the WCSession delegate in activate(), not init(). Constructing a WatchConnectivitySession no longer has the global side effect of claiming WCSession.default's delegate. This prevents a throwaway instance — e.g. one created then discarded by a SwiftUI @State autoclosure re-evaluation — from hijacking delivery from the real, activated instance. Calling activate() more than once is safe (re-assigning the delegate is a no-op and WCSession tolerates a repeated activate()).

  • Auto-acknowledge received messages so reply-expecting sends don't time out. When a sender uses the reply-expecting sendMessage, the receiver now auto-acks (empty reply) if the delegate didn't reply itself, avoiding WCErrorDomain 7012 timeouts. The dictionary path now mirrors the existing binary didReceiveMessageData path.

  • Once-guard the auto-ack reply handlers. The same reply closure is handed to the delegate and auto-acknowledged. A delegate that actually replies (e.g. via ConnectivityReceiveContext.replyWith) would otherwise invoke replyHandler twice — undefined behavior on the sender side. Both the dictionary and binary paths now wrap the handler so it fires at most once.

Files

  • WatchConnectivitySession.swift — drop delegate assignment from init
  • WatchConnectivitySession+ConnectivitySession.swift — assign delegate in activate()
  • WatchConnectivitySession+WCSessionDelegate.swift — once-guarded reply handlers + auto-ack on both message paths

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60413d9f-884e-4841-a5b5-dd25e8b868a3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch atleast-beta.6

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Jun 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (v2.0.0-alpha.3@a168245). Learn more about missing BASE report.

Additional details and impacted files
@@                Coverage Diff                @@
##             v2.0.0-alpha.3      #84   +/-   ##
=================================================
  Coverage                  ?   65.47%           
=================================================
  Files                     ?       32           
  Lines                     ?      533           
  Branches                  ?        0           
=================================================
  Hits                      ?      349           
  Misses                    ?      184           
  Partials                  ?        0           
Flag Coverage Δ
spm 66.85% <ø> (?)
swift-6.1 65.91% <ø> (?)
swift-6.2 65.91% <ø> (?)
swift-6.3 65.91% <ø> (?)
ubuntu 65.91% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown

Code Review: Adding TransferUserInfo (#84)

Overview

This PR adds queued-transport support to the SundialKitConnectivity layer:

  • transferUserInfo(_:) / transferFile(_:metadata:) methods on ConnectivitySession
  • Corresponding didReceiveUserInfo and didReceiveFile delegate callbacks
  • Two new counts (outstandingUserInfoTransferCount, outstandingFileTransferCount)
  • A new SendOptions.useApplicationContext flag (Phase 2 stub)
  • Full conformances on WatchConnectivitySession, NeverConnectivitySession, and both mocks
  • New QueuedTransportTests + CapturingConnectivityDelegate test helpers

The approach is solid and well-documented. A few things worth addressing before merge:


Issues

Medium

1. Silent failure in transferFile

WatchConnectivitySession+ConnectivitySession.swift — when Data.write(to:) fails the method logs and returns without any signal to the caller. Consider making this throws (matching the existing sendMessage/sendBinaryMessage signatures) so callers can handle the failure, or at minimum document the silent-failure contract explicitly.

2. MockSession.transferUserInfo calls the delegate back immediately (unrealistic round-trip)

In MockSession.swift, transferUserInfo immediately calls delegate?.session(self, didReceiveUserInfo: userInfo) on the same session. In reality, didReceiveUserInfo fires on the counterpart device. This means tests using MockSession will behave differently from real two-device flows. The receiveFile method takes a separate code path (correct pattern) — transferUserInfo should follow the same explicit-trigger style with a corresponding receiveUserInfo helper.

3. NeverConnectivitySession diverges from existing "always throws" contract

All other mutating methods on NeverConnectivitySession throw SundialError.sessionNotSupported. The two new methods are silent no-ops. This is inconsistent — either make them throw (matching the existing contract) or document why the queued methods are intentionally silent.

4. Comments reference "Phase 2" — violates CLAUDE.md guidance

ConnectivityManager+DelegateHandling.swift contains // Future enhancement: routed in the Phase 2 stream-routing work. Per CLAUDE.md: "Don't reference the current task, fix, or callers… since those belong in the PR description and rot as the codebase evolves." Replace with a plain TODO or remove the comment.

5. @unchecked Sendable in CapturingConnectivityDelegate

CapturingConnectivityDelegate is marked @unchecked Sendable while having mutable stored properties. CLAUDE.md calls out "zero @unchecked Sendable in plugins" as a goal. Test code is different, but this is a latent data-race if assertions run concurrently. Consider nonisolated(unsafe) on the captured properties to make the intent explicit.


Low

6. Orphaned temp file on crash

If the process is killed after fileData.write(to: tempURL) but before session(_:didFinish:) fires, the temp file is never cleaned up. Worth a note in the doc comment so callers understand the trade-off.

7. Discarded transfer handles

_ = session.transferUserInfo(…) and _ = session.transferFile(…) silently drop the returned transfer handles. The outstandingUserInfoTransferCount / outstandingFileTransferCount properties already surface the count — consider storing the handles internally to enable cancellation in a future PR.


Positive Notes

  • The synchronous file-read in session(_:didReceive:) is exactly right — WatchConnectivity deletes the file when the delegate method returns, and the doc comment explains why, which is the non-obvious part worth keeping.
  • Protocol additions are backward-compatible via NeverConnectivitySession and both mocks.
  • Test coverage hits the happy path for each new method.
  • Doc comments are clear and thorough throughout.

Summary

The core design is sound. The main asks before merge are: (1) decide on a failure-propagation strategy for transferFile, (2) fix the MockSession round-trip simulation to match receiveFile's explicit-trigger pattern, and (3) align NeverConnectivitySession with its existing throwing contract. The Phase 2 comment cleanup is a minor style fix per CLAUDE.md.

@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown

review text

@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown

Code Review: Adding TransferUserInfo (PR 84)

Overview

This PR adds queued-transport support to SundialKit's WatchConnectivity layer via transferUserInfo(_:) and transferFile(_:metadata:) primitives, delegate receive counterparts, and a new SendOptions.useApplicationContext routing flag. The foundational work is well-structured; the file-URL lifetime handling (reading data synchronously before the WatchConnectivity callback returns) is particularly thoughtful.


Bugs / Correctness

1. Temp file leak on app crash between write and didFinish

WatchConnectivitySession+ConnectivitySession.swift writes a temp file then calls WCSession.transferFile. Cleanup relies on session(_:didFinish:error:) firing. If the app is killed between the write and WatchConnectivity picking up the file, the temp file leaks permanently. A dedicated subdirectory (e.g. temporaryDirectory/SundialKit/transfers/) purged on next launch would address this.

2. Silent error discard in didFinish

The error parameter in session(_:didFinish:error:) is silently discarded (error _: Error?). A failed transfer produces no observable signal. At minimum log the error; ideally forward it to the delegate so callers can react.

3. MockSession transferUserInfo / transferFile asymmetry

transferUserInfo immediately calls the delegate (instant delivery). transferFile only records the transfer without notifying the delegate. Inconsistent semantics make tests misleading about real queued-delivery behavior. Both should behave consistently — the existing receiveFile test helper already does the right thing for files.


Design

4. Breaking protocol change with no default implementations

ConnectivitySession and ConnectivitySessionDelegate are public protocols. Adding six new requirements is source-breaking for any third-party conformer. Expected in alpha, but empty default protocol extensions for the two new delegate callbacks would reduce early-adopter friction.

5. SendOptions.useApplicationContext is unused at the call site

The flag is defined and documented but nothing in this PR reads it. A TODO with a linked issue number, or a test asserting no routing effect yet, would confirm the omission is intentional.


Thread Safety

6. @unchecked Sendable in CapturingConnectivityDelegate

CapturingConnectivityDelegate stores mutable vars (receivedUserInfo, receivedFileData, receivedFileMetadata) with no synchronization, suppressing the compiler check with @unchecked Sendable. The project goal is zero @unchecked Sendable. Using OSAllocatedUnfairLock or converting to an actor would remove the annotation and stay consistent with the codebase.


Test Coverage

7. No test for transferFile write failure path

The do/catch in WatchConnectivitySession.transferFile has no test covering the failure branch. Injecting FileManager via a protocol would make this testable.

8. NeverConnectivitySessionTests no-op tests have no assertions

transferUserInfoNoOp() and transferFileNoOp() pass by not crashing but assert nothing. A before/after check of outstandingUserInfoTransferCount == 0 would make intent explicit.


CI / Workflow

9. Tag trigger removed from SundialKit.yml

The version-tag trigger was removed. Must be restored before merging to main — otherwise tagged releases will not trigger CI.


Nits

  • Phase 2 comments in ConnectivityManager+DelegateHandling.swift will become stale. A TODO with a linked issue is more durable.
  • public let rawValue: Int in SendOptions.swift is now sandwiched between static members. Placing it first follows the usual OptionSet convention.
  • The internal access on the new WCSessionDelegate bridge methods is correct — good call.

Summary

Severity Count
Bug / correctness 3
Design 2
Thread safety 1
Test coverage 2
CI 1
Nits 3

Highest-priority fixes before merging: silent error discard in didFinish, temp-file leak on crash, and @unchecked Sendable in the test helper.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Code Review: Adding TransferUserInfo (#84)

Overview

This PR adds the queued background-transport layer to SundialKit's WatchConnectivity abstraction: transferUserInfo(_:), transferFile(_:metadata:), their delegate callbacks, two new outstanding* count properties on ConnectivitySession, and a new SendOptions.useApplicationContext flag. All concrete types (WatchConnectivitySession, NeverConnectivitySession, mocks) are updated, and tests are added.

The design is largely clean and follows existing project patterns. A few issues worth addressing before merge:


Issues

1. MockSession asymmetry — transferUserInfo calls the delegate immediately, transferFile does not

In MockSession.swift, transferUserInfo fires delegate?.session(self, didReceiveUserInfo:) synchronously (simulating the receiving side), but transferFile only records lastFileTransferred without calling any delegate. This inconsistency will confuse test authors: a test for file-receive has to call session.receiveFile(…) explicitly, but user-info-receive is automatic. Pick one model — either both simulate the round-trip immediately or neither does (and tests call the receive* helpers explicitly for both).

2. SendOptions.useApplicationContext is added but never consumed

The flag is declared and documented, but there is no call site that reads it. The comments acknowledge this is deferred to "Phase 2", but shipping an API option that silently has no effect is a regression-risk: callers who set it believe they are opting into coalescing, but they are not. Either hold the flag until Phase 2 routing is implemented, or add an assertion/log at the routing layer so behaviour is at least observable during development.

3. Transfer errors are silently discarded in two places

  • transferFile write failure on pre-iOS 14 / pre-macOS 11: The #available guard around SundialLogger.connectivity.error(…) means the write failure is completely silent on older deployment targets (iOS < 14.0, watchOS < 7.0, macOS < 11.0 — all within the stated deployment targets).
  • didFinish:error: drops the error unconditionally: A failed file transfer produces no log, no delegate callback, and no visible signal to the observer layer.

At minimum, log the error unconditionally (the project's minimum deployment targets support os_log directly; SundialLogger can be called without the #available guard if it is available on those targets).

4. Stale-reference comments in stub implementations

ConnectivityManager+DelegateHandling.swift and ConnectivityDelegateHandling.swift contain comments like:

// Future enhancement: routed in the Phase 2 stream-routing work

Per the project's own CLAUDE.md: "Don't reference the current task, fix, or callers … since those belong in the PR description and rot as the codebase evolves." These should either be removed or replaced with a neutral TODO that doesn't reference the internal phase naming.

5. CapturingConnectivityDelegate uses @unchecked Sendable over mutable state

The class has three mutable properties with no synchronization (no lock, no actor). While this is fine in today's sequential test contexts, @unchecked Sendable is a promise to the compiler that will cause data races if tests ever run concurrently. Making the class a final class with a simple NSLock (or making it an actor) would eliminate the risk without much overhead.


Minor / Nits

  • CI workflow change removes tag triggers — removing tags: 'v[0-9]*…' means CI will no longer run on version tags. Confirm this is intentional and not a copy-paste omission; it may affect release workflows.
  • Temporary file has no extensionUUID().uuidString produces a bare filename. Some platforms or tools surface the extension in logs/Instruments. Adding a .bin suffix costs nothing and aids debugging.
  • Missing test for transferFile write-failure path — the error path in WatchConnectivitySession.transferFile (where fileData.write throws) has no test coverage. A unit test with a read-only directory URL or a MockFileManager would close this gap.
  • CapturingConnectivityDelegate imports use @testable on SundialKitConnectivity and SundialKitCore, but the class only uses public API — @testable can be dropped.

Positives

  • The decision to read the file into Data synchronously inside session(_:didReceive:) before calling the delegate is correct and the rationale is clearly documented — good defensive coding.
  • Protocol extension in ConnectivityDelegateHandling providing bridge implementations is consistent with the existing style.
  • NeverConnectivitySession stubs are correct and the zero-count properties make sense semantically.
  • Test coverage for SendOptions flag orthogonality, outstanding counts, and round-trip delegate forwarding is appreciated.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review: Adding TransferUserInfo (PR #84)

Good foundation for the queued transport layer. The documentation throughout is excellent — the didReceiveFile note about reading Data synchronously before the delegate returns (because WC deletes the underlying file on return) is exactly the kind of non-obvious invariant worth capturing. The useApplicationContext flag design (opt into coalescing rather than opt into queuing) is smart API ergonomics.

A few issues worth addressing before merge:


Bug — MockSession.transferUserInfo conflates sender and receiver

Tests/SundialKitConnectivityTests/MockSession.swift

internal func transferUserInfo(_ userInfo: ConnectivityMessage) {
    lastUserInfoTransferred = userInfo
    delegate?.session(self, didReceiveUserInfo: userInfo)  // ← problem
}

transferUserInfo is called on the sending device; didReceiveUserInfo fires on the receiving device. Having the mock immediately call its own delegate's receive callback means transferUserInfoForwards in QueuedTransportTests is actually testing a fictitious loopback, not a real transport contract. The transferFile mock (correctly) does not do this — it only records the data. transferUserInfo should follow the same pattern; CapturingConnectivityDelegate.receivedUserInfo should be populated via a separate receiveUserInfo(_:) helper (mirroring receiveFile(_:metadata:)) when a test wants to simulate receipt.


Silent failure — transferFile write error is not surfaced to any observer

Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift

public func transferFile(_ fileData: Data, metadata: ConnectivityMessage?) {
    ...
    do {
        try fileData.write(to: tempURL)
    } catch {
        // Logs on ≥iOS 14, then returns silently
        return
    }
    _ = session.transferFile(tempURL, metadata: ...)
}

The caller gets no signal that the transfer was never queued. Since this is a void method today that's acceptable short-term, but the delegate, outstanding count, and eventual Phase 2 result stream will all see a consistent "zero transfers queued" without any indication of the write failure. A // FIXME: comment or a throw design consideration would prevent this from becoming a silent production bug later.


Silent discard — didFinish(fileTransfer:error:) ignores transfer errors

Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift

internal func session(
    _: WCSession,
    didFinish fileTransfer: WCSessionFileTransfer,
    error _: Error?        // ← ignored
) {
    try? FileManager.default.removeItem(at: fileTransfer.file.fileURL)
}

A failed transfer (network interruption, watch unpaired mid-transfer, etc.) silently discards its error. The temp file is still cleaned up, but the sender never learns delivery failed. Even a os.log call here (the import is already present) would give developers something to look for in Console. Phase 2 routing will likely need to surface this through the result stream.


@unchecked Sendable without synchronization in CapturingConnectivityDelegate

Tests/SundialKitConnectivityTests/CapturingConnectivityDelegate.swift

internal final class CapturingConnectivityDelegate: ConnectivitySessionDelegate,
  @unchecked Sendable
{
  internal var receivedUserInfo: ConnectivityMessage?
  ...

The current tests are single-threaded, so this is safe in practice today. But the codebase explicitly aims for zero @unchecked Sendable, and test helpers that relax that rule set a precedent. Consider converting to a final class with a Lock or restructuring the tests to assert synchronously after the call (since MockSession is also synchronous), eliminating the need for the annotation altogether.


ConnectivitySendContext.queued is defined but unreachable today

Sources/SundialKitConnectivity/ConnectivitySendContext.swift

The .queued(transport:) case is well-specified in the docstring, but no code path in this PR returns it yet — ConnectivityManager's handleUserInfoReceived/handleFileReceived are no-ops, and Phase 2 routing isn't here yet. That's fine for a staged approach, but a // Used by Phase 2 stream routing — see #185 comment on the case would make it clear this isn't dead code and help the reviewer of #185 find it quickly.


Minor nits

  • outstandingUserInfoTransferCount / outstandingFileTransferCount properties have no change-notification path (no @Published, no AsyncStream). That's appropriate for now, but noting it as a Phase 2 gap in the protocol comments would prevent callers from expecting reactivity.
  • The internal keyword on WatchConnectivitySession+WCSessionDelegate.swift delegate methods is correct (they satisfy WCSessionDelegate, which is internal), but a brief comment to that effect would preempt "why not public?" questions.

Summary

Core protocol additions ✅ Clean, well-documented
WatchConnectivitySession implementation ✅ Correct (sync file read, temp-file lifecycle)
NeverConnectivitySession stubs
SendOptions.useApplicationContext design
MockSession.transferUserInfo loopback ❌ Incorrect simulation
Write/transfer error surfaces ⚠️ Silent failures
CapturingConnectivityDelegate @unchecked Sendable ⚠️ Minor inconsistency
ConnectivitySendContext.queued discoverability ℹ️ Suggest comment

The loopback bug in MockSession is the one I'd call a blocking issue; the rest are improvements that can land in follow-ups or alongside this PR.

leogdion and others added 2 commits June 8, 2026 18:58
Constructing a WatchConnectivitySession no longer sets WCSession.default's
delegate as a side effect. A throwaway instance (e.g. created and discarded by a
SwiftUI @State autoclosure re-evaluation) could otherwise hijack the delegate
from the active instance and silently drop all incoming messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
didReceiveMessage now calls replyHandler([:]) after forwarding, mirroring the
binary didReceiveMessageData path. Without it, every reply-expecting sendMessage
times out with WCErrorDomain 7012.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Code Review

This PR contains two independent bug fixes — both address real problems, but one has a latent correctness issue worth addressing before merge.


Fix 1: Delegate registration deferred to activate()

WatchConnectivitySession.swift / WatchConnectivitySession+ConnectivitySession.swift

The motivation is sound and the fix is correct. Moving session.delegate = self from init(session:) to activate() prevents throwaway instances (SwiftUI @State autoclosure re-evaluations are a realistic culprit) from silently stealing WCSession.default's delegate and dropping all incoming messages.

A couple of follow-up points:

  • Test coverage: There are no tests exercising WatchConnectivitySession directly (tests use MockSession). The new invariant — "only the activated instance owns the WCSession delegate" — has no automated verification. Even a simple unit test that creates two WatchConnectivitySession instances, activates only the second, and asserts the delegate belongs to the second would protect against regression.

  • activate() called twice: If activate() is called a second time before deactivation, session.delegate = self is re-set (no-op since it's the same object) and session.activate() is called again. WCSession tolerates this gracefully, but it's worth a brief mention in the doc comment to avoid surprising callers.


Fix 2: Auto-ack reply handler in didReceiveMessage ⚠️

WatchConnectivitySession+WCSessionDelegate.swift

The problem being solved is real: without calling replyHandler, the sender's sendMessage(_:replyHandler:errorHandler:) will time out with WCErrorDomain 7012. The binary path already did this correctly, so mirroring it here is logical.

However, there is a double-invocation risk:

let handler = unsafeBitCast(replyHandler, to: ConnectivityHandler.self)
delegate?.session(self, didReceiveMessage: sendableMessage, replyHandler: handler)
// ↑ passes `handler` (== `replyHandler` with different type) to the delegate
replyHandler([:])
// ↑ then always calls `replyHandler` unconditionally

handler and replyHandler are the same underlying closure. If any ConnectivitySessionDelegate implementation calls the handler it receives (which ConnectivityReceiveContext.replyWith in the public API explicitly supports), the WCSession reply handler will be invoked twice. WCSession does not tolerate that — the framework will crash or produce undefined behavior on the sender side.

Today this doesn't bite because ConnectivityDelegateHandling's bridge implementation ignores the reply handler:

// ConnectivityDelegateHandling.swift:129
public func session(_: any ConnectivitySession, didReceiveMessage message: ConnectivityMessage, replyHandler: @escaping ConnectivityHandler) {
    handleMessageReceived(message)  // replyHandler is silently dropped
}

But that's a silent contract that will break any custom ConnectivitySessionDelegate that attempts to use ConnectivityReceiveContext.replyWith to send a real reply.

Suggested fix — wrap in a once-guard:

var replied = false
let safeReply: ([String: Any]) -> Void = { response in
    guard !replied else { return }
    replied = true
    replyHandler(response)
}
let sendableHandler = unsafeBitCast(safeReply, to: ConnectivityHandler.self)
delegate?.session(self, didReceiveMessage: sendableMessage, replyHandler: sendableHandler)
if !replied {
    replyHandler([:])
}

Alternatively, document clearly (in ConnectivitySessionDelegate) that replyHandler in didReceiveMessage must not be called by delegate implementations, since the framework handles auto-acknowledgment. That would at least make the contract explicit even if the guard isn't added.


PR Metadata

  • PR title vs. content mismatch: The title is "Adding TransferUser Info" but both commits are bug fixes with no transferUserInfo implementation. Since this is a draft, I assume WCSession.transferUserInfo() work is still pending — just flagging it in case the title needs updating or the feature is meant to land in this PR.

  • Empty PR description: Even for a draft, a brief description of the two bugs being fixed (delegate hijack + reply timeout) would help reviewers and future git bisect context.


Summary

Delegate deferred to activate() ✅ Correct, well-commented — add a regression test
Auto-ack replyHandler([:]) ⚠️ Correct for current callers, but needs a once-guard or explicit documented contract to be safe for custom delegates
Test coverage Missing for both new behaviors
PR description Empty — please add context
transferUserInfo Not present — is it coming in a follow-up commit?

@leogdion leogdion marked this pull request as ready for review June 9, 2026 21:00
Guard the WCSession reply handler in both didReceiveMessage and
didReceiveMessageData so a delegate that sends a real reply (via
ConnectivityReceiveContext.replyWith) cannot double-invoke the handler
alongside the unconditional auto-acknowledgment. The binary path was
equally exposed since its delegate bridge forwards the handler to
consumers. Also document that activate() is safe to call twice and that
only the activated instance owns WCSession.default's delegate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

PR Review: Adding TransferUser Info / Three WatchConnectivity Fixes

This PR bundles three related bug fixes for WatchConnectivitySession. Each addresses a real problem; comments below are mostly about correctness nuances and hardening.


1. Delegate registration moved to activate()

WatchConnectivitySession.swift / WatchConnectivitySession+ConnectivitySession.swift

The fix is correct and the motivation is sound: a throwaway instance (SwiftUI @State autoclosure re-evaluation, unit test setup, etc.) can no longer hijack WCSession.default's delegate from the live instance. Moving session.delegate = self to activate() is the right call.

Minor nit: the doc comment in activate()'s doc block and the inline comment immediately below repeat the same rationale. The inline comment can be removed since the doc block already covers it.

// This comment is redundant — the doc block above says the same thing.
// Register as the WCSession delegate here (not in `init`) so only the ...
session.delegate = self

2. Auto-ack for dictionary messages ✅

didReceiveMessage(_:replyHandler:)

Correct mirror of the existing binary didReceiveMessageData path. Without it, every reply-expecting sendMessage times out with WCErrorDomain 7012. No issues here.


3. Once-guard on reply handlers — thread safety concern ⚠️

WatchConnectivitySession+WCSessionDelegate.swift, both delegate methods

The intent is correct — prevent double-invocation when the delegate replies and the auto-ack also fires. However, replied is a plain var Bool with no synchronisation:

var replied = false
let safeReply: ([String: Any]) -> Void = { response in
    guard !replied else { return }
    replied = true
    replyHandler(response)
}
delegate?.session(self, didReceiveMessage: sendableMessage, replyHandler: handler)
if !replied {
    replyHandler([:])
}

If a consumer stores the handler and calls it asynchronously (a valid WatchConnectivity pattern — e.g., look up data then reply), there is a data race:

  • Thread A (WC callback): if !replied → true → calls replyHandler
  • Thread B (consumer async reply): guard !replied → not yet set → also calls replyHandler

Since WatchConnectivitySession is @unchecked Sendable and WCSession dispatches its delegate callbacks on an internal serial queue, this is likely safe today (because the delegate chain is synchronous), but it is fragile. The ConnectivityDelegateHandling bridge currently drops the reply handler in session(_:didReceiveMessage:replyHandler:) entirely, so this race cannot be triggered in production code right now — but custom delegates could expose it.

Suggestion: Replace var replied = false with an os_unfair_lock or a simple NSLock-guarded check, or document that the reply handler must only be called synchronously during the delegate callback.


4. unsafeBitCast on the once-guarded closure (pre-existing, worth noting)

The cast of safeReply: ([String: Any]) -> VoidConnectivityHandler (i.e., @Sendable ([String: any Sendable]) -> Void) was present before this PR and is a pre-existing concern. It works because the ABI layout is identical, but it silently removes the @Sendable annotation for the duration of the call. Not a regression, but worth tracking via the existing #warning on the lock.


5. Test coverage gaps

No new tests cover:

  • The delegate-not-set-in-init behaviour (verifying that a second instance doesn't steal the delegate until activated).
  • The once-guard: that calling the reply handler inside a delegate prevents the auto-ack from firing, and vice-versa.
  • The WCErrorDomain 7012 regression (auto-ack for dictionary messages).

These scenarios are not easily testable against WCSession.default, but a mock-session-based test for the once-guard behaviour (using the existing MockSession infrastructure) is feasible and would lock in the correctness guarantee.


Summary

Change Assessment
Delegate in activate() ✅ Correct fix, well-motivated
Auto-ack dict messages ✅ Correct mirror of binary path
Once-guard reply handlers ✅ Correct intent, ⚠️ replied flag is not thread-safe for async reply patterns
Tests ⚠️ Missing coverage for all three changes

The delegate-registration change is the highest-value fix here. The once-guard is logically sound for the synchronous-delegate case that exists today; hardening the replied flag against async callers and adding tests would make this production-ready.

@leogdion leogdion changed the title Adding TransferUser Info fix(connectivity): harden WCSession delegate registration and reply handling Jun 10, 2026
@leogdion leogdion merged commit 77d8d2c into v2.0.0-alpha.3 Jun 10, 2026
49 of 56 checks passed
@leogdion leogdion deleted the atleast-beta.6 branch June 10, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant