Skip to content

fix(interpreter): defensive cleanup for my-hash/my-array on JIT->interpreter fallback#561

Merged
fglock merged 1 commit intomasterfrom
fix/interpreter-myhash-myarray-scope-cleanup
Apr 26, 2026
Merged

fix(interpreter): defensive cleanup for my-hash/my-array on JIT->interpreter fallback#561
fglock merged 1 commit intomasterfrom
fix/interpreter-myhash-myarray-scope-cleanup

Conversation

@fglock
Copy link
Copy Markdown
Owner

@fglock fglock commented Apr 26, 2026

Summary

Fixes a ClassCastException thrown by the bytecode interpreter at sub/scope
exit when a my %h or my @a is declared on a control-flow path that may
be skipped (early return, last, goto, or short-circuit &&/||
guarding the declaration), inside a sub that fell back from the JIT to the
interpreter.

class org.perlonjava.runtime.runtimetypes.RuntimeScalar
  cannot be cast to class org.perlonjava.runtime.runtimetypes.RuntimeHash
class org.perlonjava.runtime.runtimetypes.RuntimeScalar
  cannot be cast to class org.perlonjava.runtime.runtimetypes.RuntimeArray

Root cause

SCOPE_EXIT_CLEANUP_HASH / SCOPE_EXIT_CLEANUP_ARRAY blindly cast
registers[reg] to RuntimeHash / RuntimeArray. The compiler reuses
register slots across statements, so a slot later assigned to my %h
can transiently hold a RuntimeScalar left over from a prior
CREATE_LIST etc. If control flow skips the MY_HASH / MY_ARRAY
initialisation, the cleanup runs anyway and the cast fails.

The scalar variant SCOPE_EXIT_CLEANUP already had the matching
defensive instanceof check; this PR mirrors it for the hash and
array variants.

Trigger

Real-world repro: use Moose; -> Sub::Exporter::Progressive::import
uses goto \&Exporter::import which forces the whole import sub onto
the interpreter-fallback path. The sub also has lexical hashes/arrays,
so every Moose-based test died at use Moose; with the cast above.

Minimal repro:

sub t {
    my %h = (a=>1);     # SCOPE_EXIT_CLEANUP_HASH emitted
    print "ok\n";
    return;             # exits before the goto
    my $f = sub {1};
    goto $f;            # forces JIT->interpreter fallback
}
t();

Before this PR: class RuntimeScalar cannot be cast to class RuntimeHash.
After this PR: prints ok and exits cleanly.

What's in the diff

  • BytecodeInterpreter.java -- guard the two cleanup opcodes with
    instanceof and add an extensive multi-paragraph comment explaining
    the invariants, the trigger and a copy-pasteable minimal repro, so
    the check is not "cleaned up" by a future refactor.
  • New src/test/resources/unit/interpreter_myhash_myarray_scope_exit.t
    with 6 sub-tests covering my-hash, my-array, mixed, the
    Sub::Exporter::Progressive-style for-loop pattern, a stress
    loop, and a short-circuit-skipped my %h. All verified to actually
    exercise interpreter fallback via JPERL_SHOW_FALLBACK=1.

Test plan

  • New regression test interpreter_myhash_myarray_scope_exit.t
    passes (6/6).
  • All existing pre-PR tests in the new file actually trigger the
    JIT->interpreter fallback path (confirmed with
    JPERL_SHOW_FALLBACK=1).
  • make (full unit-test suite, both backends) is green.
  • use Moose; (after extracting Moose-2.4000) now loads
    Class::MOP and its dependency tree without the cast failure
    (next blocker is the unrelated XSLoader::load in Moose.pm,
    tracked in dev/modules/moose_support.md Phase 3).

Generated with Devin

The bytecode-interpreter opcodes SCOPE_EXIT_CLEANUP_HASH and
SCOPE_EXIT_CLEANUP_ARRAY blindly cast their register slot to RuntimeHash
/ RuntimeArray. If a control-flow path skipped the my-hash / my-array
initialisation (early `return`, `last`, `goto`, or a short-circuit
guarding the declaration), the register could still hold a transient
RuntimeScalar produced by an unrelated CREATE_LIST that recycled the
same slot. The unconditional cast then threw

  class RuntimeScalar cannot be cast to class RuntimeHash
  class RuntimeScalar cannot be cast to class RuntimeArray

at sub/scope exit, even when the user's logic completed normally.

This only surfaces in the interpreter-fallback path (the JIT/JVM
backend uses a different code generator), so it stayed hidden until
something forced fallback. `use Moose;` is the canonical trigger:
Sub::Exporter::Progressive::import contains `goto \&Exporter::import`,
which forces JIT->interpreter fallback for that whole sub, and the
sub also has lexical hashes/arrays.

The scalar variant SCOPE_EXIT_CLEANUP already had the same defensive
`instanceof` check (with a long explanatory comment); this commit
mirrors that pattern for the hash and array variants and adds an
extensive comment explaining the invariants, the trigger, and a
minimal repro so future maintainers don't "clean up" the check.

Adds src/test/resources/unit/interpreter_myhash_myarray_scope_exit.t
which exercises every variant on the interpreter-fallback path
(verified via JPERL_SHOW_FALLBACK=1).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@fglock fglock merged commit b950ef3 into master Apr 26, 2026
2 checks passed
@fglock fglock deleted the fix/interpreter-myhash-myarray-scope-cleanup branch April 26, 2026 08:14
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