Repository navigation
Ruby 4.0 parity: Binding's implicit-parameter API and Thread#raise's cause: - #872
Conversation
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>
Measured against a control built from the same commit minus the changeBoth engines built at 4.0.9 ( The control reads 0 on the three Binding files, which is the invisible case ⛔ The +17 is a net, and the net hides a swapComparing the control's failure labels against the treatment's, example by
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 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 Filed as #873, and marked at The nine failures left in 🤖 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>
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_getand#implicit_parameter_defined?(proc.cv4.0.9:680-783). They are the otherhalf of taking
_1out of#local_variables: the implicit parameters do notvanish, they move to an API of their own.
Two facts from the reference carry the implementation:
itunder
idItImplicitrather thanidIt. So they have a name and a value whilebeing absent from
#local_variables.get_local_variable_ptr(&env, lid, **TRUE**)and every implicit-parameter one withFALSE(: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.ISeqgainsImplicitParams, a second channel besideLocals. rbgoalready blanked
Localsforit; this records the name instead of discardingit, 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):
Collapsing them would answer "not defined" for a name no scope could ever hold.
Not in this change:
_1stays visible to#local_variables. ruby/specpins 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'scause:keyword (19 examples)ruby/spec includes the shared
:kernel_raise_with_causeexamples intocore/thread/raise_spec.rbfrom 4.0 and not before.rbgo already had the rule.
Fiber#raisebuilt its exception through a helperthat handles
cause:, the circular-cause refusal, the "only cause is given"refusal and the backtrace argument.
Thread#raisebuilt its own by hand andsilently dropped both
cause:and the third positional backtrace argumentKernel#raisehas always taken. Two places, one rule, one of them right.The builder is now shared rather than copied, renamed
asyncRaiseExceptionforwhat 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:
causeis the caller's$!at the moment of the call, not the target's— which is what ruby/spec's
:kernel_raise_across_contextspins from 4.0. Thetarget 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:
itmust still be absent from#local_variablesand unreachable through#local_variable_get, and_1must still be present. Without it, makingitreachable would have passed everything else.#raiseand answernil— pinned outsideany 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