Bug #22235
closednode_id/location for constant assignment failing while reading
Description
namespace = Module.new
begin
namespace::NotDefined += 1
rescue => e
pp e.backtrace_locations[0]
pp e.backtrace_locations[0].source_range
pp e.backtrace_locations[0].syntax_tree
end
On CRuby master it gives:
$ ruby -v const_assignment_fail_while_reading.rb
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
"const_assignment_fail_while_reading.rb:3:in '<main>'"
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,28)>
@ ConstantPathOperatorWriteNode (location: (3,2)-(3,28))
├── flags: newline
├── target:
│ @ ConstantPathNode (location: (3,2)-(3,23))
│ ├── flags: ∅
│ ├── parent:
│ │ @ LocalVariableReadNode (location: (3,2)-(3,11))
│ │ ├── flags: ∅
│ │ ├── name: :namespace
│ │ └── depth: 0
│ ├── name: :NotDefined
│ ├── delimiter_loc: (3,11)-(3,13) = "::"
│ └── name_loc: (3,13)-(3,23) = "NotDefined"
├── binary_operator_loc: (3,24)-(3,26) = "+="
├── value:
│ @ IntegerNode (location: (3,27)-(3,28))
│ ├── flags: static_literal, decimal
│ └── value: 1
└── binary_operator: :+
So even though it's the read failing, it returns the ConstantPathOperatorWriteNode and the section is namespace::NotDefined += 1.
I'm implementing source_range & syntax_tree on TruffleRuby and there the result is:
"const_assignment_fail_while_reading.rb:3:in 'Module#const_missing'"
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,23)>
@ ConstantPathNode (location: (3,2)-(3,23))
...
So this returns the ConstantPathNode and the section is namespace::NotDefined.
I think TruffleRuby is correct here, especially if we want to differentiate failing while reading or writing.
On CRuby it is currently impossible to differentiate because the same node is returned in both cases.
(UPDATE: it's possible to differentiate via the exception, but that seems quite brittle. The point is syntax_tree&source_range should point to the relevant reading node)
More information:
irb(main):001> namespace = Module.new
irb(main):002> namespace::NotDefined += 1
irb(main):003> e=_
irb(main):005> l=e.backtrace_locations[0]
irb(main):008> puts RubyVM::InstructionSequence.of(l).disasm
== disasm: #<ISeq:<compiled>@(irb):2 (2,0)-(2,26)>
0000 getlocal_WC_1 namespace@0 ( 2)[Li]
0002 dup
0003 putobject true
0005 getconstant :NotDefined
0007 putobject_INT2FIX_1_
0008 opt_plus <calldata!mid:+, argc:1, FCALL|ARGS_SIMPLE>[CcCr]
0010 swap
0011 topn 1
0013 swap
0014 setconstant :NotDefined
0016 leave
irb(main):011> RubyVM::InstructionSequence.of(l).to_a
=>
["YARVInstructionSequence/SimpleDataFormat",
4,
1,
1,
{arg_size: 0,
local_size: 0,
stack_max: 3,
node_id: 6,
source_hash: 614720579064667961,
code_location: [2, 0, 2, 26],
node_ids: [2, 5, 5, 5, 4, 5, 5, 5, 5, 5, 6],
parser: :prism},
"<compiled>",
"(irb)",
nil,
2,
:eval,
[],
{},
[],
[2,
:RUBY_EVENT_LINE,
[:getlocal_WC_1, 3],
[:dup],
[:putobject, true],
[:getconstant, :NotDefined],
[:putobject_INT2FIX_1_],
[:opt_plus, {mid: :+, flag: 20, orig_argc: 1}],
[:swap],
[:topn, 1],
[:swap],
[:setconstant, :NotDefined],
[:leave]]]
irb(main):013> l.syntax_tree
=>
@ ConstantPathOperatorWriteNode (location: (1,0)-(1,26))
...
The failing instruction must be getconstant (BTW, do we have any API to get the insn/insn index for an exception?).
That's given node_id 5 which is the ConstantPathOperatorWriteNode.
OK to fix the node_id of getconstant in such a case to point to the ConstantPathNode?
Updated by Eregon (Benoit Daloze) about 2 hours ago
I found this via this spec: https://github.com/ruby/spec/blob/0f44fd58585fcdc652da70521ecb753f6fda80f9/core/thread/backtrace/location/source_range_spec.rb#L155-L158
It encodes the current CRuby behavior, but as described above I think that's suboptimal and we should change it and change the spec accordingly.
There are other similar cases of "assignment failing while reading" there.
Updated by Eregon (Benoit Daloze) about 1 hour ago
· Edited
Regarding failing while reading or writing, here is reading:
namespace = Module.new
begin
namespace::NotDefined += 1
rescue => e
p e
pp e.backtrace_locations[0]
pp e.backtrace_locations[0].source_range
puts e.backtrace_locations[0].syntax_tree.inspect.lines.first
end
$ ruby -v const_assignment_fail_while_reading.rb
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
#<NameError: uninitialized constant #<Module:0x00007f9bb77fbc00>::NotDefined>
"const_assignment_fail_while_reading.rb:3:in '<main>'"
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,28)>
@ ConstantPathOperatorWriteNode (location: (3,2)-(3,28))
And here is writing:
namespace = Module.new
namespace::A = 42
namespace.freeze
begin
namespace::A += 1
rescue => e
p e
pp e.backtrace_locations[0]
pp e.backtrace_locations[0].source_range
puts e.backtrace_locations[0].syntax_tree.inspect.lines.first
end
$ ruby -v const_assignment_fail_while_writing.rb
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
#<FrozenError: can't modify frozen Module: #<Module:0x00007fb0ee3fbc80>>
"const_assignment_fail_while_writing.rb:5:in '<main>'"
#<Ruby::SourceRange /.../const_assignment_fail_while_writing.rb:(5,2)-(5,19)>
@ ConstantPathOperatorWriteNode (location: (5,2)-(5,19))
Printing the exception ensures we are indeed failing while reading/writing.
Both reading and writing return the same node (a "write node", i.e. ConstantPathOperatorWriteNode) on CRuby currently.
Expected:
Reading:
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,23)>
@ ConstantPathNode (location: (3,2)-(3,23))
Writing:
#<Ruby::SourceRange /.../const_assignment_fail_while_writing.rb:(5,2)-(5,19)>
@ ConstantPathOperatorWriteNode (location: (5,2)-(5,19))
Writing is already as expected and matching between CRuby & TruffleRuby.
Reading is the difference.
Updated by Eregon (Benoit Daloze) about 1 hour ago
- Description updated (diff)
Updated by Eregon (Benoit Daloze) about 1 hour ago
- Description updated (diff)
Updated by Eregon (Benoit Daloze) about 1 hour ago
To be complete, the other two "failing while reading" cases in the spec are:
($ marks the beginning and end of the source_range)
"index operator assignments failing while reading" => <<-RUBY,
value = nil
$value[0] += 42$
RUBY
"attribute operator assignments failing while reading" => <<-RUBY,
value = nil
$value.foo += 42$
RUBY
Those also currently return the write/assignment node, even though it fails while reading.
We should probably be consistent there and return the "read node" (value[0]/value.foo).
The trouble there is there isn't such a node in Prism:
1st example, no node with (3,0)-(3,8):
$ ruby -rprism -e 'pp Prism.parse("
value = nil
value[0] += 42
").value'
@ ProgramNode (location: (2,0)-(3,14))
├── flags: ∅
├── locals: [:value]
└── statements:
@ StatementsNode (location: (2,0)-(3,14))
├── flags: ∅
└── body: (length: 2)
├── @ LocalVariableWriteNode (location: (2,0)-(2,11))
│ ├── flags: newline
│ ├── name: :value
│ ├── depth: 0
│ ├── name_loc: (2,0)-(2,5) = "value"
│ ├── value:
│ │ @ NilNode (location: (2,8)-(2,11))
│ │ └── flags: static_literal
│ └── operator_loc: (2,6)-(2,7) = "="
└── @ IndexOperatorWriteNode (location: (3,0)-(3,14))
├── flags: newline
├── receiver:
│ @ LocalVariableReadNode (location: (3,0)-(3,5))
│ ├── flags: ∅
│ ├── name: :value
│ └── depth: 0
├── call_operator_loc: ∅
├── opening_loc: (3,5)-(3,6) = "["
├── arguments:
│ @ ArgumentsNode (location: (3,6)-(3,7))
│ ├── flags: ∅
│ └── arguments: (length: 1)
│ └── @ IntegerNode (location: (3,6)-(3,7))
│ ├── flags: static_literal, decimal
│ └── value: 0
├── closing_loc: (3,7)-(3,8) = "]"
├── block: ∅
├── binary_operator: :+
├── binary_operator_loc: (3,9)-(3,11) = "+="
└── value:
@ IntegerNode (location: (3,12)-(3,14))
├── flags: static_literal, decimal
└── value: 42
2nd example, no node with (3,0)-(3,9):
ruby -rprism -e 'pp Prism.parse("
value = nil
value.foo += 42
").value'
@ ProgramNode (location: (2,0)-(3,15))
├── flags: ∅
├── locals: [:value]
└── statements:
@ StatementsNode (location: (2,0)-(3,15))
├── flags: ∅
└── body: (length: 2)
├── @ LocalVariableWriteNode (location: (2,0)-(2,11))
│ ├── flags: newline
│ ├── name: :value
│ ├── depth: 0
│ ├── name_loc: (2,0)-(2,5) = "value"
│ ├── value:
│ │ @ NilNode (location: (2,8)-(2,11))
│ │ └── flags: static_literal
│ └── operator_loc: (2,6)-(2,7) = "="
└── @ CallOperatorWriteNode (location: (3,0)-(3,15))
├── flags: newline
├── receiver:
│ @ LocalVariableReadNode (location: (3,0)-(3,5))
│ ├── flags: ∅
│ ├── name: :value
│ └── depth: 0
├── call_operator_loc: (3,5)-(3,6) = "."
├── message_loc: (3,6)-(3,9) = "foo"
├── read_name: :foo
├── write_name: :foo=
├── binary_operator: :+
├── binary_operator_loc: (3,10)-(3,12) = "+="
└── value:
@ IntegerNode (location: (3,13)-(3,15))
├── flags: static_literal, decimal
└── value: 42
Updated by Eregon (Benoit Daloze) 29 minutes ago
- Status changed from Open to Rejected
NotDefined += 1 also doesn't have a Prism node for the reading part (name_loc covers it but that's not a node):
ruby -rprism -e 'pp Prism.parse("
NotDefined += 1
").value'
@ ProgramNode (location: (2,0)-(2,15))
├── flags: ∅
├── locals: []
└── statements:
@ StatementsNode (location: (2,0)-(2,15))
├── flags: ∅
└── body: (length: 1)
└── @ ConstantOperatorWriteNode (location: (2,0)-(2,15))
├── flags: newline
├── name: :NotDefined
├── name_loc: (2,0)-(2,10) = "NotDefined"
├── binary_operator_loc: (2,11)-(2,13) = "+="
├── value:
│ @ IntegerNode (location: (2,14)-(2,15))
│ ├── flags: static_literal, decimal
│ └── value: 1
└── binary_operator: :+
So in general there is no node for the reading part, and ConstantPathOperatorWriteNode seems like the exception there.
So for consistency I now think it makes sense to use the full assignment/OperatorWriteNode when it fails, even for reading.
And differentiating failing while reading or writing must be done with the exception then.
It's a little bit unfortunate but I see no good solution, so closing this as rejected since the current state makes sense.
Sorry for the noise.
Although maybe this will help someone else realize why it is the way it is for ConstantPathOperatorWriteNode.