Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Please refer to the following for the [benchmark code](https://github.com/hadash
| ChibiRuby.Compiler | Ruby source compiler utility (native binding). | [![NuGet](https://img.shields.io/nuget/v/ChibiRuby.Compiler)](https://www.nuget.org/packages/ChibiRuby.Compiler) |
| ChibiRuby.Cli | dotnet tool with subcommands (e.g. `compile`) for ChibiRuby workflows | [![NuGet](https://img.shields.io/nuget/v/ChibiRuby.Cli)](https://www.nuget.org/packages/ChibiRuby.Cli) |
| ChibiRuby.Serializer | Converts between Ruby and C# objects | [![NuGet](https://img.shields.io/nuget/v/ChibiRuby.Serializer)](https://www.nuget.org/packages/ChibiRuby.Serializer) |
| ChibiRuby.Debugger | Protocol-agnostic debugger core (breakpoints, stepping, `binding.irb` suspension) | [![NuGet](https://img.shields.io/nuget/v/ChibiRuby.Debugger)](https://www.nuget.org/packages/ChibiRuby.Debugger) |
| ChibiRuby.Debugger | Protocol-agnostic debugger core (breakpoints, stepping, `binding.break` suspension) | [![NuGet](https://img.shields.io/nuget/v/ChibiRuby.Debugger)](https://www.nuget.org/packages/ChibiRuby.Debugger) |
| ChibiRuby.Debugger.Dap | DAP server (TCP) for any DAP-compatible editor — see [Debugger](#debugger) | [![NuGet](https://img.shields.io/nuget/v/ChibiRuby.Debugger.Dap)](https://www.nuget.org/packages/ChibiRuby.Debugger.Dap) |

### Unity
Expand Down Expand Up @@ -1599,13 +1599,41 @@ VSCode and Zed need a small extension to register the `chibiruby` debug type; Ri

### Setting breakpoints

There are two ways to pause execution: set a breakpoint from the editor, or write `binding.break` directly in your Ruby code.

#### From the editor

Once host + editor are wired:

1. Open the `.rb` file in the editor.
2. Click the gutter next to the line you want to pause at — a red breakpoint marker appears.
3. Run the host. Execution stops at the breakpoint; the editor surfaces the call stack and locals.
4. Use the editor's debug controls (**Continue** / **Step Over** / **Step In** / **Step Out**) and the REPL pane (variables view + expression evaluation) as usual.

#### From code (`binding.break`)

Write `binding.break` at the line you want to pause at — same API as [ruby/debug](https://github.com/ruby/debug):

```ruby
def update
hp = @hp
binding.break # execution suspends here; inspect `hp` from the editor
hp - 1
end
```

`binding.b` and `debugger` are also available as aliases:

```ruby
binding.b # same as binding.break
debugger # same as binding.break on the caller's frame
```

When the VM hits `binding.break`, the thread suspends until a DAP client attaches and resumes it — handy for stopping at a precise point before you've had a chance to attach the editor. Once a client has attached and disconnected, later `binding.break` calls become no-ops so the host doesn't hang after the editor goes away.

> [!NOTE]
> `binding.break` requires the debugger to be installed on the state (`MRubyDapServer` does this automatically). Without it, calling `binding.break` raises `RuntimeError`.

> [!NOTE]
> Please ensure that ChibiRubyCompiler passes the filename when compiling Ruby.
> APIs such as CompileFile or Unity's ScriptedImporter resolve the filename automatically.
Expand Down
4 changes: 2 additions & 2 deletions editor-extensions/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Or search **"ChibiRuby Debugger"** in the Extensions panel.
- Evaluate Ruby expressions in the suspended binding (Debug Console / Watch / Hover)
- View `self` and locals in the Variables pane
- Continue / terminate
- `binding.irb` as an inline pause (also supported, useful when you can't easily
- `binding.break` as an inline pause (also supported, useful when you can't easily
set a gutter breakpoint — e.g. generated code)

Not yet supported: launch mode (run a `.rb` file from VSCode), step
Expand All @@ -33,7 +33,7 @@ over/into/out.
The extension supports **attach mode** only. Your C# / Unity host embeds
ChibiRuby and opens a TCP listener via `new MRubyDapServer(...).StartAsync()`;
VSCode connects over TCP, sends your breakpoints, and execution halts when a
breakpoint hits (or at any `binding.irb` call).
breakpoint hits (or at any `binding.break` call).

See [`sandbox/SampleDebuggerEmbedded/`](../../sandbox/SampleDebuggerEmbedded/)
for a complete working host you can run with `dotnet run`.
Expand Down
4 changes: 2 additions & 2 deletions editor-extensions/vscode/examples/sample.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sample script for trying the ChibiRuby DAP debugger.
# Open this file in VSCode (with the chibiruby-debugger extension installed),
# press F5, and you should pause at the binding.irb call.
# press F5, and you should pause at the binding.break call.

greeting = "hello"
counter = 0
Expand All @@ -9,7 +9,7 @@
counter += i
end

binding.irb
binding.break

# In the VSCode Debug Console, try:
# greeting.upcase
Expand Down
2 changes: 1 addition & 1 deletion sandbox/SampleDebuggerEmbedded/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Minimal embedded host that demonstrates how to wire up ChibiRuby.Debugger.Dap from a
// regular C# application. Run with `dotnet run --project sandbox/SampleDebuggerEmbedded`
// and the program will block at the first binding.irb call until a DAP client (e.g.
// and the program will block at the first binding.break call until a DAP client (e.g.
// VSCode with the chibiruby-debugger extension) attaches to 127.0.0.1:4711.

using System.Net;
Expand Down
8 changes: 4 additions & 4 deletions sandbox/SampleDebuggerEmbedded/scenarios/quest.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Sample mruby script for poking at the embedded-host debugger.
#
# The host (Program.cs) loads this file via MRubyCompiler.LoadSourceCode. When the
# binding.irb line is reached, execution suspends until a DAP client attaches to the
# binding.break line is reached, execution suspends until a DAP client attaches to the
# host's listening port (default 4711) and sends a `continue`.

class Hero
Expand All @@ -23,13 +23,13 @@ def take_damage(amount)

hero.take_damage(15)

p "Before binding.irb: #{hero.name}, HP=#{hero.hp}"
binding.irb
p "Before binding.break: #{hero.name}, HP=#{hero.hp}"
binding.break
# Try in the Debug Console:
# self # the toplevel object
# binding.local_variables # [:hero, :weapon, :inventory]
# binding.local_variable_get(:weapon).upcase
# binding.local_variable_get(:hero).hp
p "After binding.irb: #{hero.name}, HP=#{hero.hp}"
p "After binding.break: #{hero.name}, HP=#{hero.hp}"

inventory
8 changes: 4 additions & 4 deletions src/ChibiRuby.Debugger.Dap/MRubyDapMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ enum HandshakePhase
public MRubyDebugger Debugger { get; }

const int ThreadId = 1;
const int FrameIdBindingIrb = 1;
const int FrameIdBindingBreak = 1;
const int VariablesRefLocals = 1000;

readonly PipeReader reader;
Expand Down Expand Up @@ -211,7 +211,7 @@ await RespondSuccessAsync(new ConfigurationDoneResponse
await HandleStepAsync(seq, "stepOut", StepMode.StepOut, cancellationToken).ConfigureAwait(false);
break;
case PauseRequest:
await RespondErrorAsync(seq, "pause", "pause is not supported in Phase 1; use binding.irb", cancellationToken).ConfigureAwait(false);
await RespondErrorAsync(seq, "pause", "pause is not supported in Phase 1; use binding.break", cancellationToken).ConfigureAwait(false);
break;
case DisconnectRequest:
await HandleDisconnectAsync(seq, "disconnect", cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -377,7 +377,7 @@ Task HandleStackTraceAsync(int seq, CancellationToken cancellationToken)

var frame = new StackFrame
{
Id = FrameIdBindingIrb,
Id = FrameIdBindingBreak,
Name = sourcePath is null ? "(toplevel)" : Path.GetFileName(sourcePath),
Line = (ulong)line,
Column = 1,
Expand Down Expand Up @@ -617,7 +617,7 @@ Task SendStoppedEventAsync(StopEvent stopEvent)
StopReason.LineBreakpoint => ("breakpoint", stopEvent.Line > 0
? $"Paused at line breakpoint {Path.GetFileName(stopEvent.File ?? "")}:{stopEvent.Line}"
: "Paused at line breakpoint"),
StopReason.BindingIrb => ("pause", "Paused at binding.irb"),
StopReason.BindingBreak => ("pause", "Paused at binding.break"),
StopReason.Step => ("step", stopEvent.Line > 0
? $"Paused after step at {Path.GetFileName(stopEvent.File ?? "")}:{stopEvent.Line}"
: "Paused after step"),
Expand Down
2 changes: 1 addition & 1 deletion src/ChibiRuby.Debugger/ChibiRuby.Debugger.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFrameworks>net8.0;net9.0;net10.0;netstandard2.1</TargetFrameworks>
<Nullable>enable</Nullable>
<LangVersion>13</LangVersion>
<Description>Debugger core for ChibiRuby. Protocol-agnostic primitives for breakpoints, stepping, and binding.irb suspension.</Description>
<Description>Debugger core for ChibiRuby. Protocol-agnostic primitives for breakpoints, stepping, and binding.break suspension.</Description>
</PropertyGroup>

<ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions src/ChibiRuby.Debugger/MRubyDebugger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
StepRequest? stepRequest;

// Once any client has attached: subsequent stops with no client attached become no-op
// (so binding.irb doesn't hang the host thread after Rider/VSCode disconnects).
// (so binding.break doesn't hang the host thread after Rider/VSCode disconnects).
bool hadAttachedClient;

int disposed;
Expand Down Expand Up @@ -204,9 +204,9 @@
return sep == '/' || sep == '\\';
}

void IMRubyDebuggerHook.OnBindingIrb(MRubyState state, RBinding binding)
void IMRubyDebuggerHook.OnBindingBreak(MRubyState state, RBinding binding)
{
SuspendInPump(StopReason.BindingIrb, binding);
SuspendInPump(StopReason.BindingBreak, binding);
}

void IMRubyDebuggerHook.OnInstruction(MRubyState state, Irep irep, int pc)
Expand Down Expand Up @@ -250,7 +250,7 @@
{
if (!TryMatchBreakpointFile(filename, out bpLines)) return;
}
if (!bpLines.Contains(line)) return;

Check warning on line 253 in src/ChibiRuby.Debugger/MRubyDebugger.cs

View workflow job for this annotation

GitHub Actions / test-dotnet

Dereference of a possibly null reference.

Check warning on line 253 in src/ChibiRuby.Debugger/MRubyDebugger.cs

View workflow job for this annotation

GitHub Actions / test-dotnet

Dereference of a possibly null reference.

Check warning on line 253 in src/ChibiRuby.Debugger/MRubyDebugger.cs

View workflow job for this annotation

GitHub Actions / test-dotnet

Dereference of a possibly null reference.

Check warning on line 253 in src/ChibiRuby.Debugger/MRubyDebugger.cs

View workflow job for this annotation

GitHub Actions / test-dotnet

Dereference of a possibly null reference.

var binding2 = state.CreateBindingForCurrentFrame();
SuspendInPump(StopReason.LineBreakpoint, binding2, filename, line);
Expand Down
2 changes: 1 addition & 1 deletion src/ChibiRuby.Debugger/StopReason.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace ChibiRuby.Debugger;

public enum StopReason
{
BindingIrb,
BindingBreak,
LineBreakpoint,
Step,
}
Expand Down
4 changes: 2 additions & 2 deletions src/ChibiRuby/IMRubyDebuggerHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ namespace ChibiRuby;
/// <summary>Optional VM-side hook installed via <see cref="MRubyState.DebuggerHook"/>. Invoked on the VM thread.</summary>
public interface IMRubyDebuggerHook
{
/// <summary>Called when user code invokes <c>binding.irb</c>; expected to suspend the VM thread until resumed.</summary>
void OnBindingIrb(MRubyState state, RBinding binding);
/// <summary>Called when user code invokes <c>binding.break</c> (or its aliases <c>binding.b</c> / <c>debugger</c>); expected to suspend the VM thread until resumed.</summary>
void OnBindingBreak(MRubyState state, RBinding binding);

/// <summary>Fires on every instruction while the hook is installed; implementations must early-out fast.</summary>
void OnInstruction(MRubyState state, Irep irep, int pc);
Expand Down
2 changes: 1 addition & 1 deletion src/ChibiRuby/MRubyState.Binding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ partial class MRubyState
{
public RClass BindingClass { get; private set; } = default!;

/// <summary>Optional hook for a debugger to suspend execution at <c>binding.irb</c>.</summary>
/// <summary>Optional hook for a debugger to suspend execution at <c>binding.break</c>.</summary>
public IMRubyDebuggerHook? DebuggerHook { get; set; }

/// <summary>Capture the caller's frame as a <see cref="RBinding"/> (for C#-defined Ruby methods).</summary>
Expand Down
4 changes: 3 additions & 1 deletion src/ChibiRuby/MRubyState.Init.cs
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,11 @@ void InitBinding()
DefineMethod(BindingClass, Intern("local_variable_get"u8), BindingMembers.LocalVariableGet);
DefineMethod(BindingClass, Intern("local_variable_set"u8), BindingMembers.LocalVariableSet);
DefineMethod(BindingClass, Intern("local_variable_defined?"u8), BindingMembers.LocalVariableDefined);
DefineMethod(BindingClass, Intern("irb"u8), BindingMembers.Irb);
DefineMethod(BindingClass, Intern("break"u8), BindingMembers.Break);
DefineMethod(BindingClass, Intern("b"u8), BindingMembers.Break);

DefineMethod(KernelModule, Intern("binding"u8), BindingMembers.Binding);
DefineMethod(KernelModule, Intern("debugger"u8), BindingMembers.Debugger);
}

void InitException()
Expand Down
21 changes: 14 additions & 7 deletions src/ChibiRuby/StdLib/BindingMembers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,27 @@ static class BindingMembers
return binding.TryGetLocal(name, out _) ? MRubyValue.True : MRubyValue.False;
});

// Binding#irb - default implementation when no debugger is attached.
// The ChibiRuby.Debugger package replaces this with a hook-triggering implementation
// by calling DefineMethod again at attach time.
public static MRubyMethod Irb = new((state, self) =>
// Binding#break (alias Binding#b) - ruby/debug compatible breakpoint.
public static MRubyMethod Break = new((state, self) =>
{
var binding = AsBinding(state, self);
return TriggerBreak(state, binding);
});

// Kernel#debugger - ruby/debug compatible; equivalent to binding.break on the caller's frame.
public static MRubyMethod Debugger = new((state, self) =>
TriggerBreak(state, state.CreateBinding()));

static MRubyValue TriggerBreak(MRubyState state, RBinding binding)
{
if (state.DebuggerHook is { } hook)
{
hook.OnBindingIrb(state, binding);
hook.OnBindingBreak(state, binding);
return MRubyValue.Nil;
}
state.Raise(Names.RuntimeError, "binding.irb called but no debugger is attached (ChibiRuby.Debugger not initialized)"u8);
state.Raise(Names.RuntimeError, "binding.break called but no debugger is attached (ChibiRuby.Debugger not initialized)"u8);
return MRubyValue.Nil;
});
}

static RBinding AsBinding(MRubyState state, MRubyValue self)
{
Expand Down
10 changes: 5 additions & 5 deletions tests/ChibiRuby.Debugger.Dap.Tests/ChibiRubyDapServerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ public async Task Initialize_RespondsWithCapabilitiesAndInitializedEvent()
}

[Test]
public async Task Launch_RunsScript_StopsAtBindingIrb_AndContinues()
public async Task Launch_RunsScript_StopsAtBindingBreak_AndContinues()
{
File.WriteAllText(scriptPath, "binding.irb\n");
File.WriteAllText(scriptPath, "binding.break\n");

using var harness = new TestHarness();
await harness.InitializeAsync();
Expand All @@ -61,7 +61,7 @@ public async Task Launch_RunsScript_StopsAtBindingIrb_AndContinues()
[Test]
public async Task Evaluate_RunsRubyExpressionInBinding()
{
File.WriteAllText(scriptPath, "x = 7\nbinding.irb\n");
File.WriteAllText(scriptPath, "x = 7\nbinding.break\n");

using var harness = new TestHarness();
await harness.InitializeAsync();
Expand All @@ -80,7 +80,7 @@ public async Task Evaluate_RunsRubyExpressionInBinding()
[Test]
public async Task Variables_ListsLocalsAndSelf()
{
File.WriteAllText(scriptPath, "a = 10\nb = 'hi'\nbinding.irb\n");
File.WriteAllText(scriptPath, "a = 10\nb = 'hi'\nbinding.break\n");

using var harness = new TestHarness();
await harness.InitializeAsync();
Expand Down Expand Up @@ -109,7 +109,7 @@ public async Task Variables_ListsLocalsAndSelf()
[Test]
public async Task Evaluate_RubyRaiseReturnsErrorResponse_AndContinueStillWorks()
{
File.WriteAllText(scriptPath, "binding.irb\n:ok\n");
File.WriteAllText(scriptPath, "binding.break\n:ok\n");

using var harness = new TestHarness();
await harness.InitializeAsync();
Expand Down
6 changes: 3 additions & 3 deletions tests/ChibiRuby.Debugger.Dap.Tests/StackTraceLineTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace ChibiRuby.Debugger.Dap.Tests;

/// <summary>
/// Verifies that Phase 2.1 DBG-section plumbing actually reaches the DAP wire: the
/// stackTrace response should carry the real source filename + line where binding.irb
/// stackTrace response should carry the real source filename + line where binding.break
/// was invoked, not the (toplevel) / line 1 placeholder.
/// </summary>
[TestFixture]
Expand All @@ -25,7 +25,7 @@ public async Task StackTrace_ReturnsRealLineFromDbgSection_Attach()
var port = server.LocalEndpoint.Port;

var scriptPath = Path.Combine(Path.GetTempPath(), $"dbg-{System.Guid.NewGuid():N}.rb");
File.WriteAllText(scriptPath, "x = 10\ny = 20\nbinding.irb\nx + y\n");
File.WriteAllText(scriptPath, "x = 10\ny = 20\nbinding.break\nx + y\n");
try
{
var vmDone = new ManualResetEventSlim();
Expand Down Expand Up @@ -58,7 +58,7 @@ public async Task StackTrace_ReturnsRealLineFromDbgSection_Attach()

var frame0 = frames[0];
Assert.That(frame0.Line, Is.EqualTo((ulong)3),
"stackTrace should report the source line of binding.irb (line 3)");
"stackTrace should report the source line of binding.break (line 3)");

var sourcePath = frame0.Source?.Path;
Assert.That(sourcePath, Is.EqualTo(scriptPath));
Expand Down
8 changes: 4 additions & 4 deletions tests/ChibiRuby.Debugger.Dap.Tests/TcpDapServerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ namespace ChibiRuby.Debugger.Dap.Tests;

/// <summary>
/// End-to-end test of the embedded host scenario: a host C# thread runs Ruby (with
/// <c>binding.irb</c> inside), <see cref="MRubyDapServer"/> listens for an attach over
/// <c>binding.break</c> inside), <see cref="MRubyDapServer"/> listens for an attach over
/// loopback TCP, the test plays the client role.
/// </summary>
[TestFixture]
public class TcpDapServerTest
{
[Test]
public async Task EmbeddedHost_BlocksAtBindingIrb_UntilClientAttaches_ThenContinues()
public async Task EmbeddedHost_BlocksAtBindingBreak_UntilClientAttaches_ThenContinues()
{
var state = MRubyState.Create();
var compiler = MRubyCompiler.Create(state);
Expand All @@ -36,7 +36,7 @@ public async Task EmbeddedHost_BlocksAtBindingIrb_UntilClientAttaches_ThenContin
{
compiler.LoadSourceCode("""
secret = 42
binding.irb
binding.break
secret
"""u8);
}
Expand All @@ -46,7 +46,7 @@ public async Task EmbeddedHost_BlocksAtBindingIrb_UntilClientAttaches_ThenContin
{ IsBackground = true, Name = "test-vm" };
vmThread.Start();

Assert.That(vmDone.Wait(200), Is.False, "VM should be blocked at binding.irb waiting for client");
Assert.That(vmDone.Wait(200), Is.False, "VM should be blocked at binding.break waiting for client");

using var tcp = new TcpClient();
await tcp.ConnectAsync("127.0.0.1", port);
Expand Down
Loading
Loading