From 01651e402350ad0c726e1d12d45fb8fb821cd6c5 Mon Sep 17 00:00:00 2001 From: "David E. Weekly" Date: Thu, 3 Sep 2026 19:22:52 -0700 Subject: [PATCH 1/2] chore(release): prepare 0.17.0 Bump the version constant, consolidate the per-PR Unreleased entries into a release section organized by user-visible value, and correct four claims that would otherwise have shipped: - The embedded database's memory cost. Two source comments disagreed with each other, one saying ~6 MB and one ~50 MB. Measured against SwiftIP2ASN 0.5.1: a copy is ~15 MB, and the first load takes process footprint to ~51 MB because the decoder's 8x scratch buffer is retained by the allocator rather than returned. Recorded in BENCHMARKS.md with the reproduction and with where a further reduction would have to happen, which is the upstream format. - The default ASN strategy. The doc comment still said the default was .dns two lines above the bullet marking .hybrid(.embedded) as the default. - The dependency note claimed taking SwiftIP2ASN 0.5.0 pinned our CI. It pins nothing: Package.resolved is gitignored, and consumers resolve independently. We test against 0.5.1, which is what `from: "0.4.1"` actually resolves to. - The cold-resolution figures, which were inherited rather than reproduced. Re-measured in release: 0.187s on .dns against 0.059s on .hybrid(.embedded). The debug build inverts that ordering, so BENCHMARKS.md now says to compare in release only. Drop the two roadmap items this release completed, leaving the reverse-DNS cache sharing that did not ship and stating the eviction-scoping problem that blocks it. Rewrite the lifecycle design doc to describe what exists rather than what was proposed, and cite it from README so it does not depend on a roadmap entry for its home. Verified on this branch: 126 tests pass with live network, swift-format lint clean, DocC builds warning-free, `diagnose-api-breaking-changes v0.16.0` reports no breaking changes, and the release binary reports 0.17.0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017sTdURjHTGgQ5j5vY6uhTm --- BENCHMARKS.md | 53 ++++++++- CHANGELOG.md | 147 +++++++++++++++---------- README.md | 2 + ROADMAP.md | 51 +++------ Sources/SwiftFTR/ASN.swift | 4 +- Sources/SwiftFTR/Traceroute.swift | 14 +-- Sources/SwiftFTR/Version.swift | 2 +- docs/CACHE-AND-TRANSITION-LIFECYCLE.md | 67 ++++++----- 8 files changed, 200 insertions(+), 140 deletions(-) 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..f1cd3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,77 +3,112 @@ 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` records where a seeded address came from + (`.validatedCallerCache`, `.gatewayReported`). +- `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..3cc37e2 100644 --- a/Sources/SwiftFTR/Traceroute.swift +++ b/Sources/SwiftFTR/Traceroute.swift @@ -295,18 +295,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: 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..094bc3e 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,17 @@ 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. +- Whether a gateway-reported WAN value should be seedable at all. It is today, + labelled `PublicIPSource.gatewayReported` so a caller can tell seeded values + apart by trust level; the alternative is to let callers use it only to decide + that a revalidation is redundant. From 8a60e476ae94ac769f8b62edaf75551637e2e943 Mon Sep 17 00:00:00 2001 From: "David E. Weekly" Date: Thu, 3 Sep 2026 19:27:00 -0700 Subject: [PATCH 2/2] docs(seed): stop claiming the seeded public-IP source is retained codex-review caught the release notes and the lifecycle design doc asserting that PublicIPSource lets a caller tell seeded addresses apart by trust level. It does not: seedPublicIP takes the parameter and discards it, caching every accepted address identically, and nothing reads it back. State that plainly on the enum, on the parameter, and in the release notes, and record the real decision in the design doc's open questions: either persist the source and act on the difference, or drop a required argument that does nothing. Resolving that is API design and does not belong in a release-prep change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017sTdURjHTGgQ5j5vY6uhTm --- CHANGELOG.md | 7 +++++-- Sources/SwiftFTR/Traceroute.swift | 8 +++++++- docs/CACHE-AND-TRANSITION-LIFECYCLE.md | 13 +++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cd3d9..65a4466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,11 @@ of all of them. 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` records where a seeded address came from - (`.validatedCallerCache`, `.gatewayReported`). + `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`. diff --git a/Sources/SwiftFTR/Traceroute.swift b/Sources/SwiftFTR/Traceroute.swift index 3cc37e2..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 @@ -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/docs/CACHE-AND-TRANSITION-LIFECYCLE.md b/docs/CACHE-AND-TRANSITION-LIFECYCLE.md index 094bc3e..d283f74 100644 --- a/docs/CACHE-AND-TRANSITION-LIFECYCLE.md +++ b/docs/CACHE-AND-TRANSITION-LIFECYCLE.md @@ -235,7 +235,12 @@ it see no behavior change. Beyond that, library-level tests demonstrate: - 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. It is today, - labelled `PublicIPSource.gatewayReported` so a caller can tell seeded values - apart by trust level; the alternative is to let callers use it only 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.