RFC-034: 統一デバッグツールチェーン
要約
YaoXiang に統一デバッグツールチェーンを導入する。核心となる設計は一つの源、三つの消費:コンパイルフロントエンドがソースコード位置、変数名、型情報を第一級の市民として YaoXiang IR に埋め込み、インタプリタ、JIT、LLVM の三つのバックエンドがそれぞれ同一のメタデータを消費する。ユーザは yaoxiang run --debug で DAP(Debug Adapter Protocol)サーバを起動し、VS Code が stdio 経由で接続することで、ブレークポイント、ステップ実行、変数表示、コールスタック、式評価、並行デバッグの統一体験を得る——基盤となる実行エンジンの種類を問わない。
動機
なぜこの機能が必要なのか
現在、YaoXiang プログラムのエラーを調査する手段は極めて原始的である:
io.println("DEBUG: x = " + x.to_string())
io.println("DEBUG: entered branch A")三つの致命的な問題がある:
- コンパイラ開発のセルフホスティングが阻害される:YaoXiang で YaoXiang コンパイラを書くが、コンパイラを書く人が自分のコードをデバッグできない。セルフホスティング段階でインタラクティブなデバッグ手段がないことは行き止まりである。
- 三つのエンジン、ゼロのデバッグ:インタプリタ、JIT、LLVM がそれぞれ独立して動作し、問題が発生した際、ユーザは stdout に
ALL TESTS PASSEDが現れるかどうかを確認するしかない。 assert が失敗したのか? どの行で起きたのか、変数の値は何か、わからない。 - 並行処理がブラックボックス:
spawnで複数のタスクが作成されるが、どのタスクがハングしたのか? 変数は誰に move されたのか? すべて運任せの推測である。
設計目標
- 統一体験:インタプリタで機能するブレークポイントが JIT でも機能し、LLVM でも一貫したソースマッピングが得られる。ユーザは基盤のエンジンの違いを意識しない。
- 一つの源:デバッグメタデータは IR と共に流れ、重複定義せず、二つのマッピングを維持しない。
- ゼロ侵襲:
yaoxiang run --debugという一つの引数で済み、この引数を付けない場合のコンパイルと実行の動作は完全に変わらない。 - DAP 標準:VS Code エコシステムに直接接続し、エディタプロトコルを再発明しない。
提案
中核設計
アーキテクチャ概要:
┌──────────────────────────────────────────────────────┐
│ VS Code / エディタ │
│ DAP Client (launch.json) │
└────────────────────────┬─────────────────────────────┘
│ stdio
┌────────────────────────▼─────────────────────────────┐
│ DAP サーバ (yx-core) │
│ ┌─────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ セッション│ │ブレークポイント│ │式評価エンジン │ │
│ │ 管理 │ │ 管理 │ │ │ │
│ └─────────┘ └──────────┘ └───────────────────┘ │
└────────────────────────┬─────────────────────────────┘
│ クエリ/制御
┌────────────────────────▼─────────────────────────────┐
│ ランタイムデバッグインターフェース (trait)│
│ pause / resume / step / get_frames / eval / ... │
└────┬──────────────────┬──────────────────┬──────────┘
│ │ │
┌────▼────┐ ┌───────▼───────┐ ┌─────▼──────┐
│ インタプリタ│ │ JIT (RFC-028)│ │ LLVM AOT │
│ IRメタデータ│ │ 軽量デバッグ表│ │ IRメタデータ│
│ を直接消費 │ │ を生成 │ │ → DWARF │
└─────────┘ └───────────────┘ └────────────┘重要な設計判断:
- DAP サーバとランタイムは trait で疎結合する。サーバは基盤がインタプリタか JIT かを関知しない——
DebugEnginetrait を介して命令を出すだけである。各エンジンは同じ trait を独立に実装する。 yaoxiang run --debugはインタプリタの使用を強制する。デバッグには制御可能性が必要であり、性能は必要ない。LLVM モードでは DWARF を事後的な遡及(core dump / crash report)用に生成するのみで、インタラクティブデバッグは行わない。- エントリポイント発見ロジックを
yaoxiang runから再利用する。新しいサブコマンドを導入せず、心のモデルは「デバッグモードでプログラムを実行する」のまま。
IR デバッグメタデータ
既存の YaoXiang IR にメタデータを付加し、新しい IR 種類は追加しない。すべてのメタデータはコンパイルフロントエンドで一箇所で生成され、バックエンドの消費は読み取り専用:
| メタデータ | 添付点 | 説明 |
|---|---|---|
SourceLocation | 各 IR ノード | ソースファイル:行番号:列番号 |
VarName | 変数宣言/束縛ノード | ソースコード中の変数名 |
TypeAnnotation | 変数/式ノード | 推論された型(コンパイル時述語を含む) |
ScopeBoundary | ブロック/関数の入口出口 | 変数スコープのライフサイクル |
SpanInfo | spawn ノード | spawn ブロック内のタスク境界 |
起動フロー
yaoxiang run --debug file.yx
│
├── Phase 1: コンパイル(デバッグメタデータ付き)
│ ├── 解析 → AST
│ ├── 型チェック + コンパイル時述語の検証
│ └── IR への降格(デバッグメタデータを付加)
│
├── Phase 2: インタプリタエンジンの使用
│ └── --debug モードでは --release の有無に関わらず、インタプリタを使用
│
├── Phase 3: DAP サーバの起動
│ ├── stdio トランスポートチャネルの初期化
│ ├── VS Code のアタッチを待機
│ ├── アタッチ成功後、プログラム入口で一時停止
│ └── インタラクティブデバッグループへ移行
│
└── Phase 4: プログラム終了 / デバッグセッション終了 → 終了各モードの違い
| モード | デバッグ方式 |
|---|---|
yaoxiang run --debug | インタプリタを強制、フル機能の DAP インタラクティブデバッグ |
yaoxiang run --release | DWARF を生成、事後遡及用(core dump / crash report)、DAP は起動しない |
yaoxiang run(通常) | デバッグメタデータなし、デバッグサポートなし |
詳細設計
1. ブレークポイント
ブレークポイントの種類:
├── ソース行ブレークポイント → コンパイルフロントエンドが位置メタデータを生成、バックエンドがマッチングを照会
├── 関数入口ブレークポイント → 関数呼び出し時にトリガ(第二段階)
├── 条件ブレークポイント → 式の評価が true になったときにトリガ
└── データブレークポイント → 変数が変更されたときにトリガ(第二段階)ソース行ブレークポイントの中核ロジック:
VS Code 送信: "file.yx:42 にブレークポイントを設定"
│
DAP サーバ:
├── IR 内の全 SourceLocation == (file.yx, 42) のノードを照会
├── ランタイムに転送: "これらの IR アドレスで一時停止"
└── ランタイムが返却: ブレークポイント ID
プログラムが IR ノードを実行 → ランタイムがチェック: このノードはブレークポイントリストにあるか?
├── 通常ブレークポイント → 一時停止、DAP サーバに通知
└── 条件ブレークポイント → 条件式を評価 → true なら一時停止三つのエンジンの実装:
| インタプリタ | JIT | LLVM | |
|---|---|---|---|
| ブレークポイント挿入方法 | 実行ループで IR ノード ID をチェック | JIT が機械語に int3 を挿入 | LLVM DWARF + ハードウェアブレークポイントを利用 |
| 条件ブレークポイント評価 | 式を直接解釈 | 条件式を一時的に JIT コンパイル | DWARF 式スタック + 評価 |
| 性能オーバーヘッド | 各 IR ノードで追加のテーブルルックアップ | ブレークポイント箇所のみオーバーヘッド | ほぼゼロオーバーヘッド(ハードウェアブレークポイント) |
2. ステップ実行
Step Over → 現在行を実行、関数呼び出しの内部をスキップし、次の行で停止
Step Into → 現在行の関数呼び出しの内部に入る
Step Out → 現在関数から戻るまで実行
Continue → 次のブレークポイントまたはプログラム終了まで実行を再開実装ロジック:ステップ操作は本質的にテンポラリブレークポイントである。ユーザが明示的に設定したブレークポイントと同一の機構を共有し、二つのシステムではなく、一つのシステムの二つの使用方法である。
Step Over:
現在のソース行番号 = query_line(frame)
→ 次の行にテンポラリブレークポイントを設定
→ 現在行が関数呼び出しの場合: 呼び出し点の後にテンポラリブレークポイントを設定
→ Continue → テンポラリブレークポイントヒット → 削除 → 一時停止
Step Into:
現在呼び出し先の最初の実行可能行
→ 関数本体の最初の IR ノードのソース位置を特定
→ テンポラリブレークポイントを設定 → Continue → ヒット → 一時停止
Step Out:
現在のスタックフレームのリターンアドレス
→ 呼び出し元の次の行を特定
→ テンポラリブレークポイントを設定 → Continue → ヒット → 一時停止テンポラリブレークポイントの四つの境界ケース処理:
- 並行帰属:テンポラリブレークポイントは現在のタスク ID に紐付けられ、他のタスクがヒットした場合は直接無視する。
- spawn ブロックに対する Step Over:spawn の外側で Step Over を押すのは spawn ブロック全体を完了し、その後へジャンプするのと同じである。spawn 内部に入ってデバッグするには Step Into を使用すべき。
- テンポラリブレークポイントがヒットしない:ウォッチドッグタイムアウト(30 秒間どのブレークポイントもヒットしない)を設定 → 強制一時停止 → VS Code に通知。同時にプログラム終了イベントを監視 → 即座にクリーンアップ。
- 同一行に複数の IR ノード:Step Over のテンポラリブレークポイントに
ignore_current_lineをマークし、ヒット時にソース行番号が現在の行番号と等しい場合 → 無視して続行。
3. 変数の検査とスコープ
VS Code リクエスト: "現在フレームの変数リスト"
│
DAP サーバ:
├── 現在一時停止点の IR ノードを照会
├── 現在の ScopeBoundary 内の変数束縛を走査
│ └── 各束縛が返却: (名前, 型, ランタイム値参照)
└── VariablesResponse を組み立て → VS Codeスコープ階層:
┌─ Globals ───────────────────────────┐
│ モジュールレベル束縛: 定数、型エイリアス、グローバル │
├─ Locals ────────────────────────────┤
│ 現在関数内で可視の局所変数 │
│ ├── 引数 (関数引数) │
│ └── 局所束縛 (let / 代入) │
├─ Captured ──────────────────────────┤
│ spawn ブロック / クロージャが捕捉した外部変数│
│ 所有権状態を表示: move 済み / ref 共有│
└─────────────────────────────────────┘エンジン間の差異:
| エンジン | 変数値取得 |
|---|---|
| インタプリタ | VM スタックフレームとヒープを直接読み取る。各値はメモリ上に明確な表現を持つ |
| JIT | レジスタとスタック上の値 → JIT コンパイル時に「変数 → レジスタ/スタックスロット」マッピング表を記録する必要がある |
| LLVM | DWARF の .debug_info セクション → DW_AT_location → LLDB がネイティブサポート |
特殊型の表示:コンパイル時述語による精密化された型は、デバッグ上有益な情報を表示する:
x: Positive(x) → "Int (x > 0 = True)" を表示
y: Sorted(y) → "Array(Int) (ソート保証)" を表示
result: T → ランタイムの具体型を表示4. コールスタック
DAP リクエスト: StackTrace
│
返却:
┌──────────────────────────────────┐
│ #0 process_item() file.yx:42 │ ← 現在の一時停止点
│ locals: item = "hello" │
│ spawn タスク ID: task-3 │
├──────────────────────────────────┤
│ #1 main() file.yx:67 │ ← caller
│ locals: data = ["hello", ...]│
├──────────────────────────────────┤
│ #2 <entry> file.yx:1 │ ← ルート
└──────────────────────────────────┘各フレームは:関数シグネチャ、呼び出し位置(ソースファイル+行番号)、局所変数(遅延評価)、spawn コンテキスト(タスク ID)を記録する。
インタプリタと JIT はそれぞれ独立にフレームリンクリストを維持する。フレーム取得はゼロコストではない——しかしデバッグモードはゼロオーバーヘッドを追求しない。
5. 式評価(Watch / REPL)
ユーザがブレークポイントで任意の YaoXiang 式を入力する:
Watch: x + y → 計算結果を返却
Watch: items[2].name → 複雑な構造にアクセス
Watch: f(x) → 関数を呼び出す(副作用リスクあり)評価戦略:
ユーザが式を入力
│
├── コンパイラフロントエンドが式を解析
├── 現在フレームのコンテキストで型チェックを実行
├── 変数値を現在フレームから取得(読み取り専用参照)
├── 式を独立したマイクロプログラムとして実行
│ └── 外部変数の変更は許可しない
│ └── spawn は許可しない
│ └── IO は許可しない(またはオプションで有効化)
└── 結果値を返却 → オリジナルフレームの状態は完全に不変インタプリタは自然なサンドボックス:式評価は新しいサンドボックスを構築するわけではない——インタプリタ自体がサンドボックスである。式評価は一時的にフレームを push し、使用後に破棄するだけである。通常実行と同一の VM を共有するが、いかなる副作用もコミットしない。
関数呼び出し評価:デフォルトで許可するが、ユーザに「この式は副作用を持つ可能性がある」と警告し、ユーザの確認後に実行する。
エンジン間の差異:
| エンジン | 式評価 |
|---|---|
| インタプリタ | 既存の eval コードパスを再利用、現在フレーム環境を注入 |
| JIT | 式を一時的にコンパイル → 現在フレームにリンク → 実行 → 一時コードを破棄 |
| LLVM | サポート対象外——LLVM モードはインタラクティブデバッグを行わない |
6. 並行デバッグ
タスクモデルの可視性:
DAP の threads 概念は YaoXiang の spawn タスクにマッピングされる。各タスクは独自のスタックフレームリンクリストと実行状態を持つ。
┌─ Threads ───────────────────────────┐
│ ● task-1 main() file.yx:10 │ ← 現在フォーカス
│ ▶ task-2 fetch() file.yx:34 │ ← 実行中
│ ⏸ task-3 process() file.yx:56 │ ← ブレークポイントで一時停止
│ ◼ task-4 write() 終了済み │
└─────────────────────────────────────┘並行コンテキストでのブレークポイント:
| 一時停止モード | 動作 | 適用シナリオ |
|---|---|---|
stop-all(デフォルト) | 一つのタスクがヒット → 全タスクが一時停止 | データ競合、グローバル状態のデバッグ |
stop-this-only | ヒットしたタスクのみ一時停止、他は継続 | 独立したタスクロジックのデバッグ |
spawn ブロックのステップセマンティクス:
spawn { // Step Over → spawn ブロック全体を完了
task_a() // Step Into → task_a に入る
task_b() // 並列実行、個別のステップの影響を受けない
}7. DAP プロトコルマッピング
第一段階:中核リクエスト
| DAP リクエスト | YaoXiang セマンティクス |
|---|---|
initialize | 能力ネゴシエーション:ブレークポイント、ステップ、変数、スタックフレームをサポート |
launch / attach | YaoXiang プログラムを起動/アタッチ(--debug は attach モードで動作) |
setBreakpoints | ソース行ブレークポイントを設定 |
configurationDone | ブレークポイント準備完了、実行開始 |
threads | 全アクティブ spawn タスクのリストを返却 |
stackTrace | 指定されたタスクのスタックフレームリストを返却 |
scopes | 現在フレームの変数スコープを返却 |
variables | 指定されたスコープの変数リストを返却 |
continue | 実行を再開 |
next | Step Over |
stepIn | Step Into |
stepOut | Step Out |
pause | 全タスクを中断 |
evaluate | 現在フレームで式を評価 |
disconnect | デバッグセッションを終了 |
第二段階:拡張リクエスト
| DAP リクエスト | YaoXiang セマンティクス |
|---|---|
setFunctionBreakpoints | 関数名ブレークポイント |
setExceptionBreakpoints | エラー/panic 時に一時停止 |
dataBreakpointInfo | データブレークポイント(変数変更でトリガ) |
実装戦略
段階ゼロ:インフラ(全段階に先立つ)
目標:コンパイルフロントエンドがデバッグメタデータを IR に付加する。
| コンポーネント | 変更内容 |
|---|---|
| IR 定義 | SourceLocation、VarName、TypeAnnotation などのメタデータフィールドを追加 |
| Parser | 各 AST ノードがソース位置を記録 |
| TypeChecker | 型情報を IR ノードに添付 |
| テスト | IR dump に位置と変数情報が含まれることを検証 |
ランタイムは関与しない。
実装進捗(2026-09-17):
| 成果物 | 状態 | 実装形式 |
|---|---|---|
| ソース位置 | 完了 | 全 76 個の Instruction バリアントが span フィールドを保持;span() メソッドには意図的にワイルドカードアームを設定せず、新規バリアントが span を持たない場合はコンパイル失敗となる。位置は 40/41 命令をカバー |
| 変数名 | 完了 | LocalSlot { name, ty, scope_depth } を FunctionBody::Code::locals に紐付け;register_local が書き込み時点で生成。.42 デバッグセグメント v2 は名前を保持し、v1 産物は後方互換で読み取り可能 |
| グローバルスロット名 | 完了 | .42 デバッグセグメント v3 は「スロット番号 → トップレベル束縛名」テーブルを保持。トップレベル束縛は Operand::Global を経由し、いかなる関数の局所名テーブルにも存在しないため、このテーブルがなければ数値のみ報告可能で変数名は報告不可。v1/v2 産物は空テーブルで補完読み取り |
| 型情報 | 一部 | スロットは既に ty を含む;TypeAnnotation 独立メタデータは未実装 |
| dump 可視性 | 完了 | dump は各命令に対し ; <file>:<line>:<col> を出力し、locals: 名前@スロット を列挙 |
上表で実装されたデータ形式は段階一と直接接続する:DAP のブレークポイント解析は debug_map を消費し、変数パネルは local_names(名前)と locals(型)を消費する。
第一段階:インタプリタ DAP MVP
目標:yaoxiang run --debug file.yx でブレークポイント設定、ステップ実行、変数表示が可能。
| コンポーネント | 変更内容 |
|---|---|
| DAP サーバ (yx-core 新規モジュール) | stdio トランスポート層、中核リクエスト処理、ブレークポイントマネージャ(ソース行 → IR ノードマッピング) |
| ランタイムデバッグ trait (yx-core) | DebugEngine trait 定義(pause, resume, step, get_frames, eval, get_variables) |
| インタプリタ | 実行ループでのブレークポイントチェック、一時停止/再開機構、フレームリンクリスト維持、InterpreterDebugEngine 実装 |
| CLI | yaoxiang run --debug 引数 |
受け入れ基準:tests/yaoxiang/ 下の任意の .yx ファイルに対し、VS Code でブレークポイント設定、Step Over、変数値表示ができる。
第二段階:高度なデバッグ機能
目標:式評価、関数ブレークポイント、並行デバッグ、例外ブレークポイント。
| コンポーネント | 変更内容 |
|---|---|
| 式評価エンジン | マイクロプログラムコンパイル(parser + typechecker を再利用)、一時フレームの VM への push、副作用隔離 |
| 並行デバッグ | spawn タスクリストマッピング、ブレークポイントのタスク ID 紐付け、stop-all / stop-this-only 一時停止戦略 |
| 関数/例外ブレークポイント | setFunctionBreakpoints、setExceptionBreakpoints のマッピング |
| VS Code 拡張 | デフォルトの launch.json テンプレートを提供 |
第三段階:JIT デバッグ & LLVM DWARF
目標:JIT エンジンが DAP を再利用、LLVM がクラッシュ遡及用 DWARF を出力。
| コンポーネント | 変更内容 |
|---|---|
| JIT | DebugEngine trait 実装、コンパイル時に変数→レジスタマッピング表生成、ランタイムフレームリンクリスト、式の一時コンパイル |
| LLVM | IR デバッグメタデータ → LLVM DILocation / DISubprogram → DWARF(DAP インタラクションなし) |
依存関係
段階ゼロ(IR メタデータ)
↓
段階一(インタプリタ DAP MVP) ← ここから使い始められる
↓
段階二(高度な機能)
↓
段階三(JIT + LLVM DWARF)リスク
| リスク | 緩和策 |
|---|---|
| インタプリタの一時停止機構の複雑さ | 複雑なステートマシンではなく、シンプルな channel/signal を使用。一時停止は次の命令の fetch を禁止するだけ |
| 式評価の型安全性 | 既存の typechecker を再利用、読み取り専用参照、副作用をコミットしない |
| DAP プロトコルの詳細 | debugpy / delve の実装を参照。プロトコルは成熟している |
| 並行デバッグの stop-all ライブロック | タイムアウト機構 + 強制一時停止 |
トレードオフ
利点
- 一つの源:デバッグメタデータは一度だけ生成され、三つのエンジンで共有。「インタプリタのデバッグ情報は正しいが LLVM は間違っている」が起きない
- ゼロ侵襲:
--debugという一つの引数。この引数を付けない場合の動作は完全に変わらない - DAP 標準:VS Code エコシステムに直接接続。カスタムエディタプロトコルやデバッガ UI が不要
- インタプリタ優先:デバッグは本質的にインタプリタに適している——柔軟、制御可能、式評価がシンプル。LLVM モードでインタラクティブデバッグを行わないのは最も実用的な選択
欠点
- デバッグモードの性能が低い:インタプリタは JIT/LLVM より遥かに遅い。しかしデバッグには性能は不要——誰も debug モードで本番負荷を走らせない
- LLVM デバッグの制限:AOT コンパイルはインタラクティブデバッグができず、GDB/LLDB + DWARF のみ。しかしこれはトレードオフ:LLVM モードではそもそもデバッグ動作の差異があってはならない
- 並行一時停止の複雑さ:stop-all セマンティクスをインタプリタで実装するには全アクティブタスクの走査が必要
代替案
| 代替案 | 選択しない理由 |
|---|---|
| 三つのエンジンがそれぞれ DAP を実装 | 三倍の作業、三セットのバグ。「良い品味」に反する |
| DWARF のみ使用、自前 DAP は作らない | インタプリタと JIT には DWARF 概念がなく、LLDB は VM 内部に入れない |
| Python pdb のようにコマンドラインデバッガを模倣 | VS Code 体験はコマンドラインデバッガを圧倒的に凌駕する |
| DAP を LSP プロセスに詰め込む | ライフサイクルが根本的に異なる——LSP はプロジェクトに紐付き、DAP はデバッグセッションに紐付く。プロセス隔離はハード要件 |
オープンな問題
- [ ] 条件ブレークポイントの式構文は通常の YaoXiang と完全に同一か?(提案:完全に同一、parser を再利用)
- [ ]
spawnブロック内の Step Into 動作:ユーザが Step Into を押して spawn ブロックに入った場合、複数の並列タスクのどれを表示すべきか?(提案:最初に作成されたタスクで一時停止) - [ ] VS Code 拡張:デバッグ設定は既存の
vscode-extension/ディレクトリ下に配置するか、独立リポジトリとするか?
