← board

Frozen-string function Result is a shared global → not reentrant / thread-unsafe

Symptom / hazard

A function returning a frozen string (tyString, the compiler's fixed-capacity internal string model) gets its Result slot allocated as a program GLOBAL, not a stack local:

{ parser.inc, ParseSubroutine, return-value slot }
else if retType = tyString then
begin
  savedCurProc := CurProc;
  CurProc := -1;                 { force global scope }
  AllocVar('Result', retType);
  CurProc := savedCurProc;
  Procs[procIdx].RetSymIdx := SymCount - 1;
  Syms[SymCount-1].Kind := skGlobal;   { one shared BSS slot for ALL calls }
end

So every invocation of that function writes the same BSS slot. Consequences:

Only frozen-string returns are affected. Managed-string (AnsiString, the user default) returns use the normal ARC/handle path; record and dyn-array returns use local/hidden-dest slots. The compiler self-builds with frozen strings, so this is exercised by the compiler itself — it works today only because no frozen-string function recurses in a way that observes the clobber before the caller copies the value out (fragile by accident, not design).

Likely intent

A frozen string is ~STRING_CAP + 8 bytes; the value is returned by copying from Result's address after the frame is gone, so a stable address was wanted. A global gives that, but at the cost of sharing. The correct model is a per-call home: a hidden caller-allocated return slot (like the aggregate/record-return ProcAggregateDestSym path), or a stack local copied out before the epilogue tears the frame down.

Fix sketch

Route frozen-string returns through the existing hidden-return-slot mechanism (the caller passes the destination address; Result aliases it), the same way struct-by-value returns already work — instead of a shared global. Must keep self-host byte-identical and the --threadsafe self-build green.

Acceptance

Notes

Fix proposal — refined (2026-06-30)

Route frozen-string returns through the existing hidden-destination aggregate return path (the one records/sets already use), instead of the shared global.

Concretely:

  1. Allocate Result for a tyString/tyFixedString/tyShortString function as a routine local (the normal CurProc >= 0 AllocVar), not CurProc := -1 + Kind := skGlobal (parser.inc ParseSubroutine, the else if retType = tyString block ~12523).
  2. Give the function a hidden destination param like aggregate returns (ProcAggregateDestSym): the caller allocates the return buffer and passes its address (r10 on x86-64 / the per-target dest register already used for records).
  3. The epilogue copies the local Result into the caller's dest and returns that pointer — the existing TypeIsAggregate(...) and ProcAggregateDestSym >= 0 branch in EmitProcEpilog (symtab.inc) already does exactly this with rep movsb; the tyString branch right below it (which returns the global address) is what gets removed.
  4. Call sites: allocate the hidden dest temp and pass it, as record-by-value calls already do.

Do NOT widen TypeIsAggregate to include tyString globally — tyString is special-cased in many codegen paths (load/store width, concat, length); flipping it to "aggregate" everywhere is high-risk. Instead gate the return path on TypeIsFrozenString(retType) so only the return ABI changes, reusing the aggregate copy/dest machinery.

Per-backend: the frozen-string return branch exists in every backend's epilogue

PRIORITY: LOW — deferred (2026-06-30, user decision)

User clarified the frozen-string model + priority:

CORRECTION (2026-06-30, after user review) — capacity was a strawman; real blocker = virtual/indirect calls

Attempt 1 below blamed a "capacity 8MB→256" regression and a frozen self-build crash. Both were wrong / confounded (user caught this):

Attempt 2 (re-applied the same direct-call fix, validated properly):

Recommendation: frozen mode is currently broadly broken anyway ([[bug-frozen-self-build-unreliable]] — startup SIGSEGV, can't even self-build), so the reentrancy fix sits on a broken foundation. Fix the frozen-self-build crash FIRST (makes frozen usable + testable via a working bootstrap-frozen), THEN land the reentrancy fix with virtual/indirect dest support and validate under test-frozen. Direct-call-only reverted to avoid shipping the virtual/ indirect regression. The RetViaHiddenDest/AggRetCopySize wiring + the exact edit sites are recorded below and re-confirmed correct — re-applying them is step 1 of the eventual fix; steps 2-3 are virtual + indirect dest passing.

Attempt 1 (2026-06-30, Track A) — copy-out model PROVEN INSUFFICIENT, reverted

Implemented the refined proposal exactly (copy-out-to-fixed-local), full multi-target. Reverted clean; tree green. Findings (these de-risk the next attempt):

The wiring is almost entirely mechanical and the machinery already exists:

Why it fails (the real blocker — a capacity-semantics problem, not a wiring bug):

What the next attempt must do instead — true NRVO (Result aliases the caller dest):

Status: stays in backlog. Wiring approach is validated and mechanical; the open question is the Result home (NRVO redirect + per-site big temp vs. an agreed capacity cap). Pick that first, then the rest is the mechanical swap above.

RESOLVED (2026-06-30, Track A) — Attempt 3, complete

Frozen-string Result is now a routine local returned via the hidden caller-destination ABI (per-call, reentrant), instead of a shared BSS global. The capacity question is settled: a default frozen string result is LOCAL_STR_CAP (256, the ShortString norm) — correct, not a regression (user).

Wired on every call path (the gap Attempt 2 hit):

Verified: test/test_frozen_string_reentrant.pas (4/4) — direct recursion (Build(5)='5', was clobbered to '0'), virtual (two independent calls), indirect (proc-pointer). Managed self-host byte-identical; make test + cross (i386/aarch64/arm32/riscv32) green. The vma/ve frozen array-of-string quirks seen while testing are a separate pre-existing frozen bug (identical on pinned), not this fix.

Note: --threadsafe was not separately re-tested; the single-threaded reentrancy (the reported hazard) is fixed. Thread-safety of the per-call dest is inherent (each call has its own destination).