diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd8018f6..937af059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,7 @@ jobs: - name: Verify interpreter benchmark workloads working-directory: sandbox/Benchmark run: dotnet run --project Benchmark.csproj -c Release --no-restore -- --verify + - name: Test debugger protocol + run: dotnet test tests/Lua.Debugger.Dap.Tests/Lua.Debugger.Dap.Tests.csproj -c Release --no-restore + - name: Test debugger controls and hosting + run: dotnet test tests/Lua.Debugger.Tests/Lua.Debugger.Tests.csproj -c Release --no-restore diff --git a/.gitignore b/.gitignore index a57d2f3b..58f81391 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store .claude/settings.local.json artifacts/ +[Bb]in/ +[Oo]bj/ /.idea/ *.sln.DotSettings.user diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..b2ead4d9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Lua debugger extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/editor-extensions/vscode", + "${workspaceFolder}/.vscode/lua-debugger.code-workspace" + ] + }, + { + "name": "Debug Lua sample", + "type": "lua-csharp", + "request": "attach", + "host": "127.0.0.1", + "port": 4711, + "stopOnEntry": true, + "preLaunchTask": "Run Lua debugger sample" + }, + { + "name": "Debug user-script host", + "type": "lua-csharp", + "request": "attach", + "host": "127.0.0.1", + "port": 4711, + "stopOnEntry": true, + "preLaunchTask": "Run user-script host" + }, + { + "name": "Attach to Lua application", + "type": "lua-csharp", + "request": "attach", + "host": "127.0.0.1", + "port": 4711, + "stopOnEntry": false + } + ] +} diff --git a/.vscode/lua-debugger.code-workspace b/.vscode/lua-debugger.code-workspace new file mode 100644 index 00000000..3ab524e2 --- /dev/null +++ b/.vscode/lua-debugger.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": ".." + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..3c162f27 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,99 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Lua debugger sample", + "type": "process", + "command": "dotnet", + "args": [ + "run", + "--project", + "sandbox/Debugger/Debugger.csproj", + "--configuration", + "Release", + "--", + "sandbox/Debugger/example.lua" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "problemMatcher": { + "base": "$msCompile", + "background": { + "activeOnStart": true, + "beginsPattern": "^\\s*Determining projects to restore\\.\\.\\.$", + "endsPattern": "^Lua debugger listening on " + } + } + }, + { + "label": "Run user-script host", + "type": "process", + "command": "dotnet", + "args": [ + "run", + "--project", + "sandbox/DebuggerHost/DebuggerHost.csproj", + "--configuration", + "Release", + "--", + "--host", + "${input:luaHostType}", + "--pause", + "${input:luaPauseMode}", + "--scripts", + "${input:luaUserScripts}", + "--entry", + "${input:luaUserEntry}", + "--wait-for-debugger" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "problemMatcher": { + "base": "$msCompile", + "background": { + "activeOnStart": true, + "beginsPattern": "^\\s*Determining projects to restore\\.\\.\\.$", + "endsPattern": "^Lua debugger listening on " + } + } + } + ], + "inputs": [ + { + "id": "luaUserScripts", + "type": "promptString", + "description": "Application-owned script directory (absolute or relative to the repository)", + "default": "sandbox/DebuggerHost/UserScripts" + }, + { + "id": "luaUserEntry", + "type": "promptString", + "description": "Entry file relative to script directory (blank: main.lua for game; worker.lua for service)", + "default": "" + }, + { + "id": "luaHostType", + "type": "pickString", + "description": "Application hosting model", + "options": [ + "game", + "service" + ], + "default": "game" + }, + { + "id": "luaPauseMode", + "type": "pickString", + "description": "Debugger pause policy: async releases the host thread; sync blocks it", + "options": [ + "async", + "sync" + ], + "default": "async" + } + ] +} diff --git a/Lua.slnx b/Lua.slnx index 740cb310..bae70e8a 100644 --- a/Lua.slnx +++ b/Lua.slnx @@ -1,5 +1,7 @@ + + @@ -7,11 +9,15 @@ + + + + diff --git a/README.md b/README.md index 537dea8b..342126ca 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Performance and allocation costs depend on the workload and embedding API. The i * Support for exception handling with try-catch * High-performance implementation utilizing modern C# * Unity support (works with both Mono and IL2CPP) +* Optional [C# debugger and DAP attach server](docs/debugger.md) ## Installation diff --git a/README_JA.md b/README_JA.md index 988ee086..b78270b4 100644 --- a/README_JA.md +++ b/README_JA.md @@ -27,6 +27,7 @@ MoonSharpは多くの場合で十分な速度を発揮しますが、設計上 * try-catchによる例外処理のサポート * 最新のC#を活用したハイパフォーマンスな実装 * Unityサポート(Mono/IL2CPPの両方で動作) +* オプションの[C#デバッガーとDAP接続サーバー](docs/debugger.md) ## インストール diff --git a/docs/debugger.md b/docs/debugger.md new file mode 100644 index 00000000..4e28102e --- /dev/null +++ b/docs/debugger.md @@ -0,0 +1,185 @@ +# C# Lua debugger and DAP adapter + +`Lua.Debugging.LuaDebugger`, in the optional `LuaCSharp.Debugger` package, provides protocol-independent controls for .NET 6, 8, 10 and .NET Standard 2.1. `LuaCSharp.Debugger.Dap` implements the [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/overview) in C# for an existing Lua host and references that package. The adapter targets .NET 8 and .NET Standard 2.1. Applications own the scripts and execution lifecycle, and can choose asynchronous or blocking stops. See [hosting user-owned scripts](#hosting-user-owned-scripts) and the [practical application sample](#practical-application-sample). + +The interpreter retains only private instruction instrumentation and an internal session bridge. Breakpoints, stepping, source management, inspection and pause policies live in `Lua.Debugger`; protocol, JSON and transport live in `Lua.Debugger.Dap`. The interpreter has no dependency on either debugger assembly and still uses one VM loop. Direct debugger hosts reference `LuaCSharp.Debugger`; DAP hosts need only `LuaCSharp.Debugger.Dap`. The `Lua.Debugging` namespace is unchanged. + +## Embedded host + +Use an `@`-prefixed absolute filename so editor breakpoints match the compiled source. Registering source text lets the editor retrieve it without reading the host filesystem. + +```csharp +using Lua; +using Lua.Debugger.Dap; +using Lua.Standard; + +using var state = LuaState.Create(); +state.OpenStandardLibraries(); +var path = Path.GetFullPath("script.lua"); +var source = await File.ReadAllTextAsync(path); +var closure = state.Load(source, "@" + path); + +using var server = new LuaDapServer(state, port: 4711); +server.Debugger.RegisterSource(closure, source); +var listening = server.RunAsync(); +try +{ + await server.WaitForConfigurationAsync(); + await state.ExecuteAsync(closure); + await server.NotifyTerminatedAsync(); +} +finally +{ + server.Dispose(); + await listening; +} +``` + +The server binds to loopback by default; another bind address is an explicit host choice. It serves one client at a time and supports reconnecting. It does not own the state, launch the host process, or terminate it on disconnect. `WaitForConfigurationAsync` waits for the first client's `configurationDone`, including installing an entry-stop request, before execution starts. Hosts that should run without an editor can omit that wait. + +Disconnect and EOF clear session controls and release a stop. The server keeps its debugger installed for later clients; server disposal detaches it. Host functions used by an evaluation must honor their cancellation token so disconnect can cancel that evaluation. Pass the application's lifetime token to execution and the server when needed. A pause cannot interrupt arbitrary C# code or an outstanding host await. Normal Lua-state ownership rules apply: do not dispose the state or execute it concurrently while it is running or stopped. + +For another transport, use `LuaDapSession(debugger, inputStream, outputStream)` and await `RunAsync`. The caller owns the streams. Use one active DAP session per debugger. + +## Hosting user-owned scripts + +The debug target is the application's `LuaState`, including every user file, module and coroutine loaded into it. The application owns its state, script loader, source storage, execution schedule and shutdown. The DAP server exposes that existing runtime; it does not decide which user code the application should run. + +Use `LuaDebuggerOptions` with either `LuaDebugger` or `LuaDapServer`: + +```csharp +var options = new LuaDebuggerOptions +{ + PauseMode = LuaDebugPauseMode.Asynchronous, + // Optional: map uploaded/resource/generated chunk names to editor files. + SourcePathResolver = chunkName => FindEditorPath(chunkName), + // Optional: provide the source snapshot that the application actually compiled. + SourceTextProvider = (source, token) => ReadApplicationSourceAsync(source, token), +}; +using var server = new LuaDapServer(state, options, port: 4711); +``` + +`SourcePathResolver` receives the decoded chunk name, including its `@` or `=` prefix. Return null to retain the usual filename/in-memory mapping. `SourceTextProvider` runs when the editor requests source, possibly on the protocol thread; it can read an application cache, resource store, upload or database without exposing the Lua stack. Use host-owned source data rather than reading live Lua storage. Registered source text takes precedence, and a null provider result falls back to the state's filesystem. For fileless scripts, a source reference and registered/provided text work without any editor file path. These callbacks concern source ownership and display; the application still loads and executes its code normally. + +Choose the pause policy to match the host: + +| Policy | Lua execution at a stop | Application behavior | +| --- | --- | --- | +| `Asynchronous` (default) | Returns a pending execution task; resumes through the host's synchronization context. | A UI/game loop can continue processing unrelated work while that script call is suspended. Do not start another call on the same state until the pending call completes. | +| `Synchronous` | Keeps the execution thread inside the stop and services debugger commands there. | A UI/game thread's ordinary updates stop. A service's other threads can continue. Awaited debugger evaluation uses a private synchronization context to return to the stopped thread; ordinary UI callbacks are not pumped. | + +Both modes use the same VM loop and checkpoints. The choice is consulted only for an actual stop, not on each instruction. Asynchronous pause does not move ordinary execution to a background thread or introduce time slicing. Synchronous mode does not make normal asynchronous host functions synchronous; it chooses how a debugger stop waits. A synchronous stop blocks the thread currently executing Lua; a previous asynchronous host call may already have released its original caller. Work explicitly posted to the application's blocked dispatcher remains queued, so use asynchronous pause for evaluations that depend on that dispatcher. Neither policy automatically freezes other states or the application's world. If the application needs a global pause, its own scheduling policy can respond to `Stopped`/`StopEnded` or `CurrentStop`. Cancellation and disconnect release either kind of stop. + +Install the debugger/listener before starting execution. The editor may connect later while the application is running. Waiting for `WaitForConfigurationAsync` is optional and belongs at the point where the application wants to hold user-code execution for the first editor, rather than unconditionally holding its startup. Keep the listener alive across repeated calls and disconnects. Call `NotifyTerminatedAsync` when the debugged application/runtime ends, not after every update callback returns. + +Each listener currently represents one root state and its coroutines; independently executing states can use separate listeners/ports. A single session does not multiplex independent states. + +## Practical application sample + +[sandbox/DebuggerHost](../sandbox/DebuggerHost) is a long-running embedding example. It loads a user-selected entry file and modules from an application-owned directory. The game entry returns `update(frame, dt)`; the service entry returns `handle(job)`. The application invokes either without overlapping calls. The game scripts maintain an actor across updates; the service scripts price incoming jobs and return results to C#. Both use separate Lua modules and a C# text-loading function. Logical `=user/...` chunk names map back to editor files, while source requests receive the text that was compiled. + +In the development window described below, select **Debug user-script host** and press **F5**. Choose the game/service host, pause policy, script directory and optional entry file at the prompts. A blank entry uses `main.lua` for games or `worker.lua` for services. The game defaults use [UserScripts/main.lua](../sandbox/DebuggerHost/UserScripts/main.lua); set a breakpoint on line 10 or in [movement.lua](../sandbox/DebuggerHost/UserScripts/movement.lua) on line 9. Step across the module call, inspect `actor`, `step` and the `updates` upvalue, or evaluate `host.read_text("greeting.txt")`. For services, use [worker.lua](../sandbox/DebuggerHost/UserScripts/worker.lua) and [pricing.lua](../sandbox/DebuggerHost/UserScripts/pricing.lua), with a breakpoint on line 6 of `pricing.lua` to inspect/edit `subtotal` and `tax`. + +The game host uses a single-thread dispatcher as a console stand-in for a UI/game application. Its `[app]` heartbeat and frame counter continue at an asynchronous stop and stop at a synchronous one. The service host has no UI synchronization context: its independent heartbeat continues even when the execution thread is blocked. No sample dispatcher is required in a real UI framework that already supplies its own synchronization context. A pending Lua update stays pending while the application continues; skipped frames do not cause concurrent/reentrant execution. + +To start an application yourself and attach later: + +```sh +dotnet run -c Release --project sandbox/DebuggerHost -- --scripts sandbox/DebuggerHost/UserScripts --host game --pause async +dotnet run -c Release --project sandbox/DebuggerHost -- --scripts /path/to/user/scripts --entry worker.lua --host service --pause sync --port 4712 +``` + +Run one of these commands, then choose **Attach to Lua application** in the editor, adjusting its port when needed. That launch profile starts no application process and works with your own C# host as well. Omit `--wait-for-debugger` to run without an editor; add it to hold the first user call until configuration is complete. `--frames N` provides a finite run for batch use. Otherwise the host runs until Ctrl+C. Disconnecting resumes the script and leaves the application running, so the editor can reconnect. Stop the task/Ctrl+C to end the application. The sample loads code once; restart it to apply script edits. It does not implement hot reload. + +## VS Code example + +The small connector in [editor-extensions/vscode](../editor-extensions/vscode) registers the debugger type and connects to the C# server. It has no DAP request handling or Lua runtime. + +The repository includes [launch configurations](../.vscode/launch.json) and a [sample task](../.vscode/tasks.json). With the .NET 10 SDK installed: + +1. Open the repository root in VS Code. In Run and Debug, select **Run Lua debugger extension** and press **F5**. This opens an Extension Development Host with the local connector loaded and the repository available through a dedicated workspace file; no npm installation or extension build is needed. +2. In that new window, open [sandbox/Debugger/example.lua](../sandbox/Debugger/example.lua) and set a breakpoint on line 8. +3. Select **Debug Lua sample** and press **F5**. Its task builds and starts the C# host, waits for the listener's readiness message, and then attaches on `127.0.0.1:4711`. +4. Execution stops on entry. Press **F5** to reach the breakpoint, **F11** to enter `add`, **F10** to step over, or **Shift+F11** to step out. Inspect locals or evaluate `total` in the Debug Console. + +The sample prints `total 6` in its task terminal and exits. Press F5 with **Debug Lua sample** selected to start a fresh run. To stop an active session, disconnect the debugger; the host resumes and finishes. If needed, terminate the sample task from the terminal. After loading the connector, subsequent runs need only the sample configuration in that development window. + +VS Code skips a folder that is already open when launching an Extension Development Host. The launcher therefore opens [.vscode/lua-debugger.code-workspace](../.vscode/lua-debugger.code-workspace), which includes the repository under a distinct workspace identity. Close any previously opened empty development window before retrying the launcher; manually opening the repository folder can focus the original window instead. + +## Stops and steps + +A dedicated VM checkpoint runs before a selected instruction executes. It does not replace `LuaState.SetHook` or Lua's `debug.sethook`, insert Lua callback frames, change coroutine status, or patch shared bytecode. It covers existing and future coroutines sharing the global state. Install the core debugger before execution starts; `LuaDapServer.RunAsync` does this before accepting clients, so an editor can attach to an already-running host. + +An asynchronous stop awaits a signal; a synchronous stop waits on its debugger command queue. Both process inspection commands on the suspended execution, and the protocol reader continues receiving requests without reading live stack storage itself. Frame/variable handles expire on resume, cancellation, or disconnect; table references are cleared when the stop ends. + +DAP inspections are serialized separately from execution controls. Continue and stepping can cancel an awaiting evaluation; execution resumes after that inspection unwinds. Requests retain the stop ID observed when received, so queued inspections cannot operate on a later stop. An application resume racing an editor resume preserves the newer stop and produces one response to the editor request. Failures from inspection cancellation callbacks after an acknowledged resume are reported through DAP stderr output. + +An editor connecting to an existing stop receives it after configuration completes. Disconnect releases the stop and clears session controls after pending requests finish. Replacing the debugger while execution is idle also covers coroutines that yielded under its predecessor. If posted work throws during a synchronous stop, pending inspections are cancelled and drained before the failure unwinds Lua execution. + +The installation guard covers host execution on ordinary `CreateThread()` states and coroutine states, including `RunAsync`, `ExecuteAsync`, `CallAsync` and host metamethod calls, as well as coroutine resumes through `ResumeAsync(LuaStack)`. The outermost host entry and each coroutine resume are tracked until they return, yield or fail. A yielded coroutine is idle for debugger installation even though it still has suspended call frames. This tracking runs at execution boundaries, without per-instruction checks. + +Session teardown unsubscribes handlers, resets controls and drains its protocol workers even if an inspection's cancellation callback throws. Server disposal also closes its transport and disposes its debugger before propagating a cancellation callback failure. Disposing a debugger releases its current stop; `StopEnded` is raised after pending inspection cleanup finishes, allowing the host to release its own application pause. Disposal does not wait for that cleanup, so the host must still await its execution before disposing the Lua state. + +The synchronous pump drains accepted callbacks even when a stop is resumed immediately by a `Stopped` handler. It also finishes draining when a cancellation callback throws, then reports the original posted failure. While a client is connected, application-driven resume, reset or execution cancellation sends a `continued` event. Client continue/step requests already imply resumption and do not produce a duplicate event. + +| Command | Stop rule | +| --- | --- | +| Pause / entry | At the next source/control-flow checkpoint; entry stops before the first instruction. | +| Source breakpoint | At a verified source-line event, including loop revisits. | +| Step in | At the next line event, including a called function or resumed coroutine. | +| Step over | At the next line in the original frame, or after it returns. A tail-call replacement is distinct. | +| Step out | After the original frame returns. A coroutine yielding/completing can transfer the stop to its caller. | + +Frame identity supplements depth to handle tail calls. A pending step ends when its owning Lua stack has unwound and execution returns to the host; breakpoints remain installed for later invocations. A yielded coroutine retains its step across direct C# resumes because its Lua frames are still live. Hooks already executed for a stopped instruction are not executed again on resume. Locals, upvalues, varargs, globals, and raw table/userdata contents are inspected lazily with paging. Value display does not call user `__tostring`. + +Source paths use host path/case rules. Breakpoints resolve to the first executable line at or after the requested line. Unknown sources remain pending until registered/executed, with breakpoint-change events on resolution. There is no basename-only fallback. For an in-memory chunk, use an `=name` chunk name, register its text, and use the source reference supplied by the adapter. + +## Evaluation and supported requests + +Evaluate accepts Lua expressions. Locals and upvalues are copied into an evaluation environment, with lookup falling back to the selected frame's environment. Explicit `_ENV` refers to that evaluation environment. Evaluation does not retain writable pointers to a paused frame. Expressions can call Lua/C# functions and therefore have side effects. Lua yielding is disabled across evaluation; C# functions can await. Evaluation failures do not invoke the program's `xpcall` handler. `setVariable` writes locals, upvalues, or globals explicitly; raw table/userdata children are read-only. + +Supported requests: initialize, attach, configurationDone, setBreakpoints, pause, continue, next, stepIn, stepOut, threads, stackTrace, scopes, variables, evaluate, setVariable, source, loadedSources, and disconnect. Empty exception-breakpoint configuration is accepted. Client line/column bases and file-URI paths are respected. One DAP thread represents the global state's execution and its coroutines. Stack inspection shows the active coroutine, without stitching its parent's stack. + +The adapter does not advertise launch/process control, exception breakpoints, conditional/hit-count/log breakpoints, reverse execution, instruction stepping, source maps, or remote path mappings. Stripped bytecode has no meaningful source lines/named locals; pause can still stop it, but source breakpoints cannot be verified. Concurrent execution against the same global state remains unsupported. + +## Why checkpoints + +The design follows [ChibiRuby's C# debugger / DAP separation](https://github.com/hadashiA/ChibiRuby/tree/main/src/ChibiRuby.Debugger.Dap), adapting suspension to Lua-CSharp's asynchronous VM. References also include the [earlier Lua-CSharp debugger](https://github.com/akeit0/Lua-CSharp/tree/debugger) and [Okojo's checkpoint design](https://github.com/akeit0/okojo/blob/main/docs/architecture/debugging/OKOJO_EXECUTION_CHECKPOINT_NOTE.md). + +An ordinary Lua hook can be replaced by the script and adds callback/frame overhead. A separate checkpoint preserves Lua 5.2 hook behavior and reaches existing coroutines. Awaiting the stop keeps the host responsive without converting a debugger stop into `coroutine.yield`. + +Other embedders informed the hosting API as well. [MoonSharp's server](https://github.com/moonsharp-devs/moonsharp/blob/master/src/MoonSharp.VsCodeDebugger/MoonSharpVsCodeDebugServer.cs) attaches application-owned scripts and accepts a source finder; its [AsyncDebugger](https://github.com/moonsharp-devs/moonsharp/blob/master/src/MoonSharp.VsCodeDebugger/DebuggerLogic/AsyncDebugger.cs) nevertheless blocks the script thread while waiting for debugger actions. [Jint's debug callbacks](https://github.com/sebastienros/jint/blob/main/Jint/Runtime/Debugger/DebugHandler.cs) run inside engine execution and resume it by returning. [ClearScript's document pause flag](https://github.com/ClearFoundry/ClearScript/blob/master/ClearScript/DocumentFlags.cs) separates a selected script's initial debugger wait from general engine setup. Lua-CSharp keeps application ownership and source mapping explicit, and offers both blocking and asynchronous stops because its host calls can already await. + +The VM keeps one dispatch loop. Ordinary execution uses `Prototype.Code`; a debugger installs its own cached execution copy for each prototype. That copy contains fixed trap instructions at selected source-line and control-flow points. Breakpoint, pause, and step changes update control flags; the protocol thread never patches instructions. Inactive checkpoints immediately dispatch the original instruction. Loop targets provide a checkpoint even for a single-line infinite loop. Bytecode without source metadata still has control-flow checkpoints. + +Trap handling lives in the dispatch switch's existing fallback path. There is no second interpreter loop or generic specialization. Keeping traps outside the ordinary opcode cases also avoids expanding the normal dispatch table. + +A forward numeric-loop setup jump needs no separate header trap when it stays on the same source line. The setup records that untrapped header once, preserving the first backward jump's line event. Loop-body targets and source-line boundaries remain checkpoints. Call/return checkpoints remain available for pause requests around host awaits; unreachable fallthrough points after returns and tail calls are omitted. + +An internal resume trap executes the stopped instruction once and restores its checkpoint. Only the VM owner writes that marker. It survives breakpoint edits during a stop and does not replay Lua hooks. Private execution copies keep shared prototypes, other Lua states, and bytecode dumps unchanged. Volatile control flags are checked only at traps, so ordinary instruction fetching needs no debugger flag or volatile read. Execution-copy metadata is weakly cached and released with its prototype. Attached execution pays for checkpoints, including when no breakpoint is hit; detached execution uses the ordinary instructions. + +Execution copies carry an opaque session identity and their source metadata, without a reference back to the debugger. A yielded coroutine therefore does not retain a detached debugger or its application callbacks. The weak tracking list removes dead entries when it fills, before growing, as well as during control changes. Compiling temporary chunks therefore does not grow that list indefinitely while controls stay unchanged. The VM checks ownership at frame restart; within the instruction loop it reads the active debugger only at enabled checkpoints. Ownership requires no weak-reference lookup on the execution path. + +## Performance + +Debugging is opt-in. Detached execution uses the original bytecode. Starting a server installs checkpoints before accepting clients, so an installed debugger has an execution cost even while no editor is connected. The cost depends on the workload's line boundaries, calls, loop targets and enabled breakpoints. A breakpoint in an uncalled function does not activate matching in other functions from the same source. + +First execution builds private instruction copies and source metadata. Reusing compiled chunks avoids that setup on later calls. Pause, stepping and inspection allocate only as needed; execution ownership is tracked at host-call and coroutine-resume boundaries. Control updates use the weak tracking list and add no per-instruction work. + +Run benchmarks in Release mode from this repository: + +```sh +# Warmed execution, detached versus installed, across six interpreter workloads. +dotnet run -c Release --project sandbox/Benchmark -- --filter '*DebuggerBenchmark*' + +# An unhit breakpoint in another function versus a cold branch of the active function. +dotnet run -c Release --project sandbox/Benchmark -- --filter '*DebuggerBreakpointBenchmark*' + +# Pause/reset cost with 1 or 2,000 activated functions. +dotnet run -c Release --project sandbox/Benchmark -- --filter '*DebuggerControlBenchmark*' + +# First-use metadata cost; compilation is excluded. +dotnet run -c Release --project sandbox/Benchmark -- --filter '*DebuggerFirstUseBenchmark*' +``` + +`DebuggerFirstUseBenchmark` varies function count, source preregistration and an unhit breakpoint. Keep first-use measurements separate from warmed execution and measure allocations as well as elapsed time. Small timing differences are sensitive to JIT profiles, code layout and system load; compare the same workloads and runtime settings when assessing a change. diff --git a/editor-extensions/vscode/extension.js b/editor-extensions/vscode/extension.js new file mode 100644 index 00000000..12fc6fbb --- /dev/null +++ b/editor-extensions/vscode/extension.js @@ -0,0 +1,14 @@ +const vscode = require("vscode"); + +exports.activate = (context) => { + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory("lua-csharp", { + createDebugAdapterDescriptor(session) { + return new vscode.DebugAdapterServer( + session.configuration.port ?? 4711, + session.configuration.host ?? "127.0.0.1", + ); + }, + }), + ); +}; diff --git a/editor-extensions/vscode/package.json b/editor-extensions/vscode/package.json new file mode 100644 index 00000000..99704fda --- /dev/null +++ b/editor-extensions/vscode/package.json @@ -0,0 +1,31 @@ +{ + "name": "lua-csharp-debugger", + "displayName": "Lua-CSharp Debugger", + "description": "Attach to the embedded C# Lua-CSharp DAP server.", + "version": "0.1.0", + "publisher": "luacsharp", + "engines": { "vscode": "^1.85.0" }, + "categories": ["Debuggers"], + "activationEvents": ["onDebugResolve:lua-csharp"], + "main": "./extension.js", + "contributes": { + "debuggers": [{ + "type": "lua-csharp", + "label": "Lua-CSharp", + "languages": ["lua"], + "configurationAttributes": { + "attach": { + "properties": { + "host": { "type": "string", "default": "127.0.0.1" }, + "port": { "type": "number", "default": 4711 }, + "stopOnEntry": { "type": "boolean", "default": true } + } + } + }, + "initialConfigurations": [{ + "type": "lua-csharp", "request": "attach", "name": "Attach to Lua-CSharp", + "host": "127.0.0.1", "port": 4711, "stopOnEntry": true + }] + }] + } +} diff --git a/sandbox/Benchmark/Benchmark.csproj b/sandbox/Benchmark/Benchmark.csproj index dd064769..1fc13074 100644 --- a/sandbox/Benchmark/Benchmark.csproj +++ b/sandbox/Benchmark/Benchmark.csproj @@ -15,6 +15,7 @@ + "local x=0;for i=1,10000 do x=x+i end;assert(x==50005000)", + "calls" => + "local function f(x)return x+1 end;local x=0;for i=1,1000 do x=f(x)end;assert(x==1000)", + "tables" => "local t={x=1};for i=1,1000 do t.x=t.x+1 end;assert(t.x==1001)", + "metamethods" => + "local t=setmetatable({},{__add=function(a,b)return b+1 end});local x;for i=1,1000 do x=t+i end;assert(x==1001)", + "coroutines" => + "local co=coroutine.create(function()for i=1,100 do coroutine.yield(i)end end);local x=0;for i=1,100 do local ok,v=coroutine.resume(co);assert(ok);x=x+v end;assert(x==5050)", + "n-body" => ReadNBody(), + _ => throw new InvalidOperationException(), + }; + closure = state.Load(source, "=debugger-benchmark"); + if (Attached) + { + debugger = new LuaDebugger(state); + debugger.Attach(); + } + Run(); + } + + [Benchmark] + public void Run() => + state.ExecuteAsync(closure, Memory.Empty).GetAwaiter().GetResult(); + + static string ReadNBody() + { + using var source = typeof(DebuggerBenchmark).Assembly.GetManifestResourceStream( + "Comparison.n-body.lua" + )!; + using var reader = new StreamReader(source); + return reader.ReadToEnd(); + } + + [GlobalCleanup] + public void Cleanup() + { + debugger?.Dispose(); + state.Dispose(); + } +} diff --git a/sandbox/Benchmark/DebuggerBreakpointBenchmark.cs b/sandbox/Benchmark/DebuggerBreakpointBenchmark.cs new file mode 100644 index 00000000..91ec4049 --- /dev/null +++ b/sandbox/Benchmark/DebuggerBreakpointBenchmark.cs @@ -0,0 +1,52 @@ +using BenchmarkDotNet.Attributes; +using Lua; +using Lua.Debugging; +using Lua.Runtime; +using Lua.Standard; + +[Config(typeof(ComparisonConfig))] +public class DebuggerBreakpointBenchmark +{ + [Params("other-function", "cold-branch")] + public string Location { get; set; } = "other-function"; + + [Params(false, true)] + public bool BreakpointEnabled { get; set; } + + LuaState state = default!; + LuaClosure closure = default!; + LuaDebugger debugger = default!; + + [GlobalSetup] + public void Setup() + { + state = LuaState.Create(); + state.OpenStandardLibraries(); + debugger = new LuaDebugger(state); + debugger.Attach(); + closure = state.Load( + Location == "other-function" + ? "local function unused()\n return 0\nend\nlocal x=0;for i=1,10000 do x=x+i end;assert(x==50005000)" + : "local x=0\nfor i=1,10000 do x=x+i end\nif x<0 then\n x=0\nend\nassert(x==50005000)", + "=breakpoint-benchmark" + ); + debugger.RegisterSource(closure); + if (BreakpointEnabled) + debugger.SetBreakpoints( + "=breakpoint-benchmark", + [Location == "other-function" ? 2 : 4] + ); + Run(); + } + + [Benchmark] + public void Run() => + state.ExecuteAsync(closure, Memory.Empty).GetAwaiter().GetResult(); + + [GlobalCleanup] + public void Cleanup() + { + debugger.Dispose(); + state.Dispose(); + } +} diff --git a/sandbox/Benchmark/DebuggerControlBenchmark.cs b/sandbox/Benchmark/DebuggerControlBenchmark.cs new file mode 100644 index 00000000..dae3b1cf --- /dev/null +++ b/sandbox/Benchmark/DebuggerControlBenchmark.cs @@ -0,0 +1,49 @@ +using BenchmarkDotNet.Attributes; +using Lua; +using Lua.Debugging; +using Lua.Runtime; + +[Config(typeof(ComparisonConfig))] +public class DebuggerControlBenchmark +{ + [Params(1, 2000)] + public int FunctionCount { get; set; } + + LuaState state = default!; + LuaClosure source = default!; + LuaDebugger debugger = default!; + + [GlobalSetup] + public void Setup() + { + state = LuaState.Create(); + debugger = new LuaDebugger(state); + debugger.Attach(); + source = state.Load( + "local t={}\n" + + string.Join( + "\n", + Enumerable + .Range(1, FunctionCount) + .Select(i => $"t[{i}]=function() return {i} end") + ) + + $"\nfor i=1,{FunctionCount} do t[i]() end", + "=controls" + ); + state.ExecuteAsync(source, Memory.Empty).GetAwaiter().GetResult(); + } + + [Benchmark] + public void PauseAndReset() + { + debugger.Pause(); + debugger.Reset(); + } + + [GlobalCleanup] + public void Cleanup() + { + debugger.Dispose(); + state.Dispose(); + } +} diff --git a/sandbox/Benchmark/DebuggerFirstUseBenchmark.cs b/sandbox/Benchmark/DebuggerFirstUseBenchmark.cs new file mode 100644 index 00000000..5ed6db3b --- /dev/null +++ b/sandbox/Benchmark/DebuggerFirstUseBenchmark.cs @@ -0,0 +1,59 @@ +using BenchmarkDotNet.Attributes; +using Lua; +using Lua.Debugging; +using Lua.Runtime; + +[Config(typeof(ComparisonConfig))] +public class DebuggerFirstUseBenchmark +{ + [Params(250, 1000, 2000)] + public int FunctionCount { get; set; } + + [Params(false, true)] + public bool BreakpointEnabled { get; set; } + + [Params(false, true)] + public bool PreRegistered { get; set; } + + LuaState state = default!; + LuaClosure source = default!; + LuaClosure driver = default!; + LuaDebugger debugger = default!; + + [GlobalSetup] + public void Setup() + { + state = LuaState.Create(); + var functions = string.Join( + "\n", + Enumerable.Range(1, FunctionCount).Select(i => $"t[{i}]=function() return {i} end") + ); + source = state.Load( + "local t={}\n" + functions + "\nlocal function unused()\n return 0\nend\nreturn t", + "=first-use" + ); + state.Environment["functions"] = state.ExecuteAsync(source).GetAwaiter().GetResult()[0]; + driver = state.Load($"for i=1,{FunctionCount} do functions[i]() end", "=driver"); + } + + [IterationSetup] + public void Attach() + { + debugger = new LuaDebugger(state); + debugger.Attach(); + if (PreRegistered) + debugger.RegisterSource(source); + if (BreakpointEnabled) + debugger.SetBreakpoints("=first-use", [FunctionCount + 4]); + } + + [Benchmark] + public void FirstCalls() => + state.ExecuteAsync(driver, Memory.Empty).GetAwaiter().GetResult(); + + [IterationCleanup] + public void Detach() => debugger.Dispose(); + + [GlobalCleanup] + public void Cleanup() => state.Dispose(); +} diff --git a/sandbox/Benchmark/Program.cs b/sandbox/Benchmark/Program.cs index 856edff5..bc0d1e04 100644 --- a/sandbox/Benchmark/Program.cs +++ b/sandbox/Benchmark/Program.cs @@ -32,6 +32,10 @@ typeof(StartupComparison), typeof(GeneratedUserDataBenchmark), typeof(CoroutineTransferBenchmark), + typeof(DebuggerBenchmark), + typeof(DebuggerBreakpointBenchmark), + typeof(DebuggerFirstUseBenchmark), + typeof(DebuggerControlBenchmark), ]) .Run(args); diff --git a/sandbox/Debugger/Debugger.csproj b/sandbox/Debugger/Debugger.csproj new file mode 100644 index 00000000..c3e9833d --- /dev/null +++ b/sandbox/Debugger/Debugger.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + + + + + + diff --git a/sandbox/Debugger/Program.cs b/sandbox/Debugger/Program.cs new file mode 100644 index 00000000..75bb6ae9 --- /dev/null +++ b/sandbox/Debugger/Program.cs @@ -0,0 +1,35 @@ +using Lua; +using Lua.Debugger.Dap; +using Lua.Standard; + +var path = Path.GetFullPath( + args.Length > 0 ? args[0] : Path.Combine(AppContext.BaseDirectory, "example.lua") +); +using var state = LuaState.Create(); +state.OpenStandardLibraries(); +using var server = new LuaDapServer(state); +using var cancellation = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cancellation.Cancel(); +}; +var source = await File.ReadAllTextAsync(path, cancellation.Token); +var closure = state.Load(source, "@" + path); +server.Debugger.RegisterSource(closure, source); +var listening = server.RunAsync(cancellation.Token); +Console.Error.WriteLine( + $"Lua debugger listening on {server.LocalEndpoint}. Attach an editor to begin." +); +try +{ + await server.WaitForConfigurationAsync(cancellation.Token); + await state.ExecuteAsync(closure, cancellation.Token); + await server.NotifyTerminatedAsync(cancellation.Token); +} +catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } +finally +{ + server.Dispose(); + await listening; +} diff --git a/sandbox/Debugger/example.lua b/sandbox/Debugger/example.lua new file mode 100644 index 00000000..be8cce3e --- /dev/null +++ b/sandbox/Debugger/example.lua @@ -0,0 +1,10 @@ +local function add(a, b) + local result = a + b + return result +end + +local total = 0 +for i = 1, 3 do + total = add(total, i) +end +print("total", total) diff --git a/sandbox/DebuggerHost/DebuggerHost.csproj b/sandbox/DebuggerHost/DebuggerHost.csproj new file mode 100644 index 00000000..24333ff6 --- /dev/null +++ b/sandbox/DebuggerHost/DebuggerHost.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + + + + + + diff --git a/sandbox/DebuggerHost/HostLoop.cs b/sandbox/DebuggerHost/HostLoop.cs new file mode 100644 index 00000000..61faa6e5 --- /dev/null +++ b/sandbox/DebuggerHost/HostLoop.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; + +// A console stand-in for a UI dispatcher: all application callbacks share one thread. +sealed class HostLoop : SynchronizationContext +{ + readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> work = new(); + + public static void Run(Func action) + { + var previous = Current; + var context = new HostLoop(); + SetSynchronizationContext(context); + try + { + var task = action(); + _ = task.ContinueWith( + _ => context.work.CompleteAdding(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + foreach (var item in context.work.GetConsumingEnumerable()) + item.Callback(item.State); + task.GetAwaiter().GetResult(); + } + finally + { + SetSynchronizationContext(previous); + context.work.Dispose(); + } + } + + public override void Post(SendOrPostCallback callback, object? state) => + work.Add((callback, state)); +} diff --git a/sandbox/DebuggerHost/HostOptions.cs b/sandbox/DebuggerHost/HostOptions.cs new file mode 100644 index 00000000..63b13b5c --- /dev/null +++ b/sandbox/DebuggerHost/HostOptions.cs @@ -0,0 +1,75 @@ +using Lua.Debugging; + +sealed record HostOptions( + string Scripts, + string Entry, + LuaDebugPauseMode PauseMode, + bool Game, + bool WaitForDebugger, + int Port, + int Frames +) +{ + public static HostOptions Parse(string[] args) + { + var scripts = Path.Combine(AppContext.BaseDirectory, "UserScripts"); + string? entry = null; + var mode = LuaDebugPauseMode.Asynchronous; + var game = true; + var wait = false; + var port = 4711; + var frames = 0; + for (var i = 0; i < args.Length; i++) + { + string Value() => + ++i < args.Length ? args[i] : throw new ArgumentException("Missing option value."); + switch (args[i]) + { + case "--scripts": + scripts = Value(); + break; + case "--entry": + entry = Value(); + break; + case "--pause": + mode = Value() switch + { + "async" => LuaDebugPauseMode.Asynchronous, + "sync" => LuaDebugPauseMode.Synchronous, + _ => throw new ArgumentException("Use --pause async or sync."), + }; + break; + case "--host": + game = Value() switch + { + "game" => true, + "service" => false, + _ => throw new ArgumentException("Use --host game or service."), + }; + break; + case "--port": + port = int.Parse(Value()); + break; + case "--frames": + frames = int.Parse(Value()); + break; + case "--wait-for-debugger": + wait = true; + break; + default: + throw new ArgumentException("Unknown option: " + args[i]); + } + } + if (frames < 0) + throw new ArgumentOutOfRangeException(nameof(frames)); + return new( + Path.GetFullPath(scripts), + string.IsNullOrEmpty(entry) ? (game ? "main.lua" : "worker.lua") : entry, + mode, + game, + wait, + port, + frames + ); + } +} diff --git a/sandbox/DebuggerHost/Program.cs b/sandbox/DebuggerHost/Program.cs new file mode 100644 index 00000000..3e61701c --- /dev/null +++ b/sandbox/DebuggerHost/Program.cs @@ -0,0 +1,173 @@ +using Lua; +using Lua.Debugger.Dap; +using Lua.Debugging; +using Lua.Standard; + +if (args.Contains("--help")) +{ + Console.WriteLine( + "DebuggerHost [--scripts DIR] [--entry FILE] [--host game|service] [--pause async|sync] [--port PORT] [--wait-for-debugger] [--frames COUNT]" + ); + return; +} +var options = HostOptions.Parse(args); +if (options.Game) + HostLoop.Run(() => RunAsync(options)); +else + await RunAsync(options); + +static async Task RunAsync(HostOptions options) +{ + using var shutdown = new CancellationTokenSource(); + ConsoleCancelEventHandler cancel = (_, e) => + { + e.Cancel = true; + shutdown.Cancel(); + }; + Console.CancelKeyPress += cancel; + var token = shutdown.Token; + var scripts = new UserScriptStore(options.Scripts); + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.ModuleLoader = scripts; + var frames = 0; + var updates = 0; + state.Environment["host"] = new LuaTable + { + ["log"] = new LuaFunction( + (context, _) => + { + Console.WriteLine("[script] " + context.GetArgument(0)); + return new(context.Return()); + } + ), + ["frame"] = new LuaFunction((context, _) => new(context.Return(Volatile.Read(ref frames)))), + ["read_text"] = new LuaFunction( + async (context, cancellationToken) => + { + var path = Path.Combine(options.Scripts, context.GetArgument(0)); + var text = await File.ReadAllTextAsync(path, cancellationToken); + return context.Return(text.TrimEnd()); + } + ), + }; + using var server = new LuaDapServer( + state, + new LuaDebuggerOptions + { + PauseMode = options.PauseMode, + SourcePathResolver = scripts.ResolvePath, + SourceTextProvider = scripts.ReadSourceAsync, + }, + options.Port + ); + server.Debugger.Stopped += stop => + Console.WriteLine($"[debug] {stop.Reason} at {stop.Source.Name}:{stop.Line}"); + Task listening = Task.CompletedTask; + Task heartbeat = Task.CompletedTask; + Task? invocation = null; + try + { + listening = server.RunAsync(token); + if (listening.IsCompleted) + await listening; + Console.Error.WriteLine( + $"Lua debugger listening on {server.LocalEndpoint}. Host={(options.Game ? "game" : "service")}, pause={options.PauseMode}." + ); + Console.WriteLine( + $"User scripts: {options.Scripts}; entry: {options.Entry}. Ctrl+C stops the application." + ); + heartbeat = HeartbeatAsync(); + if (options.WaitForDebugger) + await server.WaitForConfigurationAsync(token); + var closure = state.Load(await scripts.ReadAsync(options.Entry, token)); + var result = await state.ExecuteAsync(closure, token); + var handler = + result.Length == 1 && result[0].TryRead(out var function) + ? function + : throw new InvalidOperationException( + "The entry script must return update(frame, dt) for a game host or handle(job) for a service host." + ); + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250)); + while (options.Frames == 0 || Volatile.Read(ref frames) < options.Frames) + { + await timer.WaitForNextTickAsync(token); + var frame = Interlocked.Increment(ref frames); + if (invocation is { IsCompleted: true }) + { + var completed = invocation; + invocation = null; + await completed; + } + if (invocation == null) + invocation = InvokeAsync(handler, frame); + } + if (invocation != null) + { + var completed = invocation; + invocation = null; + await completed; + } + await server.NotifyTerminatedAsync(token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + Environment.ExitCode = 1; + } + finally + { + shutdown.Cancel(); + server.Dispose(); + try + { + var finishing = + invocation == null + ? [listening, heartbeat] + : new[] { listening, heartbeat, invocation }; + await Task.WhenAll(finishing); + } + catch (LuaCanceledException) { } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + Environment.ExitCode = 1; + } + finally + { + Console.CancelKeyPress -= cancel; + } + } + + async Task InvokeAsync(LuaFunction handler, int id) + { + if (options.Game) + await state.CallAsync(handler, [id, 0.25], token); + else + { + var job = new LuaTable + { + ["id"] = id, + ["unit_price"] = 12.5, + ["quantity"] = id % 3 + 1, + }; + var results = await state.CallAsync(handler, [job], token); + Console.WriteLine($"[service] job {id} returned {string.Join(", ", results)}"); + } + Interlocked.Increment(ref updates); + } + + async Task HeartbeatAsync() + { + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (await timer.WaitForNextTickAsync(token)) + Console.WriteLine( + $"[app] frame={Volatile.Read(ref frames)} updates={Volatile.Read(ref updates)} {(server.Debugger.CurrentStop == null ? "running" : "script paused")}" + ); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + } +} diff --git a/sandbox/DebuggerHost/UserScriptStore.cs b/sandbox/DebuggerHost/UserScriptStore.cs new file mode 100644 index 00000000..f46965fc --- /dev/null +++ b/sandbox/DebuggerHost/UserScriptStore.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using Lua; +using Lua.Debugging; + +sealed class UserScriptStore(string directory) : ILuaModuleLoader +{ + const string Prefix = "=user/"; + readonly ConcurrentDictionary contents = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal + ); + + public string? ResolvePath(string name) => + name.StartsWith(Prefix, StringComparison.Ordinal) + ? Path.GetFullPath(Path.Combine(directory, name[Prefix.Length..])) + : null; + + public ValueTask ReadSourceAsync(LuaDebugSource source, CancellationToken _) => + new(source.Path != null && contents.TryGetValue(source.Path, out var text) ? text : null); + + public bool Exists(string moduleName) => FindModule(moduleName) != null; + + public ValueTask LoadAsync( + string moduleName, + CancellationToken cancellationToken = default + ) => + ReadAsync( + FindModule(moduleName) + ?? throw new FileNotFoundException("Module not found: " + moduleName), + cancellationToken + ); + + public async ValueTask ReadAsync( + string relativePath, + CancellationToken cancellationToken + ) + { + var path = Path.GetFullPath(Path.Combine(directory, relativePath)); + var content = await File.ReadAllTextAsync(path, cancellationToken); + contents[path] = content; + return new LuaModule( + Prefix + Path.GetRelativePath(directory, path).Replace('\\', '/'), + content + ); + } + + string? FindModule(string name) + { + var relative = name.Replace('.', Path.DirectorySeparatorChar); + if (File.Exists(Path.Combine(directory, relative + ".lua"))) + return relative + ".lua"; + var init = Path.Combine(relative, "init.lua"); + return File.Exists(Path.Combine(directory, init)) ? init : null; + } +} diff --git a/sandbox/DebuggerHost/UserScripts/greeting.txt b/sandbox/DebuggerHost/UserScripts/greeting.txt new file mode 100644 index 00000000..9f42d0cd --- /dev/null +++ b/sandbox/DebuggerHost/UserScripts/greeting.txt @@ -0,0 +1 @@ +User scripts loaded. Set breakpoints in this directory; the application keeps running between script calls. diff --git a/sandbox/DebuggerHost/UserScripts/main.lua b/sandbox/DebuggerHost/UserScripts/main.lua new file mode 100644 index 00000000..a9ce1487 --- /dev/null +++ b/sandbox/DebuggerHost/UserScripts/main.lua @@ -0,0 +1,15 @@ +local movement = require("movement") +local greeting = host.read_text("greeting.txt") +local actor = { name = "player", position = 0, speed = 1.5 } +local updates = 0 + +host.log(greeting) + +return function(frame, dt) + updates = updates + 1 + local position = movement.advance(actor, dt) + if updates % 4 == 0 then + host.log(string.format("frame %d: %s at %.2f", frame, actor.name, position)) + end + return position +end diff --git a/sandbox/DebuggerHost/UserScripts/movement.lua b/sandbox/DebuggerHost/UserScripts/movement.lua new file mode 100644 index 00000000..ec5c58cb --- /dev/null +++ b/sandbox/DebuggerHost/UserScripts/movement.lua @@ -0,0 +1,13 @@ +local movement = {} + +local function distance(speed, dt) + return speed * dt +end + +function movement.advance(actor, dt) + local step = distance(actor.speed, dt) + actor.position = actor.position + step + return actor.position +end + +return movement diff --git a/sandbox/DebuggerHost/UserScripts/pricing.lua b/sandbox/DebuggerHost/UserScripts/pricing.lua new file mode 100644 index 00000000..9a126cfb --- /dev/null +++ b/sandbox/DebuggerHost/UserScripts/pricing.lua @@ -0,0 +1,9 @@ +local pricing = {} + +function pricing.total(unit_price, quantity) + local subtotal = unit_price * quantity + local tax = subtotal * 0.1 + return subtotal + tax +end + +return pricing diff --git a/sandbox/DebuggerHost/UserScripts/worker.lua b/sandbox/DebuggerHost/UserScripts/worker.lua new file mode 100644 index 00000000..3443e186 --- /dev/null +++ b/sandbox/DebuggerHost/UserScripts/worker.lua @@ -0,0 +1,8 @@ +local pricing = require("pricing") + +return function(job) + local total = pricing.total(job.unit_price, job.quantity) + local message = host.read_text("greeting.txt") + host.log(string.format("job %d: %s", job.id, message)) + return total +end diff --git a/src/Lua.Debugger.Dap/DapConnection.cs b/src/Lua.Debugger.Dap/DapConnection.cs new file mode 100644 index 00000000..08e7e5a9 --- /dev/null +++ b/src/Lua.Debugger.Dap/DapConnection.cs @@ -0,0 +1,115 @@ +using System.Buffers; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Lua.Debugger.Dap; + +sealed class DapConnection(Stream input, Stream output) +{ + readonly byte[] buffer = new byte[8192]; + readonly SemaphoreSlim writeGate = new(1, 1); + int position; + int end; + int sequence; + + public async Task ReadAsync(CancellationToken token) + { + var header = new ArrayBufferWriter(); + var terminator = 0; + while (terminator != 4) + { + if (position == end) + { + end = await input.ReadAsync(buffer.AsMemory(), token).ConfigureAwait(false); + position = 0; + if (end == 0) + { + if (header.WrittenCount != 0) + throw new EndOfStreamException("Incomplete DAP header."); + return null; + } + } + var b = buffer[position++]; + header.GetSpan(1)[0] = b; + header.Advance(1); + terminator = + b == (terminator % 2 == 0 ? '\r' : '\n') ? terminator + 1 + : b == '\r' ? 1 + : 0; + if (header.WrittenCount > 8192) + throw new InvalidDataException("DAP header is too large."); + } + var length = -1; + foreach ( + var line in Encoding + .ASCII.GetString(header.WrittenSpan) + .Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries) + ) + { + var colon = line.IndexOf(':'); + if ( + colon < 0 + || !line[..colon].Equals("Content-Length", StringComparison.OrdinalIgnoreCase) + ) + continue; + if ( + length >= 0 + || !int.TryParse( + line[(colon + 1)..].Trim(), + NumberStyles.None, + CultureInfo.InvariantCulture, + out length + ) + || length <= 0 + || length > 16 * 1024 * 1024 + ) + throw new InvalidDataException("Invalid DAP Content-Length."); + } + if (length < 0) + throw new InvalidDataException("Missing DAP Content-Length."); + var body = new byte[length]; + var copied = Math.Min(length, end - position); + buffer.AsSpan(position, copied).CopyTo(body); + position += copied; + while (copied < length) + { + var read = await input.ReadAsync(body.AsMemory(copied), token).ConfigureAwait(false); + if (read == 0) + throw new EndOfStreamException("Incomplete DAP message."); + copied += read; + } + try + { + return JsonNode.Parse(body) as JsonObject + ?? throw new InvalidDataException("Expected a DAP object."); + } + catch (JsonException exception) + { + throw new InvalidDataException("Invalid DAP JSON.", exception); + } + } + + public async Task WriteAsync(JsonObject message, CancellationToken token) + { + await writeGate.WaitAsync(token).ConfigureAwait(false); + try + { + message["seq"] = ++sequence; + var bytes = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(bytes)) + message.WriteTo(writer); + var header = Encoding.ASCII.GetBytes( + $"Content-Length: {bytes.WrittenCount.ToString(CultureInfo.InvariantCulture)}\r\n\r\n" + ); + await output.WriteAsync(header.AsMemory(), token).ConfigureAwait(false); + await output.WriteAsync(bytes.WrittenMemory, token).ConfigureAwait(false); + await output.FlushAsync(token).ConfigureAwait(false); + } + finally + { + writeGate.Release(); + } + } +} diff --git a/src/Lua.Debugger.Dap/Lua.Debugger.Dap.csproj b/src/Lua.Debugger.Dap/Lua.Debugger.Dap.csproj new file mode 100644 index 00000000..a5c4b41a --- /dev/null +++ b/src/Lua.Debugger.Dap/Lua.Debugger.Dap.csproj @@ -0,0 +1,23 @@ + + + net8.0;netstandard2.1 + 13 + enable + enable + LuaCSharp.Debugger.Dap + Embedded C# Debug Adapter Protocol server for Lua-CSharp. + + + + + + + + + + + diff --git a/src/Lua.Debugger.Dap/LuaDapServer.cs b/src/Lua.Debugger.Dap/LuaDapServer.cs new file mode 100644 index 00000000..b8fdf18e --- /dev/null +++ b/src/Lua.Debugger.Dap/LuaDapServer.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Sockets; +using Lua.Debugging; + +namespace Lua.Debugger.Dap; + +/// A C# DAP listener for an existing Lua host. Serves one client at a time and supports reconnecting. +public sealed class LuaDapServer : IDisposable +{ + readonly TcpListener listener; + readonly CancellationTokenSource shutdown = new(); + readonly TaskCompletionSource configured = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + TcpClient? client; + LuaDapSession? session; + int started; + int disposed; + + public LuaDapServer(LuaState state, int port = 4711, IPAddress? bindAddress = null) + : this(state, null, port, bindAddress) { } + + public LuaDapServer( + LuaState state, + LuaDebuggerOptions? debuggerOptions, + int port = 4711, + IPAddress? bindAddress = null + ) + { + listener = new(bindAddress ?? IPAddress.Loopback, port); + Debugger = new LuaDebugger(state, debuggerOptions); + } + + public LuaDebugger Debugger { get; } + public IPEndPoint LocalEndpoint => (IPEndPoint)listener.LocalEndpoint; + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref started, 1) != 0) + throw new InvalidOperationException("The DAP server has already started."); + using var lifecycle = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + shutdown.Token + ); + using var registration = lifecycle.Token.Register(listener.Stop); + try + { + lifecycle.Token.ThrowIfCancellationRequested(); + Debugger.Attach(); + listener.Start(); + while (!lifecycle.IsCancellationRequested) + { + using var connected = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + client = connected; + connected.NoDelay = true; + using var disconnect = lifecycle.Token.Register(connected.Dispose); + var stream = connected.GetStream(); + var current = new LuaDapSession(Debugger, stream, stream); + current.Configured += OnConfigured; + Volatile.Write(ref session, current); + try + { + await current.RunAsync(lifecycle.Token).ConfigureAwait(false); + } + catch (IOException) { } + catch (SocketException) { } + finally + { + Volatile.Write(ref session, null); + current.Configured -= OnConfigured; + client = null; + } + } + } + catch (Exception exception) + when (lifecycle.IsCancellationRequested + && exception + is OperationCanceledException + or SocketException + or ObjectDisposedException + ) { } + finally + { + listener.Stop(); + Debugger.Detach(); + configured.TrySetCanceled(); + } + } + + void OnConfigured() => configured.TrySetResult(true); + + /// Waits for the first client to finish configuring breakpoints before the host starts its script. + public async Task WaitForConfigurationAsync(CancellationToken cancellationToken = default) + { + var cancelled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + using var registration = cancellationToken.Register(() => + cancelled.TrySetCanceled(cancellationToken) + ); + await await Task.WhenAny(configured.Task, cancelled.Task).ConfigureAwait(false); + } + + /// Notifies the client that the host's script completed; the host retains control of process lifetime. + public Task NotifyTerminatedAsync(CancellationToken cancellationToken = default) => + Volatile.Read(ref session)?.NotifyTerminatedAsync(cancellationToken) ?? Task.CompletedTask; + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + return; + configured.TrySetCanceled(); + try + { + shutdown.Cancel(); + } + finally + { + listener.Stop(); + client?.Dispose(); + Debugger.Dispose(); + } + } +} diff --git a/src/Lua.Debugger.Dap/LuaDapSession.Requests.cs b/src/Lua.Debugger.Dap/LuaDapSession.Requests.cs new file mode 100644 index 00000000..54e5cba6 --- /dev/null +++ b/src/Lua.Debugger.Dap/LuaDapSession.Requests.cs @@ -0,0 +1,460 @@ +using System.Text.Json.Nodes; +using Lua.Debugging; + +namespace Lua.Debugger.Dap; + +public sealed partial class LuaDapSession +{ + async Task DispatchAsync(JsonObject request, int expectedStop, CancellationToken token) + { + var command = Text(request, "command"); + var args = request["arguments"] as JsonObject ?? new JsonObject(); + if (!initialized && command != "initialize") + throw new InvalidOperationException("Initialize the debug adapter first."); + switch (command) + { + case "initialize": + if (initialized) + throw new InvalidOperationException("The adapter is already initialized."); + linesStartAtOne = Boolean(args, "linesStartAt1", true); + columnsStartAtOne = Boolean(args, "columnsStartAt1", true); + uriPaths = Text(args, "pathFormat") == "uri"; + initialized = true; + await RespondAsync( + request, + new JsonObject + { + ["supportsConfigurationDoneRequest"] = true, + ["supportsEvaluateForHovers"] = true, + ["supportsSetVariable"] = true, + ["supportsLoadedSourcesRequest"] = true, + ["exceptionBreakpointFilters"] = new JsonArray(), + }, + token + ) + .ConfigureAwait(false); + await connection + .WriteAsync(Event("initialized", null), token) + .ConfigureAwait(false); + return; + case "attach": + if (attached) + throw new InvalidOperationException("A client is already attached."); + debugger.Attach(); + attached = true; + stopOnEntry = Boolean(args, "stopOnEntry", false); + break; + case "launch": + throw new NotSupportedException( + "This embedded adapter supports attach. Start the Lua host application separately." + ); + case "configurationDone": + if (!attached) + throw new InvalidOperationException("Attach before completing configuration."); + await RespondAsync(request, new JsonObject(), token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + lock (referenceGate) + { + configured = true; + if (debugger.CurrentStop is { } existingStop) + OnStopped(existingStop); + else if (stopOnEntry) + { + entryPending = true; + debugger.Pause(); + } + } + Configured?.Invoke(); + return; + case "setBreakpoints": + var source = + args["source"]?.AsObject() + ?? throw new ArgumentException("A source is required."); + var path = Text(source, "path"); + if (path.Length != 0) + path = FromClientPath(path); + else + { + var known = debugger.GetSource(Integer(source, "sourceReference")); + path = known.Path ?? "=" + known.Name; + } + var specs = args["breakpoints"] as JsonArray ?? new JsonArray(); + var lines = new List(); + foreach (var spec in specs) + { + var item = spec!.AsObject(); + if ( + item["condition"] != null + || item["hitCondition"] != null + || item["logMessage"] != null + ) + throw new NotSupportedException( + "Only unconditional source breakpoints are supported." + ); + lines.Add(Integer(item, "line") + (linesStartAtOne ? 0 : 1)); + } + var points = debugger.SetBreakpoints(path, lines); + await RespondAsync( + request, + new JsonObject + { + ["breakpoints"] = new JsonArray( + points.Select(ToBreakpoint).Cast().ToArray() + ), + }, + token + ) + .ConfigureAwait(false); + return; + case "setExceptionBreakpoints": + if (args["filters"] is JsonArray filters && filters.Count != 0) + throw new NotSupportedException("Exception breakpoints are not supported."); + break; + case "threads": + await RespondAsync( + request, + new JsonObject + { + ["threads"] = new JsonArray( + new JsonObject { ["id"] = 1, ["name"] = "Lua" } + ), + }, + token + ) + .ConfigureAwait(false); + return; + case "pause": + CheckThread(args); + await RespondAsync(request, new JsonObject(), token).ConfigureAwait(false); + debugger.Pause(); + return; + case "continue": + case "next": + case "stepIn": + case "stepOut": + CheckThread(args); + var current = CurrentStop(expectedStop); + var mode = command switch + { + "next" => LuaDebugStep.Over, + "stepIn" => LuaDebugStep.Into, + "stepOut" => LuaDebugStep.Out, + _ => LuaDebugStep.Continue, + }; + await RespondAsync( + request, + command == "continue" + ? new JsonObject { ["allThreadsContinued"] = true } + : new JsonObject(), + token + ) + .ConfigureAwait(false); + lock (referenceGate) + { + if (stopId != current) + return; + stopId = 0; + references.Clear(); + } + try + { + debugger.TryContinue(current, mode); + } + catch (AggregateException exception) + { + events.Writer.TryWrite( + Event( + "output", + new JsonObject + { + ["category"] = "stderr", + ["output"] = exception + Environment.NewLine, + } + ) + ); + } + return; + case "stackTrace": + CheckThread(args); + var frames = await debugger + .GetStackTraceAsync(CurrentStop(expectedStop), token) + .ConfigureAwait(false); + var start = Integer(args, "startFrame"); + var levels = Integer(args, "levels"); + if (start < 0 || levels < 0) + throw new ArgumentOutOfRangeException(nameof(args)); + var selected = frames.Skip(start).Take(levels == 0 ? int.MaxValue : levels); + await RespondAsync( + request, + new JsonObject + { + ["totalFrames"] = frames.Length, + ["stackFrames"] = new JsonArray( + selected + .Select(frame => + (JsonNode?) + new JsonObject + { + ["id"] = frame.Id, + ["name"] = frame.Name, + ["source"] = + frame.Source == null + ? null + : ToSource(frame.Source), + ["line"] = + Math.Max(frame.Line, 1) + - (linesStartAtOne ? 0 : 1), + ["column"] = columnsStartAtOne ? 1 : 0, + } + ) + .ToArray() + ), + }, + token + ) + .ConfigureAwait(false); + return; + case "scopes": + var frameId = Integer(args, "frameId"); + var allFrames = await debugger + .GetStackTraceAsync(CurrentStop(expectedStop), token) + .ConfigureAwait(false); + if (!allFrames.Any(frame => frame.Id == frameId)) + throw new ArgumentException("Unknown frame."); + var scopes = new JsonArray(); + foreach ( + var scope in new[] + { + LuaDebugScope.Locals, + LuaDebugScope.UpValues, + LuaDebugScope.Globals, + } + ) + scopes.Add( + new JsonObject + { + ["name"] = scope.ToString(), + ["variablesReference"] = AddReference( + new VariableReference(frameId, scope), + expectedStop + ), + ["expensive"] = scope == LuaDebugScope.Globals, + } + ); + await RespondAsync(request, new JsonObject { ["scopes"] = scopes }, token) + .ConfigureAwait(false); + return; + case "variables": + var reference = GetReference(Integer(args, "variablesReference")); + var offset = Integer(args, "start"); + var count = Integer(args, "count"); + if (offset < 0 || count < 0) + throw new ArgumentOutOfRangeException(nameof(args)); + LuaDebugVariable[] values; + if (reference.Scope is { } variableScope) + values = await debugger + .GetVariablesAsync( + CurrentStop(expectedStop), + reference.FrameId, + variableScope, + offset, + count == 0 ? int.MaxValue : count, + token + ) + .ConfigureAwait(false); + else + values = await debugger + .GetChildrenAsync( + CurrentStop(expectedStop), + reference.Value, + offset, + count == 0 ? int.MaxValue : count, + token + ) + .ConfigureAwait(false); + await RespondAsync( + request, + new JsonObject + { + ["variables"] = new JsonArray( + values + .Select(value => + (JsonNode?)ToVariable( + value, + reference.Scope == null, + expectedStop + ) + ) + .ToArray() + ), + }, + token + ) + .ConfigureAwait(false); + return; + case "evaluate": + var evaluationFrame = Integer(args, "frameId"); + if (evaluationFrame == 0) + evaluationFrame = ( + await debugger + .GetStackTraceAsync(CurrentStop(expectedStop), token) + .ConfigureAwait(false) + )[0].Id; + var evaluated = await debugger + .EvaluateAsync( + CurrentStop(expectedStop), + evaluationFrame, + Text(args, "expression"), + token + ) + .ConfigureAwait(false); + await RespondAsync( + request, + new JsonObject + { + ["result"] = evaluated.DisplayValue, + ["type"] = evaluated.Value.TypeToString(), + ["variablesReference"] = ValueReference(evaluated.Value, expectedStop), + }, + token + ) + .ConfigureAwait(false); + return; + case "setVariable": + var target = GetReference(Integer(args, "variablesReference")); + if (target.Scope is not { } targetScope) + throw new NotSupportedException("This variable is read-only."); + var changed = await debugger + .SetVariableAsync( + CurrentStop(expectedStop), + target.FrameId, + targetScope, + Text(args, "name"), + Text(args, "value"), + token + ) + .ConfigureAwait(false); + await RespondAsync( + request, + new JsonObject + { + ["value"] = changed.DisplayValue, + ["type"] = changed.Value.TypeToString(), + ["variablesReference"] = ValueReference(changed.Value, expectedStop), + }, + token + ) + .ConfigureAwait(false); + return; + case "loadedSources": + await RespondAsync( + request, + new JsonObject + { + ["sources"] = new JsonArray( + debugger + .GetSources() + .Select(source => (JsonNode?)ToSource(source)) + .ToArray() + ), + }, + token + ) + .ConfigureAwait(false); + return; + case "source": + var content = await debugger + .ReadSourceAsync(Integer(args, "sourceReference"), token) + .ConfigureAwait(false); + await RespondAsync( + request, + new JsonObject { ["content"] = content, ["mimeType"] = "text/x-lua" }, + token + ) + .ConfigureAwait(false); + return; + default: + throw new NotSupportedException($"Unsupported DAP request: {command}"); + } + await RespondAsync(request, new JsonObject(), token).ConfigureAwait(false); + } + + JsonObject ToBreakpoint(LuaBreakpoint point) => + new() + { + ["id"] = point.Id, + ["verified"] = point.Verified, + ["line"] = point.Line - (linesStartAtOne ? 0 : 1), + ["message"] = point.Message, + }; + + JsonObject ToSource(LuaDebugSource source) => + new() + { + ["name"] = source.Name, + ["path"] = + source.Path == null ? null + : uriPaths ? new Uri(source.Path).AbsoluteUri + : source.Path, + ["sourceReference"] = source.Id, + }; + + JsonObject ToVariable(LuaDebugVariable value, bool readOnly, int expectedStop) + { + var result = new JsonObject + { + ["name"] = value.Name, + ["value"] = value.DisplayValue, + ["type"] = value.Value.TypeToString(), + ["variablesReference"] = ValueReference(value.Value, expectedStop), + }; + if (readOnly || value.IsReadOnly || value.Name.StartsWith("...", StringComparison.Ordinal)) + result["presentationHint"] = new JsonObject + { + ["attributes"] = new JsonArray("readOnly"), + }; + return result; + } + + int ValueReference(LuaValue value, int expectedStop) => + value.Type is LuaValueType.Table or LuaValueType.UserData + ? AddReference(new VariableReference(value), expectedStop) + : 0; + + static void CheckThread(JsonObject args) + { + if (Integer(args, "threadId", 1) != 1) + throw new ArgumentException("Unknown Lua thread."); + } + + Task RespondAsync(JsonObject request, JsonNode? body, CancellationToken token) => + connection.WriteAsync( + new JsonObject + { + ["type"] = "response", + ["request_seq"] = Integer(request, "seq"), + ["command"] = Text(request, "command"), + ["success"] = true, + ["body"] = body, + }, + token + ); + + static JsonObject Event(string name, JsonNode? body) => + new() + { + ["type"] = "event", + ["event"] = name, + ["body"] = body, + }; + + static string Text(JsonObject value, string name) => value[name]?.GetValue() ?? ""; + + static int Integer(JsonObject value, string name, int fallback = 0) => + value[name]?.GetValue() ?? fallback; + + static bool Boolean(JsonObject value, string name, bool fallback) => + value[name]?.GetValue() ?? fallback; + + string FromClientPath(string path) => uriPaths ? new Uri(path).LocalPath : path; +} diff --git a/src/Lua.Debugger.Dap/LuaDapSession.cs b/src/Lua.Debugger.Dap/LuaDapSession.cs new file mode 100644 index 00000000..6a1b7069 --- /dev/null +++ b/src/Lua.Debugger.Dap/LuaDapSession.cs @@ -0,0 +1,333 @@ +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Lua.Debugging; + +namespace Lua.Debugger.Dap; + +/// A single attach session over caller-owned streams. The protocol and VM pumps run independently. +public sealed partial class LuaDapSession +{ + readonly LuaDebugger debugger; + readonly DapConnection connection; + readonly Channel events = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true } + ); + readonly Dictionary references = new(); + readonly object referenceGate = new(); + bool initialized; + bool attached; + bool configured; + bool stopOnEntry; + bool entryPending; + bool linesStartAtOne = true; + bool columnsStartAtOne = true; + bool uriPaths; + int stopId; + int nextReference; + int running; + + public LuaDapSession(LuaDebugger debugger, Stream input, Stream output) + { + this.debugger = debugger; + connection = new(input, output); + } + + public event Action? Configured; + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref running, 1) != 0) + throw new InvalidOperationException("The DAP session has already started."); + using var lifecycle = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var requests = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = true } + ); + var inspections = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = true } + ); + debugger.Stopped += OnStopped; + debugger.StopEnded += OnStopEnded; + debugger.BreakpointChanged += OnBreakpointChanged; + var reader = ReadRequestsAsync(requests.Writer, lifecycle); + var dispatcher = DispatchRequestsAsync( + requests.Reader, + inspections.Writer, + lifecycle.Token + ); + var inspector = InspectRequestsAsync(inspections.Reader, lifecycle.Token); + var sender = SendEventsAsync(lifecycle.Token); + try + { + await await Task.WhenAny(reader, dispatcher, inspector, sender).ConfigureAwait(false); + } + catch (OperationCanceledException) when (lifecycle.IsCancellationRequested) { } + finally + { + try + { + StopSession(lifecycle); + } + finally + { + try + { + await Task.WhenAll(reader, dispatcher, inspector, sender).ConfigureAwait(false); + } + catch (OperationCanceledException) when (lifecycle.IsCancellationRequested) { } + finally + { + ResetSession(); + } + } + } + } + + void StopSession(CancellationTokenSource lifecycle) + { + debugger.Stopped -= OnStopped; + debugger.StopEnded -= OnStopEnded; + debugger.BreakpointChanged -= OnBreakpointChanged; + events.Writer.TryComplete(); + try + { + lifecycle.Cancel(); + } + finally + { + ResetSession(); + } + } + + void ResetSession() + { + lock (referenceGate) + { + configured = false; + stopId = 0; + references.Clear(); + } + debugger.Reset(); + } + + public Task NotifyTerminatedAsync(CancellationToken cancellationToken = default) + { + debugger.Reset(); + return connection.WriteAsync(Event("terminated", new JsonObject()), cancellationToken); + } + + async Task ReadRequestsAsync( + ChannelWriter requests, + CancellationTokenSource lifecycle + ) + { + try + { + while (await connection.ReadAsync(lifecycle.Token).ConfigureAwait(false) is { } request) + { + if ( + request["type"] is not JsonValue type + || !type.TryGetValue(out var messageType) + ) + throw new InvalidDataException("Missing DAP message type."); + if (messageType != "request") + continue; + if ( + request["seq"] is not JsonValue sequence + || !sequence.TryGetValue(out _) + || request["command"] is not JsonValue command + || !command.TryGetValue(out _) + ) + throw new InvalidDataException("Invalid DAP request."); + if (Text(request, "command") == "disconnect") + { + await RespondAsync(request, new JsonObject(), lifecycle.Token) + .ConfigureAwait(false); + return; + } + await requests + .WriteAsync( + new PendingRequest(request, Volatile.Read(ref stopId)), + lifecycle.Token + ) + .ConfigureAwait(false); + } + } + finally + { + requests.TryComplete(); + lifecycle.Cancel(); + } + } + + async Task DispatchRequestsAsync( + ChannelReader requests, + ChannelWriter inspections, + CancellationToken token + ) + { + try + { + while (await requests.WaitToReadAsync(token).ConfigureAwait(false)) + while (requests.TryRead(out var request)) + { + if ( + Text(request.Message, "command") + is "stackTrace" + or "scopes" + or "variables" + or "evaluate" + or "setVariable" + or "source" + ) + await inspections.WriteAsync(request, token).ConfigureAwait(false); + else + await HandleRequestAsync(request, token).ConfigureAwait(false); + } + } + finally + { + inspections.TryComplete(); + } + } + + async Task InspectRequestsAsync(ChannelReader requests, CancellationToken token) + { + while (await requests.WaitToReadAsync(token).ConfigureAwait(false)) + while (requests.TryRead(out var request)) + await HandleRequestAsync(request, token).ConfigureAwait(false); + } + + async Task HandleRequestAsync(PendingRequest request, CancellationToken token) + { + try + { + await DispatchAsync(request.Message, request.StopId, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + await connection + .WriteAsync( + new JsonObject + { + ["type"] = "response", + ["request_seq"] = Integer(request.Message, "seq"), + ["command"] = Text(request.Message, "command"), + ["success"] = false, + ["message"] = exception.Message, + }, + token + ) + .ConfigureAwait(false); + } + } + + async Task SendEventsAsync(CancellationToken token) + { + while (await events.Reader.WaitToReadAsync(token).ConfigureAwait(false)) + while (events.Reader.TryRead(out var message)) + await connection.WriteAsync(message, token).ConfigureAwait(false); + } + + void OnStopped(LuaDebugStop stop) + { + lock (referenceGate) + { + if (!configured || stopId == stop.Id) + return; + stopId = stop.Id; + references.Clear(); + var reason = entryPending ? "entry" : stop.Reason; + entryPending = false; + var body = new JsonObject + { + ["reason"] = reason, + ["threadId"] = 1, + ["allThreadsStopped"] = true, + }; + if (stop.BreakpointId is { } id) + body["hitBreakpointIds"] = new JsonArray(id); + events.Writer.TryWrite(Event("stopped", body)); + } + } + + void OnBreakpointChanged(LuaBreakpoint breakpoint) => + events.Writer.TryWrite( + Event( + "breakpoint", + new JsonObject { ["reason"] = "changed", ["breakpoint"] = ToBreakpoint(breakpoint) } + ) + ); + + void OnStopEnded(int id) + { + lock (referenceGate) + if (stopId == id) + { + stopId = 0; + references.Clear(); + events.Writer.TryWrite( + Event( + "continued", + new JsonObject { ["threadId"] = 1, ["allThreadsContinued"] = true } + ) + ); + } + } + + int AddReference(VariableReference value, int expectedStop) + { + lock (referenceGate) + { + CurrentStop(expectedStop); + var id = ++nextReference; + references.Add(id, value); + return id; + } + } + + VariableReference GetReference(int id) + { + lock (referenceGate) + return references.TryGetValue(id, out var reference) + ? reference + : throw new ArgumentException("The variable reference is no longer valid."); + } + + int CurrentStop(int expected) + { + lock (referenceGate) + return stopId != 0 && expected == stopId + ? stopId + : throw new InvalidOperationException( + "Lua execution is not stopped at this location." + ); + } + + readonly struct PendingRequest(JsonObject message, int stopId) + { + public readonly JsonObject Message = message; + public readonly int StopId = stopId; + } + + sealed class VariableReference + { + public readonly int FrameId; + public readonly LuaDebugScope? Scope; + public readonly LuaValue Value; + + public VariableReference(int frame, LuaDebugScope scope) + { + FrameId = frame; + Scope = scope; + } + + public VariableReference(LuaValue value) + { + Value = value; + } + } +} diff --git a/src/Lua.Debugger/InternalVisibleTo.cs b/src/Lua.Debugger/InternalVisibleTo.cs new file mode 100644 index 00000000..11cc532d --- /dev/null +++ b/src/Lua.Debugger/InternalVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Lua.Debugger.Dap")] diff --git a/src/Lua.Debugger/Lua.Debugger.csproj b/src/Lua.Debugger/Lua.Debugger.csproj new file mode 100644 index 00000000..2b925d4c --- /dev/null +++ b/src/Lua.Debugger/Lua.Debugger.csproj @@ -0,0 +1,18 @@ + + + net10.0;net8.0;net6.0;netstandard2.1 + 13 + enable + enable + LuaCSharp.Debugger + Protocol-independent debugging for application-owned Lua-CSharp states. + + + + + + + + + + diff --git a/src/Lua.Debugger/LuaDebugInfo.cs b/src/Lua.Debugger/LuaDebugInfo.cs new file mode 100644 index 00000000..5847af6a --- /dev/null +++ b/src/Lua.Debugger/LuaDebugInfo.cs @@ -0,0 +1,35 @@ +namespace Lua.Debugging; + +public enum LuaDebugStep +{ + Continue, + Into, + Over, + Out, +} + +public enum LuaDebugScope +{ + Locals, + UpValues, + Globals, +} + +public sealed record LuaDebugSource(int Id, string Name, string? Path, string? Content); + +public sealed record LuaBreakpoint(int Id, int Line, bool Verified, string? Message = null); + +public sealed record LuaDebugStop( + int Id, + string Reason, + LuaDebugSource Source, + int Line, + int? BreakpointId +); + +public sealed record LuaDebugStackFrame(int Id, string Name, LuaDebugSource? Source, int Line); + +public sealed record LuaDebugVariable(string Name, LuaValue Value, string DisplayValue) +{ + public bool IsReadOnly { get; init; } +} diff --git a/src/Lua.Debugger/LuaDebugger.Code.cs b/src/Lua.Debugger/LuaDebugger.Code.cs new file mode 100644 index 00000000..baf5a26d --- /dev/null +++ b/src/Lua.Debugger/LuaDebugger.Code.cs @@ -0,0 +1,65 @@ +using System.Runtime.CompilerServices; +using Lua.Runtime; + +namespace Lua.Debugging; + +public sealed partial class LuaDebugger +{ + readonly ConditionalWeakTable executionCodes = new(); + readonly List> codeReferences = new(); + + LuaDebugCode ILuaDebugSession.GetExecutionCode(LuaClosure closure) + { + if (executionCodes.TryGetValue(closure.Proto, out var existing)) + return existing; + lock (gate) + { + if (!registered.TryGetValue(closure.Proto, out var source)) + { + RegisterSource(closure); + source = registered.GetValue( + closure.Proto, + _ => throw new InvalidOperationException() + ); + } + var code = new ExecutionCode(identity, closure.Proto, source); + executionCodes.Add(closure.Proto, code); + if (codeReferences.Count == codeReferences.Capacity) + codeReferences.RemoveAll(static reference => !reference.TryGetTarget(out _)); + codeReferences.Add(new WeakReference(code)); + UpdateControls(code, IsAttached); + return code; + } + } + + void UpdateExecutionCodes() + { + var attached = IsAttached; + for (var i = codeReferences.Count - 1; i >= 0; i--) + { + if (codeReferences[i].TryGetTarget(out var code)) + UpdateControls(code, attached); + else + codeReferences.RemoveAt(i); + } + } + + void UpdateControls(ExecutionCode code, bool attached) + { + var enabled = pauseRequested || step != null; + if (!enabled && code.Source.Points.Count != 0) + foreach (var line in code.Prototype.LineInfo) + if (code.Source.Points.ContainsKey(line)) + { + enabled = true; + break; + } + code.Enabled = attached && enabled; + } + + sealed class ExecutionCode(object identity, Prototype prototype, SourceEntry source) + : LuaDebugCode(identity, prototype) + { + public readonly SourceEntry Source = source; + } +} diff --git a/src/Lua.Debugger/LuaDebugger.Inspection.cs b/src/Lua.Debugger/LuaDebugger.Inspection.cs new file mode 100644 index 00000000..25ab0cdb --- /dev/null +++ b/src/Lua.Debugger/LuaDebugger.Inspection.cs @@ -0,0 +1,401 @@ +using Lua.Runtime; + +namespace Lua.Debugging; + +public sealed partial class LuaDebugger +{ + public Task GetStackTraceAsync( + int stopId, + CancellationToken cancellationToken = default + ) => + InspectAsync( + stopId, + (context, _) => + { + var frames = context.State.GetCallStackFrames(); + var result = new LuaDebugStackFrame[frames.Length]; + for (var i = frames.Length - 1; i >= 0; i--) + { + var frame = frames[i]; + var pc = + i == frames.Length - 1 ? context.Pc : frames[i + 1].CallerInstructionIndex; + LuaDebugSource? source = null; + var line = 0; + var name = frame.Function.Name; + if (frame.Function is LuaClosure closure) + { + lock (gate) + { + RegisterPrototype(closure.Proto); + source = registered + .GetValue(closure.Proto, _ => throw new InvalidOperationException()) + .Source; + } + if ((uint)pc < (uint)closure.Proto.LineInfo.Length) + line = closure.Proto.LineInfo[pc]; + name = + closure.Proto.LineDefined == 0 + ? "main chunk" + : $"function at {source.Name}:{closure.Proto.LineDefined}"; + } + result[frames.Length - 1 - i] = new LuaDebugStackFrame( + i + 1, + name, + source, + line + ); + } + return new ValueTask(result); + }, + cancellationToken + ); + + public Task GetVariablesAsync( + int stopId, + int frameId, + LuaDebugScope scope, + CancellationToken cancellationToken = default + ) => + InspectAsync( + stopId, + (context, _) => new ValueTask(ReadScope(context, frameId, scope)), + cancellationToken + ); + + public Task GetVariablesAsync( + int stopId, + int frameId, + LuaDebugScope scope, + int start, + int count, + CancellationToken cancellationToken = default + ) => + InspectAsync( + stopId, + (context, _) => + new ValueTask( + scope == LuaDebugScope.Globals + ? ReadChildren(GetEnvironment(context, frameId), start, count) + : ReadScope(context, frameId, scope).Skip(start).Take(count).ToArray() + ), + cancellationToken + ); + + /// Reads raw table entries or userdata uservalues without invoking metamethods. + public Task GetChildrenAsync( + int stopId, + LuaValue value, + int start = 0, + int count = 100, + CancellationToken cancellationToken = default + ) => + InspectAsync( + stopId, + (_, _) => new ValueTask(ReadChildren(value, start, count)), + cancellationToken + ); + + /// Evaluates a Lua expression against copied locals/upvalues and the selected frame's environment. + public Task EvaluateAsync( + int stopId, + int frameId, + string expression, + CancellationToken cancellationToken = default + ) => + InspectAsync( + stopId, + async (context, token) => + Variable(expression, await EvaluateCoreAsync(context, frameId, expression, token)), + cancellationToken + ); + + /// Evaluates a new value and writes a named local, upvalue, or global while stopped. + public Task SetVariableAsync( + int stopId, + int frameId, + LuaDebugScope scope, + string name, + string expression, + CancellationToken cancellationToken = default + ) => + InspectAsync( + stopId, + async (context, token) => + { + var value = await EvaluateCoreAsync(context, frameId, expression, token); + WriteScope(context, frameId, scope, name, value); + return Variable(name, value); + }, + cancellationToken + ); + + public string FormatValue(LuaValue value) => + value.Type switch + { + LuaValueType.String => "\"" + + root.Platform.DecodeText(value.Read()) + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t") + .Replace("\0", "\\0") + + "\"", + LuaValueType.Function => "function " + value.Read().Name, + LuaValueType.Table => "table", + LuaValueType.Thread => "thread", + LuaValueType.UserData => "userdata", + LuaValueType.LightUserData => "light userdata", + _ => value.ToLuaString(root.Platform.NumberFormat), + }; + + LuaDebugVariable Variable(string name, LuaValue value) => + new(root.Platform.DecodeText(name), value, FormatValue(value)); + + string TableKeyName(LuaValue key) => + key.Type == LuaValueType.String ? key.Read() : "[" + FormatValue(key) + "]"; + + static int ParseUpValueIndex(string name) => + name.StartsWith("upvalue[", StringComparison.Ordinal) + && name.EndsWith(']') + && int.TryParse(name.AsSpan(8, name.Length - 9), out var index) + && index > 0 + ? index - 1 + : -1; + + static (CallStackFrame Frame, int Pc) GetFrame(PauseContext context, int id) + { + var frames = context.State.GetCallStackFrames(); + var index = id - 1; + if ((uint)index >= (uint)frames.Length) + throw new ArgumentOutOfRangeException(nameof(id)); + return ( + frames[index], + index == frames.Length - 1 ? context.Pc : frames[index + 1].CallerInstructionIndex + ); + } + + LuaDebugVariable[] ReadScope(PauseContext context, int frameId, LuaDebugScope scope) + { + var (frame, pc) = GetFrame(context, frameId); + if (scope == LuaDebugScope.Globals) + return ReadChildren(GetEnvironment(context, frameId), 0, int.MaxValue); + var result = new List(); + if (frame.Function is LuaClosure closure) + { + if (scope == LuaDebugScope.Locals) + { + var slot = 0; + foreach (var local in closure.Proto.LocalVariables) + { + if (local.StartPc <= pc && pc < local.EndPc) + result.Add( + Variable( + local.Name, + context.State.GetLocalReference(frame.Base + slot++) + ) + ); + } + for (var i = 0; i < frame.VariableArgumentCount; i++) + result.Add( + Variable( + $"...[{i + 1}]", + context.State.Stack[frame.Base - frame.VariableArgumentCount + i] + ) + ); + } + else + for (var i = 0; i < closure.UpValues.Length; i++) + { + var name = closure.Proto.UpValues[i].Name; + result.Add( + Variable( + string.IsNullOrEmpty(name) ? $"upvalue[{i + 1}]" : name, + closure.UpValues[i].GetValue() + ) + ); + } + } + else if (scope == LuaDebugScope.UpValues && frame.Function is CSharpClosure cs) + for (var i = 0; i < cs.UpValues.Length; i++) + result.Add(Variable($"upvalue[{i + 1}]", cs.UpValues[i])); + return result.ToArray(); + } + + LuaValue GetEnvironment(PauseContext context, int frameId) + { + var (frame, pc) = GetFrame(context, frameId); + if (frame.Function is LuaClosure closure) + { + var slot = 0; + var environmentSlot = -1; + foreach (var local in closure.Proto.LocalVariables) + if (local.StartPc <= pc && pc < local.EndPc) + { + if (local.Name == "_ENV") + environmentSlot = slot; + slot++; + } + if (environmentSlot >= 0) + return context.State.GetLocalReference(frame.Base + environmentSlot); + for (var i = 0; i < closure.UpValues.Length; i++) + if (closure.Proto.UpValues[i].Name == "_ENV") + return closure.UpValues[i].GetValue(); + } + return context.State.Environment; + } + + LuaDebugVariable[] ReadChildren(LuaValue value, int start, int count) + { + if (start < 0 || count < 0) + throw new ArgumentOutOfRangeException(nameof(start)); + var result = new List(); + if (value.TryRead(out var table)) + { + var index = 0; + foreach (var pair in table) + { + if (index++ < start) + continue; + if (result.Count == count) + break; + result.Add( + Variable(TableKeyName(pair.Key), pair.Value) with + { + IsReadOnly = pair.Key.Type != LuaValueType.String, + } + ); + } + } + else if (value.TryRead(out var data)) + { + var values = data.UserValues; + for (var i = start; i < values.Length && result.Count < count; i++) + result.Add(Variable($"uservalue[{i + 1}]", values[i])); + } + return result.ToArray(); + } + + async ValueTask EvaluateCoreAsync( + PauseContext context, + int frameId, + string expression, + CancellationToken cancellationToken + ) + { + var bindings = new Dictionary(); + foreach (var item in ReadScope(context, frameId, LuaDebugScope.UpValues)) + bindings[root.Platform.EncodeText(item.Name)] = item.Value; + foreach (var item in ReadScope(context, frameId, LuaDebugScope.Locals)) + bindings[root.Platform.EncodeText(item.Name)] = item.Value; + var globals = GetEnvironment(context, frameId); + var environment = new LuaTable + { + Metatable = new LuaTable + { + ["__index"] = new LuaFunction( + async (call, token) => + { + var key = call.GetArgument(1); + var value = + key.TryRead(out var name) + && bindings.TryGetValue(name, out var local) + ? local + : await call.State.GetTableAsync(globals, key, token); + return call.Return(value); + } + ), + }, + }; + var function = context.State.Load( + "return (" + expression + "\n)", + "=debugger", + environment + ); + var previousPc = context.State.LastPc; + var previousPrototype = context.State.LastCallerPrototype; + evaluating = true; + context.State.NonYieldableCallCount++; + try + { + var results = await context.State.CallAsync(function, [], cancellationToken); + return results.Length == 0 ? LuaValue.Nil : results[0]; + } + finally + { + context.State.NonYieldableCallCount--; + context.State.LastPc = previousPc; + context.State.LastCallerPrototype = previousPrototype; + evaluating = false; + } + } + + void WriteScope( + PauseContext context, + int frameId, + LuaDebugScope scope, + string name, + LuaValue value + ) + { + name = root.Platform.EncodeText(name); + var (frame, pc) = GetFrame(context, frameId); + if ( + scope == LuaDebugScope.Globals + && GetEnvironment(context, frameId).TryRead(out var globals) + ) + { + if (!globals.ContainsKey(name)) + foreach (var pair in globals) + if (pair.Key.Type != LuaValueType.String && TableKeyName(pair.Key) == name) + throw new ArgumentException( + "This global entry is read-only.", + nameof(name) + ); + globals[name] = value; + return; + } + if (scope == LuaDebugScope.Locals && frame.Function is LuaClosure closure) + { + var slot = 0; + var found = -1; + foreach (var local in closure.Proto.LocalVariables) + if (local.StartPc <= pc && pc < local.EndPc) + { + if (local.Name == name) + found = slot; + slot++; + } + if (found >= 0) + { + context.State.GetLocalReference(frame.Base + found) = value; + return; + } + } + else if (scope == LuaDebugScope.UpValues) + { + var index = ParseUpValueIndex(name); + if (frame.Function is LuaClosure lua) + { + if (index < 0) + for (var i = 0; i < lua.UpValues.Length; i++) + if (lua.Proto.UpValues[i].Name == name) + { + index = i; + break; + } + if ((uint)index < (uint)lua.UpValues.Length) + { + lua.UpValues[index].SetValue(value); + return; + } + } + else if (frame.Function is CSharpClosure cs && (uint)index < (uint)cs.UpValues.Length) + { + cs.UpValues[index] = value; + return; + } + } + throw new ArgumentException("The variable is not writable in this scope.", nameof(name)); + } +} diff --git a/src/Lua.Debugger/LuaDebugger.Sources.cs b/src/Lua.Debugger/LuaDebugger.Sources.cs new file mode 100644 index 00000000..59e1e5fd --- /dev/null +++ b/src/Lua.Debugger/LuaDebugger.Sources.cs @@ -0,0 +1,226 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Lua.Runtime; + +namespace Lua.Debugging; + +public sealed partial class LuaDebugger +{ + readonly Dictionary sources = new( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal + ); + readonly Dictionary sourcesById = new(); + readonly ConditionalWeakTable registered = new(); + readonly Dictionary> breakpoints; + + /// Replaces all breakpoints for one source; unresolved lines are reported as pending. + public LuaBreakpoint[] SetBreakpoints(string source, IReadOnlyList lines) + { + var key = SourceKey(source); + lock (gate) + { + sources.TryGetValue(key, out var entry); + var requests = new List(lines.Count); + foreach (var line in lines) + { + if (line <= 0) + throw new ArgumentOutOfRangeException(nameof(lines)); + var request = new RequestedBreakpoint( + line, + new LuaBreakpoint( + ++nextBreakpointId, + line, + false, + "Source has not been loaded." + ) + ); + Resolve(request, entry); + requests.Add(request); + } + if (requests.Count == 0) + breakpoints.Remove(key); + else + breakpoints[key] = requests; + if (entry != null) + { + entry.Points.Clear(); + foreach (var request in requests) + if (request.Resolved.Verified) + entry.Points[request.Resolved.Line] = request.Resolved; + } + UpdateExecutionCodes(); + return requests.Select(x => x.Resolved).ToArray(); + } + } + + public void ClearBreakpoints() + { + lock (gate) + { + ClearBreakpointsCore(); + UpdateExecutionCodes(); + } + } + + void ClearBreakpointsCore() + { + breakpoints.Clear(); + foreach (var entry in sources.Values) + entry.Points.Clear(); + } + + /// Registers executable lines and optional .NET source text before execution starts. + public LuaDebugSource RegisterSource(LuaClosure closure, string? content = null) + { + lock (gate) + { + var work = new Stack(); + var controlsChanged = false; + work.Push(closure.Proto); + while (work.Count != 0) + { + var prototype = work.Pop(); + controlsChanged |= RegisterPrototype(prototype); + foreach (var child in prototype.ChildPrototypes) + work.Push(child); + } + var entry = registered.GetValue( + closure.Proto, + _ => throw new InvalidOperationException() + ); + if (content != null) + entry.Source = entry.Source with { Content = content }; + if (controlsChanged) + UpdateExecutionCodes(); + return entry.Source; + } + } + + public LuaDebugSource[] GetSources() + { + lock (gate) + return sourcesById.Values.Select(x => x.Source).ToArray(); + } + + public LuaDebugSource GetSource(int id) + { + lock (gate) + return sourcesById.TryGetValue(id, out var entry) + ? entry.Source + : throw new ArgumentException("Unknown source reference.", nameof(id)); + } + + public async ValueTask ReadSourceAsync( + int id, + CancellationToken cancellationToken = default + ) + { + var source = GetSource(id); + if (source.Content != null) + return source.Content; + if (options.SourceTextProvider != null) + { + var content = await options.SourceTextProvider(source, cancellationToken); + if (content != null) + return content; + } + if (source.Path == null) + throw new InvalidOperationException("Source text was not registered by the host."); + using var input = await root.Platform.FileSystem.Open( + source.Path, + IO.LuaFileOpenMode.Read, + cancellationToken + ); + return root.Platform.DecodeText(await input.ReadAllAsync(cancellationToken)); + } + + bool RegisterPrototype(Prototype prototype) + { + if (registered.TryGetValue(prototype, out _)) + return false; + var name = root.Platform.DecodeText(prototype.ChunkName); + var path = options.SourcePathResolver?.Invoke(name); + if (path == null && name.StartsWith('@')) + path = name[1..]; + var key = path != null ? SourceKey(path) : "=" + name.TrimStart('='); + if (!sources.TryGetValue(key, out var entry)) + { + path = path != null ? key : null; + entry = new SourceEntry( + new LuaDebugSource( + sourcesById.Count + 1, + path == null ? name.TrimStart('=') : Path.GetFileName(path), + path, + null + ) + ); + sources.Add(key, entry); + sourcesById.Add(entry.Source.Id, entry); + } + foreach (var line in prototype.LineInfo) + if (line > 0) + entry.Lines.Add(line); + registered.Add(prototype, entry); + var controlsChanged = false; + if (breakpoints.TryGetValue(key, out var requests)) + { + entry.Points.Clear(); + foreach (var request in requests) + { + var previous = request.Resolved; + Resolve(request, entry); + if (request.Resolved.Verified) + entry.Points[request.Resolved.Line] = request.Resolved; + if (request.Resolved != previous) + { + controlsChanged = true; + BreakpointChanged?.Invoke(request.Resolved); + } + } + } + return controlsChanged; + } + + static void Resolve(RequestedBreakpoint request, SourceEntry? entry) + { + if (entry == null) + return; + var line = entry.Lines.GetViewBetween(request.Line, int.MaxValue).Min; + request.Resolved = + line == 0 + ? request.Resolved with + { + Verified = false, + Message = "No executable line at or after this location.", + } + : request.Resolved with + { + Line = line, + Verified = true, + Message = null, + }; + } + + static string SourceKey(string source) + { + if (source.StartsWith('=')) + return source; + return Path.GetFullPath(source.TrimStart('@')) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + } + + sealed class SourceEntry(LuaDebugSource source) + { + public LuaDebugSource Source = source; + public readonly SortedSet Lines = new(); + public readonly Dictionary Points = new(); + } + + sealed class RequestedBreakpoint(int line, LuaBreakpoint resolved) + { + public readonly int Line = line; + public LuaBreakpoint Resolved = resolved; + } +} diff --git a/src/Lua.Debugger/LuaDebugger.cs b/src/Lua.Debugger/LuaDebugger.cs new file mode 100644 index 00000000..7ecf5867 --- /dev/null +++ b/src/Lua.Debugger/LuaDebugger.cs @@ -0,0 +1,382 @@ +using System.Runtime.CompilerServices; +using Lua.Runtime; + +namespace Lua.Debugging; + +/// +/// An opt-in debugger shared by a Lua state and its coroutines. Inspection runs on +/// the suspended execution, never concurrently against its stack. It does not own the state. +/// +public sealed partial class LuaDebugger : IDisposable, ILuaDebugSession +{ + readonly object identity = new(); + readonly LuaState root; + readonly LuaDebuggerOptions options; + readonly object gate = new(); + readonly Queue commands = new(); + TaskCompletionSource signal = NewSignal(); + PauseContext? paused; + StepRequest? step; + bool pauseRequested; + bool resumeRequested; + bool evaluating; + bool disposed; + int nextBreakpointId; + int nextStopId; + int nextFrameId; + + public LuaDebugger(LuaState state) + : this(state, null) { } + + public LuaDebugger(LuaState state, LuaDebuggerOptions? options) + { + root = state.MainThread; + this.options = options ?? new LuaDebuggerOptions(); + breakpoints = new(sources.Comparer); + } + + /// Raised on the execution thread once the stack is available for inspection. + public event Action? Stopped; + + /// Raised after a stop and its inspections unwind, including when disposed during a stop. + public event Action? StopEnded; + public event Action? BreakpointChanged; + object ILuaDebugSession.Identity => identity; + + public bool IsAttached => ReferenceEquals(Volatile.Read(ref root.GlobalState.Debugger), this); + public LuaDebugStop? CurrentStop + { + get + { + lock (gate) + return paused?.Stop; + } + } + + public void Attach() + { + lock (gate) + { + if (disposed) + throw new ObjectDisposedException(nameof(LuaDebugger)); + if (!IsAttached && root.GlobalState.ActiveExecutions != 0) + throw new InvalidOperationException( + "Install the debugger before starting Lua execution." + ); + var previous = Interlocked.CompareExchange(ref root.GlobalState.Debugger, this, null); + if (previous != null && previous != this) + throw new InvalidOperationException( + "A debugger is already attached to this Lua state." + ); + } + } + + /// Detaches and releases a stop without cancelling host execution. + public void Detach() => Release(detach: true); + + /// Clears session controls and releases a stop, retaining the debugger attachment for reconnecting. + public void Reset() => Release(detach: false); + + void Release(bool detach) + { + CancellationTokenSource? inspections; + lock (gate) + { + if (detach) + Interlocked.CompareExchange(ref root.GlobalState.Debugger, null, this); + ClearBreakpointsCore(); + step = null; + pauseRequested = false; + resumeRequested = true; + UpdateExecutionCodes(); + signal.TrySetResult(true); + inspections = paused?.Inspections; + } + CancelInspections(inspections); + } + + /// Requests a stop at the next source/control-flow checkpoint. It cannot interrupt host C# code. + public void Pause() + { + lock (gate) + { + if (!IsAttached) + throw new InvalidOperationException("The debugger is not attached."); + pauseRequested = true; + UpdateExecutionCodes(); + } + } + + /// Resumes the specified stop. Stale stop IDs cannot resume a later stop. + public void Continue(int stopId, LuaDebugStep mode = LuaDebugStep.Continue) + { + if (!TryContinue(stopId, mode)) + throw new InvalidOperationException("The debug stop is no longer active."); + } + + internal bool TryContinue(int stopId, LuaDebugStep mode) + { + CancellationTokenSource inspections; + lock (gate) + { + if (paused is not { } context || context.Stop.Id != stopId || resumeRequested) + return false; + step = + mode == LuaDebugStep.Continue + ? null + : new StepRequest(mode, context.State, context.Depth, context.FrameId); + resumeRequested = true; + UpdateExecutionCodes(); + signal.TrySetResult(true); + inspections = context.Inspections; + } + CancelInspections(inspections); + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + ValueTask ILuaDebugSession.CheckpointAsync( + LuaState state, + LuaClosure closure, + int pc, + int line, + bool lineEntry, + CancellationToken cancellationToken + ) + { + if (evaluating || state.IsInHook) + return new(0); + PauseContext context; + lock (gate) + { + if (paused != null || !IsAttached) + return new(0); + var entry = registered.GetValue( + closure.Proto, + _ => throw new InvalidOperationException() + ); + LuaBreakpoint? breakpoint = null; + if (lineEntry) + entry.Points.TryGetValue(line, out breakpoint); + var stopForStep = lineEntry && step is { } request && ShouldStep(state, request); + if (!pauseRequested && !stopForStep && breakpoint == null) + return new(0); + var reason = + pauseRequested ? "pause" + : breakpoint != null ? "breakpoint" + : "step"; + pauseRequested = false; + step = null; + var stop = new LuaDebugStop(++nextStopId, reason, entry.Source, line, breakpoint?.Id); + paused = context = new PauseContext( + state, + pc, + state.CallStackFrameCount, + state.GetDebugFrameId(++nextFrameId), + stop + ); + resumeRequested = false; + signal = NewSignal(); + UpdateExecutionCodes(); + } + return options.PauseMode == LuaDebugPauseMode.Synchronous + ? SuspendSynchronously(context, cancellationToken) + : SuspendAsync(context, cancellationToken); + } + + ValueTask SuspendSynchronously( + PauseContext context, + CancellationToken cancellationToken + ) => new(SynchronousDebugPause.Run(token => SuspendAsync(context, token), cancellationToken)); + + void ILuaDebugSession.ExecutionEnded() + { + if (step == null) + return; + lock (gate) + { + if (step is not { } request || request.State.CallStackFrameCount != 0) + return; + step = null; + UpdateExecutionCodes(); + } + } + + static bool ShouldStep(LuaState state, StepRequest request) + { + if (request.Mode == LuaDebugStep.Into) + return true; + if (state != request.State) + return request.State.GetStatus() is LuaThreadStatus.Suspended or LuaThreadStatus.Dead; + var depth = state.CallStackFrameCount; + return depth < request.Depth + || request.Mode == LuaDebugStep.Over + && depth == request.Depth + && state.GetCurrentFrame().Version == request.FrameId; + } + + async ValueTask SuspendAsync(PauseContext context, CancellationToken cancellationToken) + { + using var cancellation = cancellationToken.Register(Wake); + try + { + Stopped?.Invoke(context.Stop); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + Command? command = null; + Task wait; + lock (gate) + { + if (resumeRequested || !IsAttached) + break; + if (commands.Count != 0) + command = commands.Dequeue(); + if (signal.Task.IsCompleted) + signal = NewSignal(); + wait = signal.Task; + } + if (command != null) + await command.Run(context, cancellationToken); + else + await wait; + } + return 0; + } + finally + { + Action? stopEnded; + lock (gate) + { + paused = null; + while (commands.Count != 0) + commands + .Dequeue() + .Fail(new InvalidOperationException("The debug stop has ended.")); + UpdateExecutionCodes(); + stopEnded = StopEnded; + if (disposed) + StopEnded = null; + } + context.Inspections.Dispose(); + stopEnded?.Invoke(context.Stop.Id); + } + } + + void Wake() + { + lock (gate) + signal.TrySetResult(true); + } + + static void CancelInspections(CancellationTokenSource? inspections) + { + try + { + inspections?.Cancel(); + } + catch (ObjectDisposedException) { } + } + + PauseContext RequireStop(int id) => + paused is { } context && context.Stop.Id == id && !resumeRequested + ? context + : throw new InvalidOperationException("The debug stop is no longer active."); + + Task InspectAsync( + int stopId, + Func> action, + CancellationToken cancellationToken + ) + { + lock (gate) + { + RequireStop(stopId); + var command = new Command(action, cancellationToken); + commands.Enqueue(command); + signal.TrySetResult(true); + return command.Completion.Task; + } + } + + public void Dispose() + { + lock (gate) + { + if (disposed) + return; + disposed = true; + Stopped = null; + if (paused == null) + StopEnded = null; + BreakpointChanged = null; + } + try + { + Detach(); + } + finally + { + lock (gate) + { + sources.Clear(); + sourcesById.Clear(); + } + } + } + + static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + sealed record StepRequest(LuaDebugStep Mode, LuaState State, int Depth, int FrameId); + + sealed record PauseContext(LuaState State, int Pc, int Depth, int FrameId, LuaDebugStop Stop) + { + public CancellationTokenSource Inspections { get; } = new(); + } + + abstract class Command + { + public abstract ValueTask Run(PauseContext context, CancellationToken cancellationToken); + public abstract void Fail(Exception exception); + } + + sealed class Command( + Func> action, + CancellationToken requestToken + ) : Command + { + public readonly TaskCompletionSource Completion = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + public override async ValueTask Run( + PauseContext context, + CancellationToken cancellationToken + ) + { + try + { + requestToken.ThrowIfCancellationRequested(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + requestToken, + cancellationToken, + context.Inspections.Token + ); + Completion.TrySetResult(await action(context, linked.Token)); + } + catch (Exception exception) + { + Fail(exception); + } + } + + public override void Fail(Exception exception) + { + if (exception is OperationCanceledException cancelled) + Completion.TrySetCanceled(cancelled.CancellationToken); + else + Completion.TrySetException(exception); + } + } +} diff --git a/src/Lua.Debugger/LuaDebuggerOptions.cs b/src/Lua.Debugger/LuaDebuggerOptions.cs new file mode 100644 index 00000000..bd994337 --- /dev/null +++ b/src/Lua.Debugger/LuaDebuggerOptions.cs @@ -0,0 +1,25 @@ +namespace Lua.Debugging; + +public enum LuaDebugPauseMode +{ + /// Suspends Lua execution without blocking its host thread. + Asynchronous, + + /// Blocks the execution thread, servicing only debugger work until resumed. + Synchronous, +} + +public sealed class LuaDebuggerOptions +{ + public LuaDebugPauseMode PauseMode { get; init; } + + /// Maps a decoded Lua chunk name to its editor file path, or null for the default mapping. + public Func? SourcePathResolver { get; init; } + + /// Reads application-owned source text. Null falls back to the state's filesystem. + public Func< + LuaDebugSource, + CancellationToken, + ValueTask + >? SourceTextProvider { get; init; } +} diff --git a/src/Lua.Debugger/SynchronousDebugPause.cs b/src/Lua.Debugger/SynchronousDebugPause.cs new file mode 100644 index 00000000..db094162 --- /dev/null +++ b/src/Lua.Debugger/SynchronousDebugPause.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using System.Runtime.ExceptionServices; + +namespace Lua.Debugging; + +sealed class SynchronousDebugPause(SynchronizationContext? previous) : SynchronizationContext +{ + readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> work = new(); + readonly object gate = new(); + readonly int threadId = Environment.CurrentManagedThreadId; + bool accepting = true; + + public static int Run( + Func> action, + CancellationToken cancellationToken + ) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var previous = Current; + var context = new SynchronousDebugPause(previous); + SetSynchronizationContext(context); + try + { + var operation = action(cancellation.Token); + if (operation.IsCompleted) + { + context.Complete(); + if (context.work.Count == 0) + return operation.GetAwaiter().GetResult(); + } + var task = operation.AsTask(); + _ = task.ContinueWith( + static (_, state) => ((SynchronousDebugPause)state!).Complete(), + context, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + ExceptionDispatchInfo? failure = null; + foreach (var item in context.work.GetConsumingEnumerable()) + try + { + item.Callback(item.State); + } + catch (Exception exception) + { + failure ??= ExceptionDispatchInfo.Capture(exception); + try + { + cancellation.Cancel(); + } + catch (AggregateException) + { + // Drain the suspended operation before propagating the original failure. + } + } + if (failure != null) + { + _ = task.Exception; + failure.Throw(); + } + return task.GetAwaiter().GetResult(); + } + finally + { + context.Complete(); + SetSynchronizationContext(previous); + context.work.Dispose(); + } + } + + public override SynchronizationContext CreateCopy() => this; + + public override void Post(SendOrPostCallback callback, object? state) + { + lock (gate) + { + if (accepting) + { + work.Add((callback, state)); + return; + } + } + if (previous != null) + previous.Post(callback, state); + else + ThreadPool.QueueUserWorkItem(_ => callback(state)); + } + + public override void Send(SendOrPostCallback callback, object? state) + { + if (Environment.CurrentManagedThreadId == threadId) + { + callback(state); + return; + } + var completed = new TaskCompletionSource(); + Post( + _ => + { + try + { + callback(state); + completed.SetResult(true); + } + catch (Exception exception) + { + completed.SetException(exception); + } + }, + null + ); + completed.Task.GetAwaiter().GetResult(); + } + + void Complete() + { + lock (gate) + { + if (!accepting) + return; + accepting = false; + work.CompleteAdding(); + } + } +} diff --git a/src/Lua/Internal/CoroutineCore.cs b/src/Lua/Internal/CoroutineCore.cs index 3015d12e..98685e9e 100644 --- a/src/Lua/Internal/CoroutineCore.cs +++ b/src/Lua/Internal/CoroutineCore.cs @@ -104,10 +104,8 @@ internal async ValueTask ResumeAsyncCore( ) { if (baseThread != null) - { baseThread.UnsafeSetStatus(LuaThreadStatus.Normal); - baseThread.GlobalState.ThreadStack.Push(Thread); - } + var entered = false; try { var phase = Interlocked.CompareExchange(ref handoff, Preparing, Suspended); @@ -123,6 +121,8 @@ phase is Completed or Closed or Cancelling ); try { + Thread.GlobalState.ActiveExecutions++; + entered = true; if (isFirstCall) Thread.Stack.EnsureCapacity(Thread.Stack.Count + argCount); else @@ -258,11 +258,9 @@ phase is Completed or Closed or Cancelling } finally { + Thread.ExitExecution(entered); if (baseThread != null) - { - baseThread.GlobalState.ThreadStack.Pop(); baseThread.UnsafeSetStatus(LuaThreadStatus.Running); - } } } diff --git a/src/Lua/Internal/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index 3301f2df..4fff36b9 100644 --- a/src/Lua/Internal/LuaGlobalState.cs +++ b/src/Lua/Internal/LuaGlobalState.cs @@ -10,7 +10,6 @@ sealed class LuaGlobalState { // states readonly LuaState mainState; - FastStackCore stateStack; readonly LuaTable environment; readonly LuaTable registry = new(); @@ -18,11 +17,14 @@ sealed class LuaGlobalState public LuaPlatform Platform { get; set; } public ILuaModuleLoader? ModuleLoader { get; set; } + internal Runtime.ILuaDebugSession? Debugger; + + // Active host entries and coroutine resumes; yielded frames do not count. + internal int ActiveExecutions; // Coroutines share this policy so Lua code cannot reset the limit by creating a new thread. internal int MaxCallDepth = 100_000; - internal ref FastStackCore ThreadStack => ref stateStack; internal ref FastStackCore DebugBufferPool => ref debugBufferPool; public LuaTable Environment => environment; diff --git a/src/Lua/InternalVisibleTo.cs b/src/Lua/InternalVisibleTo.cs index 030008dd..1fe6b9e2 100644 --- a/src/Lua/InternalVisibleTo.cs +++ b/src/Lua/InternalVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Lua.Tests")] +[assembly: InternalsVisibleTo("Lua.Debugger")] diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 7a8e2295..13c525f4 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -297,6 +297,14 @@ public ref readonly CallStackFrame GetCurrentFrame() return ref CoreData!.CallStack.PeekRef(); } + internal int GetDebugFrameId(int id) + { + ref var frame = ref CoreData!.CallStack.PeekRef(); + if (frame.Version == 0) + frame.Version = id; + return frame.Version; + } + public ReadOnlySpan GetStackValues() { return CoreData == null ? default : CoreData!.Stack.AsSpan(); @@ -623,6 +631,7 @@ CancellationToken cancellationToken var callStackTop = state.CallStackFrameCount; var previousCallerPrototype = state.LastCallerPrototype; var previousPc = state.LastPc; + var entered = EnterExecution(); try { state.ThrowIfCancellationRequested(cancellationToken); @@ -659,9 +668,26 @@ CancellationToken cancellationToken PopCallStackFrameUntil(callStackTop); LastCallerPrototype = previousCallerPrototype; LastPc = previousPc; + ExitExecution(entered); } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool EnterExecution() + { + if (GlobalState.ActiveExecutions != 0) + return false; + GlobalState.ActiveExecutions++; + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void ExitExecution(bool entered) + { + if (entered && --GlobalState.ActiveExecutions == 0) + Volatile.Read(ref GlobalState.Debugger)?.ExecutionEnded(); + } + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] internal async ValueTask HandleErrorAsync( Exception exception, @@ -918,5 +944,7 @@ public void Dispose() CoreData.Release(); CoreData = null!; + if (GlobalState.ActiveExecutions == 0) + Volatile.Read(ref GlobalState.Debugger)?.ExecutionEnded(); } } diff --git a/src/Lua/Runtime/LuaDebugCode.cs b/src/Lua/Runtime/LuaDebugCode.cs new file mode 100644 index 00000000..761cd546 --- /dev/null +++ b/src/Lua/Runtime/LuaDebugCode.cs @@ -0,0 +1,103 @@ +namespace Lua.Runtime; + +internal interface ILuaDebugSession +{ + object Identity { get; } + + LuaDebugCode GetExecutionCode(LuaClosure closure); + + void ExecutionEnded(); + + ValueTask CheckpointAsync( + LuaState state, + LuaClosure closure, + int pc, + int line, + bool lineEntry, + CancellationToken cancellationToken + ); +} + +internal class LuaDebugCode +{ + internal readonly object Identity; + internal readonly Prototype Prototype; + internal readonly Instruction[] Code; + internal volatile bool Enabled; + + internal LuaDebugCode(object identity, Prototype prototype) + { + Identity = identity; + Prototype = prototype; + Code = prototype.Code.ToArray(); + var points = new bool[Code.Length]; + void Mark(int pc) + { + if ((uint)pc < (uint)points.Length) + points[pc] = true; + } + Mark(0); + for (var pc = 0; pc < Code.Length; pc++) + { + var instruction = Code[pc]; + if ( + pc != 0 + && !prototype.LineInfo.IsEmpty + && prototype.LineInfo[pc] != prototype.LineInfo[pc - 1] + ) + Mark(pc); + switch (instruction.OpCode) + { + case OpCode.Jmp: + case OpCode.ForLoop: + case OpCode.TForLoop: + Mark(pc + 1 + instruction.SBx); + break; + case OpCode.ForPrep: + var target = pc + 1 + instruction.SBx; + if ( + target <= pc + || Code[target].OpCode != OpCode.ForLoop + || ( + !prototype.LineInfo.IsEmpty + && prototype.LineInfo[target] != prototype.LineInfo[pc] + ) + ) + Mark(target); + break; + case OpCode.Call: + case OpCode.TailCall: + case OpCode.TForCall: + case OpCode.Return: + Mark(pc); + if (instruction.OpCode is OpCode.Call or OpCode.TForCall) + Mark(pc + 1); + break; + case OpCode.Eq: + case OpCode.Lt: + case OpCode.Le: + case OpCode.Test: + case OpCode.TestSet: + Mark(pc + 2); + break; + case OpCode.LoadBool when instruction.C != 0: + Mark(pc + 2); + break; + } + } + for (var pc = 0; pc < Code.Length; pc++) + if (points[pc] && Code[pc].OpCode != OpCode.ExtraArg) + Code[pc] = (uint)LuaVirtualMachine.DebuggerBreakOpCode; + } + + internal void ResumeAt(int pc) + { + Code[pc] = (uint)LuaVirtualMachine.DebuggerResumeOpCode; + } + + internal Instruction RestoreAt(int pc) + { + Code[pc] = (uint)LuaVirtualMachine.DebuggerBreakOpCode; + return Prototype.Code[pc]; + } +} diff --git a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs index 0d2b5ae9..0921ddfb 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.Debug.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.Debug.cs @@ -5,6 +5,46 @@ namespace Lua.Runtime; public static partial class LuaVirtualMachine { + internal const OpCode DebuggerBreakOpCode = (OpCode)63; + internal const OpCode DebuggerResumeOpCode = (OpCode)62; + + [MethodImpl(MethodImplOptions.NoInlining)] + static bool ExecuteDebuggerCheckpoint( + VirtualMachineExecutionContext context, + ILuaDebugSession? debugger + ) + { + if (debugger == null) + return false; + var prototype = context.Prototype; + var pc = context.Pc; + var lines = prototype.LineInfo; + var line = lines.IsEmpty ? 0 : lines[pc]; + var lineEntry = + pc == 0 + || context.LastDebugPc < 0 + || pc <= context.LastDebugPc + || (!lines.IsEmpty && lines[context.LastDebugPc] != line); + context.LastDebugPc = pc; + var task = debugger.CheckpointAsync( + context.State, + context.LuaClosure, + pc, + line, + lineEntry, + context.CancellationToken + ); + if (task.IsCompleted) + { + task.GetAwaiter().GetResult(); + return false; + } + context.Task = task; + context.PostOperation = PostOperationType.Debugger; + context.Pc--; + return true; + } + [MethodImpl(MethodImplOptions.NoInlining)] static bool ExecutePerInstructionHook(VirtualMachineExecutionContext context) { diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 0a2b28ef..22e3a384 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -41,6 +41,7 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio PostOperation = PostOperationType.None; BaseCallStackCount = state.CallStackFrameCount; LastHookPc = -1; + LastDebugPc = -1; Task = default; } @@ -61,6 +62,8 @@ void Init(LuaState state, in CallStackFrame frame, CancellationToken cancellatio public int CurrentReturnFrameBase; public ValueTask Task; public int LastHookPc; + public int LastDebugPc = -1; + public LuaDebugCode? DebugCode; public bool IsTopLevel => BaseCallStackCount == State.CallStackFrameCount; @@ -111,6 +114,7 @@ public bool PopFromBuffer(int src, int srcCount) State.LastPc = Pc; ref readonly var lastFrame = ref frames[^2]; LuaClosure = Unsafe.As(lastFrame.Function); + LastDebugPc = Pc; CurrentReturnFrameBase = frame.ReturnBase; var callInstruction = Prototype.Code[Pc]; if (callInstruction.OpCode == OpCode.TailCall) @@ -195,6 +199,7 @@ public bool PopFromBuffer(int src, int srcCount) public void Push(in CallStackFrame frame) { Pc = -1; + LastDebugPc = -1; LuaClosure = (LuaClosure)frame.Function; FrameBase = frame.Base; CurrentReturnFrameBase = frame.ReturnBase; @@ -282,6 +287,21 @@ public async ValueTask ExecuteClosureAsyncImpl() toCatchFlag = true; await Task; Task = default; + if (PostOperation == PostOperationType.Debugger) + { + PostOperation = PostOperationType.None; + // Re-fetch the stopped instruction without counting or firing its Lua hook twice. + if (State.HookCount != 0) + State.HookCount++; + LastHookPc = Pc + 1; + toCatchFlag = false; + ThrowIfCancellationRequested(); + if ( + DebugCode!.Identity == Volatile.Read(ref GlobalState.Debugger)?.Identity + ) + DebugCode.ResumeAt(Pc + 1); + continue; + } ref readonly var frame = ref State.GetCurrentFrame(); CurrentReturnFrameBase = frame.ReturnBase; var variableArgumentCount = frame.VariableArgumentCount; @@ -346,6 +366,7 @@ or PostOperationType.DontPop Stack = null!; State = null!; LuaClosure = null!; + DebugCode = null; Task = default; CancellationToken = default; pool.TryPush(this); @@ -382,6 +403,7 @@ enum PostOperationType Self, Compare, DontPop, + Debugger, } internal static ValueTask ExecuteClosureAsync( @@ -410,7 +432,16 @@ static bool MoveNext(VirtualMachineExecutionContext context) { // This is a label to restart the execution when new function is called or restarted Restart: - ref var instructionsHead = ref Unsafe.AsRef(in context.Prototype.Code[0]); + var debugger = Volatile.Read(ref context.GlobalState.Debugger); + if ( + context.DebugCode == null + || context.DebugCode.Prototype != context.Prototype + || context.DebugCode.Identity != debugger?.Identity + ) + context.DebugCode = debugger?.GetExecutionCode(context.LuaClosure); + ref var instructionsHead = ref context.DebugCode != null + ? ref context.DebugCode.Code[0] + : ref Unsafe.AsRef(in context.Prototype.Code[0]); var frameBase = context.FrameBase; var stack = context.Stack; stack.EnsureCapacity(frameBase + context.Prototype.MaxStackSize); @@ -448,6 +479,7 @@ static bool MoveNext(VirtualMachineExecutionContext context) } context.LastHookPc = -1; + Dispatch: var iA = instruction.A; var opCode = instruction.OpCode; switch (opCode) @@ -1037,6 +1069,13 @@ out doRestart indexRef = init - step; stack.NotifyTop(iA + frameBase + 1); context.Pc += instruction.SBx; + // Track an untrapped initial header so its first backward jump is a line event. + if ( + context.DebugCode != null + && Unsafe.Add(ref instructionsHead, context.Pc + 1).OpCode + == OpCode.ForLoop + ) + context.LastDebugPc = context.Pc + 1; continue; case OpCode.TForCall: Markers.TForCall(); @@ -1114,8 +1153,35 @@ static void VarArg(VirtualMachineExecutionContext context) } continue; - case OpCode.ExtraArg: default: + if (opCode is DebuggerResumeOpCode or DebuggerBreakOpCode) + { + if (context.DebugCode == null) + { + ThrowLuaNotImplementedException(context, opCode); + return true; + } + instruction = + opCode == DebuggerResumeOpCode + ? context.DebugCode.RestoreAt(context.Pc) + : context.Prototype.Code[context.Pc]; + if (instruction.OpCode is DebuggerBreakOpCode or DebuggerResumeOpCode) + { + ThrowLuaNotImplementedException(context, instruction.OpCode); + return true; + } + context.Instruction = instruction; + if ( + opCode == DebuggerBreakOpCode + && context.DebugCode.Enabled + && ExecuteDebuggerCheckpoint( + context, + Volatile.Read(ref context.GlobalState.Debugger) + ) + ) + return true; + goto Dispatch; + } ThrowLuaNotImplementedException(context, context.Instruction.OpCode); return true; } @@ -1594,6 +1660,7 @@ CancellationToken cancellationToken }; state.PushCallStackFrame(newFrame); + var entered = state.EnterExecution(); try { var functionContext = new LuaFunctionExecutionContext @@ -1631,6 +1698,7 @@ CancellationToken cancellationToken finally { state.PopCallStackFrame(); + state.ExitExecution(entered); } } @@ -1708,7 +1776,6 @@ static bool TailCall(VirtualMachineExecutionContext context, out bool doRestart) ); newFrame.CallerInstructionIndex = lastFrame.CallerInstructionIndex; - newFrame.Version = lastFrame.Version; newFrame.Flags |= lastFrame.Flags & CallStackFrameFlags.ReversedLe; state.ReplaceCallStackFrame(newFrame); // Release the replaced closure before a hook or native tail callee can suspend. @@ -2172,6 +2239,7 @@ bool isHostCall var stack = state.Stack; var top = stack.Count; var framePushed = false; + var entered = state.EnterExecution(); var previousHandler = isHostCall ? state.ErrorHandlerIndex : 0; if (previousHandler != 0) state.ErrorHandlerIndex = 0; @@ -2217,6 +2285,7 @@ bool isHostCall state.PopCallStackFrameWithStackPop(top); else stack.PopUntil(top); + state.ExitExecution(entered); } } @@ -2447,6 +2516,7 @@ bool isHostCall var stack = state.Stack; var top = stack.Count; var framePushed = false; + var entered = state.EnterExecution(); var previousHandler = isHostCall ? state.ErrorHandlerIndex : 0; if (previousHandler != 0) state.ErrorHandlerIndex = 0; @@ -2495,6 +2565,7 @@ bool isHostCall state.PopCallStackFrameWithStackPop(top); else stack.PopUntil(top); + state.ExitExecution(entered); } } @@ -2794,6 +2865,7 @@ bool isHostCall { var stack = state.Stack; var framePushed = false; + var entered = state.EnterExecution(); var previousHandler = isHostCall ? state.ErrorHandlerIndex : 0; if (previousHandler != 0) state.ErrorHandlerIndex = 0; @@ -2840,6 +2912,7 @@ bool isHostCall state.PopCallStackFrameWithStackPop(top); else stack.PopUntil(top); + state.ExitExecution(entered); } } diff --git a/tests/Lua.Debugger.Dap.Tests/DapClient.cs b/tests/Lua.Debugger.Dap.Tests/DapClient.cs new file mode 100644 index 00000000..a6227e1e --- /dev/null +++ b/tests/Lua.Debugger.Dap.Tests/DapClient.cs @@ -0,0 +1,105 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using NUnit.Framework; + +namespace Lua.Debugger.Dap.Tests; + +sealed class DapClient(Stream connection, CancellationToken cancellationToken) +{ + readonly Queue events = new(); + readonly List receivedEvents = new(); + public IReadOnlyList ReceivedEvents => receivedEvents; + int sequence; + + public async Task RequestAsync( + string command, + JsonObject? args = null, + bool expectSuccess = true + ) + { + var id = await SendAsync(command, args); + return await ResponseAsync(id, expectSuccess); + } + + public async Task SendAsync(string command, JsonObject? args = null, bool fragment = false) + { + var id = ++sequence; + var request = new JsonObject + { + ["seq"] = id, + ["type"] = "request", + ["command"] = command, + ["arguments"] = args, + }; + var body = Encoding.UTF8.GetBytes( + request.ToJsonString( + new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping } + ) + ); + var packet = Encoding + .ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n") + .Concat(body) + .ToArray(); + var stream = connection; + if (fragment) + for (var i = 0; i < packet.Length; i++) + await stream.WriteAsync(packet.AsMemory(i, 1), cancellationToken); + else + await stream.WriteAsync(packet, cancellationToken); + return id; + } + + public async Task ResponseAsync(int id, bool success) + { + while (true) + { + var response = await ReadAsync(); + if (response["type"]!.GetValue() == "event") + { + events.Enqueue(response); + continue; + } + Assert.That(response["request_seq"]!.GetValue(), Is.EqualTo(id)); + Assert.That( + response["success"]!.GetValue(), + Is.EqualTo(success), + response.ToJsonString() + ); + return response; + } + } + + public async Task EventAsync(string name) + { + while (true) + { + var message = events.Count != 0 ? events.Dequeue() : await ReadAsync(); + Assert.That(message["type"]!.GetValue(), Is.EqualTo("event")); + if (message["event"]!.GetValue() == name) + return message; + } + } + + async Task ReadAsync() + { + var stream = connection; + var header = new List(); + var one = new byte[1]; + while (header.Count < 4 || !header.TakeLast(4).SequenceEqual("\r\n\r\n"u8.ToArray())) + { + if (await stream.ReadAsync(one, cancellationToken) == 0) + throw new EndOfStreamException(); + header.Add(one[0]); + } + var text = Encoding.ASCII.GetString(header.ToArray()); + var length = int.Parse(text.Split(':')[1].Trim()); + var body = new byte[length]; + await stream.ReadExactlyAsync(body, cancellationToken); + var message = JsonNode.Parse(body)!.AsObject(); + if (message["type"]!.GetValue() == "event") + receivedEvents.Add(message["event"]!.GetValue()); + return message; + } +} diff --git a/tests/Lua.Debugger.Dap.Tests/DapTests.cs b/tests/Lua.Debugger.Dap.Tests/DapTests.cs new file mode 100644 index 00000000..28efb32c --- /dev/null +++ b/tests/Lua.Debugger.Dap.Tests/DapTests.cs @@ -0,0 +1,505 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json.Nodes; +using Lua.Debugging; +using Lua.Standard; +using NUnit.Framework; + +namespace Lua.Debugger.Dap.Tests; + +public class DapTests +{ + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task ServerDisposalCleansUpWhenInspectionCancellationThrows(LuaDebugPauseMode mode) + { + await using var host = await Host.CreateAsync(mode); + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var cleanupRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var drained = false; + var ended = 0; + host.Server.Debugger.StopEnded += _ => ended++; + host.State.Environment["wait"] = new LuaFunction( + async (context, token) => + { + var waiting = new TaskCompletionSource().Task.WaitAsync(token); + using var registration = token.Register(() => + throw new InvalidOperationException("cancellation callback failed") + ); + try + { + entered.TrySetResult(true); + await waiting; + return context.Return(); + } + finally + { + await cleanupRelease.Task.WaitAsync(host.Token); + drained = true; + } + } + ); + await host.InitializeAsync(stopOnEntry: true); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(host.State.Load("return 42", "=dispose")); + await host.EventAsync("stopped"); + await host.SendAsync("evaluate", new() { ["frameId"] = 1, ["expression"] = "wait()" }); + await entered.Task.WaitAsync(host.Token); + try + { + var failure = Assert.Throws(host.Server.Dispose); + Assert.That(failure!.ToString(), Does.Contain("cancellation callback failed")); + Assert.That(host.Server.Debugger.IsAttached, Is.False); + Assert.Throws(host.Server.Debugger.Attach); + Assert.DoesNotThrow(host.Server.Dispose); + cleanupRelease.TrySetResult(true); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(drained, Is.True); + Assert.That(ended, Is.EqualTo(1)); + } + finally + { + cleanupRelease.TrySetResult(true); + host.Server.Debugger.Detach(); + await execution.WaitAsync(host.Token); + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task ClientSteppingDoesNotSendAnExtraContinuedEvent(LuaDebugPauseMode mode) + { + await using var host = await Host.CreateAsync(mode); + await host.InitializeAsync(stopOnEntry: true); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(host.State.Load("local x=42\nreturn x", "=client-step")); + await host.EventAsync("stopped"); + await host.RequestAsync("next", new() { ["threadId"] = 1 }); + await host.EventAsync("stopped"); + Assert.That(host.ReceivedEvents, Does.Not.Contain("continued")); + await host.RequestAsync("continue", new() { ["threadId"] = 1 }); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + } + + [TestCase(LuaDebugPauseMode.Asynchronous, "continue")] + [TestCase(LuaDebugPauseMode.Synchronous, "continue")] + [TestCase(LuaDebugPauseMode.Asynchronous, "reset")] + [TestCase(LuaDebugPauseMode.Synchronous, "reset")] + [TestCase(LuaDebugPauseMode.Asynchronous, "cancel")] + [TestCase(LuaDebugPauseMode.Synchronous, "cancel")] + public async Task HostDrivenResumeNotifiesTheClient(LuaDebugPauseMode mode, string action) + { + await using var host = await Host.CreateAsync(mode); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(host.Token); + await host.InitializeAsync(stopOnEntry: true); + await host.RequestAsync("configurationDone"); + var execution = Task.Run(async () => + await host.State.DoStringAsync("return 42", cancellationToken: cancellation.Token) + ); + try + { + await host.EventAsync("stopped"); + if (action == "continue") + host.Server.Debugger.Continue(host.Server.Debugger.CurrentStop!.Id); + else if (action == "reset") + host.Server.Debugger.Reset(); + else + cancellation.Cancel(); + var continued = await host.EventAsync("continued"); + Assert.That(continued["body"]!["threadId"]!.GetValue(), Is.EqualTo(1)); + Assert.That(continued["body"]!["allThreadsContinued"]!.GetValue(), Is.True); + if (action == "cancel") + Assert.ThrowsAsync(async () => await execution); + else + Assert.That( + await execution.WaitAsync(host.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + } + finally + { + host.Server.Debugger.Reset(); + try + { + await execution; + } + catch (LuaCanceledException) when (cancellation.IsCancellationRequested) { } + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous, false)] + [TestCase(LuaDebugPauseMode.Asynchronous, true)] + [TestCase(LuaDebugPauseMode.Synchronous, false)] + [TestCase(LuaDebugPauseMode.Synchronous, true)] + public async Task AttachingAdoptsAnExistingStop(LuaDebugPauseMode mode, bool stopOnEntry) + { + await using var host = await Host.CreateAsync(mode); + var stopped = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + host.Server.Debugger.Stopped += _ => stopped.TrySetResult(true); + host.Server.Debugger.Pause(); + var execution = Task.Run(async () => + await host.State.DoStringAsync("return 42", cancellationToken: host.Token) + ); + try + { + await stopped.Task.WaitAsync(host.Token); + await host.InitializeAsync(stopOnEntry); + await host.RequestAsync("configurationDone"); + await host.EventAsync("stopped"); + var stack = await host.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + Assert.That(stack["body"]!["stackFrames"]!.AsArray(), Has.Count.EqualTo(1)); + await host.RequestAsync("continue", new() { ["threadId"] = 1 }); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + } + finally + { + host.Server.Debugger.Reset(); + await execution; + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task BreakpointsStackScopesEvaluationAndVariableEditingWorkOverTcp( + LuaDebugPauseMode mode + ) + { + await using var host = await Host.CreateAsync(mode); + host.State.Environment[1] = 7; + var source = "local x=1\nlocal t={name='é🐇'}\nx=x+2\nreturn x,t.name"; + var closure = host.State.Load(source, "@" + host.Path); + var knownSource = host.Server.Debugger.RegisterSource(closure, source); + await host.InitializeAsync(); + var points = await host.RequestAsync( + "setBreakpoints", + new() + { + ["source"] = new JsonObject { ["path"] = host.Path }, + ["breakpoints"] = new JsonArray(new JsonObject { ["line"] = 3 }), + } + ); + Assert.That(points["body"]!["breakpoints"]![0]!["verified"]!.GetValue(), Is.True); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(closure); + var stopped = await host.EventAsync("stopped"); + Assert.That(stopped["body"]!["reason"]!.GetValue(), Is.EqualTo("breakpoint")); + var stack = await host.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + var frame = stack["body"]!["stackFrames"]![0]!; + Assert.That(frame["line"]!.GetValue(), Is.EqualTo(3)); + var frameId = frame["id"]!.GetValue(); + var scopes = await host.RequestAsync("scopes", new() { ["frameId"] = frameId }); + var localsRef = scopes["body"]!["scopes"]![0]!["variablesReference"]!.GetValue(); + var globalsRef = scopes["body"]!["scopes"]![2]!["variablesReference"]!.GetValue(); + var globals = await host.RequestAsync( + "variables", + new() { ["variablesReference"] = globalsRef } + ); + var numericGlobal = globals["body"]!["variables"]! + .AsArray() + .Single(v => v!["name"]!.GetValue() == "[1]")!; + Assert.That( + numericGlobal["presentationHint"]!["attributes"]![0]!.GetValue(), + Is.EqualTo("readOnly") + ); + await host.RequestAsync( + "setVariable", + new() + { + ["variablesReference"] = globalsRef, + ["name"] = "[1]", + ["value"] = "42", + }, + expectSuccess: false + ); + var variables = await host.RequestAsync( + "variables", + new() { ["variablesReference"] = localsRef } + ); + var locals = variables["body"]!["variables"]!.AsArray(); + var tableRef = locals.Single(v => v!["name"]!.GetValue() == "t")![ + "variablesReference" + ]!.GetValue(); + var children = await host.RequestAsync( + "variables", + new() + { + ["variablesReference"] = tableRef, + ["start"] = 0, + ["count"] = 1, + } + ); + Assert.That( + children["body"]!["variables"]![0]!["value"]!.GetValue(), + Is.EqualTo("\"é🐇\"") + ); + var evaluated = await host.RequestAsync( + "evaluate", + new() { ["frameId"] = frameId, ["expression"] = "x+4" } + ); + Assert.That(evaluated["body"]!["result"]!.GetValue(), Is.EqualTo("5")); + await host.RequestAsync( + "setVariable", + new() + { + ["variablesReference"] = localsRef, + ["name"] = "x", + ["value"] = "10", + } + ); + var text = await host.RequestAsync( + "source", + new() { ["sourceReference"] = knownSource.Id } + ); + Assert.That(text["body"]!["content"]!.GetValue(), Is.EqualTo(source)); + await host.RequestAsync("continue", new() { ["threadId"] = 1 }); + Assert.That( + await execution.WaitAsync(host.Token), + Is.EqualTo(new LuaValue[] { 12, "é🐇" }) + ); + Assert.That(host.State.Environment[1], Is.EqualTo(new LuaValue(7))); + Assert.That(host.State.Environment["[1]"], Is.EqualTo(LuaValue.Nil)); + var stale = await host.RequestAsync( + "variables", + new() { ["variablesReference"] = tableRef }, + expectSuccess: false + ); + Assert.That(stale["success"]!.GetValue(), Is.False); + } + + [Test] + public async Task EntryAndSteppingUseDapResponseOrdering() + { + await using var host = await Host.CreateAsync(); + var closure = host.State.Load("local x=1\nx=x+1\nreturn x", "@" + host.Path); + host.Server.Debugger.RegisterSource(closure); + await host.InitializeAsync(stopOnEntry: true); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(closure); + Assert.That( + (await host.EventAsync("stopped"))["body"]!["reason"]!.GetValue(), + Is.EqualTo("entry") + ); + await host.RequestAsync("next", new() { ["threadId"] = 1 }); + await host.EventAsync("stopped"); + var stack = await host.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + Assert.That(stack["body"]!["stackFrames"]![0]!["line"]!.GetValue(), Is.EqualTo(2)); + await host.RequestAsync("stepIn", new() { ["threadId"] = 1 }); + await host.EventAsync("stopped"); + stack = await host.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + Assert.That(stack["body"]!["stackFrames"]![0]!["line"]!.GetValue(), Is.EqualTo(3)); + await host.RequestAsync("continue", new() { ["threadId"] = 1 }); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 2 })); + } + + [TestCase(false, LuaDebugPauseMode.Asynchronous)] + [TestCase(true, LuaDebugPauseMode.Asynchronous)] + [TestCase(false, LuaDebugPauseMode.Synchronous)] + [TestCase(true, LuaDebugPauseMode.Synchronous)] + public async Task DisconnectOrEofResumesTheHostAndAllowsReconnection( + bool disconnectRequest, + LuaDebugPauseMode mode + ) + { + await using var host = await Host.CreateAsync(mode); + var closure = host.State.Load("return 42", "@" + host.Path); + await host.InitializeAsync(stopOnEntry: true); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(closure); + await host.EventAsync("stopped"); + if (disconnectRequest) + await host.RequestAsync("disconnect"); + host.Client.Dispose(); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + await host.ReconnectAsync(); + await host.InitializeAsync(); + await host.RequestAsync("configurationDone"); + Assert.That( + await host.Execute(closure).WaitAsync(host.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + } + + [Test] + public async Task CoordinatesAndUriPathsRespectClientInitialization() + { + await using var host = await Host.CreateAsync(); + var closure = host.State.Load("local x=1\nreturn x", "@" + host.Path); + host.Server.Debugger.RegisterSource(closure); + await host.InitializeAsync(zeroBased: true); + await host.RequestAsync( + "setBreakpoints", + new() + { + ["source"] = new JsonObject { ["path"] = new Uri(host.Path).AbsoluteUri }, + ["breakpoints"] = new JsonArray(new JsonObject { ["line"] = 1 }), + } + ); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(closure); + await host.EventAsync("stopped"); + var stack = await host.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + var frame = stack["body"]!["stackFrames"]![0]!; + Assert.That(frame["line"]!.GetValue(), Is.EqualTo(1)); + Assert.That(frame["column"]!.GetValue(), Is.EqualTo(0)); + Assert.That( + frame["source"]!["path"]!.GetValue(), + Is.EqualTo(new Uri(host.Path).AbsoluteUri) + ); + await host.RequestAsync("continue", new() { ["threadId"] = 1 }); + await execution.WaitAsync(host.Token); + } + + [Test] + public async Task FragmentedUnicodeRequestsAndUnsupportedCommandsDoNotBreakTheSession() + { + await using var host = await Host.CreateAsync(); + await host.InitializeAsync(fragment: true); + var response = await host.RequestAsync( + "launch", + new() { ["program"] = "é🐇.lua" }, + expectSuccess: false + ); + Assert.That(response["success"]!.GetValue(), Is.False); + var threads = await host.RequestAsync("threads"); + Assert.That(threads["body"]!["threads"]!.AsArray(), Has.Count.EqualTo(1)); + } + + [Test] + public async Task DisconnectCancelsAnAwaitingEvaluationWithoutCancellingHostExecution() + { + await using var host = await Host.CreateAsync(); + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var waiting = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + host.State.Environment["wait"] = new LuaFunction( + async (context, token) => + { + entered.TrySetResult(true); + await waiting.Task.WaitAsync(token); + return context.Return(); + } + ); + var closure = host.State.Load("return 42", "@" + host.Path); + await host.InitializeAsync(stopOnEntry: true); + await host.RequestAsync("configurationDone"); + var execution = host.Execute(closure); + await host.EventAsync("stopped"); + await host.SendAsync("evaluate", new() { ["frameId"] = 1, ["expression"] = "wait()" }); + await entered.Task.WaitAsync(host.Token); + host.Client.Dispose(); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + } + + sealed class Host : IAsyncDisposable + { + readonly CancellationTokenSource lifetime = new(TimeSpan.FromSeconds(15)); + DapClient protocol = null!; + Task? execution; + Task listening = null!; + public LuaState State { get; } = LuaState.Create(); + public LuaDapServer Server { get; } + public TcpClient Client { get; private set; } = null!; + public string Path { get; } = + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "LuaDapTests", "test.lua"); + public CancellationToken Token => lifetime.Token; + public IReadOnlyList ReceivedEvents => protocol.ReceivedEvents; + + Host(LuaDebugPauseMode mode) + { + State.OpenStandardLibraries(); + Server = new LuaDapServer( + State, + port: 0, + bindAddress: IPAddress.Loopback, + debuggerOptions: new LuaDebuggerOptions { PauseMode = mode } + ); + } + + public static async Task CreateAsync( + LuaDebugPauseMode mode = LuaDebugPauseMode.Asynchronous + ) + { + var host = new Host(mode); + host.listening = host.Server.RunAsync(host.Token); + await host.ReconnectAsync(); + return host; + } + + public async Task ReconnectAsync() + { + Client = new TcpClient { NoDelay = true }; + await Client.ConnectAsync(IPAddress.Loopback, Server.LocalEndpoint.Port, Token); + protocol = new DapClient(Client.GetStream(), Token); + } + + public Task Execute(Runtime.LuaClosure closure) => + execution = Task.Run(() => ExecuteConfiguredAsync(closure)); + + async Task ExecuteConfiguredAsync(Runtime.LuaClosure closure) + { + await Server.WaitForConfigurationAsync(Token); + return await State.ExecuteAsync(closure, Token); + } + + public async Task InitializeAsync( + bool stopOnEntry = false, + bool zeroBased = false, + bool fragment = false + ) + { + var id = await SendAsync( + "initialize", + new() + { + ["adapterID"] = "é🐇", + ["linesStartAt1"] = !zeroBased, + ["columnsStartAt1"] = !zeroBased, + ["pathFormat"] = zeroBased ? "uri" : "path", + }, + fragment + ); + await protocol.ResponseAsync(id, true); + await EventAsync("initialized"); + await RequestAsync("attach", new() { ["stopOnEntry"] = stopOnEntry }); + } + + public Task RequestAsync( + string command, + JsonObject? args = null, + bool expectSuccess = true + ) => protocol.RequestAsync(command, args, expectSuccess); + + public Task SendAsync( + string command, + JsonObject? args = null, + bool fragment = false + ) => protocol.SendAsync(command, args, fragment); + + public Task EventAsync(string name) => protocol.EventAsync(name); + + public async ValueTask DisposeAsync() + { + lifetime.Cancel(); + Client.Dispose(); + Server.Dispose(); + await listening; + if (execution != null) + try + { + await execution; + } + catch (LuaCanceledException) { } + State.Dispose(); + lifetime.Dispose(); + } + } +} diff --git a/tests/Lua.Debugger.Dap.Tests/Lua.Debugger.Dap.Tests.csproj b/tests/Lua.Debugger.Dap.Tests/Lua.Debugger.Dap.Tests.csproj new file mode 100644 index 00000000..6f7bb970 --- /dev/null +++ b/tests/Lua.Debugger.Dap.Tests/Lua.Debugger.Dap.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0;net10.0 + enable + enable + false + true + + + + + + + + + diff --git a/tests/Lua.Debugger.Dap.Tests/SessionLifetimeTests.cs b/tests/Lua.Debugger.Dap.Tests/SessionLifetimeTests.cs new file mode 100644 index 00000000..4a2f9d65 --- /dev/null +++ b/tests/Lua.Debugger.Dap.Tests/SessionLifetimeTests.cs @@ -0,0 +1,421 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Lua.Debugging; +using Lua.Runtime; +using NUnit.Framework; + +namespace Lua.Debugger.Dap.Tests; + +public class SessionLifetimeTests +{ + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task OutputFailureDrainsAndResetsDespiteThrowingCancellationCallbacks( + LuaDebugPauseMode mode + ) + { + await using var host = await SessionHost.CreateAsync(mode); + var entered = Signal(); + var cleanupEntered = Signal(); + var cleanupRelease = Signal(); + var drained = false; + host.State.Environment["wait"] = new LuaFunction( + async (context, token) => + { + var waiting = new TaskCompletionSource().Task.WaitAsync(token); + using var registration = token.Register(() => + throw new InvalidOperationException("cancellation callback failed") + ); + try + { + entered.TrySetResult(true); + await waiting; + return context.Return(); + } + finally + { + cleanupEntered.TrySetResult(true); + await cleanupRelease.Task.WaitAsync(host.Token); + drained = true; + } + } + ); + await host.ConfigureAsync(stopOnEntry: true); + var execution = host.Execute("return 42"); + await host.Protocol.EventAsync("stopped"); + await host.Protocol.SendAsync( + "evaluate", + new() { ["frameId"] = 1, ["expression"] = "wait()" } + ); + await entered.Task.WaitAsync(host.Token); + try + { + host.Output.FailWrites = true; + await host.Protocol.SendAsync("threads"); + await cleanupEntered.Task.WaitAsync(host.Token); + cleanupRelease.TrySetResult(true); + var failure = Assert.ThrowsAsync(async () => + await host.Running.WaitAsync(host.Token) + ); + Assert.That(failure!.Message, Is.EqualTo("output failed")); + Assert.That(drained, Is.True); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(host.Debugger.CurrentStop, Is.Null); + var stops = 0; + host.Debugger.Stopped += _ => stops++; + Assert.That( + await host.State.DoStringAsync("return 43"), + Is.EqualTo(new LuaValue[] { 43 }) + ); + Assert.That(stops, Is.Zero); + } + finally + { + cleanupRelease.TrySetResult(true); + host.Debugger.Reset(); + } + } + + [TestCase("pause")] + [TestCase("configurationDone")] + public async Task DisconnectDuringResponseCannotRearmDebuggerControls(string command) + { + await using var host = await SessionHost.CreateAsync( + LuaDebugPauseMode.Asynchronous, + command + ); + host.Debugger.Pause(); + var execution = host.Execute("return 42"); + await host.Stops.Reader.ReadAsync(host.Token); + await host.InitializeAsync(stopOnEntry: true); + var response = host.Protocol.RequestAsync(command); + await host.Output.Entered.Task.WaitAsync(host.Token); + await response; + host.Client.Client.Shutdown(SocketShutdown.Send); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + host.Output.Release.TrySetResult(true); + await host.Running.WaitAsync(host.Token); + Assert.That(host.Configured, Is.False); + var stops = 0; + host.Debugger.Stopped += stop => + { + stops++; + host.Debugger.Continue(stop.Id); + }; + Assert.That(await host.State.DoStringAsync("return 43"), Is.EqualTo(new LuaValue[] { 43 })); + Assert.That(stops, Is.Zero); + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task LateResumeResponsePreservesANewerStop(LuaDebugPauseMode mode) + { + await using var host = await SessionHost.CreateAsync(mode, "continue"); + var closure = host.State.Load("local x=1\nreturn x", "=resume-race"); + host.Debugger.RegisterSource(closure); + host.Debugger.SetBreakpoints("=resume-race", [1, 2]); + await host.ConfigureAsync(); + var execution = host.Execute(closure); + var first = await host.Stops.Reader.ReadAsync(host.Token); + await host.Protocol.EventAsync("stopped"); + var response = host.Protocol.RequestAsync("continue", new() { ["threadId"] = 1 }); + await host.Output.Entered.Task.WaitAsync(host.Token); + await response; + host.Debugger.Continue(first.Id); + var second = await host.Stops.Reader.ReadAsync(host.Token); + host.Output.Release.TrySetResult(true); + await host.Protocol.RequestAsync("threads"); + var stack = await host.Protocol.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + Assert.That(stack["body"]!["stackFrames"]![0]!["line"]!.GetValue(), Is.EqualTo(2)); + Assert.That(host.Debugger.CurrentStop!.Id, Is.EqualTo(second.Id)); + await host.Protocol.RequestAsync("continue", new() { ["threadId"] = 1 }); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 1 })); + } + + [TestCase(LuaDebugPauseMode.Asynchronous, "continue", false)] + [TestCase(LuaDebugPauseMode.Synchronous, "continue", false)] + [TestCase(LuaDebugPauseMode.Asynchronous, "next", false)] + [TestCase(LuaDebugPauseMode.Synchronous, "next", false)] + [TestCase(LuaDebugPauseMode.Asynchronous, "stepIn", false)] + [TestCase(LuaDebugPauseMode.Synchronous, "stepIn", false)] + [TestCase(LuaDebugPauseMode.Asynchronous, "stepOut", false)] + [TestCase(LuaDebugPauseMode.Synchronous, "stepOut", false)] + [TestCase(LuaDebugPauseMode.Asynchronous, "continue", true)] + [TestCase(LuaDebugPauseMode.Synchronous, "continue", true)] + public async Task ResumeCancelsAnAwaitingEvaluation( + LuaDebugPauseMode mode, + string command, + bool throwOnCancellation + ) + { + await using var host = await SessionHost.CreateAsync(mode); + var entered = Signal(); + var drained = false; + host.State.Environment["wait"] = new LuaFunction( + async (context, token) => + { + var waiting = new TaskCompletionSource().Task.WaitAsync(token); + using var registration = token.Register(() => + { + if (throwOnCancellation) + throw new InvalidOperationException("cancellation callback failed"); + }); + try + { + entered.TrySetResult(true); + await waiting; + return context.Return(); + } + finally + { + drained = true; + } + } + ); + await host.ConfigureAsync(stopOnEntry: true); + var execution = host.Execute("local x=42\nreturn x"); + await host.Protocol.EventAsync("stopped"); + var evaluation = await host.Protocol.SendAsync( + "evaluate", + new() { ["frameId"] = 1, ["expression"] = "wait()" } + ); + await entered.Task.WaitAsync(host.Token); + await host.Protocol.RequestAsync(command, new() { ["threadId"] = 1 }); + await host.Protocol.ResponseAsync(evaluation, false); + Assert.That(drained, Is.True); + if (throwOnCancellation) + { + var output = await host.Protocol.EventAsync("output"); + Assert.That( + output["body"]!["output"]!.GetValue(), + Does.Contain("cancellation callback failed") + ); + } + if (command is "next" or "stepIn") + { + await host.Protocol.EventAsync("stopped"); + var stack = await host.Protocol.RequestAsync("stackTrace", new() { ["threadId"] = 1 }); + Assert.That(stack["body"]!["stackFrames"]![0]!["line"]!.GetValue(), Is.EqualTo(2)); + await host.Protocol.RequestAsync("continue", new() { ["threadId"] = 1 }); + } + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 42 })); + await host.Protocol.RequestAsync("threads"); + Assert.That(host.Protocol.ReceivedEvents, Does.Not.Contain("continued")); + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task QueuedEvaluationCannotRunAgainstALaterStop(LuaDebugPauseMode mode) + { + await using var host = await SessionHost.CreateAsync(mode, "evaluate"); + var entered = Signal(); + var mutated = false; + host.State.Environment["wait"] = new LuaFunction( + async (context, token) => + { + entered.TrySetResult(true); + await new TaskCompletionSource().Task.WaitAsync(token); + return context.Return(); + } + ); + host.State.Environment["mutate"] = new LuaFunction( + (context, _) => + { + mutated = true; + return new(context.Return()); + } + ); + var closure = host.State.Load("local x=1\nx=x+1\nreturn x", "=queued-evaluation"); + host.Debugger.RegisterSource(closure); + host.Debugger.SetBreakpoints("=queued-evaluation", [2]); + await host.ConfigureAsync(); + var execution = host.Execute(closure); + await host.Stops.Reader.ReadAsync(host.Token); + await host.Protocol.EventAsync("stopped"); + var first = await host.Protocol.SendAsync( + "evaluate", + new() { ["frameId"] = 1, ["expression"] = "wait()" } + ); + await entered.Task.WaitAsync(host.Token); + var queued = await host.Protocol.SendAsync( + "evaluate", + new() { ["frameId"] = 1, ["expression"] = "mutate()" } + ); + await host.Protocol.RequestAsync("next", new() { ["threadId"] = 1 }); + await host.Output.Entered.Task.WaitAsync(host.Token); + await host.Stops.Reader.ReadAsync(host.Token); + await host.Protocol.ResponseAsync(first, false); + host.Output.Release.TrySetResult(true); + await host.Protocol.ResponseAsync(queued, false); + Assert.That(mutated, Is.False); + await host.Protocol.EventAsync("stopped"); + await host.Protocol.RequestAsync("continue", new() { ["threadId"] = 1 }); + Assert.That(await execution.WaitAsync(host.Token), Is.EqualTo(new LuaValue[] { 2 })); + } + + static TaskCompletionSource Signal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + sealed class SessionHost : IAsyncDisposable + { + readonly CancellationTokenSource lifetime = new(TimeSpan.FromSeconds(10)); + readonly TaskCompletionSource configuration = Signal(); + TcpClient peer = null!; + Task? execution; + public LuaState State { get; } = LuaState.Create(); + public LuaDebugger Debugger { get; } + public TcpClient Client { get; } = new() { NoDelay = true }; + public ControlledOutput Output { get; private set; } = null!; + public DapClient Protocol { get; private set; } = null!; + public Channel Stops { get; } = Channel.CreateUnbounded(); + public Task Running { get; private set; } = Task.CompletedTask; + public bool Configured => configuration.Task.IsCompletedSuccessfully; + public CancellationToken Token => lifetime.Token; + + SessionHost(LuaDebugPauseMode mode) + { + Debugger = new LuaDebugger(State, new LuaDebuggerOptions { PauseMode = mode }); + Debugger.Attach(); + } + + public static async Task CreateAsync( + LuaDebugPauseMode mode, + string delayCommand = "" + ) + { + var host = new SessionHost(mode); + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var connect = host.Client.ConnectAsync( + IPAddress.Loopback, + ((IPEndPoint)listener.LocalEndpoint).Port, + host.Token + ); + host.peer = await listener.AcceptTcpClientAsync(host.Token); + await connect; + host.peer.NoDelay = true; + host.Output = new ControlledOutput(host.peer.GetStream(), delayCommand); + host.Protocol = new DapClient(host.Client.GetStream(), host.Token); + var session = new LuaDapSession(host.Debugger, host.peer.GetStream(), host.Output); + session.Configured += () => host.configuration.TrySetResult(true); + host.Running = session.RunAsync(host.Token); + host.Debugger.Stopped += stop => host.Stops.Writer.TryWrite(stop); + return host; + } + catch + { + await host.DisposeAsync(); + throw; + } + finally + { + listener.Stop(); + } + } + + public async Task InitializeAsync(bool stopOnEntry = false) + { + await Protocol.RequestAsync("initialize"); + await Protocol.RequestAsync("attach", new() { ["stopOnEntry"] = stopOnEntry }); + } + + public async Task ConfigureAsync(bool stopOnEntry = false) + { + await InitializeAsync(stopOnEntry); + await Protocol.RequestAsync("configurationDone"); + await configuration.Task.WaitAsync(Token); + } + + public Task Execute(string source) => Execute(State.Load(source, "=session")); + + public Task Execute(LuaClosure closure) => + execution = Task.Run(async () => await State.ExecuteAsync(closure, Token)); + + public async ValueTask DisposeAsync() + { + Output?.Release.TrySetResult(true); + lifetime.Cancel(); + Debugger.Reset(); + try + { + await Task.WhenAll( + execution == null ? [Running] : new Task[] { Running, execution } + ); + } + catch (LuaCanceledException) { } + catch (IOException) when (Output?.FailWrites == true) { } + finally + { + Client.Dispose(); + peer?.Dispose(); + Debugger.Dispose(); + State.Dispose(); + lifetime.Dispose(); + } + } + } + + sealed class ControlledOutput(Stream inner, string command) : Stream + { + bool delay; + public volatile bool FailWrites; + public readonly TaskCompletionSource Entered = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + public readonly TaskCompletionSource Release = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) + { + if (FailWrites) + throw new IOException("output failed"); + await inner.WriteAsync(buffer, cancellationToken); + delay = Encoding + .UTF8.GetString(buffer.Span) + .Contains($"\"command\":\"{command}\"", StringComparison.Ordinal); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + await inner.FlushAsync(cancellationToken); + if (delay) + { + delay = false; + Entered.TrySetResult(true); + await Release.Task; + } + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + inner.Write(buffer, offset, count); + } +} diff --git a/tests/Lua.Debugger.Tests/DebuggerHostingTests.cs b/tests/Lua.Debugger.Tests/DebuggerHostingTests.cs new file mode 100644 index 00000000..b944a09a --- /dev/null +++ b/tests/Lua.Debugger.Tests/DebuggerHostingTests.cs @@ -0,0 +1,794 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Threading.Channels; +using Lua.Debugging; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Debugger.Tests; + +public class DebuggerHostingTests +{ + [TestCase(LuaDebugPauseMode.Asynchronous, LuaDebugStep.Over, "return")] + [TestCase(LuaDebugPauseMode.Synchronous, LuaDebugStep.Over, "return")] + [TestCase(LuaDebugPauseMode.Asynchronous, LuaDebugStep.Out, "return")] + [TestCase(LuaDebugPauseMode.Synchronous, LuaDebugStep.Out, "return")] + [TestCase(LuaDebugPauseMode.Asynchronous, LuaDebugStep.Out, "error")] + [TestCase(LuaDebugPauseMode.Asynchronous, LuaDebugStep.Out, "cancel")] + public async Task TopLevelStepsEndWithTheirInvocationWithoutClearingBreakpoints( + LuaDebugPauseMode mode, + LuaDebugStep step, + string outcome + ) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state, new LuaDebuggerOptions { PauseMode = mode }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + state.Environment["finish"] = new LuaFunction( + (context, token) => + { + if (outcome == "error") + throw new InvalidOperationException("invocation failed"); + if (outcome == "cancel") + { + lifetime.Cancel(); + token.ThrowIfCancellationRequested(); + } + return new(context.Return(42)); + } + ); + var next = state.Load("local x=40\nreturn x+2", "=next"); + debugger.RegisterSource(next); + debugger.SetBreakpoints("=next", [2]); + var stops = 0; + debugger.Stopped += stop => + debugger.Continue(stop.Id, stops++ == 0 ? step : LuaDebugStep.Continue); + debugger.Attach(); + debugger.Pause(); + if (outcome == "return") + Assert.That( + await state.DoStringAsync("return 42", "=entry", lifetime.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + else if (outcome == "error") + Assert.ThrowsAsync(async () => + await state.DoStringAsync("return finish()", "=entry", lifetime.Token) + ); + else + Assert.ThrowsAsync(async () => + await state.DoStringAsync("return finish()", "=entry", lifetime.Token) + ); + Assert.That( + typeof(LuaDebugger) + .GetField("step", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(debugger), + Is.Null + ); + Assert.That( + await state.ExecuteAsync(next, timeout.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + Assert.That(stops, Is.EqualTo(2)); + } + + [Test] + public async Task StepOverSurvivesADirectCoroutineYield() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var debugger = new LuaDebugger(state); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load("local x=1\ncoroutine.yield(x)\nx=x+1\nreturn x", "=yield-step"); + using var thread = state.CreateCoroutine(closure); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + debugger.RegisterSource(closure); + debugger.SetBreakpoints("=yield-step", [2]); + debugger.Attach(); + var output = new LuaStack(); + var execution = Task.Run(async () => await thread.ResumeAsync(output, timeout.Token)); + try + { + var first = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(first.Line, Is.EqualTo(2)); + debugger.Continue(first.Id, LuaDebugStep.Over); + await execution.WaitAsync(timeout.Token); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 1 })); + output.Clear(); + execution = Task.Run(async () => await thread.ResumeAsync(output, timeout.Token)); + var second = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(second.Line, Is.EqualTo(3)); + Assert.That(second.Reason, Is.EqualTo("step")); + debugger.Continue(second.Id); + await execution.WaitAsync(timeout.Token); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 2 })); + } + finally + { + debugger.Detach(); + await execution; + } + } + + [TestCase(false)] + [TestCase(true)] + public void ImmediateSynchronousResumeDrainsAcceptedCallbacks(bool throwPosted) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger( + state, + new LuaDebuggerOptions { PauseMode = LuaDebugPauseMode.Synchronous } + ); + var previous = SynchronizationContext.Current; + var thread = Environment.CurrentManagedThreadId; + var continuation = false; + var continuationThread = 0; + var posted = false; + debugger.Stopped += async _ => + { + await Task.Yield(); + continuationThread = Environment.CurrentManagedThreadId; + continuation = true; + }; + debugger.Stopped += stop => + { + SynchronizationContext.Current!.Post( + _ => + { + posted = true; + if (throwPosted) + throw new InvalidOperationException("posted callback failed"); + }, + null + ); + debugger.Continue(stop.Id); + }; + debugger.Attach(); + debugger.Pause(); + if (throwPosted) + { + var failure = Assert.Throws(() => + state.DoStringAsync("return 42").GetAwaiter().GetResult() + ); + Assert.That(failure!.ToString(), Does.Contain("posted callback failed")); + } + else + Assert.That( + state.DoStringAsync("return 42").GetAwaiter().GetResult(), + Is.EqualTo(new LuaValue[] { 42 }) + ); + Assert.That(continuation, Is.True); + Assert.That(continuationThread, Is.EqualTo(thread)); + Assert.That(posted, Is.True); + Assert.That(debugger.CurrentStop, Is.Null); + Assert.That(SynchronizationContext.Current, Is.SameAs(previous)); + Assert.That( + state.DoStringAsync("return 43").GetAwaiter().GetResult(), + Is.EqualTo(new LuaValue[] { 43 }) + ); + } + + [TestCase("return", false, "run")] + [TestCase("error", false, "run")] + [TestCase("cancel", false, "run")] + [TestCase("return", true, "run")] + [TestCase("return", true, "call")] + [TestCase("return", true, "index")] + [TestCase("return", true, "newindex")] + [TestCase("return", true, "add")] + public async Task AttachmentRejectsActiveHostExecutionAndAllowsReuse( + string outcome, + bool coroutine, + string entry + ) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var lifetime = new CancellationTokenSource(); + using var release = new ManualResetEventSlim(); + var entered = Signal(); + state.SetHook( + new LuaFunction( + (context, _) => + { + entered.TrySetResult(true); + release.Wait(timeout.Token); + return new(context.Return()); + } + ), + "", + 1 + ); + using var thread = coroutine + ? state.CreateCoroutine(state.Load("return 7", "=entry")) + : state.CreateThread(); + using var debugger = new LuaDebugger(thread); + var closure = thread.Load(outcome == "error" ? "error('failed')" : "return 55", "=child"); + Exception? failure = null; + LuaValue[]? results = null; + var execution = Task.Run(async () => + { + try + { + if (entry == "run") + results = await thread.ExecuteAsync(closure, lifetime.Token); + else if (entry == "call") + results = await thread.CallAsync(closure, [], lifetime.Token); + else + { + var table = new LuaTable + { + Metatable = new LuaTable { ["__" + entry] = closure }, + }; + switch (entry) + { + case "index": + results = [await thread.GetTableAsync(table, "x", lifetime.Token)]; + break; + case "newindex": + await thread.SetTableAsync(table, "x", 1, lifetime.Token); + results = []; + break; + case "add": + results = [await thread.AddAsync(table, 1, lifetime.Token)]; + break; + } + } + } + catch (Exception exception) + { + failure = exception; + } + }); + try + { + await entered.Task.WaitAsync(timeout.Token); + Assert.That(state.IsRunning, Is.False); + Assert.That(thread.IsRunning, Is.True); + Assert.Throws(debugger.Attach); + if (outcome == "cancel") + lifetime.Cancel(); + } + finally + { + release.Set(); + await execution.WaitAsync(timeout.Token); + } + if (outcome == "return") + { + Assert.That(failure, Is.Null); + Assert.That(results, Is.EqualTo(entry == "newindex" ? [] : new LuaValue[] { 55 })); + } + else + Assert.That( + failure, + outcome == "cancel" + ? Is.InstanceOf() + : Is.TypeOf() + ); + debugger.Attach(); + var stops = 0; + debugger.Stopped += stop => + { + stops++; + debugger.Continue(stop.Id); + }; + debugger.Pause(); + Assert.That( + await thread.DoStringAsync("return 42", cancellationToken: timeout.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + Assert.That(stops, Is.EqualTo(1)); + if (coroutine) + { + var output = new LuaStack(); + await thread.ResumeAsync(output, timeout.Token); + Assert.That(output.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 })); + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task DisposingAnActiveStopNotifiesTheHostAfterInspectionDrains( + LuaDebugPauseMode mode + ) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state, new LuaDebuggerOptions { PauseMode = mode }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var stopped = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var entered = Signal(); + var cleanupEntered = Signal(); + var cleanupRelease = Signal(); + var drained = false; + var applicationPaused = false; + var ended = new List(); + debugger.Stopped += stop => + { + applicationPaused = true; + stopped.SetResult(stop); + }; + debugger.StopEnded += id => + { + Assert.That(drained, Is.True); + Assert.That(debugger.CurrentStop, Is.Null); + applicationPaused = false; + ended.Add(id); + }; + state.Environment["inspect"] = new LuaFunction( + async (context, token) => + { + try + { + entered.TrySetResult(true); + await new TaskCompletionSource().Task.WaitAsync(token); + return context.Return(); + } + finally + { + cleanupEntered.TrySetResult(true); + await cleanupRelease.Task.WaitAsync(timeout.Token); + drained = true; + } + } + ); + debugger.Attach(); + debugger.Pause(); + var execution = Task.Run(async () => + await state.DoStringAsync("return 42", cancellationToken: timeout.Token) + ); + try + { + var stop = await stopped.Task.WaitAsync(timeout.Token); + var inspection = debugger.EvaluateAsync(stop.Id, 1, "inspect()", timeout.Token); + await entered.Task.WaitAsync(timeout.Token); + debugger.Dispose(); + await cleanupEntered.Task.WaitAsync(timeout.Token); + Assert.That(applicationPaused, Is.True); + Assert.That(ended, Is.Empty); + cleanupRelease.TrySetResult(true); + Assert.ThrowsAsync(async () => await inspection); + Assert.That( + await execution.WaitAsync(timeout.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + Assert.That(applicationPaused, Is.False); + Assert.That(ended, Is.EqualTo(new[] { stop.Id })); + Assert.That(debugger.IsAttached, Is.False); + Assert.Throws(debugger.Attach); + } + finally + { + cleanupRelease.TrySetResult(true); + debugger.Detach(); + await execution; + } + } + + [TestCase("return")] + [TestCase("yield")] + [TestCase("error")] + public async Task AttachmentRejectsActiveDirectResumesAndAllowsIdleThreads(string outcome) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var release = new ManualResetEventSlim(); + var entered = Signal(); + state.SetHook( + new LuaFunction( + (context, _) => + { + entered.TrySetResult(true); + release.Wait(timeout.Token); + return new(context.Return()); + } + ), + "", + 1 + ); + var source = outcome switch + { + "yield" => "coroutine.yield(55)\nreturn 42", + "error" => "error('failed')", + _ => "local n=0\nfor i=1,10 do n=n+i end\nreturn n", + }; + using var thread = state.CreateCoroutine( + state.Load(source, "=direct-resume"), + isProtectedMode: true + ); + using var debugger = new LuaDebugger(state); + var output = new LuaStack(); + var execution = Task.Run(async () => await thread.ResumeAsync(output, timeout.Token)); + try + { + await entered.Task.WaitAsync(timeout.Token); + Assert.That(state.IsRunning, Is.False); + Assert.That(thread.IsRunning, Is.True); + Assert.Throws(debugger.Attach); + } + finally + { + release.Set(); + await execution.WaitAsync(timeout.Token); + } + Assert.That(output.AsSpan()[0], Is.EqualTo(new LuaValue(outcome != "error"))); + debugger.Attach(); + if (outcome == "yield") + { + var stops = 0; + debugger.Stopped += stop => + { + stops++; + debugger.Continue(stop.Id); + }; + debugger.Pause(); + var results = new LuaStack(); + await thread.ResumeAsync(results, timeout.Token); + Assert.That(results.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 42 })); + Assert.That(stops, Is.EqualTo(1)); + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task ReattachingTheSameDebuggerDoesNotSkipTheFirstCheckpoint( + LuaDebugPauseMode mode + ) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state, new LuaDebuggerOptions { PauseMode = mode }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + var closure = state.Load("local x=42\nreturn x", "=reattach"); + debugger.Attach(); + debugger.Pause(); + var execution = Task.Run(async () => await state.ExecuteAsync(closure, timeout.Token)); + try + { + var first = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(first.Line, Is.EqualTo(1)); + debugger.Detach(); + Assert.That( + await execution.WaitAsync(timeout.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + debugger.Attach(); + debugger.Pause(); + execution = Task.Run(async () => await state.ExecuteAsync(closure, timeout.Token)); + var second = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(second.Line, Is.EqualTo(1)); + debugger.Continue(second.Id); + Assert.That( + await execution.WaitAsync(timeout.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + } + finally + { + debugger.Detach(); + await execution; + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task YieldedCoroutineUsesTheCurrentDebugger(LuaDebugPauseMode mode) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var first = new LuaDebugger(state); + using var second = new LuaDebugger(state, new LuaDebuggerOptions { PauseMode = mode }); + first.Attach(); + var closure = state.Load( + "co=coroutine.create(function()\n coroutine.yield(1)\n local x=42\n return x\nend)", + "@lifetime.lua" + ); + await state.ExecuteAsync(closure); + Assert.That( + await state.DoStringAsync("return coroutine.resume(co)"), + Is.EqualTo(new LuaValue[] { true, 1 }) + ); + first.Detach(); + second.Attach(); + second.RegisterSource(closure); + Assert.That(second.SetBreakpoints("lifetime.lua", [4])[0].Verified, Is.True); + var stops = new List(); + second.Stopped += stop => + { + stops.Add(stop.Line); + second.Continue(stop.Id); + }; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Assert.That( + await state.DoStringAsync( + "return coroutine.resume(co)", + cancellationToken: timeout.Token + ), + Is.EqualTo(new LuaValue[] { true, 42 }) + ); + Assert.That(stops, Is.EqualTo(new[] { 4 })); + } + + [TestCase(false)] + [TestCase(true)] + public async Task SynchronousCallbackFailureDrainsInspectionBeforeExecutionUnwinds( + bool throwOnCancellation + ) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger( + state, + new LuaDebuggerOptions { PauseMode = LuaDebugPauseMode.Synchronous } + ); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var stopped = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var entered = Signal(); + var drained = false; + var ended = false; + var pumped = false; + SynchronizationContext? pauseContext = null; + debugger.Stopped += stop => + { + pauseContext = SynchronizationContext.Current; + stopped.SetResult(stop); + }; + debugger.StopEnded += _ => ended = true; + state.Environment["inspect"] = new LuaFunction( + async (context, token) => + { + using var registration = token.Register(() => + { + if (throwOnCancellation) + throw new InvalidOperationException("cancellation callback failed"); + }); + try + { + entered.SetResult(true); + await Task.Delay(Timeout.Infinite, token); + return context.Return(); + } + finally + { + drained = true; + } + } + ); + debugger.Attach(); + debugger.Pause(); + var execution = Task.Run(async () => + await state.DoStringAsync("return 42", cancellationToken: timeout.Token) + ); + try + { + var stop = await stopped.Task.WaitAsync(timeout.Token); + var inspection = debugger.EvaluateAsync(stop.Id, 1, "inspect()", timeout.Token); + await entered.Task.WaitAsync(timeout.Token); + pauseContext!.Post( + _ => + { + pauseContext.Post(_ => pumped = true, null); + throw new InvalidOperationException("posted callback failed"); + }, + null + ); + try + { + await execution.WaitAsync(timeout.Token); + Assert.Fail("The callback failure should fault Lua execution."); + } + catch (LuaRuntimeException exception) + { + Assert.That(exception.ToString(), Does.Contain("posted callback failed")); + } + Assert.That(drained, Is.True); + Assert.That(pumped, Is.True); + Assert.That(ended, Is.True); + Assert.That(debugger.CurrentStop, Is.Null); + Assert.ThrowsAsync(async () => await inspection); + Assert.That(await state.DoStringAsync("return 43"), Is.EqualTo(new LuaValue[] { 43 })); + } + finally + { + debugger.Detach(); + } + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task PausePolicyPreservesInspectionAffinityAndControlsHostProgress( + LuaDebugPauseMode mode + ) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state, new LuaDebuggerOptions { PauseMode = mode }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + var entered = Signal(); + var release = Signal(); + var returned = Signal(); + var progress = Signal(); + var before = 0; + var after = 0; + state.Environment["inspect"] = new LuaFunction( + async (context, token) => + { + before = Environment.CurrentManagedThreadId; + entered.TrySetResult(true); + await release.Task.WaitAsync(token); + after = Environment.CurrentManagedThreadId; + return context.Return(42); + } + ); + debugger.Attach(); + debugger.Pause(); + var host = new TestHost(); + var execution = host.RunAsync(async () => + { + var original = SynchronizationContext.Current; + var operation = state.DoStringAsync( + "local x=40\nreturn x+2", + cancellationToken: timeout.Token + ); + returned.TrySetResult(true); + Assert.That(await operation, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(SynchronizationContext.Current, Is.SameAs(original)); + }); + try + { + var stop = await stops.Reader.ReadAsync(timeout.Token); + host.Post(_ => progress.TrySetResult(true), null); + var evaluation = debugger.EvaluateAsync(stop.Id, 1, "inspect()", timeout.Token); + await entered.Task.WaitAsync(timeout.Token); + release.TrySetResult(true); + Assert.That((await evaluation).Value, Is.EqualTo(new LuaValue(42))); + Assert.That(before, Is.EqualTo(host.ThreadId)); + Assert.That(after, Is.EqualTo(host.ThreadId)); + if (mode == LuaDebugPauseMode.Asynchronous) + { + await returned.Task.WaitAsync(timeout.Token); + await progress.Task.WaitAsync(timeout.Token); + } + else + { + Assert.That(returned.Task.IsCompleted, Is.False); + Assert.That(progress.Task.IsCompleted, Is.False); + } + debugger.Continue(stop.Id); + await execution.WaitAsync(timeout.Token); + } + finally + { + debugger.Detach(); + await execution; + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task BlockingPauseReleasesOnDetachOrCancellation(bool cancel) + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger( + state, + new LuaDebuggerOptions { PauseMode = LuaDebugPauseMode.Synchronous } + ); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var lifetime = new CancellationTokenSource(); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + debugger.Attach(); + debugger.Pause(); + var host = new TestHost(); + var execution = host.RunAsync(async () => + { + try + { + var results = await state.DoStringAsync( + "return 42", + cancellationToken: lifetime.Token + ); + Assert.That(cancel, Is.False); + Assert.That(results, Is.EqualTo(new LuaValue[] { 42 })); + } + catch (LuaCanceledException) when (cancel) { } + }); + await stops.Reader.ReadAsync(timeout.Token); + if (cancel) + lifetime.Cancel(); + else + debugger.Detach(); + await execution.WaitAsync(timeout.Token); + Assert.That(debugger.CurrentStop, Is.Null); + debugger.Detach(); + Assert.That(await state.DoStringAsync("return 43"), Is.EqualTo(new LuaValue[] { 43 })); + } + + [Test] + public async Task ApplicationCanMapLogicalSourcesAndSupplyTheExecutedText() + { + var path = Path.GetFullPath("user-scripts/player.lua"); + var text = "local x=40\nreturn x+2"; + using var state = LuaState.Create(); + using var debugger = new LuaDebugger( + state, + new LuaDebuggerOptions + { + SourcePathResolver = name => name == "=uploaded:player" ? path : null, + SourceTextProvider = (source, _) => new(source.Path == path ? text : null), + } + ); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + debugger.Attach(); + debugger.SetBreakpoints(path, [2]); + var execution = state.DoStringAsync(text, "=uploaded:player", timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Source.Path, Is.EqualTo(path)); + Assert.That(stop.Line, Is.EqualTo(2)); + Assert.That( + await debugger.ReadSourceAsync(stop.Source.Id, timeout.Token), + Is.EqualTo(text) + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 42 })); + } + + static TaskCompletionSource Signal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + sealed class TestHost : SynchronizationContext + { + readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> work = new(); + public int ThreadId { get; private set; } + + public Task RunAsync(Func action) + { + var completed = Signal(); + new Thread(() => + { + ThreadId = Environment.CurrentManagedThreadId; + SetSynchronizationContext(this); + try + { + var task = action(); + _ = task.ContinueWith( + _ => work.CompleteAdding(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + foreach (var item in work.GetConsumingEnumerable()) + item.Callback(item.State); + task.GetAwaiter().GetResult(); + completed.TrySetResult(true); + } + catch (Exception exception) + { + completed.TrySetException(exception); + } + finally + { + SetSynchronizationContext(null); + work.Dispose(); + } + }) + { + IsBackground = true, + }.Start(); + return completed.Task; + } + + public override void Post(SendOrPostCallback callback, object? state) => + work.Add((callback, state)); + } +} diff --git a/tests/Lua.Debugger.Tests/DebuggerLifetimeTests.cs b/tests/Lua.Debugger.Tests/DebuggerLifetimeTests.cs new file mode 100644 index 00000000..6d4ef053 --- /dev/null +++ b/tests/Lua.Debugger.Tests/DebuggerLifetimeTests.cs @@ -0,0 +1,132 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.CompilerServices; +using Lua.Debugging; +using Lua.Standard; + +namespace Lua.Debugger.Tests; + +public class DebuggerLifetimeTests +{ + [TestCase(false)] + [TestCase(true)] + public void FinishedSteppingCoroutineIsNotRetainedByTheDebugger(bool cancelYield) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var debugger = new LuaDebugger(state); + debugger.Attach(); + debugger.Stopped += stop => debugger.Continue(stop.Id, LuaDebugStep.Out); + var reference = CompleteSteppingCoroutine(state, debugger, cancelYield); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + GC.KeepAlive(state); + GC.KeepAlive(debugger); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CompleteSteppingCoroutine( + LuaState state, + LuaDebugger debugger, + bool cancelYield + ) + { + using var lifetime = new CancellationTokenSource(); + var thread = state.CreateCoroutine( + state.Load( + cancelYield ? "coroutine.yield(1)\nreturn 42" : "return 42", + "=completed-step" + ) + ); + debugger.Pause(); + var output = new Lua.Runtime.LuaStack(); + thread.ResumeAsync(output, lifetime.Token).GetAwaiter().GetResult(); + Assert.That( + output.AsSpan().ToArray(), + Is.EqualTo(new LuaValue[] { true, cancelYield ? 1 : 42 }) + ); + if (cancelYield) + lifetime.Cancel(); + Assert.That(thread.GetStatus(), Is.EqualTo(LuaThreadStatus.Dead)); + return new WeakReference(thread); + } + + [Test] + public void CollectedChunksDoNotAccumulateDebuggerMetadata() + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state); + debugger.Attach(); + var retained = state.Load("return 42", "=generated"); + state.ExecuteAsync(retained).GetAwaiter().GetResult(); + for (var i = 0; i < 16; i++) + { + ExecuteTemporaryChunks(state); + GC.Collect(); + } + var references = (ICollection) + typeof(LuaDebugger) + .GetField("codeReferences", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(debugger)!; + Assert.That(references.Count, Is.LessThan(512)); + var stops = 0; + debugger.Stopped += stop => + { + stops++; + debugger.Continue(stop.Id); + }; + debugger.SetBreakpoints("=generated", [1]); + Assert.That( + state.ExecuteAsync(retained).GetAwaiter().GetResult(), + Is.EqualTo(new LuaValue[] { 42 }) + ); + Assert.That(stops, Is.EqualTo(1)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void ExecuteTemporaryChunks(LuaState state) + { + for (var i = 0; i < 128; i++) + state.DoStringAsync("return 1", "=generated").GetAwaiter().GetResult(); + } + + [TestCase(false)] + [TestCase(true)] + public void YieldedCoroutineDoesNotRetainReleasedDebugger(bool dispose) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var reference = AttachAndYield(state, dispose); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.That(reference.IsAlive, Is.False); + Assert.That( + state.DoStringAsync("return coroutine.resume(co)").AsTask().GetAwaiter().GetResult(), + Is.EqualTo(new LuaValue[] { true, 42 }) + ); + GC.KeepAlive(state); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference AttachAndYield(LuaState state, bool dispose) + { + var debugger = new LuaDebugger(state); + debugger.Attach(); + var result = state + .DoStringAsync( + "co=coroutine.create(function() coroutine.yield(1);return 42 end);return coroutine.resume(co)" + ) + .AsTask() + .GetAwaiter() + .GetResult(); + Assert.That(result, Is.EqualTo(new LuaValue[] { true, 1 })); + if (dispose) + debugger.Dispose(); + else + debugger.Detach(); + return new WeakReference(debugger); + } +} diff --git a/tests/Lua.Debugger.Tests/DebuggerTests.cs b/tests/Lua.Debugger.Tests/DebuggerTests.cs new file mode 100644 index 00000000..f328caf4 --- /dev/null +++ b/tests/Lua.Debugger.Tests/DebuggerTests.cs @@ -0,0 +1,705 @@ +using System.Threading.Channels; +using Lua.Debugging; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Debugger.Tests; + +public class DebuggerTests +{ + [Test] + public async Task NewlyRegisteredLinesUpdateExistingExecutionControls() + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state); + debugger.Attach(); + var first = state.Load("\n\n\n\n\nreturn 6", "=shared-source"); + debugger.RegisterSource(first); + Assert.That(debugger.SetBreakpoints("=shared-source", [3])[0].Line, Is.EqualTo(6)); + var stops = new List(); + debugger.Stopped += stop => + { + stops.Add(stop.Line); + debugger.Continue(stop.Id); + }; + await state.ExecuteAsync(first); + var second = state.Load("\n\n\nreturn 4", "=shared-source"); + debugger.RegisterSource(second); + await state.ExecuteAsync(first); + await state.ExecuteAsync(second); + Assert.That(stops, Is.EqualTo(new[] { 6, 4 })); + } + + [TestCase("local n=0;for i=1,3 do n=n+i end;return n")] + [TestCase("for i=1,3 do end; return 42")] + [TestCase("for i=1,0 do error('unreachable') end; return 42")] + [TestCase("local n=0;for i=1,3 do for j=1,2 do n=n+j end end;return n")] + [TestCase("local n=0\nfor i=3,1,-1 do\n if i==2 then break end\n n=n+i\nend\nreturn n")] + [TestCase("local n=0\nfor i=1,\n 3 do n=n+i end\nreturn n")] + [TestCase("local n=0;while n<3 do n=n+1 end;repeat n=n-1 until n==0;return n")] + [TestCase( + "local function f(x) return x+1 end;local function g(x) return f(x) end;local n=0;for i=1,3 do n=g(n) end;return n" + )] + [TestCase("local n=0;for k,v in ipairs({1,2,3}) do n=n+v end;return n")] + public async Task BreakpointLineEventsMatchLuaLineHooks(string source) + { + async Task Run(bool debug) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var debugger = new LuaDebugger(state); + var lines = new List(); + var closure = state.Load(source, "@line-events.lua"); + if (debug) + { + debugger.Attach(); + debugger.RegisterSource(closure); + debugger.SetBreakpoints( + "line-events.lua", + Enumerable.Range(1, source.Count(c => c == '\n') + 1).ToArray() + ); + debugger.Stopped += stop => + { + lines.Add(stop.Line); + debugger.Continue(stop.Id); + }; + } + else + state.SetHook( + new LuaFunction( + (context, _) => + { + lines.Add(context.GetArgument(1)); + return new(context.Return()); + } + ), + "l" + ); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await state.ExecuteAsync(closure, timeout.Token); + return lines.ToArray(); + } + Assert.That(await Run(true), Is.EqualTo(await Run(false))); + } + + [Test] + public async Task NumericLoopBreakpointsIncludeTheFirstIteration() + { + using var state = LuaState.Create(); + using var debugger = new LuaDebugger(state); + state.Environment["n"] = 0; + debugger.Attach(); + debugger.SetBreakpoints("numeric-loop.lua", [1]); + var values = new List(); + debugger.Stopped += stop => + { + values.Add(state.Environment["n"].Read()); + debugger.Continue(stop.Id); + }; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Assert.That( + await state.DoStringAsync( + "for i=1,3 do n=n+i end;return n", + "@numeric-loop.lua", + timeout.Token + ), + Is.EqualTo(new LuaValue[] { 6 }) + ); + Assert.That(values, Is.EqualTo(new double[] { 0, 0, 1, 3 })); + } + + [Test] + public async Task PauseDuringAnAwaitingMetamethodStopsBeforeTheChunkReturns() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var started = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var resume = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + state.Environment["t"] = new LuaTable + { + Metatable = new LuaTable + { + ["__add"] = new LuaFunction( + async (context, token) => + { + started.TrySetResult(true); + await resume.Task.WaitAsync(token); + return context.Return(42); + } + ), + }, + }; + var execution = state + .DoStringAsync("return t+t", cancellationToken: timeout.Token) + .AsTask(); + await started.Task.WaitAsync(timeout.Token); + debugger.Pause(); + resume.TrySetResult(true); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Reason, Is.EqualTo("pause")); + Assert.That(execution.IsCompleted, Is.False); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 42 })); + } + + [Test] + public async Task CSharpClosureUpValuesCanBeEdited() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var leaf = state.Load("local x=0\nreturn", "=leaf"); + state.Environment["bridge"] = new CSharpClosure( + "bridge", + [7, 9], + async (context, token) => + { + await context.State.RunAsync(leaf, token); + return context.Return(context.GetCsClosure()!.UpValues); + } + ); + debugger.SetBreakpoints("=leaf", [2]); + var execution = state.DoStringAsync("return bridge()", "=main", timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + var frame = (await debugger.GetStackTraceAsync(stop.Id, timeout.Token)) + .Single(frame => frame.Name == "bridge") + .Id; + var upvalues = await debugger.GetVariablesAsync( + stop.Id, + frame, + LuaDebugScope.UpValues, + timeout.Token + ); + Assert.That(upvalues[1].Value, Is.EqualTo(new LuaValue(9))); + await debugger.SetVariableAsync( + stop.Id, + frame, + LuaDebugScope.UpValues, + upvalues[1].Name, + "42", + timeout.Token + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 7, 42 })); + } + + [Test] + public async Task InspectionUsesTheInnermostLocalEnvironment() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + state.Environment["x"] = 99; + var closure = state.Load( + "local _ENV={x=11}\ndo\n local _ENV={x=42}\n local y=x\n return y\nend", + "=local-env" + ); + debugger.SetBreakpoints("=local-env", [4]); + var execution = state.ExecuteAsync(closure, timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + var frame = (await debugger.GetStackTraceAsync(stop.Id, timeout.Token))[0].Id; + var globals = await debugger.GetVariablesAsync( + stop.Id, + frame, + LuaDebugScope.Globals, + timeout.Token + ); + Assert.That(globals.Single(value => value.Name == "x").Value, Is.EqualTo(new LuaValue(42))); + Assert.That( + (await debugger.EvaluateAsync(stop.Id, frame, "x", timeout.Token)).Value, + Is.EqualTo(new LuaValue(42)) + ); + await debugger.SetVariableAsync( + stop.Id, + frame, + LuaDebugScope.Globals, + "x", + "x+58", + timeout.Token + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 100 })); + Assert.That(state.Environment["x"], Is.EqualTo(new LuaValue(99))); + } + + [Test] + public async Task EvaluationPreservesNilShadowingAndTheFramesGlobalMetatable() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load( + "setmetatable(_G,{__index=function() return 99 end})\nx=41; y=42\nlocal y=nil\nlocal function run()\n local x=nil\n return x,y\nend\nreturn run()", + "@nil-bindings.lua" + ); + debugger.SetBreakpoints("nil-bindings.lua", [6]); + var execution = state.ExecuteAsync(closure, timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + var frame = (await debugger.GetStackTraceAsync(stop.Id, timeout.Token))[0].Id; + Assert.That( + ( + await debugger.EvaluateAsync( + stop.Id, + frame, + "x==nil and y==nil and missing==99", + timeout.Token + ) + ).Value, + Is.EqualTo(new LuaValue(true)) + ); + await debugger.SetVariableAsync( + stop.Id, + frame, + LuaDebugScope.UpValues, + "y", + "41", + timeout.Token + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { LuaValue.Nil, 41 })); + } + + [Test] + public async Task RemovingALuaHookDuringAStopDoesNotReplayIt() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var hooks = 0; + state.SetHook( + new LuaFunction( + (context, _) => + { + hooks++; + return new(context.Return()); + } + ), + "l", + 1 + ); + debugger.Pause(); + var execution = state.DoStringAsync("return 42", cancellationToken: timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + state.SetHook(null, ""); + var stoppedHooks = hooks; + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(hooks, Is.EqualTo(stoppedHooks)); + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task StepOverMetamethodAndStepOutOfYieldingCoroutineReturnToTheCaller( + LuaDebugPauseMode mode + ) + { + var (state, debugger, stops) = Create(mode); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load( + "local t=setmetatable({}, {__add=function()\n return 42\nend})\nlocal x=t+t\nlocal co=coroutine.create(function()\n coroutine.yield(x)\n return x+1\nend)\nlocal ok,v=coroutine.resume(co)\nreturn ok,v", + "@metamethod-coroutine.lua" + ); + debugger.SetBreakpoints("metamethod-coroutine.lua", [4, 6]); + var execution = Task.Run(() => state.ExecuteAsync(closure, timeout.Token).AsTask()); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(4)); + debugger.Continue(stop.Id, LuaDebugStep.Over); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(5)); + debugger.Continue(stop.Id); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(6)); + debugger.Continue(stop.Id, LuaDebugStep.Out); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(10)); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { true, 42 })); + } + + [Test] + public async Task DebuggerExecutionCopiesDoNotChangeSharedPrototypesOrDumps() + { + using var first = LuaState.Create(); + using var second = LuaState.Create(); + var closure = first.Load("local x=40\nreturn x+2", "@shared.lua"); + var original = closure.Proto.ToBytecode(); + using var debugger = new LuaDebugger(first); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + debugger.Attach(); + debugger.SetBreakpoints("shared.lua", [2]); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var execution = first.ExecuteAsync(closure, timeout.Token).AsTask(); + try + { + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(closure.Proto.ToBytecode(), Is.EqualTo(original)); + var shared = new Lua.Runtime.LuaClosure(second, closure.Proto); + Assert.That( + await second.ExecuteAsync(shared, timeout.Token), + Is.EqualTo(new LuaValue[] { 42 }) + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 42 })); + Assert.That(closure.Proto.ToBytecode(), Is.EqualTo(original)); + } + finally + { + debugger.Detach(); + await execution; + } + } + + [Test] + public async Task BreakpointsCanBeInstalledAndClearedDuringExecution() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var running = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + state.Environment["tick"] = new LuaFunction( + (context, _) => + { + running.TrySetResult(true); + return new(context.Return()); + } + ); + var closure = state.Load( + "local n=0\nwhile not done do\n n=n+1; tick()\nend\nreturn n", + "@live.lua" + ); + var execution = Task.Run(async () => await state.ExecuteAsync(closure, timeout.Token)); + await running.Task.WaitAsync(timeout.Token); + debugger.SetBreakpoints("live.lua", [3]); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Reason, Is.EqualTo("breakpoint")); + await debugger.SetVariableAsync( + stop.Id, + 1, + LuaDebugScope.Globals, + "done", + "true", + timeout.Token + ); + debugger.SetBreakpoints("live.lua", []); + debugger.Continue(stop.Id); + Assert.That((await execution)[0].Read(), Is.GreaterThan(0)); + } + + [Test] + public async Task FailedEvaluationDoesNotInvokeTheProgramsErrorHandler() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load( + "local n=0\nlocal function run()\n local x=42\n return x\nend\nlocal ok,v=xpcall(run,function(e) n=n+1;return e end)\nreturn ok,v,n", + "@evaluation-error.lua" + ); + debugger.RegisterSource(closure); + debugger.SetBreakpoints("evaluation-error.lua", [4]); + var execution = state.ExecuteAsync(closure, timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + var frame = (await debugger.GetStackTraceAsync(stop.Id, timeout.Token))[0].Id; + try + { + await debugger.EvaluateAsync(stop.Id, frame, "error('watch failed')", timeout.Token); + Assert.Fail(); + } + catch (LuaRuntimeException) { } + Assert.That( + (await debugger.EvaluateAsync(stop.Id, frame, "x+1", timeout.Token)).Value, + Is.EqualTo(new LuaValue(43)) + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { true, 42, 0 })); + } + + [TestCase(LuaDebugPauseMode.Asynchronous)] + [TestCase(LuaDebugPauseMode.Synchronous)] + public async Task DetachCancelsAnInspectionAndReleasesTheStoppedProgram(LuaDebugPauseMode mode) + { + var (state, debugger, stops) = Create(mode); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var started = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var waiting = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + state.Environment["wait"] = new LuaFunction( + async (context, token) => + { + started.TrySetResult(true); + await waiting.Task.WaitAsync(token); + return context.Return(); + } + ); + debugger.Pause(); + var execution = Task.Run(async () => + await state.DoStringAsync("return 42", cancellationToken: timeout.Token) + ); + var stop = await stops.Reader.ReadAsync(timeout.Token); + var evaluation = debugger.EvaluateAsync(stop.Id, 1, "wait()", timeout.Token); + await started.Task.WaitAsync(timeout.Token); + debugger.Detach(); + try + { + await evaluation; + Assert.Fail(); + } + catch (OperationCanceledException) { } + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 42 })); + } + + [TestCase(0, LuaDebugPauseMode.Asynchronous)] + [TestCase(1, LuaDebugPauseMode.Asynchronous)] + [TestCase(0, LuaDebugPauseMode.Synchronous)] + [TestCase(1, LuaDebugPauseMode.Synchronous)] + public async Task StoppingDoesNotDuplicateLuaHookEvents(int interval, LuaDebugPauseMode mode) + { + static async Task Run(bool debug, int interval, LuaDebugPauseMode mode) + { + using var state = LuaState.Create(); + var events = new List(); + state.SetHook( + new LuaFunction( + (context, _) => + { + events.Add( + context.GetArgument(0) + ":" + context.GetArgument(1).ToString() + ); + return new(context.Return()); + } + ), + "l", + interval + ); + using var debugger = new LuaDebugger( + state, + new LuaDebuggerOptions { PauseMode = mode } + ); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + if (debug) + { + debugger.Attach(); + debugger.Pause(); + } + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var execution = Task.Run(() => + state + .DoStringAsync( + "local x=0\nfor i=1,3 do\n x=x+i\nend\nreturn x", + "@hooks.lua", + timeout.Token + ) + .AsTask() + ); + if (debug) + debugger.Continue((await stops.Reader.ReadAsync(timeout.Token)).Id); + await execution; + return events.ToArray(); + } + Assert.That(await Run(true, interval, mode), Is.EqualTo(await Run(false, interval, mode))); + } + + static (LuaState, LuaDebugger, Channel) Create( + LuaDebugPauseMode mode = LuaDebugPauseMode.Asynchronous + ) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + var debugger = new LuaDebugger(state, new LuaDebuggerOptions { PauseMode = mode }); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + debugger.Attach(); + return (state, debugger, stops); + } + + [Test] + public async Task BreakpointInspectionAndEditingRunWhileExecutionIsSuspended() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load( + "local x=1\nlocal t={a=7}\nx=x+2\nreturn x,t.a", + "@inspection.lua" + ); + var pending = debugger.SetBreakpoints("inspection.lua", [3]); + Assert.That(pending[0].Verified, Is.False); + debugger.RegisterSource(closure); + var execution = state.ExecuteAsync(closure, timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(3)); + Assert.That(execution.IsCompleted, Is.False); + var frames = await debugger.GetStackTraceAsync(stop.Id, timeout.Token); + var frameId = frames[0].Id; + var locals = await debugger.GetVariablesAsync( + stop.Id, + frameId, + LuaDebugScope.Locals, + timeout.Token + ); + Assert.That(locals.Single(x => x.Name == "x").Value, Is.EqualTo(new LuaValue(1))); + var table = locals.Single(x => x.Name == "t").Value; + Assert.That( + (await debugger.GetChildrenAsync(stop.Id, table, cancellationToken: timeout.Token))[ + 0 + ].Value, + Is.EqualTo(new LuaValue(7)) + ); + Assert.That( + (await debugger.EvaluateAsync(stop.Id, frameId, "x+t.a", timeout.Token)).Value, + Is.EqualTo(new LuaValue(8)) + ); + await debugger.SetVariableAsync( + stop.Id, + frameId, + LuaDebugScope.Locals, + "x", + "10", + timeout.Token + ); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 12, 7 })); + Assert.That(debugger.CurrentStop, Is.Null); + Assert.Throws(() => debugger.Continue(stop.Id)); + } + + [Test] + public async Task StepIntoOverAndOutFollowSourceLines() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load( + "local function leaf()\n local x=1\n return x+1\nend\nlocal v=leaf()\nv=v+1\nreturn v", + "@steps.lua" + ); + debugger.RegisterSource(closure); + debugger.SetBreakpoints("steps.lua", [5]); + var execution = state.ExecuteAsync(closure, timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(5)); + debugger.Continue(stop.Id, LuaDebugStep.Into); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(2)); + debugger.Continue(stop.Id, LuaDebugStep.Over); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(3)); + debugger.Continue(stop.Id, LuaDebugStep.Out); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(6)); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 3 })); + } + + [Test] + public async Task StepOverTailCallWaitsForTheOriginalFrameToReturn() + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var closure = state.Load( + "local function leaf()\n return 42\nend\nlocal function tail()\n return leaf()\nend\nlocal x=tail()\nreturn x", + "@tail.lua" + ); + debugger.RegisterSource(closure); + debugger.SetBreakpoints("tail.lua", [5]); + var execution = state.ExecuteAsync(closure, timeout.Token).AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(5)); + debugger.Continue(stop.Id, LuaDebugStep.Over); + stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(8)); + debugger.Continue(stop.Id); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { 42 })); + } + + [TestCase("ready(); while true do end")] + [TestCase("ready(); for i=1,1e15 do end")] + [TestCase("ready(); for i=1,1e15 do local n=i+i end")] + public async Task PauseInterruptsASingleLineLoopAndCancellationReleasesTheStop(string source) + { + var (state, debugger, stops) = Create(); + using var ownedState = state; + using var ownedDebugger = debugger; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var cancellation = new CancellationTokenSource(); + var started = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + state.Environment["ready"] = new LuaFunction( + (context, _) => + { + started.TrySetResult(true); + return new(context.Return()); + } + ); + var execution = Task.Run(async () => + await state.DoStringAsync(source, cancellationToken: cancellation.Token) + ); + await started.Task.WaitAsync(timeout.Token); + debugger.Pause(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Reason, Is.EqualTo("pause")); + cancellation.Cancel(); + try + { + await execution.WaitAsync(timeout.Token); + Assert.Fail("Execution should be cancelled."); + } + catch (LuaCanceledException) { } + Assert.That(debugger.CurrentStop, Is.Null); + debugger.Detach(); + Assert.That(await state.DoStringAsync("return 42"), Is.EqualTo(new LuaValue[] { 42 })); + } + + [Test] + public async Task DebuggerDoesNotReplaceLuaHooksAndCoversExistingCoroutines() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + "hooks=0; co=coroutine.create(function()\n local x=40\n return x+2\nend); debug.sethook(function() hooks=hooks+1 end,'l')", + "@coroutine.lua" + ); + using var debugger = new LuaDebugger(state); + var stops = Channel.CreateUnbounded(); + debugger.Stopped += stop => stops.Writer.TryWrite(stop); + debugger.Attach(); + debugger.SetBreakpoints("coroutine.lua", [3]); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var execution = state + .DoStringAsync("return coroutine.resume(co)", cancellationToken: timeout.Token) + .AsTask(); + var stop = await stops.Reader.ReadAsync(timeout.Token); + Assert.That(stop.Line, Is.EqualTo(3)); + debugger.Detach(); + Assert.That(await execution, Is.EqualTo(new LuaValue[] { true, 42 })); + Assert.That(state.Environment["hooks"].Read(), Is.GreaterThan(0)); + } +} diff --git a/tests/Lua.Debugger.Tests/GlobalUsings.cs b/tests/Lua.Debugger.Tests/GlobalUsings.cs new file mode 100644 index 00000000..32445676 --- /dev/null +++ b/tests/Lua.Debugger.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/tests/Lua.Debugger.Tests/Lua.Debugger.Tests.csproj b/tests/Lua.Debugger.Tests/Lua.Debugger.Tests.csproj new file mode 100644 index 00000000..bd82a515 --- /dev/null +++ b/tests/Lua.Debugger.Tests/Lua.Debugger.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0;net10.0 + enable + enable + false + true + + + + + + + + +