diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 1d89d0d..6c677be 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -44,14 +44,55 @@ Benchmarks are run using the `ResourceBenchmark` tool: `swift run ResourceBenchm Measured with `swift run -c release asnloadprobe 8`, which constructs 8 `SwiftFTR` instances with `asnResolverStrategy: .embedded`, preloads them one after another, then constructs and preloads 8 -more at once. Apple M4, macOS 26, 2026-09-01. +more at once. Apple M4, macOS 26. Shared-store column re-measured 2026-09-03 against SwiftIP2ASN +0.5.1; the per-instance column was taken 2026-09-01 at the commit before sharing landed. | Metric | Per-instance loading | Shared store | | :--- | :--- | :--- | -| **Preload, first instance** | 59.9 ms | 61.5 ms | -| **Preload, each further instance** | 56–62 ms | 0.0 ms | +| **Preload, first instance** | 59.9 ms | 56.8 ms | +| **Preload, each further instance** | 56-62 ms | 0.0 ms | | **8 concurrent preloads** | 64.3 ms | 0.0 ms (already resident) | -| **Resident memory, 16 instances** | 6.1 MB → 507.9 MB | 6.1 MB → 57.3 MB | +| **Resident memory, 16 instances** | 6.1 MB -> 507.9 MB | 6.1 MB -> 57.2 MB | -One loaded database costs about 50 MB resident. Every instance in the process built for the same -source shares it, and it is released when the last one goes away. +### Resolver strategies + +Cold and warm resolution of ten well-known public addresses, release build, SwiftIP2ASN 0.5.1, +2026-09-03. Reproduce with `swift test -c release --filter AsnStrategyBench`. Cold includes the +one-time database load for the local strategies; the `.dns` figure depends on the resolver in front +of the machine and varies between runs. + +| Strategy | Coverage | Cold | Warm | +| :--- | :--- | :--- | :--- | +| `.dns` | 10/10 | 0.187 s | <0.1 ms | +| `.hybrid(.embedded)` (default) | 10/10 | 0.059 s | <0.1 ms | +| `.embedded` | 10/10 | 0.055 s | <0.1 ms | + +Run the same benchmark in a debug build and the local strategies invert, costing about 0.50 s cold +against DNS's 0.22 s, because decompressing and parsing the database is roughly eight times slower +unoptimized. Compare strategies in release only. + +### What one copy costs + +Physical footprint, measured by loading `UltraCompactDatabase` repeatedly in a release build +against SwiftIP2ASN 0.5.1 on 2026-09-03. The database holds 455,832 IPv4 ranges, 121,752 IPv6 +ranges and 86,833 ASN names. + +| | Footprint | Delta | +| :--- | :--- | :--- | +| Baseline | 1.6 MB | | +| One database loaded | 51.0 MB | +49.4 MB | +| Two held | 66.1 MB | +15.1 MB | +| Three held | 81.1 MB | +15.0 MB | + +So a copy is about **15 MB**: 9.4 MB of parallel range arrays plus roughly 6 MB of ASN-name +strings. The first load costs far more footprint than the copy it produces because the decoder +allocates a scratch buffer eight times the compressed size, decodes into it, and copies the result +out before parsing; the allocator retains those pages for reuse rather than returning them to the +system. Releasing every database does not return the footprint either, for the same reason. + +Reducing this further means changing the on-disk format upstream in SwiftIP2ASN rather than +anything in SwiftFTR. In rough order of payoff: ship the database in its binary-searchable layout +and memory-map it, which makes the pages clean, evictable and shared between processes and removes +the decode entirely; flatten the ASN-name table into one contiguous UTF-8 buffer with a sorted +offset array, worth about 3.5 MB; and record the decompressed size in the header so the decoder can +size its buffer exactly instead of guessing at 8x. diff --git a/CHANGELOG.md b/CHANGELOG.md index e6112ba..65a4466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,77 +3,115 @@ Changelog All notable changes to this project are documented here. This project follows Semantic Versioning. -Unreleased ----------- - -### Added - -- `cancelActiveTraces()` cancels currently active traces without invalidating cached rDNS, - public IP, or ASN resolution. -- `invalidateNetworkScopedRDNS()` evicts network-scoped (private, CGNAT, link-local, loopback, - ULA) rDNS entries and resets the stall breaker while preserving globally routable internet - hostnames and allowing in-flight global lookups to finish normally. -- `seedPublicIP(_:source:)` dynamically seeds a validated public IP address into the actor's - cache for the current generation, allowing classified traces to bypass discovery without - the permanent suppression of `SwiftFTRConfig.publicIP`. Rejects non-global (RFC 1918, CGNAT, - link-local, loopback, ULA, multicast), malformed, and placeholder sentinels. -- `PublicIPSource` indicates the origin of a seeded public IP address (`.validatedCallerCache`, - `.gatewayReported`). -- `TraceOptions` and per-operation options on `trace(to:options:)` and `traceClassified(to:vpnContext:resolver:options:)` - permit overriding `maxHops` on individual traces without reconfiguring the actor. - -### Changed - -- STUN public IP fallback now resolves endpoints concurrently across worker threads to avoid - serial DNS stalls. Deduplicated the fallback server list by dropping redundant `stun1.l.google.com` - (which pointed to the exact same IP as `stun.l.google.com`), ensuring genuine multi-provider failover - between independent providers (Google and Cloudflare) on distinct networks. +0.17.0 — 2026-09-03 +------------------- -### Tooling +Two themes. A caller no longer has to replace the `SwiftFTR` actor to react to a network change: +cancellation, reverse-DNS eviction, public-IP freshness, and hop budgets are now independently +controllable. And ASN enrichment no longer depends on the network by default, because the +embedded database is now the default source and is loaded once per process rather than once per +tracer. -- Pull requests are now gated on the DocC documentation build: broken doc links and - documentation warnings fail CI instead of surfacing at release time. +Everything here is additive. `swift package diagnose-api-breaking-changes v0.16.0` reports no +breaking changes, so upgrading is a version bump. Read the behavior changes below, since two of +them alter what an unmodified caller does at runtime. ### Behavior changes -- `isPrivateIPv4(_:)` and `isCGNATIPv4(_:)` now validate addresses with the same strict, - byte-based parser used by scope classification. Malformed dotted strings return `false` instead - of being classified from only their first one or two components; valid IPv4 behavior is - unchanged. - - **ASN lookups now use the embedded database by default.** The default `asnResolverStrategy` changes from `.dns` to `.hybrid(.embedded)`: the local database answers first, and DNS is consulted only for addresses it does not cover. Until now the embedded database shipped but was never loaded unless a caller opted in, so every ASN lookup was a Team Cymru DNS query. - Measured on a fixed set of ten public addresses: both strategies resolve 10/10, so this is a - latency and reliability change rather than a coverage change. Cold resolution drops from 0.215s - to 0.094s, and the common path no longer depends on the resolver at all — which matters because - a cold DNS lookup against an unresponsive resolver stalls for 30 seconds, and callers that - discard caches on a network change re-pay the cold path every time. - - One loaded database costs about 50 MB of resident memory, shared by every instance in the - process. Callers who want the old behavior can pass `asnResolverStrategy: .dns`; those who want - no network under any circumstances can pass `.embedded`. Call `preloadASNDatabase()` early to - move the first-load cost off the first trace. + Measured by `AsnStrategyBench` on a fixed set of ten public addresses, release build, 2026-09-03: + every strategy resolves 10/10, so this is a latency and reliability change rather than a coverage + change. Cold resolution of the set drops from 0.187s on `.dns` to 0.059s on `.hybrid(.embedded)`, + where the local figure is dominated by the one-time database load and the DNS figure varies with + the resolver in front of you. The point is less the millisecond count than what the common path + depends on: nothing on the network. That matters because a cold DNS lookup against an + unresponsive resolver stalls for 30 seconds, and callers that discard caches on a network change + re-pay the cold path every time. + + The cost is memory. One loaded database is about 15 MB, and the first load takes process + footprint to roughly 50 MB, because the decoder's scratch buffers are retained by the allocator + rather than returned to the system. That is paid once per process however many tracers exist. + Callers who want the old behavior can pass `asnResolverStrategy: .dns`; those who want no + network under any circumstances can pass `.embedded`. Call `preloadASNDatabase()` early to move + the first-load cost off the first trace. + +- **`isPrivateIPv4(_:)` and `isCGNATIPv4(_:)` validate addresses strictly.** Both now use the same + byte-based parser as scope classification. Malformed dotted strings return `false` instead of + being classified from only their first one or two components. Valid IPv4 behavior is unchanged. + +### New public API: network-transition lifecycle + +`networkChanged()` cancels traces and clears every cache as one operation, so a caller with +evidence that only part of the network state changed had to either over-invalidate or replace the +actor. Replacing the actor risks orphaned tasks on the shared blocking-I/O executor. Each control +below is now independently callable, and `networkChanged()` remains the conservative composition +of all of them. + +- `cancelActiveTraces()` cancels in-flight traces while preserving cached reverse-DNS entries, the + discovered public IP, and ASN results. Cancellation interrupts in-flight reverse-DNS and ASN + enrichment rather than letting those phases run to completion. Traces registered while + cancellation is in progress stay tracked for the next call rather than being silently dropped. +- `invalidateNetworkScopedRDNS()` evicts only network-scoped entries — private, CGNAT, link-local, + loopback and unique-local — and resets the reverse-DNS stall breaker. Globally routable + hostnames survive, and in-flight global lookups finish normally. +- `seedPublicIP(_:source:)` seeds a validated public address into the actor's cache for the current + generation, so a caller that already knows the answer can skip discovery without the permanent + suppression that setting `SwiftFTRConfig.publicIP` implies. It rejects non-global addresses + (RFC 1918, CGNAT, link-local, loopback, unique-local, multicast), malformed input, zone-suffixed + addresses, and placeholder sentinels; it canonicalizes what it accepts, so a seeded address reads + back in the same form the rest of the API reports; and it supersedes any in-flight discovery, so a + slower lookup already running cannot overwrite it. A seed does not survive `invalidatePublicIP()`, + `clearCaches()` or `networkChanged()`. `PublicIPSource` (`.validatedCallerCache`, + `.gatewayReported`) makes the caller state where the address came from, but the library does not + retain it and treats every accepted address identically; whether to persist and act on the + distinction is an open question, and until it is answered the parameter is documentation at the + call site rather than behavior. +- `TraceOptions` carries per-operation overrides, currently `maxHops`, via `trace(to:options:)` and + `traceClassified(to:vpnContext:resolver:options:)`. Out-of-range values throw + `TracerouteError.invalidConfiguration`. + +The existing `trace(to:)` and `traceClassified(to:vpnContext:resolver:)` entry points keep their +exact shapes, so callers and DocC symbol links continue to resolve unchanged. + +### Performance and reliability - **The local ASN database is loaded once per process and shared.** Every `LocalASNResolver`, and - so every `SwiftFTR` and `HybridASNResolver` built for the same source, reads one copy through - a process-wide store that also coalesces concurrent loads. Until now each instance decompressed - and held its own copy, and `preloadASNDatabase()` warmed only the instance it was called on. - - Measured in a release build on Apple M4, 2026-09-01, with `swift run -c release asnloadprobe 8`: - constructing and preloading 16 tracers took the process from 6 MB to 508 MB resident at 56–62 ms - per tracer; it now reaches 57 MB after one 61 ms load, and every further tracer is free. The copy - is released when the last resolver using it goes away, so a host that wants it resident keeps one - instance alive. No API change. + so every `SwiftFTR` and `HybridASNResolver` built for the same source, reads one copy through a + process-wide store that also coalesces concurrent loads. Until now each instance decompressed and + held its own copy, and `preloadASNDatabase()` warmed only the instance it was called on. This + matters for callers that hold one tracer per interface, since interface binding is fixed at + construction. + + Measured in a release build on Apple M4 with `swift run -c release asnloadprobe 8`: constructing + and preloading 16 tracers took the process from 6 MB to 508 MB resident at 56–62 ms per tracer; + it now reaches 57 MB after one 57 ms load, and every further tracer is free. The copy is released + when the last resolver using it goes away, so a caller that wants it resident keeps one instance + alive. No API change; the reproducer ships as `Tests/TestSupport/asnloadprobe`. + +- **Public-IP discovery fails over between independent providers, concurrently.** The STUN server + list previously held multiple Google hostnames that resolved to a single address, so it paid + several DNS lookups for no IP-level redundancy, serially, with each unanswered lookup able to + stall for 30 seconds. The list is now Google and Cloudflare on distinct networks, resolved + concurrently. `stunGetPublicIPWithFallback` also validates its timeout before starting DNS + resolution rather than after. ### Dependencies -- SwiftIP2ASN 0.4.1 → 0.5.0, which adds observable database freshness and conditional-GET - refreshes. The existing `from: "0.4.1"` requirement already permitted 0.5.0, so consumers - resolved to it regardless; this pins our own CI to what they get. SwiftFTR never referenced - `UltraCompactError`, so the breaking change in that release does not reach us. +- SwiftIP2ASN: the requirement remains `from: "0.4.1"`, which permits any 0.x at or above that. + This release is tested against 0.5.1, which adds observable database freshness and conditional-GET + refreshes. `Package.resolved` is deliberately not committed, and SwiftPM ignores a library's + resolution when it is consumed as a dependency, so consumers resolve to the newest permitted + version independently of what our CI pins. SwiftFTR never referenced `UltraCompactError`, so the + breaking change in 0.5.0 does not reach us. + +### Tooling + +- Pull requests are gated on the DocC documentation build: broken doc links and documentation + warnings fail CI instead of surfacing at release time. 0.16.0 — 2026-08-31 ------------------- diff --git a/README.md b/README.md index 8e501fc..a60ef27 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,8 @@ Fuzzing Documentation ------------- - DocC bundle at `Sources/SwiftFTR/SwiftFTR.docc`. +- [`docs/CACHE-AND-TRANSITION-LIFECYCLE.md`](docs/CACHE-AND-TRANSITION-LIFECYCLE.md) — what the tracer caches, + what invalidates each class, and the controls a caller gets across a network transition (fresh as of 2026-09-03). - [`BENCHMARKS.md`](BENCHMARKS.md) — measured throughput, memory and ASN-database load cost per release, with the `ResourceBenchmark` and `asnloadprobe` commands that reproduce them (fresh as of 2026-09-01). - [`docs/IPV6.md`](docs/IPV6.md) — sequenced plan and architectural contracts for IPv6 feature parity (ping, traceroute, probes, STUN, ASN). diff --git a/ROADMAP.md b/ROADMAP.md index 2d99967..2d8e926 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,39 +4,24 @@ Forward-looking work, stack-ranked top-to-bottom by priority. For what has alrea ## Priority Queue -### Cache and network-transition lifecycle -**Goal**: Give callers independent control over trace cancellation, -public-IP freshness, network-scoped rDNS eviction, and per-operation hop -budgets, so a network transition never requires replacing the `SwiftFTR` actor. - -- **Problem**: `networkChanged()` cancels traces and clears every cache as one - operation. A caller with evidence that only part of the network state changed - (a local roam vs. a WAN change) cannot act selectively; the workaround of - recreating the actor risks orphaned tasks on the process-global blocking-I/O - executor and encourages writing transient observations into the immutable - `SwiftFTRConfig.publicIP` override. -- **Approach**: Add `cancelActiveTraces()`, `invalidateNetworkScopedRDNS()`, - `seedPublicIP(_:source:)`, and an operation-scoped `TraceOptions` override, - all additive. `invalidatePublicIP()` already exists. Keep `networkChanged()` - as the conservative composition. -- **Also in scope**: letting sibling tracers share one `RDNSCache`. The local ASN database is - already shared per process; the rDNS cache stays per instance until eviction is network-scoped, - because one instance's `networkChanged()` would otherwise evict for all of them. -- **Design and acceptance**: [Cache and network-transition lifecycle](docs/CACHE-AND-TRANSITION-LIFECYCLE.md). - -### STUN server list provides no actual redundancy -**Goal**: Make public-IP discovery fail over to a genuinely different endpoint. - -- **Problem**: `stunServers` lists multiple Google hostnames, and discovery walks them serially, - paying a `getaddrinfo` for each. Measured 2026-08-30: `stun.l.google.com` and - `stun1.l.google.com` both resolve to `74.125.250.129` — the same address. The list costs 3x the - DNS work of one server while providing no IP-level failover, and against an unresponsive - resolver each of those lookups stalls 30s. -- **Approach**: pick endpoints operated by different providers so failover means something, and - resolve them concurrently rather than serially. Two servers on distinct networks beat three - hostnames pointing at one address. -- **Note**: verify the duplication still holds before acting — Google's records carry a ~150s TTL - and are geo-steered, so a different vantage point may resolve them differently. +### Share the reverse-DNS cache across tracer instances +**Goal**: Sibling tracers reuse hostname lookups instead of each starting cold, without one +instance's network change evicting another's entries. + +- **Problem**: `interface` is fixed at construction, so a caller needing several binding contexts + holds several tracers, and each owns a private `RDNSCache`. The local ASN database is already + shared per process; reverse DNS is the remaining per-instance cost, and it is the expensive one + on a cold network because each lookup can stall. +- **Blocker, and why this did not ship with the rest of the lifecycle work**: eviction is + per-instance today. `networkChanged()` on one tracer would clear a shared cache for all of them, + which is wrong when the instances are bound to different interfaces that changed independently. + Sharing needs eviction scoped to the network a cache entry was observed on, not to the actor + that happens to call it. +- **Approach**: accept an `RDNSCache` at construction the way `traceClassified` accepts a + resolver, keeping per-instance as the default. Gate it on scoping eviction by network identity + so a shared cache cannot be cleared out from under a sibling. +- **Design context**: [Cache and network-transition lifecycle](docs/CACHE-AND-TRANSITION-LIFECYCLE.md) + describes the cache classes and their invalidation rules. ### Literal-IP STUN endpoints **Goal**: Remove DNS from public-IP discovery entirely, rather than bounding its cost. diff --git a/Sources/SwiftFTR/ASN.swift b/Sources/SwiftFTR/ASN.swift index e412cfa..e1dc004 100644 --- a/Sources/SwiftFTR/ASN.swift +++ b/Sources/SwiftFTR/ASN.swift @@ -30,7 +30,9 @@ public enum ASNResolverStrategy: Sendable { /// Use embedded local database from SwiftIP2ASN package resources. /// Fast (~10μs), works offline. The database is loaded once per process and shared by every - /// resolver that uses it; it costs about 50 MB of resident memory after load. + /// resolver that uses it, so additional resolvers cost nothing. One copy is about 15 MB; + /// the first load takes process footprint to roughly 50 MB, because the decoder's scratch + /// buffers are retained by the allocator rather than returned to the system. case embedded /// Use remote database with optional bundled fallback. diff --git a/Sources/SwiftFTR/Traceroute.swift b/Sources/SwiftFTR/Traceroute.swift index 912d9a4..014524f 100644 --- a/Sources/SwiftFTR/Traceroute.swift +++ b/Sources/SwiftFTR/Traceroute.swift @@ -135,6 +135,11 @@ public struct TraceOptions: Sendable, Equatable { } /// Origin of a dynamically seeded public IP address. +/// +/// ``SwiftFTR/seedPublicIP(_:source:)`` requires the caller to state where a seeded address came +/// from, but does not retain the value: a seeded address is cached the same way whatever its +/// source, and the library exposes no way to read the source back. Whether to persist it and act +/// on the difference is an open question recorded in the lifecycle design document. public enum PublicIPSource: Sendable, Equatable { /// Seeded from a validated caller-side cache or observation. case validatedCallerCache @@ -295,18 +300,18 @@ public struct SwiftFTRConfig: Sendable { /// ASN resolver strategy for trace classification. /// /// Controls how IP-to-ASN lookups are performed during `traceClassified()`. - /// Defaults to `.dns` (Team Cymru DNS) for backward compatibility. /// /// Options: /// - `.hybrid(source, fallbackTimeout:)`: **Default.** Local database first, DNS only for - /// addresses it does not cover. Costs ~6MB of memory for the embedded database and removes - /// the network from the common path, which matters because a caller that discards caches on - /// a network change re-pays the cold path every time — and a cold DNS lookup against an - /// unresponsive resolver stalls for 30 seconds. + /// addresses it does not cover. Removes the network from the common path, which matters + /// because a caller that discards caches on a network change re-pays the cold path every + /// time — and a cold DNS lookup against an unresponsive resolver stalls for 30 seconds. + /// Loads the embedded database; see `.embedded` for what that costs. /// - `.dns`: DNS-based lookups via Team Cymru. No memory cost, but every uncached address is a /// network round trip, and enrichment is only as reliable as the resolver. - /// - `.embedded`: Local database only (~10μs lookups, +6MB memory). No network at any point; - /// an address the database does not cover simply has no ASN. + /// - `.embedded`: Local database only (~10μs lookups). No network at any point; an address the + /// database does not cover simply has no ASN. The database is loaded once per process and + /// shared, so it costs about 15 MB however many tracers exist. /// - `.remote(bundledPath:url:)`: Remote database with optional offline fallback /// /// Example: @@ -1365,7 +1370,8 @@ public actor SwiftFTR { /// /// - Parameters: /// - address: The public IP address string to seed. - /// - source: The origin of this observation (e.g. validated caller cache or gateway). + /// - source: The origin of this observation. Stating it is required; the library does not + /// retain it and treats every accepted address identically. See ``PublicIPSource``. /// - Returns: `true` if the address was accepted and cached; `false` if rejected. @discardableResult public func seedPublicIP( diff --git a/Sources/SwiftFTR/Version.swift b/Sources/SwiftFTR/Version.swift index 611102b..21f4e3c 100644 --- a/Sources/SwiftFTR/Version.swift +++ b/Sources/SwiftFTR/Version.swift @@ -1,4 +1,4 @@ /// The current SwiftFTR library version. /// /// Update this constant when cutting a new release. -public let swiftFTRVersion = "0.16.0" +public let swiftFTRVersion = "0.17.0" diff --git a/docs/CACHE-AND-TRANSITION-LIFECYCLE.md b/docs/CACHE-AND-TRANSITION-LIFECYCLE.md index beaa13c..d283f74 100644 --- a/docs/CACHE-AND-TRANSITION-LIFECYCLE.md +++ b/docs/CACHE-AND-TRANSITION-LIFECYCLE.md @@ -2,10 +2,14 @@ ## Status -Proposal. Scope is the SwiftFTR library contract only: what the actor caches, +Implemented in 0.17.0, except for sharing an `RDNSCache` between tracer +instances, which is tracked in [ROADMAP.md](../ROADMAP.md). This document is +the design of record for the lifecycle contract: what the actor caches, what +invalidates it, and which controls callers get. Scope is the SwiftFTR library +contract only. How a caller decides what invalidates it, and which controls callers get. How a caller decides *when* to invoke these controls — network identity heuristics, revalidation -policy, telemetry — is caller policy and out of scope for this document. +policy, telemetry — is caller policy and out of scope. ## Decision summary @@ -13,16 +17,14 @@ A caller should keep a long-lived `SwiftFTR` actor for a measurement context. A network transition must not require replacing that actor merely to cancel active work or refresh cached discovery state. -The library therefore needs its lifecycle controls to be independently -callable: +The library's lifecycle controls are therefore independently callable: -- cancelling active traces (**proposed**: `cancelActiveTraces()`); -- invalidating the discovered public IP (**exists**: `invalidatePublicIP()`); -- evicting only network-scoped rDNS entries (**proposed**: - `invalidateNetworkScopedRDNS()`); -- seeding a validated public IP without suppressing discovery (**proposed**: - `seedPublicIP(_:source:)`); and -- supplying a per-operation hop budget (**proposed**: `TraceOptions`). +- cancelling active traces: `cancelActiveTraces()`; +- invalidating the discovered public IP: `invalidatePublicIP()`; +- evicting only network-scoped rDNS entries: `invalidateNetworkScopedRDNS()`; +- seeding a validated public IP without suppressing discovery: + `seedPublicIP(_:source:)`; and +- supplying a per-operation hop budget: `TraceOptions`. `networkChanged()` remains the conservative composition — cancel everything, invalidate everything — for callers without finer-grained evidence. @@ -85,10 +87,10 @@ The library-owned state and what may invalidate it: | Data | Key | Invalidated by | |---|---|---| -| Active trace handles | cache generation | `networkChanged()`; proposed `cancelActiveTraces()` | +| Active trace handles | cache generation | `networkChanged()`; `cancelActiveTraces()` | | Discovered public IP | current generation | `invalidatePublicIP()`, `clearCaches()`, `networkChanged()` | | Globally routable rDNS | IP address | 86400 s TTL or LRU capacity; should survive local transitions | -| Network-scoped rDNS (private, CGNAT, link-local, ULA, loopback) | IP address | Proposed `invalidateNetworkScopedRDNS()`; today only full `clear()` | +| Network-scoped rDNS (private, CGNAT, link-local, ULA, loopback) | IP address | `invalidateNetworkScopedRDNS()`; `networkChanged()` | | ASN results | globally routable address | Capacity only (no TTL); local transitions must not clear it | Anything keyed on the caller's understanding of network identity — which @@ -96,10 +98,10 @@ gateway it is behind, whether a WAN lease changed, how confident it is in a path — is caller state. The library's contract is only that each control above does exactly what it says and nothing more. -## Proposed API +## The controls -All four additions are additive; existing overloads remain source-compatible. -Snippets establish semantics, not final spellings. +All four additions are additive; the pre-existing overloads remain +source-compatible. ### `cancelActiveTraces()` @@ -199,20 +201,11 @@ existing `1...255` range and applies only to that operation. The library provides the mechanism; when to shorten a budget, how much headroom to add, and when to retry at full budget are caller policy. -## Migration plan +## What the tests hold to -- Add the three new controls and `TraceOptions` as additive API, each with - deterministic unit tests (injected resolvers; no live network). -- Keep `networkChanged()` byte-for-byte equivalent to the composition of - cancellation, public-IP invalidation, and full rDNS eviction, so existing - callers see no behavior change. -- Document on `SwiftFTRConfig.publicIP` that it is an authoritative override, - not a cache-seeding mechanism, and point to `seedPublicIP(_:source:)`. - -## Acceptance - -The implementation is not complete when it compiles. Library-level tests must -demonstrate: +`networkChanged()` stays equivalent to the composition of cancellation, +public-IP invalidation, and full rDNS eviction, so callers that only ever call +it see no behavior change. Beyond that, library-level tests demonstrate: - `cancelActiveTraces()` cancels the snapshot generation, leaves later-registered traces tracked, and leaves every cache intact. @@ -232,15 +225,22 @@ demonstrate: - Whether `seedPublicIP` should take a generation token instead of relying on the documented invalidate-then-seed ordering. -- Whether CGNAT-range (`100.64.0.0/10`) rDNS entries should be treated as - network-scoped for eviction: overlay networks assign stable addresses from - this range, so evicting them on every local transition re-pays lookups for - addresses that did not change. Options: evict (current proposal), retain, - or make the scope set configurable. +- Whether CGNAT-range (`100.64.0.0/10`) rDNS entries should stay network-scoped + for eviction. They are evicted today. Overlay networks assign stable addresses + from this range, so evicting them on every local transition re-pays lookups for + addresses that did not change. The alternatives are retaining them or making + the scope set configurable. - Whether the ASN cache should gain a TTL while this area is open, or remain capacity-only. - Whether `seedPublicIP` should accept a typed address once the library has a public address representation; today the public surface is string presentations. -- Whether a gateway-reported WAN value should be seedable at all, or only - usable by callers to decide that a revalidation is redundant. +- What `PublicIPSource` is for. `seedPublicIP` requires it and then discards it: + a gateway-reported address and a caller-validated one are cached identically, + and nothing reads the source back. Either the library should persist it and act + on the difference — a lower-trust source might be treated as a hint that expires + sooner, or might not satisfy classification on its own — or the parameter should + be dropped, since a required argument that does nothing misleads the caller. + Resolve this before the seeding API is considered settled. +- Whether a gateway-reported WAN value should be seedable at all, or only usable + by callers to decide that a revalidation is redundant.