Skip to content

Fixed a crash when URLSession reports a WebSocket pong more than once - #75

Merged
i2h3 merged 1 commit into
mainfrom
fix/websocket-ping-resume-once
Sep 30, 2026
Merged

i2h3 merged 1 commit into
mainfrom
fix/websocket-ping-resume-once

Conversation

@i2h3

@i2h3 i2h3 commented Sep 30, 2026

Copy link
Copy Markdown
Owner

URLSessionWebSocketTask.sendPing(pongReceiveHandler:) can call its handler more than once, even concurrently, when a connection is torn down between ping and pong. Apple acknowledges this as its own bug (r. 129686846, forum thread). URLSessionWebSocketChannel.sendPing() resumed its CheckedContinuation on every call, and the second resume terminates the process. Cirruscope 1.0.0 and 1.1.0 ship Rainmaker 3.3.0, and their Xcode Organizer shows 21 crash reports of this from App Store and TestFlight installs, most 1–9 s after the Mac woke from sleep. 4.1.0 has the same code.

Changes

  • One outcome per ping. sendPing() now waits through the new PendingPing, a lock-protected PendingPingState that resumes the waiting task exactly once. Repeated handler calls are dropped and logged at .notice. The waiting task's cancellation competes for the same outcome, because the handler is also known never to be called (FB12179546).
  • Teardown closes the socket. Neither URLSession's receive() nor its pong handler observes task cancellation, so a stopped Server.events(_:) stream used to leave its socket open until the network dropped it. PushNotificationsConnection now closes the channel as soon as a session ends, and whenever its task is cancelled, including during the handshake.
  • Pong timeout. A ping without a pong within 10 s closes the connection so the coordinator reconnects. Before, the ping loop hung silently. pingInterval and pongTimeout are configurable on the coordinator for tests.
  • The WebSocketChannel documentation now states what the transport relies on: sendPing() may end with CancellationError, and cancel() ends outstanding calls and may be called more than once.

Tests

  • PendingPingTests stands in for the framework: it stores the handler a ping hands out and calls it later, from other queues, twice, concurrently (256×), racing cancellation (300 iterations), or never.
  • New ServerEventsTests cases cover the socket closing on stop (after and during authentication), reconnecting when a pong never arrives (also with a ping that ignores cancellation), and staying connected while pongs arrive. The mock channel can now ignore cancellation the way URLSession does.
  • Verified locally:
    • swift test: 240/240 pass.
    • iOS Simulator: 240/240 pass. watchOS, tvOS and visionOS test builds compile.
    • Thread Sanitizer is clean on the ping and events suites.
    • Removing the guard, the teardown or the timeout each makes its tests fail after a deadline rather than hang. Removing the guard reproduces the production trap message.

Semantic versioning

This is a patch release (4.1.1): no public API changes. The only behaviour change is that a missing pong now leads to a reconnect, which is what the ping loop's documentation already said it did.

🤖 Generated with Claude Code

URLSessionWebSocketTask.sendPing(pongReceiveHandler:) calls its handler more than once, even concurrently, when a connection is torn down between ping and pong (Apple r. 129686846), and sendPing() resumed its CheckedContinuation on every call, which terminated Cirruscope 1.0.0 and 1.1.0 in 21 reported crashes. PendingPing now delivers exactly one outcome and lets the waiting task's cancellation compete for it, since the handler is also known never to be called.

PushNotificationsConnection closes its channel as soon as a session ends or its task is cancelled, handshake included, because neither URLSession's receive() nor its pong handler observes cancellation and a stopped stream otherwise kept its socket open. A ping without a pong within ten seconds now closes the connection so the coordinator reconnects.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@i2h3
i2h3 merged commit 0c538c6 into main Sep 30, 2026
7 checks passed
@i2h3
i2h3 deleted the fix/websocket-ping-resume-once branch September 30, 2026 15:57
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