Skip to content

Commit 9d88063

Browse files
fglockDevin
andauthored
Fix local package variable bug - our variables now see local changes (#333)
* Document implementation attempt findings for local package variable bug Added notes about an attempted approach (skipping our variables from closure capture) that partially worked but broke Test::More due to constructor signature mismatches. Documented that the fix needs to happen at the variable access level, not closure capture level. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <noreply@cognition.ai> * Add test cases and document investigation for local package variable bug - Added test cases in local.t (in __END__ section) for cross-package our/local scenarios that currently fail - Updated design doc with detailed findings from implementation attempts: - Approach 1 (skip our from closures) broke constructor signatures - Approach 2 (non-lexical our) breaks Test::More/Test2 due to BEGIN block interactions - The fix requires further investigation into how BEGIN blocks interact with lexical closure capture Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <noreply@cognition.ai> * Fix local package variable bug - our variables now see local changes This fixes the bug where subroutines don't see 'local'ized values of 'our' variables set from outside their package. Root cause: 'our' variables were treated as lexical (stored in JVM local variable slots), capturing values at subroutine definition time. This meant 'local $Pkg::Var' changes weren't visible inside subroutines. The fix: 1. EmitVariable.java: Distinguish between BEGIN-captured 'our' variables (in PerlOnJava::_BEGIN_* packages, which should remain lexical for compile-to-runtime persistence) and regular 'our' variables (which should look up from GlobalVariable at runtime). 2. EmitSubroutine.java and SubroutineParser.java: Use 4-argument addVariable() to preserve the original perlPackage when copying variables to subroutine scopes. 3. ScopedSymbolTable.java: Add 4-argument addVariable() overload to allow explicit package specification. Test results: - All existing tests pass - New cross-package our/local tests pass (54 tests in local.t) - Test::More/Test2 modules work correctly - BEGIN block lexical persistence works correctly See dev/design/local-package-variable-fix.md for detailed analysis. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <noreply@cognition.ai> * Fix exit() inside BEGIN blocks to exit program instead of causing compilation error In Perl, calling exit() inside a BEGIN block terminates the program immediately. Previously, jperl would catch the PerlExitException and convert it to a "BEGIN failed--compilation aborted" error, then continue parsing and fail. This fix re-throws PerlExitException so it propagates to Main.main() which converts it to System.exit(). Fixes tests that use `plan skip_all` in BEGIN blocks (e.g., Log4perl's t/056SyncApp2.t and t/066SQLite.t) - they now skip cleanly without errors. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <noreply@cognition.ai> * Update log4perl-compatibility.md with exit() in BEGIN fix and status updates - Document PR #331 fix for exit() inside BEGIN blocks - Update test results (now 700 tests, 26 failures) - Mark 'local' package variable bug as verified fixed - Update t/020Easy.t issue category Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <noreply@cognition.ai> --------- Co-authored-by: Devin <noreply@cognition.ai>
1 parent fce1177 commit 9d88063

9 files changed

Lines changed: 241 additions & 35 deletions

File tree

dev/design/local-package-variable-fix.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,121 @@ If Phase 4 shows performance issues:
288288
- Log4perl compatibility: `dev/design/log4perl-compatibility.md`
289289
- Affects: t/020Easy.t, t/022Wrap.t, t/024WarnDieCarp.t, t/051Extra.t
290290

291+
## Implementation Attempt Notes (2024-03-19)
292+
293+
### What Was Tried
294+
295+
**Approach 1: Skip `our` variables from closure capture**
296+
297+
Attempted to modify `SubroutineParser.java` and `EmitSubroutine.java` to skip `our` variables when building the closure variable list, so they wouldn't be captured at subroutine definition time and would instead be looked up fresh at runtime.
298+
299+
**Files modified:**
300+
- `SubroutineParser.java` (lines ~706-812): Skip variables with `declarationType.equals("our")` in closure capture loop
301+
- `EmitSubroutine.java` (lines ~102-138): Filter `our` from `visibleVariables` for anonymous subs
302+
303+
**Result:** The basic test case passed (`X=1` was printed correctly), but it broke Test::More and other modules with a subroutine constructor mismatch error:
304+
305+
```
306+
Subroutine error: org.perlonjava.anon51.<init>(org.perlonjava.runtime.runtimetypes.RuntimeHash,org.perlonjava.runtime.runtimetypes.RuntimeScalar,...) at jar:PERL5LIB/Test2/Util.pm line 8
307+
```
308+
309+
**Root Cause of Failure:** The issue is that when we skip `our` variables from closure capture, the generated class constructor signature changes (fewer parameters), but existing code that creates instances of those anonymous subroutines still passes the old number of arguments. This creates a method signature mismatch at runtime.
310+
311+
### Deeper Analysis Required
312+
313+
The fix needs to be more surgical:
314+
315+
1. **Don't change constructor signatures** - `our` variables still need to be in the closure capture list to maintain API compatibility
316+
2. **Change how captured `our` variables are used** - Instead of using the captured value, emit code to re-fetch from the global symbol table at access time
317+
318+
This aligns with "Option A" in the design doc but requires changes at the **variable access** level, not at the **closure capture** level.
319+
320+
### Recommended Next Steps
321+
322+
1. **Phase 1:** Implement `getOurVariableGlobalName()` in `ScopedSymbolTable.java` - track which variables are `our` and their fully-qualified global names
323+
2. **Phase 2:** In `EmitVariable.java` (JVM backend) and `BytecodeCompiler.java` (interpreter), when emitting code to access a variable, check if it's an `our` variable and if so, emit `LOAD_GLOBAL_*` instead of using the register
324+
3. **Phase 3:** Test with both the basic reproduction case AND Test::More
325+
326+
The key insight is: **`our` variables should still be captured in closures (to maintain constructor signatures), but the captured value should be ignored at access time in favor of a fresh global lookup.**
327+
328+
### Approach 2: Make `our` variables non-lexical in EmitVariable.java (2024-03-19)
329+
330+
**What Was Tried:**
331+
332+
Modified `EmitVariable.java` to treat `our` variables as non-lexical:
333+
1. Removed `our` from the `isLexical` check (lines 383-389)
334+
2. Added `@_` as a special exception (it's declared as `our` but is actually passed as a parameter)
335+
3. Added `isOurDeclaration` to `createIfNotExists` to allow `our` variable access
336+
337+
Also modified:
338+
- `ScopedSymbolTable.java`: Added overload `addVariable(name, decl, perlPackage, ast)` to preserve package
339+
- `SubroutineParser.java`: Used new overload for `our` variables when copying to subroutine scope
340+
341+
**Result:**
342+
- Basic `our`/`local` test cases PASSED (`X=1` printed correctly)
343+
- `@_` continued to work correctly
344+
- BUT: Test::More and Test2 modules FAILED with "Can't call method 'stack' on an undefined value"
345+
346+
**Root Cause of Test2 Failure:**
347+
348+
The Test2 framework uses a pattern like:
349+
```perl
350+
my $INST;
351+
use Test2::API::Instance(\$INST); # Sets $INST via import
352+
my $STACK = $INST->stack; # $INST is undef here!
353+
```
354+
355+
When loading a module, the `use` statement runs at compile time (as a BEGIN block). The lexical `$INST` declared with `my` should persist from the BEGIN block to the subsequent runtime code.
356+
357+
**Investigation revealed:** This is a pre-existing issue with how lexical variables work in BEGIN blocks:
358+
```perl
359+
my $x = 1;
360+
BEGIN { $x = 99; }
361+
print "x = $x\n"; # Prints "x = 1", not "x = 99"
362+
```
363+
364+
This behavior exists BOTH with and without the `our` fix. However, the Test2/Test::More modules work in the current codebase through some mechanism that my changes apparently break.
365+
366+
The interaction between my `our` variable changes and the BEGIN/lexical behavior needs further investigation. The changes to symbol table handling during subroutine compilation (SubroutineParser.java) may be affecting how lexicals are captured in BEGIN blocks.
367+
368+
### Successful Fix (2024-03-19)
369+
370+
**Root Cause Identified:** The issue had two components:
371+
372+
1. **`our` variables were treated as lexical:** In `EmitVariable.java`, `our` variables were stored in JVM local variable slots, capturing the value at subroutine definition time. This meant `local $Pkg::Var` changes weren't visible inside subroutines.
373+
374+
2. **Package information was lost:** When copying variables to subroutine scopes (in `EmitSubroutine.java` and `SubroutineParser.java`), the 3-argument `addVariable()` was used, which used `getCurrentPackage()` instead of preserving the original `perlPackage`.
375+
376+
**The Key Insight:** BEGIN blocks use `our` declarations in `PerlOnJava::_BEGIN_*` packages to capture outer `my` variables for persistence across compile/runtime. The fix must distinguish these from regular `our` variables:
377+
- `our` in `PerlOnJava::_BEGIN_*` packages → treat as lexical (use JVM slot)
378+
- Regular `our` → NOT lexical (look up from GlobalVariable)
379+
380+
**Files Modified:**
381+
382+
1. **`EmitVariable.java`** (lines 381-395, 439-453):
383+
- Added `isOurInBeginCapture` check: `our` variables in `PerlOnJava::_BEGIN_*` packages are treated as lexical
384+
- Regular `our` variables are now looked up from GlobalVariable at runtime
385+
- Added `isOurDeclaration` to `createIfNotExists` for proper variable creation
386+
- Added `@_` to `isLexical` (it's declared as `our` but is always lexical)
387+
388+
2. **`EmitSubroutine.java`** (line 125):
389+
- Changed from 3-argument to 4-argument `addVariable()` to preserve `perlPackage`
390+
391+
3. **`SubroutineParser.java`** (line 788):
392+
- Changed from 3-argument to 4-argument `addVariable()` to preserve `perlPackage`
393+
394+
**Test Results:**
395+
- All existing tests pass
396+
- New cross-package `our`/`local` tests pass (54 tests in local.t)
397+
- Test::More/Test2 modules work correctly
398+
- BEGIN block lexical persistence works correctly
399+
400+
### Current Status
401+
402+
- **Fix implemented and tested:** All tests pass
403+
- **Test cases enabled:** Cross-package `our`/`local` tests moved from `__END__` section to active tests
404+
- **Design documented:** This file contains the full analysis
405+
291406
## References
292407

293408
- Perl `our` documentation: https://perldoc.perl.org/functions/our

dev/design/log4perl-compatibility.md

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@
44

55
This document tracks the work needed to make `./jcpan Log::Log4perl` fully pass its test suite on PerlOnJava.
66

7-
## Current Status (2026-03-18)
7+
## Current Status (2026-03-19)
88

99
### Test Results
1010

1111
```
12-
Files=73, Tests=695
13-
Failed 8/73 test programs (down from 9)
14-
Failed 23/695 subtests (down from 28)
12+
Files=73, Tests=700
13+
Failed 8/73 test programs
14+
Failed 26/700 subtests
1515
```
1616

1717
### Failing Tests Summary
1818

1919
| Test File | Failed/Total | Issue Category |
2020
|-----------|--------------|----------------|
2121
| t/016Export.t | 1/16 | DESTROY message |
22-
| t/020Easy.t | 5/21 | Carp.pm undef GLOB reference (4 are filename mismatches) |
22+
| t/020Easy.t | 3/21 | caller() / Carp line numbers |
2323
| t/022Wrap.t | 2/5 | caller() stack trace format |
2424
| t/024WarnDieCarp.t | 11/73 | caller() / Carp line numbers |
2525
| t/026FileApp.t | 3/27 | File permissions / substr issues |
@@ -67,6 +67,26 @@ my $m = Carp::longmess(); # Sometimes fails with undef GLOB
6767

6868
## Completed Fixes
6969

70+
### 8. exit() Inside BEGIN Blocks (PR #331, 2026-03-19)
71+
72+
**Problem:** `exit()` inside a BEGIN block caused "BEGIN failed--compilation aborted" error instead of exiting the program cleanly.
73+
74+
**Symptom:**
75+
```
76+
$ ./jperl -e 'BEGIN { exit 0; } print "should not print"'
77+
exit 0
78+
BEGIN failed--compilation aborted at -e line 1, near ""
79+
```
80+
81+
**Root Cause:** `SpecialBlockParser.runSpecialBlock()` caught all `Throwable` exceptions (including `PerlExitException`) and converted them to `PerlCompilerException` with "BEGIN failed" message.
82+
83+
**Fix:** Added specific catch for `PerlExitException` that re-throws it, allowing it to propagate to `Main.main()` which converts it to `System.exit()`.
84+
85+
**Files Changed:**
86+
- `src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java`
87+
88+
**Tests Fixed:** Tests using `plan skip_all` in BEGIN blocks (e.g., t/056SyncApp2.t, t/066SQLite.t) now skip cleanly without compilation errors.
89+
7090
### 1. *{NAME} Glob Slot Accessor (Committed 2026-03-18)
7191

7292
**Problem:** `*{$glob}{NAME}` returned empty string instead of the glob's name.
@@ -169,36 +189,24 @@ my $m = Carp::longmess(); # Sometimes fails with undef GLOB
169189

170190
## Remaining Issues
171191

172-
### Issue 1: `local` Package Variable Bug (ROOT CAUSE)
192+
### Issue 1: `local` Package Variable Bug - VERIFIED FIXED
173193

174-
**Symptom:** `$Carp::CarpLevel` set via `local` from outside the Carp package is not visible when accessed inside Carp.pm using the short name `$CarpLevel`.
194+
**Status:** The basic reproduction case now works correctly. Need to verify if Log4perl tests improved.
175195

176-
**Reproduction:**
196+
**Verification (2026-03-19):**
177197
```perl
178198
package Foo;
179199
our $X = 0;
180-
sub check { print "X=$X\n"; } # Uses short name
200+
sub check { print "X=$X\n"; }
181201

182202
package main;
183203
local $Foo::X = 1;
184-
Foo::check(); # jperl prints "X=0", Perl prints "X=1"
204+
Foo::check(); # jperl now correctly prints "X=1"
185205
```
186206

187-
**Root Cause:** When compiling `$X` inside `package Foo`, jperl resolves it to a different storage location than `$Foo::X`. The `local` modifier only affects the fully-qualified name, not the short name used inside the package.
188-
189-
**Impact:** This breaks any module that uses `local $Module::Variable` to temporarily modify package state, including:
190-
- `$Carp::CarpLevel` - affects all Carp error reporting
191-
- Many other modules that use this pattern
192-
193-
**Affected Tests:**
194-
- t/020Easy.t (tests 18-20) - line numbers off due to CarpLevel not working
195-
- t/022Wrap.t (2 failures) - same root cause
196-
- t/024WarnDieCarp.t (11 failures) - same root cause
197-
- t/051Extra.t (2 failures) - same root cause
198-
199-
**Workaround:** Modules can use fully-qualified variable names (`$Carp::CarpLevel` instead of `$CarpLevel`) but this requires patching every affected module.
207+
The issue may have been fixed by earlier changes (possibly the `our` variable handling in SymbolTable). The design doc at `dev/design/local-package-variable-fix.md` was created but implementation may not have been needed.
200208

201-
**Fix Needed:** Investigate how package variables are resolved during compilation. The short name `$X` inside `package Foo` should resolve to the same glob as `$Foo::X`.
209+
**Note:** Some Log4perl tests still fail with caller() / Carp line number issues - these may be separate from the `local` issue.
202210

203211
### Issue 2: Carp.pm / warnings.pm Interaction
204212

src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,11 @@ public static void emitSubroutine(EmitterContext ctx, SubroutineNode node) {
118118
newSymbolTable.enterScope();
119119

120120
// Add only the filtered visible variables (excluding 'our sub' entries)
121+
// IMPORTANT: Use the 4-argument version to preserve the original perlPackage
122+
// This is critical for 'our' variables declared in BEGIN captures (PerlOnJava::_BEGIN_*)
123+
// which must retain their original package to work correctly with the 'local' fix
121124
for (SymbolTable.SymbolEntry entry : visibleVariables.values()) {
122-
newSymbolTable.addVariable(entry.name(), entry.decl(), entry.ast());
125+
newSymbolTable.addVariable(entry.name(), entry.decl(), entry.perlPackage(), entry.ast());
123126
}
124127

125128
// Copy package, subroutine, and flags from the current context

src/main/java/org/perlonjava/backend/jvm/EmitVariable.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,14 +378,20 @@ static void handleVariableOperator(EmitterVisitor emitterVisitor, OperatorNode n
378378
// Note: @_ is lexical in PerlOnJava (unlike standard Perl where it's package-scoped)
379379
boolean isDeclared = symbolEntry != null;
380380

381-
// A variable is lexical if it was declared with my/our/state
382-
// These are stored in JVM local variable slots, not in GlobalVariable registry
381+
// A variable is lexical if it was declared with my/state.
382+
// 'our' variables have special handling:
383+
// - For BEGIN block captures (package starts with "PerlOnJava::_BEGIN_"): treat as lexical
384+
// This is needed because BEGIN blocks re-declare outer 'my' variables as 'our' for persistence
385+
// - For regular 'our' variables: NOT lexical - must look up from GlobalVariable
386+
// This ensures 'local $Pkg::Var' changes are visible inside subroutines
387+
boolean isOurInBeginCapture = isDeclared
388+
&& symbolEntry.decl().equals("our")
389+
&& symbolEntry.perlPackage().startsWith("PerlOnJava::_BEGIN_");
383390
boolean isLexical = isDeclared && (
384391
symbolEntry.decl().equals("my")
385392
|| symbolEntry.decl().equals("state")
386-
|| symbolEntry.decl().equals("our")
387-
// Note: @_ special handling is disabled as it breaks some tests
388-
// || (symbolEntry.decl().equals("our") && symbolEntry.name().equals("@_"))
393+
|| isOurInBeginCapture // 'our' in BEGIN captures are lexical
394+
|| symbolEntry.name().equals("@_") // @_ is always lexical
389395
);
390396

391397
if (!isLexical) {
@@ -430,6 +436,9 @@ static void handleVariableOperator(EmitterVisitor emitterVisitor, OperatorNode n
430436
}
431437
}
432438

439+
// Check if this is an 'our' declaration (not in BEGIN capture) - these create global vars
440+
boolean isOurDeclaration = isDeclared && symbolEntry.decl().equals("our") && !isOurInBeginCapture;
441+
433442
// Compute createIfNotExists flag - determines if variable can be auto-vivified
434443
boolean createIfNotExists = name.contains("::") // Fully qualified: $Package::var
435444
|| (ScalarUtils.isInteger(name) && !name.startsWith("0")) // Regex capture: $1, $2, etc.
@@ -440,13 +449,14 @@ static void handleVariableOperator(EmitterVisitor emitterVisitor, OperatorNode n
440449
|| isNonAsciiLengthOneScalarAllowedUnderNoUtf8(emitterVisitor.ctx, sigil, name)
441450
|| allowIfAlreadyExists
442451
|| !emitterVisitor.ctx.symbolTable.isStrictOptionEnabled(HINT_STRICT_VARS) // no strict 'vars'
443-
|| (isDeclared && isLexical); // Lexically declared (my/our/state)
452+
|| isOurDeclaration // 'our' declarations (global variable aliases)
453+
|| (isDeclared && isLexical); // Lexically declared (my/state)
444454

445455
// Fetch the global variable (may throw exception if strict and not allowed)
446456
fetchGlobalVariable(emitterVisitor.ctx, createIfNotExists, sigil, name, node.getIndex());
447457
} else {
448458
// ===== LEXICAL VARIABLE ACCESS =====
449-
// Variable is lexical (my/our/state), load it from JVM local variable slot
459+
// Variable is lexical (my/state/@_/BEGIN-captured-our), load it from JVM local variable slot
450460
mv.visitVarInsn(Opcodes.ALOAD, symbolEntry.index());
451461
}
452462

src/main/java/org/perlonjava/core/Configuration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public final class Configuration {
3333
* Automatically populated by Gradle/Maven during build.
3434
* DO NOT EDIT MANUALLY - this value is replaced at build time.
3535
*/
36-
public static final String gitCommitId = "9519c1617";
36+
public static final String gitCommitId = "3e40937a2";
3737

3838
/**
3939
* Git commit date of the build (ISO format: YYYY-MM-DD).

src/main/java/org/perlonjava/frontend/parser/SpecialBlockParser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ static RuntimeList runSpecialBlock(Parser parser, String blockPhase, Node block)
250250
new BlockNode(nodes, tokenIndex),
251251
parser.tokens,
252252
parsedArgs);
253+
} catch (PerlExitException e) {
254+
// exit() inside BEGIN block should terminate the program, not cause compilation error
255+
// Re-throw so it propagates to the CLI (Main.main()) which will call System.exit()
256+
throw e;
253257
} catch (Throwable t) {
254258
if (parsedArgs.debugEnabled) {
255259
// Print full JVM stack

src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,9 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S
771771
filteredSnapshot.enterScope();
772772

773773
// Copy all visible variables except field declarations and code references
774+
// IMPORTANT: Use the 4-argument version to preserve the original perlPackage
775+
// This is critical for 'our' variables which must retain their declared package
776+
// for correct global lookup (especially with the 'local' fix)
774777
Map<Integer, SymbolTable.SymbolEntry> visibleVars = parser.ctx.symbolTable.getAllVisibleVariables();
775778
for (SymbolTable.SymbolEntry entry : visibleVars.values()) {
776779
// Skip field declarations when creating snapshot for bytecode generation
@@ -782,7 +785,7 @@ public static ListNode handleNamedSubWithFilter(Parser parser, String subName, S
782785
if (sigil.equals("&")) {
783786
continue;
784787
}
785-
filteredSnapshot.addVariable(entry.name(), entry.decl(), entry.ast());
788+
filteredSnapshot.addVariable(entry.name(), entry.decl(), entry.perlPackage(), entry.ast());
786789
}
787790

788791
// Clone the current package

src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,22 @@ public int addVariable(String name, String variableDeclType, OperatorNode ast) {
237237
return symbolTableStack.peek().addVariable(name, variableDeclType, getCurrentPackage(), ast);
238238
}
239239

240+
/**
241+
* Adds a variable to the current scope with an explicit package.
242+
* This is needed when copying 'our' variables to subroutine scopes,
243+
* where the original package must be preserved for correct global lookup.
244+
*
245+
* @param name The name of the variable to add.
246+
* @param variableDeclType The declaration type (my/our/state).
247+
* @param perlPackage The Perl package where the variable was declared.
248+
* @param ast The AST node for the declaration.
249+
* @return The index of the variable in the current scope.
250+
*/
251+
public int addVariable(String name, String variableDeclType, String perlPackage, OperatorNode ast) {
252+
clearVisibleVariablesCache();
253+
return symbolTableStack.peek().addVariable(name, variableDeclType, perlPackage, ast);
254+
}
255+
240256
public void addVariableWithIndex(String name, int index, String variableDeclType) {
241257
clearVisibleVariablesCache();
242258
symbolTableStack.peek().addVariableWithIndex(name, index, variableDeclType, getCurrentPackage());

0 commit comments

Comments
 (0)