Skip to content

libexpr: detect cycles when converting values to JSON/XML#15888

Open
dyegoaurelio wants to merge 3 commits into
NixOS:masterfrom
dyegoaurelio:infinite-recursion-json-decoding
Open

libexpr: detect cycles when converting values to JSON/XML#15888
dyegoaurelio wants to merge 3 commits into
NixOS:masterfrom
dyegoaurelio:infinite-recursion-json-decoding

Conversation

@dyegoaurelio
Copy link
Copy Markdown

Motivation

Serializing a self-referential value such as let a = { b = a; }; in a was
broken in two ways depending on the target:

  • builtins.toJSON and nix eval --json failed with an opaque
    stack overflow; max-call-depth exceeded error.
  • nix-instantiate --xml streamed thousands of nested <attrs>/<list>
    elements to stdout

Context

Fixes #12289.


Add 👍 to pull requests you find important.

The Nix maintainer team uses a GitHub project board to schedule and track reviews.

@dyegoaurelio dyegoaurelio requested a review from edolstra as a code owner May 20, 2026 04:35
@github-actions github-actions Bot added documentation with-tests Issues related to testing. PRs with tests have some priority labels May 20, 2026
Comment thread src/libexpr/value-to-json.cc Outdated
}
const void * key = v.attrs();
if (!seen.insert(key).second)
state.error<InfiniteRecursionError>("infinite recursion encountered while converting value to JSON")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could add a bit more detail referring to the cyclic nature.
(and then do the same for nList)

Suggested change
state.error<InfiniteRecursionError>("infinite recursion encountered while converting value to JSON")
state.error<InfiniteRecursionError>("infinite recursion encountered while converting Nix value to JSON, because only cycle-free data can be represented")

Comment thread tests/functional/lang/eval-fail-toJSON-mutual-recursion.err.exp Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't know if we want to change anything about the XML dump.
I am unaware of its use cases, so the change looks potentially breaking to me.
@xokdvium @edolstra wdyt?

I'd say omit the XML change for now, maybe put it on a branch, unless you use it yourself.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm ok removing it from this PR and I don't use this feature myself.

But IMO the current behavior is much worse on the XML dump than on the json side.

nix-instantiate --xml --eval --strict --expr 'let a = { b = a; }; in a' just starts an infinite loop

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Failing eagerly would make more sense. Changing a diverging computation -> early error is perfectly safe.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

diverging computation -> early error is perfectly safe.

I think I agree actually. The old behavior was not usable, so compatibility is not much of a concern.

@dyegoaurelio dyegoaurelio force-pushed the infinite-recursion-json-decoding branch 2 times, most recently from 120c51d to f4b2fbc Compare May 21, 2026 17:24
@dyegoaurelio dyegoaurelio requested review from roberth and xokdvium May 21, 2026 17:54
Comment on lines +88 to +113
const void * key = v.attrs();
auto [it, fresh] = seen.try_emplace(key, currentPath ? *currentPath : ValuePath{});
if (!fresh)
cycleError(key, it->second);
Finally cleanup([&] { seen.erase(key); });
if (auto i = v.attrs()->get(state.s.outPath)) {
if (currentPath)
currentPath->emplace_back(state.s.outPath);
Finally popOutPath([&] {
if (currentPath)
currentPath->pop_back();
});
return valueToJSON(state, strict, *i->value, i->pos, context, copyToStore, seen, currentPath);
} else {
out = json::object();
for (auto & a : v.attrs()->lexicographicOrder(state.symbols)) {
if (currentPath)
currentPath->emplace_back(a->name);
Finally popSegment([&] {
if (currentPath)
currentPath->pop_back();
});
try {
out.emplace(
state.symbols[a->name],
printValueAsJSON(state, strict, *a->value, a->pos, context, copyToStore));
valueToJSON(state, strict, *a->value, a->pos, context, copyToStore, seen, currentPath));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This does quite a bit more work in the happy (non-error) path. Does this measurably affect performance?

Copy link
Copy Markdown
Author

@dyegoaurelio dyegoaurelio May 23, 2026

Choose a reason for hiding this comment

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

I've just ran some benchs and it did add a 2-4% slowdown on ""realistic"" inputs and ~30% slowdown on deeply nested arrays/attrsets ([ [ [ [ [ 0 ] ] ] ] ])

Bench results
builddir (915772aa5) ✗ ./src/libexpr-tests/nix-expr-benchmarks  --benchmark_filter='BM_PrintValueAsJSON_.*' --benchmark_repetitions=10 --benchmark_report_aggregates_only=true
2026-05-23T14:15:19-03:00
Running ./src/libexpr-tests/nix-expr-benchmarks
Run on (12 X 5407.65 MHz CPU s)
CPU Caches:
  L1 Data 48 KiB (x6)
  L1 Instruction 32 KiB (x6)
  L2 Unified 1024 KiB (x6)
  L3 Unified 32768 KiB (x1)
Load Average: 1.24, 2.25, 2.94
--------------------------------------------------------------------------------------------------------
Benchmark                                              Time             CPU   Iterations UserCounters...
--------------------------------------------------------------------------------------------------------
BM_PrintValueAsJSON_DeepAttrs/100_mean             10355 ns        10346 ns           10 items_per_second=9.66602M/s
BM_PrintValueAsJSON_DeepAttrs/100_median           10358 ns        10348 ns           10 items_per_second=9.66331M/s
BM_PrintValueAsJSON_DeepAttrs/100_stddev            19.2 ns         19.5 ns           10 items_per_second=18.228k/s
BM_PrintValueAsJSON_DeepAttrs/100_cv                0.19 %          0.19 %            10 items_per_second=0.19%
BM_PrintValueAsJSON_DeepAttrs/1000_mean           107731 ns       107596 ns           10 items_per_second=9.2942M/s
BM_PrintValueAsJSON_DeepAttrs/1000_median         107626 ns       107499 ns           10 items_per_second=9.30239M/s
BM_PrintValueAsJSON_DeepAttrs/1000_stddev            433 ns          425 ns           10 items_per_second=36.6456k/s
BM_PrintValueAsJSON_DeepAttrs/1000_cv               0.40 %          0.39 %            10 items_per_second=0.39%
BM_PrintValueAsJSON_DeepAttrs/5000_mean           565658 ns       564697 ns           10 items_per_second=8.85452M/s
BM_PrintValueAsJSON_DeepAttrs/5000_median         565417 ns       564423 ns           10 items_per_second=8.8586M/s
BM_PrintValueAsJSON_DeepAttrs/5000_stddev           2890 ns         2969 ns           10 items_per_second=46.4683k/s
BM_PrintValueAsJSON_DeepAttrs/5000_cv               0.51 %          0.53 %            10 items_per_second=0.52%
BM_PrintValueAsJSON_DeepLists/100_mean              7726 ns         7717 ns           10 items_per_second=12.9599M/s
BM_PrintValueAsJSON_DeepLists/100_median            7711 ns         7700 ns           10 items_per_second=12.9864M/s
BM_PrintValueAsJSON_DeepLists/100_stddev            63.1 ns         63.3 ns           10 items_per_second=105.394k/s
BM_PrintValueAsJSON_DeepLists/100_cv                0.82 %          0.82 %            10 items_per_second=0.81%
BM_PrintValueAsJSON_DeepLists/1000_mean            81499 ns        81415 ns           10 items_per_second=12.2829M/s
BM_PrintValueAsJSON_DeepLists/1000_median          81429 ns        81350 ns           10 items_per_second=12.2926M/s
BM_PrintValueAsJSON_DeepLists/1000_stddev            154 ns          149 ns           10 items_per_second=22.3901k/s
BM_PrintValueAsJSON_DeepLists/1000_cv               0.19 %          0.18 %            10 items_per_second=0.18%
BM_PrintValueAsJSON_DeepLists/5000_mean           468968 ns       468349 ns           10 items_per_second=10.6762M/s
BM_PrintValueAsJSON_DeepLists/5000_median         468764 ns       468179 ns           10 items_per_second=10.6797M/s
BM_PrintValueAsJSON_DeepLists/5000_stddev           2934 ns         2935 ns           10 items_per_second=66.8169k/s
BM_PrintValueAsJSON_DeepLists/5000_cv               0.63 %          0.63 %            10 items_per_second=0.63%
BM_PrintValueAsJSON_BalancedAttrs/4/6_mean        505421 ns       504739 ns           10 items_per_second=10.8198M/s
BM_PrintValueAsJSON_BalancedAttrs/4/6_median      503841 ns       503196 ns           10 items_per_second=10.8526M/s
BM_PrintValueAsJSON_BalancedAttrs/4/6_stddev        3245 ns         3200 ns           10 items_per_second=68.3479k/s
BM_PrintValueAsJSON_BalancedAttrs/4/6_cv            0.64 %          0.63 %            10 items_per_second=0.63%
BM_PrintValueAsJSON_BalancedAttrs/8/4_mean        463897 ns       463382 ns           10 items_per_second=10.102M/s
BM_PrintValueAsJSON_BalancedAttrs/8/4_median      463920 ns       463355 ns           10 items_per_second=10.1024M/s
BM_PrintValueAsJSON_BalancedAttrs/8/4_stddev        2314 ns         2287 ns           10 items_per_second=49.4949k/s
BM_PrintValueAsJSON_BalancedAttrs/8/4_cv            0.50 %          0.49 %            10 items_per_second=0.49%
BM_PrintValueAsJSON_BalancedAttrs/16/3_mean       473908 ns       473439 ns           10 items_per_second=9.22856M/s
BM_PrintValueAsJSON_BalancedAttrs/16/3_median     473676 ns       473227 ns           10 items_per_second=9.23237M/s
BM_PrintValueAsJSON_BalancedAttrs/16/3_stddev       3089 ns         3051 ns           10 items_per_second=59.2301k/s
BM_PrintValueAsJSON_BalancedAttrs/16/3_cv           0.65 %          0.64 %            10 items_per_second=0.64%
BM_PrintValueAsJSON_PackageSet/1000_mean          260759 ns       260438 ns           10 items_per_second=3.83973M/s
BM_PrintValueAsJSON_PackageSet/1000_median        260360 ns       260038 ns           10 items_per_second=3.8456M/s
BM_PrintValueAsJSON_PackageSet/1000_stddev           851 ns          838 ns           10 items_per_second=12.3212k/s
BM_PrintValueAsJSON_PackageSet/1000_cv              0.33 %          0.32 %            10 items_per_second=0.32%
BM_PrintValueAsJSON_PackageSet/5000_mean         1552765 ns      1550252 ns           10 items_per_second=3.22592M/s
BM_PrintValueAsJSON_PackageSet/5000_median       1547737 ns      1545242 ns           10 items_per_second=3.23574M/s
BM_PrintValueAsJSON_PackageSet/5000_stddev         23391 ns        23080 ns           10 items_per_second=47.3982k/s
BM_PrintValueAsJSON_PackageSet/5000_cv              1.51 %          1.49 %            10 items_per_second=1.47%
BM_PrintValueAsJSON_PackageSet/10000_mean        3297638 ns      3292583 ns           10 items_per_second=3.03736M/s
BM_PrintValueAsJSON_PackageSet/10000_median      3282901 ns      3277862 ns           10 items_per_second=3.05077M/s
BM_PrintValueAsJSON_PackageSet/10000_stddev        30951 ns        30691 ns           10 items_per_second=28.1163k/s
BM_PrintValueAsJSON_PackageSet/10000_cv             0.94 %          0.93 %            10 items_per_second=0.93%builddir (infinite-recursion-json-decoding) ✗ ./src/libexpr-tests/nix-expr-benchmarks  --benchmark_filter='BM_PrintValueAsJSON_.*' --benchmark_repetitions=10 --benchmark_report_aggregates_only=true
2026-05-23T14:17:28-03:00
Running ./src/libexpr-tests/nix-expr-benchmarks
Run on (12 X 5345.19 MHz CPU s)
CPU Caches:
  L1 Data 48 KiB (x6)
  L1 Instruction 32 KiB (x6)
  L2 Unified 1024 KiB (x6)
  L3 Unified 32768 KiB (x1)
Load Average: 1.59, 2.07, 2.78
--------------------------------------------------------------------------------------------------------
Benchmark                                              Time             CPU   Iterations UserCounters...
--------------------------------------------------------------------------------------------------------
BM_PrintValueAsJSON_DeepAttrs/100_mean             13692 ns        13669 ns           10 items_per_second=7.31601M/s
BM_PrintValueAsJSON_DeepAttrs/100_median           13730 ns        13707 ns           10 items_per_second=7.29581M/s
BM_PrintValueAsJSON_DeepAttrs/100_stddev            92.7 ns         92.4 ns           10 items_per_second=49.8798k/s
BM_PrintValueAsJSON_DeepAttrs/100_cv                0.68 %          0.68 %            10 items_per_second=0.68%
BM_PrintValueAsJSON_DeepAttrs/1000_mean           145453 ns       145191 ns           10 items_per_second=6.88756M/s
BM_PrintValueAsJSON_DeepAttrs/1000_median         145331 ns       145073 ns           10 items_per_second=6.89307M/s
BM_PrintValueAsJSON_DeepAttrs/1000_stddev            516 ns          503 ns           10 items_per_second=23.7781k/s
BM_PrintValueAsJSON_DeepAttrs/1000_cv               0.35 %          0.35 %            10 items_per_second=0.35%
BM_PrintValueAsJSON_DeepAttrs/5000_mean           751088 ns       749646 ns           10 items_per_second=6.67011M/s
BM_PrintValueAsJSON_DeepAttrs/5000_median         750557 ns       749179 ns           10 items_per_second=6.67398M/s
BM_PrintValueAsJSON_DeepAttrs/5000_stddev           5454 ns         5300 ns           10 items_per_second=46.6692k/s
BM_PrintValueAsJSON_DeepAttrs/5000_cv               0.73 %          0.71 %            10 items_per_second=0.70%
BM_PrintValueAsJSON_DeepLists/100_mean              9832 ns         9819 ns           10 items_per_second=10.185M/s
BM_PrintValueAsJSON_DeepLists/100_median            9775 ns         9763 ns           10 items_per_second=10.2431M/s
BM_PrintValueAsJSON_DeepLists/100_stddev            98.4 ns         97.0 ns           10 items_per_second=100.158k/s
BM_PrintValueAsJSON_DeepLists/100_cv                1.00 %          0.99 %            10 items_per_second=0.98%
BM_PrintValueAsJSON_DeepLists/1000_mean           108991 ns       108830 ns           10 items_per_second=9.18864M/s
BM_PrintValueAsJSON_DeepLists/1000_median         109030 ns       108864 ns           10 items_per_second=9.18579M/s
BM_PrintValueAsJSON_DeepLists/1000_stddev            194 ns          205 ns           10 items_per_second=17.2999k/s
BM_PrintValueAsJSON_DeepLists/1000_cv               0.18 %          0.19 %            10 items_per_second=0.19%
BM_PrintValueAsJSON_DeepLists/5000_mean           545378 ns       544444 ns           10 items_per_second=9.18371M/s
BM_PrintValueAsJSON_DeepLists/5000_median         545477 ns       544448 ns           10 items_per_second=9.18361M/s
BM_PrintValueAsJSON_DeepLists/5000_stddev            860 ns          866 ns           10 items_per_second=14.6199k/s
BM_PrintValueAsJSON_DeepLists/5000_cv               0.16 %          0.16 %            10 items_per_second=0.16%
BM_PrintValueAsJSON_BalancedAttrs/4/6_mean        520316 ns       519477 ns           10 items_per_second=10.5127M/s
BM_PrintValueAsJSON_BalancedAttrs/4/6_median      520534 ns       519572 ns           10 items_per_second=10.5106M/s
BM_PrintValueAsJSON_BalancedAttrs/4/6_stddev        2453 ns         2364 ns           10 items_per_second=47.774k/s
BM_PrintValueAsJSON_BalancedAttrs/4/6_cv            0.47 %          0.46 %            10 items_per_second=0.45%
BM_PrintValueAsJSON_BalancedAttrs/8/4_mean        484706 ns       483517 ns           10 items_per_second=9.68868M/s
BM_PrintValueAsJSON_BalancedAttrs/8/4_median      478137 ns       477374 ns           10 items_per_second=9.80589M/s
BM_PrintValueAsJSON_BalancedAttrs/8/4_stddev       14991 ns        14346 ns           10 items_per_second=282.474k/s
BM_PrintValueAsJSON_BalancedAttrs/8/4_cv            3.09 %          2.97 %            10 items_per_second=2.92%
BM_PrintValueAsJSON_BalancedAttrs/16/3_mean       486369 ns       485096 ns           10 items_per_second=9.00762M/s
BM_PrintValueAsJSON_BalancedAttrs/16/3_median     485174 ns       483827 ns           10 items_per_second=9.03011M/s
BM_PrintValueAsJSON_BalancedAttrs/16/3_stddev       5781 ns         5795 ns           10 items_per_second=107.483k/s
BM_PrintValueAsJSON_BalancedAttrs/16/3_cv           1.19 %          1.19 %            10 items_per_second=1.19%
BM_PrintValueAsJSON_PackageSet/1000_mean          271135 ns       270743 ns           10 items_per_second=3.69361M/s
BM_PrintValueAsJSON_PackageSet/1000_median        270766 ns       270355 ns           10 items_per_second=3.69884M/s
BM_PrintValueAsJSON_PackageSet/1000_stddev          1343 ns         1299 ns           10 items_per_second=17.6638k/s
BM_PrintValueAsJSON_PackageSet/1000_cv              0.50 %          0.48 %            10 items_per_second=0.48%
BM_PrintValueAsJSON_PackageSet/5000_mean         1589454 ns      1586677 ns           10 items_per_second=3.15127M/s
BM_PrintValueAsJSON_PackageSet/5000_median       1589694 ns      1586950 ns           10 items_per_second=3.1507M/s
BM_PrintValueAsJSON_PackageSet/5000_stddev          5485 ns         5455 ns           10 items_per_second=10.8287k/s
BM_PrintValueAsJSON_PackageSet/5000_cv              0.35 %          0.34 %            10 items_per_second=0.34%
BM_PrintValueAsJSON_PackageSet/10000_mean        3365166 ns      3359508 ns           10 items_per_second=2.97668M/s
BM_PrintValueAsJSON_PackageSet/10000_median      3369174 ns      3362968 ns           10 items_per_second=2.97356M/s
BM_PrintValueAsJSON_PackageSet/10000_stddev        15649 ns        15460 ns           10 items_per_second=13.7507k/s
BM_PrintValueAsJSON_PackageSet/10000_cv             0.47 %          0.46 %            10 items_per_second=0.46%
Bench code
commit 25643e9e4741cc0fb073fa398aaf8e9499833914
Author: Dyego Aurélio <dyegoaurelio@gmail.com>
Date:   Sat May 23 13:31:07 2026 -0300

    libexpr-tests: add value-to-json benchmark
    
    Covers four value shapes:
    
      DeepAttrs    — linear chain of single-attr attrsets at depth 100/1k/5k
      DeepLists    — linear chain of single-element lists at depth 100/1k/5k
      BalancedAttrs — balanced attrset tree at (breadth, depth) = (4,6),
                      (8,4), (16,3); each yields ~5k total nodes
      PackageSet   — wide top-level attrset of derivation-like entries with
                      outPath shortcut, at 1k/5k/10k entries; mimics the
                      shape of an evaluated hackage-packages.nix

diff --git a/src/libexpr-tests/meson.build b/src/libexpr-tests/meson.build
index 50d158209..55ed20527 100644
--- a/src/libexpr-tests/meson.build
+++ b/src/libexpr-tests/meson.build
@@ -100,6 +100,7 @@ if get_option('benchmarks')
     'dynamic-attrs-bench.cc',
     'get-drvs-bench.cc',
     'regex-cache-bench.cc',
+    'value-to-json-bench.cc',
   )
 
   benchmark_exe = executable(
diff --git a/src/libexpr-tests/value-to-json-bench.cc b/src/libexpr-tests/value-to-json-bench.cc
new file mode 100644
index 000000000..acb064ec8
--- /dev/null
+++ b/src/libexpr-tests/value-to-json-bench.cc
@@ -0,0 +1,196 @@
+#include <benchmark/benchmark.h>
+#include <nlohmann/json.hpp>
+
+#include "nix/expr/value-to-json.hh"
+#include "nix/expr/eval.hh"
+#include "nix/expr/eval-settings.hh"
+#include "nix/fetchers/fetch-settings.hh"
+#include "nix/store/store-open.hh"
+#include "nix/util/fmt.hh"
+
+namespace nix {
+
+static std::shared_ptr<EvalState> makeEvalState()
+{
+    static auto store = openStore("dummy://");
+    static fetchers::Settings fetchSettings;
+    static bool readOnlyMode = true;
+    static EvalSettings evalSettings = [] {
+        EvalSettings s{readOnlyMode};
+        s.nixPath = {};
+        return s;
+    }();
+    return std::make_shared<EvalState>(LookupPath{}, store, fetchSettings, evalSettings, nullptr);
+}
+
+// Build a linear chain of attrs: { a = { a = { ... { a = 0; } ... } } } of depth `depth`.
+static void buildDeepAttrs(EvalState & state, Value & root, size_t depth)
+{
+    Value * cur = &root;
+    auto sym = state.symbols.create("a");
+    for (size_t i = 0; i < depth; ++i) {
+        auto b = state.buildBindings(1);
+        auto & child = b.alloc(sym);
+        cur->mkAttrs(b.finish());
+        cur = &child;
+    }
+    cur->mkInt(0);
+}
+
+// Build a linear chain of lists: [[[ ... [ 0 ] ... ]]] of depth `depth`.
+static void buildDeepLists(EvalState & state, Value & root, size_t depth)
+{
+    Value * cur = &root;
+    for (size_t i = 0; i < depth; ++i) {
+        auto lb = state.buildList(1);
+        auto * child = state.allocValue();
+        lb[0] = child;
+        cur->mkList(lb);
+        cur = child;
+    }
+    cur->mkInt(0);
+}
+
+// Build a balanced attrset tree: each node has `names.size()` attrs, recursing `depth` levels.
+// Total node count = breadth^depth.
+static void buildBalancedAttrs(EvalState & state, Value & v, const std::vector<std::string> & names, size_t depth)
+{
+    if (depth == 0) {
+        v.mkInt(0);
+        return;
+    }
+    auto b = state.buildBindings(names.size());
+    for (auto & n : names) {
+        auto & child = b.alloc(state.symbols.create(n));
+        buildBalancedAttrs(state, child, names, depth - 1);
+    }
+    v.mkAttrs(b.finish());
+}
+
+static void BM_PrintValueAsJSON_DeepAttrs(benchmark::State & bstate)
+{
+    auto statePtr = makeEvalState();
+    auto & state = *statePtr;
+    const auto depth = static_cast<size_t>(bstate.range(0));
+    Value root;
+    buildDeepAttrs(state, root, depth);
+
+    NixStringContext context;
+    for (auto _ : bstate) {
+        auto j = printValueAsJSON(state, /*strict=*/true, root, noPos, context, /*copyToStore=*/false);
+        benchmark::DoNotOptimize(j);
+    }
+    bstate.SetItemsProcessed(bstate.iterations() * depth);
+}
+
+BENCHMARK(BM_PrintValueAsJSON_DeepAttrs)->Arg(100)->Arg(1'000)->Arg(5'000);
+
+static void BM_PrintValueAsJSON_DeepLists(benchmark::State & bstate)
+{
+    auto statePtr = makeEvalState();
+    auto & state = *statePtr;
+    const auto depth = static_cast<size_t>(bstate.range(0));
+    Value root;
+    buildDeepLists(state, root, depth);
+
+    NixStringContext context;
+    for (auto _ : bstate) {
+        auto j = printValueAsJSON(state, /*strict=*/true, root, noPos, context, /*copyToStore=*/false);
+        benchmark::DoNotOptimize(j);
+    }
+    bstate.SetItemsProcessed(bstate.iterations() * depth);
+}
+
+BENCHMARK(BM_PrintValueAsJSON_DeepLists)->Arg(100)->Arg(1'000)->Arg(5'000);
+
+static void BM_PrintValueAsJSON_BalancedAttrs(benchmark::State & bstate)
+{
+    auto statePtr = makeEvalState();
+    auto & state = *statePtr;
+    const auto breadth = static_cast<size_t>(bstate.range(0));
+    const auto depth = static_cast<size_t>(bstate.range(1));
+
+    std::vector<std::string> names;
+    names.reserve(breadth);
+    for (size_t i = 0; i < breadth; ++i)
+        names.push_back(fmt("k%|1$04d|", i));
+
+    Value root;
+    buildBalancedAttrs(state, root, names, depth);
+
+    size_t totalNodes = 1;
+    size_t p = 1;
+    for (size_t i = 0; i < depth; ++i) {
+        p *= breadth;
+        totalNodes += p;
+    }
+
+    NixStringContext context;
+    for (auto _ : bstate) {
+        auto j = printValueAsJSON(state, /*strict=*/true, root, noPos, context, /*copyToStore=*/false);
+        benchmark::DoNotOptimize(j);
+    }
+    bstate.SetItemsProcessed(bstate.iterations() * totalNodes);
+}
+
+BENCHMARK(BM_PrintValueAsJSON_BalancedAttrs)
+    ->ArgPair(4, 6)   // ~5k nodes
+    ->ArgPair(8, 4)   // ~4.7k nodes
+    ->ArgPair(16, 3); // ~4.4k nodes
+
+// Build a "package set" shape: a wide top-level attrset where every entry is
+// a small derivation-like attrset (pname, version, description, license,
+// broken, outPath). The outPath attr triggers the JSON serializer's shortcut
+// to emit only the store path. Mimics the shape of nixpkgs/.../hackage-packages.nix
+// after evaluation.
+static void buildPackageSet(EvalState & state, Value & root, size_t numPackages)
+{
+    auto pnameSym = state.symbols.create("pname");
+    auto versionSym = state.symbols.create("version");
+    auto descSym = state.symbols.create("description");
+    auto licenseSym = state.symbols.create("license");
+    auto brokenSym = state.symbols.create("broken");
+    auto outPathSym = state.s.outPath;
+
+    auto topB = state.buildBindings(numPackages);
+    for (size_t i = 0; i < numPackages; ++i) {
+        auto pkgB = state.buildBindings(6);
+
+        auto & op = pkgB.alloc(outPathSym);
+        op.mkString(fmt("/nix/store/000000000000000000000000000000000-pkg-%|1$06d|-1.2.3", i), state.mem);
+        auto & pn = pkgB.alloc(pnameSym);
+        pn.mkString(fmt("pkg-%|1$06d|", i), state.mem);
+        auto & ver = pkgB.alloc(versionSym);
+        ver.mkString("1.2.3", state.mem);
+        auto & d = pkgB.alloc(descSym);
+        d.mkString("A benchmark package", state.mem);
+        auto & l = pkgB.alloc(licenseSym);
+        l.mkString("BSD3", state.mem);
+        auto & b = pkgB.alloc(brokenSym);
+        b.mkBool(false);
+
+        auto & pkg = topB.alloc(state.symbols.create(fmt("pkg-%|1$06d|", i)));
+        pkg.mkAttrs(pkgB.finish());
+    }
+    root.mkAttrs(topB.finish());
+}
+
+static void BM_PrintValueAsJSON_PackageSet(benchmark::State & bstate)
+{
+    auto statePtr = makeEvalState();
+    auto & state = *statePtr;
+    const auto numPackages = static_cast<size_t>(bstate.range(0));
+    Value root;
+    buildPackageSet(state, root, numPackages);
+
+    NixStringContext context;
+    for (auto _ : bstate) {
+        auto j = printValueAsJSON(state, /*strict=*/true, root, noPos, context, /*copyToStore=*/false);
+        benchmark::DoNotOptimize(j);
+    }
+    bstate.SetItemsProcessed(bstate.iterations() * numPackages);
+}
+
+BENCHMARK(BM_PrintValueAsJSON_PackageSet)->Arg(1'000)->Arg(5'000)->Arg(10'000);
+
+} // namespace nix

@dyegoaurelio dyegoaurelio force-pushed the infinite-recursion-json-decoding branch from f4b2fbc to e74168f Compare May 23, 2026 17:22
Previously, `builtins.toJSON` on a self-referential value (e.g.
`let a = { b = a; }; in a`) crashed the evaluator with a stack
overflow because `printValueAsJSON` recursed unboundedly.

Track visited Bindings (for attrsets) and Value pointers (for lists)
in a scoped seen-set; on a collision, throw `InfiniteRecursionError`.
To name both ends of the cycle in the error (e.g. `the value at
.branch.twig is the same as the value at .branch`), the walk runs
twice: recording a path for every node is overhead that's wasted
on cycle-free values, so the first pass uses a minimal seen-set and
throws an internal sentinel on collision, and only the re-run
records paths to build the rich message.
Mirrors the cycle detection added for `printValueAsJSON`. The XML
walker already handled cycles for derivations (via `drvsSeen` keyed
on `drvPath`) but not for arbitrary attrsets or lists, which could
loop indefinitely on self-referential values.

Throws `InfiniteRecursionError` on collision, naming both ends of
the cycle (e.g. `the value at .branch.twig is the same as the value
at .branch`) via the same two-pass walk used by `printValueAsJSON`:
the fast pass streams XML to the user's `ostream` and throws an
internal sentinel on collision; the wrapper then re-runs against a
discarded `XMLWriter` with path tracking to build the rich message.
@dyegoaurelio dyegoaurelio force-pushed the infinite-recursion-json-decoding branch from e74168f to 058b490 Compare May 23, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation with-tests Issues related to testing. PRs with tests have some priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Segfault when displaying a self-referencing attrset as JSON

3 participants