feat(build): lower the Linux glibc floor to 2.31 and publish linux-arm64 - #119
feat(build): lower the Linux glibc floor to 2.31 and publish linux-arm64#119MichaelTaylor3d wants to merge 7 commits into
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
The shipped linux-x64 binary silently began requiring glibc >= 2.38, so it would not start on Ubuntu 22.04 LTS (2.35), Debian 12 (2.36) or Amazon Linux 2023 (2.34) — the three most common server distros. No code caused it: GitHub repointed `ubuntu-latest` at Ubuntu 24.04, and a glibc-linked binary runs on its BUILD glibc and anything newer, never anything older. The builder image, and nothing else, sets the supported-OS floor. Both Linux targets now build NATIVELY inside a pinned `debian:11` container (glibc 2.31), which clears every distro above plus Ubuntu 20.04, Debian 11 and RHEL 9. linux-arm64 is published for the first time, built on the free public-repo arm64 runner — so the vendored openssl-sys in the Chia-wallet-SDK graph is compiled by a native toolchain and the old 'cross-compiling OpenSSL is fragile' objection to arm64 no longer applies. The apt .deb moves into the same container and gains an arm64 leg; apt's arm64 index previously had no dig-node at all. The floor is now GATED, not merely claimed: it is declared once in .github/actions/setup-linux-build, asserted against the build container so image and number cannot drift, and asserted against every produced binary by scripts/check-glibc-floor.sh. That gate is pinned from both sides — an at-the-floor binary must pass and a one-minor-over binary must fail — and the release job additionally re-runs it with an impossible 2.0 floor, because a gate that cannot fail is a rubber stamp. Its version comparison is version-aware, not lexical: glibc 2.4 is OLDER than 2.31. Refs #1736, #1735, #1741 Co-Authored-By: Claude <noreply@anthropic.com>
Three PR#118 gate follow-ups in the connected-pool address path. #1782 — a supersede republishes PoolEvent::PeerAdded and fires NO PeerRemoved, so the per-peer address list grew by one entry per distinct SocketAddr ever seen (measured: 5000 events, 5000 entries). Capped at 8, dig-dht's own MAX_ADDRESSES_PER_RECORD. Three downstream guards each stop a remote peer driving this today, so it is defence-in-depth: the cap is what keeps a future relaxation of any one of them from becoming a remote memory sink. Eviction is family-aware rather than a blind truncate, because the tail is where the other address family ends up and the downstream dial ladder is both IPv6-first and very short: a lone IPv4 fallback must survive IPv6 churn (§5.2 prefers IPv6, it does not exclude IPv4). #1784 — dig-nat reports [::]:0 as the remote of an accepted relayed circuit when no relay endpoint is configured. That wildcard flowed into the pool as the peer's fetchRange target. It is now rejected at the event, not filtered on read, so a peer that already has a working address cannot have it displaced from the front of the dial order by an address nothing can be reached at. The root cause is in dig-nat and is reported there; this is the node's own guard. #1783 — the PEX dial loop checked is_pool_peer, then spawned a dial that can run for CANDIDATE_DIAL_TIMEOUT before adopting. Since dig-gossip 0.17.12 a second connection for an identity SUPERSEDES the live slot and tears its session down, killing any in-flight range stream; before 0.17.12 it merely returned DuplicateConnection, which is why ignoring the race used to be safe. Membership is now re-checked after the dial resolves, at a single named adoption gate. Refs #1782, #1783, #1784 Co-Authored-By: Claude <noreply@anthropic.com>
…cy contract SPEC 11.3 now specifies the five-asset matrix including linux-arm64, the arm64 .deb, and the glibc 2.31 floor as a HARD RULE over the whole Linux delivery surface, with the three mechanisms that enforce it — container assertion, per-binary gate, and executing the binaries on the oldest supported distros. Raising the floor is named as a coordinated act. dig-node-core SPEC 7.8 gains the contract the code always depended on but the spec never stated: PoolEvent::PeerAdded is REPUBLISHED for an already-added peer_id with no intervening PeerRemoved, so consumers must be keyed-idempotent and must bound what they accumulate per identity. Also states that a churn address is a hint that must be rejected when it is not a destination, and that newest-leads ordering holds within the pool's list only — the dial ladder is IPv6-first, so one family's churn must not evict a peer's only address of the other. The #1640 framing ceiling is marked RESOLVED where it was still documented as an open KNOWN CONSTRAINT: dig-nat 0.13 capped the sender and 0.14 validates at the receiver, both fail-closed, and this workspace is on that line. dig-node-core moves to 0.24.0 to stop understating its public surface, which now exports dig-nat 0.14 / dig-dht 0.8 types; the reason is recorded in the manifest. Refs #1785, #1736 Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The asset-naming guard grepped build-binaries.yml for a literal staged path. Naming now lives in the stage-binaries composite action, because the Linux targets moved into a pinned old-glibc container and the resulting two build jobs must not be able to drift on the names downstream consumers resolve. The guard follows it, and is strengthened while there: it asserts the dign bin is built by BOTH build jobs (a count, so one job dropping the alias is caught), that neither job constructs an asset name itself, and that the legacy dig-companion copy is absent from both files rather than only the workflow. Co-Authored-By: Claude <noreply@anthropic.com>
`ldd --version | head -n 1` under `pipefail` fails intermittently with exit 141: head closes the pipe after the first line, ldd takes SIGPIPE, and the step dies. It passed on two runs before failing on a third, which is the worst shape for a gate — a false red that looks like a floor violation. The version is now sliced out of a captured string with no pipe at all, and an unparseable banner is an explicit error rather than an empty comparison that could silently match. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES-REQUIRED (recorded as a comment review - GitHub rejects request-changes on a self-authored PR, HTTP 422).
CHANGES-REQUIRED - 2 gating findings, both small
Strong PR. The floor work is the most thoroughly gated CI change I have reviewed here: the container-glibc assert, the artifact gate, and the execution matrix are three INDEPENDENT gates, and the 2.0 negative control is genuine. I reproduced the mutation claims independently rather than taking them on report - all five discriminate, and none of the four peer-path fixes opens the remotely-inducible-lasting-verdict class.
Two things gate:
-
GATING - there IS a third unguarded path into the DHT. The PR asserts two independent entry points and no other path publishing an address into the pool or the DHT.
bring_up_dht(crates/dig-node-core/src/peer.rs:2575) readspool.connected_pool_peers()- the SAME source as the routing feed you did guard - maps it with a baredht_addr_from_gossip_addr, and feeds the result intoservice.bootstrap()viabootstrap_peers_from_pool(seams/dig_peer/dht.rs:456), with nois_usable_contact. Detail on the inline thread. -
GATING (one sentence) - the musl rejection is not recorded normatively. I judge the rationale SOUND, but it lives only in this PR body, and
scripts/check-glibc-floor.sh:41-45returns OK for a musl/static binary - so a future switch to a*-musltarget passes all three gates while removing the property the DNS-seeding argument protects. Detail on the inline thread.
Mutations I executed (mine, not the lane reported ones)
Worktree at 3f59bad; baseline cargo test -p dig-node-core --lib = 461 passed. Reverted by file copy; tree verified clean afterwards.
| Mutation | Result |
|---|---|
retain_newest_per_family -> addrs.truncate(8) |
ipv6_churn_cannot_evict_a_peers_only_ipv4_address FAILS, a_peer_that_supersedes_forever_never_exceeds_the_address_cap PASSES - the two tests discriminate independently, as claimed |
| remove the cap call + the wildcard guard | all 4 of the #1782/#1784 pool tests FAIL |
is_usable_contact(&dht) -> is_usable_contact(&gossip) (raw not mapped) |
a_wildcard_or_unshiftable_pool_address_yields_no_dht_contact FAILS on 203.0.113.7:1, its control PASSES |
should_adopt_dialed_peer -> true |
a_candidate_already_in_the_pool_is_not_adopted_after_its_dial_resolves FAILS |
floor gate: sort -V -> sort |
the 2.4-vs-2.31 case FAILS |
floor gate: widen the grep to swallow GLIBC_PRIVATE |
the private-tag case FAILS |
floor gate: usage error exit 2 -> exit 0 |
the misuse case FAILS |
Direct verdicts
Are the floor gates real negative controls? YES. The 2.0 control in build-binaries.yml is genuine, and it does more than advertised: because check-glibc-floor.sh:41-45 exits 0 on empty readelf output, a missing or broken readelf would make the gate silently green - and the 2.0 invocation catches exactly that, since it would also return 0. That is the one fail-open branch in the script, and the control covers it. All three script mutations above confirm the unit suite is not vacuous.
Does the #1783 test gap gate? NO. The verdict function is tested against a REAL two-node loopback-mTLS pool with a real connected peer plus an honest unconnected control, and my mutation fails it. The uncovered part is three lines of straight-line code between Ok(conn) and adopt_nat_connection, and - the thing I actually checked - the new return path takes NO penalize/disconnect/backoff action, so a mis-fire costs one redundant connection and nothing durable. Seaming out connect_via_nat for this is disproportionate to a LOW/analytic finding. The honest disclosure is the right treatment.
Was closing #114 correct? YES - and main is a strict SUPERSET, not merely equivalent. Verified item by item: the cascade pins (dig-nat 0.14, dig-dht 0.8, dig-download 0.12, dig-peer 0.7, dig-peer-selector 0.7, plus the dig-gossip rev) and the non_exhaustive DownloadConfig mutation are on main at download.rs:655. The #114 SPEC bracketing paragraph is superseded by the broader Authority-construction note now on main, which additionally governs /health, status and the control-client URL. The #114 stringly browser_authority is superseded by a typed UrlAuthority enum (open.rs:477) with its own regression test at open.rs:645. Config::bind_addr on main returns SocketAddr where #114 returned String, and Config::host is IpAddr, making the defect unrepresentable. KNOWN_VIOLATIONS is empty on main with the cross-crate reach detector present (banned_address_patterns.rs:314) rather than the #114 SIBLING_CRATE_UNDER_SCAN constant - same job, same guarantee. No work was lost.
Light security - none of the four opens the lasting-verdict class. The cap and the eviction are keyed per peer_id, so churn can only evict a peer own addresses (self-harm). The is_usable_contact inputs come from dig-nat own transport bookkeeping, never a peer-supplied wire field, so a remote peer cannot make an honest peer address look unspecified or port-0. The eviction is provably unable to drop index 0 (a majority family of a list of 9 or more has at least 5 members, so rposition never lands on the head), so a LIVE newest address cannot be evicted in favour of a stale one - which was the original defect. And the #1783 guard writes nothing durable.
The SIGPIPE flake is fixed and the sweep is clean. .github/actions/setup-linux-build/action.yml:56-60 reads ldd --version into a variable and slices it with read plus shell expansion - no pipe. I swept .github/, scripts/ and packaging/ for any other pipe-into-head under pipefail: the only remaining occurrence is the comment explaining the fix. The tail -n 1 in check-glibc-floor.sh is not the same hazard, since tail drains its input.
arm64 .deb metadata is correct, not copied. Architecture: ${ARCH} is parameterized, the filename carries the arch, and Depends: libc6 is arch-independent by design (dpkg resolves libc6:arm64). Confirmed at HEAD, not just in the canary: job 90479828149 logs container glibc: 2.31, declared floor: 2.31, check-glibc-floor: ... OK for the aarch64 payload, and Architecture: arm64.
The canary evidence is real. Run 30420036070, all 6 verify legs green. The arm64 amazonlinux:2023 leg resolves the complete set (libc.so.6 => /lib64/libc.so.6, /lib/ld-linux-aarch64.so.1, zero not-found) and both binaries print 0.67.0; ldd is logged unconditionally before each run, as claimed. Note the canary ran at a2f7f34, before the SIGPIPE fix - but the two .deb legs at HEAD exercise the fixed setup-linux-build step and pass, so the head revision of that step is executed evidence.
Beautiful-code gate: PASSES
retain_newest_per_family, is_usable_contact and should_adopt_dialed_peer are each one named concept with a WHY-comment that explains the defect rather than the mechanism, and every new test doc-comment states what it would catch. The expect in the eviction loop is provably unreachable and says so. The composite-action extraction is honest DRY - the naming genuinely could not diverge across two build jobs afterwards, and the guard test was strengthened to assert that.
Non-gating notes follow in a separate review comment; I will resolve those myself so they cannot block the merge.
| let dht_addr = dht_addr_from_gossip_addr(addr); | ||
| // Skip a peer whose pool address is not a destination (#1784) — routing must never seed a | ||
| // lookup from a wildcard contact. | ||
| let Some(dht_addr) = dht_contact_from_pool_addr(addr) else { |
There was a problem hiding this comment.
GATING - a third path publishes a pool address into the DHT, unguarded.
This guard is right, and so is the one in download.rs::on_pool_event. But the PR claims those are the only two paths, and they are not.
bring_up_dht at crates/dig-node-core/src/peer.rs:2575:
.map(|(peer_id, addr, _outbound)| {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(peer_id.as_ref());
// The pool reports each peer GOSSIP addr; the DHT must dial its DHT/peer-RPC addr (GAP 2).
(bytes, dht_addr_from_gossip_addr(addr))
})That feeds bootstrap_peers_from_pool (seams/dig_peer/dht.rs:456), which turns each entry into a BootstrapPeer::direct(...) with no filtering, and the result goes into service.bootstrap(&bootstrap). Same source (pool.connected_pool_peers()), same port shift, same destination (the DHT routing table) - but no is_usable_contact.
Why this is not merely theoretical: the call site own comment says "Bootstrap from the connected pool (+ relay-introducer peers discovered into it)", and a relay-introducer-discovered peer is precisely the accepted-relayed-circuit case dig-nat reports as [::]:0. Your rationale for the guard on this very line - "routing must never seed a lookup from a wildcard contact" - applies verbatim to bootstrap, which seeds the same table a moment earlier.
Fix is one line: use dht_contact_from_pool_addr at peer.rs:2575 and drop the Nones (or filter inside bootstrap_peers_from_pool, arguably the better home, since that is the function owning the pool-to-DHT-contact conversion).
It also needs a test. bootstrap_peers_from_pool_maps_each_pool_peer (dht.rs:946) feeds only 203.0.113.1:9444 and 203.0.113.2:9444 - the BENIGN-FIXTURE shape: the filter would be a no-op on that input, so adding one and then deleting it again leaves the test green either way. Give it a [::]:0 entry and assert the peer count drops.
While you are there, please restate the blast-radius claim in the PR body - "two independent entry points, no third path" is the sentence that sent me looking, and a reviewer who trusted it would have stopped.
| `arm64`. | ||
|
|
||
| **Every published Linux artifact — the raw binaries AND the `.deb` — MUST run on glibc 2.31 or | ||
| newer.** 2.31 is the supported floor, and it is a floor on the whole Linux delivery surface, not a |
There was a problem hiding this comment.
GATING (one sentence) - record the musl rejection here.
I agree with the decision. The musl built-in resolver is not a drop-in for the glibc one: no NSS module support, a much narrower resolv.conf reading, and historically no TCP fallback for a UDP answer over 512 bytes - which is exactly the shape of a DNS-seed record set carrying many A/AAAA entries. Trading a known floor problem for an unknown resolver problem on the node bootstrap path is the wrong trade, and you called it correctly.
The problem is that this reasoning exists ONLY in the PR description, while 11.3 now reads as though the -gnu target plus a 2.31 container is the whole contract. And the gates do not backstop it:
scripts/check-glibc-floor.sh:41-45explicitly returns OK for a binary with no versioned glibc symbols - the comment even says "(static or musl)";- the container assert only compares the container glibc to the declared number, which a musl TARGET inside the same container still satisfies;
verify-linux-floorwould go green, since a musl binary starts everywhere.
So a future "just target x86_64-unknown-linux-musl and delete the container" passes all three gates and silently changes the resolver the node seeds from. That is precisely the drift 11.3 was written to prevent, one layer up.
One sentence closes it, e.g.: "The -gnu target is REQUIRED. A *-musl target would remove the floor but replaces the glibc NSS/resolver, which DNS seeding depends on; switching to musl is a deliberate protocol-level decision, not a build optimisation."
Since the gate script cannot see this, the SPEC sentence is the only durable record - please also add the same one-liner to the check-glibc-floor.sh:41-45 branch comment, which is the exact place someone will read "static or musl - OK" and conclude musl is sanctioned.
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Non-gating notes (I am resolving these myself so they cannot block the merge)
None of these gate. Recording them because they are cheap and sit next to work this PR already touched.
-
packaging/linux/build-deb.sh:47-Depends: libc6carries no version constraint, so apt has no way to express the 2.31 floor. On a host below the floor,apt-get install dig-nodesucceeds and the binary then fails at exec, which is a worse failure shape than a clean dependency refusal.Depends: libc6 (>= 2.31)(or generating it withdpkg-shlibdeps) would make apt do the right thing. The build-time gate you added is the important half; this is the delivery-time half. -
.github/workflows/package.yml- the two .deb legs runcheck-glibc-floor.shbut NOT the impossible-2.0 negative control thatbuild-binaries.ymlruns. Since the whole point of the control is that a gate which cannot fail is a rubber stamp, and the .deb is a separate delivery path you explicitly gated for exactly that reason, the symmetry is worth having. -
No guard test covers
package.yml.release_workflow_dign_guard.rsnow watchesbuild-binaries.ymlplus the staging action, and it caught a real drift this round - but the .deb path could silently dropcontainer: debian:11or the floor-check step with nothing to notice. A thirdinclude_str!assertion overpackage.yml(asserts the container image and thecheck-glibc-floor.shinvocation) matches the mechanical-guard-over-a-hand-maintained-claim pattern the rest of this PR follows well. -
The .deb ships only
dig-node, not thedignalias, while the raw release assets ship both. Pre-existing and out of scope here; noting it becauseevery_build_job_builds_the_dign_binasserting exactly 2 occurrences made me check, and an apt user therefore has nodigncommand. Worth a ticket rather than a change in this PR. -
crates/dig-node-core/src/download.rs:376-retain_newest_per_familyrecomputes the family counts on every iteration, which is quadratic in the overshoot. With a limit of 8 and a single insertion per event the overshoot is always exactly 1, so this is genuinely irrelevant to performance - I mention it only so the next reader does not think it was missed. The straightforward loop is the right call for readability here.
What changed
The platform floor (#1736 / #1735 duplicates, #1741) — the MVP half
What raised the floor: the builder image, not the code.
build-binaries.ymlbuiltlinux-x64onubuntu-latest, which GitHub repointed at Ubuntu 24.04 (glibc 2.39). A glibc-linked binary runs on its BUILD glibc and anything newer, never anything older — so v0.64.0 began requiring glibc >= 2.38 and stopped starting on Ubuntu 22.04 LTS (2.35), Debian 12 (2.36) and Amazon Linux 2023 (2.34), with no code change and no signal.The resulting floor is glibc 2.31, and the supported target matrix is now:
linux-x64x86_64-unknown-linux-gnuubuntu-latest+container: debian:11linux-arm64(new)aarch64-unknown-linux-gnuubuntu-24.04-arm+container: debian:11(NATIVE).debamd64x86_64-unknown-linux-gnu.debarm64(new)aarch64-unknown-linux-gnuwindows-x64.exex86_64-pc-windows-msvcwindows-latestmacos-arm64/macos-x64aarch64/x86_64-apple-darwinmacos-142.31 clears Ubuntu 20.04+, Debian 11+, Amazon Linux 2023 and RHEL 9. The
-gnutarget was kept over musl deliberately: musl would remove the floor entirely but replaces glibc's NSS/resolver, which the node's DNS seeding relies on — a bigger behavioural risk than the problem being fixed. arm64 builds NATIVELY on the free public-repo arm64 runner, which removes the old SPEC 11.3 objection wholesale (that objection was specifically about CROSS-compiling the vendoredopenssl-sysin the Chia-wallet-SDK graph).The floor is gated, not claimed. Declared once in
.github/actions/setup-linux-build/action.yml, then enforced three ways:scripts/check-glibc-floor.shasserts every produced binary —dig-nodeAND thedignalias, which is a separate link — is at or below the floor, for both the release binaries and the.debpayload (apt is a separate delivery path and could otherwise regress alone);verify-linux-floorEXECUTES both binaries inubuntu:22.04,debian:12andamazonlinux:2023on both architectures.The gate script is pinned from BOTH sides (at-bound passes, one-minor-over fails) and the release job additionally re-runs it against an impossible
2.0floor — a gate that cannot fail is a rubber stamp. Its comparison is version-aware, not lexical: glibc 2.4 is older than 2.31, and a lexical sort gets that backwards.The
lddoutput is logged unconditionally before each run because the dynamic loader names only the FIRST unsatisfied library, so a partial fix reads as a fresh regression.PR#118 gate follow-ups
download.rs:PeerAddedis republished on a supersede and fires noPeerRemoved, so the per-peer address list grew one entry per distinctSocketAddrever seen (measured 5000 -> 5000). Capped at 8 = dig-dht'sMAX_ADDRESSES_PER_RECORD. Eviction is family-aware, not a blind truncate: the tail is where the other address family ends up, and the downstream dial ladder is both IPv6-first and very short, so a lone IPv4 fallback would be churned away (5.2 prefers IPv6, it does not exclude IPv4).pex.rs: pool membership is re-checked after the dial resolves, at a single named gate (should_adopt_dialed_peer). Since dig-gossip 0.17.12 a second connection SUPERSEDES and tears down the live session; pre-0.17.12 it returnedDuplicateConnection, which is why ignoring the race used to be safe.download.rs::on_pool_event, and the DHT routing feed inpeer.rs, which bypasses the pool entirely. Root cause is in dig-nat and is reported, not patched here.PeerAddedkeyed-idempotency contract; theRANGE_WINDOW#1640 "KNOWN CONSTRAINT" is marked RESOLVED (dig-nat 0.13 sender + 0.14 receiver, both fail-closed);dig-node-coremoves to 0.24.0 with the reason recorded in the manifest; the "newest leads the dial order" comment is corrected to hold only within an address family.Blast radius checked
gitnexus is disabled in the loop (it hangs in worktrees), so this was done with ripgrep + direct reads per CLAUDE.md 2.0.
on_pool_event— callers:peer.rs::spawn_selector_registry_feed(snapshot + churn),lib.rs:7156,peer.rs:1790/1805/2893/3116/3136. All feedPeerAdded/PeerRemoved; behaviour changes only for a wildcard address (dropped) or past 8 addresses (capped).dht_addr_from_gossip_addr— retained unchanged; the newdht_contact_from_pool_addrwraps it, and the two DHT-feed call sites moved to the wrapper. No other caller altered.is_usable_contact— new symbol, no prior callers.build-binaries.yml— consumed byrelease.ymlandnightly-release.yml, both of which download ALL artifacts by pattern, solinux-arm64flows through with no caller change.package.yml'sdebjob became a matrix; its artifact name gained an-${arch}suffix (two legs would otherwise collide) andpublishflattens by extension, so the rename is transparent.release_workflow_dign_guard.rspinned the old literal and CAUGHT it — updated and strengthened to assert both build jobs build the alias and neither names an asset itself.peer.rs's test module and three of its helpers becamepub(crate)(matchingdownload.rs's existing precedent) so the PEX test can drive a REAL pool. Test-only; no shipped surface.How verified
verifyjobs green (linux-x64andlinux-arm64xubuntu:22.04,debian:12,amazonlinux:2023), each running--versionfor both binaries. The arm64/amazonlinux:2023 log resolves the complete dependency set (libc.so.6 => /lib64/libc.so.6, nonot found) — Amazon Linux 2023 is the distro that killed the original e2e.container glibc: 2.31, declared floor: 2.31on both arches;requires glibc up to 2.30 (declared floor 2.31) ... OK;dpkg-deb --infoshowsArchitecture: arm64/amd64.truncateinstead of family-aware -> the IPv4-survival test fails while the cap test still passes, so the two tests are independently discriminating;should_adopt_dialed_peer->true-> the #1783 test fails;GLIBC_PRIVATE-> the private-tag case fails.cargo clippy --all-targets --locked -- -D warningsclean;cargo fmt --all --checkclean;cargo test --workspacegreen.Version
0.66.0->0.67.0(minor). New published build targets (linux-arm64binary + arm64.deb) are added capability, and every code change is compatible.dig-node-core0.23.0->0.24.0separately, per #1785c.Not done here, deliberately
install.sh's architecture detection (#1741, second deliverable) lives in dig-installer (install.sh:34hardcodesos_slug="linux-x64"for every Linux, while the macOS branch does checkuname -m), which another lane owns. Reported rather than edited.unspecified_addr()root cause (#1784) is in dig-nat. Guarded here; reported there.dial_candidatestruncation order (#1782 secondary) is upstream: it sorts IPv6-first then truncates to 4, so >=4 IPv6 candidates can still cut an IPv4 address out of the ladder. dig-node now guarantees the address survives in the POOL; guaranteeing it survives the LADDER is dig-download's to fix.connect_via_nat/adopt_nat_connectionon a live P2P path — a larger refactor than a LOW/analytic finding warrants. Stated rather than papered over.Closes #1736
Closes #1735
Closes #1741
Closes #1782
Closes #1783
Closes #1784
Closes #1785