diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 992f314686..2a0b5e8e0e 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-3122de8", + "image_tag": "sha-40cdf49", "configs": { "ubuntu": [ { diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml index 8574182a7e..fe2f43fdcc 100644 --- a/.github/workflows/build-nix-images.yml +++ b/.github/workflows/build-nix-images.yml @@ -13,6 +13,7 @@ on: - "!nix/docker/README.md" - "!nix/devshell.nix" - "bin/check-tools.sh" + - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" pull_request: paths: @@ -24,6 +25,7 @@ on: - "!nix/docker/README.md" - "!nix/devshell.nix" - "bin/check-tools.sh" + - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" workflow_dispatch: diff --git a/.github/workflows/check-pr-description.yml b/.github/workflows/check-pr-description.yml index 744449f216..f8e7b6cdc4 100644 --- a/.github/workflows/check-pr-description.yml +++ b/.github/workflows/check-pr-description.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Write PR body to file env: diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml new file mode 100644 index 0000000000..af20c5f17e --- /dev/null +++ b/.github/workflows/check-tools.yml @@ -0,0 +1,114 @@ +# Verifies the committed snapshots of `bin/check-tools.sh` output for each Nix +# environment (see nix/check-tools/). If the environment changes — a new image +# tag, an updated flake.lock, a different tool list — without the matching +# snapshot being regenerated and committed, this workflow fails so the drift is +# caught in review. +# +# To regenerate the snapshots, see nix/check-tools/README.md. +name: Check tools + +on: + pull_request: + paths: + - ".github/workflows/check-tools.yml" + - ".github/scripts/strategy-matrix/linux.json" + - "bin/check-tools.sh" + - "nix/**" + - "flake.nix" + - "flake.lock" + - "rust-toolchain.toml" + push: + branches: + - "develop" + paths: + - ".github/workflows/check-tools.yml" + - ".github/scripts/strategy-matrix/linux.json" + - "bin/check-tools.sh" + - "nix/**" + - "flake.nix" + - "flake.lock" + - "rust-toolchain.toml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + # The nix-nixos image tag is pinned alongside the build matrix in linux.json, + # so snapshots are checked against the exact image CI builds against. + linux-image-tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read nix image tag + id: tag + run: echo "tag=$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" >>"${GITHUB_OUTPUT}" + + # One job for all environments; they differ only in whether the tools come + # from the nix-nixos container (Linux) or `nix develop` (macOS). + check-tools: + needs: linux-image-tag + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + snapshot: nix/check-tools/nix-ubuntu-amd64.txt + nix_develop: false + - runner: ubuntu-24.04-arm + snapshot: nix/check-tools/nix-ubuntu-arm64.txt + nix_develop: false + - runner: macos-26-apple-clang-21 + snapshot: nix/check-tools/macos.txt + nix_develop: true + runs-on: ${{ matrix.runner }} + # Linux runs inside the pinned nix-nixos image; macOS runs natively and uses + # the flake's dev shell instead (see the run step below). + container: ${{ !matrix.nix_develop && format('ghcr.io/xrplf/xrpld/nix-ubuntu:{0}', needs.linux-image-tag.outputs.tag) || null }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 + with: + enable_ccache: false + + - name: Regenerate snapshot + env: + CHECK_TOOLS_SKIP_CLONE: "1" + # check-tools.sh skips some macOS tools when CI is set; the snapshots + # capture the full `nix develop` environment, so unset it here. + CI: "" + run: | + if [ "${{ matrix.nix_develop }}" = "true" ]; then + # `nix develop` prints the dev-shell greeting first; keep only the + # check-tools.sh output (from the "Detected OS:" line onward). + nix --extra-experimental-features "nix-command flakes" develop \ + -c bash bin/check-tools.sh | sed -n '/^Detected OS:/,$p' >"${{ matrix.snapshot }}" + else + bash bin/check-tools.sh >"${{ matrix.snapshot }}" + fi + + - name: Verify snapshot is up to date + run: | + if ! git diff --exit-code -- "${{ matrix.snapshot }}"; then + echo "::error::${{ matrix.snapshot }} is out of date. Regenerate it (see nix/check-tools/README.md) and commit the result." + exit 1 + fi + + - name: Upload regenerated snapshot + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: check-tools-${{ runner.os }}-${{ runner.arch }} + path: ${{ matrix.snapshot }} diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 442a202a44..1cd97305da 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Determine changed files # This step checks whether any files have changed that should # cause the next jobs to run. We do it this way rather than @@ -90,6 +90,7 @@ jobs: .clang-tidy .codecov.yml bin/check-tools.sh + bin/default-loader-path.sh cfg/** cmake/** conan/** diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 49a93d2746..b8899cec72 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -28,6 +28,7 @@ on: - ".clang-tidy" - ".codecov.yml" - "bin/check-tools.sh" + - "bin/default-loader-path.sh" - "cfg/**" - "cmake/**" - "conan/**" diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 19c73f93d6..c1e67e2010 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,10 +41,10 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-3122de8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-40cdf49 steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 6372bb6328..74425febe8 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -110,7 +110,7 @@ jobs: uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/reusable-check-levelization.yml b/.github/workflows/reusable-check-levelization.yml index 88c95ac3ba..7f547f2ab6 100644 --- a/.github/workflows/reusable-check-levelization.yml +++ b/.github/workflows/reusable-check-levelization.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check levelization run: python .github/scripts/levelization/generate.py - name: Check for differences diff --git a/.github/workflows/reusable-check-rename.yml b/.github/workflows/reusable-check-rename.yml index 9a91e98ee3..874c8adcde 100644 --- a/.github/workflows/reusable-check-rename.yml +++ b/.github/workflows/reusable-check-rename.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check definitions run: .github/scripts/rename/definitions.sh . - name: Check copyright notices diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index e81bbea367..3c19b58a12 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -34,13 +34,13 @@ jobs: needs: [determine-files] if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }} runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: "ghcr.io/xrplf/xrpld/nix-debian:sha-3122de8" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-40cdf49" permissions: contents: read issues: write steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 55bc20dc5c..e1c11ac677 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -27,7 +27,7 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download pre-built binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/reusable-strategy-matrix.yml b/.github/workflows/reusable-strategy-matrix.yml index de8d9cfc8e..12f11b0fbe 100644 --- a/.github/workflows/reusable-strategy-matrix.yml +++ b/.github/workflows/reusable-strategy-matrix.yml @@ -23,7 +23,7 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index df4a2d9516..bce4da2df6 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,14 +40,14 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-3122de8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-40cdf49 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} CONAN_PASSWORD_XRPLF: ${{ secrets.remote_password }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate build version number id: version diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 8a02c4c2db..80a75a1fbf 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -65,7 +65,7 @@ jobs: uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/bin/check-tools.sh b/bin/check-tools.sh index 7886bcf8b0..e230302742 100755 --- a/bin/check-tools.sh +++ b/bin/check-tools.sh @@ -30,8 +30,10 @@ missing=() checked=0 # check [probe-command...] -# Runs the probe (default: " --version") quietly. Records as -# missing if the command is not found or exits non-zero. +# Runs the probe (default: " --version"), capturing both stdout and +# stderr, and prints one aligned line: the status, the name, and the first +# non-blank line of the probe output (its version). Records as missing +# if the command is not found or exits non-zero. check() { local name="$1" shift @@ -40,10 +42,11 @@ check() { probe=("${name}" --version) fi - echo "Checking ${name}..." checked=$((checked + 1)) - if "${probe[@]}" | head -n 1; then - printf ' [ ok ] %s\n' "${name}" + local output version + if output="$("${probe[@]}" 2>&1)"; then + version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" + printf ' [ ok ] %-20s %s\n' "${name}" "${version}" else printf ' [MISS] %s\n' "${name}" missing+=("${name}") @@ -85,12 +88,14 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then check file check less check make - check netstat which netstat + # net-tools netstat reports "net-tools X.Y"; macOS ships BSD netstat with no + # version flag, so fall back to a presence marker there. + check netstat sh -c 'command -v netstat >/dev/null && { netstat --version 2>&1 | grep -m1 -oE "net-tools [0-9.]+" || echo present; }' check ninja - check perl + check perl perl -e 'print "$^V\n"' check pkg-config check vim - check zip + check zip bash -c 'zip --version 2>&1 | grep -m1 -oE "Zip [0-9.]+"' # These tools are present in our Linux CI images and in local development # setups, but not in the macOS CI environment. So check them everywhere diff --git a/nix/docker/loader-path.sh b/bin/default-loader-path.sh similarity index 100% rename from nix/docker/loader-path.sh rename to bin/default-loader-path.sh diff --git a/cmake/CompilationEnv.cmake b/cmake/CompilationEnv.cmake index 8e69a4dfdd..471c43d6c6 100644 --- a/cmake/CompilationEnv.cmake +++ b/cmake/CompilationEnv.cmake @@ -29,6 +29,27 @@ if(CMAKE_GENERATOR STREQUAL "Xcode") set(is_xcode TRUE) endif() +# -------------------------------------------------------------------- +# Nix toolchain detection +# -------------------------------------------------------------------- +# True when the C++ compiler resolves into the Nix store. CMAKE_CXX_COMPILER may +# be referenced through a symlink outside the store (a Nix profile, a /usr/bin +# alternative, ...), so resolve the real path before matching. +set(is_nix_compiler FALSE) +get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH) +if(_cxx_real MATCHES "^/nix/store/") + set(is_nix_compiler TRUE) +endif() +unset(_cxx_real) + +# True inside the Nix CI Docker image, identified by the /nix/ci-env tree it +# ships (see nix/docker/Dockerfile). The dev shell and bare systems don't have +# it, so it distinguishes the CI image from other Nix-compiler environments. +set(is_ci_image FALSE) +if(EXISTS "/nix/ci-env/bin") + set(is_ci_image TRUE) +endif() + # -------------------------------------------------------------------- # Operating system detection # -------------------------------------------------------------------- diff --git a/cmake/PatchNixBinary.cmake b/cmake/PatchNixBinary.cmake index 79ca0b150c..2490416f1f 100644 --- a/cmake/PatchNixBinary.cmake +++ b/cmake/PatchNixBinary.cmake @@ -1,26 +1,37 @@ #[===================================================================[ Patch executables to run in non-Nix environments. - The Nix-based CI image links binaries against an ELF interpreter (loader) - that lives in the Nix store, so the resulting binaries don't run elsewhere - (including once installed from the .deb package). `patch_nix_binary` adds a - POST_BUILD step that resets the interpreter to the system default loader and - drops the rpath. + The Nix toolchain links binaries against an ELF interpreter (loader) + that lives in the Nix store, so the resulting binaries don't run elsewhere. + `patch_nix_binary` adds a POST_BUILD step that resets the interpreter + to the system default loader and drops the rpath. - This is only active inside the Nix-based image, detected by the presence of - /tmp/loader-path.sh (shipped by that image, resolves the default loader). It - is skipped for sanitizer builds, whose runtime libraries are resolved through - the rpath. Everywhere else `patch_nix_binary` is a no-op. + This runs by default for Nix-toolchain builds (determined by whether the compiler resolves under /nix/store/). + Those builds are where binaries get a Nix-store loader. + It is opted out of by setting the XRPLD_NO_PATCH_NIX_BINARY environment variable — + the plain Nix dev shells set it, since their binaries link a newer glibc + and must not be retargeted to the system loader. + + Non-Nix builds (a system compiler, already using the system loader) and sanitizer builds + (runtime libraries resolved through the rpath) are skipped too. + Everywhere else `patch_nix_binary` is a no-op. + + The default loader is resolved by bin/default-loader-path.sh. #]===================================================================] include_guard(GLOBAL) include(CompilationEnv) -# Provided by the Nix-based CI image; prints the system default ELF loader path. -set(_loader_path_script "/tmp/loader-path.sh") +# Resolves the system default ELF loader path for the current architecture. +set(_loader_path_script "${CMAKE_SOURCE_DIR}/bin/default-loader-path.sh") -if(is_linux AND NOT SANITIZERS_ENABLED AND EXISTS "${_loader_path_script}") +if( + is_linux + AND NOT SANITIZERS_ENABLED + AND is_nix_compiler + AND NOT DEFINED ENV{XRPLD_NO_PATCH_NIX_BINARY} +) execute_process( COMMAND "${_loader_path_script}" OUTPUT_VARIABLE DEFAULT_LOADER_PATH diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index cb4e797137..e262acf1c9 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -171,9 +171,8 @@ else() # Clang wrapper supplies those paths itself (via -nostdinc++), so at compile time the # flag is unused -> Clang errors under our -Werror. At link time the flag IS consumed # (it selects the C++ runtime), so we move it there instead of dropping it entirely. - get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH) if( - _cxx_real MATCHES "^/nix/store/" + is_nix_compiler AND is_linux AND is_clang AND CMAKE_CXX_FLAGS MATCHES "stdlib=libstdc" diff --git a/cmake/XrplSanity.cmake b/cmake/XrplSanity.cmake index a35645ad5c..ba9f7988bc 100644 --- a/cmake/XrplSanity.cmake +++ b/cmake/XrplSanity.cmake @@ -36,6 +36,19 @@ elseif(is_gcc) endif() endif() +# A Nix compiler is only meant to be used from a managed environment: the xrpld +# dev shell (which exports XRPL_DEVSHELL) or the CI image. Using one from a bare +# shell usually means a leaked toolchain (picked up via PATH or a Conan profile) +# and leads to confusing breakage, so fail early with guidance. +if(is_nix_compiler AND NOT is_ci_image AND NOT DEFINED ENV{XRPL_DEVSHELL}) + message( + FATAL_ERROR + "A Nix compiler (${CMAKE_CXX_COMPILER}) is being used outside the xrpld " + "dev shell. Enter it with `nix develop` (see docs/build/nix.md) before " + "configuring the build." + ) +endif() + # check for in-source build and fail if("${CMAKE_CURRENT_SOURCE_DIR}" STREQUAL "${CMAKE_BINARY_DIR}") message( diff --git a/docs/build/nix.md b/docs/build/nix.md index b95b82fc42..d0001294e3 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -154,6 +154,14 @@ conan install .. --output-folder . --build '*' --settings build_type=Release To update `flake.lock` to the latest revision use `nix flake update` command. +## Tooling snapshots + +The tool versions in each Nix environment are recorded in +[`nix/check-tools/`](../../nix/check-tools) and verified by CI. If you change the +environment (bump the CI image tag, update `flake.lock`, or edit the tool list in +`bin/check-tools.sh`), CI fails until you regenerate and commit the affected +snapshot — see [`nix/check-tools/README.md`](../../nix/check-tools/README.md). + ## Troubleshooting See [Troubleshooting Nix problems](./nix_troubleshooting.md) for common issues, diff --git a/nix/check-tools/README.md b/nix/check-tools/README.md new file mode 100644 index 0000000000..fe9ab3e250 --- /dev/null +++ b/nix/check-tools/README.md @@ -0,0 +1,49 @@ +# check-tools snapshots + +These files capture the output of [`bin/check-tools.sh`](../../bin/check-tools.sh) +— the versions of the development tooling — in each Nix environment: + +| File | Environment | +| --------------------- | ----------------------------------- | +| `nix-nixos-amd64.txt` | `nix-nixos` CI image, `linux/amd64` | +| `nix-nixos-arm64.txt` | `nix-nixos` CI image, `linux/arm64` | +| `macos.txt` | macOS, inside `nix develop` | + +The [`check-tools`](../../.github/workflows/check-tools.yml) workflow regenerates +each snapshot in its environment and fails if it differs from the committed file. +So if you change the environment (bump the image tag in +[`linux.json`](../../.github/scripts/strategy-matrix/linux.json), update +`flake.lock`, change the tool list in `check-tools.sh`, …) you must regenerate +and commit the affected snapshots. + +Each snapshot is `check-tools.sh` stdout with the git-clone connectivity check +skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it contains only deterministic version +data. On macOS the dev-shell greeting that `nix develop` prints first is dropped +with `sed -n '/^Detected OS:/,$p'`. + +## Regenerating + +The two Linux snapshots come from the `nix-nixos` image (Docker or a compatible +runtime such as Apple `container`). The image tag is pinned in `linux.json`: + +```bash +img="ghcr.io/xrplf/xrpld/nix-nixos:$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" + +for arch in amd64 arm64; do + container run --rm -i -e CHECK_TOOLS_SKIP_CLONE=1 -a "${arch}" --entrypoint bash "${img}" -s \ + "nix/check-tools/nix-nixos-${arch}.txt" +done +``` + +(With Docker, replace `container run … -a "${arch}"` with +`docker run … --platform "linux/${arch}"`.) + +The macOS snapshot is generated locally. `CI=` is unset so `check-tools.sh` +checks the full dev-shell tool set (it otherwise skips some tools when `CI` is +set): + +```bash +CI= nix develop -c bash -c 'CHECK_TOOLS_SKIP_CLONE=1 bash bin/check-tools.sh' | + sed -n '/^Detected OS:/,$p' \ + >nix/check-tools/macos.txt +``` diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt new file mode 100644 index 0000000000..93cc926181 --- /dev/null +++ b/nix/check-tools/macos.txt @@ -0,0 +1,47 @@ +Detected OS: macos (Darwin arm64) + +Core build tools: + [ ok ] cmake cmake version 4.1.2 + [ ok ] conan Conan version 2.28.1 + [ ok ] git git version 2.54.0 + [ ok ] python3 Python 3.13.13 + +Development tooling: + [ ok ] ccache ccache version 4.13.6 + [ ok ] clang clang version 22.1.7 + [ ok ] clang++ clang version 22.1.7 + [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 + [ ok ] curl curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + [ ok ] file file-5.47 + [ ok ] less less 692 (PCRE2 regular expressions) + [ ok ] make GNU Make 4.4.1 + [ ok ] netstat present + [ ok ] ninja 1.13.2 + [ ok ] perl v5.42.0 + [ ok ] pkg-config 0.29.2 + [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + [ ok ] zip Zip 3.0 + [ ok ] clang-format clang-format version 22.1.7 + [ ok ] dot dot - graphviz version 12.2.1 (0) + [ ok ] doxygen 1.16.1 + [ ok ] gcovr gcovr 8.4 + [ ok ] gh gh version 2.94.0 (nixpkgs) + [ ok ] git-cliff git-cliff 2.13.1 + [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) + [ ok ] gpg gpg (GnuPG) 2.4.9 + [ ok ] pre-commit pre-commit 4.5.1 + [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + +Rust toolchain: + [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) + [ ok ] cargo-audit cargo-audit-audit 0.22.1 + [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 + [ ok ] cargo-nextest cargo-nextest 0.9.137 + [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) + [ ok ] rust-analyzer rust-analyzer 1.95.0 (59807616 2026-04-14) + [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) + [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + +Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). + +All 36 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt new file mode 100644 index 0000000000..b922cca4a8 --- /dev/null +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -0,0 +1,55 @@ +Detected OS: linux (Linux x86_64) + +Core build tools: + [ ok ] cmake cmake version 4.1.2 + [ ok ] conan Conan version 2.28.1 + [ ok ] git git version 2.54.0 + [ ok ] python3 Python 3.13.13 + +Development tooling: + [ ok ] ccache ccache version 4.13.6 + [ ok ] clang clang version 22.1.7 + [ ok ] clang++ clang version 22.1.7 + [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 + [ ok ] curl curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + [ ok ] file file-5.47 + [ ok ] less less 692 (PCRE2 regular expressions) + [ ok ] make GNU Make 4.4.1 + [ ok ] netstat net-tools 2.10 + [ ok ] ninja 1.13.2 + [ ok ] perl v5.42.0 + [ ok ] pkg-config 0.29.2 + [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + [ ok ] zip Zip 3.0 + [ ok ] clang-format clang-format version 22.1.7 + [ ok ] dot dot - graphviz version 12.2.1 (0) + [ ok ] doxygen 1.16.1 + [ ok ] gcovr gcovr 8.4 + [ ok ] gh gh version 2.94.0 (nixpkgs) + [ ok ] git-cliff git-cliff 2.13.1 + [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) + [ ok ] gpg gpg (GnuPG) 2.4.9 + [ ok ] pre-commit pre-commit 4.5.1 + [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + +Rust toolchain: + [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) + [ ok ] cargo-audit cargo-audit-audit 0.22.1 + [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 + [ ok ] cargo-nextest cargo-nextest 0.9.137 + [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) + [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) + [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) + [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + +GCC toolchain: + [ ok ] gcc gcc (GCC) 15.2.0 + [ ok ] g++ g++ (GCC) 15.2.0 + [ ok ] gcov gcov (GCC) 15.2.0 + +Mold: + [ ok ] mold mold 2.41.0 (compatible with GNU ld) + +Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). + +All 40 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt new file mode 100644 index 0000000000..5267839682 --- /dev/null +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -0,0 +1,55 @@ +Detected OS: linux (Linux aarch64) + +Core build tools: + [ ok ] cmake cmake version 4.1.2 + [ ok ] conan Conan version 2.28.1 + [ ok ] git git version 2.54.0 + [ ok ] python3 Python 3.13.13 + +Development tooling: + [ ok ] ccache ccache version 4.13.6 + [ ok ] clang clang version 22.1.7 + [ ok ] clang++ clang version 22.1.7 + [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 + [ ok ] curl curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + [ ok ] file file-5.47 + [ ok ] less less 692 (PCRE2 regular expressions) + [ ok ] make GNU Make 4.4.1 + [ ok ] netstat net-tools 2.10 + [ ok ] ninja 1.13.2 + [ ok ] perl v5.42.0 + [ ok ] pkg-config 0.29.2 + [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + [ ok ] zip Zip 3.0 + [ ok ] clang-format clang-format version 22.1.7 + [ ok ] dot dot - graphviz version 12.2.1 (0) + [ ok ] doxygen 1.16.1 + [ ok ] gcovr gcovr 8.4 + [ ok ] gh gh version 2.94.0 (nixpkgs) + [ ok ] git-cliff git-cliff 2.13.1 + [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) + [ ok ] gpg gpg (GnuPG) 2.4.9 + [ ok ] pre-commit pre-commit 4.5.1 + [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + +Rust toolchain: + [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) + [ ok ] cargo-audit cargo-audit-audit 0.22.1 + [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 + [ ok ] cargo-nextest cargo-nextest 0.9.137 + [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) + [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) + [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) + [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + +GCC toolchain: + [ ok ] gcc gcc (GCC) 15.2.0 + [ ok ] g++ g++ (GCC) 15.2.0 + [ ok ] gcov gcov (GCC) 15.2.0 + +Mold: + [ ok ] mold mold 2.41.0 (compatible with GNU ld) + +Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). + +All 40 checked tools are present and runnable. diff --git a/nix/devshell.nix b/nix/devshell.nix index 9b453ddef8..cb4a99c76a 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -50,12 +50,17 @@ let # compilerName is the command used to print the version, or null for none. makeShell = { + shellName, stdenv, compilerName, version ? null, versionedTools ? [ ], extraPackages ? [ ], warningHook ? "", + # Opt out of PatchNixBinary.cmake retargeting binaries to the system + # loader. The plain toolchain links a newer glibc, so it must not be + # patched; the custom toolchain patches by default. + noPatchNixBinary ? false, }: let compilerVersionHook = @@ -73,14 +78,20 @@ let tools = versionedTools; }); in - (pkgs.mkShell.override { inherit stdenv; }) { - packages = commonPackages ++ versionedLinks ++ extraPackages; - shellHook = '' - echo "Welcome to xrpld development shell"; - ${compilerVersionHook} - ${warningHook} - ''; - }; + (pkgs.mkShell.override { inherit stdenv; }) ( + { + packages = commonPackages ++ versionedLinks ++ extraPackages; + # Marks a managed dev shell, so the build (XrplSanity.cmake) can tell an + # intentional Nix toolchain from one leaked into a bare shell. + XRPL_DEVSHELL = shellName; + shellHook = '' + echo "Welcome to xrpld development shell"; + ${compilerVersionHook} + ${warningHook} + ''; + } + // pkgs.lib.optionalAttrs noPatchNixBinary { XRPLD_NO_PATCH_NIX_BINARY = "1"; } + ); in rec { # macOS: Nix Clang. Linux: Nix GCC. @@ -89,6 +100,7 @@ rec { # gcc/clang use the custom-glibc toolchain, matching CI. On darwin there is no # custom glibc, so they fall back to the plain nixpkgs toolchain. gcc = makeShell { + shellName = "gcc"; stdenv = customGccStdenv; compilerName = "gcc"; version = gccVersion; @@ -97,6 +109,7 @@ rec { }; clang = makeShell { + shellName = "clang"; stdenv = customClangStdenv; compilerName = "clang"; version = llvmVersion; @@ -105,6 +118,7 @@ rec { # Nix provides no compiler; use the one from your system (e.g. Apple Clang). no-compiler = makeShell { + shellName = "no-compiler"; stdenv = pkgs.stdenvNoCC; compilerName = null; }; @@ -115,19 +129,23 @@ rec { # makes `nix develop .#gcc-plain` fail there rather than silently aliasing gcc. // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { gcc-plain = makeShell { + shellName = "gcc-plain"; stdenv = plainGccStdenv; compilerName = "gcc"; version = gccVersion; versionedTools = gccVersionedTools; extraPackages = [ plainGcov ]; warningHook = plainWarningHook; + noPatchNixBinary = true; }; clang-plain = makeShell { + shellName = "clang-plain"; stdenv = plainClangStdenv; compilerName = "clang"; version = llvmVersion; versionedTools = clangVersionedTools; warningHook = plainWarningHook; + noPatchNixBinary = true; }; } diff --git a/nix/docker/Dockerfile b/nix/docker/Dockerfile index 53b646ac7c..74c630cb61 100644 --- a/nix/docker/Dockerfile +++ b/nix/docker/Dockerfile @@ -58,7 +58,7 @@ ENV GIT_SSL_CAINFO="/nix/ci-env/etc/ssl/certs/ca-bundle.crt" # Externally-built dynamically-linked ELF binaries hard-code the loader path # (e.g. /lib64/ld-linux-x86-64.so.2) in their PT_INTERP header. Install it # from the Nix store when the base image doesn't already provide one. -COPY nix/docker/loader-path.sh /tmp/loader-path.sh +COPY bin/default-loader-path.sh /tmp/loader-path.sh RUN <>(); - auto future = serverBarrier->get_future(); - boost::asio::post(app().getIOContext(), [serverBarrier]() { serverBarrier->set_value(); }); - auto const status = future.wait_for(timeout); - return result && status == std::future_status::ready; + return result && drainServerIo(timeout); + } + + /** + * Disconnect the Env's built-in client and wait for the server to + * register the dropped connection. + * + * Env holds one persistent client connection to the server's RPC port for + * its whole lifetime (see client()), and that connection counts against + * the port's connection limit. Tests that need a known starting occupancy + * can call this to deterministically release that slot instead of waiting + * out the server's localhost idle timeout. + * + * The server decrements its per-port connection count in the peer's + * destructor, which runs when the io_context processes the end-of-stream + * on the closed socket. After closing the client this drains the server's + * io_context twice: the first barrier guarantees the reactor has reaped + * the closed socket and queued the peer's teardown, and the second + * guarantees that teardown (and therefore the count decrement) has run. + * + * This is only sound when the server uses a single io_context thread, so + * that draining establishes ordering against the teardown - configure the + * Env with singleThreadIo() (as syncClose() also requires). Like + * syncClose(), it relies on loopback teardown latency being negligible. + * + * @param timeout Maximum time to wait for each barrier task to execute + * @return true if both barriers executed within timeout, false otherwise + */ + [[nodiscard]] bool + disconnectClient(std::chrono::steady_clock::duration timeout = std::chrono::seconds{1}) + { + XRPL_ASSERT( + app().getNumberOfThreads() == 1, + "disconnectClient() is only useful on an application with a single " + "thread"); + + bundle_.client->disconnect(); + + // Drain the server's single io thread twice: the first barrier flushes + // the reactor's reap of the closed socket (queuing the peer teardown), + // the second flushes that teardown - and therefore the connection-count + // decrement. Both run unconditionally so a timed-out first drain does + // not short-circuit the second. + bool const reaped = drainServerIo(timeout); + bool const toreDown = drainServerIo(timeout); + return reaped && toreDown; } /** @@ -846,6 +887,25 @@ public: } private: + /** + * Drain the (single) server io_context thread once. + * + * Posts a barrier task to the server's io_context and blocks until it + * runs, so every task queued before it has been processed. Only meaningful + * with a single io thread (see syncClose()/disconnectClient()). + * + * @param timeout Maximum time to wait for the barrier task to execute + * @return true if the barrier ran within timeout, false otherwise + */ + [[nodiscard]] bool + drainServerIo(std::chrono::steady_clock::duration timeout) + { + auto barrier = std::make_shared>(); + auto future = barrier->get_future(); + boost::asio::post(app().getIOContext(), [barrier]() { barrier->set_value(); }); + return future.wait_for(timeout) == std::future_status::ready; + } + void fund(bool setDefaultRipple, STAmount const& amount, Account const& account); diff --git a/src/test/jtx/WSClient_test.cpp b/src/test/jtx/WSClient_test.cpp index d77e0f948b..801ca50504 100644 --- a/src/test/jtx/WSClient_test.cpp +++ b/src/test/jtx/WSClient_test.cpp @@ -13,8 +13,9 @@ class WSClient_test : public beast::unit_test::Suite { public: void - run() override + testSmoke() { + testcase("smoke"); using namespace jtx; Env env(*this); auto wsc = makeWSClient(env.app().config()); @@ -28,6 +29,47 @@ public: auto jv = wsc->getMsg(std::chrono::seconds(1)); pass(); } + + void + testGracefulDisconnect() + { + testcase("graceful disconnect"); + using namespace jtx; + using namespace std::chrono; + + Env env(*this); + auto wsc = makeWSClient(env.app().config()); + + // Put real traffic on the connection before closing it. + json::Value stream; + stream["streams"] = json::ValueType::Array; + stream["streams"].append("ledger"); + auto const sub = wsc->invoke("subscribe", stream); + BEAST_EXPECT(sub.isMember("result") || sub.isMember("status")); + + // disconnect() performs a graceful WebSocket closing handshake and + // blocks until the server acknowledges. On loopback that completes in + // well under its internal 1s timeout; only a broken async_close/ack + // coordination would fall through to the force-close path at ~1s. A + // generous bound keeps this from flaking under load while still + // catching that regression. + auto const start = steady_clock::now(); + wsc->disconnect(); + auto const elapsed = duration_cast(steady_clock::now() - start); + BEAST_EXPECT(elapsed < milliseconds{750}); + + // disconnect() must be idempotent: a second call (and the subsequent + // destructor) must not hang, double-close, or crash. + wsc->disconnect(); + pass(); + } + + void + run() override + { + testSmoke(); + testGracefulDisconnect(); + } }; BEAST_DEFINE_TESTSUITE(WSClient, jtx, xrpl); diff --git a/src/test/jtx/impl/JSONRPCClient.cpp b/src/test/jtx/impl/JSONRPCClient.cpp index 495fc5a657..06474c3616 100644 --- a/src/test/jtx/impl/JSONRPCClient.cpp +++ b/src/test/jtx/impl/JSONRPCClient.cpp @@ -14,18 +14,23 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -84,6 +89,40 @@ class JSONRPCClient : public AbstractClient boost::beast::multi_buffer bout_; unsigned rpcVersion_; + bool disconnected_ = false; + + // Errors that mean the persistent keep-alive connection was dropped by the + // server (rather than a genuine protocol failure), so the request can be + // safely retried on a fresh connection. + static bool + droppedConnection(boost::system::error_code const& ec) + { + namespace error = boost::asio::error; + static auto const kDroppedConnectionErrors = std::to_array({ + boost::beast::http::error::end_of_stream, + error::eof, + error::connection_reset, + error::connection_aborted, + error::broken_pipe, + error::not_connected, + }); + + return std::ranges::any_of( + kDroppedConnectionErrors, + [&ec](boost::system::error_code const& e) { return ec == e; }); + } + + // Tear down and re-establish the socket to ep_, discarding any buffered + // bytes left over from the dropped connection. + void + reconnect() + { + boost::system::error_code ec; + stream_.close(ec); + bin_.clear(); + stream_.connect(ep_); + } + public: explicit JSONRPCClient(Config const& cfg, unsigned rpcVersion) : ep_(getEndpoint(cfg)), stream_(ios_), rpcVersion_(rpcVersion) @@ -91,12 +130,10 @@ public: stream_.connect(ep_); } - /* - Return value is an Object type with up to three keys: - status - error - result - */ + // Return value is an Object type with up to three keys: + // status + // error + // result json::Value invoke(std::string const& cmd, json::Value const& params) override { @@ -104,6 +141,13 @@ public: using namespace boost::asio; using namespace std::string_literals; + // Once disconnect() has released the slot, the client must not be + // reused (see AbstractClient::disconnect). Refuse rather than let the + // failed write/read below trip the reconnect path and silently + // re-consume a connection slot, which would defeat disconnectClient(). + if (disconnected_) + Throw("JSONRPCClient::invoke called after disconnect()"); + request req; req.method(boost::beast::http::verb::post); req.target("/"); @@ -131,10 +175,29 @@ public: req.body() = to_string(jr); } req.prepare_payload(); - write(stream_, req); + // The client keeps a single keep-alive connection for its whole + // lifetime, but the server drops idle localhost connections after a few + // seconds (BaseHTTPPeer::kTimeoutSecondsLocal). If a slow gap between + // requests let the server close the socket, the write/read here fails + // with end_of_stream; reconnect and retry the request exactly once. response res; - read(stream_, bin_, res); + auto writeAndRead = [&] { + write(stream_, req); + read(stream_, bin_, res); + }; + try + { + writeAndRead(); + } + catch (boost::system::system_error const& e) + { + if (!droppedConnection(e.code())) + throw; + reconnect(); + res = {}; + writeAndRead(); + } json::Reader jr; json::Value jv; @@ -151,6 +214,19 @@ public: { return rpcVersion_; } + + void + disconnect() override + { + if (disconnected_) + return; + + disconnected_ = true; + + boost::system::error_code ec; + stream_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + stream_.close(ec); + } }; std::unique_ptr diff --git a/src/test/jtx/impl/WSClient.cpp b/src/test/jtx/impl/WSClient.cpp index ca322415fb..a8702c12d9 100644 --- a/src/test/jtx/impl/WSClient.cpp +++ b/src/test/jtx/impl/WSClient.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -112,10 +113,11 @@ class WSClientImpl : public WSClient bool peerClosed_ = false; - // synchronize destructor - bool b0_ = false; - std::mutex m0_; - std::condition_variable cv0_; + // disconnect() waits on this until the read loop ends (for any reason: + // the server acknowledged our close, or a timeout force-closed the socket). + static constexpr auto kDisconnectTimeout = std::chrono::seconds{1}; + xrpl::Mutex readEnded_; + std::condition_variable readEndCv_; // synchronize message queue std::mutex m_; @@ -127,23 +129,26 @@ class WSClientImpl : public WSClient void cleanup() { - boost::asio::post(ios_, boost::asio::bind_executor(strand_, [this] { - if (!peerClosed_) - { - ws_.async_close( - {}, boost::asio::bind_executor(strand_, [&](error_code) { - try - { - stream_.cancel(); - } - // NOLINTNEXTLINE(bugprone-empty-catch) - catch (boost::system::system_error const&) - { - // ignored - } - })); - } - })); + boost::asio::post( + ios_, // + boost::asio::bind_executor(strand_, [this] { + if (!peerClosed_) + { + ws_.async_close( + {}, // + boost::asio::bind_executor(strand_, [&](error_code) { + try + { + stream_.cancel(); + } + // NOLINTNEXTLINE(bugprone-empty-catch) + catch (boost::system::system_error const&) + { + // ignored + } + })); + } + })); work_ = std::nullopt; thread_.join(); } @@ -289,6 +294,44 @@ public: return rpcVersion_; } + void + disconnect() override + { + // Perform a graceful WebSocket closing handshake and block until the + // read loop ends, so the server observes a clean close (not a RST) and + // has finished tearing the connection down by the time we return. + // If the server already closed, the wait below returns immediately. + boost::asio::post( + ios_, + boost::asio::bind_executor( + strand_, // + [this] { + if (!peerClosed_) + { + ws_.async_close( + boost::beast::websocket::close_code::normal, + boost::asio::bind_executor(strand_, [](error_code) {})); + } + })); + + auto lock = readEnded_.lock(); + readEndCv_.wait_for(lock, kDisconnectTimeout, [&lock] { return *lock; }); + + // On timeout (server gone or not replying) force the socket closed so + // the outstanding read ends and the worker thread can later be joined. + if (!*lock) + { + boost::asio::post( + ios_, + boost::asio::bind_executor( + strand_, // + [this] { + boost::system::error_code ec; + stream_.close(ec); + })); + } + } + private: void onReadMsg(error_code const& ec) @@ -297,33 +340,31 @@ private: { if (ec == boost::beast::websocket::error::closed) peerClosed_ = true; + + *readEnded_.lock() = true; + readEndCv_.notify_all(); + return; } json::Value jv; json::Reader jr; + jr.parse(bufferString(rb_.data()), jv); rb_.consume(rb_.size()); + auto m = std::make_shared(std::move(jv)); { std::scoped_lock const lock(m_); msgs_.push_front(m); cv_.notify_all(); } + ws_.async_read( rb_, boost::asio::bind_executor(strand_, [this](error_code const& ec, std::size_t) { onReadMsg(ec); })); } - - // Called when the read op terminates - void - onReadDone() - { - std::scoped_lock const lock(m0_); - b0_ = true; - cv0_.notify_all(); - } }; std::unique_ptr diff --git a/src/test/server/ServerStatus_test.cpp b/src/test/server/ServerStatus_test.cpp index 60ea622616..5adf6a08f5 100644 --- a/src/test/server/ServerStatus_test.cpp +++ b/src/test/server/ServerStatus_test.cpp @@ -558,10 +558,12 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En using namespace test::jtx; using namespace boost::asio; using namespace boost::beast::http; - Env env{*this, envconfig([&](std::unique_ptr cfg) { + // Run the server with a single io thread so disconnectClient() below + // can deterministically drain the server's io_context (see its docs). + Env env{*this, singleThreadIo(envconfig([&](std::unique_ptr cfg) { (*cfg)[Sections::kPortRpc].set(Keys::kLimit, std::to_string(limit)); return cfg; - })}; + }))}; auto const section = env.app().config().section(Sections::kPortRpc); // NOLINTBEGIN(bugprone-unchecked-optional-access) @@ -580,16 +582,27 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En BEAST_EXPECT(!ec); std::vector> clients; - int connectionCount{1}; // starts at 1 because the Env already has one - // for JSONRPCCLient - // for nonzero limits, go one past the limit, although failures happen - // at the limit, so this really leads to the last two clients failing. - // for zero limit, pick an arbitrary nonzero number of clients - all - // should connect fine. + // Env owns a persistent JSON-RPC HTTP client connection to port_rpc as + // part of startup, which counts against this port's connection limit. + // This test wants a known starting occupancy of zero, so for nonzero + // limits it deterministically drops that hidden client and waits for + // the server to register the disconnect before opening its own clients. + // + // Starting from zero is important because the port limit rejects once + // the incremented connection count reaches the configured limit. With a + // zero baseline and N = limit + 1 test-owned clients, exactly the last + // two requests should be rejected. + if (limit != 0) + BEAST_EXPECT(env.disconnectClient()); + + // For nonzero limits, go one past the limit. The port rejects at the + // limit, not only above it, so this yields the last two clients + // failing. For zero limit, pick an arbitrary nonzero number of clients + // and expect them all to succeed. int const testTo = (limit == 0) ? 50 : limit + 1; - while (connectionCount < testTo) + while (static_cast(clients.size()) < testTo) { clients.emplace_back(ip::tcp::socket{ios}, boost::beast::multi_buffer{}); async_connect(clients.back().first, it, yield[ec]); @@ -597,19 +610,24 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En auto req = makeHTTPRequest(ip, port, to_string(jr), {}); async_write(clients.back().first, req, yield[ec]); BEAST_EXPECT(!ec); - ++connectionCount; } - int readCount = 0; + int successfulReads = 0; for (auto& [soc, buf] : clients) { boost::beast::http::response resp; async_read(soc, buf, resp, yield[ec]); - ++readCount; - // expect the reads to fail for the clients that connected at or - // above the limit. If limit is 0, all reads should succeed - BEAST_EXPECT((limit == 0 || readCount < limit - 1) ? (!ec) : bool(ec)); + if (!ec) + ++successfulReads; } + + // This test cares about the exact number of accepted requests, not which + // specific client observed the rejection. With a zero baseline (the + // hidden Env client dropped above), the server accepts until the + // connection count reaches the limit: all clients for limit 0, else + // limit - 1 of the limit + 1 clients (the last two are rejected). + int const expectedReads = (limit == 0) ? static_cast(clients.size()) : limit - 1; + BEAST_EXPECT(successfulReads == expectedReads); } void