Skip to content

Ruby 4.0 parity: Binding's implicit-parameter API and Thread#raise's cause: - #872

Merged
tannevaled merged 5 commits into
mainfrom
binding-implicit-parameters
Nov 3, 2026
Merged

tannevaled merged 5 commits into
mainfrom
binding-implicit-parameters

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The two largest blocks of the Ruby 4.0 version move in #799 — 56 of the ~75
examples
it opens. They ship together because they are one finding: the move
adds surface that a pass-count delta cannot see, and both of these were sitting
behind ruby_version_is "4.0" guards.

1. Binding's implicit-parameter API (37 examples)

Ruby 4.0 adds Binding#implicit_parameters, #implicit_parameter_get and
#implicit_parameter_defined? (proc.c v4.0.9:680-783). They are the other
half of taking _1 out of #local_variables: the implicit parameters do not
vanish, they move to an API of their own.

Two facts from the reference carry the implementation:

  • MRI keeps them in the local table under IDs the program cannot spell — it
    under idItImplicit rather than idIt. So they have a name and a value while
    being absent from #local_variables.
  • Every local-variable method reads with get_local_variable_ptr(&env, lid, **TRUE**) and every implicit-parameter one with FALSE (:688, :734,
    :782). That final argument is "search the parent scopes", and it alone is
    the whole of "ignores a numbered parameter defined in a parent scope", which
    ruby/spec pins four times over.

bytecode.ISeq gains ImplicitParams, a second channel beside Locals. rbgo
already blanked Locals for it; this records the name instead of discarding
it, and the public local table is unchanged.

⛔ The two refusals are kept apart, as MRI keeps them — the shape of the name
is checked before any lookup (:726, :775):

:a   -> NameError  "'a' is not an implicit parameter"
:_1  -> NameError  "implicit parameter '_1' is not defined for …"

Collapsing them would answer "not defined" for a name no scope could ever hold.

Not in this change: _1 stays visible to #local_variables. ruby/spec
pins it so under ruby_version_is ""..."4.0" and pins the hiding only from 4.0,
and rbgo reports 3.4.1. Hiding it belongs with the version bump — which will
flip the channel this adds rather than invent one.

2. Thread#raise's cause: keyword (19 examples)

ruby/spec includes the shared :kernel_raise_with_cause examples into
core/thread/raise_spec.rb from 4.0 and not before.

rbgo already had the rule. Fiber#raise built its exception through a helper
that handles cause:, the circular-cause refusal, the "only cause is given"
refusal and the backtrace argument. Thread#raise built its own by hand and
silently dropped both cause: and the third positional backtrace argument
Kernel#raise has always taken. Two places, one rule, one of them right.

The builder is now shared rather than copied, renamed asyncRaiseException for
what it is: the exception for a raise aimed at another context.

⛔ Running it in the caller's context is what makes the automatic chaining
right: cause is the caller's $! at the moment of the call, not the target's
— which is what ruby/spec's :kernel_raise_across_contexts pins from 4.0. The
target thread in those examples is asleep and has no current exception at all.

Tests, and their controls

23 behaviour cases and 7 error cases for the Binding trio; 9 for Thread#raise,
all on the sleeping-thread shape, because a raise on the current thread
takes a different path and would have exercised none of the delivery.

Each half carries a control that would have caught its obvious wrong turn:

  • it must still be absent from #local_variables and unreachable through
    #local_variable_get, and _1 must still be present. Without it, making
    it reachable would have passed everything else.
  • a dead thread must still ignore #raise and answer nil — pinned outside
    any version guard, and the easiest thing to break by moving argument handling
    earlier.

Full internal suite green (260 s), no regression.

Refs #799

🤖 Generated with Claude Code

tannevaled and others added 3 commits November 2, 2026 18:34
Ruby 4.0 adds Binding#implicit_parameters, #implicit_parameter_get and
#implicit_parameter_defined? (proc.c v4.0.9:680-783). They are the other half of
taking `_1` out of #local_variables: the implicit parameters do not vanish, they
move to an API of their own.

rbgo had none of the three, and nothing said so. The three ruby/spec files are
`pass=0 fail=0 error=0` below 4.0 -- every example behind a
`ruby_version_is "4.0"` guard -- and 37 failures above it, so a version-move
measurement that compared PASSING counts read zero on all three. That is 37 of
the ~75 examples #799's move opens, and the largest single gap in it.

## The reading

Two facts from the reference, both load-bearing:

  * MRI keeps implicit parameters in the local table under IDs the program
    cannot spell: `it` under idItImplicit rather than idIt, and the numbered
    ones under IDs rb_numparam_id_p recognises. So they have a name and a value
    while being absent from #local_variables.

  * Every local-variable method reads with get_local_variable_ptr(&env, lid,
    TRUE) and every implicit-parameter one with FALSE (:688, :734, :782). That
    final argument is "search the parent scopes", and it alone is the whole of
    "ignores a numbered parameter defined in a parent scope", which ruby/spec
    pins four times over.

## The change

`bytecode.ISeq` gains `ImplicitParams`, a second channel beside `Locals` naming
the implicit slots. rbgo already blanked `Locals` for `it` so it could not be
seen; this records the name instead of discarding it, and the public local table
is unchanged.

`Binding` captures those names at DEPTH 0 only, which is MRI's FALSE, and the
three methods read them. `implicit_parameters` answers [:it] or the numbered
ones and never a mixture, because a block cannot have both.

⛔ The two refusals are kept apart, as MRI keeps them -- the shape of the NAME is
checked before any lookup (:726, :775):

    :a   -> NameError "'a' is not an implicit parameter"
    :_1  -> NameError "implicit parameter '_1' is not defined for …"

Collapsing them would answer "not defined" for a name no scope could ever hold.

## Not in this change

`_1` stays VISIBLE to #local_variables. ruby/spec pins it so under
`ruby_version_is ""..."4.0"` and pins the hiding only from 4.0, and rbgo reports
3.4.1; hiding it belongs with the version bump. The channel this adds is what
that bump will flip, so it will not have to invent one.

## Tests

Sixteen behaviour cases and seven error cases, each a shape the three spec files
pin, plus a control that would have caught the obvious wrong turn: `it` must
STILL be absent from #local_variables and unreachable through
#local_variable_get, and `_1` must still be present. Without it, making `it`
reachable would have passed everything else.

Full internal suite green, no regression.

Refs #799

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y used

Ruby 4.0 gives Thread#raise the cause: keyword. ruby/spec includes the shared
`:kernel_raise_with_cause` examples into core/thread/raise_spec.rb from 4.0 and
not before, which is 19 of the examples #799's version move opens in that one
file -- the second largest block in the move after Binding's implicit
parameters.

rbgo already had the rule. Fiber#raise built its exception through
fiberRaiseException, which handles cause:, the circular-cause refusal, the
"only cause is given" refusal and the backtrace argument. Thread#raise built
its own by hand:

    if len(args) == 0 { exc = RuntimeError.new("") } else { exc = raiseExceptionObject(args) }

-- which silently dropped cause: AND the third positional backtrace argument
Kernel#raise has always taken. Two places, one rule, and only one of them
right.

So the builder is shared rather than copied, renamed asyncRaiseException for
what it is: the exception for a raise aimed at ANOTHER context. A second copy
would have had to be kept in step with the first.

⛔ Running it in the CALLER's context is what makes the automatic chaining
right. `cause` is the caller's $! at the moment of the call and not the
target's, which is exactly what ruby/spec's `:kernel_raise_across_contexts`
pins from 4.0 -- the target thread in those examples is asleep and has no
current exception at all.

## Tests

Nine cases on the SLEEPING-thread shape, because Thread#raise on the CURRENT
thread takes a different path (it panics here and now) and would have exercised
none of the delivery: cause: set, cause: nil blocking the chain, the automatic
cause coming from the caller, no cause at all, the two ArgumentErrors, the
TypeError, and the backtrace argument that was being dropped.

⛔ Plus the control: a dead thread must STILL ignore the raise and answer nil.
That is pinned outside any version guard and is the easiest thing to break by
moving argument handling earlier.

Full internal suite green, no regression.

Refs #799

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A native cannot tell `raise("m", {cause: e})` from `raise("m", cause: e)`:
both arrive as a trailing *object.Hash and nothing marks which it was. The
defect predates this function -- Kernel#raise and Fiber#raise have always had
it, measured on the control -- and it is why Thread#raise now fails
`raises a TypeError when passed a message and an extra argument`, an example it
used to pass BECAUSE it ignored cause: entirely.

Refs #873

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled

Copy link
Copy Markdown
Contributor Author

Measured against a control built from the same commit minus the change

Both engines built at 4.0.9 (RUBY_VERSION printed, not assumed), same
corpus, same host, three passes each, cmd/specdelta:

PROVENANCE
  control   rbgo409ctl — built from ced0e31b07f7
  treatment rbgo409    — built from bf7e4f05f178
MOVE none declared: both engines read the same corpus, so every delta below is the ENGINE

core/binding/implicit_parameter_get_spec.rb          0 → 14   +14
core/binding/implicit_parameter_defined_spec.rb      0 → 13   +13
core/binding/implicit_parameters_spec.rb             0 → 10   +10
12 file(s): control 51 passing, treatment 88 passing, +37
3 file(s) moved, 0 regressed

core/thread/raise_spec.rb                           51 → 68   +17
53 file(s): control 328 passing, treatment 345 passing, +17
1 file(s) moved, 0 regressed

The control reads 0 on the three Binding files, which is the invisible case
this whole thread started from.

⛔ The +17 is a net, and the net hides a swap

Comparing the control's failure labels against the treatment's, example by
example rather than by count:

fixed the whole :kernel_raise_with_cause block, and the :kernel_raise_across_contexts cause block — 17 examples
also fixed allows Exception, message, and backtrace_locations parameters (×2), from the backtrace argument that was being dropped
also fixed calls #set_backtrace only in the caller thread
⛔ broken raises a TypeError when passed a message and an extra argument (×2)

That last one is written with a brace literal:

-> { @object.raise("message", {cause: RuntimeError.new()}) }
    .should.raise(TypeError, "exception class/object expected")

Two positional arguments. A native in rbgo cannot tell a positional Hash from
keyword arguments — it receives a trailing *object.Hash either way — so
popCauseKwarg reads it as cause:. Measured on the control, before any of
this change:

Kernel#raise positional Hash: [RuntimeError, "message"]
Fiber#raise positional Hash:  [RuntimeError, "message"]

Both already wrong. The defect is older than this PR and wider than raise;
Thread#raise passed that example only because of a different defect — it
ignored cause: entirely, so there was no trailing Hash to mispop.

Filed as #873, and marked at asyncRaiseException where it bites. Trading
two examples that passed by accident for seventeen that now pass on purpose is
the right trade, but it is a trade and not a clean win, and the per-file delta
could not have shown it: +17 is what a pass count says when sixteen move one
way and two move the other.

The nine failures left in core/thread/raise_spec.rb are all pre-existing and
none touch cause:: raise of an Exception instance (TypeError: class or module required), the constructor-arity example, the two positional-Hash ones
above, and three about the interrupted thread's backtrace and #exception.

🤖 Generated with Claude Code

The coverage ratchet named four functions this branch dropped below 100%, and
the four split cleanly in two.

## Unreachable, so deleted

    internal/compiler/compiler.go hideImplicitBlockParams  91.7%
    internal/vm/vm.go             collectBindingLocals     97.1%

Both carried a `name == ""` arm I wrote out of caution. It cannot run:
blockParamsAreImplicit already means the parser synthesised the WHOLE parameter
list, so there is no anonymous slot among them and the list is dense from slot
0. The guards were not defensive, they were dead -- and a dead branch is exactly
what a per-function gate is for. ISeq.ImplicitParams now says it is dense.

## Written and never driven, so tested

    internal/vm/binding.go copyImplicit     33.3%
    internal/vm/binding.go registerBinding  95.8%

Every `dup`/`clone` in the suite happened to be of a binding with NO implicit
parameters, so the copy was never exercised; and the three arity refusals were
written without a single call reaching them, which is the same as not having
them. Six cases added: dup and clone of a binding that HAS one (plus the
control, one that has none), and each method called with the wrong number of
arguments.

Verified locally with the gate CI runs, not by hoping:

    coverage ratchet OK -- no function newly below 100%

Refs #799

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 5f51213 into main Nov 3, 2026
26 checks passed
@tannevaled
tannevaled deleted the binding-implicit-parameters branch November 3, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant