TunnelVision

Spec — Packet parsing (Shared/IP)

Turns a raw IP datagram (Data) into typed headers and a FlowKey. Pure, synchronous, and allocation-frugal: parsing must not copy the payload, only read fields and return ranges into the original buffer.

The same module also holds the mirror direction — PacketEmitter, which serializes a datagram so the relay can reinject a reply. It arrives with M8 and is specced in relay-and-tls.md.

Contract

public enum PacketParseError: Error, Sendable {
    case tooShort(expected: Int, got: Int)
    case unsupportedVersion(UInt8)
    case badHeaderLength
    case truncatedL4
    case unsupportedProtocol(UInt8)   // no es error fatal: el caller lo cuenta y hace passthrough
}

/// Resultado de parsear un datagrama IP completo.
public struct ParsedPacket: Sendable {
    public let ip: IPHeader
    public let tcp: TCPHeader?
    public let udp: UDPHeader?
    public let flowKey: FlowKey
    public let source: IPEndpoint       // origen tal cual viene en el paquete
    public let destination: IPEndpoint
}

public enum PacketParser {
    /// Parsea un datagrama IP. `protocolFamily` es el AF_INET/AF_INET6 que da `packetFlow`,
    /// usado como pista; si no coincide con el nibble de versión, gana el contenido.
    public static func parse(_ packet: Data, protocolFamily: Int32) throws -> ParsedPacket
}

Data is accessed via withUnsafeBytes; all offsets are computed against a base pointer. No intermediate Data copies are created.

IPv4 (RFC 791)

IPv6 (RFC 8200)

TCP (RFC 9293)

UDP (RFC 768)

Endianness helpers

Provide small inlined big-endian readers (readU16BE, readU32BE) over the raw pointer. Network byte order is big-endian; convert once at read time, store host-order in the typed headers.

Error handling policy

Performance

Tests (M3)

Above L4: the DNS dissector (roadmap step 10)

The parser above stops at the transport header. Shared/DNS is the first thing that reads what comes after it: DNSMessageParser.parse takes the payload of a UDP datagram on port 53 and returns the message (DNSMessage) — the header bits, the questions and the answer section — or a typed DNSParseError. It is the same shape of component as PacketParser: pure, synchronous, no state, bounded, and 0-based over whatever Data it is handed (withUnsafeBytes normalizes a slice, which matters here because a compression pointer is an offset from the start of the message).

DNS goes first among the dissectors, ahead of HTTP, TLS records and QUIC frames, for a reason that is not aesthetic: it is the only layer a 2026 iPhone still sends in the clear, so it is the only one readable on hardware without inspection turned on, and it is what most changes a packet screen — Looked up: api.example.com · Record type: A · Answer: 203.0.113.10 where there used to be only UDP · Datagram length: 73 B.

Where the limits are, and why each one is there. A DNS message is a stranger’s data, and its name compression makes that sharp: a two-byte pointer can name any offset in the message, and a pointer to itself is an infinite loop.

What it does not do, each on purpose: it does not walk the authority and additional sections (nothing shows them, so walking them would be failure surface for nothing); it does not pair a query with its reply by id (that needs state across packets, and the screen that reads this describes one packet); it does not read DNS over TCP (a two-byte length prefix this parser does not expect, so reading it would be reading it wrong); and it does not read mDNS on 5353, which uses the same format and is the obvious next port — one dissector per increment.

A record type it does not break down is not an error: its content becomes DNSRecordData.opaque(byteCount:), and so does a known type whose length is not its own (an A that does not measure four bytes is not an address however its type is labelled).

What the screen makes of all this — which four readings a reply can have, and what is said when a port-53 datagram cannot be read at all — is in TunnelVision/Models/DNSPresentation.swift.

Tests

Names from DNS: the address → name map

A ClientHello names a flow only when the tunnel sees one: TLS over TCP, or a QUIC connection whose first packets passed through it (Naming a QUIC flow). Plenty of traffic is not TLS at all, and a connection already open when the tunnel started announces nothing again, so many flows of a present-day phone have an address and nothing else. ResolvedNameMap (Shared/DNS) is what gives them a name: it is handed the DNS messages the dissector has already read and answers which name had the device asked for when it was given this address?

public struct ResolvedNameLimits: Sendable, Hashable {
    public let capacity: Int                 // address–name pairs held in total
    public let namesPerAddress: Int          // names remembered for one address
    public let minimumLifetime: UInt32       // seconds an answer is believed, whatever its TTL
    public let maximumLifetime: UInt32
    public init(capacity: Int, namesPerAddress: Int, minimumLifetime: UInt32, maximumLifetime: UInt32)
    public static let tunnel: ResolvedNameLimits   // 2048 pairs, 4 names, 60 s … 1 day
}

public struct ResolvedName: Sendable, Hashable {
    public let name: String                  // the name that was asked for, normalised
    public let resolvedAt: UInt64            // when the answer went by, on the packet clock
    public let otherNames: [String]          // other live names of the address, most recent first
}

public enum DNSNameIngestion: Sendable, Hashable {
    case recorded(addresses: Int)
    case ignored(Reason)
    public enum Reason: Sendable, Hashable {
        case notAResponse, unsupportedOpcode, errorResponse
        case unsupportedQuestion, unusableName, noAddresses
    }
}

public struct ResolvedNameMap: Sendable {
    public let limits: ResolvedNameLimits
    public private(set) var count: Int
    public init(limits: ResolvedNameLimits)
    public mutating func ingest(_ message: DNSMessage, at instant: UInt64) -> DNSNameIngestion
    public mutating func removeExpired(at instant: UInt64) -> Int
    public func name(for address: IPAddress, at instant: UInt64) -> ResolvedName?
}

It is a pure value: it reads no clock (every instant is passed in, in the nanoseconds that stamp packets, PacketMeta.timestamp), touches no disk and knows nothing about flows. ResolvedNameLimits has no default values — each of its four numbers decides something — and the ones the tunnel uses are ResolvedNameLimits.tunnel, each with its reason written next to it.

A resolved name is not an announced SNI. An SNI is stated by the connection itself; this is an inference from an earlier lookup. That is why the result carries when it was resolved and which other names the same address had, and why it is a type of its own rather than a string that could be mistaken for an SNI.

What goes in

Only a reply (QR set, standard opcode, NOERROR) to exactly one question of class IN, and from it only the addresses (A, AAAA) reachable from the question’s name by following the reply’s own CNAME records.

Everything else is reported, not dropped in silence: ingest returns why a message recorded nothing. There was nothing to read is a result that has to be countable — with encrypted DNS no reply crosses port 53 at all, and an unnamed flow needs its reason.

When it expires

A pair lives for the shortest TTL on the path from the name to the address — the address record’s and every CNAME’s in between — clamped to the limits. Seeing the same answer again renews it. An address listed twice in one reply takes the shorter of its two TTLs.

The clock is the monotonic one that stamps packets, which stops while the device sleeps: a pair can outlive its TTL by however long the device was asleep. That errs towards keeping a name the device itself may still be using from its own cache, and it keeps the map free of wall-clock jumps.

The tie-break on a shared address

One address can have several live names at once — that is what a CDN is. The rule:

  1. The most recently resolved live name wins. A device looks a name up immediately before it connects, so the latest lookup that returned this address is the likeliest reason for the flow.
  2. At the same instant, the lexicographically smaller name wins — so the answer never depends on which message was ingested first or on dictionary order.
  3. The losers are not hidden: they are otherNames, most recent first. An empty list means the attribution is uncontested; a non-empty one means name is the best candidate, not the only one, and whoever judges the flow against an allowlist can see every name it might have been.

Resolving an older name again makes it the most recent. An address remembers at most namesPerAddress names and forgets the least recently resolved beyond that.

How much it holds

Never more than capacity pairs. A full map makes room by dropping what has expired first and, if nothing has, the single least recently resolved pair (ties broken by address, again so that two runs over the same messages end in the same map). Renewing a pair that is already there evicts nothing. With the tunnel’s limits the worst case — 2048 names of 253 bytes — is under 1 MB.

Tests

ResolvedNameMapTests builds DNSMessage values directly, since the map reads what the dissector read and several cases need records DNSMessageFixture cannot write (an address owned by a name that is not the question). They cover: what is recorded and each of the six reasons for recording nothing; the alias chain in order, out of order, looping, and with scrambled case; an unrelated address left out; the lifetime at its exact edge, through a chain, floored, capped, renewed and saturated; the tie-break in both arrival orders, an expired winner giving way, and the per-address limit; and the capacity — expired first, then least recent, never exceeded under a flood, and the same victim whatever the dictionary order. One case goes from bytes through the parser to a name.

Naming flows: where the map lives

The map is owned by the PacketPipeline (PacketTunnel/Pipeline), for the reason the flow table is: every datagram goes through it, in both directions, and it is where a flow is born.

public struct ResolvedFlowName: Sendable, Hashable, Codable {   // Shared/Models/FlowName.swift
    public let name: String
    public let otherNames: [String]
    public init(_ resolved: ResolvedName)
}

The name goes into its own field, FlowRecord.resolvedName, never into sni — see data-model.md for FlowName, which is how a reader gets the name of a flow together with where it came from, and persistence.md for the v7 columns.

Everything is counted (DNSNameStats, inside PipelineStats, Shared/IPC): replies recorded and the addresses they gave, datagrams that could not be read, one counter per DNSNameIngestion.Reason, and the flows that were born with a name. All of them at zero while traffic flows is the statement the device’s DNS is encrypted and there was nothing to read — the reason a whole session can be unnamed. Settings › Session diagnostics is where that statement is made in words: DiagnosticsPresentation.dnsNamingVerdict (app-services.md § The third verdict).

PipelineResolvedNameTests covers the hookup: a reply followed by a UDP/443 flow to that address comes out named with origin dns and an empty sni; the reinjected path; TCP, where a later SNI wins without erasing the resolved name; IPv6; a flow that predates the reply; a named flow keeping its name; the other names of a shared address reaching the store; an expired answer; the configured limits; outbound datagrams and other ports left unread; and each counter, including the all-zero case.

Above L4: the QUIC long header

Shared/QUIC/QUICLongHeader.swift, types in Shared/Models/QUICVersion.swift. It answers one question about a UDP datagram — which QUIC version does this packet say it speaks? — and through it a second one the flow table could not answer before: is this UDP flow encrypted?

public struct QUICVersion: RawRepresentable, Sendable, Hashable, Codable {   // the four wire bytes
    public static let v1: QUICVersion        // 0x00000001, RFC 9000
    public static let v2: QUICVersion        // 0x6b3343cf, RFC 9369
    public var hasKnownPacketProtection: Bool { get }   // v1 and v2, nothing else
}

public enum QUICVersionSource: String, Sendable, Hashable, Codable { case client, server }

public struct QUICVersionReading: Sendable, Hashable, Codable {
    public let version: QUICVersion
    public let source: QUICVersionSource
    public func replaces(_ current: QUICVersionReading?) -> Bool
}

public enum QUICLongHeader {
    public static func version(in payload: Data) -> QUICVersion?
}

What is read

Only what RFC 8999 fixes for every version: the first byte, the four bytes of version, and the two connection IDs with their length bytes — far enough to know they fit. Nothing is decrypted and the ClientHello inside the Initial is not touched; that is Opening a QUIC Initial.

The long header travels only on the first packets of a connection (Initial, 0-RTT, Handshake, Retry). Everything after uses the short header, which carries no version. A flow whose start the tunnel did not see therefore has no reading, and that is not the same as not being QUIC.

What counts as a long header

More than its top bit, because half the internet starts a datagram with a byte above 0x80 (RTP does):

The version is kept as its raw value, like a TLS version: a draft, a vendor version or a number made up to force a negotiation (RFC 9000 § 15) is evidence. The two named values were checked against the IANA QUIC Versions registry, not written from memory.

Encrypted is said only of what is known to be

hasKnownPacketProtection is true for versions 1 and 2, which protect every packet with TLS 1.3 (RFC 9001, RFC 9369), and for nothing else. A long-header shape with an unknown version is recorded on the flow and does not mark it encrypted: unknown bytes that look like a header do not prove anything, and a tool that produces evidence must not hide a possible cleartext flow behind a guess. When the registry gains a version, it is added in that one property.

Where it is read, and what a flow keeps

In PacketPipeline.observe, on every UDP datagram whose remote port is 443, in both directions (what the server sends reaches the pipeline through record(reinjected:)). The relay reads the TLS handshake because that needs a reassembled stream; a datagram needs nothing reassembled, and the pipeline is where both directions pass. The reading goes to the flow table with the packet (FlowTable.observe(…, quic:)), not through a second call: no extra actor hop, and the record enqueued for that very packet already carries it.

The store keeps the reading in quic_version / quic_from_server (schema v12, persistence.md).

Tests

QUICLongHeaderTests: versions 1 and 2, every long packet type, an unknown version kept by value, connection IDs at their limit and empty, a slice that does not start at index zero; and what is not a long header — a short header, a Version Negotiation, a cleared fixed bit, a payload too short, IDs that do not fit, an oversized ID on a known version (and the same length accepted on an unknown one). QUICVersionReadingTests: the registry values, which versions are known to encrypt, and the four cases of replaces. FlowTableTests (Versión de QUIC) and PacketPipelineTests (Versión de QUIC de un flujo) cover the hookup: a flow born or raised to encrypted, short headers leaving it alone, the server’s version arriving through the reinjected path and winning, an unknown version recorded without the mark, a non-QUIC payload and another port left plaintext, the local port being 443 not counting, and the route staying passthrough with inspection on.

Above L4: opening a QUIC Initial

Shared/QUIC/QUICInitialKeys.swift, QUICInitialPacket.swift, QUICPacketNumber.swift and QUICVarint.swift. The long header says which version a flow speaks; the name the client asks for is one layer down, in the ClientHello that the client’s first packets carry. This section is the part that gets to those bytes: a client Initial in, its frames out. It is pure — no flow, no pipeline, no state. Reading the frames and naming the flow are separate pieces of work, described in the two sections after this one; the pipeline calls all three since 2026-10-12.

public struct QUICInitialKeys: Sendable, Equatable {          // the client's, never the server's
    public let version: QUICVersion
    public let key: Data                    // AEAD_AES_128_GCM, 16 bytes
    public let iv: Data                     // 12 bytes
    public let headerProtectionKey: Data    // AES-128-ECB, 16 bytes
    public init?(version: QUICVersion, clientDestinationConnectionID: Data)   // nil: unknown version
}

public enum QUICHeaderProtection {
    public static func mask(key: Data, sample: Data) throws -> Data           // 5 bytes
}

public struct QUICInitialHeader: Sendable, Equatable {        // what travels in the clear
    public let version: QUICVersion
    public let destinationConnectionID: Data
    public let sourceConnectionID: Data
    public let tokenLength: Int
    public let packetNumberOffset: Int
    public let packetLength: Int            // shorter than the datagram: another packet follows
    public init(datagram: Data) throws
}

public struct QUICInitialPacket: Sendable, Equatable {
    public let header: QUICInitialHeader
    public let packetNumber: UInt64
    public let frames: Data                 // decrypted payload, padding included
    public init(datagram: Data, header: QUICInitialHeader,
                keys: QUICInitialKeys, largestPacketNumber: UInt64?) throws
}

public enum QUICPacketNumber {
    public static func decode(truncated: UInt64, byteCount: Int, largestProcessed: UInt64?) -> UInt64?
}

public enum QUICVarint {
    public static func read(from data: Data, at index: inout Data.Index) -> UInt64?
}

public enum QUICInitialError: Error, Equatable, Sendable {
    case notALongHeader, unsupportedVersion(QUICVersion), notAnInitial, connectionIDTooLong
    case truncated, tooShortToSample, keysOfAnotherVersion, headerProtectionFailed
    case authenticationFailed
}

Why this is not decryption of anything private

Initial keys are not a secret. RFC 9001 § 5.2 derives them from a salt printed in the RFC and the Destination Connection ID of the client’s first Initial, which travels in the clear; RFC 9000 § 17.2.2 says in so many words that this protection gives no confidentiality against anyone who can see the packets — it exists so that a middlebox that does not know the version cannot rewrite them. Any on-path observer can do what this code does, and Wireshark does it by default. ADR 0003 is untouched, and ADR 0006 already counted the SNI of a QUIC Initial as metadata.

The boundary is drawn in the types:

Two steps, because the keys depend on the header

QUICInitialHeader(datagram:) needs no key and gives the version and the Destination Connection ID; the caller derives QUICInitialKeys from them and then opens the packet. They are separate so the keys can be derived once per connection and kept, and because the keys are those of the client’s first Initial, not of the packet in hand: once the server answers, the client addresses its packets to the connection ID the server chose and keeps protecting them with the original keys (§ 5.2). Only a Retry changes them — the next Initial then derives from the connection ID the server put in the Retry, which is the one that packet carries. A caller that gets authenticationFailed with a connection’s keys has that case to consider.

What the header read accepts

packetLength is what the Length field says, not the size of the datagram: a datagram may carry a second packet behind the first (RFC 9000 § 12.2), and the rest of the datagram is then a packet to read on its own.

Opening

  1. Header protection (§ 5.4): sixteen bytes of ciphertext, taken four bytes after the start of the packet number, are encrypted with AES-128-ECB under the header-protection key. The first five bytes are a mask: the low four bits of the first byte (five in a short header, which is not read here) and the packet number. CryptoKit has no bare block cipher, so this one block goes through CommonCrypto.
  2. The packet number (RFC 9000 § 17.1) travels truncated to one to four bytes and is completed against the largest one already opened, with the algorithm of RFC 9000 Appendix A.3. With none opened the expected number is 0, where every packet number space starts. It matters because it is part of the nonce: a wrong guess does not open the packet.
  3. The AEAD (§ 5.3): AES-128-GCM, nonce = IV XOR packet number, associated data = the header as it was before protection, first byte through packet number. A tag that does not verify is authenticationFailed and no bytes come out — an altered packet, a packet of another connection and keys from the wrong connection ID are indistinguishable, and need not be told apart.

The reserved bits are not checked: the endpoint that receives the packet is the one to treat them as a protocol violation, and an observer that dropped the packet would only know less.

What is not here

Tests

The expected values are the ones the RFCs print, copied from the documents, not computed:

Above L4: from CRYPTO frames to a ClientHello

Shared/QUIC/QUICInitialFrames.swift and QUICCryptoStream.swift, both pure, and PacketTunnel/TLS/QUICClientHelloScanner.swift, which joins them to the ClientHello reader that TCP flows already use. The decrypted payloads of a client’s Initial packets in, the name it announced out. Which Initials of a flow are opened, with which keys, and where the name goes are the hookup’s decisions (Naming a QUIC flow).

public struct QUICCryptoFrame: Sendable, Equatable {           // Shared/QUIC
    public let offset: UInt64
    public let data: Data
}

public struct QUICInitialFrames: Sendable, Equatable {         // Shared/QUIC
    public enum Stop: Error, Sendable, Equatable {
        case unexpectedFrameType(UInt64), truncated, cryptoBeyondStreamLimit
    }
    public let crypto: [QUICCryptoFrame]    // in wire order
    public let stop: Stop?                  // nil: the whole payload was walked
    public init(payload: Data)
}

public struct QUICCryptoStream: Sendable, Equatable {          // Shared/QUIC
    public struct Limits: Sendable, Equatable {
        public init(maxStreamBytes: Int, maxPendingFragments: Int)   // no defaults
    }
    public enum Exceeded: Sendable, Equatable { case streamBytes, pendingFragments }
    public enum Delivery: Sendable, Equatable {
        case delivered(Data)                // the bytes now in order; may be empty
        case exceeded(Exceeded)             // terminal
    }
    public init(limits: Limits)
    public private(set) var deliveredByteCount: Int
    public private(set) var exceeded: Exceeded?
    public var pendingFragmentCount: Int { get }
    public mutating func insert(_ frame: QUICCryptoFrame) -> Delivery
}

struct QUICClientHelloScanner: Sendable {                      // PacketTunnel/TLS
    enum Outcome: Sendable, Equatable {
        case needMoreFrames, found(String), unavailable(Reason)
    }
    enum Reason: Sendable, Equatable {
        case clientHello(ClientHelloScanner.Reason)
        case cryptoStream(QUICCryptoStream.Exceeded)
    }
    var offer: ClientTLSOffer? { get }
    init(streamLimits: QUICCryptoStream.Limits, maxHandshakeBytes: Int)
    mutating func scan(initialPayload: Data) -> Outcome
}

Walking the frames

An Initial packet can carry five kinds of frame (RFC 9000 § 12.4, Table 3), and the walker knows the length of each, because the CRYPTO frame may sit behind any of them — clients put padding first, and the Initial that retransmits a ClientHello usually opens with the ACK of the server’s Initial:

Type Frame What is skipped (RFC 9000 § 19)
0x00 PADDING the byte
0x01 PING the byte
0x02, 0x03 ACK four varints, two more per range, and for 0x03 three ECN counts
0x06 CRYPTO kept: offset, length, data
0x1c CONNECTION_CLOSE error code, frame type, and the reason phrase by its length

What had to be decided:

Reassembling the stream

A ClientHello with a post-quantum key share does not fit in one Initial, so its CRYPTO frames arrive in two or three packets, and not tidily: packets overtake each other, a retransmission repeats what was seen, and some clients cut the ClientHello into pieces and shuffle them inside a single packet. QUICCryptoStream takes frames in arrival order and hands back the stream’s bytes in order and exactly once.

Handing it to the ClientHello reader

QUIC carries the TLS handshake message without the record layer: the content of a handshake record goes into CRYPTO frames, not the record (RFC 9001 § 4.1.3). So ClientHelloScanner gained a Framing — .tlsRecords, what it always read, and .handshakeMessages, the message as it is — and everything past the record layer is the same code: the name, the offer, the validation of the host name, the ceiling (relay-and-tls.md § SNI without decryption). A QUIC flow’s name and offer therefore follow the same rules as a TCP flow’s, Encrypted Client Hello included: the name read is the outer one and the offer says so.

QUICClientHelloScanner is the join: per payload, the frames are walked, each CRYPTO frame goes into the stream, and whatever comes out in order goes to the reader. Like the reader, it settles and stays put — after a definitive outcome it drops what it held and looks at no further frame. Its Reason keeps apart a message that was read and gave no name (clientHello) from a stream that was given up (cryptoStream).

It opens no packet and holds no key. The boundary of the previous section stands: the client’s Initial packets, the ClientHello, and nothing else.

What is not here

The per-flow state the opening needs (the keys of the first connection ID, the largest packet number opened), the values of the limits and how many Initials of a flow are looked at: all of that is the hookup, Naming a QUIC flow.

Tests

Above L4: naming a QUIC flow

PacketTunnel/TLS/QUICClientHelloReader.swift, PacketTunnel/Pipeline/QUICNameReadLimits.swift, the hookup in PacketPipeline, and the counters in Shared/IPC/QUICNameStats.swift. The UDP datagrams a device sends to port 443 in, the name its QUIC client announced on the flow. This is what the two previous sections were built for.

public struct QUICClientHelloReader: Sendable {                // PacketTunnel/TLS
    public struct Limits: Sendable, Equatable {
        public let maxInitialPackets: Int
        public let stream: QUICCryptoStream.Limits
        public let maxHandshakeBytes: Int
        public init(maxInitialPackets: Int, stream: QUICCryptoStream.Limits, maxHandshakeBytes: Int)
        public static let tunnel: Limits     // 8 Initials, 8 192 + 8 192 bytes, 32 fragments
    }
    enum Outcome: Sendable, Equatable {
        case needMoreInitials, found(String), unavailable(Reason)
    }
    enum Reason: Sendable, Equatable {
        case clientHello(ClientHelloScanner.Reason)
        case cryptoStream(QUICCryptoStream.Exceeded)
        case initialsExhausted
    }
    struct Reading: Sendable, Equatable {
        var outcome: Outcome
        var offer: ClientTLSOffer?           // only on the reading that settles the outcome
        var opened: Int                      // Initial packets of this datagram that opened
        var notOpened: Int
        var restarted: Bool                  // started over with another connection ID's keys
        var attemptedAnInitial: Bool { get }
    }
    init(limits: Limits)
    mutating func read(datagram: Data) -> Reading     // the UDP payload, whole
}

public struct QUICNameReadLimits: Sendable, Equatable {        // PacketTunnel/Pipeline
    public let perFlow: QUICClientHelloReader.Limits
    public let maxConcurrentReads: Int
    public init(perFlow: QUICClientHelloReader.Limits, maxConcurrentReads: Int)
    public static let tunnel: QUICNameReadLimits      // .tunnel per flow, 64 reads at once
}

public struct QUICNameStats: Sendable, Equatable, Codable {    // Shared/IPC, in PipelineStats.quicNames
    public var initialsOpened, initialsNotOpened, readsRestarted, initialsSkippedAtCapacity: UInt64
    public var flowsNamed: UInt64
    public var withoutServerName, unreadableClientHello, cryptoStreamAbandoned, initialsExhausted: UInt64
    public var flowsLeftUnnamed: UInt64 { get }
}

The state one flow needs

QUICClientHelloReader is what a flow keeps between its first Initial and the moment its name is known: the client’s Initial keys, the largest packet number opened, and a QUICClientHelloScanner. It is given the UDP payloads the client sends, whole and in arrival order.

The limits, and where their values come from

Until this step every limit was a parameter without a default. Their values were set against what two real clients send, captured on 2026-10-11 against a local UDP socket that never answers (QUICClientCaptures in the tests; names under .test):

  ClientHello Initials CRYPTO frames per packet
Apple’s QUIC stack (NWProtocolQUIC, macOS 27) 1 506 bytes 2 1, in order
Chrome 155 1 951 bytes 2 10 and 9, shuffled; 7, 3 and 11 in its retransmissions
Limit Value Why
maxInitialPackets 8 Four times the two that are needed: room for a Retry (everything is sent twice) and for a couple of retransmissions or a packet that does not open
stream.maxStreamBytes, maxHandshakeBytes 8 192 Four times the largest ClientHello measured. A flow being read cannot hold more than this twice over
stream.maxPendingFragments 32 Nearly three times the eleven frames of the most shredded packet captured, which is what would wait if none of them touched another. The captures peak at 3
maxConcurrentReads 64 A read lasts the first two datagrams of a connection, so the reads open at once are the connections being opened at that instant. 64 × 16 384 bytes is 1 MB in a worst case that only traffic written to fill it reaches

QUICClientHelloReaderTests asserts the measurements against the captures, so a capture replaced by a larger one fails the test that justifies the values.

Cost, measured: deriving the keys, opening Chrome’s two Initials and reassembling its ClientHello takes about 80 µs per flow (Debug build, Simulator on an Apple-silicon Mac; testCostOfReadingTheNameOfAChromeFlow). It is paid once per QUIC connection. Every other datagram of the flow costs one bit test — the long-header bit — or, for the long-header packets that follow the Initials, one dictionary lookup.

Where it is read, and where the name goes

In PacketPipeline.observe, after the flow table has seen the packet, on UDP datagrams the device sends to remote port 443 — the same port rule as the version. Not in the relay, where the TCP ClientHello is read: that choice was made for the reassembled stream, and QUIC brings its own reassembly inside the packets.

The boundary of Opening a QUIC Initial stands, and the hookup adds nothing to it: the client’s Initial packets, its ClientHello, and nothing else.

What is not here

Tests