Skip to content
446 changes: 446 additions & 0 deletions dev/modules/dbix_class.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions dev/sandbox/walker_blind_spot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Walker blind spot in `MortalList.maybeAutoSweep()`

Investigation sandbox for the bug documented in
`dev/modules/dbix_class.md` "Investigation Plan: Schema-Detached Bug
in t/52leaks.t (line 430)".

## Summary

Under `./jcpan -t DBIx::Class`, `t/52leaks.t` occasionally throws
`Unable to perform storage-dependent operations with a detached
result source` mid-test. We confirmed it is caused by
`MortalList.maybeAutoSweep()` (5-s throttled) clearing the weak ref
from `ResultSource → Schema`.

`JPERL_NO_AUTO_GC=1` removes the crash but exposes 14/23 leak-detection
failures (the existing tests 12-18 issues), so the fix is NOT to
disable the sweep.

## Reproducer attempts (all PASS — none reproduce the actual bug)

| File | Pattern | Result |
|---|---|---|
| `simple_lexical_repro.t` | 1 schema, 1 result-source, 1 weakened back-ref, busy loop > 5 s | PASS in both default and JPERL_NO_AUTO_GC modes |
| `lexical_scalar_root_PASSES.t` | `my $obj = bless` lexical + weakened back-ref, JPERL_FORCE_SWEEP_EVERY_FLUSH=1 + 20× Internals::jperl_gc() | PASS — walker seeds the lexical correctly |
| `dbic_real_pattern_PASSES.t` | DBIC-shape: schema in global %REGISTRY, RS chain via `$phantom`, JPERL_FORCE_SWEEP_EVERY_FLUSH=1 | PASS — walker traces the global path correctly |

**Conclusion**: the walker correctly seeds both `my $scalar = $ref`
lexicals AND globally-registered schemas. The actual DBIC blind spot is
elsewhere — likely tied to Moo/Class::C3::XS/MRO interaction, DBIC's
accessor-magic for `_result_source`, Storable's seen-table inflating
refcounts during `dclone`, or some other DBIC-specific structural cycle.

## How to find the actual bug

Don't speculate further. Use the diagnostic infrastructure now in PR #635:

1. **`JPERL_FORCE_SWEEP_EVERY_FLUSH=1`** — landed in this PR.
Bypasses the 5-s sweep throttle so timing-dependent races trigger
on every statement boundary.

2. **Add `JPERL_WALKER_TRACE=1`** (next investigator's job).
Instrument `ReachabilityWalker.sweepWeakRefs()` so every cleared
weak ref logs target identity + `findPathTo()` output + which
seeding sources were active.

3. Run `JPERL_FORCE_SWEEP_EVERY_FLUSH=1 JPERL_WALKER_TRACE=1
./jcpan -t DBIx::Class` and inspect the first cleared
Schema/ResultSource. The path-not-found tells you exactly which
seeding gate dropped it.

## Pointers

- `src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java`
- `src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java` — `maybeAutoSweep()`
- `src/main/java/org/perlonjava/runtime/runtimetypes/MyVarCleanupStack.java`
- `src/main/java/org/perlonjava/runtime/runtimetypes/ScalarRefRegistry.java`
- Disable while debugging: `JPERL_NO_AUTO_GC=1`
- Force sweep on every flush: `JPERL_FORCE_SWEEP_EVERY_FLUSH=1`
- Trace mode (existing): `JPERL_GC_DEBUG=1`
76 changes: 76 additions & 0 deletions dev/sandbox/walker_blind_spot/dbic_real_pattern_PASSES.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env perl
# More accurate reproducer of DBIC's t/52leaks.t pattern.
# The schema is NOT kept in a my-lexical. Once $phantom is reassigned
# past it, the schema's reachability depends on whatever global
# structures DBIC's init_schema leaves behind.
use strict;
use warnings;
use Scalar::Util qw(weaken);
use Test::More;

unless ($ENV{JPERL_FORCE_SWEEP_EVERY_FLUSH}) {
plan skip_all => 'set JPERL_FORCE_SWEEP_EVERY_FLUSH=1';
}

package My::Schema {
our %REGISTRY; # global — strongly holds every schema we create
sub new {
my $class = shift;
my $self = bless { sources => {}, name => 'main' }, $class;
$self->{sources}{Artist} = My::ResultSource->new($self, 'Artist');
$REGISTRY{$self} = $self; # strong global ref
return $self;
}
sub source { $_[0]->{sources}{$_[1]} }
}

package My::ResultSource {
use Scalar::Util qw(weaken);
sub new {
my ($class, $schema, $name) = @_;
my $self = bless { schema => $schema, name => $name }, $class;
weaken $self->{schema};
return $self;
}
sub schema {
$_[0]->{schema} or die "DETACHED at $_[0]->{name}\n"
}
sub resultset {
my $self = shift;
bless { source => $self }, 'My::ResultSet';
}
}

package My::ResultSet {
sub source { $_[0]->{source} }
sub schema { $_[0]->source->schema }
}

package main;

# DBIC pattern: chain replaces $phantom each iter; schema only kept
# alive via the global %My::Schema::REGISTRY hash.
my $phantom;
for my $step (
sub { My::Schema->new }, # creates schema
sub { shift->source('Artist') }, # $phantom = RS, schema in global
sub { shift->resultset }, # ← needs schema via weak ref
sub { shift->source }, # back to RS
sub { shift->resultset }, # again
sub { shift->schema }, # FINAL: must dereference schema
sub { shift->source('Artist') },
sub { shift->resultset },
) {
Internals::jperl_gc() if defined &Internals::jperl_gc;
my $err;
eval { $phantom = $step->($phantom); 1 } or $err = $@;
if ($err) {
diag "FAILURE: $err";
fail("step failed: $err");
last;
}
pass("step OK; \$phantom now ref=" . (ref($phantom) // 'scalar'));
}

ok( defined $phantom, 'final $phantom defined' );
done_testing;
87 changes: 87 additions & 0 deletions dev/sandbox/walker_blind_spot/lexical_scalar_root_PASSES.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env perl
# Reachability walker must seed `my $scalar = $ref` lexicals as roots,
# so that auto-sweep does not clear weak refs to objects held by them.
#
# Background: PerlOnJava's MortalList.maybeAutoSweep() runs the
# ReachabilityWalker periodically (5s throttle by default) to clear
# weak refs whose referents are no longer reachable. The walker seeds
# its root set from globals, MyVarCleanupStack, and ScalarRefRegistry.
# ScalarRefRegistry is a WeakHashMap and its snapshot races against the
# `forceGcAndSnapshot()` call that immediately precedes each sweep —
# under cumulative GC pressure, a `my $obj = $ref` lexical can be GC'd
# from the registry between the force-GC and the snapshot read, even
# though it's still alive on the JVM stack.
#
# When that happens, the walker treats the referent as unreachable and
# clears any weak refs to it. In DBIx::Class this surfaces as
# `t/52leaks.t line 430`: `Unable to perform storage-dependent operations
# with a detached result source` — the schema's weak ref from
# ResultSource::Table got cleared while the test scope's `my $schema`
# was still alive.
#
# This test forces the race deterministically using
# `JPERL_FORCE_SWEEP_EVERY_FLUSH=1` (which fires the auto-sweep on
# every statement boundary, no throttle) and verifies that the walker
# correctly sees the `my $obj` lexical as a root.
#
# Without the fix, this test FAILS — the weak ref is cleared and the
# `back_to_obj` accessor returns undef.
# With the fix (registering `my $scalar` declarations into
# MyVarCleanupStack the same way `my @arr` / `my %hash` are), the test
# PASSES because path (b) in ReachabilityWalker.walk() always finds it.

use strict;
use warnings;
use Scalar::Util qw(weaken);
use Test::More;

# Skip unless the test was launched with the debug knob.
unless ($ENV{JPERL_FORCE_SWEEP_EVERY_FLUSH}) {
plan skip_all =>
'set JPERL_FORCE_SWEEP_EVERY_FLUSH=1 to run this regression test';
}

package My::Holder {
use Scalar::Util qw(weaken);
sub new {
my ($class, $obj) = @_;
my $self = bless { back => $obj }, $class;
weaken $self->{back};
return $self;
}
sub back_to_obj {
my $self = shift;
return $self->{back}
// die "DETACHED: weak ref cleared while \$obj still alive\n";
}
}

# Standard pattern: a `my $obj` lexical holds a strong ref;
# elsewhere a separate object stores a WEAK back-ref to it.
my $obj = bless { id => 'ALIVE' }, 'My::Stuff';
my $holder = My::Holder->new($obj);

ok( $holder->back_to_obj, 'baseline: weak ref intact at t=0' );
is(
$holder->back_to_obj->{id}, 'ALIVE',
'baseline content correct'
);

# Force several auto-sweeps. Internals::jperl_gc() forces JVM GC + sweep;
# additionally, with FORCE_SWEEP_EVERY_FLUSH=1, every statement below also
# triggers a sweep at its flush point. So we hit the race many times.
for my $i ( 1 .. 20 ) {
Internals::jperl_gc() if defined &Internals::jperl_gc;
my $err;
my $r;
eval { $r = $holder->back_to_obj; 1 } or $err = $@;
last if $err;
ok( defined $r, "iteration $i: weak ref still resolves" );
is( $r->{id}, 'ALIVE', "iteration $i: content preserved" );
}

# `$obj` lexical is still alive in this scope — that's the point of the
# test. The walker should see it as a root.
ok( defined $obj, 'final: $obj lexical still alive' );

done_testing;
76 changes: 76 additions & 0 deletions dev/sandbox/walker_blind_spot/simple_lexical_repro.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env perl
# Minimal reproducer for the ReachabilityWalker blind spot in
# MortalList.maybeAutoSweep() — the bug that breaks DBIx::Class
# t/52leaks.t under jcpan -t DBIx::Class.
#
# Pattern (matches DBIC's Schema ↔ ResultSource ↔ Row chain):
#
# my $schema = ...; # strong ref in main lexical
# my $rs = My::ResultSource->new($schema);
# $rs->{schema} -> weakened reference back to $schema
#
# At every Perl statement boundary, MortalList.flush() may invoke
# maybeAutoSweep() (5-s throttle, fires once `weakRefsExist` is true).
# That sweep walks reachable objects from globals and lexicals, then
# clears weak refs to unreachable ones.
#
# Since $schema lives in a `my` slot held strongly by the running
# main scope, it is reachable. The bug: ReachabilityWalker fails
# to seed the live lexical as a root, so the schema is classified
# unreachable, and the weak ref from $rs->{schema} is cleared.

use strict;
use warnings;
use Scalar::Util qw(weaken);
use Test::More;

package My::Schema;
sub new { bless { name => 'main schema' }, shift }

package My::ResultSource;
use Scalar::Util qw(weaken);
sub new {
my ($class, $schema) = @_;
my $self = bless { schema => $schema }, $class;
weaken $self->{schema};
return $self;
}
sub schema {
my $self = shift;
return $self->{schema}
|| die "detached: weak ref to schema cleared\n";
}

package main;

my $schema = My::Schema->new;
my $rs = My::ResultSource->new($schema);

ok( $rs->schema, 'weak ref intact at t=0' );

# Burn > 5 s of wall clock at statement boundaries so
# MortalList.maybeAutoSweep() definitely fires at least once.
# Each iteration is a separate statement → triggers flush → may sweep.
my $deadline = time() + 7;
my $iterations = 0;
while ( time() < $deadline ) {
$iterations++;
my @junk = ( 1 .. 50 );
my %junk = ( a => 1, b => 2 );
}

# After auto-sweep should have fired several times, the weak ref MUST
# still resolve — `$schema` is still strongly held in our main scope.
my $s;
my $err;
eval { $s = $rs->schema; 1 } or $err = $@;
ok( !$err, "schema still reachable after $iterations iterations + auto-sweep" )
or diag "got error: $err";
is( defined($s) ? $s->{name} : '<undef>',
'main schema',
'schema content preserved' );

# Sanity: $schema lexical itself is still defined (no compiler issue)
ok( defined $schema, '$schema lexical itself still defined' );

done_testing;
4 changes: 2 additions & 2 deletions src/main/java/org/perlonjava/core/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public final class Configuration {
* Automatically populated by Gradle/Maven during build.
* DO NOT EDIT MANUALLY - this value is replaced at build time.
*/
public static final String gitCommitId = "9a1145435";
public static final String gitCommitId = "6883a9b9d";

/**
* Git commit date of the build (ISO format: YYYY-MM-DD).
Expand All @@ -48,7 +48,7 @@ public final class Configuration {
* Parsed by App::perlbrew and other tools via: perl -V | grep "Compiled at"
* DO NOT EDIT MANUALLY - this value is replaced at build time.
*/
public static final String buildTimestamp = "Apr 30 2026 11:43:39";
public static final String buildTimestamp = "Apr 30 2026 16:27:15";

// Prevent instantiation
private Configuration() {
Expand Down
22 changes: 18 additions & 4 deletions src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,15 @@ public static boolean suppressFlush(boolean suppress) {
System.getenv("JPERL_NO_AUTO_GC") != null;
private static final boolean AUTO_GC_DEBUG =
System.getenv("JPERL_GC_DEBUG") != null;
// Phase D-W6.20 (debug knob): force the auto-sweep on EVERY flush()
// call, bypassing the 5-s throttle and the `weakRefsExist` gate.
// Used to reproduce timing-dependent walker bugs (e.g. the
// ScalarRefRegistry-vs-forceGc race that surfaces as DBIC's
// "detached result source" mid-test crash). Off by default; when on,
// every Perl statement boundary triggers a full sweepWeakRefs walk —
// very slow, only for diagnostics.
private static final boolean FORCE_SWEEP_EVERY_FLUSH =
System.getenv("JPERL_FORCE_SWEEP_EVERY_FLUSH") != null;
private static boolean inAutoSweep = false;

// D-W6.18 perf: cached reachable-set, valid for the duration of a
Expand Down Expand Up @@ -642,14 +651,19 @@ && isReachableCached(base)) {
private static void maybeAutoSweep() {
if (AUTO_GC_DISABLED) return;
if (inAutoSweep) return;
if (!WeakRefRegistry.weakRefsExist) return;
// FORCE_SWEEP_EVERY_FLUSH bypasses the weakRefsExist gate AND the
// 5-s throttle so reproducers can deterministically trigger the
// walker at every statement boundary.
if (!FORCE_SWEEP_EVERY_FLUSH && !WeakRefRegistry.weakRefsExist) return;
// Phase B2a: skip while require/use/BEGIN/eval-STRING is running.
// Those paths depend on weak-refed intermediate state staying
// defined until the init completes.
if (ModuleInitGuard.inModuleInit()) return;
long now = System.nanoTime();
if (now - lastAutoSweepNanos < AUTO_SWEEP_MIN_INTERVAL_NS) return;
lastAutoSweepNanos = now;
if (!FORCE_SWEEP_EVERY_FLUSH) {
long now = System.nanoTime();
if (now - lastAutoSweepNanos < AUTO_SWEEP_MIN_INTERVAL_NS) return;
lastAutoSweepNanos = now;
}
inAutoSweep = true;
try {
// Quiet mode: only clear weak refs for unreachable objects,
Expand Down
Loading