Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4bc1425
test(zero-swift): run the e2e tests with URLSessionTransport on Apple…
ampagent Oct 27, 2026
956d284
test(zero-swift): drive AppLifecycle with posted notifications agains…
ampagent Oct 27, 2026
4048d76
ci: TEMPORARY r2 Apple probe workflow (do not merge)
ampagent Oct 27, 2026
4cc196e
test(zero-swift): probe a client against a server gone mid-frame, and…
ampagent Oct 27, 2026
a06c0f7
fix(zero-swift): URLSessionTransport doesn't follow redirects
ampagent Oct 27, 2026
32f77be
fix(zero-swift): URLSessionTransport drops a frame cut short by the c…
ampagent Oct 27, 2026
0b3deb7
ci: TEMPORARY r2 probe runs the probes against main's Apple layer first
ampagent Oct 27, 2026
d9bdbca
fix(zero-swift): tell a cut frame by the receive after it
ampagent Oct 27, 2026
e75c103
test(zero-swift): measure URLSession's gap before a cut connection fa…
ampagent Oct 27, 2026
7a2574f
ci: TEMPORARY r2 probe drops the before-the-fixes run
ampagent Oct 27, 2026
cc06fa7
docs(zero-swift): changeset and design note for URLSessionTransport r…
ampagent Oct 27, 2026
b7d511f
test(zero-swift): measure bare URLSession latency and the gap after a…
ampagent Oct 27, 2026
96b2b94
fix(zero-swift): wait for a cut frame on a precise 5 ms timer
ampagent Oct 27, 2026
883358a
test(zero-swift): measure the gap after a partial frame and timer pre…
ampagent Oct 27, 2026
e4eaabf
docs(zero-swift): changeset and design note for URLSessionTransport d…
ampagent Oct 27, 2026
395394f
test(zero-swift): drop the print-only URLSession measurements
ampagent Oct 27, 2026
b0f86a6
ci(zero-swift): run the end-to-end tests with URLSessionTransport and…
ampagent Oct 27, 2026
2260fe2
ci: TEMPORARY remove the r2 probe workflow, superseded by the e2e job
ampagent Oct 27, 2026
78c9b1b
test(zero-swift): wait for the server to record the client's close frame
ampagent Oct 27, 2026
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: 5 additions & 0 deletions .changeset/urlsession-cut-frame.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

A server that goes away partway through a WebSocket frame no longer stops the client in the `error` state until the app calls `connection.connect()`. `URLSessionTransport` delivered the part of the frame it had as a whole message, which the client rejected as invalid; it now drops it and reports an abrupt close, as a browser does, and the client reconnects. A message with no other right behind it can arrive up to 5 ms later than before.
5 changes: 5 additions & 0 deletions .changeset/urlsession-no-redirects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

`URLSessionTransport` no longer follows an HTTP redirect of the WebSocket upgrade, which sent the auth token in `Sec-WebSocket-Protocol` to wherever the redirect pointed. The connect fails instead and the client retries, as a browser `WebSocket` does.
70 changes: 70 additions & 0 deletions .github/workflows/swift-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,76 @@ jobs:
python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith("iPhone")))')"
xcodebuild test -scheme Zero-Package -destination "id=$device" -skipPackagePluginValidation

# The shipped transport, URLSessionTransport, against a real zero-cache: the end-to-end and chaos tests, the transport probes, and the app lifecycle tests on macOS and the iOS Simulator.
e2e:
name: Swift end-to-end (macOS)
runs-on: macos-26
timeout-minutes: 45
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 11.11.0
# zero-cache runs from source, which needs Node 24 (`using`).
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x
package-manager-cache: false
- run: pnpm install --frozen-lockfile
- name: Start Postgres
run: |
brew install postgresql@17
bin="$(brew --prefix postgresql@17)/bin"
echo "$bin" >> "$GITHUB_PATH"
"$bin/initdb" -D "$RUNNER_TEMP/pg" -U postgres --auth=trust > /dev/null
"$bin/pg_ctl" -D "$RUNNER_TEMP/pg" -l "$RUNNER_TEMP/pg.log" -o "-c wal_level=logical -c max_connections=200" start
until "$bin/pg_isready" -q -h 127.0.0.1; do sleep 1; done
- name: Build
working-directory: swift
run: swift build --build-tests
- name: Start zero-cache
env:
ZERO_E2E_PG: postgresql://postgres:postgres@127.0.0.1:5432/postgres
run: |
node packages/zero-conformance/src/e2e/server.ts > "$RUNNER_TEMP/server.log" 2>&1 &
for _ in $(seq 1 180); do
grep -q '^ready ' "$RUNNER_TEMP/server.log" && exit 0
sleep 1
done
cat "$RUNNER_TEMP/server.log"
exit 1
- name: Test
working-directory: swift
env:
ZERO_E2E_URL: ws://127.0.0.1:4848
run: swift test --skip-build --filter E2ETests
# The lifecycle tests post the system's notifications, which reach every client in the process, so they run alone.
- name: Test the app lifecycle (macOS)
working-directory: swift
env:
ZERO_LIFECYCLE_TESTS: '1'
run: swift test --skip-build --filter AppLifecycleTests
- name: Test the app lifecycle (iOS Simulator)
working-directory: swift
env:
TEST_RUNNER_ZERO_LIFECYCLE_TESTS: '1'
run: |
device="$(xcrun simctl list devices available -j |
python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith("iPhone")))')"
xcodebuild test -scheme Zero-Package -destination "id=$device" -skipPackagePluginValidation \
-only-testing:AppleTests/AppLifecycleTests
- name: Server logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: swift-e2e-macos-logs
path: |
${{ runner.temp }}/server.log
${{ runner.temp }}/pg.log
if-no-files-found: ignore

zbugs:
name: zbugs app (iOS and macOS)
runs-on: macos-26
Expand Down
6 changes: 3 additions & 3 deletions swift/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ Rationale:
### Transport

- The core defines a transport protocol and never touches a socket directly.
- `URLSessionTransport` implements it with `URLSessionWebSocketTask` and is the default socket on Apple platforms. It accepts frames up to 64 MB (a poke can exceed URLSession's 1 MB default) and reports the server's close code and reason, which the client's error handling reads.
- `URLSessionTransport` implements it with `URLSessionWebSocketTask` and is the default socket on Apple platforms. It accepts frames up to 64 MB (a poke can exceed URLSession's 1 MB default) and reports the server's close code and reason, which the client's error handling reads. Like a browser `WebSocket`, it doesn't follow redirects of the upgrade, so the auth token never goes to another server. When the connection ends partway through a frame, URLSession returns the part it has as a whole message; the transport holds each message until the receive after it settles, up to 5 ms, and drops it when the connection ended without a close frame by then, as a browser drops a partial frame.
- One writer task per connection sends frames in order.
- Tests use an in-memory transport that replays scenario vectors, and, on Linux end-to-end runs, `NIOTransport` in `Tests/E2ETests` (plain `ws://`). Linux needs it because corelibs Foundation's `URLSessionWebSocketTask` compiles but never completes a connection.
- Tests use an in-memory transport that replays scenario vectors, and, on Linux end-to-end runs, `NIOTransport` in `Tests/E2ETests` (plain `ws://`). Linux needs it because corelibs Foundation's `URLSessionWebSocketTask` compiles but never completes a connection. On macOS the end-to-end tests use `URLSessionTransport` (`ZERO_E2E_TRANSPORT=nio` switches back).
- Wire details follow `zero-protocol`: connect to `/sync/v53/connect`, put `initConnection` and the auth token in `Sec-WebSocket-Protocol` while they fit under `maxHeaderLength`, and accept binary poke chunks (protocol 52 and later).

### App lifecycle and recovery
Expand Down Expand Up @@ -294,7 +294,7 @@ A mutator that calls `Date.now()` or generates IDs internally can't be compared.
### CI

- **Linux, every PR.** Swift build, unit tests, and conformance vectors; the vector and drift-vector staleness checks; `apps/zbugs-swift`'s drift test; an end-to-end run against a real `zero-cache` and Postgres; `apps/zbugs-swift` against zbugs' backend (`server/server.ts`); and the `Zero` and `ZeroTesting` docs, where a broken symbol link or an undocumented parameter fails.
- **macOS, path-filtered to `swift/`, `packages/zero-conformance/`, and `apps/zbugs-swift/`.** The Apple layer (URLSession transport against a local mock WebSocket server, SwiftUI, UIKit, and AppKit helpers), iOS simulator tests, all three DocC catalogs, and the zbugs app built for iOS and macOS.
- **macOS, path-filtered to `swift/`, `packages/zero-conformance/`, and `apps/zbugs-swift/`.** The Apple layer (URLSession transport against a local mock WebSocket server, SwiftUI, UIKit, and AppKit helpers), iOS simulator tests, all three DocC catalogs, and the zbugs app built for iOS and macOS. A second job runs the end-to-end and chaos tests with `URLSessionTransport` against a real `zero-cache` and Postgres, the transport probes that check it against a browser `WebSocket` (`Tests/E2ETests/TransportProbeTests.swift`), and the app lifecycle tests on macOS and the iOS simulator.
- **zbugs screenshots, path-filtered to the app and `ZeroUI`.** The app on an iPhone and an iPad simulator and on macOS against zbugs' backend, through each screen and both the SwiftUI and the UIKit or AppKit list, with the screenshots kept as an artifact.
- **Benchmarks.** Ports of the key JS benchmarks (`zero-client/src/client/zero.bench.ts`, the shared B-tree and hash benchmarks) plus Swift benchmarks for query hydration over N rows, poke apply, mutation round trip, and memory per 100k rows. Tracked next to the JS numbers, non-blocking in v1, with a goal of no slower than JS. CI builds them; [BENCHMARKS.md](BENCHMARKS.md) has how to run them and the latest numbers.

Expand Down
77 changes: 73 additions & 4 deletions swift/Sources/Zero/Apple/URLSessionTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@
}
}

/// Resumes a continuation with the first of several values.
private final class FirstResume<Value: Sendable>: Sendable {
private let continuation: Mutex<CheckedContinuation<Value, Never>?>

init(_ continuation: CheckedContinuation<Value, Never>) {
self.continuation = Mutex(continuation)
}

func resume(_ value: Value) {
let c = continuation.withLock { c in
defer { c = nil }
return c
}
c?.resume(returning: value)
}
}

/// One `URLSessionWebSocketTask`. Its delegate reports the open and the
/// close.
final class URLSessionSocket: NSObject, Socket, URLSessionWebSocketDelegate {
Expand Down Expand Up @@ -106,23 +123,75 @@
resumeOpen(.failure(close))
}

/// A browser `WebSocket` doesn't follow redirects (the WHATWG WebSockets standard fetches the handshake with redirect mode "error"), and following one would send the auth token in `Sec-WebSocket-Protocol` wherever it points. The upgrade fails instead.
func urlSession(
_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest
) async -> URLRequest? {
nil
}

func send(_ message: SocketMessage) async throws {
switch message {
case .text(let text): try await task.send(.string(text))
case .binary(let bytes): try await task.send(.data(Data(bytes)))
}
}

/// How long a message waits to learn whether the connection ended right after it (see `receive`).
static let cutFrameWindow: Duration = .milliseconds(5)

/// The receive after the last message, asked for before that message was returned.
private let next = Mutex<Task<URLSessionWebSocketTask.Message, any Error>?>(nil)

func receive() async throws -> SocketMessage? {
let pending = next.withLock { n in
defer { n = nil }
return n
}
let message: URLSessionWebSocketTask.Message
do {
switch try await task.receive() {
case .string(let text): return .text(text)
case .data(let data): return .binary([UInt8](data))
@unknown default: return nil
if let pending {
message = try await pending.value
} else {
message = try await task.receive()
}
} catch {
throw closeReason(error)
}
// When the connection ends partway through a frame, URLSession returns the part it has as a whole message, and only the receive after it tells that the connection ended; a browser drops the part and reports an abrupt close. So the next message is asked for now, and this one waits for it up to `cutFrameWindow`: when the connection ends without a close frame by then, this one is dropped.
let following = Task { [task] in try await task.receive() }
next.withLock { $0 = following }
if case .failure(let error)? = await Self.outcome(of: following, within: Self.cutFrameWindow)
{
let close = closeReason(error)
if !close.wasClean { throw close }
}
switch message {
case .string(let text): return .text(text)
case .data(let data): return .binary([UInt8](data))
@unknown default: return nil
}
}

/// The outcome of `task` if it settles within `window`. A dispatch timer without leeway: `Task.sleep` can take many times as long, and every message waits on it when no other follows.
private static func outcome(
of task: Task<URLSessionWebSocketTask.Message, any Error>, within window: Duration
) async -> Result<URLSessionWebSocketTask.Message, any Error>? {
await withCheckedContinuation { c in
let first = FirstResume(c)
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated))
timer.schedule(
deadline: .now() + .nanoseconds(Int(window.components.attoseconds / 1_000_000_000)),
leeway: .nanoseconds(0))
timer.setEventHandler { first.resume(nil) }
timer.resume()
Task {
let result = await task.result
timer.cancel()
first.resume(result)
}
}
}

/// How the socket closed, once `receive` fails: the close frame when one
Expand Down
Loading
Loading