LHR.JintDebugger 0.8.3

dotnet add package LHR.JintDebugger --version 0.8.3
                    
NuGet\Install-Package LHR.JintDebugger -Version 0.8.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LHR.JintDebugger" Version="0.8.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LHR.JintDebugger" Version="0.8.3" />
                    
Directory.Packages.props
<PackageReference Include="LHR.JintDebugger" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LHR.JintDebugger --version 0.8.3
                    
#r "nuget: LHR.JintDebugger, 0.8.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LHR.JintDebugger@0.8.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LHR.JintDebugger&version=0.8.3
                    
Install as a Cake Addin
#tool nuget:?package=LHR.JintDebugger&version=0.8.3
                    
Install as a Cake Tool

LHR.JintDebugger

给嵌入 Jint 的 .NET 宿主接上 VSCode 断点调试。 引擎依然由你自己构造 —— 本库挂载到你造好的 Engine 上,不接管它的创建, 所以 Jint 的全部配置面原样可用,既有代码的 Engine 类型也不需要改。

快速上手

1. 引用

<PackageReference Include="LHR.JintDebugger" Version="0.8.*" />

2. 安装 VSCode 扩展

一次性:从 repo 里 vscode-extension/ 打包安装 (或用 scripts/reinstall-extension.ps1)。

3. 宿主代码

using Jint;
using LHR.JintDebugger;

var enableDebug = args.Contains("--debug");
var scriptPath  = "/abs/path/to/demo.js";

// 引擎完全由你构造 —— 原有的 Options 配置一字不用改,
// 只在里面打开 Jint 自带的 debug 开关。
var engine = new Engine(o =>
{
    o.Strict().LimitRecursion(64);               // 你原有的配置
    if (enableDebug) o.Debugger.Enabled = true;  // Jint 原生开关
});

// 常规 Jint 用法 —— 直接用 Engine,没有包装类
engine.SetValue("host", new HostApi());

// 引擎造好之后挂载调试器
using var dbg = engine.AttachDebugger();
// ↑ `using var` 的作用域是"到当前方法结束"(top-level 代码即 Main 结束)。
//   宿主整段执行期间 debug 一直活着;方法退出那一刻 Dispose 才触发,
//   发 terminated 事件、关掉 DAP socket、把引擎清理干净。不是启动即销毁。

// 一个调用 = 执行脚本 + 整个调试握手
engine.ExecuteFileWithDebugger(scriptPath);

// ← 到这里 Main 返回,`using var` 触发 dbg.Dispose()

就这些。跑起来的效果:

  • enableDebug=false → AttachDebugger 返回 inert 实例,完全按普通 Jint 跑; 不绑 TCP、不装钩子、不起线程,ExecuteFileWithDebugger 退化成 Evaluate。 上面的代码一行都不用改
  • enableDebug=true → 首次 execute 自动打开一个新 VSCode 窗口指向 脚本所在目录(无论哪个 execute 方法,工作区目录一律取脚本文件的父目录), 加载扩展、发起 attach,并把编辑器 tab 精确定位到当前脚本; shim 阻塞在 configurationDone 握手上,保证脚本执行前所有断点都已装好
  • 后续每次 execute shim 会再发一次 code --reuse-window --goto <path>:1 跟随当前执行的文件(切了目标就跟着切);相同目标连续调用去重不抖动

为什么开关必须写在 new Engine 里

Jint 在构造引擎时把 Options.Debugger.Enabled 快照进一个只读字段,之后再改 那个选项毫无作用——不是改不进去,是改进去了 Jint 也不再读。所以这个开关只能由你在 构造时决定,本库只负责读取它,不提供第二个开关去覆盖。好处是:永远不可能出现两个 开关打架,release 构建关调试只有一个地方要改。

JintDebuggerOptions 速查

AttachDebugger 的可选入参,每项都有可用默认值,engine.AttachDebugger() 不传就是常见用法。

成员 默认 用途
Port 9222 DAP TCP 端口。传 0 让 OS 分配,之后从 JintDebugger.Port 取回真实端口
ListenAddress IPAddress.Loopback 监听网卡。默认只在本机可见
WaitClientTimeout 60s 首次 execute 等客户端完成 configurationDone 的上限。TimeSpan.Zero = 不等
ThrowOnWaitTimeout true 等超时是抛异常,还是不带调试器继续跑
StopOnEntry true JintDebugger.StopOnEntry 的初值
DiagnosticLog null 本库自身诊断信息的接收器
VSCodeExtensionId lhr.jint-debugger 自动拉起时使用的扩展 id
CaptureGlobalsOnUncaught true 未捕获异常时是否连全局作用域一起冻结,见下面的「未捕获异常」一节

这里没有开关。 开与不开由上一节说的 o.Debugger.Enabled 单独决定。

API 速查

Engine 上的扩展方法(using LHR.JintDebugger; 之后可用):

成员 用途
AttachDebugger(options?) 挂载调试器,返回 JintDebugger。debug 未开启时返回 inert 实例(不是 null)
ExecuteFileWithDebugger(scriptPath) 读磁盘文件并执行。断点按 canonical path 匹配,VSCode 打开的就是这份
ExecuteInlineWithDebugger(code, path) 把内存代码写到你指定的 path(File.WriteAllText)再走磁盘一样的调试路径。仅在挂载了调试器时落盘——文件是给编辑器打开用的,没调试器就没人读它
PrepareFile(scriptPath) / PrepareInline(code, path) Parse-once:解析并缓存 AST,返回 PreparedScript 句柄。PrepareInline 同样只在挂载调试器时落盘。不执行脚本
ExecutePreparedWithDebugger(prepared) 用 PreparedScript 句柄执行——跳过解析,其他调试链路(launch/focus/stopOnEntry/break events)完全一样。同一个句柄可以反复用

AttachDebugger 返回的 JintDebugger 上:

成员 用途
IsActive 当前是否真的在驱动调试会话。false 有两种情况:inert(引擎构造时没开 debug),或已 Detach
Port DAP 实际绑定的端口;没有监听时为 null(inert 或已 Detach —— 此时 socket 已关,再报旧端口只会诱导别人去连一个连不上的地址)
StopOnEntry (property) 全局 stop-on-entry 行为,默认 true;运行期随时切换
WriteConsoleOutput(text, category) 把宿主 console.log 等转发到 VSCode Debug Console
Detach() 结束会话并把引擎还原干净:发 terminated、关 socket、摘掉 Jint 事件处理器、复位单步游标、清除断点。幂等
Dispose() 调用 Detach()。幂等
ClientAttached / ClientDetached (event) DAP 客户端连接 / 断开

inert 时以上成员全部无害 no-op,宿主代码不需要额外分支。

Engine 本身不是 IDisposable(Jint 如此),所以 using 的是 JintDebugger。

ExecuteFileWithDebugger vs ExecuteInlineWithDebugger

严格按脚本来源区分,避免 API 语义歧义:

ExecuteFileWithDebugger(string scriptPath)

  • 内部走标准 .NET File IO:相对路径按 CWD 解析,文件不存在抛 FileNotFoundException
  • Debugger 用 canonical path 匹配断点,VSCode 打开的就是这份磁盘文件

ExecuteInlineWithDebugger(string code, string path)

  • path 必填,无默认值。就是一个磁盘文件路径,你决定放哪、叫什么、带不带 .js 后缀
  • 挂载了调试器时:内部一句 File.WriteAllText(path, code)——路径不合法、父目录 不存在、写权限不足 → 由 BCL 直接抛(DirectoryNotFoundException / UnauthorizedAccessException 等)
  • 没挂调试器时不落盘,path 只当作传给 Jint 的 source 标签。落盘的唯一目的是 给编辑器一个能打开、能下断点的文件;没有调试器就没有读者,而一次同步写盘比跑一段 小脚本贵几个数量级(实测约 200µs vs 0.5µs),高频调用下它就是主要开销
  • 同 path 总是同文件:内容变了覆盖,VSCode 编辑器一直是同一 tab,其 file watcher 会看到新内容
  • 想让不同代码看起来像"不同文件"(避免旧断点残留) → 传不同的 path
  • 生命周期归你:Detach 时 shim 不会去删你写出的文件,也不会做启动清扫

关闭调试 = 裸 Jint

没挂调试器时,上面这些方法退化成它们包装的那个 Jint 调用本身,不落盘、不做路径 规范化、不记账:

方法 无调试器时等价于
ExecuteFileWithDebugger(p) engine.Evaluate(File.ReadAllText(p), source: p)
ExecuteInlineWithDebugger(c, p) engine.Evaluate(c, source: p)
ExecutePreparedWithDebugger(h) engine.Evaluate(h)

保留的只有参数校验(几 ns,换来的是 null 报错里出现我们的参数名而不是 BCL 内部的), 以及一次引擎→会话的查表(约 7ns,分派机制本身)。实测 ExecutePreparedWithDebugger 相对裸 Evaluate 的开销约 24ns/次——热循环里重复执行同一段脚本正是这个方法的用途, 所以这条路径被刻意压到最短。

一个可见的后果:无调试器时脚本看到的 source 标签是你传进来的原样路径(和裸 Jint 一致);挂了调试器则是绝对路径,因为断点匹配和编辑器定位都要靠它找到文件。传相对路径 时两种模式下 Location.SourceFile 的拼写会不同。规范化的消费者只有调试器,没有调试器 就没必要付这个钱。

StopOnEntry 是 JintDebugger 上的属性(默认 true),纯二值语义 对齐 C# / Node.js VSCode 调试器:true 每次 execute 都停在第一句;false 从不在入口停。断点和 debugger; 语句无论哪种设置都正常触发。想一开始就 关掉入口停留,用 engine.AttachDebugger(new JintDebuggerOptions { StopOnEntry = false })。

使用范式

console.log 转发

JsValue MakeConsoleFn(string level) => new ClrFunction(engine, level, (_, jsArgs) =>
{
    var text = string.Join(" ", jsArgs.Select(v => v?.ToString() ?? "undefined"));
    dbg.WriteConsoleOutput(text + "\n",
        category: level == "error" ? "stderr" : "stdout");
    return JsValue.Undefined;
});

engine.SetValue("console", JsObject.CreateFromEntries(engine, new[]
{
    new KeyValuePair<string, JsValue>("log",   MakeConsoleFn("log")),
    new KeyValuePair<string, JsValue>("warn",  MakeConsoleFn("warn")),
    new KeyValuePair<string, JsValue>("error", MakeConsoleFn("error")),
}));

Ctrl+C 干净退出

Console.CancelKeyPress += (_, _) => dbg.Dispose();   // 触发 terminated

using 只在正常作用域退出时释放,Ctrl+C 需要手动兜底。

多次 execute(同一 engine 反复调,可动态切换调试目标)

dbg.StopOnEntry = true;      // 全局:每次 execute 都暂停第一句(默认已是 true)

// 第一次:磁盘文件
engine.ExecuteFileWithDebugger(a);

// 第二次:同一个会话换一个文件
engine.ExecuteFileWithDebugger(b);

// 第三次:内存脚本(你自选路径落盘)
engine.ExecuteInlineWithDebugger("console.log('hi')", Path.Combine(scriptsDir, "snippet.js"));
// 全部跑完,using 走 Dispose

每次 execute 走完整个周期(注册源、若首次则 auto-launch + 等 client、 stopOnEntry、软结束),但会话保持——断点跨 execute 持久。

Parse-once, execute-many(PrepareFile / PrepareInline + ExecutePreparedWithDebugger)

同一段脚本要跑很多次时,先 Prepare 拿到 PreparedScript,再反复 ExecutePreparedWithDebugger——解析只做一次,AST 缓存在句柄里;调试链路每次照常走。

省下来的不只是解析:Jint 在 PrepareScript 阶段会把常量折叠、变量提升作用域、 块级作用域布局、标识符绑定解析的结果全部预先算好,挂在 AST 节点上(解析本身只占 PrepareScript 总成本的一半左右)。这些活 ExecuteFileWithDebugger / ExecuteInlineWithDebugger 每次 都要重做。收益取决于「解析准备成本 ÷ 单次总成本」这个比值——源码大、执行轻的脚本 (一堆函数定义 + 少量调用)能省 60~85%,源码小但跑长循环的脚本几乎省不到。

using var dbg = engine.AttachDebugger(new JintDebuggerOptions { StopOnEntry = false });

var prepared = engine.PrepareFile(scriptPath);          // 或 PrepareInline(code, path)
for (int i = 0; i < 100; i++)
{
    engine.SetValue("iteration", i);
    engine.ExecutePreparedWithDebugger(prepared);
}

顶层 let / const / class 声明在 Jint 里跨 execute 是不会自动重置 的(同一个 lexical environment),所以循环里第二次执行就会抛 Identifier 'x' has already been declared。解法是用 Jint 的 snapshot/restore 隔离:

var snapshot = engine.Advanced.CaptureGlobalSnapshot();
for (int i = 0; i < 100; i++)
    engine.Advanced.WithRestoredGlobals(snapshot,
        () => engine.ExecutePreparedWithDebugger(prepared));

PreparedScript 句柄不绑定准备它的引擎:同一个句柄可以喂给任意多个 Engine、在任意多个线程上执行。它装的东西都与引擎无关——Jint 官方文档把 Prepared<Script> 明确定义为可复用且线程安全,并称其为「在引擎之间共享解析成果的 受支持方式」,句柄里另外两个字段只是不可变字符串。不能跨引擎的是 JsValue: ObjectInstance 硬引用创建它的 engine 和 realm。

注意这和「引擎本身线程安全」是两回事——Jint 的 Engine 不是线程安全的。 可以共享句柄,不能共享引擎;每个线程用自己的 Engine(若同时开启调试, 记得给每个实例分配不同的 Port)。

具体示例见 samples/Sample.PrepareFile 和 samples/Sample.PrepareInline。

按需挂载:长驻宿主只在需要时才带调试器

引擎归你所有、比调试会话活得久,所以调试器可以挂上、摘掉、再挂上。长驻服务因此 不必为"万一有人要调试"常年占着一个端口和一组 Jint 钩子:

// 服务启动时构造一次,之后一直是它
var engine = new Engine(o => o.Debugger.Enabled = enableDebug);
engine.SetValue("bus", bus);

// …正常跑,没有调试器…
engine.ExecuteFileWithDebugger(path);      // 没挂载时就是一次普通 Evaluate

// 开发者请求调试时才挂上
var dbg = engine.AttachDebugger(new JintDebuggerOptions { Port = 0 });
engine.ExecuteFileWithDebugger(path);      // 现在走完整调试链

// 用完摘掉,引擎继续干活
dbg.Detach();
engine.ExecuteFileWithDebugger(path);      // 又退回普通 Evaluate

Detach 会把我们对引擎做过的手脚全部还原(事件处理器、单步游标、断点),所以之后 这台引擎和从没挂载过一样干净——也正因如此才能重新挂载。Detach 与 Dispose 等价且幂等,任意顺序调多少次都安全。

注意 o.Debugger.Enabled 仍然必须在构造时就为 true,否则之后无论怎么挂载都只会 得到 inert 实例(Jint 把这个开关快照在只读字段里)。想要"运行期才决定要不要调试", 就让这个开关在构造时保持开启,靠挂载/摘除来控制实际代价。

完整示例见 samples/Sample.Lifecycle(不依赖 VSCode,可直接跑完)。

断点落点(0.8.2 起)

Jint 按 (行, 列) 精确匹配断点,且只在语句边界检查,所以断点只能落在语句起点上。 在编辑器里点击 gutter 时,落在注释、空行、} 或某条语句的续行上都很常见——这些位置 没有任何东西可绑定。

断点位置由解析后的语法树决定,落不上时自动吸附到最近的可停位置,并把修正后的行号回报 给 VSCode(红点会自己挪过去):

点击的行 结果
有语句 就地停,列取 AST 精确值(不受缩进影响)
语句的续行(如换行表达式的后半段) 上归到该语句起点——那才是你想停的那条语句
注释 / 空行 / } 下移到下一条语句(上归会落到已经执行完的语句上)
最后一条语句之后 verified: false,附说明
源有语法错误 verified: false,附说明——脚本本来就跑不起来

红点是承诺:报了 verified 就一定会停。反过来,在宿主执行到该源之前设的断点会报 verified: false(编辑器画空心标记),等脚本真正跑起来、拿到源文本后自动绑定,不需要 重设。

未捕获异常(0.7.0 起)

脚本抛出且没人接住时,调试器会做两件事:

  1. 永远上报——错误消息和完整 JS 调用栈打到 Debug Console(stderr 分类), execute 的结束分隔线也会标明本次失败。宿主 interop 抛的 CLR 异常同样上报, 那是 Jint 的异常事件完全看不见的盲区。
  2. 在 VSCode 里停下来——发 stopped(reason: "exception"),调用栈、局部变量、 闭包变量、this 全部可查,对象可以任意展开。点 Continue 后异常照常抛给宿主, 宿主看到的行为和以前完全一样。

对应 VSCode「BREAKPOINTS」面板里的 Uncaught Exceptions 复选框,默认勾选。

try
{
    engine.ExecuteFileWithDebugger(scriptPath);
}
catch (Jint.Runtime.JavaScriptException ex)
{
    // 走到这里时 VSCode 那边已经停过、你已经看完现场并点了 Continue
    Console.WriteLine($"script failed: {ex.Message}");
}

为什么只有 "Uncaught" 一个过滤器,没有 "All Exceptions"? 因为在 JS 里 try/catch 常被当控制流用(特性探测、JSON.parse 试探),而 Jint 在抛出的那一刻 无法知道这个异常会不会被接住——Jint 的 JS 异常就是 CLR 异常,处理器链是 CLR 调用栈, 不是它自己的数据结构。与其猜,不如等:现场在抛出时记录,是否未捕获等到它真的从 execute 逃出来才判定,此时那已经是既成事实。所以这个过滤器零误报、零噪音,可以放心 默认开着。

你看到的变量是抛出那一刻的快照。 Jint 在弹栈时会回收帧的环境记录,抛出帧的局部变量 在异常到达宿主时已经被清空,所以现场必须在抛出的瞬间抄下来。抄的是对象引用不是字符串, 因此展开对象、深入嵌套结构都照常可用。

两条由此而来的限制:

  • Watch / Hover / Debug Console 求值不可用——栈已经展开,没有活的执行上下文能跑表达式。 求值请求会返回明确的说明而不是一个看起来像 bug 的错误。
  • 单步没有意义——任何 resume 都等于 Continue,异常继续往宿主抛。

captureGlobalsOnUncaught(默认 true) 控制要不要连全局作用域一起冻结。 崩溃现场不可重现:如果你的全局是接着活宿主状态的访问器属性(比如 Sample.Signals 那样的总线信号),它在你读调用栈的这几十秒里一直在变,你要的是炸的那一刻的值。 代价是崩溃时会把每个全局 getter 调一遍。

普通断点暂停从不这么做——断点可以重来,不值得为它污染宿主。所以日常调试不会给 你的总线制造任何额外读取。如果你的全局 getter 有破坏性副作用(从队列弹出、推进游标), 设 captureGlobalsOnUncaught: false,全局改为用户点开时才读。

生成 .d.ts 让脚本获得 IntelliSense

配套 DtsGenerator 会把 CLR 类型翻成 TypeScript 声明:

new DtsGenerator()
    .AddGlobal("host", typeof(HostApi))
    .WriteTo(Path.Combine(scriptsDir, "host.d.ts"));

把生成的 .d.ts 放脚本旁边,VSCode 内置 TS 服务自动识别。

可空性(0.6.0 起):属性、方法参数、方法返回值和委托成员上的可空引用类型会翻成 T | undefined(JS 侧调用方传 undefined 或直接省略实参);可空值类型 T? 仍是 T | null。没开 NRT 的程序集读出来是 Unknown,一律当非空处理,输出和以前一致。顶层 AddGlobal(name, type) 只有一个裸 Type、没有成员上下文可读,因此永远按非空输出 —— 注册非空类型即可。

Generate() / WriteTo() 可重复调用,输出稳定;两次之间再 AddGlobal 也只是往上加。

已知限制:类型名取的是 CLR 简单名,所以自定义泛型(Box<int>)和跨命名空间的同名类型(Can.Message / Lin.Message)目前无法正确表达,且不会报错。宿主 API 避开这两种形状即可。

生命周期

new Engine(o => o.Debugger.Enabled = true)     ← 引擎由宿主构造,我们不参与

engine.AttachDebugger(options)
   │
   ├── 校验 Jint 内部成员是否可用(不可用 → JintDebuggerIncompatibleException)
   ├── 读 Engine._isDebugMode 判断宿主开没开 debug
   ├── 把 `debugger;` 从 Ignore 提升为 Script(仅当宿主没显式设过)
   └── 启 DAP TCP 监听(127.0.0.1:Port)

engine.ExecuteFileWithDebugger(path) / ExecuteInlineWithDebugger(code, path)  ← 首次:
   │  ├── ExecuteInlineWithDebugger 先 File.WriteAllText(path, code)
   │  ├── 注册源(canonical path + 缓存文本供列推断)
   │  ├── code --new-window <脚本所在目录>
   │  ├── 向 lhr.jint-debugger 派发 vscode://attach?port=…&workspace=…
   │  ├── 阻塞等 configurationDone(WaitClientTimeout,默认 60s)
   │  └── 执行脚本 + 软结束(面板分隔线,会话保持)
   │        断点在此生效

… 更多 execute(复用已开 session,不再 launch/wait)…

dbg.Detach() / dbg.Dispose()   ← 结束会话 + 把引擎还原干净
   │  ├── terminated 事件 + 关 socket
   │  └── 摘掉 Jint 事件处理器、复位单步游标、清除断点
   └── 之后 engine 是一台干净的普通 Jint 引擎,可继续用,也可重新 AttachDebugger

引擎构造时没开 debug 时,AttachDebugger 返回 inert 实例,DAP/auto-launch/wait 全部跳过,execute 直接透传。

引擎比调试器活得久。 这是挂载模型和旧包装类最大的差别:Detach 必须把我们对引擎 做过的手脚全部还原,否则宿主拿回去的是一台「能用但行为诡异」的引擎——事件处理器还挂着 (每条语句白跑一遍)、单步游标可能卡在 armed(逐语句触发 Step)、客户端设过的断点还在。 所以 detach 后可以放心继续用这台引擎,也可以在需要时重新挂载:长驻宿主可以只在开发者 真的要调试时才付出代价。

常见问答

Q:能不能先按 F5 再跑宿主? 可以。waitClientTimeout: TimeSpan.Zero 表示不等待,用户先 F5 一个 type: "jint" 的 attach 配置,之后 execute 时再连上;或干脆让首次 execute 自己 auto-launch。

Q:端口冲突? Port = 0 让 OS 随机分配,然后读 dbg.Port 拿回实际端口。 auto-launch 路径不需要你操心——派发给 VSCode 的 URI 里带着真实端口。

Q:脚本能用 import / export 吗? 不能。脚本按 Jint 的 Script 而非 Module 求值,含 import 的文件会抛 Cannot use import statement outside a module。这是刻意的范围取舍:本库面向 「嵌在宿主里跑一小段脚本」,典型形态是单文件 + 宿主注入的全局 API (见 Sample.Signals)。需要复用时把公共逻辑放进宿主侧的全局对象, 而不是脚本之间互相 import。

多个独立脚本文件没问题——同一个 engine 反复 ExecuteFileWithDebugger 不同文件即可, 全局在它们之间共享,断点按 canonical path 各自隔离。

Q:ExecuteFileWithDebugger 支持相对路径吗? 支持——直接透传给 .NET File IO,行为跟 File.ReadAllText 一致(按 CWD 解析)。 但传绝对路径更稳妥:DAP 客户端拿到的 canonical path 会用于 VSCode 打开文件, 相对路径在客户端解析会跟宿主 CWD 不一致时踩坑。

Q:递归深度 / 内存 / 超时怎么设? 用 Jint 自己的 Options:o.LimitRecursion(64)、o.LimitMemory(bytes)、 o.TimeoutInterval(ts)。本库不再代理这些参数——引擎是你构造的,Jint 的全部配置面 都直接可用,不再被我们的参数列表截断。

Q:AttachDebugger 抛 JintDebuggerIncompatibleException 怎么办? 说明当前加载的 Jint 版本超出了本库适配范围(异常消息里带着实际版本号)。 本库靠反射读取 Jint 的几个内部成员来判断 debug 是否开启、注入 debugger; 处理方式、 以及驱动 stop-on-entry。这类失败只可能来自 Jint 内部结构变动,所以在挂载期一次性 响亮失败,而不是静默降级成「调试器装上了但不工作」。处理方式:把 Jint 固定到 4.16.x, 或升级本库。宿主若宁可没有调试器也不要启动失败,可以精确 catch 这个类型。

包依赖已经写成区间 [4.16.0,4.17.0),所以正常情况下碰不到这个异常:不兼容的 Jint 会在还原阶段就报冲突,而不是等到运行时。区间是刻意的——裸写 4.16.0 在 NuGet 语义里是「>= 4.16.0」,依赖树里若有更高版本会被浮上去,而那些版本本库 没有验证过。

Q:脚本里的 debugger; 不生效? Jint 默认忽略 debugger; 语句。AttachDebugger 会在挂载时把它提升为真正的断点, 所以正常情况下能停。如果你在构造引擎时显式设过 o.Debugger.StatementHandling = DebuggerStatementHandling.Clr,我们不会覆盖你的选择。

Q:多个宿主进程并存? 每个进程给不同 Port;VSCode 每个 attach 会话对应一个端口。

从 0.7.x 迁移

0.8.0 删除了 DebugEngine。改动集中在两处,Engine 类型不变,所以既有的辅助方法 签名、DI 注册、扩展方法都不用动。

// 0.7.x
using var engine = DebugEngine.Create(enableDebug, recursionLimit: 64, stopOnEntry: false);
engine.JintEngine.SetValue("host", api);
engine.ExecuteFile(path);

// 0.8.0
var engine = new Engine(o =>
{
    o.LimitRecursion(64);                        // Jint 原生,不再经我们代理
    if (enableDebug) o.Debugger.Enabled = true;
});
engine.SetValue("host", api);                    // 没有 JintEngine 这一层了
using var dbg = engine.AttachDebugger(new JintDebuggerOptions { StopOnEntry = false });
engine.ExecuteFileWithDebugger(path);

对照表:

0.7.x 0.8.0
DebugEngine.Create(enableDebug: true) o.Debugger.Enabled = true + engine.AttachDebugger()
DebugEngine.Create(enableDebug: false) 什么都不做(或照常 AttachDebugger(),得到 inert 实例)
engine.JintEngine.X engine.X
engine.ExecuteFile / ExecuteInline / ExecutePrepared 同名 + WithDebugger 后缀
engine.PrepareFile / PrepareInline 不变(Engine 上的扩展方法)
recursionLimit: / memoryLimit: / scriptTimeout: Jint 的 o.LimitRecursion / o.LimitMemory / o.TimeoutInterval
port: / waitClientTimeout: / stopOnEntry: 等 JintDebuggerOptions 上的同名属性
engine.Dispose() dbg.Dispose()(Engine 本身不是 IDisposable)
engine.WriteConsoleOutput(...) dbg.WriteConsoleOutput(...)
engine.Port / engine.StopOnEntry dbg.Port / dbg.StopOnEntry

一个行为差异值得注意:0.7.x 会在构造引擎时替你把 debugger; 语句从 Ignore 提升为 Script。0.8.0 改在挂载时做同样的事,效果一致,但如果你在自己的 Options 里显式设了 StatementHandling,我们会尊重你的选择而不再覆盖。

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • Jint (>= 4.16.0 && < 4.17.0)

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.3 110 8/20/2026
0.8.2 89 8/20/2026
0.8.1 101 8/19/2026
0.8.0 99 8/19/2026
0.6.0 109 8/12/2026
0.5.1 113 8/4/2026
0.3.6 111 8/4/2026
0.3.1 100 8/3/2026
0.3.0 109 8/3/2026
0.2.0 108 8/3/2026
0.1.0 115 7/31/2026

0.8.3: Jint moves to 4.16.0, and the dependency gains an upper bound. The reason to upgrade is Options.Constraints.StackOverflowGuard, which converts unbounded recursion from a process kill into a catchable RangeError across eighteen recursion routes -- for a library whose whole purpose is running scripts inside someone else's host, a script that can take the process down is a sharper problem than one that merely throws. 4.16.0 also brings proper tail calls in strict mode, async module loading, and several iterator helpers; none of that is used here. The upgrade was not obviously safe: the release notes state that "post-construction mutation of Options instances no longer affects already-built engines", which describes exactly what this library does to promote `debugger;` statements at attach time. It still works -- what 4.16.0 froze is the configuration the engine reads during construction, while Options.Debugger.StatementHandling is still consulted each time execution reaches a `debugger;` statement. That was verified rather than assumed, along with the other two reflection dependencies (Engine._isDebugMode still reflects the construction-time flag, DebugHandler._steppingDepth is still a writable Int32), and the full suite passes unchanged on 4.16.0. The near miss is the point of the second change: the PackageReference is now the range [4.16.0,4.17.0) instead of a bare version. NuGet reads a bare "4.16.0" as ">= 4.16.0", so a transitive dependency could float the engine onto a Jint this library has never been checked against -- and the one dependency that cannot be checked at attach time is precisely the one that fails silently, with `debugger;` quietly reverting to being ignored. The range makes that a restore-time conflict instead of a runtime surprise. JintDebuggerIncompatibleException now names 4.16.x in its remediation advice, and the XML docs record both versions the reflection has been verified against.

0.8.2: a breakpoint's `verified` flag now means something. Every breakpoint used to be reported verified, but whether one can actually fire depends on hitting an exact column, and Jint only tests that match at statement boundaries — so the column was inferred from the source text ("first non-whitespace character on the line"). That inference is right for ordinary statement lines and has no way to answer the question that matters: does this line contain a statement at all? Setting a breakpoint on each line of a nine-line script, four never fired and all four reported verified — a comment, a blank line, a closing brace, and the continuation line of a wrapped expression. The continuation line shows why text cannot decide this: it has content at a column the AST really does mention, as a sub-expression, and Jint never pauses on sub-expressions. VSCode draws a solid red marker on the strength of `verified`, so the user sees a confirmed breakpoint that never stops and concludes the code did not run. Breakpoint positions now come from the parsed script: the source is parsed once when registered (only with a debugger attached, cached per source, off the ExecutePreparedWithDebugger hot path) and breakpoints resolve against its statement starts. A line holding a statement binds there with the AST's exact column; a line inside an earlier statement's span snaps back to where that statement begins (that is the stop the user pointed at); a line merely sitting between statements — comment, blank, closing brace — snaps forward to the next one, since snapping back would land on a statement that has already run; a line past the last statement, or a source that does not parse, is refused with a reason instead of being reported verified. The corrected line travels back in the setBreakpoints response, which DAP allows precisely for this, and the editor moves its marker onto the line that will really stop. Also fixes a pre-existing defect on the other side of the same code: a breakpoint set before its source existed (a client configuring breakpoints at startup, or ExecuteInline before it writes its file) was registered at column 0 — correct only for an unindented statement — and likewise reported verified. Those now report unverified, so the editor draws a hollow marker, and are rebound to real statement positions once the source arrives, keeping their adapter-assigned ids; rewriting a source re-parses and rebinds too. `InferStatementStartColumn` is gone: both of its fall-backs returned column 0, which is exactly the never-matching registration this release removes. Tests 149 to 165, including fifteen covering each shape (comment, blank, continuation, closing brace, past-EOF, unparseable, indented column, deferred binding, deferred binding with nowhere to land, re-parse) — eight of which fail against the previous implementation — and one over a real DAP socket asserting that clicking a closing brace yields both a moved marker and an actual stop there.

0.8.1 (behavioural change): with no debugger attached, the execute methods now reduce to the plain Jint call they wrap. 0.8.0 already bound no port and installed no hooks when debugging was off, but every execute still did two things that only serve a debug client — and both sat on the hottest path. Inline execution wrote the script to disk unconditionally (~200µs, against ~0.5µs to actually run a small script), and every entry point resolved its path to absolute via Path.GetFullPath (~174ns out of a ~470ns prepared execute). The file exists so an editor can open it and set breakpoints; the absolute path exists so breakpoint matching and editor focus can locate it. With no debugger there is no reader for either. Undebugged, ExecuteFileWithDebugger(p) is now exactly Evaluate(File.ReadAllText(p), source: p), ExecuteInlineWithDebugger(c, p) is Evaluate(c, source: p), and ExecutePreparedWithDebugger(h) is Evaluate(h) — overhead on the prepared path drops from 208ns to 24ns (+44% to +5%). Argument validation and the one engine-to-session lookup stay, at a few ns each. Two visible consequences: inline execution and PrepareInline no longer create a file when debugging is off, and the `source` label a script sees is the path you passed verbatim (matching raw Jint) rather than an absolute one, so relative paths are spelled differently in the two modes.

0.8.0 (BREAKING): the library no longer constructs your engine — it attaches to one you built. `DebugEngine.Create` was the only way in, which meant it was also the only way to configure Jint: it exposed 4 options out of Jint's several dozen, so an existing `new Engine(o => o.Strict().AllowClr(...).Modules.RegisterModule(...))` could not be carried over, and every new option meant another parameter on an already 8-parameter method. It also hid `Engine` behind a `JintEngine` property, which forced `Engine`-typed helpers throughout a codebase to change signature. `DebugEngine` is removed; build the engine as you always have, add Jint's own `o.Debugger.Enabled = true`, then `using var dbg = engine.AttachDebugger();` and call `engine.ExecuteFileWithDebugger(path)` (also `ExecuteInlineWithDebugger`, `ExecutePreparedWithDebugger`, `PrepareFile`, `PrepareInline`) — all extension methods on `Engine`, so the type never changes. The `WithDebugger` suffix separates them in IntelliSense from Jint's own `Execute`/`Evaluate`, which behave very differently (these can open an editor and block for a debug client). Debugging on/off is Jint's flag alone, read rather than duplicated: Jint snapshots `Options.Debugger.Enabled` at construction and never re-reads it, so `JintDebuggerOptions` deliberately has no `Enabled` field and two settings can never disagree. With debugging off, `AttachDebugger` returns an inert object rather than null — no port, no hooks, no threads — so call sites read identically in debug and release builds and the flag appears exactly once, at construction. New `Detach()` (called by `Dispose`, idempotent) hands the engine back clean: the engine now outlives the session, so leaving Jint event handlers subscribed (firing on every statement, and pinning the whole DAP object graph against GC), the stepping cursor armed, or client breakpoints installed would return something that runs but misbehaves. After detaching the engine is fully usable and can be attached again, so a long-lived host can pay for a debugger only while someone is debugging. `JintDebuggerOptions` is now public and replaces the parameter list; Jint-side limits go through Jint's own `LimitRecursion`/`LimitMemory`/`TimeoutInterval`. All reflection into Jint internals is verified once at attach time and throws `JintDebuggerIncompatibleException` (naming the loaded Jint version) instead of degrading silently — previously a missing stop-on-entry field only logged a line and quietly stopped working. `debugger;` statements still pause: that promotion moved from construction to attach, which works because Jint does not snapshot `StatementHandling` the way it does `Enabled`; an explicit host choice is left alone. A failed attach (a taken port, typically) no longer leaves anything stranded: the session unwinds its own Jint event subscriptions and disposes the half-built DAP server, and `TcpListener`'s eagerly-allocated socket — held even when the bind fails — is now released explicitly rather than left to a finalizer. The engine is untouched and a retry on another port works. Normal shutdown also reclaims the listening socket now: DisposeAsync only called Stop(), which breaks the accept but leaves the handle to the finalizer — it is disposed after the accept loop unwinds (that order matters; disposing first would hand the still-running loop a disposed socket). `IsActive` and `Port` report the present, not construction time: after `Detach` they read false and null, since the session is gone and the socket is closed (reporting the old port would invite a connection that cannot succeed). Tests 106 → 144, covering detach residue (asserted on Jint's actual subscriber counts and stepping value), idempotent and concurrent teardown, re-attach without handler accumulation, failed-attach rollback, per-engine isolation under parallel attach, host-set breakpoints surviving detach, and canonical source labels across all three modes.

0.7.0: uncaught script exceptions are no longer silent. A script that throws now reports the error and its JS stack to the Debug Console, and pauses in VSCode on a `stopped(reason: "exception")` with the call stack, locals, closures and `this` from the moment of the throw — objects stay expandable. Continue rethrows to the host, so host behaviour is unchanged. Host-side CLR exceptions out of interop are reported too (Jint's exception event never sees those); OperationCanceledException is passed through untouched as control flow. Only an "Uncaught Exceptions" filter is offered, on by default: whether an exception is caught cannot be known at throw time, so the scene is recorded then and the decision waits until the exception has actually escaped the execute — which makes the filter exact rather than a guess. Values are a snapshot because Jint recycles a frame's environment as it pops; watch/hover/REPL are therefore refused during such a pause, with an explanation. New `DebugEngine.Port` reports the actually bound port (useful with `port: 0`). New `Create(captureGlobalsOnUncaught:)`, default true, freezes the global scope on a crash so globals wired to live host state show their crash-time value; breakpoint pauses never read the global scope, so ordinary debugging cannot fire host accessors. Test suite grown from 58 to 106, including first-ever coverage of the pause handshake over a real DAP socket.

0.6.1 (docs only, no behaviour change): corrects the `PreparedScript` sharing contract. The XML docs claimed a prepared handle was tied to the engine that created it and that cross-engine use was undefined. That is wrong: Jint documents `Prepared<Script>` as reusable and thread-safe and names it the supported way to share parse work between engines, and the handle's other fields are immutable strings — so one handle may be fed to any number of DebugEngine instances on any number of threads. (The engines themselves remain non-thread-safe; `JsValue` is what must not cross engines.) README also documents what PrepareScript actually amortizes beyond parsing.

0.6.0: DtsGenerator now reads reference-type nullability via NullabilityInfoContext and emits `T | undefined` for nullable properties, method parameters, method returns and delegate members (value-type `T?` keeps emitting `T | null`; types compiled without NRT are unchanged). FIX: Generate() and WriteTo() are now idempotent — emission state used to live on the generator, so a second call emitted globals referencing interfaces it never declared. Internal: no dispose race on the client-ready gate during teardown, no leaked process handles from the VSCode launcher.