Skip to content

fix: don't re-capture a source_location when a not_null is relocated - #7772

Merged
PastaPastaPasta merged 1 commit into
dashpay:developfrom
UdjinM6:fix-not-null-move-recapture
Sep 30, 2026
Merged

PastaPastaPasta merged 1 commit into
dashpay:developfrom
UdjinM6:fix-not-null-move-recapture

Conversation

@UdjinM6

@UdjinM6 UdjinM6 commented Sep 30, 2026

Copy link
Copy Markdown

Issue being fixed or feature implemented

Relocating a gsl::not_null re-records where it was moved, so a failed precondition names whatever code moved it rather than the code that produced the null pointer. When the relocation happens inside a standard library header, that header's path is embedded in the shipped binaries.

not_null's converting constructor takes a Dash-specific defaulted nostd::source_location loc = nostd::source_location::current() (src/gsl/pointers.h:122-126) and forwards it to Expects(). Upstream Microsoft GSL has no such parameter, so while its own overload resolution behaves the same way, there is nothing there to recapture. The other candidates were not_null(const not_null&) and the templated not_null(const not_null<U>&). For an rvalue or a non-const lvalue, the forwarding template binds U&& exactly while both of those need an added const, so overload resolution picked the template, and every relocation re-evaluated source_location::current() and re-ran the null check on a value already known to be non-null.

That location is printed, not merely stored: gsl::details::terminate() (src/gsl/assert.cpp:26-37) writes file_name(), line(), column() and function_name() to stderr and to debug.log before std::terminate(), in release builds as well as debug ones.

std::stack<gsl::not_null<const CBlockIndex*>> in evo/creditpool.cpp moves its element into a freshly allocated deque node, so the linux release binaries embed /usr/include/c++/13/bits/stl_construct.h and would report it as the origin of a failed precondition. Confirmed by symbolizing the only reference to that string in a release dashd: it resolves through _M_push_back_aux and _M_allocate_node to a source_location built with file stl_construct.h and line 97, followed by the null-check branch. The darwin binaries are clean only because clang drops the unreachable terminate() path that keeps the string alive; gcc does not.

This is the remaining half of #7760. That PR stopped a raw pointer being forwarded into the standard library and converted there; this stops an already-constructed not_null re-capturing when a container relocates it. Neither subsumes the other, and with only #7760 the linux binaries still carried the path.

What was done?

Excluded every not_null specialisation from the location-capturing constructor, through a details::is_not_null trait rather than is_same, so that converting between specialisations does not select it either. Relocation then binds to the copy constructor.

No move constructor is added, deliberately. Moving would leave a smart-pointer T null and break the invariant the type exists to enforce, which is why upstream omits one and why strict_not_null's own move can be defaulted - it copies the base rather than moving it, as its comment at src/gsl/pointers.h:333-335 says.

The not_null<U> to not_null<T> constructor no longer delegates, which recorded this header as the origin of any failure, but keeps its check: U is non-null, yet nothing constrains a U to T conversion to preserve that. It takes its own location so the check names the caller.

Two assertions pin the result. The first fails if the location-capturing constructor is selected for a relocation again, since as written it is not noexcept - it would not catch one that someone explicitly declared noexcept, which is a smaller hole than leaving it unguarded. The second fails if a usable move constructor is added, which the first would not catch, since a defaulted move constructor satisfies it too; a move-only T that must not be movable is what detects one.

Deliberately not addressed, to keep this to the defect: make_not_null and the strict_not_null constructors still capture locations inside pointers.h, and strict_not_null is not covered by the trait because it derives from not_null rather than being a specialisation. Neither embeds a standard library path, and strict_not_null is unused outside src/gsl/.

How Has This Been Tested?

No unit test was added. The captured location is observable only by making the precondition fail, which needs a death or subprocess test around a deliberately null pointer; that seemed a poor trade for the compile-time guarantee below. The two static_asserts are the regression test, and both were checked in each direction: they pass against this header, and fail against develop's header and against a header with a move constructor re-added.

Verified by compiling probes that use #line to attribute a relocation to a synthetic header:

  • before: moving a not_null, copying a non-const lvalue, and converting not_null<B*> to not_null<const B*> all recorded the mover's file
  • after: all three record only the genuine construction site, and a 600-element std::deque forcing node allocation embeds no standard library path at all
  • a not_null<std::shared_ptr<int>> was moved and the source then checked at runtime: still non-null, so the invariant holds

Full build plus the evo_* and llmq_* suites (228 cases) pass locally. The guix build passes on all seven hosts - x86_64-linux-gnu, riscv64, aarch64, powerpc64, x86_64-w64-mingw32 and both darwin - which is the real coverage here, since only two of those toolchains are reachable locally.

Breaking Changes

None. No consensus, P2P, RPC, wallet or on-disk behaviour changes, and no change to the class layout - the sole data member is still T. The not_null<U> to not_null<T> constructor gains a defaulted parameter, and overload selection among the inline constructors changes, so this is not source-identical for anything that named those overloads explicitly. For the copyable T used in this tree, raw pointers and shared_ptr, relocation already copied the pointer; it simply no longer re-runs a redundant check or records a location while doing so.

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

not_null's converting constructor takes a defaulted nostd::source_location,
and the only other constructor alongside it was `not_null(const not_null&)`.
For an rvalue or a non-const lvalue, the template is an exact match while the
copy constructor needs an added const, so overload resolution picked the
template: relocating a not_null re-evaluated source_location::current() at
whatever code moved it, and re-ran the Expects() null check on a value already
known to be non-null.

When the relocation happens inside a standard library header, that header's
path is what gets recorded. std::stack<gsl::not_null<const CBlockIndex*>> in
evo/creditpool.cpp moves its element into a freshly allocated deque node, so
the linux release binaries embed /usr/include/c++/13/bits/stl_construct.h and
would report it as the origin of a failed precondition. Confirmed by
symbolizing the only reference to that string in dashd, which resolves through
_M_push_back_aux and _M_allocate_node to a source_location built with file
stl_construct.h and line 97, followed by the null-check branch. The darwin
binaries are clean only because clang drops the unreachable terminate() path
that keeps the string alive; gcc does not.

Exclude every not_null specialisation from that constructor, through a trait
rather than is_same, so that converting between specialisations does not select
it either. Relocation then binds to the copy constructor.

Deliberately no move constructor is added. Moving would leave a smart-pointer T
null and break the invariant the type exists to enforce, which is why upstream
omits one and why strict_not_null's own move can be defaulted - it copies the
base rather than moving it, as its comment says.

The not_null<U> to not_null<T> constructor no longer delegates, which recorded
this header as the origin of any failure, but keeps its check: U is non-null,
yet nothing constrains a U to T conversion to preserve that. It takes its own
location so the check names the caller.

Two assertions pin the result. The first fails if the location-capturing
constructor is selected for a relocation again, since it cannot be noexcept
while it calls Expects(). The second fails if a move constructor is ever added,
which the first would not catch - a defaulted move constructor satisfies it
too, so a move-only T that must not be movable is what detects one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@UdjinM6 UdjinM6 added this to the 24 milestone Sep 30, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@coderabbitai

coderabbitai Bot commented Sep 30, 2026

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 14c4e3e1-1a43-4aa8-8924-f408e4d23d5f

📥 Commits

Reviewing files that changed from the base of the PR and between 7c15a39 and be3c436.

📒 Files selected for processing (1)
  • src/gsl/pointers.h

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.


Walkthrough

gsl::not_null now excludes other not_null specializations from its forwarding constructor. Its converting constructor accepts a source location and checks the converted pointer there. Compile-time assertions check move-construction properties for pointer, shared-pointer, and unique-pointer specializations.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: knst

Merge Risk: ⚪ Minimal · up to be3c4

The change preserves non-null checks and smart-pointer source state while correcting location attribution during relocation. No actionable merge-blocking risk is established; merge after normal checks pass.

Architecture Summary

Architecture risk: 🔵 Low · up to be3c4

The change affects 1 system.

Changed systems: src

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — src (service) was modified; 1 changed file maps to changed impact.

Before / after behavior

  • observed — Modified behavior in src/gsl/pointers.h: Adds details::is_not_null, which is false by default and true for not_null<T> specializations.
  • observed — Modified behavior in src/gsl/pointers.h: The forwarding constructor now excludes not_null specializations from its U constraint; its added comments describe avoiding constructor selection during copy, move, or conversion.
  • observed — Modified behavior in src/gsl/pointers.h: The converting constructor now takes a source-location argument and initializes from other.get() before checking the converted pointer with Expects. This replaces delegation through the ordinary constructor, which did not accept the caller’s location.
  • observed — Modified behavior in src/gsl/pointers.h: Adds compile-time checks that pointer and shared-pointer not_null types are nothrow move-constructible, while a unique-pointer not_null is not move-constructible.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 1 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preventing source_location recapture when gsl::not_null is relocated.
Description check ✅ Passed The description directly explains the relocation bug, the constructor-overload fix, the preserved non-null invariant, and the reported validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit be3c436) · triage: low

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Phase 1 + Phase 2

Verified the combined reviewer evidence against the exact head and merge-base PR diff; no actionable defects were found. Independent C++20 probes confirmed that relocation avoids redundant checks, shared-pointer sources remain non-null, and cross-specialisation conversions retain caller-attributed failure checks. The regression assertions pass at head and fail against the parent header and a move-constructor mutation; the full build and multi-platform results reported in the PR were not independently rerun.

Review provenance

Source: reviewer 1: glm-5.3-flash (agent: phase1-reviewer, role: general); reviewer 2: glm-5.3-flash (agent: phase1-reviewer, role: dash-core-commit-history); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: dash-core-commit-history); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: low by gpt-6.1-sol (effort low) — Small, self-contained change to gsl::not_null constructor overload resolution and compile-time regression checks, with no impact on consensus, funds, cryptography, networking, or storage.
  • Phase 1 reviewers: glm-5.3-flash — general (completed, effort high); agent phase1-reviewer, glm-5.3-flash — dash-core-commit-history (completed, effort high); agent phase1-reviewer
  • Phase 1 model: glm-5.3-flash — zai quota: 5h 100% left, weekly 79% left; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 13% left, 5h 100% left)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort medium); agent phase2-reviewer, gpt-6.1-sol — dash-core-commit-history (completed, effort medium); agent phase2-reviewer

@thepastaclaw thepastaclaw added the pastaclaw:approved thepastaclaw's latest review approved this PR label Sep 30, 2026
@PastaPastaPasta
PastaPastaPasta merged commit 8efb259 into dashpay:develop Sep 30, 2026
45 checks passed
@thepastaclaw thepastaclaw removed the pastaclaw:approved thepastaclaw's latest review approved this PR label Sep 30, 2026
PastaPastaPasta added a commit that referenced this pull request Sep 30, 2026
…library header path

b6b9b75 ci: run the stdlib header path check on the linux64 and mac builds (UdjinM6)
4471e14 guix: fail the build when a binary embeds a stdlib header path (UdjinM6)

Pull request description:

  ## Issue being fixed or feature implemented

  A source location captured inside a standard library header is wrong twice over: `gsl::details::terminate()` prints it, so a failed precondition names a standard library internal instead of the code that broke it, and the recorded path belongs to the toolchain, so on darwin it comes from the macOS SDK - whose location differs between builders and makes the release binaries irreproducible.

  Nothing catches that today. 966f532 introduced exactly this bug in `evo/creditpool.cpp` and it went unnoticed until guix attestation for v24.0.0-rc.1 disagreed on the macOS hashes: just over seven days after the commit landed, and only because one builder happened to keep their SDK somewhere other than the rest. Comparing hashes across builders is a poor detector. It needs a second builder, a full release build, and a difference in their setups before it fires at all, and it fires at the worst possible moment.

  ## What was done?

  A check that fires on a single build instead. `contrib/guix/stdlib-path-check.py` parses each executable with LIEF and fails if one embeds a C++ standard library header path, wired as a `check-stdlib-paths` make target beside the existing `check-symbols` and `check-security`, and invoked from the guix build.

  It also runs in regular CI, behind `RUN_STDLIB_PATH_CHECK`, enabled for the `linux64` and `mac` targets. `check-symbols` could not be run there - it caps GLIBC at 2.31 (`contrib/guix/symbol-check.py:31-38`) while a native CI build links against the container's far newer glibc, so it asserts a property of release artifacts that a native build legitimately violates - and `check-security` is simply not wired into regular CI today. This check has no such dependence: outside the instrumented builds noted below, an embedded standard library header path is wrong regardless of host or toolchain. Regular CI builds from a `make distdir` tree (`ci/dash/build_src.sh:29-37`), so the script is listed in `BIN_CHECKS`/`EXTRA_DIST`.

  Both targets matter, and neither is redundant. On libc++ the relocation form of this bug is invisible, because clang proves the moved-from pointer non-null and drops the branch that keeps the string alive; gcc does not. On libstdc++ it is visible. Conversely the original rc.1 form - a raw pointer forwarded into the standard library - is visible on both. So darwin alone cannot cover this class, and `linux64` should not be dropped on the assumption that it does.

  Only sections that hold string literals are examined: `.rodata`, `.rdata`, `__cstring`, `__const`, and anything with those prefixes, so ELF variants such as `.rodata.str1.1` are included. DWARF legitimately names standard library headers for inlined template code, and skipping debug sections by name is not portable - PE stores a long section name as an offset into the string table, so they read as `/81` rather than `.debug_*`. A first version denied those by name and drowned the mingw build in thousands of DWARF false positives. Naming the sections we want cannot pick up debug data by accident. The trade is deliberate: a literal in an unusual section would be missed, which for a gate that blocks builds is the safer direction.

  Each hit reports its section, how many times the path occurs, and the first few virtual addresses, so a real literal is distinguishable at a glance from debug data, and can be attributed to the referencing code with `objdump` without a rebuild. A file LIEF cannot parse is also a failure rather than a silent pass.

  `RUN_STDLIB_PATH_CHECK` defaults to false, and only `linux64` and `mac` opt in. That is a default rather than a hard exclusion - a target that inherited the variable as true would run the check - but no target sets it today. Sanitizer and fuzz builds are deliberately not opted in: their instrumentation records source locations for its own diagnostics and is expected to name standard library headers legitimately, so enabling it there would report instrumentation as a defect. They can be revisited once we know what they actually embed.

  ## How Has This Been Tested?

  The check found a real second bug on its first run, which is the strongest evidence for it. After #7760 the linux release binaries still embedded `bits/stl_construct.h`; that turned out to be a separate defect in `gsl::not_null`, fixed in #7772, which source review had missed.

  Against real artifacts:

  - the pre-fix guix build of 0f87636 fails on `dashd`, `dash-qt` and `test_dash` - precisely the three binaries that differed between builders on rc.1 - and passes on `dash-cli`, `dash-tx`, `dash-util` and `dash-wallet`, precisely the four that were byte-identical
  - discrimination was checked directly: an object file containing twelve standard library paths in total reports exactly one, the string literal in `__TEXT,__cstring`
  - no false positives on an ordinary local non-guix build of `dashd`, `test_dash`, `dash-qt` and `dash-cli`

  The two halves were separated across branches so each can be observed on its own, since this PR on its own is expected to fail until #7772 merges:

  - [`check-stdlib-paths`](https://github.com/UdjinM6/dash/commits/refs/heads/check-stdlib-paths/) - this PR, the check without the fix. Regular CI `linux64` and the guix libstdc++ hosts fail on `/usr/include/c++/13/bits/stl_construct.h`; `mac` and both guix darwin hosts pass. That asymmetry is the coverage point above: clang drops the branch that keeps the string alive, gcc does not.
  - [`check-stdlib-paths-with-fix`](https://github.com/UdjinM6/dash/commits/refs/heads/check-stdlib-paths-with-fix/) - this PR rebased on top of #7772, kept purely as evidence and not proposed for merge. Every guix host passes - `x86_64-linux-gnu`, `riscv64`, `aarch64`, `powerpc64`, `x86_64-w64-mingw32` and both darwin - along with `linux64` and `mac` in regular CI.

  Taken together: the check fails on a tree with the bug, passes on a tree with the fix, and produces no false positives across four object-format and standard-library combinations.

  ## Breaking Changes

  None at runtime. This adds a build-time check; a build that would have produced a binary embedding such a path now fails instead.

  ## Checklist:

  - [x] I have performed a self-review of my own code
  - [x] I have made corresponding changes to the documentation
  - [x] I have assigned this pull request to a milestone

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

Top commit has no ACKs.

Tree-SHA512: b4941bfb9627910a4152b7b836a1bc92031eeecd5e38bc1765c89dc6509f570933e681193b106461bad01afb560e5ace939c10b0dbc38785aaf8ccf7b44e2a44
PastaPastaPasta added a commit that referenced this pull request Sep 30, 2026
c41035f guix: mount the macOS SDK at a fixed path in the container (UdjinM6)

Pull request description:

  ## Issue being fixed or feature implemented

  Every container share whose value reaches the compiler is mapped to a fixed path. `contrib/guix/guix-build` shares `$PWD` as `/dash` and `DISTSRC_BASE` as `/distsrc-base` precisely so the build host's layout cannot end up in the outputs. `SDK_PATH` was the exception: it was shared with no target, so the SDK sat at the builder's own host path inside the container, and depends hands that path to the compiler as `-isysroot$(OSX_SDK)` (`depends/hosts/darwin.mk:7,74-81`).

  What still records that path is the debug output. `dsymutil` writes the include directory of every header it collected, so the darwin `*-debug.tar.gz` carries the builder's SDK path tens of thousands of times over - 37,337 occurrences measured on a current build - and two builders who keep their SDK in different places do not agree on that archive.

  This is worth being precise about, because it is easy to overstate. **It does not affect an attested artifact.** `contrib/guix/guix-attest` filters `apple-darwin-debug.tar` out of both `noncodesigned.SHA256SUMS` (`:193-199`) and `all.SHA256SUMS` (`:220-228`), and `doc/release-process.md:202-214` does not ship it. So nothing a release verifies depends on the SDK's location. This is reproducibility hygiene for a developer artifact, plus closing the one compiler-facing share that was not normalised.

  The separate and genuinely release-affecting problem - a `source_location` captured inside a standard library header, which put the SDK path into the shipped binaries as a string literal and produced the differing macOS hashes on v24.0.0-rc.1 - is fixed in #7772 and detected from now on by #7773. That defect is not what this PR addresses, and this PR is not needed to fix it.

  ## What was done?

  Resolve the SDK's parent directory host-side, with `make print-SDK_PATH`, inside the loop that already verifies the SDK exists, and mount it at a fixed `/macos-sdk`, passing `SDK_PATH=/macos-sdk` into the container.

  Resolving the path rather than testing whether `SDK_PATH` is set is what makes the default and an explicit setting converge. `contrib/containers/guix/scripts/guix-start:13` exports `SDK_PATH` as `"${WORKSPACE_PATH}/depends/SDKs"`, so the value already differed between builders who use the containerised workflow from different directories. Normalising only the explicitly-set case would have left two hash classes and fixed nothing.

  The share is made whenever `HOSTS` contains a darwin host, for every host's container in that run, which is what it did before this mapping existed - only darwin's depends reads `SDK_PATH`, so the others ignore it. A build with no darwin host now shares nothing even if `SDK_PATH` happens to be exported, which was not guaranteed before.

  `guix-clean` and the precious-directory logic keep operating on the host-side path and are unaffected. `guix-codesign` does not build depends and never consumed `SDK_PATH`.

  This diverges from Bitcoin Core, which carries the same un-remapped `${SDK_PATH:+--share="$SDK_PATH"}` line. The divergence is deliberate rather than a missed backport, and is confined to guix-build's existing SDK check and share.

  ## How Has This Been Tested?

  A local guix build with `SDK_PATH=/tmp/macOS-SDKs` and a CI guix build with no custom `SDK_PATH` produced identical output for the same commit, which is the property the change exists to provide: the two cases previously resolved to different container paths.

  Measured before the change, on a green build of the current tree: the shipped `...-arm64-apple-darwin-debug.tar.gz` contains 37,337 occurrences of the builder's SDK path, as DWARF include-directory entries. A control on the same scan found 44,348 `/dash/depends` paths, confirming the pipeline was reading the archive rather than silently returning nothing.

  The gating was exercised across five combinations - linux-only with `SDK_PATH` unset and set, mixed linux plus darwin with it unset and set, and darwin-only - confirming that both darwin cases converge on `/macos-sdk` and that a linux-only build shares nothing.

  ## Breaking Changes

  None to any shipped or attested artifact. The darwin `*-debug.tar.gz` and its per-host `SHA256SUMS.part` line change for every builder, including those using the default `SDK_PATH`, because the recorded path moves from `<workspace>/depends/SDKs/Xcode-...` to `/macos-sdk/Xcode-...`. Debug archives built before and after this change are therefore not comparable. Best landed between release cycles rather than during an active tagged RC's signing and verification.

  ## Checklist:

  - [x] I have performed a self-review of my own code
  - [x] I have made corresponding changes to the documentation
  - [x] I have assigned this pull request to a milestone

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

Top commit has no ACKs.

Tree-SHA512: f0c3111f734173b94dab13fdda15ceaef6682936db29a71939e76b2d84960912cffbb0a3052cfd5ae19855c226996def39c1a64e0c09849ac6597a62088d4046
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants