Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
eecc892
fix: SCOPE_EXIT_CLEANUP type guards and GlobalDestruction CME
fglock Apr 10, 2026
df00d08
docs: update DBIx::Class plan with Phase 9 re-baseline after DESTROY/…
fglock Apr 10, 2026
3b9bb81
fix: prevent premature DESTROY by tracking named container local bind…
fglock Apr 10, 2026
a598143
fix: bundle Devel::GlobalDestruction with plain Exporter
fglock Apr 11, 2026
e0b7db7
feat: bundle DBI::Const::GetInfoType and related modules
fglock Apr 11, 2026
5f75cf4
docs: add Phase 10 full-suite re-baseline to DBIx::Class plan
fglock Apr 11, 2026
46eb4cd
docs: refine Phase 10 categorization with detailed failure analysis
fglock Apr 11, 2026
d34d2bc
fix: suppress MortalList flush during setFromList materialization
fglock Apr 11, 2026
61af173
docs: trim Phase 11 to focus on actionable next steps
fglock Apr 11, 2026
fe7d072
docs: update DBIx::Class plan with Step 11.2 failure analysis and rev…
fglock Apr 11, 2026
4f1ed14
fix: cascade hash/array cleanup for blessed objects without DESTROY
fglock Apr 11, 2026
088898a
docs: update DBIx::Class plan with Step 11.4 investigation and fix de…
fglock Apr 11, 2026
439bbcb
docs: update DBIx::Class plan with Step 11.4 findings and next steps
fglock Apr 11, 2026
43ea1d3
docs: Phase 12 plan — all DBIx::Class tests must pass
fglock Apr 11, 2026
a4c18ce
fix: DBI numeric formatting, DBI_DRIVER env, HandleError callback
fglock Apr 11, 2026
87de4f4
docs: add resource management warning to AGENTS.md
fglock Apr 11, 2026
82fb4b7
fix: suppress mortal flush on do-block scope exit
fglock Apr 11, 2026
56f1b38
fix: fire DESTROY for blessed objects when die unwinds through eval
fglock Apr 11, 2026
a5a1214
feat: DESTROY-on-die for blessed my-variables (Phase 13)
fglock Apr 11, 2026
4c375f0
fix: flush deferred DESTROY in void-context sub calls
fglock Apr 11, 2026
b77dc88
fix: DBI handle lifecycle — localBindingExists, finish, circular refs
fglock Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@

---

## ⚠️ Resource Management: Avoid Fork Exhaustion ⚠️

**Do NOT spawn excessive parallel processes.** Running too many background shells, subagents, or parallel builds at once can exhaust the system's process table (fork bomb), forcing a reboot and losing work.

- **Limit parallel operations**: Run at most 2-3 concurrent processes at a time
- **Avoid unnecessary background shells**: Use foreground execution when you don't need parallelism
- **Wait for processes to finish** before starting new ones when possible
- **Never run `make` in parallel with other heavy processes** (builds already use multiple threads internally)
- **Clean up**: Kill background shells when they're no longer needed

---

## Project Rules

### Progress Tracking for Multi-Phase Work
Expand Down Expand Up @@ -60,6 +72,10 @@ Example format at the end of a design doc:
- Keep docs updated as implementation progresses
- Reference related docs and skills at the end

### Sandbox Tests

- `dev/sandbox/destroy_weaken/` — Tests for DESTROY and weaken behavior (cascading cleanup, scope exit timing, blessed-without-DESTROY, etc.). Run with `./jperl` or `perl` for comparison.

### Partially Implemented Features

| Feature | Status |
Expand Down
1,411 changes: 1,377 additions & 34 deletions dev/modules/dbix_class.md

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions dev/patches/cpan/DBIx-Class-0.082844/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# DBIx::Class 0.082844 Patches for PerlOnJava

## Problem

DBIx::Class uses `TxnScopeGuard` which relies on `DESTROY` for automatic
transaction rollback when a scope guard goes out of scope without being
committed. On PerlOnJava (JVM), `DESTROY` does not fire deterministically,
so:

1. Failed bulk inserts leave `transaction_depth` permanently elevated
2. Subsequent transactions silently nest instead of creating new top-level transactions
3. `BEGIN`/`COMMIT` disappear from SQL traces
4. Failed populates don't roll back (partial data left in DB)

## Fix

Wrap `txn_scope_guard`-protected code in `eval { ... } or do { rollback; die }`
to ensure explicit rollback on error, instead of relying on guard DESTROY.

## Files Patched

### Storage/DBI.pm — `_insert_bulk` method (line ~2415)
- Wraps bulk insert + query_start/query_end + guard->commit in eval block
- On error: sets guard inactivated, calls txn_rollback, re-throws

### ResultSet.pm — `populate` method
- **List context path** (line ~2239): wraps map-insert loop + guard->commit in eval
- **Void context with rels path** (line ~2437): wraps _insert_bulk + children rels + guard->commit in eval

## Applying Patches

Patches must be applied to BOTH locations:
1. Installed modules: `~/.perlonjava/lib/DBIx/Class/Storage/DBI.pm` and `ResultSet.pm`
2. CPAN build dir: `~/.cpan/build/DBIx-Class-0.082844-*/lib/DBIx/Class/Storage/DBI.pm` and `ResultSet.pm`

```bash
# From the PerlOnJava project root:
cd ~/.perlonjava/lib
patch -p0 < path/to/dev/patches/cpan/DBIx-Class-0.082844/Storage-DBI.pm.patch

# Also patch the active CPAN build dir (find the latest one):
BUILDDIR=$(ls -td ~/.cpan/build/DBIx-Class-0.082844-*/lib | head -1)
cd "$BUILDDIR/.."
patch -p0 < path/to/dev/patches/cpan/DBIx-Class-0.082844/Storage-DBI.pm.patch
```

## Tests Fixed

- t/100populate.t: tests 37-42 (void ctx trace BEGIN/COMMIT), 53 (populate is atomic),
59 (literal+bind normalization), 104-107 (multicol-PK has_many trace)
- Result: 108/108 real tests pass (was 98/108), only GC tests 109-112 remain

## Date

2026-04-11
230 changes: 230 additions & 0 deletions dev/sandbox/destroy_weaken/destroy_no_destroy_method.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
use strict;
use warnings;
use Test::More;
use Scalar::Util qw(weaken isweak);

# =============================================================================
# destroy_no_destroy_method.t — Cascading cleanup for blessed objects
# without a DESTROY method
#
# When a blessed hash goes out of scope and its class does NOT define
# DESTROY, Perl must still decrement refcounts on the hash's values.
# This is critical for patterns like DBIx::Class where intermediate
# Moo objects (e.g. BlockRunner) hold strong refs to tracked objects
# but don't define DESTROY themselves.
#
# Root cause: DestroyDispatch.callDestroy skips scopeExitCleanupHash
# for blessed objects whose class has no DESTROY method, leaking the
# refcounts of the hash's values.
# =============================================================================

# --- Blessed holder WITHOUT DESTROY should still release contents ---
{
my @log;
{
package NDM_Tracked;
sub new { bless {}, shift }
sub DESTROY { push @log, "tracked" }
}
{
package NDM_HolderNoDestroy;
sub new { bless { target => $_[1] }, $_[0] }
# No DESTROY defined
}
my $weak;
{
my $tracked = NDM_Tracked->new;
$weak = $tracked;
weaken($weak);
my $holder = NDM_HolderNoDestroy->new($tracked);
}
is_deeply(\@log, ["tracked"],
"blessed holder without DESTROY still triggers DESTROY on contents");
ok(!defined $weak,
"tracked object is collected when holder without DESTROY goes out of scope");
}

# --- Contrast: blessed holder WITH DESTROY properly releases contents ---
{
my @log;
{
package NDM_TrackedB;
sub new { bless {}, shift }
sub DESTROY { push @log, "tracked" }
}
{
package NDM_HolderWithDestroy;
sub new { bless { target => $_[1] }, $_[0] }
sub DESTROY { push @log, "holder" }
}
my $weak;
{
my $tracked = NDM_TrackedB->new;
$weak = $tracked;
weaken($weak);
my $holder = NDM_HolderWithDestroy->new($tracked);
}
is_deeply(\@log, ["holder", "tracked"],
"blessed holder with DESTROY cascades to contents");
ok(!defined $weak,
"tracked object is collected when holder with DESTROY goes out of scope");
}

# --- Contrast: unblessed hashref properly releases contents ---
{
my @log;
{
package NDM_TrackedC;
sub new { bless {}, shift }
sub DESTROY { push @log, "tracked" }
}
my $weak;
{
my $tracked = NDM_TrackedC->new;
$weak = $tracked;
weaken($weak);
my $holder = { target => $tracked };
}
is_deeply(\@log, ["tracked"],
"unblessed hashref releases tracked contents");
ok(!defined $weak,
"tracked object is collected when unblessed holder goes out of scope");
}

# --- Nested: blessed-no-DESTROY holds blessed-no-DESTROY holds tracked ---
{
my @log;
{
package NDM_TrackedD;
sub new { bless {}, shift }
sub DESTROY { push @log, "tracked" }
}
{
package NDM_OuterNoDestroy;
sub new { bless { inner => $_[1] }, $_[0] }
}
{
package NDM_InnerNoDestroy;
sub new { bless { target => $_[1] }, $_[0] }
}
my $weak;
{
my $tracked = NDM_TrackedD->new;
$weak = $tracked;
weaken($weak);
my $inner = NDM_InnerNoDestroy->new($tracked);
my $outer = NDM_OuterNoDestroy->new($inner);
}
ok(!defined $weak,
"nested blessed-no-DESTROY chain still releases tracked object");
}

# --- Weak backref pattern (Schema/Storage cycle) ---
#
# Schema (blessed, has DESTROY) ──strong──> Storage
# Storage (blessed, has DESTROY) ──weak────> Schema
# BlockRunner (blessed, NO DESTROY) ──strong──> Storage
#
# When BlockRunner goes out of scope, Storage refcount must decrement.
# Later when Schema goes out of scope, cascading DESTROY must bring
# Storage refcount to 0.
{
my @log;
{
package NDM_Storage;
use Scalar::Util qw(weaken);
sub new {
my ($class, $schema) = @_;
my $self = bless {}, $class;
$self->{schema} = $schema;
weaken($self->{schema});
return $self;
}
sub DESTROY { push @log, "storage" }
}
{
package NDM_Schema;
sub new { bless {}, $_[0] }
sub DESTROY { push @log, "schema" }
}
{
package NDM_BlockRunner;
sub new { bless { storage => $_[1] }, $_[0] }
# No DESTROY — like DBIx::Class::Storage::BlockRunner
}

my $weak_storage;
{
my $schema = NDM_Schema->new;
my $storage = NDM_Storage->new($schema);
$schema->{storage} = $storage;

$weak_storage = $storage;
weaken($weak_storage);

# Simulate dbh_do: create a BlockRunner that holds storage
my $runner = NDM_BlockRunner->new($storage);
undef $storage;

# Runner goes out of scope here — must release storage ref
undef $runner;
# Now only $schema->{storage} should hold storage
}
# After block: schema out of scope -> DESTROY schema -> cascade -> DESTROY storage
ok(!defined $weak_storage,
"Schema/Storage/BlockRunner pattern: storage collected after all go out of scope");
my @sorted = sort @log;
ok(grep({ $_ eq "schema" } @sorted) && grep({ $_ eq "storage" } @sorted),
"both schema and storage DESTROY fired");
}

# --- Explicit undef of blessed-no-DESTROY should release contents ---
{
my @log;
{
package NDM_TrackedE;
sub new { bless {}, shift }
sub DESTROY { push @log, "tracked" }
}
{
package NDM_HolderNoDestroyE;
sub new { bless { target => $_[1] }, $_[0] }
}
my $weak;
my $tracked = NDM_TrackedE->new;
$weak = $tracked;
weaken($weak);
my $holder = NDM_HolderNoDestroyE->new($tracked);
undef $tracked; # only holder keeps it alive
ok(defined $weak, "tracked still alive via holder");
undef $holder; # should cascade-release tracked
ok(!defined $weak,
"explicit undef of blessed-no-DESTROY holder releases tracked object");
is_deeply(\@log, ["tracked"], "DESTROY fired on tracked after holder undef");
}

# --- Array-based blessed object without DESTROY ---
{
my @log;
{
package NDM_TrackedF;
sub new { bless {}, shift }
sub DESTROY { push @log, "tracked" }
}
{
package NDM_ArrayHolder;
sub new { bless [ $_[1] ], $_[0] }
# No DESTROY
}
my $weak;
{
my $tracked = NDM_TrackedF->new;
$weak = $tracked;
weaken($weak);
my $holder = NDM_ArrayHolder->new($tracked);
}
ok(!defined $weak,
"array-based blessed-no-DESTROY releases tracked object");
}

done_testing;
Original file line number Diff line number Diff line change
Expand Up @@ -1093,10 +1093,12 @@ public void visit(BlockNode node) {
}

// Exit scope restores register state.
// Flush mortal list for non-subroutine blocks so DESTROY fires promptly
// at scope exit. Subroutine body blocks must NOT flush — the implicit
// return value may still be in a register and flushing could destroy it.
exitScope(!node.getBooleanAnnotation("blockIsSubroutine"));
// Flush mortal list for non-subroutine, non-do blocks so DESTROY fires
// promptly at scope exit. Subroutine body blocks and do-blocks must NOT
// flush — the implicit return value may still be in a register and
// flushing could destroy it before the caller captures it.
exitScope(!node.getBooleanAnnotation("blockIsSubroutine")
&& !node.getBooleanAnnotation("blockIsDoBlock"));

if (needsLocalRestore) {
emit(Opcodes.POP_LOCAL_LEVEL);
Expand Down Expand Up @@ -5134,10 +5136,17 @@ private void visitAnonymousSubroutine(SubroutineNode node) {
private void visitEvalBlock(SubroutineNode node) {
int resultReg = allocateRegister();

// Record the first register that will be allocated inside the eval body.
// Registers from firstBodyReg up to peakRegister will be cleaned up on
// exception to ensure DESTROY fires for blessed objects going out of scope.
int firstBodyReg = nextRegister;

// Emit EVAL_TRY with placeholder for catch target (absolute address)
// and the first body register for exception cleanup
emitWithToken(Opcodes.EVAL_TRY, node.getIndex());
int catchTargetPos = bytecode.size();
emitInt(0); // Placeholder for absolute catch address (4 bytes)
emitReg(firstBodyReg); // First register allocated inside eval body

// Track eval block nesting for "goto &sub from eval" detection
evalBlockDepth++;
Expand Down
Loading