Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ The human readable introduction of this Swift Package is in [README.md](README.m
- `Sources/Rainmaker/Extensions/` is for implementations of extensions of first-party or platform types. One source code file per extended type and added feature.
- `Sources/Rainmaker/Models` contains Swift source code for data models which are also publicly available types. They do not necessarily mirror the structure and types as returned by the server in responses. They are meant to be as elegant and plausible as possible from a Swift client developer perspective, not necessarily mirroring the server responses exactly.
- `Sources/Rainmaker/Responses/Models` contains Swift source code for data models which actually enable the use of Swift's `Decodable` for server response data. They are not meant to be exposed outside the Swift package module but only as an intermediate representation to simplify deserialization.
- `Sources/Rainmaker/Push/` contains the `notify_push` WebSocket transport and the coordinator behind `Server.events(_:)`, which prefers the WebSocket when the server advertises the capability and falls back to polling otherwise. The mockable WebSocket abstraction protocols (`WebSocketConnecting`, `WebSocketChannel`, `WebSocketFrame`) live alongside `Requesting` in `Sources/Rainmaker/Requests/`, mirroring how the HTTP session is abstracted.
- `Sources/Rainmaker/Push/` contains the `notify_push` WebSocket transport and the coordinator behind `Server.events(_:)`, which prefers the WebSocket when the server advertises the capability and falls back to polling otherwise. The mockable WebSocket abstraction protocols (`WebSocketConnecting`, `WebSocketChannel`, `WebSocketFrame`) live alongside `Requesting` in `Sources/Rainmaker/Requests/`, mirroring how the HTTP session is abstracted. `PendingPing` (with its `PendingPingState`) is what `URLSessionWebSocketChannel.sendPing()` waits through: it resumes the waiting task exactly once, because `URLSessionWebSocketTask.sendPing(pongReceiveHandler:)` has been seen calling its handler more than once for one ping, concurrently, when a connection is torn down, and also never calling it at all, which is why the waiting task's cancellation competes for the same outcome. Neither URLSession's `receive()` nor its pong handler observes task cancellation, so `PushNotificationsConnection` ends a session by closing its channel rather than by cancelling its tasks, and bounds every ping with a pong timeout.
- `Sources/RainmakerCLI/` contains the Swift source code for the accompanying command line utility which enables the usage of the library in a terminal environment without any additional upstream project.
- `Sources/RainmakerCLI/Commands/RecordFixtures.swift` and `Sources/RainmakerCLI/Fixtures/` implement the `record-fixtures` subcommand which automates the creation of test fixtures. It is macOS-only: it deploys ephemeral Nextcloud containers via the `NextcloudContainerManager` package, runs the test suite against them in recording mode to capture real responses into `Tests/RainmakerTests/Responses/`, and verifies the captures replay without a server. Containers are deployed with the apps a suite's fixtures depend on but which are not part of a Nextcloud installation, currently the notes app and the Talk app, which makes a recording run depend on the app store being reachable. All of its Docker-facing code is guarded with `#if os(macOS)` so the simulator builds compile the CLI without it.
- `Sources/RainmakerTestServerTags/` is a small internal module holding `ServerVersion`, the single source of truth for the supported Nextcloud versions. Both the test target and the CLI's fixture recorder depend on it, so the version list is declared once.
- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package.
- `Tests/` contains the automated tests per target.
- `Tests/RainmakerTests/Responses/` contains static test fixtures which are the HTTP response bodies of actual server responses. They either are in JSON or XML format.
- `Tests/RainmakerTests/URLTestSession.swift` replays those fixtures during normal test runs, while `Tests/RainmakerTests/URLRecordingSession.swift` is its recording counterpart used by the `record-fixtures` subcommand. Both derive fixture paths through the shared `Tests/RainmakerTests/FixtureLocator.swift` so recording and replay can never diverge, and `Tests/RainmakerTests/FixtureCanonicalizer.swift` normalizes recorded responses (canonical host, redacted volatile fields) so fixtures stay stable. `Tests/RainmakerTests/ServerTesting.swift` selects between the two sessions based on the `RAINMAKER_FIXTURE_RECORD` environment variable.
- The `Server.events(_:)` tests do not use fixtures: they drive hand-authored WebSocket and request doubles (`Tests/RainmakerTests/MockWebSocketConnecting.swift`, `MockWebSocketChannel.swift`, `MockRequesting.swift`) so the WebSocket handshake, frame mapping, polling fallback, and reconnection are exercised deterministically without a server.
- The `Server.events(_:)` tests do not use fixtures: they drive hand-authored WebSocket and request doubles (`Tests/RainmakerTests/MockWebSocketConnecting.swift`, `MockWebSocketChannel.swift`, `MockRequesting.swift`) so the WebSocket handshake, frame mapping, polling fallback, reconnection, and teardown are exercised deterministically without a server. `MockPingBehavior` scripts whether a mock channel answers pings, and `MockWebSocketClosure` records that a channel was closed and lets its receiving ignore task cancellation until then, as `URLSessionWebSocketTask` does, so the teardown is tested against the behaviour it actually has to cope with.
- `Tests/RainmakerTests/PendingPingTests.swift` drives `PendingPing` directly, a `URLSessionWebSocketTask` being impossible to substitute: it stores the pong handler a ping hands out and calls it the way the framework has been seen to, later, repeatedly, concurrently, or never. `Eventually.swift` is the polling wait those tests and the teardown tests use instead of `WithTimeout.swift`, which cannot bound an operation that ignores cancellation, and `LockedValue.swift` is the lock-protected box they share state with `@Sendable` closures through.
- `Rainmaker.png` is a static artwork file for presentation on the web and can be ignored.

## Code Style
Expand Down
179 changes: 179 additions & 0 deletions Sources/Rainmaker/Push/PendingPing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation
import os

///
/// One ping sent by ``URLSessionWebSocketChannel/sendPing()`` which resumes the task waiting for it exactly once.
///
/// `URLSessionWebSocketTask.sendPing(pongReceiveHandler:)` calls its handler more than once for a single ping, even concurrently from different queues, when the connection is torn down between ping and pong, which Apple acknowledged as a bug of its own.
/// Resuming a `CheckedContinuation` a second time terminates the process, so the handler's outcomes are funnelled through the ``PendingPingState`` held here and only the first one reaches the waiting task.
/// The handler is also known never to be called at all, which is why the waiting task's cancellation competes for the same single outcome: that is what lets ``PushNotificationsConnection`` end a session whose pong never arrives.
///
/// It is `@unchecked Sendable` because ``state`` is only read and written while ``lock`` is held; `Mutex` and `OSAllocatedUnfairLock` would express that to the compiler but are not available at every deployment target of this package.
///
final class PendingPing: @unchecked Sendable {
///
/// Serializes every access to ``state``, which the pong handler, the waiting task and its cancellation handler reach from different threads.
///
private let lock = NSLock()

///
/// Where the ping stands, only ever accessed while ``lock`` is held.
///
private var state = PendingPingState.idle

///
/// The logger recording the outcomes which arrive after the one that resumed the waiting task.
///
private let logger: Logger

///
/// Prepare a ping which is not sent before ``wait(function:sending:)`` is called.
///
/// - Parameters:
/// - logger: The logger to record dropped outcomes through, which should be the one of the channel sending the ping.
///
init(logger: Logger) {
self.logger = logger
}

///
/// Send the ping and suspend until its first outcome arrives or the calling task is cancelled, whichever happens first.
///
/// - Parameters:
/// - function: The name a continuation misuse or leak message reports, which defaults to the caller's so such a message names the method sending the ping rather than this one.
/// - send: Sends the ping and hands the given pong handler to the framework; it is not called at all when the calling task is already cancelled.
///
/// - Throws: The first error the pong handler reports, or `CancellationError` when the calling task is cancelled first.
///
func wait(function: String = #function, sending send: (@escaping @Sendable ((any Error)?) -> Void) -> Void) async throws {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation(function: function) { (continuation: CheckedContinuation<Void, any Error>) in
guard install(continuation) else {
return
}

send { error in
self.resolve(error)
}
}
} onCancel: {
self.cancel()
}
}

///
/// Deliver one outcome of the pong handler, of which only the first resumes the waiting task.
///
/// - Parameters:
/// - error: The error the pong handler reported, or `nil` when the pong arrived.
///
/// - Returns: Whether this outcome resumed the waiting task.
///
@discardableResult
func resolve(_ error: (any Error)?) -> Bool {
let previous = lock.withLock {
let previous = state

if case .waiting = previous {
state = .resolved
}

return previous
}

switch previous {
case let .waiting(continuation):
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}

return true

case .cancelled:
// Expected: closing the channel of a cancelled session makes the framework report the ping it still had outstanding.
logger.debug("Dropped the outcome of a cancelled ping: \(Self.describe(error), privacy: .public)")
return false

case .resolved:
logger.notice("Dropped a repeated pong handler call for a ping which already resumed its task: \(Self.describe(error), privacy: .public)")
return false

case .idle:
// Unreachable: the pong handler is only handed out once the continuation is installed.
logger.error("Dropped a pong handler call for a ping which was never sent: \(Self.describe(error), privacy: .public)")
return false
}
}

///
/// Install the continuation of the waiting task unless the task was cancelled first.
///
/// - Parameters:
/// - continuation: The continuation to resume with the first outcome.
///
/// - Returns: Whether the continuation was installed and the ping should be sent; when `false`, the continuation was already resumed with `CancellationError`.
///
private func install(_ continuation: CheckedContinuation<Void, any Error>) -> Bool {
let installed = lock.withLock {
guard case .idle = state else {
return false
}

state = .waiting(continuation)
return true
}

if installed == false {
logger.debug("Did not send a ping for a task cancelled before it could")
continuation.resume(throwing: CancellationError())
}

return installed
}

///
/// Resume the waiting task with `CancellationError` unless an outcome already did, and drop every outcome arriving afterwards.
///
private func cancel() {
let previous = lock.withLock {
let previous = state

switch previous {
case .idle, .waiting:
state = .cancelled

case .cancelled, .resolved:
break
}

return previous
}

if case let .waiting(continuation) = previous {
logger.debug("Cancelled a ping still waiting for its pong")
continuation.resume(throwing: CancellationError())
}
}

///
/// Describe an outcome of the pong handler without any user data, so it can be logged publicly.
///
/// - Parameters:
/// - error: The error the pong handler reported, or `nil` when the pong arrived.
///
/// - Returns: `"pong received"`, or the error's domain, code and description.
///
private static func describe(_ error: (any Error)?) -> String {
guard let error else {
return "pong received"
}

let nsError = error as NSError
return "\(nsError.domain) \(nsError.code): \(nsError.localizedDescription)"
}
}
29 changes: 29 additions & 0 deletions Sources/Rainmaker/Push/PendingPingState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

///
/// Where one ``PendingPing`` stands between being sent and delivering its single outcome to the task waiting for it.
///
/// Every case but ``idle`` and ``waiting(_:)`` is final for the ping, which is what lets ``PendingPing`` resume the waiting task exactly once no matter how often and in which order the pong handler and the task's cancellation report.
///
enum PendingPingState {
///
/// The waiting task has not installed its continuation yet, so the ping has not been sent either.
///
case idle

///
/// The ping was sent and its continuation is waiting for the first outcome.
///
case waiting(CheckedContinuation<Void, any Error>)

///
/// The waiting task was cancelled before any outcome arrived, so its continuation is resumed with `CancellationError`, either when the cancellation finds it waiting or when it is installed afterwards.
///
case cancelled

///
/// The pong handler delivered its first outcome to the waiting task, so any later one is dropped.
///
case resolved
}
Loading
Loading