Skip to content

Perfect-Notifications 6.0.0

Latest

Choose a tag to compare

@taplin taplin released this 26 Oct 09:42
6a9fdd3

Perfect-Notifications 6.0.0 is the first tagged release of the Swift 6 rewrite. It replaces 5.0.0 (2020). The 5.x NotificationPusher and its HTTP/2 client on Perfect-Net and Perfect-Thread are gone: APNSClient sends over Foundation's URLSession, with token-based authentication (an ES256 JWT signed with swift-crypto). It's about 1,400 lines of Swift with one dependency.

.package(url: "https://github.com/PerfectlySoft/Perfect-Notifications.git", from: "6.0.0"),

Requirements

  • Swift 6.2 or later (swift-tools-version: 6.2, Swift 6 language mode).
  • macOS 12 or later, or Linux. CI covers Swift 6.2 and 6.4 on Ubuntu 24.04 and Xcode 26.0.1 and 26.6 on macOS, in debug and release, with warnings as errors. iOS is not supported: a provider is server-side, and its signing key must not ship in an app.
  • On Linux, URLSession comes from FoundationNetworking, which uses the system libcurl. APNs needs HTTP/2, so libcurl must be built with nghttp2. Ubuntu 24.04's libcurl 8.5.0 is (curl-config --features lists HTTP2), and the swift:*-noble images ship it.
  • One dependency: swift-crypto 4.0.0..<5.0.0. No swift-nio, no libcurl wrapper of our own.

Linux: the transport decision

APNs requires HTTP/2, and the pre-release main declared macOS only, so the first question was whether corelibs URLSession really speaks HTTP/2 on Linux. It does:

  • Test setup. A small SwiftNIO HTTP/2 server with TLS and a throwaway CA, which closes any connection that doesn't negotiate ALPN h2. curl --http1.1 against it gets an empty reply, and the server logs REFUSED: not h2, so an HTTP/1.1 fallback would have shown up as a failure.
  • Result. In swift:6.2-noble and swift:6.4-noble, on arm64 and on x86_64, URLSession negotiated h2 every time. The server saw one TLS connection with ALPN h2 and every request as HTTP/2.0. Five sequential requests reused that connection, and 20 concurrent requests were multiplexed over it. timeoutInterval fired at 2.00 s against a stream that never answered. macOS behaves the same (networkProtocolName=h2).
  • End to end. The library itself, built in the same containers, sent alerts, 20 concurrent sends, a background push and a 410 response through Environment.custom to that server. Every request arrived as HTTP/2 on one connection, with a bearer JWT and the right priority and push-type headers.

So the transport stays URLSession, with #if canImport(FoundationNetworking), and Linux is supported.

The adversarial review then found that URLSession's async API isn't safe to cancel on Linux (see Fixes). The client now drives a callback-based data task on its own session, and never cancels a URLSession task through Swift task cancellation. One corelibs limitation remains. Against an endless response body, send throws responseTooLarge at once and peak RSS stays around 100 MB, but corelibs keeps reading the body in the background before it closes the connection (see Known limitations). A SwiftNIO HTTP/2 transport is planned for 6.1.

Highlights

  • Linux support, verified as above.
  • Input validation before any request is built:
    • device tokens must be hex;
    • topics must be bundle-ID syntax;
    • collapse IDs are 1–64 printable ASCII characters;
    • priority and push type must be compatible;
    • expirations are range-checked;
    • payload size is checked (4096 bytes, 5120 for VoIP);
    • key and team IDs must be 10 characters.
  • A Sendable payload model. APNSJSONValue replaces 5.x's Any, with literal conversions so most call sites don't change.
  • Provider-token lifecycle following Apple's rules:
    • a token is reused for 50 minutes;
    • a new one is never minted within 20 minutes of the last;
    • ExpiredProviderToken gets one automatic retry with a fresh token.
  • A whole-request deadline (timeout:, default 30 s, fractions allowed), prompt cancellation, and a 16 KiB response cap.
  • Redirects are never followed, so the provider token can't be sent to another origin, and a redirect is an error, not a result.
  • Per-token results for the multi-token send.
  • Typed APNs reasons (APNSErrorReason). The 410 timestamp is exposed in milliseconds and as a Date, and so is apns-unique-id.
  • Secrets stay out of logs: print, dump and Mirror of the client never show the key or the token.

Breaking and behaviour changes

  • New API. NotificationPusher, NotificationResponse and the named-configuration registry are gone. Upgrade: let client = try APNSClient(keyID:teamID:privateKeyPath:environment:), then try await client.send(APNSNotification(items: [...]), to: token, topic: bundleID). The README has a 5.x mapping table.
  • Async. send is async throws and returns an APNSResponse. An APNs rejection is a response with error set, not a thrown error.
  • .customPayload(String, APNSJSONValue) replaces .customPayload(String, Any). String, number, Bool, nil, array and dictionary literals still compile. Upgrade: wrap variables (.string(id), .int(count)), or convert an untyped value with try APNSJSONValue(jsonObject:). APNSNotificationItem is now really Sendable (it was @unchecked) and Equatable. A custom aps key throws APNSClientError.reservedPayloadKey.
  • priority: is an APNSPriority?: .immediate (10), .background (5) or the new .low (1). It was an Int?. APNSPushType.defaultPriority returns an APNSPriority. Upgrade: priority: 10 → .immediate, priority: 5 → .background. A background push with any priority but 5 throws .invalidPriority.
  • The multi-token send returns [APNSBatchResult], one per token, each with a Result<APNSResponse, APNSClientError>. It used to return [APNSResponse] and threw away every result when one token threw. A bad token or a network error now affects only that token. Problems shared by every token (topic, priority, payload size, expiration, collapse ID) still throw before anything is sent. Cancelling the calling task returns the results so far: the token in flight and every later one get .failure(.cancelled). The one in flight may still have been delivered. APNSBatchResult is Equatable.
  • APNSResponseError.timestamp is an Int64? of milliseconds. It was an Int? documented as epoch seconds; Apple sends milliseconds. invalidatedAt gives the Date.
  • Stricter input. Each of these throws a typed APNSClientError, and nothing is sent:
    • device tokens that aren't 64–200 hex digits of even length (.invalidDeviceToken);
    • topics outside bundle-ID syntax (.invalidTopic);
    • collapse IDs that aren't 1–64 printable ASCII characters, ! to ~ (.invalidCollapseID). Apple only documents the 64-byte limit, so spaces and non-ASCII are refused to be safe;
    • a negative badge or a relevanceScore outside 0–1 (.invalidPayload);
    • JSON nested more than 64 levels (.jsonNestingTooDeep);
    • APNSNotification(raw:) bytes that aren't a JSON object with an aps dictionary, or are over 5120 bytes. init(raw:) now throws;
    • negative or overflowing expirations (.invalidExpiration);
    • payloads over the limit (.payloadTooLarge(size:limit:));
    • key or team IDs that aren't 10 ASCII letters or digits (.invalidKeyID, .invalidTeamID).
  • APNSClientError has new cases. Upgrade: exhaustive switches need updating.
  • Token refresh at 50 minutes, not 55, and never within 20 minutes of the last mint. Ages are measured on a monotonic clock that counts sleep (CLOCK_MONOTONIC on Darwin, CLOCK_BOOTTIME on Linux), so wall-clock steps don't affect them.
  • The client sends through its own URLSession, built from the configuration of the session you pass (protocol classes, proxies, TLS settings and timeouts carry over). Authentication challenges, including server-trust evaluation for pinning, are forwarded to your session's delegate; other delegate callbacks are not.
  • API additions:
    • APNSClient(..., timeout:), APNSClient.defaultTimeout, and environment and timeout properties;
    • Environment.custom(host:port:);
    • APNSJSONValue;
    • APNSBatchResult;
    • APNSErrorReason and APNSResponseError.knownReason;
    • APNSResponseError.invalidatedAt;
    • APNSResponse.apnsUniqueID;
    • APNSPriority.low;
    • APNSClientError.unexpectedRedirect(statusCode:), .cancelled, .invalidPayload(_:) and .jsonNestingTooDeep(limit:);
    • APNSNotification.payload is public;
    • Equatable on APNSResponse, APNSNotification, APNSNotificationItem and APNSExpiration.
  • swift-crypto is a range, 4.0.0..<5.0.0. The pre-release main said from: "3.0.0", which means 3.x only, and that can't resolve next to Perfect-SMTP 6.0.0 ('mainpkg' depends on 'swift-crypto' 3.0.0..<4.0.0 and 'perfect-smtp' depends on 'swift-crypto' 4.0.0..<5.0.0). With this range, a package depending on Perfect-SMTP 6.0.0, Perfect-NIO 0.1.0 and this branch resolves (to 4.5.2) and builds. This package only uses P256, so the Linux Sendable problem Perfect-SMTP hit in 3.x (Curve25519) doesn't apply. 3.0.0 also built and passed before the review fixes (not rerun since); the floor is 4.0.0 to match the ecosystem.
  • The live-sandbox test is gone. The suite is hermetic, and nothing contacts APNs.
  • README.zh_CN.md is removed. It documented the 5.x API; it stays on the legacy branch.

Fixes

Against the pre-release main (the Swift 6 rewrite, unreleased):

  • Path and query injection through the device token. URL(string: "https://\(host)/3/device/\(deviceToken)")! interpolated the token as is, so ../, ? or # changed the request. A token that made an invalid URL crashed the process on the !. Tokens are now validated hex, and the error is typed.
  • Header injection. apns-topic and apns-collapse-id were set unchecked. Topics are now bundle-ID syntax (ASCII letters, digits, - and non-empty dot-separated segments). Collapse IDs are printable ASCII without spaces. Every other header value is generated from an enum or an integer.
  • JSON injection in the JWT. The header and claims were built by string interpolation of keyID and teamID, so a " in either rewrote the token's JSON. Both are validated as 10 alphanumerics, and the JSON is produced by JSONEncoder. A test compares the exact header and claims bytes and verifies the signature with swift-crypto.
  • A false Sendable claim. APNSNotificationItem was @unchecked Sendable around an Any payload (see Breaking changes).
  • No client-side payload limit. An oversized payload went to APNs for a PayloadTooLarge round trip. It is now checked first.
  • Provider-token handling:
    • a rejected token stayed cached for up to 55 minutes;
    • nothing respected the 20-minute update limit;
    • ExpiredProviderToken wasn't retried.
      See Highlights for the new rules. TooManyProviderTokenUpdates leaves the token alone.
  • No timeout. URLSession.shared's request timeout is an idle timeout (60 s between bytes), and its resource timeout is seven days. The client now has a whole-request deadline. It is enforced outside URLSession, so it holds even when the transfer doesn't stop.
  • Unbounded response bodies. Bodies are now capped at 16 KiB, counted as data arrives, and the task is cancelled at the cap. On macOS that stops the transfer: against an endless body, send threw responseTooLarge after 0.02 s with a 28 MB peak RSS. The server had pushed 8 MiB, its flow-control window, before the RST_STREAM. On Linux, see Known limitations.
  • Expiration overflow. .relative(Int.max) trapped on integer overflow. Overflow and negative values now throw .invalidExpiration.
  • Batch results. One failing token discarded every result (see Breaking changes).
  • The 410 timestamp unit (see Breaking changes).
  • APNSClientError.network("\(error)") carried a full error dump. It now carries the URLError code and localized description, and never the request headers.
  • Linux didn't build: URLSession needs FoundationNetworking there.

Found while verifying on Linux, before the adversarial review:

  • The deadline could hang. Against a server that never answered, send sometimes didn't return at the deadline (1 of 15 runs, then run 9 of 20). The adversarial review found the cause (below). The interim fix, which resumed the caller before cancelling, made it rarer but didn't remove it.

Found in the adversarial review:

  • Linux deadlock that starved the concurrency runtime (high). The deadline cancelled a Swift Task awaiting URLSession.data(for:). On Linux, that cancellation calls URLSessionTask.cancel() from the task's cancellation handler, with the task's status lock held, and cancel() waits for the session's work queue. URLSession's idle timeout (set equal to the deadline) fired at the same moment, and the work queue tried to resume the same continuation, which needs that status lock. Both blocked, and every concurrency thread that touched the session followed. The reviewer's repro (a cross-origin 307, then a 1 MiB response header, then follow-up sends; FIRST=c1,c2) hung 3 of 3 runs on swift:6.2-noble arm64.
    • Fix: APNSTransport drives a callback-based data task on the client's own session and keeps its own continuation. The deadline and caller cancellation resume that continuation first, then call URLSessionTask.cancel() on a Dispatch thread, with no lock held. URLRequest.timeoutInterval is (timeout + 5) rounded up, so URLSession's idle timeout never races the deadline. The poison sequence now passes on all four Linux configurations, repeatedly (see Verified).
  • Sub-second timeouts failed at once on Linux (medium). corelibs truncates timeoutInterval to whole seconds, so timeout: 0.5 meant 0 and every request failed immediately. The deadline is now the client's own, and accurate to about 10 ms on Linux (0.2 s → 0.20 s).
  • Redirects leaked the provider token (medium). On Linux, corelibs followed a 307 to another origin with the authorization header, and the redirected response was reported as success. Now the session delegate refuses every redirect (corelibs ignores per-task delegates, hence the client's own session). A 3xx, or a response for another origin, throws .unexpectedRedirect. In the reviewer's probe, the second origin receives no request on either platform.
  • Collapse IDs (medium). Non-ASCII IDs didn't go out as UTF-8: macOS sent them as Latin-1 or truncated them, so different IDs could collapse into one notification. Leading and trailing spaces and bidi/zero-width characters were accepted too. Now only printable ASCII without spaces is.
  • APNSJSONValue(jsonObject:) wrapped UInt64 values above Int.max to negatives (low), -1 for UInt64.max. Native UInt64 and NSNumber "Q" values are now range-checked, and throw if they don't fit.
  • Deep nesting crashed (low): depth 20,000 overflowed the stack in payload encoding and in init(jsonObject:). The depth is capped at 64, measured iteratively.
  • Cancelling a batch lost the results already obtained (low). They're returned now (see Breaking changes).
  • Wall-clock steps re-minted tokens. The step tolerance added during release prep re-minted on every backwards step of more than 60 s, so a misbehaving clock could trigger TooManyProviderTokenUpdates. Token age is now monotonic (see Breaking changes).
  • Nits: badge and relevanceScore are range-checked, and init(raw:) validates its bytes.

Found in the re-review of the new transport:

  • A cancel during a refused redirect crashed the process on Linux (medium–high). The delegate always answered the redirect callback with nil, even after the deadline or the caller had already cancelled the task. corelibs then trapped: "Received callback for HTTP redirection, but we're not waiting for it". The reviewer's race (1,000 sends to a redirecting server, every one cancelled at a random moment) crashed 5 of 5 runs on 6.2 arm64, and on 6.2 and 6.4 amd64.
    • Fix: the redirect is answered only while the request is live, and a cancel that arrives during the answer is held until it's in, so the answer always comes first. The reviewer suggested answering under the request's lock; I answer outside it instead, so a synchronous call back into the delegate can't deadlock.
  • Nits:
    • a request cancelled before it started now cancels its never-resumed task, so the session can finish invalidating;
    • the deadline uses a DispatchSourceTimer with 10 ms leeway, because asyncAfter fired 3–6 % late on macOS.

Verified

On the final branch, fd19c95:

  • The redirect-and-cancel race (MIX=c1 CANCEL_EVERY=1 OPS=1000), on each of the four Linux configurations with --network none:
    • 5 of 5 runs clean, plus one mixed 2,000-operation race;
    • after each race, a send returned 200;
    • the previous commit crashed 3 of 3 on 6.2 arm64.
  • The same race on macOS: 5 of 5 runs, plus the mixed race.
  • Re-run on all four Linux configurations: the poison, timeout, hang and full probes (3 runs each, 1 for hang and full) all pass, and the second origin received no request. Then 58 of 58 tests, debug and release.
  • macOS: Xcode 27.1 and 26.3, debug and release with warnings as errors, plus ASan and TSan, all clean. Deadlines now fire about 10 ms late (0.5 s → 0.51 s; it was 0.52 s).
  • Connection reuse: 150 sequential timeouts on one client used 2 HTTP/2 connections, on 6.2 arm64 and on 6.4 amd64.

The earlier round (code at e6c46fe, docs at 8cd7827):

  • Builds and tests (58 tests, 95 cases). Debug and release with warnings as errors (-enable-testing in release), on:
    • Xcode 27.1 (Swift 6.4) and Xcode 26.3 (Swift 6.2.4) on macOS;
    • swift:6.2-noble and swift:6.4-noble, each on arm64 and amd64, in containers with --network none (dependencies resolved on the host beforehand).
  • Sanitizers: AddressSanitizer and ThreadSanitizer on macOS are clean.
  • The reviewer's adversarial probes, on each of the four Linux configurations, with no network. In each configuration:
    • Poison sequence (cross-origin 307, then a 1 MiB response header, then 12 follow-up sends): 5 of 5 runs passed. The 307 throws unexpectedRedirect in about 0.01 s, the 1 MiB header times out at 5.00 s, and all 60 follow-ups return 200.
    • Timeouts at 0.2, 0.5, 0.9, 1.0, 1.5 and 2.5 s, against a hung server, an immediate answer and a 1 s answer: 5 of 5 runs. Every deadline is accurate to about 10 ms, immediate answers succeed, and the 1 s answer succeeds from 1.5 s up. When one request times out, the 20 multiplexed alongside it all return 200, and the same holds when one is cancelled.
    • Hang stress: 40 sequential hung sends at a 1 s deadline (worst 1.02 s), then 50 concurrent hung sends, which return together after 1.01–1.02 s with 11–13 threads. 2 of 2 runs, and the global Dispatch queue stays responsive.
    • Full probe:
      • the collapse-ID cases are refused;
      • the 16 KiB+ body throws responseTooLarge;
      • the odd response bodies (garbage, HTML, wrong JSON types, a float timestamp) parse safely;
      • caller cancellation throws CancellationError after 0.31 s.
    • The second origin received no request, in any run.
  • The same probes on macOS:
    • poison 3 of 3 (there, the 1 MiB header ends at once as network, URLError −1005);
    • 0.5 s timeouts at 0.52 s;
    • hang stress worst 1.07 s, and the burst returned in 1.06 s;
    • no request reached the second origin;
    • an endless body is refused after 0.02 s at a 28 MB peak RSS, with RST_STREAM after 8 MiB.
  • Endless body on Linux, default 30 s timeout, 6 GB container limit: 8 of 8 runs (two per configuration). send threw responseTooLarge within 0.02–0.14 s. RSS stayed at 70–110 MB, but corelibs kept reading 0.2–2.7 GB before the connection closed.
  • swift-crypto 4.0.0, pinned exactly on the final tree: builds and passes on macOS (Xcode 27.1) and on all four Linux configurations, debug and release. The range still resolves to 4.5.2 next to Perfect-SMTP 6.0.0 and Perfect-NIO 0.1.0. The earlier floor runs (3.0.0, 4.0.0, 5.0.0) predate ae4d53b and the review fixes, and only 4.0.0 was rerun.
  • README samples: the quick start, payload, batch and option samples build in a scratch package with warnings as errors.
  • Nothing contacted APNs. Every server was on loopback.

Known limitations

  • Linux keeps downloading after it gives up. corelibs URLSession keeps a cancelled transfer running. With the delegate-driven transport, the body is counted and discarded as it arrives, so send returns at once and memory stays low (under 110 MB in testing), but an endless body can still pull gigabytes before corelibs closes the connection. The pre-review transport, built on URLSession.data(for:), buffered it all: OOM-killed at about 6 GB within roughly 10 seconds with default settings. Reaching this needs a server that passes TLS verification for api.push.apple.com (or a custom host). 6.1 follow-up: a swift-nio-http2 transport, using swift-nio, swift-nio-ssl and swift-nio-http2, which are already in the graph for Perfect-NIO users. It would reset the stream at the cap and take its TLS and deadline handling from Perfect-SMTP.
  • Only authentication challenges reach the caller's session delegate. The client sends through its own session, built from the configuration of the one passed in. Other delegate callbacks (metrics, task-level data) aren't forwarded.
  • Topic suffixes aren't checked against the push type. .voip with a topic lacking .voip, for example, is rejected by APNs (DeviceTokenNotForTopic or TopicDisallowed), not by the client. Odd but valid topics and custom hosts are accepted as they are.
  • Collapse IDs refuse spaces and non-ASCII. Apple only documents the 64-byte limit, so the client errs on the safe side.
  • Payload limits: VoIP gets 5120 bytes; every other type gets 4096, including push-to-talk, whose documented limit I couldn't confirm.
  • .low (priority 1) is accepted for every type but background. Apple documents it generally, not per push type.
  • Batch sends are sequential. For concurrency, call send from a task group; requests multiplex over the one HTTP/2 connection.
  • One token per client. Each APNSClient created from the same key mints its own provider token, so several clients, or several processes, sharing a key can still trigger TooManyProviderTokenUpdates. Share one client.
  • A wrong clock at mint time gives a token with a wrong iat, which APNs rejects. It is replaced on rejection, at most once per 20 minutes.
  • No certificate-based (.p12) authentication. Tokens only.
  • The APNSErrorReason list is from memory of Apple's docs. UnrelatedKeyIdInToken and BadEnvironmentKeyInToken are the least certain. Unknown reasons still come through in reason.
  • Linux regressions run as scripted probes, not unit tests. The URLProtocol stub can't reproduce corelibs' network timing. The deadlock, timeout, redirect and endless-body checks use a SwiftNIO adversarial server and client outside the package, and run in release verification.

Upgrade notes

  • From 5.x: a rewrite of the call site; see the README's "Migrating from 5.x".
  • From the pre-release main:
    • priority: takes APNSPriority;
    • .customPayload takes APNSJSONValue;
    • the multi-token send returns [APNSBatchResult];
    • timestamp is milliseconds;
    • key and team IDs, device tokens, topics and collapse IDs (printable ASCII) are validated;
    • init(raw:) throws;
    • batch cancellation returns partial results;
    • only authentication challenges reach your session's delegate;
    • APNSClientError has new cases;
    • swift-crypto is 4.0.0..<5.0.0.

Pull requests

  • #19: Swift 6 rewrite (swift6-modernization, 2026-09-01).
  • #20: 6.0.0 (validation, Sendable payloads, Linux, token lifecycle, deadlines, CI, docs).

Legacy

The Perfect-Net-based code (5.0.0) is on the legacy branch. Stay on 5.x if you can't move to Swift 6.2.