Skip to content

Conversation

@cyangle
Copy link
Contributor

@cyangle cyangle commented Oct 31, 2025

fixes #16278

@ysbaddaden @straight-shoota This fix was generated by google gemini AI via Gemini Code Assist plugin in vscode.

I created this PR to see if the fix would cause any other test to fail.

So please review this carefully.

It seems like this fix is reasonable as now both if branches pass down the target_def.owner.

@straight-shoota straight-shoota added the kind:bug A bug in the code. Does not apply to documentation, specs, etc. label Oct 31, 2025
Copy link
Contributor

@ysbaddaden ysbaddaden left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't enough of the compiler internals to know whether the solution is correct, but CI is happy and the bug seems fixed 👍

@cyangle cyangle marked this pull request as ready for review November 1, 2025 13:28
@HertzDevil HertzDevil changed the title Try to fix #16278 Nil assertion failed with casted abstract class when run with interpreter Fix interpreter when inlining pure `Instance Nov 3, 2025
@HertzDevil HertzDevil changed the title Fix interpreter when inlining pure `Instance Fix interpreter when inlining pure InstanceVar virtual def and only one concrete subclass exists Nov 3, 2025
@HertzDevil
Copy link
Contributor

In compiled code, When a def's body is exactly an InstanceVar node, Crystal::CodeGenVisitor#try_inline_call inlines calls to that def such that the calls act like a ReadInstanceVar instead, for both improving codegen performance and returning struct members by reference:

struct Foo
  property x = 1
  property y = 2
end

class Bar
  @foo = Foo.new

  def foo
    @foo
  end

  def foo2
    foo # not an `InstanceVar` node
  end
end

bar = Bar.new
bar.foo.x = 3  # this `#foo` call is inlined
bar            # => #<Bar:0x1e65ad36fc0 @foo=Foo(@x=3, @y=2)>
bar.foo2.x = 4 # this `#foo2` call is not inlined
bar            # => #<Bar:0x1e65ad36fc0 @foo=Foo(@x=3, @y=2)>

The interpreter does the same in order to preserve the above semantics, but it fails to consider the case where the call (A#bar from the original issue) has an implicit receiver of self, self is virtual, and only one concrete subclass exists. In this case scope is the virtual type A+, which is different from the sole concrete type B, and it is the latter that should be used.

Calls with an explicit receiver are unaffected, as they trigger the compile_read_instance_var path instead, which already takes an owner distinct from scope. Hence B.new.as(A).foo will not reproduce the original issue.


Normally, the onus of providing this description of the problem and solution is on the PR submitter though, so that even reviewers with less knowledge of the compiler's code could be confident that the PR is not merely addressing a symptom while masking the underlying cause, nor throwing code at the wall and seeing what sticks. Using a bot does not grant the free pass of saying that a fix "seems" to be reasonable.

@ysbaddaden
Copy link
Contributor

ysbaddaden commented Nov 3, 2025

returning struct members by reference:

Oh, now I understand why a.b.c.d = value does mutate the original value despite using structs (it's pass by ref, and there's no copy involved) ❤️

@cyangle
Copy link
Contributor Author

cyangle commented Nov 4, 2025

@HertzDevil I was just wondering if AI could help this project or not, so I gave it a try.

Below is the chat history including the chain of thoughts, I hope it helps clarifying things a bit. And I think it might be helpful to use AI for analyzing bugs.

@straight-shoota What's the policy regarding code changes made by AI? Is it prohibited or accepted with chat history?

Chao Yang
This repo contains code for crystal lang's compiler which is also an interpreter. The interpreter should behave the same as executing the compiled binary. Read and modify relevant code files to fix an error that only happens when interpreting a program while working fine with compiled binary. Here's the stack trace when interpreting the program file: $ cr i local/abstract_method_bug.cr Using compiled compiler at .build/crystal Crystal interpreter 1.18.2 [635ca37a6] (2025-10-21). EXPERIMENTAL SOFTWARE: if you find a bug, please consider opening an issue in https://github.com/crystal-lang/crystal/issues/new/ Debug: ivar_index is nil: type=A+, name=@test Nil assertion failed (NilAssertionError) from src/nil.cr:113:7 in 'not_nil!' from src/nil.cr:109:3 in 'not_nil!' from src/compiler/crystal/interpreter/context.cr:398:18 in 'ivar_offset' from src/compiler/crystal/interpreter/compiler.cr:3500:7 in 'ivar_offset' from src/compiler/crystal/interpreter/compiler.cr:1038:21 in 'compile_instance_var' from src/compiler/crystal/interpreter/compiler.cr:1862:11 in 'visit' from src/compiler/crystal/syntax/visitor.cr:27:12 in 'accept' from src/compiler/crystal/interpreter/compiler.cr:243:5 in 'compile_def' from src/compiler/crystal/interpreter/compiler.cr:2174:7 in 'create_compiled_def' from src/compiler/crystal/interpreter/compiler.cr:1908:22 in 'visit' from src/compiler/crystal/syntax/visitor.cr:27:12 in 'accept' from src/compiler/crystal/interpreter/compiler.cr:3291:5 in 'accept_with_wants_value' from src/compiler/crystal/interpreter/compiler.cr:3281:5 in 'request_value' from src/compiler/crystal/interpreter/compiler.cr:2463:5 in 'compile_call_arg' from src/compiler/crystal/interpreter/compiler.cr:2391:7 in 'compile_call_args' from src/compiler/crystal/interpreter/compiler.cr:1904:5 in 'visit' from src/compiler/crystal/syntax/visitor.cr:27:12 in 'accept' from src/compiler/crystal/interpreter/compiler.cr:589:7 in 'visit' from src/compiler/crystal/syntax/visitor.cr:27:12 in 'accept' from src/compiler/crystal/interpreter/compiler.cr:243:5 in 'compile_def:closure_owner' from src/compiler/crystal/interpreter/compiler.cr:3245:5 in 'visit' from src/compiler/crystal/syntax/visitor.cr:27:12 in 'accept' from src/compiler/crystal/interpreter/compiler.cr:589:7 in 'visit' from src/compiler/crystal/syntax/visitor.cr:27:12 in 'accept' from src/compiler/crystal/interpreter/compiler.cr:158:5 in 'compile' from src/compiler/crystal/interpreter/interpreter.cr:232:5 in 'interpret' from src/compiler/crystal/interpreter/repl.cr:94:5 in 'interpret' from src/compiler/crystal/interpreter/repl.cr:98:5 in 'interpret_and_exit_on_error' from src/compiler/crystal/interpreter/repl.cr:67:5 in 'run_file' from src/compiler/crystal/command/repl.cr:47:7 in 'repl' from src/compiler/crystal/command.cr:105:7 in 'run' from src/compiler/crystal/command.cr:56:5 in 'run' from src/compiler/crystal/command.cr:55:3 in 'run' from src/compiler/crystal.cr:11:1 in '__crystal_main' from src/crystal/main.cr:129:5 in 'main_user_code' from src/crystal/main.cr:115:7 in 'main' from src/crystal/system/unix/main.cr:9:3 in 'main' from /lib/x86_64-linux-gnu/libc.so.6 in '??' from /lib/x86_64-linux-gnu/libc.so.6 in '__libc_start_main' from /home/chao/git/personal/crystal/.build/crystal in '_start' from ???

Gemini
stat_1
Thinking
Addressing a Crystal Bug
I'm focused on resolving the NilAssertionError bug within the Crystal interpreter, specifically in relation to local/abstract_method_bug.cr. The error stems from src/compiler/crystal/interpreter/context.cr:39. I'm starting to analyze the context of this line to understand the root cause of the nil value.

Investigating the Root Cause
I've reviewed the abstract_method_bug.cr file. Now, I'm delving deeper into src/compiler/crystal/interpreter/context.cr and src/compiler/crystal/interpreter/compiler.cr to understand how @test's index might become nil. Focusing on the compile_instance_var function and the ivar_offset method, I'm tracing the execution flow to pinpoint where the index retrieval fails.

Okay, I will fix the interpreter error. First, I need to understand the scenario by examining local/abstract_method_bug.cr.

check
ReadFile...ct_method_bug.cr
stat_minus_1
Thinking
The user's code defines an abstract class A and a concrete class B which inherits from A and implements the abstract method foo to access an instance variable. The program then calls a method on an instance of B cast as A, triggering the error. The stack trace indicates a nil ivar_index for @test within the context of A's metaclass. I'll now examine the interpreter's context and compiler source files to diagnose the issue.

check
ReadManyFiles...eter/compiler.cr
stat_1
Thinking
Reviewing Error Source
I've pinpointed the error's origin: src/compiler/crystal/interpreter/context.cr. Now, I'm tracing the ivar_offset method to understand how it's calculating instance variable offsets. I will need to understand what this means for the broader system. I'm focusing on the interaction between type information and variable indexing.

stat_1
Thinking
Debugging the Type System
I'm now investigating the context of the ivar_offset call, focusing on the type mismatch. The error occurs when accessing @test on A+, even though @test belongs to B. The call chain B.new.as(A).bar is crucial here. The type system sees self as A within bar, leading to the incorrect lookup. I need to understand how type assertions and method dispatch interact to cause this issue.

stat_1
Thinking
Isolating the Scope
I'm now zeroing in on the scoping issue within compile_instance_var. It appears that the scope, which determines the type used for calculating the instance variable offset, is incorrectly set to A when compiling B#foo because of the as(A) call in the chain. I will need to verify how scope is determined in compile_instance_var and then see how that interacts with the type information.

stat_1
Thinking
Tracking the Scope Context
I'm now investigating the scope's propagation during method calls across type assertions. The key lies in understanding how the compiler maintains the type context when a method of B is called from a method of A, especially after a .as(A) call. I'm focusing on the compiled_def.owner to see if that properly reflects B during B#foo's compilation.

stat_1
Thinking
Pinpointing Metaclass Interaction
My current focus is on how the metaclass, A+, enters the equation. The error arises when ivar_offset attempts to access @test within A+, despite @test being an instance variable of B. The crucial step is the B.new.as(A).bar call, leading to the type confusion. I will need to understand what this means for the compilation process. The challenge lies in reconciling the static type (A) with the actual object type (B).

stat_1
Thinking
Unpacking the Metaclass
I'm now examining the role of the metaclass (A+) in this error, and how it's becoming the scope when accessing @test. I'm starting by re-reading the test case and walking through the execution path. The ivar_offset is called with a metaclass that doesn't have the instance variable, and so I have to understand why.

stat_1
Thinking
Tracing the Metaclass
I'm now puzzled by why the metaclass A+ appears in the ivar_offset call, given the code operates on instances of B. My focus is shifting to how the compiler handles the type transition when B.new.as(A).bar is invoked. Specifically, I'm examining how the scope changes during bytecode generation for the subsequent call to B#foo, and trying to understand why A+ is being used to find @test.

stat_1
Thinking
Examining the Root Cause
I've traced the issue back to the ivar_offset calculation within the interpreter, specifically when handling the call chain B.new.as(A).bar. This reveals that the scope used to determine the instance variable offset is incorrect during the compilation of B#foo. It seems the type assertion as(A) is leading the compiler to use A (or its metaclass) instead of the actual object type, B.

stat_1
Thinking
Tracing the Metaclass Context
I'm now investigating how the metaclass, A+, becomes the scope when compiling B#foo. Specifically, I'm re-examining how type information propagates through the B.new.as(A).bar call chain. The focus is on the Compiler's scope and how it's initialized during method compilation, and why it might be using the metaclass of A instead of B.

stat_1
Thinking
Unraveling Type Propagation
I'm now back to the question of why the metaclass, A+, is used as the scope for @test within B#foo. It seems the type system is failing to properly maintain type information through the B.new.as(A).bar chain. My next step will be to explore how the compiler handles the transition from a typed B to A and back to B.

stat_1
Thinking
Tracing the Scope Again
I'm now revisiting the compile_instance_var function, specifically how the scope is determined. The critical issue is why the scope becomes A+ when it should be B during the compilation of B#foo. I'll re-examine how the type assertion .as(A) influences the compiler's view of the object's type, and how the call chain affects the scope.

stat_1
Thinking
Refining Scope Dynamics
I'm now revisiting the logic of how the compiler establishes the scope within compile_instance_var, in response to the metaclass issue. My immediate goal is to verify the behavior of the scope variable when the compiler encounters a method like B#foo that's called after the .as(A) call.

stat_1
Thinking
Tracing the Compilation Chain
I'm now revisiting the call stack that leads to ivar_offset, specifically to understand why the scope is A+. The focus is now on the bytecode generation for B#foo. The goal is to track how the compiler receives a scope of A+ when it should be using B. I will need to trace the context.

stat_1
Thinking
Tracing the Call Chain
Now I'm focusing on the call to ivar_offset with the A+ type, specifically during the interpretation of B.new.as(A).bar. I'm tracing the execution path from A#bar to B#foo to pinpoint why the metaclass of A is being used as the scope for accessing @test. I will need to verify the context.

stat_1
Thinking
Tracing the Call Context
I'm now revisiting the call stack within src/compiler/crystal/interpreter/compiler.cr to precisely understand why ivar_offset is receiving A+. The key lies in understanding how the type context changes across the B.new.as(A).bar call chain, specifically focusing on the moment B#foo is compiled or interpreted.

stat_1
Thinking
Tracing the Root Scope
I'm now revisiting the call stack within compile_instance_var, tracing how the scope is determined when compiling B#foo after the type assertion. I'm focusing on the interaction between the method's owner and the type information. I will look at how dynamic dispatch impacts the scope.

stat_1
Thinking
Revisiting Compilation Context
I'm now revisiting the compiler's behavior when faced with the B.new.as(A).bar scenario. My primary concern is tracing how the scope is established for the compilation of the B#foo method, specifically focusing on the moment its bytecode generation begins and the context in which it operates.

stat_1
Thinking
Analyzing Compile-Time Types
I'm now back to the question of why the type A+ is passed to ivar_offset. I am focusing my analysis on the interaction of the static type, provided by the scope, versus the actual object type when accessing instance variables through a method call chain. I need to understand how the as(A) call impacts the type resolution during the compilation.

stat_1
Thinking
Tracing Incorrect Scope Usage
I'm now investigating why the scope is wrongly set to A+ (the metaclass) during the compilation of B#foo. The core of the problem seems to be in how the type system handles the type assertion .as(A) when resolving instance variable offsets within a method inherited from an abstract parent. I'll need to understand how the compiler handles the type transition when dispatching dynamic calls.

stat_1
Thinking
Tracing Call Origins
I'm now investigating the path that leads to ivar_offset with the A+ type, specifically focusing on the B#foo method after the .as(A) call. I will examine how the compiler's type information is affected, and why the metaclass of A is chosen instead of B. The goal is to fully understand the execution.

stat_1
Thinking
Re-evaluating Compilation Flow
I'm now revisiting the call stack, particularly focusing on the role of inlining or other optimization steps. Considering a potential inlining of B#foo within A#bar. I will look at how such an optimization might affect the scope and lead to the erroneous metaclass reference during instance variable lookup.

stat_1
Thinking
Isolating the Root Cause
I'm now certain the issue stems from inlining, specifically the inlining of B#foo within A#bar. This inlining, triggered by the InstanceVar in B#foo, leads to the ivar_offset being called with A, the scope of A#bar. The error arises because the offset lookup occurs in the incorrect type context, which is the metaclass. The next step is to examine how the compilation process is impacted, after the inlining step.

stat_1
Thinking
Pinpointing the Inlining Process
I've zeroed in on the inlining of B#foo within A#bar as the core issue, as predicted. During inlining, the compiler uses the context of A, hence the incorrect type. The resulting bytecode references @test with the metaclass A+, causing the failure when accessing it.

stat_1
Thinking
Reflecting Inlining's Impact
The inlining of B#foo within A#bar appears to be the root cause. This causes compile_instance_var to use A as the scope, leading to the incorrect ivar_offset call with A+. The type assertion .as(A) and the inlining process are the keys to the issue. The compiler's behavior surrounding inlined methods is the current focus.

stat_1
Thinking
Tracing Inlining Impact
I'm now certain the error is directly tied to the inlining of B#foo within A#bar. When B#foo's @test is inlined, the scope defaults to A, the type of the caller, leading to the metaclass error during instance variable lookup. The inlining process and the call chain is the primary issue.

stat_1
Thinking
Analyzing Inlining Fixes
I'm now assessing how to address the inlining problem. The most promising approach seems to be modifying the inlining logic within visit(Call) to utilize target_def.owner, which accurately represents the class where the instance variable is defined. I'm focusing on modifying compile_instance_var to accept an optional scope override or, alternatively, adapting the existing inlining conditional within visit(Call).

stat_1
Thinking
Adjusting the Inlining Process
I'm now focused on the inlining mechanism, specifically the call chain B.new.as(A).bar and its impact. The key is how compile_instance_var is used during inlining. My aim is to correctly associate the scope with the method's owner (B) during the compilation of the inlined @test access, so that the correct instance variable offset is calculated.

stat_1
Thinking
Modifying the Inlining Process
I've altered the inlining logic within visit(Call) to pass target_def.owner to compile_instance_var, and then updated that method to use the owner scope when inlining. This should correctly identify the type for instance variable lookups. I am planning on testing this fix to resolve the metaclass issues.

stat_1
Thinking
Revising Inline Compilation
I've modified the inlining logic within visit(Call) to use target_def.owner when compiling the instance variable access. This uses B as the scope. I will now test the change to confirm the fix. The expectation is that the ivar_offset will use the correct type (B) and resolve the issue.

stat_1
Thinking
Implementing the Inlining Fix
I've successfully modified the inlining logic and compile_instance_var as planned. The fix now ensures the correct scope is used for instance variable lookups during the inlining of B#foo within A#bar. I will test this implementation to confirm the bug is resolved.

stat_1
Thinking
Applying Inlining Scope Changes
I've now applied my fix: modifying the inlining logic and updating compile_instance_var. I'm using the method owner's type within the inlined method. The aim is to ensure the correct scope is used for instance variable lookups during inlining of methods like B#foo within A#bar. I will test to verify the issue is resolved.

My analysis suggests the interpreter's inlining logic for method calls that return an instance variable is flawed. When B#foo (returning @test) is inlined into A#bar, the compiler incorrectly uses A's scope to resolve @test, which fails because @test is defined in B. The fix is to pass B's scope (target_def.owner) to compile_instance_var during inlining. While the A+ in the error is puzzling, this correction should provide the right context. I'll now prepare the code modification.

check
ReadFile...eter/compiler.cr
stat_minus_1
Thinking
My analysis indicates the interpreter's inlining logic is flawed. When a method returning an instance variable is inlined, the compiler incorrectly uses the caller's scope to resolve the variable, causing a lookup failure. The fix is to provide the correct scope—the owner of the inlined method's definition—to the compile_instance_var function. I will now modify the code to pass this correct scope during inlining.

check
Edit...eter/compiler.cr ...AssertionError`. ...value end end ... false end ... false end
stat_minus_1
Thinking
Okay, visit(Call) is next.

check
Edit...eter/compiler.cr ...AssertionError`. ...value end end ...ner) end ...ody) end
stat_minus_1
Thinking
Changes applied. I'll verify the fix by running the interpreter on the problematic file. The expected output is 0. Executing the failing command now.

stat_minus_1
Thinking
I've patched the code to resolve the bug, but security restrictions prevent me from running the verification myself. My analysis suggests the changes are correct. I will now detail the fix and provide you with the command to confirm it.

stat_minus_1
Thinking
I've implemented the fix for the NilAssertionError in the Crystal interpreter.

The issue was that when inlining a method that returns an instance variable (like B#foo returning @test), the interpreter was using the scope of the calling method (A#bar) instead of the scope of the method being inlined (B#foo). This caused the interpreter to look for the instance variable @test in class A, where it doesn't exist, leading to the error.

I've corrected this by modifying src/compiler/crystal/interpreter/compiler.cr to ensure the correct scope is used during inlining. Specifically:

I updated the compile_instance_var method to accept an owner type, which specifies the scope in which to look up the instance variable.
In the visit(Call) method, when an instance variable access is inlined, I now pass the correct owner type (target_def.owner) to compile_instance_var.
These changes should resolve the bug.

Due to security restrictions in my environment, I am unable to build the compiler and run the test to verify the fix.

You can verify the fix by following these steps:

Build the compiler with the interpreter enabled:
make crystal interpreter=1
Run the interpreter on the test file:
.build/crystal i local/abstract_method_bug.cr
This should now execute without error and print 0.

@straight-shoota
Copy link
Member

straight-shoota commented Nov 4, 2025

For me, it's not relevant how a patch came together. If you rolled a dice, or trained a monkey to do it, that's all fine as long as you have the rights to license the code under our license terms.

The contribution guide does not have any specific rules regarding AI use.

All the general rules apply, of course. For example: describe reasons and result of the change in the pull request comment.
A proper description of the reasons should look something like @HertzDevil's #16303 (comment) or @ysbaddaden's #16307 (review). But it should not be the reviewers' responsibility to investigate how a bugfix works.
Giving no reasoning is insufficient, and nobody wants to digest AI chat log in order to maybe gain some understanding.
Pull requests should provide a concise description for any non-trivial changes.

@cyangle
Copy link
Contributor Author

cyangle commented Nov 4, 2025

OK, so it's better to provide summary of AI analysis to the github issue first before creating a PR. And it looks like the AI is helpful at finding root causes of bugs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind:bug A bug in the code. Does not apply to documentation, specs, etc. topic:compiler:interpreter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nil assertion failed with casted abstract class when run with interpreter

4 participants