Skip to content

fix: 暂停状态下取消step hook,避免求值中新建的协程被误触发单步 - #359

Open
sumneko wants to merge 3 commits into
masterfrom
fix/pause-step-cancel
Open

sumneko wants to merge 3 commits into
masterfrom
fix/pause-step-cancel

Conversation

@sumneko

@sumneko sumneko commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

场景

目标 VM 主动加载调试器并监听端口(dbg:start{address=...}),关掉 autoUpdate,由 Lua 侧在合适周期手动抛 update 事件:

  1. 附加后点“暂停”,调试器停在手动 update 的调用处;
  2. 在调试控制台执行一段会 coroutine.create + coroutine.resume 的代码(协程体在真实文件里);
  3. 调试器又停在协程的第一行,调试控制台的表达式一直不返回,要按 continue 才返回;
  4. 改成“先命中一个断点,再执行同样代码”则一切正常。

影响 PUC Lua(实测 5.1/5.3/5.4/5.5),LuaJIT 不受影响。

原因

  1. CMD.stop(pause/entry)用 hookmgr.step_in() 装 hook:step_mask = MASKCALL | MASKRET | MASKLINE,且 stepL = 0(对所有线程生效)。
  2. 停在下一行后进入 event.stepstate == 'stopped' 分支直接进 runLoop,没有像用户单步那条分支那样 step_cancel(),于是停止等待期间 step hook 一直挂着。
  3. 求值时 debug_pcall 只把当前线程的 allowhook 置 0(src/luadebug/rdebug_visitor.cpp),而 lua_newthread 新建的协程会继承创建者的 hookhookmask,它自己的 allowhook 是 1。
  4. 协程第一行 → full_hook 走 step 分支(step_mask & LUA_MASKLINE && !stepL)→ event.step,此时 state 仍是 stopped → 嵌套 runLoop → 又发送一次 stopped 事件,evaluate 一直卡到用户 continue。

断点停止时 step_mask == 0,行事件走 event.bp 分支,不进 runLoop,所以正常。

修复方式

runLoop 是“进入停止状态”的唯一汇聚点,在这里取消 step hook:

 local function runLoop(reason, level)
     baseL = hookmgr.gethost()
+    hookmgr.step_cancel()
     sendToMaster 'eventStop' (reason)

没有选择在 event.step 里补 step_cancel(),因为那条路径覆盖不全:event_breakpoint(以及 event.funcbp / event.instbp / runException)会在状态判断之前就 return 并自己调用 runLoop。实测“暂停 → stepIn 踩到断点行”这条路径,只改 event.step 仍然能复现(node test/repro/dap-client.js stepbp)。

复现步骤(含测试文件)

需要 release 构建:debug 构建里 src/luadebug/util/protected_area.hcheck_recursive()#if !defined(NDEBUG))会让停止状态下的重入 rdebug.* 调用直接报 can't recursive,反而掩盖这个问题。

luamake -mode release

node test/repro/dap-client.js pause     # 复现
node test/repro/dap-client.js stepbp    # 另一条不经过 event.step 的停止路径,同样复现
node test/repro/dap-client.js entry     # stopOnEntry 同样中招
node test/repro/dap-client.js bp        # 对照组:正常
node test/repro/dap-client.js step      # 对照组:正常

set "REPRO_LUA=luajit" && node test/repro/dap-client.js pause        # 换运行时
set "REPRO_AUTOUPDATE=1" && node test/repro/dap-client.js pause      # 走调试器默认的 update hook

本次一并提交的测试文件:

  • test/repro/target.lua:目标进程。加载调试器、监听端口、关闭 autoUpdate、主循环手动抛 update,并提供调试控制台里要调用的 make_coroutine()(创建协程并立即 resume,协程体在真实文件里)。
  • test/repro/dap-client.js:假 VS Code,直接讲 DAP。流程是 attach → pause → 在停止帧上 evaluate make_coroutine(),判断是 evaluate 先返回还是又收到一次 stopped(并打印第二次停止的位置 / continue 后 evaluate 才返回)。脚本会自动把 extension/script 同步到 publish/script(等同 copy_extension),并检查 luadebug.dll 是否比 src/luadebug 旧。

副作用分析

  1. 空转代价step_cancel 内部有 if (step_mask != mask) 守卫,没挂 step 时直接 return,不会调 sethook。普通断点停止零开销,只有 pause/entry 和被中途打断的 step 会真正改一次 hook。
  2. 时序:取消放在 sendToMaster 'eventStop' 之前;worker 只在 workerThreadUpdate() 里处理命令,而那次调用在其后,所以 stackTrace / evaluate 一定发生在取消之后。
  3. 唯一的语义变化:单步被非 step 停止(断点 / 函数断点 / 指令断点 / 异常)打断时,挂起的单步会被丢弃。对 continue 无影响(CMD.run 本来就会 step_cancel);再按一次单步会重新计算 level(stepbp 里连续 3 次 stepIndebugger.lua:210target.lua:42target.lua:36,正常);语义上也合理:停止优先于挂起的单步。
  4. 残留字段无害stepL / step_current_level / step_target_level 清零后,在 step_mask == 0 时不会被读取(if (stepL == hL) { if (step_mask & LUA_MASKCALL) ... }),下一次 step_* 自己重算。
  5. 不会留下孤儿 hookupdatehookmask 只刷当前 host(非 LuaJIT 还会刷主线程);已启用 thread_open(true),任何协程 resume 都会发 THREAD 事件并被 updatehookmask(co) 刷新干净。LuaJIT 的 sethook 不镜像主线程,但这与已有的 CMD.run / 单步分支调用点行为一致,不是新增行为。
  6. autoUpdate 打开时(默认配置):暂停停止态的 hook 从 full_hook(step) 变成 idle/update hook,这正是“运行时无断点”和“断点停止”本来就会出现的状态;REPRO_AUTOUPDATE=1 实测正常。
  7. 未覆盖:多 VM(bee.thread 多 worker)场景未测(step_mask / stepL 按 hookmgr 实例隔离);restartFrame 不走 runLoop,未测。

测试结果

场景 停止来源 修复前 修复后
pause 暂停 复现 正常
entry stopOnEntry 复现 正常
stepbp 暂停后单步踩到断点 复现 正常
bp 断点 正常 正常
step 暂停后单步 正常 正常

运行时矩阵(pause / stepbp / step 各跑一遍):

runtime 修复前 修复后
lua51 / lua53 / lua54 / lua55 复现 正常
luajit 正常 正常

REPRO_AUTOUPDATE=1(走默认 update hook)下 pause / stepbp 修复后同样正常。

原始场景(游戏内实测)已通过。

Copilot AI lite review requested due to automatic review settings September 16, 2026 10:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved moderate findings affect hook cleanup and regression-test validity.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes stale debugger step hooks during stopped-state evaluation, preventing newly created coroutines from triggering unintended stops.

Changes:

  • Cancel pending step hooks centrally in runLoop.
  • Add Lua and DAP reproduction coverage for pause, entry, breakpoint, and stepping scenarios.
File summaries
File Summary Findings
test/repro/target.lua Provides the coroutine evaluation reproducer. None
test/repro/dap-client.js Automates DAP reproduction and validation. Moderate: assert breakpoint stops and fail on stale binaries (1–3 votes). Nits: use thread locations for stop reporting (1 vote each).
extension/script/backend/worker.lua Cancels pending step hooks before stop events. Moderate: refresh hooks for stepped coroutines beyond the current host (1 vote).
Review details

Suppressed comments (7)

extension/script/backend/worker.lua:701

  • This cancels the step state globally but refreshes the hook mask only for the current debug host. With stepOver/stepOut, stepL can be a different coroutine; if a breakpoint or exception stops that other coroutine, the stepped coroutine keeps its old full hook even though step_mask is zero, so it continues generating callbacks after continue. The cancellation path needs to refresh the stepped coroutine (and any other states carrying the step hook), not only the current host.
    hookmgr.step_cancel()

test/repro/dap-client.js:434

  • This branch already means that an extra stopped event arrived while evaluate was pending, so it should fail regardless of where the second stop occurred. As written, a stop at any line other than the marker makes ok true and exits successfully, masking the same blocked-evaluation regression when the reported top frame changes. Keep stopLine only for diagnostics and treat every second stop as a failure.
            ok = stopLine !== coroutineLine;

test/repro/dap-client.js:375

  • stopped events do not contain a source or line (the helper above documents that), so passing stop.body to where always logs <unknown> in this branch. Use locate(dap, stop.body.threadId) here so the repro output identifies where the breakpoint stop occurred.
            log(`[repro] 断点在 ${where(stop.body)} (reason=${stop.body.reason})`);

test/repro/dap-client.js:396

  • stopped events do not contain a source or line (the helper above documents that), so passing stop.body to where always logs <unknown> in this branch. Use locate(dap, stop.body.threadId) here so the repro output identifies where the entry stop occurred.
            log(`[repro] 入口停在 ${where(stop.body)} (reason=${stop.body.reason})`);

test/repro/dap-client.js:400

  • stopped events do not contain a source or line (the helper above documents that), so passing stop.body to where always logs <unknown> in this branch. Use locate(dap, stop.body.threadId) here so the repro output identifies where the pause stop occurred.
            log(`[repro] 暂停在 ${where(stop.body)} (reason=${stop.body.reason})`);

test/repro/dap-client.js:404

  • stopped events do not contain a source or line (the helper above documents that), so passing stop.body to where always logs <unknown> in this branch. Use locate(dap, stop.body.threadId) here so the repro output identifies where the step stop occurred.
                log(`[repro] 单步停在 ${where(stop.body)} (reason=${stop.body.reason})`);

test/repro/dap-client.js:122

  • This check only prints a warning and then runs the repro with the potentially stale binary. That allows the required release-build precondition to be violated (the description notes that a debug build masks this regression), producing a false passing result; make the stale-binary condition terminate the run instead of continuing.
    if (newer.length > 0) {
        log(`[repro] 警告:${LUADEBUG_DLL} 比这些目录里的源码旧,请 luamake -mode release 重新构建:`);
        for (const d of newer) {
            log('          ' + d);
        }
    }
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/repro/dap-client.js
Comment on lines +389 to +393
for (let i = 0; i < 10 && stop.body.reason !== 'breakpoint'; i++) {
await dap.request('stepIn', { threadId: stop.body.threadId });
stop = await dap.waitStopped(15000);
log(`[repro] stepIn(${i + 1}) 停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants