Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | 5662 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.
150 changes: 94 additions & 56 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
51 changes: 18 additions & 33 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion Sources/SwiftFTR/ASN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading