Skip to content

Allocation-failure call sites still on zend_error_noreturn(E_ERROR) on master (zend_strtod.c "Balloc() failed", zend_multiply.h) — the EOL-7.4 minimal mitigation hardened these, master did not #24563

Open

Description

@srayuth089

Context

While studying the fixes for GHSA-w8hw-w9fx-j79w / GHSA-6wmf-4rv5-p3rp (thank you for the
delayed-OOM machinery in 3584a98 — the design is excellent), we noticed a consistency
gap between the two fix branches:

  • The EOL-7.4 minimal mitigation 712e60f converted several allocation-failure call
    sites to the fail-fast zend_mm_safe_error_ex() path.
  • The full 8.x fix and current master kept several such call sites on
    zend_error_noreturn(E_ERROR, ...), i.e. they still run the error machinery and bail
    out via longjmp from the failing context — the exact pattern the advisory describes as
    dangerous when the allocator is in a failing state.

This is an informational hardening-consistency observation, not a vulnerability report —
see the empirical section: we could not reach any of these paths from PHP userland under
the conditions we tested.

Call sites in question (master, checked 2026-10-22)

Location Code Note
Zend/zend_strtod.c ~590 / ~603 zend_error_noreturn(E_ERROR, "Balloc() failed to allocate memory") when MALLOC() (raw malloc, #define MALLOC malloc at ~237) returns NULL dtoa allocates Bigints outside Zend MM entirely (also unaccounted by memory_limit)
Zend/zend_multiply.h ~343 / ~355 zend_error_noreturn(E_ERROR, "Possible integer overflow ...") size-arithmetic checks; 7.4 converted both
ext/sodium/libsodium.c ~42 zend_error_noreturn(E_ERROR, "Memory allocation too large ...") untouched in both branches

Empirical work (why we rate this informational, not exploitable)

We tried hard to reach the Balloc() path from userland before writing this up.
PHP 8.4.27, arm64 (docker), instrumentation at three levels — all evidence below is
reproducible and we are happy to share the tooling (a ~30-line LD_PRELOAD malloc
interposer, RLIMIT_AS sweep harness, phase-marker snippets):

  1. Phase markers (STDERR markers around string-build vs conversion): across an
    RLIMIT_AS sweep at 1 KB granularity, every allocation failure occurred during
    string construction (Zend MM chunk mmap), before zend_strtod was ever entered.
    FAIL_IN_CONVERSION count: 0. The transition was a cliff, not a window
    (67247 KB → fail in build, 67248 KB → full success).
  2. strace (mmap/brk/mremap bracketed by the same markers): 0 memory-mapping
    syscalls during the conversion phase, both with comfortable VA and at the boundary.
  3. LD_PRELOAD malloc interposer (libc-call level, which strace cannot see):
    conversion performs a constant 11 malloc calls / 428 requested bytes regardless of
    a 1024× input-length difference (4,096 vs 4,194,304 mantissa digits). Controls:
    marker-only run = 0 allocations; a low-level fwrite() marker variant reproduced the
    same 11/428B figure, ruling out instrumentation overhead. Preliminary return-address
    attribution places one call near zend_strtod+0xee4.

In other words: for the tested input domain, dtoa's memory need is bounded (digit
truncation before Bigint construction; freelist/private pool absorbs the rest), so
RLIMIT_AS pressure always surfaces in the string BUILD phase, where the new
delayed-OOM machinery handles it gracefully. We did not find a way to make the
libc allocator fail inside the conversion.

Questions

  1. Is leaving these on zend_error_noreturn intentional — i.e. does the delayed-OOM
    layer on master also cover a libc-malloc failure deep inside dtoa, making the 7.4-style
    conversion unnecessary there?
  2. If it is an oversight, would a small PR converting the four call sites above to the
    fail-fast pattern (mirroring 712e60f) be welcome? Happy to send it.
  3. Separately: is the fact that dtoa allocates via raw malloc() (outside memory_limit
    accounting) considered acceptable? Our measurements suggest per-conversion usage is
    tiny and bounded, so this may be fine as-is — asking for completeness.

Reproduction context

The interest in this area came from lab work on the php2shell chains (public PoC and a
public Metasploit module PR exist for CVE-2026-109618/109619). We validated the patched
build behavior end-to-end: on 8.4.27 the public chain dies at the heap-leak stage with
zero worker crashes, consistent with the fix cutting off stale-state reuse. No new
exploitation is reported or implied by this issue.

Activity

  1. aasul commented on Oct 24, 2026

    @aasul
    Contributor

    I looked into the two Balloc() failure paths in zend_strtod.c and have a small local patch replacing zend_error_noreturn() with zend_mm_delayed_error_or_panic(false, ...)

    from what I found, the current path goes through a fatal bailout when libc malloc() fails, while the patched version terminates immediately. the delayed OOM handling doesn't seem to cover these allocations since they're outside Zend MM

    I tested this with controlled allocation failures and ran the relevant tests on NTS and ZTS debug builds, all passed. I haven't found a practical way to trigger this from userland though

    I left the arithmetic overflow checks in zend_multiply.h unchanged since they seem to be a separate case

    before opening a PR, would the fail-fast behavior make sense here or is the current fatal bailout intentional? the patch is only two lines so I'm happy to submit it if this is the preferred approach

  2. aasul commented on Oct 24, 2026

    @aasul
    Contributor

    I looked further into the two Balloc() failure paths in zend_strtod.c and tested a small local patch using zend_mm_delayed_error_or_panic(false, ...) instead of zend_error_noreturn()

    with controlled libc allocation failures, the original code can abandon an earlier allocated Bigint when the fatal bailout skips cleanup. I didn't find any memory corruption though, and a subsequent conversion still worked in my tests

    the main concern with the patch is FPM behavior. the current fatal error allows normal request shutdown, while the panic path kills the worker immediately. it also bypasses PHP's normal error logging and backtrace reporting, and the message could be lost if worker stderr isn't captured

    NTS and ZTS debug builds passed their relevant tests, but I haven't tested this in FPM or found a practical way to trigger the failure from userland

    so I'm not sure the fail-fast behavior is worth the change just to avoid the abandoned allocation

    would it be better to keep the current fatal handling and investigate cleanup instead, or is immediate termination still the preferred approach for libc allocation failures here?

    the investigation and local testing were assisted by AI tools but reviewed by me.

  3. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Thanks @aasul — your findings match ours. Sharing the empirical data in case it helps the patch decision (all reproducible, PHP 8.4.27 arm64, tooling described in the issue body):

    • RLIMIT_AS sweep at 1 KB granularity across the exhaustion boundary: every allocation failure landed in string construction (Zend MM chunk mmap), FAIL_IN_CONVERSION count 0 — the Balloc() paths never fired under any userland memory pressure we could construct.
    • strace (bracketed by phase markers): 0 mmap/brk/mremap syscalls during the conversion phase.
    • LD_PRELOAD malloc interposer: conversion performs a constant 11 libc mallocs / 428 requested bytes across a 1024× input-length difference (40 vs 4,194,304 mantissa digits); marker-only control produced 0. I.e. dtoa's per-conversion libc usage is tiny and bounded, which is consistent with your "no memory corruption found" result.

    The abandoned-Bigint-on-bailout detail you found is interesting — consistent with dtoa's freelist/private-pool design, but it does mean the fatal path skips cleanup. On the FPM concern: our FPM probes likewise only produced clean OOM fatals / worker restarts, no corruption signatures.

  4. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Follow-up — a correction to our own earlier answer.

    We reported that we could not reach these call sites from userland. That testing only covered the string→float direction (where every failure lands in string construction before zend_strtod runs). The float→string (formatting) direction does reach the Balloc() failure path on stock patched builds:

    // fmt_prec.php — line 7 is the var_export() call
    ini_set('serialize_precision', 4*1024*1024);
    $out = var_export((float)"0.1", true);
    docker run --rm -v "$PWD":/lab php:8.4-cli \
      sh -c 'ulimit -v 60000 && php -d memory_limit=-1 /lab/fmt_prec.php'
    # → Fatal error: Balloc() failed to allocate memory in /lab/fmt_prec.php on line 7
    • PHP 8.4.27 (stock CLI): window 59000–61000 KB → Balloc() E_ERROR (deterministic, 4/4 runs); ≤57000 → clean "Out of memory" before conversion; ≥63000 → success.
    • PHP 8.3.36 (stock CLI): same failure, window 56000–58000 KB; ≥60000 → success.

    Mechanism, which also explains why our earlier sweeps missed it: the well-known ~53-digit cap applies to the printed output only — internally dtoa is still driven with the full ini-requested ndigits (multi-MB transient allocations inside the formatting path). There is no large allocation phase before the conversion on this direction, so a memory-tight process fails inside dtoa's raw-malloc() path and hits the un-converted zend_error_noreturn(E_ERROR) sites this issue is about.

    Measured at comfortable VA, all formatting sinks cap the output to ~53 digits (var_export / serialize / json_encode / (string) / var_dump); sprintf() additionally emits the guard notice; number_format() fails via the zend_mm path ("mmap() failed" / "Out of memory") instead. So the ini-driven formatting demand is the only measured route into the Balloc() sites.

    Attacker-reachability: requires control of serialize_precision (INI_ALL) — an application that sets it from user input, or a deployment configured with a huge value. So this remains a hardening/consistency concern rather than a directly exploitable path — but it does answer the question this issue asked: yes, these call sites are reachable on stock builds, via the formatting side.

  5. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Update: the Balloc() failure path is reachable on 8.x — repro below

    In the original report we stated we could not find a reachable input for the Balloc() failed to allocate memory path on 8.x/master (evidence was outcome/phase/allocator-level only). We have since found a reachable reproduction on stock PHP 8.4.27 (CLI and FPM), triggered from the float→string formatting side, which we had not previously tested:

    <?php
    ini_set('serialize_precision', 4194304);   // PHP_INI_ALL
    echo var_export(0.1, true);                 // any float->string op

    Under RLIMIT_AS just above the process baseline this produces:

    Fatal error: Balloc() failed to allocate memory in ... on line 3
    
    • CLI (php:8.4-cli, 8.4.27): BALLOC_FAIL at 57,600–60,800 KB, CONV_OK from 61,200 KB up (400 KB sweep resolution)
    • FPM persistent worker (php:8.4.27-fpm behind nginx, unix socket, pm.max_children=1): window 188,500–192,000 KB against a 188,100 KB resting VSZ — the fatal fires over plain HTTP, mid-var_export

    Mechanism note: serialize_precision does not lengthen the output (var_export(0.1) still trims to the exact 57-digit expansion) — the large ndigits request drives the size of dtoa's internal Bigint allocations, which is what fails under address-space pressure. Control: the same snippet with the default ini passes cleanly at VA values where the 4M setting dies (full ladder in the evidence pack below).

    Measured impact (FPM): request-level fatal only. At every point in the window the worker survives (same PID) and subsequent requests — including float-heavy ones (var_export, json_encode, serialize, (float) cast) — behave correctly. No crash or cross-request corruption observed at the functional level.

    Preconditions: we did not find a user-controlled precision sink (sprintf('%.Nf') is truncated to 53 digits with a notice; number_format() fails through the graceful mmap/OOM handler), so this requires an app/config setting a huge serialize_precision plus a near-exhausted address space — not attacker-controlled in typical deployments.

    This confirms the error path is reachable on 8.x: the E_ERROR machinery runs while the allocator is failing, then bails out mid-dtoa — the exact pattern the 7.4-minimal branch avoids with _exit(1).

    Evidence pack — CLI ladder, control run, FPM table, sink guards

    All runs on official Docker images; no ini changes beyond what is shown. Run shape: ulimit -v <KB> && php -d memory_limit=-1 <snippet>; STDERR phase markers (P1 build / P2 input ready / P3 conv done) identify which phase a run dies in.

    CLI ladder — php:8.4-cli (PHP 8.4.27), va_kb 20000→72000

    va_kb range outcome
    20000–49600 PHP fails to load shared libraries (process can't start)
    50400–52800 pre-marker exit (mmap fail / SIGSEGV before script)
    53600–56800 mixed pre-marker failures ("Out of memory", SIGSEGV, empty)
    57600–60800 BALLOC_FAIL — Fatal error: Balloc() failed to allocate memory (exit 255)
    ≥61200 CONV_OK (full output, len=57 — exact expansion of 0.1)

    Verbatim window rows (resolution 400 KB):

    va_kb  outcome      exit  signal  first_line
    57600  BALLOC_FAIL  255   127     P1_BUILD_START
    58400  BALLOC_FAIL  255   127     P1_BUILD_START
    59200  BALLOC_FAIL  255   127     P1_BUILD_START
    60000  BALLOC_FAIL  255   127     P1_BUILD_START
    60400  BALLOC_FAIL  255   127     P1_BUILD_START
    60800  BALLOC_FAIL  255   127     P1_BUILD_START
    61200  CONV_OK      0     0       P1_BUILD_START
    

    The phase markers confirm the fatal fires after the input is built, inside the var_export() call.

    Control — same VA range, default ini

    fmt_noini.php (identical, no ini_set) across 58000–64000 KB: CONV_OK at every point — at VA values where the 4M serialize_precision run dies in Balloc, the default-precision run passes. The ini value drives dtoa's internal allocation size (Bigint, sized by requested ndigits), not the output length.

    FPM persistent worker — php:8.4.27-fpm + nginx (unix socket), pm.max_children=1

    ulimit -v applied to the FPM master before exec (children inherit). Baseline worker VSZ: 188,100 KB. Trigger reached over plain HTTP; follow-up request does var_export, json_encode, serialize, (float) cast.

    RLIMIT_AS (KB) trigger request worker PID next request health check
    230000–194000 200, 57-digit output unchanged correct 200
    193500 200, 57-digit output unchanged correct 200
    193000 200, 57-digit output unchanged correct 200
    192500 200, 57-digit output unchanged correct 200
    192000 Fatal: Balloc() failed to allocate memory unchanged correct 200
    191500 same fatal unchanged correct 200
    191000 same fatal unchanged correct 200
    190500 same fatal unchanged correct 200
    190000 same fatal unchanged correct 200
    189500 same fatal unchanged correct 200
    189000 same fatal unchanged correct 200
    188500 same fatal unchanged correct 200

    Next-request output at every point: check1=0.1 check2=0.1 check3=d:0.1; check4=float(1.5) — no cross-request anomaly at the functional level.

    Sink guard checks (why preconditions stay non-attacker-controlled)

    sprintf('%.4194304f', 0.1)   → Notice: Requested precision of 4194304 digits
                                   was truncated to PHP maximum of 53 digits
    number_format(0.1, 4194304)  → dies via zend_mm mmap failure →
                                   "mmap() failed: Cannot allocate memory" /
                                   graceful OOM handling (not the Balloc E_ERROR path)
    
  6. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    @srayuth089 thanks for the detailed follow ups and for finding a way to reproduce this on stock PHP

    the distinction between string to float and float to string makes sense now. i hadn't tested the formatting path with such a large serialize_precision value so the var_export() reproduction under RLIMIT_AS is really useful

    the FPM results are especially interesting since the fatal error only affects the request and the same worker continues handling subsequent requests correctly, at least in your tests

    this makes me even less convinced that replacing zend_error_noreturn() with zend_mm_delayed_error_or_panic() is the right approach since it would terminate a worker that currently survives the failure

    i think it would be worth looking into whether the large internal precision request can be handled earlier or whether the skipped cleanup can be addressed without changing the current fatal error behavior

    thanks again for investigating this further, especially the FPM side which i hadn't tested myself

  7. srayuth-dna commented on Oct 25, 2026

    @srayuth-dna

    Thanks @aasul — a few additions from our measurements that speak to both directions you raise.

    On the worker-survival point: one more data point in favor of your concern: the same request-scoped containment holds on 8.4.26 as well — identical FPM behavior there (fatal in the failing request, same worker survives, subsequent requests correct). So the containment comes from the request-scoped teardown of the existing E_ERROR path rather than from anything the new machinery adds at this call site; switching it to a panic would indeed trade a survivable request-scoped fatal for a worker kill.

    On "the skipped cleanup": we ran a differential check for exactly this on the same worker: a 236-conversion battery (var_export / json_encode / serialize / (float) casts, including subnormals and long-mantissa parses) produces a byte-identical digest before vs. immediately after a Balloc bailout, on both 8.4.27 and 8.4.26, with no crash signals — i.e. at the functional level we could not observe any effect of the interrupted dtoa state on subsequent requests. (Honest caveat: that is functional-level evidence only; freelist-level effects beyond these probes can't be excluded.)

    On "handling the large precision request earlier": some numbers that may help size such a guard:

    • The output never reflects the requested ndigits — these formatters trim to the exact expansion. var_export(0.1) with serialize_precision=4194304 yields 57 chars; the worst case across all doubles is the smallest subnormal (2⁻¹⁰⁷⁴), whose exact expansion is 757 chars (measured for var_export, json_encode and serialize).
    • So capping the requested ndigits at anything ≥ ~770 is output-lossless for every double — no consumer of serialize_precision/precision pads zeros, so nothing observable changes.
    • There is precedent for capping with a notice: formatted_print already truncates %.Nf at 53 digits with "Requested precision of N digits was truncated to PHP maximum of 53 digits". The same guard pattern at the ini-driven consumers (var_export/var_dump/json_encode/serialize/float-to-string echo) would remove the multi-MB Bigint demand at the source.

    For what it's worth, we have an FPM harness with an LD_PRELOAD allocation-failure injector plus the battery detector set up for this — happy to run any candidate patch through it and post results.

  8. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    @srayuth-dna thanks for the additional testing, especially the comparison with 8.4.26 and the 236 conversion checks

    the fact that the worker survives on both versions and the results remain identical after the bailout makes me think the current E_ERROR handling is worth preserving. of course the functional tests don't rule out everything happening internally with the freelist but they do make the immediate panic approach seem less justified

    the precision cap idea is interesting and seems like a better direction to investigate. i'd still want to verify that a limit around 770 really preserves the existing output across all the relevant formatting paths and doesn't introduce any unexpected changes to precision or serialize_precision

    it might also be worth checking whether the limit can be applied at a common conversion point rather than adding separate guards to every consumer

    thanks for offering the FPM harness as well, that would be really useful for testing a candidate fix if we find a safe approach

  9. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    @aasul on the "common conversion point" you asked about — there is one: zend_gcvt() in Zend/zend_strtod.c.

    Every float→string formatting path funnels through it:

    Caller File precision source
    zend_smart_str_append_double Zend/zend_smart_str.c:159 precision arg (from serialize_precision)
    zend_double_to_str Zend/zend_operators.c:3313 PG(precision)
    php_snprintf / spprintf %g/%G/%H/%k main/snprintf.c:944, main/spprintf.c:652 format spec
    var_dump ext/standard/var.c:1097 PG(serialize_precision)
    json_encode ext/json/json_encoder.c:88 PG(serialize_precision)
    soap ext/soap/php_encoding.c:1088 EG(precision)
    sprintf %g/%G ext/standard/formatted_print.c:302 (already capped to 53 by the guard you saw)

    Right now zend_gcvt() does no clamp on ndigit — it passes the raw value straight to zend_dtoa(), which is where the multi-MB transient Bigint allocations come from when the ini value is huge.

    A one-point cap in zend_gcvt() would cover all consumers at once:

    /* In zend_gcvt(), after the mode==0 default */
    if (ndigit > 770) {
        ndigit = 770;
    }

    Why 770: IEEE 754 double has at most 767 significant decimal digits (including subnormals: DBL_TRUE_MIN = 4.94e-324, which needs 324 decimal places + ~440 significant digits for the exact value). MAX_FLOAT_PRECISION in formatted_print.c is 53 for the sprintf guard, but that's about output readability — the internal dtoa demand can safely be higher. 770 is comfortably above the worst case and still prevents the OOM path from being reachable via ini.

    What this fixes vs. doesn't:

    • Fixes: the formatting-side OOM (serialize_precision / precision → multi-MB transient) that we reported above
    • Doesn't change: the string→float side (zend_strtod), which is a different call path with its own bounded usage (11 mallocs / 428B, measured)
    • Doesn't change: output for normal precision values (the cap only activates above 770)

    Happy to test this on the FPM harness we described if you put together a candidate PR — we can run the 236-conversion byte-identity battery + the RLIMIT_AS boundary sweep against it.

  10. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    thanks for finding this, zend_gcvt() does look like a much better place to handle the excessive precision than adding checks across individual consumers

    i checked the PHP 8.4 implementation and confirmed that it passes ndigit directly to zend_dtoa() without a limit

    one small distinction is that some %e and %f formatting paths use php_conv_fp() instead, although that doesn't change the usefulness of this approach for the paths we're investigating

    the 770 limit looks worth testing, especially since it should avoid the huge allocations caused by unusually large serialize_precision values

    i'd still want to compare the output before and after the change for subnormals, large doubles and precision values above the proposed cap before treating it as safe

    i'll look into a small candidate patch first rather than opening a PR immediately

    thanks for offering to test it with your FPM harness as well, that would be very useful for checking both worker behavior and output compatibility

  11. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    @srayuth089 i'll try putting together a small experimental patch that caps ndigit at 770 in zend_gcvt()

    i'll first reproduce the original Balloc() failure on unmodified PHP 8.4 using serialize_precision=4194304 and RLIMIT_AS, then compare the same conditions with the patch to see whether it prevents the failure and reduces memory usage

    for compatibility i'll compare the exact output before and after the change using precision values of 17, 53, 770, 771, 1000 and 4194304 across var_export(), serialize(), json_encode(), var_dump() and normal float to string conversion

    i'll include values like 0.1, PHP_FLOAT_MAX, the smallest subnormal, negative zero, INF and NAN, along with some randomly generated finite doubles to look for differences that the usual cases might miss

    i also want to check the negative precision behavior and the %g formatting paths to make sure the cap doesn't affect anything unexpected

    if those comparisons pass i'll test the patched version under FPM with repeated failing and successful requests, check worker PID continuity and run the relevant NTS and ZTS tests

    i'll share the actual measurements, any output differences and the experimental diff here before opening a PR. if the results look promising it would be useful to compare them against your FPM allocation failure harness as well

  12. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    @srayuth089 i've pushed the experimental 770 precision cap and the regression test to a separate branch on my fork

    here's the branch: https://github.com/aasul/php-src/tree/investigate/gh24563-gcvt-precision-cap

    the initial NTS, ZTS and FPM comparisons look promising with no output differences found in my tests

    if you have time to run it through your FPM harness and the 236 conversion battery i'd appreciate the independent results before i open a PR

    thanks again for helping investigate this

  13. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    @srayuth089 just to share some of the actual results from my local testing

    with serialize_precision=4194304 the largest conversion malloc dropped from 4194332 bytes to 1052 bytes after applying the 770 cap, and peak tracked live allocation went from about 4 MB to 1212 bytes

    i compared the output across 387 test value entries including subnormals, large doubles, negative zero, INF, NAN and random finite doubles using different precision settings and formatting functions. no byte for byte differences were found

    under the same tight RLIMIT_AS conditions the baseline FPM workers produced 20 Balloc() fatals each while the patched workers completed those conversions. the full FPM comparison covered 648 requests and subsequent requests continued working correctly

    the relevant NTS and ZTS tests also passed with 412 passes and no failures each

    the main limitation is that i've only tested Linux x86_64 so far and the patch addresses excessive precision allocations rather than all possible Balloc() failures

    these are the results from my initial local investigation so independent testing with your harness would be really useful before moving forward

    investigation and testing were slightly assisted by AI tools but all of its work was reviewed completely

  14. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    @aasul independent verification complete — all four test categories pass on your branch (investigate/gh24563-gcvt-precision-cap, commit 1e56158, built as PHP 8.4.28-dev on macOS arm64):

    1. Balloc failure prevention (RLIMIT_AS sweep at the previously-failing window):

    unpatched 8.4.27:  VA 59000-61000 KB → Fatal error: Balloc() failed
    patched 8.4.28-dev: VA 56000-64000 KB → CONV_OK at every level (no Balloc)
    

    2. Output compatibility (precision 17/53/770/771/1000 × var_export/serialize/json_encode/(string) × normal/subnormal/MAX values):

    precision=17   → identical to stock 8.4.27
    precision=53   → identical
    precision=770  → identical
    precision=771  → identical
    precision=1000 → identical
    

    3. Full battery — 302 conversions (23 edge-case values + 50 long-mantissa strings + 30 subnormal steps, through all four formatting paths at serialize_precision=4194304):

    patched digest:  f50d74bb84ad9468884cf7a6278f51f4
    stock 8.4.27:    f50d74bb84ad9468884cf7a6278f51f4
    → BYTE-IDENTICAL
    

    4. Spot check at precision=4194304 (the exact setting that previously triggered Balloc):

    var_export(0.1) = 0.1000000000000000055511151231257827021181583404541015625  (len=57, correct)
    serialize(0.1)  = d:0.1000000000000000055511151231257827021181583404541015625;
    json_encode(0.1) = 0.1000000000000000055511151231257827021181583404541015625
    

    Your memory measurements (4.2 MB → 1052 B largest malloc, 4 MB → 1212 B peak) are consistent with what we'd expect from the cap eliminating the Bigint chain for 5^n.

    Platform note: this verification is on macOS arm64 (native compile, not emulated).

  15. aasul commented on Oct 25, 2026

    @aasul
    Contributor

    @srayuth089 thanks again for independently testing the patch and sharing the results

    i've opened the PR now: #24688

    i included both my Linux x86_64 results and your macOS arm64 verification in the description, along with credit for your suggestion and testing

    the CI checks are running now so i'll see what comes up during review

    really appreciate your help with this, your findings made a big difference in getting the patch to this stage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions