Repository navigation
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,
URLSessioncomes fromFoundationNetworking, 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 --featureslistsHTTP2), and theswift:*-nobleimages 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.1against it gets an empty reply, and the server logsREFUSED: 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,
URLSessionnegotiatedh2every time. The server saw one TLS connection with ALPNh2and every request as HTTP/2.0. Five sequential requests reused that connection, and 20 concurrent requests were multiplexed over it.timeoutIntervalfired 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.customto 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
Sendablepayload model.APNSJSONValuereplaces 5.x'sAny, 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;
ExpiredProviderTokengets 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 aDate, and so isapns-unique-id. - Secrets stay out of logs:
print,dumpandMirrorof the client never show the key or the token.
Breaking and behaviour changes
- New API.
NotificationPusher,NotificationResponseand the named-configuration registry are gone. Upgrade:let client = try APNSClient(keyID:teamID:privateKeyPath:environment:), thentry await client.send(APNSNotification(items: [...]), to: token, topic: bundleID). The README has a 5.x mapping table. - Async.
sendisasync throwsand returns anAPNSResponse. An APNs rejection is a response witherrorset, 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 withtry APNSJSONValue(jsonObject:).APNSNotificationItemis now reallySendable(it was@unchecked) andEquatable. A customapskey throwsAPNSClientError.reservedPayloadKey.priority:is anAPNSPriority?:.immediate(10),.background(5) or the new.low(1). It was anInt?.APNSPushType.defaultPriorityreturns anAPNSPriority. Upgrade:priority: 10→.immediate,priority: 5→.background. A background push with any priority but 5 throws.invalidPriority.- The multi-token
sendreturns[APNSBatchResult], one per token, each with aResult<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.APNSBatchResultisEquatable. APNSResponseError.timestampis anInt64?of milliseconds. It was anInt?documented as epoch seconds; Apple sends milliseconds.invalidatedAtgives theDate.- 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
badgeor arelevanceScoreoutside 0–1 (.invalidPayload); - JSON nested more than 64 levels (
.jsonNestingTooDeep); APNSNotification(raw:)bytes that aren't a JSON object with anapsdictionary, 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).
- device tokens that aren't 64–200 hex digits of even length (
APNSClientErrorhas new cases. Upgrade: exhaustiveswitches 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_MONOTONICon Darwin,CLOCK_BOOTTIMEon 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, andenvironmentandtimeoutproperties;Environment.custom(host:port:);APNSJSONValue;APNSBatchResult;APNSErrorReasonandAPNSResponseError.knownReason;APNSResponseError.invalidatedAt;APNSResponse.apnsUniqueID;APNSPriority.low;APNSClientError.unexpectedRedirect(statusCode:),.cancelled,.invalidPayload(_:)and.jsonNestingTooDeep(limit:);APNSNotification.payloadis public;EquatableonAPNSResponse,APNSNotification,APNSNotificationItemandAPNSExpiration.
- swift-crypto is a range,
4.0.0..<5.0.0. The pre-releasemainsaidfrom: "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 usesP256, so the LinuxSendableproblem 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.mdis removed. It documented the 5.x API; it stays on thelegacybranch.
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-topicandapns-collapse-idwere 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
keyIDandteamID, so a"in either rewrote the token's JSON. Both are validated as 10 alphanumerics, and the JSON is produced byJSONEncoder. A test compares the exact header and claims bytes and verifies the signature with swift-crypto. - A false
Sendableclaim.APNSNotificationItemwas@unchecked Sendablearound anAnypayload (see Breaking changes). - No client-side payload limit. An oversized payload went to APNs for a
PayloadTooLargeround 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;
ExpiredProviderTokenwasn't retried.
See Highlights for the new rules.TooManyProviderTokenUpdatesleaves 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 outsideURLSession, 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,
sendthrewresponseTooLargeafter 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 theURLErrorcode and localized description, and never the request headers.- Linux didn't build:
URLSessionneedsFoundationNetworkingthere.
Found while verifying on Linux, before the adversarial review:
- The deadline could hang. Against a server that never answered,
sendsometimes 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
TaskawaitingURLSession.data(for:). On Linux, that cancellation callsURLSessionTask.cancel()from the task's cancellation handler, with the task's status lock held, andcancel()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:
APNSTransportdrives 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 callURLSessionTask.cancel()on a Dispatch thread, with no lock held.URLRequest.timeoutIntervalis(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).
- Fix:
- Sub-second timeouts failed at once on Linux (medium). corelibs truncates
timeoutIntervalto whole seconds, sotimeout: 0.5meant 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
authorizationheader, 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:)wrappedUInt64values aboveInt.maxto negatives (low), -1 forUInt64.max. NativeUInt64andNSNumber"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:
badgeandrelevanceScoreare range-checked, andinit(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
DispatchSourceTimerwith 10 ms leeway, becauseasyncAfterfired 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-testingin 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
unexpectedRedirectin 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
CancellationErrorafter 0.31 s.
- The second origin received no request, in any run.
- Poison sequence (cross-origin 307, then a 1 MiB response header, then 12 follow-up sends): 5 of 5 runs passed. The 307 throws
- 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.
- poison 3 of 3 (there, the 1 MiB header ends at once as
- Endless body on Linux, default 30 s timeout, 6 GB container limit: 8 of 8 runs (two per configuration).
sendthrewresponseTooLargewithin 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
ae4d53band 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
URLSessionkeeps a cancelled transfer running. With the delegate-driven transport, the body is counted and discarded as it arrives, sosendreturns 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 onURLSession.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 forapi.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.
.voipwith a topic lacking.voip, for example, is rejected by APNs (DeviceTokenNotForTopicorTopicDisallowed), 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
sendfrom a task group; requests multiplex over the one HTTP/2 connection. - One token per client. Each
APNSClientcreated from the same key mints its own provider token, so several clients, or several processes, sharing a key can still triggerTooManyProviderTokenUpdates. 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
APNSErrorReasonlist is from memory of Apple's docs.UnrelatedKeyIdInTokenandBadEnvironmentKeyInTokenare the least certain. Unknown reasons still come through inreason. - 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:takesAPNSPriority;.customPayloadtakesAPNSJSONValue;- the multi-token
sendreturns[APNSBatchResult]; timestampis 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;
APNSClientErrorhas 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.