Imported from fglock/PerlOnJava (
dev/prompts/SKILL.md). Install upstream withnpx skills add fglock/PerlOnJava --skill prompts. Copyright stays with the author.
PerlOnJava Debugging Skills and Architecture Knowledge
This document captures key knowledge about PerlOnJava internals learned during debugging sessions.
Variable Storage and Scoping
Three Types of Variable Declarations
-
myvariables - Lexical scope- Stored in JVM local variable slots during normal execution
- When captured by closures: stored as closure fields or in GlobalVariable with IDs
- Symbol table entry:
decl = "my", hasindex(JVM slot number)
-
ourvariables - Package scope with lexical declaration- Actually stored in GlobalVariable (package namespace)
- Symbol table entry:
decl = "our", hasindexbut uses package name - Access:
GlobalVariable.getGlobalVariable("Package::varname")
-
use varsvariables - Package scope without lexical declaration- No symbol table entry at all
- Directly stored in GlobalVariable
- Imported at runtime by
Vars.importVars() - Access:
GlobalVariable.getGlobalVariable("Package::varname")
File-Scope Variables and IDs
When large code blocks are refactored, file-scope my variables can get IDs assigned:
my %allGroups = (); # File-scope lexical
# Large block triggers refactoring
for (1..10000) { ... } # This creates closures
# Now %allGroups has an ID (e.g., 75)
What happens:
SubroutineParser.handleNamedSubWithFilter()line 700-709 assigns IDs to variables that need to be captured- The variable is moved to GlobalVariable with key
PerlOnJava::_BEGIN_75::allGroups - References to it call
PersistentVariable.retrieveBeginHash("allGroups", 75) - This retrieves from GlobalVariable, not JVM slots
Key insight: Variables with IDs behave like BEGIN variables even if not in BEGIN blocks.
GlobalVariable Storage
The GlobalVariable registry is a runtime storage system:
// Storage maps
private static final ConcurrentHashMap<String, RuntimeScalar> globalVariables
private static final ConcurrentHashMap<String, RuntimeArray> globalArrays
private static final ConcurrentHashMap<String, RuntimeHash> globalHashes
// Access
RuntimeHash hash = GlobalVariable.getGlobalHash("Package::varname");
// Creates new empty hash if doesn't exist
Important methods:
getGlobalHash()- Returns existing or creates newremoveGlobalHash()- Removes from map and returns (transfer ownership)existsGlobalHash()- Check if exists (for strict vars)
BEGIN Blocks
Compile-Time Execution
BEGIN blocks execute during parsing, not at runtime:
my $x;
BEGIN { $x = 42 } # Executes immediately when parsed
print $x; # Uses the value set at compile-time
Implementation:
- Parser encounters BEGIN block → executes it immediately
- Variables modified in BEGIN are stored in GlobalVariable with unique IDs
- The BEGIN block itself is removed from the AST (doesn't appear in generated code)
- Later references retrieve from GlobalVariable using
PersistentVariable.retrieveBegin*()
Storage pattern:
Variable: $x in BEGIN block with ID 42
Storage key: PerlOnJava::_BEGIN_42::x
Retrieved via: PersistentVariable.retrieveBeginScalar("$x", 42)
Why BEGIN Variables Work Across Refactoring
BEGIN variables use name-based lookup, not JVM slot numbers:
- Normal
myvariables:ALOAD <slot>(breaks if slot reallocated) - BEGIN variables:
PersistentVariable.retrieveBeginScalar(name, id)(always works)
Closures and Variable Capture
How Closures Are Created
When a subroutine/closure is created, it captures variables from outer scopes:
my $outer = 1;
my $closure = sub { $outer + 1 }; # Captures $outer
Implementation (SubroutineParser.java lines 680-730):
- Scan outer symbol tables for variables the closure references
- Determine storage:
- If
decl == "our": Use package name, store as GlobalVariable reference - If
decl == "my"or"state":- If has ID: Use
PersistentVariable.retrieveBegin*() - If no ID: Use JVM slot (captured as constructor parameter)
- If has ID: Use
- If
- Create closure class with fields for captured variables
- Constructor receives captured variables and stores in fields
- apply() method accesses variables via fields
Capture By Reference
Critical: Closures capture variables by reference, not by value:
my $x = 1;
my $c1 = sub { $x };
$x = 2;
my $c2 = sub { $x };
print $c1->(); # Prints 2 (not 1!)
print $c2->(); # Prints 2
Both closures see the same $x reference. Changes are visible to all closures.
Closure Instantiation vs Execution
Two phases:
-
Instantiation (closure constructor):
NEW org/perlonjava/anon42 DUP ALOAD <captured_var_1> // Variables captured here ALOAD <captured_var_2> INVOKESPECIAL org/perlonjava/anon42.<init>Variables are captured during instantiation, even if uninitialized.
-
Execution (closure apply):
ALOAD <closure_object> ALOAD <args> ILOAD <context> INVOKEVIRTUAL org/perlonjava/anon42.applyVariables are accessed/used during execution.
Key insight: If a variable is initialized AFTER closure instantiation but BEFORE execution, the closure sees the initialized value (because it captured by reference).
Method Too Large and Refactoring
The JVM Limit
The JVM has a hard limit: 65,535 bytes per method. Large Perl code blocks can exceed this.
Refactoring Strategy
LargeBlockRefactorer.trySmartChunking() splits large blocks:
Original: 10,000 statements in one method
↓
Refactored: sub { 4000 statements, sub { 3000 statements, sub { 3000 statements }->() }->() }->()
The Algorithm
Two paths:
-
treatAllElementsAsSafe = true(no labels, no control flow):- Skip backward iteration
- Process all elements as one safe run
- Create chunks from the end
-
treatAllElementsAsSafe = false(has labels or control flow):- Iterate backward from end
- Identify "chunk breakers" (labels, return, die, etc.)
- Create safe runs between breakers
Chunking process:
Start with all elements as one safe run:
safeRunLen = 100
safeRunEndExclusive = 100
Iteration 1: Take chunk [60..99]
chunkStart = 60
Create closure with elements [60..99]
Update: safeRunEndExclusive = 60, safeRunLen = 60
Iteration 2: Take chunk [30..59]
chunkStart = 30
Create closure with elements [30..59] + previous closure call
Update: safeRunEndExclusive = 30, safeRunLen = 30
After chunking: Add remaining [0..29] to result
safeRunStart = safeRunEndExclusive - safeRunLen = 0
Add elements [0..29]
Key insight: safeRunStart is RECALCULATED on each iteration. The algorithm is correct.
When Variables Get IDs
During closure creation in SubroutineParser.java:704:
if (ast.id == 0) {
ast.id = EmitterMethodCreator.classCounter++;
}
This assigns IDs to ANY my variable that gets captured by a refactored closure, not just BEGIN variables.
Debugging Techniques
Command-Line Tools
-
--debug- Emit debug information during compilation./jperl --debug script.plShows: use statements, warnings, compilation stages
-
--disassemble- Show JVM bytecode./jperl --disassemble script.pl > output.txtShows: Java classes, methods, bytecode instructions, LINENUMBER markers
-
--parse- Show AST structure./jperl --parse script.plShows: AST nodes, token positions (pos:N)
-
--tokenize- Show lexer tokens./jperl --tokenize script.plShows: Each token with type and position
Understanding LINENUMBER vs Line Numbers
Critical distinction:
-
LINENUMBERin disassembly output (from./jperl --disassemble) is TOKEN INDEX, not source line numberLINENUMBER 229 in bytecode = Token 229 in tokenizer output Use: ./jperl --tokenize file.pl | sed -n '229p' -
"line" in Perl error messages (runtime errors) is SOURCE LINE NUMBER
Error: at line 229 Source line 229: %fileTypeLookup = ( This IS the actual source line 229 in the file
Key insight: Don't confuse bytecode LINENUMBER (token index) with error message line numbers (source line).
Adding Debug Statements
Pattern for tracking execution:
// In RuntimeHash.java
private static volatile int debugHashId = -1;
public RuntimeArray setFromList(RuntimeList value) {
if (value.elements.size() == 6 && value.elements.get(1).toString().equals("ExifTool")) {
long timestamp = System.nanoTime();
debugHashId = System.identityHashCode(this);
System.err.println("DEBUG [" + timestamp + "] setFromList CALLED: hash=" +
System.identityHashCode(this) + " size=" + this.size());
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
for (int i = 2; i < Math.min(stack.length, 15); i++) {
System.err.println(" at " + stack[i]);
}
}
// ... rest of method
}
Use System.identityHashCode() to track object identity (not .equals() which might be overridden).
Use timestamps to track execution order when multiple events happen.
Comparing Bytecode Versions
# Generate disassembly before fix
./jperl --disassemble file.pl > /tmp/before.txt
# Apply fix, rebuild
make
# Generate disassembly after fix
./jperl --disassemble file.pl > /tmp/after.txt
# Compare
diff /tmp/before.txt /tmp/after.txt | less
# Or search for specific patterns
grep "setFromList" /tmp/before.txt
grep "setFromList" /tmp/after.txt
Finding Missing Code
Pattern: Code appears in source but not in bytecode
-
Check if refactoring happened:
grep -c "anon.*apply" disassembly.txtIf > 0, code was refactored into closures
-
Search in all anonymous classes:
grep -B5 -A20 "class org/perlonjava/anon" disassembly.txt | less -
Check if code is in a closure that's never called:
grep "LINENUMBER <token>" disassembly.txtMissing LINENUMBER means code wasn't emitted
Creating Unit Tests
Rule: Tests Must Create AST Nodes
WRONG:
# This creates ONE for loop in the AST
for (1..10000) {
$x++;
}
RIGHT:
# This creates 10,000 statements in the AST
$x++;
$x++;
$x++;
# ... repeat 9,997 more times
Generating Large Tests
cat > test.t << 'EOF'
use v5.38;
use Test::More;
my $x = 0;
EOF
# Generate 10,000 actual statements
perl -e 'print "\$x += 1;\n" x 10000' >> test.t
cat >> test.t << 'EOF'
is($x, 10000, "All statements executed");
done_testing();
EOF
Test Requirements for Refactoring
To trigger LargeBlockRefactorer:
- Need > 10,000 statements (or large bytecode size)
- Statements must be at same block level (not in subroutines)
- For
treatAllElementsAsSafepath: no labels, no last/next/redo/return
Why Simple Tests Often Don't Reproduce Bugs
- Optimization: Perl optimizes
$x++ for 1..Nto a loop, not N statements - Size threshold: Need enough bytecode, not just lines of code
- Specific patterns: Bug might require specific statement types or combinations
- Timing: Bug might occur during specific compilation phases
Things That Seemed Broken But Weren't
False Lead #1: LargeBlockRefactorer Losing Elements
What I thought: Lines 383-386 only add [safeRunStart..safeRunEndExclusive-1], so elements [0..safeRunStart-1] are lost.
Reality: safeRunStart = safeRunEndExclusive - safeRunLen is RECALCULATED on each iteration. After chunking completes, this formula correctly identifies the remaining elements.
How I found out:
- Couldn't create a test case to reproduce the bug
- Traced through algorithm manually with concrete numbers
- Realized
safeRunStartchanges on each iteration
Lesson: If you can't reproduce a bug with a test, you probably misunderstood the code.
False Lead #2: Variables With IDs Don't Work With Backslash Operator
What I thought: \%allGroups where %allGroups has an ID doesn't create a proper reference.
Reality: The backslash operator (handleCreateReference) evaluates the operand (which loads the variable correctly) then calls createReference() on it. Works fine.
How I found out:
- Created test cases with
\%hashwhere hash has ID - All tests passed
- Examined
EmitOperator.handleCreateReference()- code is correct
Lesson: Test your hypothesis before claiming bugs.
False Lead #3: ExifTool Bug is About Refactoring
What I thought: ExifTool failure is caused by refactorer bug.
Reality: Fixed refactorer "bug" (which wasn't a bug), ExifTool still fails with same error.
How I found out: Applied fix, ExifTool still broken.
Lesson: Don't assume cause based on symptoms. Follow the evidence.
Key Architecture Insights
Separation of Concerns
- Parser creates AST (in
org.perlonjava.astnode) - Refactorer modifies AST to avoid JVM limits (in
org.perlonjava.astrefactor) - Emitter converts AST to bytecode (in
org.perlonjava.codegen) - Runtime executes bytecode (in
org.perlonjava.runtime.runtimetypes)
Each layer doesn't need to know about the others' internals.
Symbol Tables Are Scoped
Each block/subroutine has its own symbol table:
class ScopedSymbolTable {
Map<String, SymbolEntry> variableIndex; // Variables in this scope
ScopedSymbolTable parent; // Outer scope
}
Lookup walks up the parent chain until variable is found.
Two Compilation Strategies
- Normal variables: Compile-time slot allocation, runtime ALOAD/ASTORE
- BEGIN variables: Runtime name-based lookup via GlobalVariable
The second strategy is slower but more flexible (survives refactoring).
Best Practices for Future Debugging
- Always create a minimal test case first - If you can't reproduce it, you don't understand it
- Trust the evidence - If your "fix" doesn't change behavior, you're fixing the wrong thing
- Use the right tools - --disassemble for bytecode, --parse for AST, --tokenize for positions
- Add targeted debug - Track specific objects with identityHashCode, use timestamps for ordering
- Read the code forward AND backward - Start from error, trace back to cause
- Document false leads - Learn from mistakes, don't repeat them
- Test hypotheses - Write code to verify your theory before claiming bugs
Resources
CLAUDE.md- Project-specific guidance (READ THIS FIRST)SKILL.md- This document - debugging skills and architecture knowledge- Stack traces - JVM line numbers are relative to method start
- ASM library documentation - Understanding JVM bytecode generation
- Source code in
src/main/java/org/perlonjava/- The actual implementation