Root-cause the aarch64-only D+ DOUBLE-OVERFLOW bug (FABRIC-3.md §XII.4)
Build / build-amd64-iso (push) Waiting to run
Build / build-aarch64-iso (push) Waiting to run
Build / build-riscv64-img (push) Waiting to run

Confirmed the exact mechanism behind the aarch64-specific D+/D. divergence
found in the std79 exerciser campaign (commit cc81edf). The earlier
writeup's cell_t-width check was real but answered the wrong question --
cell_t is 64-bit everywhere, but double_word_d_plus()'s carry-detection
casts through plain `unsigned long`, and sizeof(unsigned long) is 4 (32-bit)
on this aarch64 bare-metal cross-compile target specifically (amd64 and
riscv64 both happen to have a 64-bit long). The low-cell addition silently
truncates to 32 bits, then widens back to cell_t via ordinary (non-sign-
extending) conversion, producing a wrong positive result_low whenever the
true 64-bit sum is negative -- confirmed live by splitting result_low into
hi/lo 32-bit halves: the real pushed value for `-5 S>D 3 S>D D+` is
+4294967294, not -2. D.'s overflow check is correct and is faithfully
reporting a genuinely malformed double; D+ is the actual defect.

vm.h already defines ucell_t for exactly this class of problem (same
conditional as cell_t, guaranteed width-matched on every target) --
print_number_formatted() (format_words.c) already uses it correctly, D+
doesn't. Also flagged (not exercised by the campaign, same bare-`unsigned
long` pattern, same latent risk): D-, DNEGATE, and the d_compare() helper
behind DMAX/DMIN/D</D=.

Report only, per this project's standing rule (report bugs, don't fix
without being asked) -- no source change in this commit, debug probes
added during the investigation were reverted after use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXieurDfDSsDFdnSyusuWo
This commit is contained in:
Robert Allan James
2026-09-11 06:41:39 -04:00
co-authored by Claude Sonnet 5
parent cc81edf00a
commit 91f7b39d3c
9 changed files with 27104 additions and 19 deletions
+49 -18
View File
@@ -2179,25 +2179,56 @@ project's standing rule (report, don't fix without being asked):**
9 identities, 100% consistently. A genuine cross-ISA divergence, not a flaky/intermittent
result.
Investigated (not fixed): read `double_word_d_plus()` (`double_words.c`) and
`format_word_d_dot()` (`format_words.c`). `D.`'s check is `(dhigh == 0) || (dhigh == -1 &&
dlow < 0)` -- correct FORTH-79 double-overflow semantics for a properly sign-extended result.
`D+`'s carry-detection casts through `unsigned long` for the low-cell addition, which would be
a real per-architecture bug *if* `unsigned long`'s width differed from `cell_t`'s on any of
these targets -- checked and ruled out: `cell_t` is `int64_t` on amd64 and aarch64 explicitly
(`vm.h`'s `#if defined(__amd64__) || ... || defined(__aarch64__) || ...`), and `signed
long`/`unsigned long` (the `#else` branch, since `__riscv` isn't in that `#if`) on riscv64 --
confirmed 64-bit on all three empirically via `T08: -1 1 RSHIFT` printing the identical
19-digit `9223372036854775807` on every architecture, which only a genuine 64-bit arithmetic
shift can produce. So this is not a cell-width mismatch; the actual mechanism producing a wrong
`dhigh` specifically on aarch64, for this specific input, is still open.
**Root-caused 2026-09-11 (still not fixed -- report only, per this project's standing
rule).** First pass (2026-09-10) checked `cell_t`'s width and found it 64-bit on all three
architectures (`T08: -1 1 RSHIFT` prints the identical 19-digit `9223372036854775807`
everywhere), and wrongly concluded from that alone that `D+`'s `unsigned long` carry-detection
cast couldn't be a width-mismatch bug -- **that conclusion was wrong**: `cell_t`'s width and
plain C `long`'s width are two different things, and only the first was actually checked.
Confirmed directly via three targeted probes (`double_word_s_to_d()`, `double_word_d_plus()`,
`format_word_d_dot()` -- added, used, reverted): `sizeof(unsigned long)` prints `4` on this
aarch64 build. `long`/`unsigned long` is 32-bit on this platform's aarch64 cross-compile
target (bare-metal freestanding, not a hosted Linux/LP64 environment where 64-bit `long`
would be guaranteed) -- amd64 and riscv64 both happen to use a 64-bit `long`, so the same code
only breaks here.
**Next step, if picked up:** trace `D+`'s carry computation with a targeted probe on aarch64
specifically for the `-5 S>D 3 S>D D+` sequence, comparing the pushed `result_high` against the
expected `-1` right before `SWAP D.` runs -- narrows whether the defect is in `D+` itself, in
`S>D`'s sign-extension, or somewhere in the stack-manipulation path between them. `M*`'s
universal failure (bug 1) is a separate, likely unrelated defect and would need its own trace
starting from `M*`'s own double-cell multiply, not `D+`'s.
Traced the actual data live for `-5 S>D 3 S>D D+`: `d1=(low=-5,high=-1)`, `d2=(low=3,high=0)`
-- correct inputs, `S>D`'s sign-extension is fine. Inside `D+`,
`unsigned long ud1low = (unsigned long) d1low;` truncates `d1low` (a 64-bit `cell_t`,
`0xFFFFFFFFFFFFFFFB`) down to 32 bits (`0xFFFFFFFB`) before the addition. The addition
(`0xFFFFFFFB + 3 = 0xFFFFFFFE`) happens entirely within that 32-bit width, and
`cell_t result_low = (cell_t) uresult_low;` then *widens* the 32-bit unsigned value back to a
64-bit signed `cell_t` via ordinary value-preserving conversion -- not the sign-extension the
code actually needs. Confirmed by splitting `result_low`'s real 64-bit value into hi/lo 32-bit
halves in the probe: `low_hi32=0 low_lo32=4294967294`, i.e. the true pushed value is
`+4294967294` (a large **positive** number), not `-2`. `carry` stays `0` (no overflow at the
truncated 32-bit width), so `result_high = d1high + d2high + carry = -1` -- correct on its
own, but paired with a `result_low` that is silently wrong. `D.`'s check
(`dhigh == -1 && dlow < 0`) then correctly, faithfully reports `DOUBLE-OVERFLOW` for this
malformed double -- `D.` is not the bug, `D+` is.
`vm.h` already defines `ucell_t` (`uint64_t` on amd64/aarch64, `unsigned long` on riscv64 --
the same conditional as `cell_t`, guaranteed to match its width on every target) for exactly
this class of problem; `print_number_formatted()` (`format_words.c`) already uses it
correctly. `D+`'s carry-detection using bare `unsigned long` instead is the actual defect --
a one-word class of bug (`unsigned long` should be `ucell_t`), not a logic error.
**Wider scope, not yet empirically exercised:** `double_word_d_minus()` (`D-`),
`double_word_dnegate()` (`DNEGATE`), and the `d_compare()` helper (used by `DMAX`/`DMIN`/
`D<`/`D=`) all use the identical bare-`unsigned long` pattern (`double_words.c` lines
~111-113, ~136, ~170-171) and are equally vulnerable in principle on any platform where
`long` is narrower than `cell_t`. `DNEGATE` was exercised by the campaign (`T20: -5 S>D
DNEGATE SWAP D. CR`) and happened to print the correct `5` on all three architectures --
traced why: for this specific small input, the 32-bit-truncated magnitude computation still
produces the mathematically correct positive result even without proper 64-bit sign
extension, so the bug is latent, not absent, for `DNEGATE` here. `D-`/`DMAX`/`DMIN`/`D</D=`
were never exercised by `std79-exerciser.fth` at all. `double_word_d_two_star()`/
`double_word_d_two_slash()` (`D2*`/`D2/`) use `unsigned long long` instead, which the C
standard guarantees is at least 64-bit everywhere -- not part of this bug class.
`M*`'s universal failure (bug 1) is a separate, unrelated defect (present on every architecture,
so not a `long`-width issue) and would need its own trace starting from `M*`'s own double-cell
multiply, not `D+`'s.
## XIII. Heap corruption under repeated WIREBIND attach/detach cycling — root-caused and CLOSED
2026-09-10