Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c19d83e
Implement method coverage
sferik Sep 14, 2026
c7cf3b8
Do not leave a method coverage counter pending when a define_method c…
sferik Sep 14, 2026
90d08e4
Count method coverage for the initialize of Java subclasses
sferik Sep 14, 2026
34ecb4a
Only build the parser's per-line coverage array when lines are being …
sferik Sep 14, 2026
032c5fc
Use a plain list for a file's method coverage entries
sferik Sep 14, 2026
86e4423
Do the method coverage hand-off from one place per interpreted method…
sferik Sep 15, 2026
15e85ab
Rewrite the method coverage comments in plain, compact English
sferik Sep 15, 2026
664db68
Count method coverage for methods defined by an eval
sferik Sep 16, 2026
107c4ad
Set the line and source span of blocks that follow command arguments
sferik Sep 16, 2026
8ba51d8
Encode the operand of the method coverage instruction
sferik Sep 16, 2026
533a68a
Report whether a line was actually counted
sferik Sep 16, 2026
9f9b455
Keep the unknown span marker out of method coverage keys
sferik Sep 16, 2026
caf18c6
Key a method coverage entry by the name it was defined under
sferik Sep 16, 2026
9894de3
Detach method coverage counters when measurement stops
sferik Sep 16, 2026
b9ee86c
Let the super-forwarding scan skip the method coverage instructions
sferik Sep 16, 2026
a12ddd6
Clear a method coverage counter atomically
sferik Sep 16, 2026
102a76f
Build the coverage result outside the CoverageData lock
sferik Sep 16, 2026
f2756ad
Keep method registration off the lock when methods are not measured
sferik Sep 16, 2026
3f3421c
Read and clear the coverage counts in one step
sferik Sep 16, 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
Prev Previous commit
Next Next commit
Count method coverage for the initialize of Java subclasses
The initialize of a Ruby subclass of a Java class does not run through
DynamicMethod#call: ConcreteJavaProxy runs it up to its super call
through the exitable interpreter (or a compiled terminal split), and
when the body is a plain super forwarding its arguments it does not run
it at all. None of those paths handed the entry's coverage counter to
the body, so such an initialize reported zero calls.

The hand-off now happens in AbstractIRMethod#startSplitSuperCall, with
the compiled-or-interpreted choice split out into splitSuperCall so that
MixedModeIRMethod can still delegate to its jitted method (which is not
the registered entry and holds no counter). The two ConcreteJavaProxy
shortcuts that skip the Ruby body altogether count the call directly;
they have already established that the arguments match the arity, which
is where MRI fires the CALL event it counts.
  • Loading branch information
sferik committed Sep 14, 2026
commit 90d08e420b4f9ef25fff680e54dc84faf5d783ac
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,24 @@ public SplitSuperState<?> startSplitSuperCall(ThreadContext context, IRubyObject
if (ic == null) return null;

SplitSuperState<MethodSplitState> directState = MethodSplitState.directSuperState(context, ic, args, block);
if (directState != null) return directState;
if (directState != null) {
coverElidedCall(context); // the body never runs, so Coverage cannot count the call from inside it
return directState;
}

// The body runs through interpretSplit or a compiled terminal split rather than DynamicMethod#call, so the
// hand-off of this entry's Coverage counter to it happens here.
prepareMethodCoverage(context);

return splitSuperCall(context, self, klazz, name, args, block, ic);
}

/**
* Run a split method up to its super call: compiled when the method can escape at the super, interpreted otherwise.
*/
public SplitSuperState<?> splitSuperCall(ThreadContext context, IRubyObject self,
RubyModule klazz, String name, IRubyObject[] args, Block block,
ExitableInterpreterContext ic) {
if (ic.canEscapeAtSuper()) {
SplitSuperState<MethodSplitState> compiledState = tryCompiledTerminalSplit(context, self, klazz, name, args, block, ic);
if (compiledState != null) return compiledState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,16 @@ protected final void prepareMethodCoverage(ThreadContext context) {
if (methodCoverage != null) context.setPendingMethodCoverage(methodCoverage);
}

/**
* Count a call of this entry whose body is not going to run at all: the initialize of a Java subclass that is a
* plain super forwarding its arguments is elided by ConcreteJavaProxy, but MRI still fires the CALL event for
* it. Callers have already established that the arguments match the method's arity.
*/
public final void coverElidedCall(ThreadContext context) {
MethodCoverage methodCoverage = this.methodCoverage;
if (methodCoverage != null && context.runtime.getCoverageData().isRunning()) methodCoverage.cover();
}

/**
* Indicates the method will behave like a ruby2 keywords accepting method.
* This must be a Ruby implementation to work. See Module#ruby2_keywords
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,18 @@ public SplitSuperState<?> startSplitSuperCall(ThreadContext context, IRubyObject
ExitableInterpreterContext ic) {
if (callCount >= 0) tryJit(context, this, false);

return super.startSplitSuperCall(context, self, clazz, name, args, block, ic);
}

@Override
public SplitSuperState<?> splitSuperCall(ThreadContext context, IRubyObject self,
RubyModule clazz, String name, IRubyObject[] args, Block block,
ExitableInterpreterContext ic) {
if (actualMethod instanceof AbstractIRMethod irMethod) {
return irMethod.startSplitSuperCall(context, self, clazz, name, args, block, ic);
return irMethod.splitSuperCall(context, self, clazz, name, args, block, ic);
}

return super.startSplitSuperCall(context, self, clazz, name, args, block, ic);
return super.splitSuperCall(context, self, clazz, name, args, block, ic);
}

private void doDebug() {
Expand Down
22 changes: 14 additions & 8 deletions core/src/main/java/org/jruby/java/proxies/ConcreteJavaProxy.java
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,11 @@ private boolean isValid(final RubyClass base) {
* Returns true when {@code args} can be forwarded directly to the Java superclass constructor without
* entering the split interpreter (e.g. {@code def initialize(*args); super(*args); end}).
*/
private boolean canSkipDirectSuper(IRubyObject[] args) {
return eic != null && eic.directSuperForwardable(args.length);
private boolean canSkipDirectSuper(ThreadContext context, IRubyObject[] args) {
if (eic == null || !eic.directSuperForwardable(args.length)) return false;

air.coverElidedCall(context); // the Ruby initialize is skipped entirely, but it is still a call of it
return true;
}

/**
Expand All @@ -477,6 +480,8 @@ private IRubyObject[] terminalLiteralSuperArgs(ThreadContext context, int argsLe
ExitableInterpreterContext ic = eic;
if (ic == null || !ic.terminalLiteralSuperForwardable(argsLength)) return null;

air.coverElidedCall(context); // as above: the Ruby initialize is skipped entirely

IRubyObject[] cached = cachedTerminalLiteralArgs;
if (cached != null) return cached;

Expand Down Expand Up @@ -522,23 +527,24 @@ public SplitCtorData splitInitialized(RubyClass base, IRubyObject[] args, Block

private SplitCtorData splitInitialized(SplitCtorPlan plan, IRubyObject[] args, Block block, ConstructorCache jcc, boolean fromRubySuper) {
final Ruby runtime = getRuntime();
final ThreadContext context = runtime.getCurrentContext();
final AbstractIRMethod air = plan.air;
final ExitableInterpreterContext eic = plan.eic;
final String name = plan.name;
final RubyClass sourceLocation = plan.sourceLocation;
final RubyModule effectiveSource = plan.effectiveSource;

if (plan.prependedJavaCtorWrapper && air != null) {
if (plan.canSkipDirectSuper(args)) {
if (plan.canSkipDirectSuper(context, args)) {
return splitInitialized(plan.prependedSuperPlan(sourceLocation.getSuperClass()), args, block, jcc, true);
}

IRubyObject[] literalArgs = plan.terminalLiteralSuperArgs(runtime.getCurrentContext(), args.length);
IRubyObject[] literalArgs = plan.terminalLiteralSuperArgs(context, args.length);
if (literalArgs != null) {
return splitInitialized(plan.prependedSuperPlan(sourceLocation.getSuperClass()), literalArgs, block, jcc, true);
}

SplitSuperState<?> state = air.startSplitSuperCall(runtime.getCurrentContext(), this, effectiveSource, name, args, block, eic);
SplitSuperState<?> state = air.startSplitSuperCall(context, this, effectiveSource, name, args, block, eic);
IRubyObject[] forwardedArgs = state == null ? args : state.callArgs;
Block forwardedBlock = state == null ? block : state.callBlockArgs;
SplitCtorData ctorData = splitInitialized(plan.prependedSuperPlan(sourceLocation.getSuperClass()), forwardedArgs, forwardedBlock, jcc, true);
Expand All @@ -547,11 +553,11 @@ private SplitCtorData splitInitialized(SplitCtorPlan plan, IRubyObject[] args, B

// jcreate is for nested ruby classes from a java class
if (shouldSplitJavaConstructorInitialize(plan, fromRubySuper) && air != null) {
if (plan.canSkipDirectSuper(args)) {
if (plan.canSkipDirectSuper(context, args)) {
return splitSuperInitialized(plan, args, block, jcc);
}

IRubyObject[] literalArgs = plan.terminalLiteralSuperArgs(runtime.getCurrentContext(), args.length);
IRubyObject[] literalArgs = plan.terminalLiteralSuperArgs(context, args.length);
if (literalArgs != null) {
SplitCtorData cachedTerminator = plan.cachedLiteralSuperTerminator;
if (cachedTerminator != null) return cachedTerminator;
Expand All @@ -561,7 +567,7 @@ private SplitCtorData splitInitialized(SplitCtorPlan plan, IRubyObject[] args, B
return terminator;
}

SplitSuperState<?> state = air.startSplitSuperCall(runtime.getCurrentContext(), this, effectiveSource, name, args, block, eic);
SplitSuperState<?> state = air.startSplitSuperCall(context, this, effectiveSource, name, args, block, eic);
if (state == null) { // no super in method
return new SplitCtorData(runtime, args, jcc, air, effectiveSource, name, block);
}
Expand Down
28 changes: 28 additions & 0 deletions test/jruby/test_coverage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,34 @@ def test_method_coverage_leaves_nothing_behind_when_a_define_method_call_fails
end
end

JAVA_SUBCLASSES = <<~'RUBY'
class Covered < java.util.ArrayList
def initialize(x)
super()
@x = x
end
end
class Sub < java.util.ArrayList
def initialize(x)
super(x)
end
end
RUBY

# A Java subclass's initialize is run through the split-constructor machinery (or skipped entirely when it is a
# plain super), not through a regular method call.
def test_method_coverage_counts_java_subclass_initialize
with_source(JAVA_SUBCLASSES) do |path|
Coverage.start(methods: true)
load path
3.times { Covered.new(1) }
3.times { Sub.new(1) }
methods = Coverage.result[path][:methods]
assert_equal 3, methods[[Covered, :initialize, 2, 2, 5, 5]]
assert_equal 3, methods[[Sub, :initialize, 8, 2, 10, 5]]
end
end

def test_method_coverage_follows_suspend_resume_and_clear
with_source(METHODS) do |path|
Coverage.setup(methods: true)
Expand Down