Repository navigation
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.
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
-
ASN lookups now use the embedded database by default. The default
asnResolverStrategy
changes from.dnsto.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 by
AsnStrategyBenchon 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.dnsto 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 passasnResolverStrategy: .dns; those who want no
network under any circumstances can pass.embedded. CallpreloadASNDatabase()early to move
the first-load cost off the first trace. -
isPrivateIPv4(_:)andisCGNATIPv4(_:)validate addresses strictly. Both now use the same
byte-based parser as scope classification. Malformed dotted strings returnfalseinstead 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 settingSwiftFTRConfig.publicIPimplies. 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 surviveinvalidatePublicIP(),
clearCaches()ornetworkChanged().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.TraceOptionscarries per-operation overrides, currentlymaxHops, viatrace(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 everySwiftFTRandHybridASNResolverbuilt 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, andpreloadASNDatabase()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 asTests/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.stunGetPublicIPWithFallbackalso validates its timeout before starting DNS
resolution rather than after.
Dependencies
- 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.resolvedis 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 referencedUltraCompactError, 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.
What's Changed
- ci: gate pull requests on documentation building by @dweekly in #67
- feat(asn): use the embedded database by default by @dweekly in #69
- docs: refer to downstream consumers generically by @dweekly in #70
- docs: propose cache lifecycle by @dweekly in #66
- docs: remove BUGS.md — field evidence lives with the consumer by @dweekly in #71
- docs: record the DocC CI gate in the changelog by @dweekly in #72
- docs: retract the EAGAIN mechanism claim by @dweekly in #68
- docs: credit prior art and point at peer tools by @dweekly in #73
- fix(utils): validate IPv4 scope with byte parser by @dweekly in #75
- docs(roadmap): shared ASN database across tracer instances by @dweekly in #76
- feat(asn): load the local ASN database once per process and share it by @dweekly in #78
- feat(tracer): independent trace cancellation and per-operation hop options by @dweekly in #79
- feat(rdns): network-scoped rDNS eviction with generation-guarded inflight lookups by @dweekly in #80
- feat(tracer): validated public IP seeding via seedPublicIP by @dweekly in #81
- feat(stun): STUN server deduplication and concurrent multi-provider resolution by @dweekly in #82
- chore(release): prepare 0.17.0 by @dweekly in #83
Full Changelog: v0.16.0...v0.17.0