Skip to content

heap_collect(true, false) mid-loop frees strings still live in the caller's frame — use-after-free through Release-fused frames #3734

Description

@borisbat

A string-heap collect performed inside a long-running das loop frees (or relocates) string values that are still reachable from the caller's live locals. A file-path string held by the loop later reaches fopen as a dead pointer.

Observed (Windows x64, Release, interp): ~80% of runs of the lint runner's serial mode crashed within 1–2 collects, in two flavors:

CRASH: EXCEPTION_ACCESS_VIOLATION (0xC0000005)
  reading address 0x140000001d
  [ 0] das::utf8_file_path_to_wide  (src/builtin/module_builtin_fio.cpp:340)
  [ 1] das::das_fopen_utf8
  [ 2] das::builtin_fopen
  [ 3] SimNode_ExtFuncCall<...builtin_fopen>::evalPtr
  [ 4] FusionPoint_MiscCopyReference::SimNode_CopyReferenceLocAny::eval
  ...

or

EXCEPTION: can't fopen NULL name
 at daslib/fio.das:17:12

The das-side stack of the fopen-NULL flavor: fread(file) where file is the loop variable of a for (file in files) over a local array<string> — the path strings were built by string interpolation during a directory scan, so they live on the string heap.

Repro — check out PR #3733's branch, restore the serial-loop collect it removed (utils/lint/main.das, the // NO maybe_collect here comment marks the spot — re-add maybe_collect(since_collect) after tally_result), rebuild nothing (das-only), then:

bin/Release/daslang.exe utils/lint/main.das -- daslib -q --silent -j 1 --disable LINT019

daslib is 148 files; the collect fires at file 100. 3–5 runs are enough to see both flavors. The runner declares options gc + options persistent_heap; the collect is unsafe(heap_collect(true, false)) (string_heap=true, validate=false). Running with validate=true did not fail earlier than the crash.

Isolation facts:

shape result
serial driver loop, collect every 100 files crash ~80% within 1–2 collects
identical collect in worker mode (--paths-from, same for (f in paths) loop, 305 files = 3 collects) 9/9 clean
-j 1 --paranoid-only (single lint pass) clean
full three-pass -j 1 crash — the perf/style passes' allocations appear to be part of the trigger
under-100-file sets (no collect) always clean

The serial driver differs from the worker by the amount of live state in the enclosing frame across the collect (config struct, tally counters, a growing stale_eligible : array<string>, and lint_file's CMRES LintResult return slot) and by call depth. The crash stack goes through FusionPoint_MiscCopyReference / fused SimNode permutations, so a Debug interpreter (fusion bypassed) may not reproduce — worth checking, since that localizes it to root-walking of fused-frame layouts.

Containment already landed (PR #3733): the serial driver no longer collects mid-loop (grow-only); worker mode keeps the collect. So nothing in-tree currently exercises the broken path — this issue is the runtime bug itself.

🤖 Generated with Claude Code

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