Skip to content

fix(state): share storage key between state init and named-sub capture#531

Merged
fglock merged 1 commit intomasterfrom
fix/state-var-closure-capture
Apr 21, 2026
Merged

fix(state): share storage key between state init and named-sub capture#531
fglock merged 1 commit intomasterfrom
fix/state-var-closure-capture

Conversation

@fglock
Copy link
Copy Markdown
Owner

@fglock fglock commented Apr 21, 2026

Summary

Fix a bug where a state variable declared in a block at top level (no enclosing sub) and referenced by a named sub defined in the same block was read as undef inside the sub, even after the outer block had initialized it.

Found while investigating jcpan -t QRCode::Encoder, which failed across 3 test files because QRCode::Encoder::QRSpec uses this pattern heavily:

{
    state $table = [ ...big data... ];
    sub qrspec_data_size ($version, $level) {
        my $item = $table->[$version - 1];   # $table was undef!
        return $item->{words} - $item->{ec}{$level};
    }
}

Root cause

The persistent-variable key differed between the two sides:

  • state $x = ... stores the value in global storage under PersistentVariable.beginVariable(sigilNode.id, name), where sigilNode.id is assigned by OperatorParser for state declarations.
  • When a named sub captures the same lexical, SubroutineParser allocated a separate begin id via RuntimeCode.evalBeginIds.computeIfAbsent(ast, k -> classCounter++), producing a different global key. The sub's captured field was thus a fresh empty scalar.

Fix

  1. In the named-sub capture path (SubroutineParser), for state declarations reuse the existing ast.id as the begin id instead of allocating a new one, and register it in evalBeginIds.
  2. As a safety net, StateVariable.retrieveStateScalar/Array/Hash now fall back to global storage when the per-sub map has no entry, so a sub accessing a state var declared in an outer (non-sub) scope still sees the shared storage.

Test plan

  • Minimal repro: { state $t = [10]; sub f { $t->[0] } }; print f() now prints 10.
  • State-per-closure-instance semantics still correct (verified with make make() + state $c = 0; ++$c — instances still have independent counters).
  • jcpan -t QRCode::Encoder — all tests pass (t/00-load, t/01-hello, t/02-best, t/03-version-7; also Math::ReedSolomon::Encoder dependency).
  • make (unit tests) passes.

Generated with Devin

A `state` variable declared in a block at top level (no enclosing sub)
and referenced by a named sub defined in the same block was reading an
uninitialized scalar: the sub saw undef even though the outer block had
set the value.

Root cause: the persistent-variable key differed between the two sides.

- The `state $x = ...` initializer stores the value in global storage
  using `PersistentVariable.beginVariable(sigilNode.id, name)` where
  `sigilNode.id` is assigned by `OperatorParser` for `state` decls.

- When a named sub captures the same lexical, `SubroutineParser`
  allocated a *separate* begin id via
  `RuntimeCode.evalBeginIds.computeIfAbsent(ast, k -> classCounter++)`,
  producing a different global key. The sub's captured field was thus
  a fresh empty RuntimeScalar.

Fix: in the named-sub capture path, reuse the state variable's
existing `ast.id` as the begin id (and register it in `evalBeginIds`).
As a secondary safety net, `StateVariable.retrieveStateScalar/Array/
Hash` now fall back to global storage when the per-sub map has no
entry, so a sub accessing a state var declared in an outer (non-sub)
scope sees the shared storage even if capture didn't wire it up.

Before this fix, `jcpan -t QRCode::Encoder` failed in `t/01-hello.t`,
`t/02-best.t`, `t/03-version-7.t`: `state $table = [...]` in the
block-scoped `QRSpec` helpers was undef inside `qrspec_data_size`
and friends, producing "no suitable version", "not enough bits,
wrong version?", and a cascade of `Use of uninitialized value`
warnings.

After the fix, QRCode::Encoder's full test suite passes.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@fglock fglock force-pushed the fix/state-var-closure-capture branch from 3a0525b to 0f8b355 Compare April 21, 2026 19:54
@fglock fglock merged commit c1e3e0a into master Apr 21, 2026
2 checks passed
@fglock fglock deleted the fix/state-var-closure-capture branch April 21, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant