From 86a84eda6e2fc4741ab496cef4f433d4d162701a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=80=E8=90=8C=E5=B0=8F=E6=B1=90?= Date: Wed, 16 Sep 2026 18:22:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E5=81=9C=E6=AD=A2=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8B=E5=8F=96=E6=B6=88step=20hook=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=B1=82=E5=80=BC=E4=B8=AD=E6=96=B0=E5=BB=BA=E7=9A=84?= =?UTF-8?q?=E5=8D=8F=E7=A8=8B=E8=A2=AB=E8=AF=AF=E8=A7=A6=E5=8F=91=E5=8D=95?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/script/backend/worker.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/script/backend/worker.lua b/extension/script/backend/worker.lua index ff7f45c2..b2fed04d 100644 --- a/extension/script/backend/worker.lua +++ b/extension/script/backend/worker.lua @@ -698,6 +698,7 @@ end local function runLoop(reason, level) baseL = hookmgr.gethost() + hookmgr.step_cancel() sendToMaster 'eventStop' (reason) skipFrame = level or 0 workerThreadUpdate() From 4137b8326592ee30c1c7918e1feaf35af861654c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=80=E8=90=8C=E5=B0=8F=E6=B1=90?= Date: Wed, 16 Sep 2026 18:22:44 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E5=90=8E=E6=B1=82=E5=80=BC=E6=96=B0=E5=BB=BA=E5=8D=8F?= =?UTF-8?q?=E7=A8=8B=E7=9A=84=E5=A4=8D=E7=8E=B0=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/repro/dap-client.js | 468 +++++++++++++++++++++++++++++++++++++++ test/repro/target.lua | 46 ++++ 2 files changed, 514 insertions(+) create mode 100644 test/repro/dap-client.js create mode 100644 test/repro/target.lua diff --git a/test/repro/dap-client.js b/test/repro/dap-client.js new file mode 100644 index 00000000..df29046f --- /dev/null +++ b/test/repro/dap-client.js @@ -0,0 +1,468 @@ +'use strict'; + +// +// 用一个假的 DAP 客户端复现: +// 目标 VM 主动加载调试器并监听端口 -> 关闭 autoUpdate(手动 update)-> 附加 +// -> 点“暂停” -> 在调试控制台执行“创建协程并立即执行”的表达式 +// +// 用法: node test/repro/dap-client.js [pause|bp|step|stepbp|entry] +// pause : 用“暂停”进入停止状态(这个会复现问题) +// entry : 用 stopOnEntry 进入停止状态(同 pause,走的是同一条路径) +// stepbp : 暂停后单步、单步撞上断点停下(另一条不经过 event.step 的停止路径) +// bp : 用“命中断点”进入停止状态(对照组,正常) +// step : 暂停后再单步(对照组,正常) +// +// 必须用 release 构建的 publish/(luamake -mode release): +// debug 构建里 protected_area.h 的 check_recursive() 会让停止状态下 +// 重入的 rdebug.* 调用直接报 "can't recursive",反而掩盖这个 bug。 +// + +const net = require('net'); +const path = require('path'); +const fs = require('fs'); +const { spawn } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const PUBLISH = path.join(ROOT, 'publish'); +const LUA = (process.env.REPRO_LUA || 'lua54').trim(); +const RUNTIME = path.join(PUBLISH, 'runtime', 'win32-x64', LUA); +const EXTENSION = path.join(ROOT, 'extension'); +const SRC_SCRIPT = path.join(EXTENSION, 'script'); +const PUB_SCRIPT = path.join(PUBLISH, 'script'); +const TARGET_SCRIPT = path.join(__dirname, 'target.lua'); +const TARGET_EXE = path.join(RUNTIME, 'lua.exe'); +const LUADEBUG_DLL = path.join(RUNTIME, 'luadebug.dll'); +const ADAPTER_EXE = path.join(PUBLISH, 'bin', 'lua-debug.exe'); +const WORKER_LOG = path.join(PUBLISH, 'worker.log'); + +const TARGET_PORT = Number(process.env.REPRO_TARGET_PORT || 55321); +const ADAPTER_PORT = Number(process.env.REPRO_ADAPTER_PORT || 55322); +const EVAL_EXPR = 'make_coroutine()'; + +const mode = (process.argv[2] || 'pause').replace(/^--?mode=/, ''); +if (!['pause', 'bp', 'step', 'stepbp', 'entry'].includes(mode)) { + console.error('usage: node dap-client.js [pause|bp|step|stepbp|entry]'); + process.exit(2); +} + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const log = (...a) => console.log(...a); + +function fail(msg) { + console.error('FAIL: ' + msg); + process.exit(2); +} + +function checkEnv() { + for (const f of [TARGET_EXE, LUADEBUG_DLL, ADAPTER_EXE, path.join(PUB_SCRIPT, 'debugger.lua')]) { + if (!fs.existsSync(f)) { + fail(`缺少 ${f},请先 luamake -mode release 构建`); + } + } +} + +// publish/script 是 luamake 从 extension/script 拷贝出来的(copy_extension)。 +// 这里保证它是当前源码,免得测到旧脚本。 +function syncScripts() { + let copied = 0; + const walk = (rel) => { + for (const e of fs.readdirSync(path.join(SRC_SCRIPT, rel), { withFileTypes: true })) { + const r = path.join(rel, e.name); + if (e.isDirectory()) { + fs.mkdirSync(path.join(PUB_SCRIPT, r), { recursive: true }); + walk(r); + continue; + } + const src = path.join(SRC_SCRIPT, r); + const dst = path.join(PUB_SCRIPT, r); + const a = fs.readFileSync(src); + if (fs.existsSync(dst) && Buffer.compare(a, fs.readFileSync(dst)) === 0) { + continue; + } + fs.copyFileSync(src, dst); + copied++; + } + }; + walk(''); + if (copied > 0) { + log(`[repro] 已同步 ${copied} 个脚本到 publish/script(相当于 copy_extension)`); + } +} + +function newestMtime(dir, exts) { + let newest = 0; + const walk = d => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) { + walk(p); + } else if (!exts || exts.some(x => e.name.endsWith(x))) { + newest = Math.max(newest, fs.statSync(p).mtimeMs); + } + } + }; + walk(dir); + return newest; +} + +function checkBinaries() { + const dllTime = fs.statSync(LUADEBUG_DLL).mtimeMs; + const newer = []; + for (const d of [path.join(ROOT, 'src', 'luadebug'), path.join(ROOT, '3rd', 'lua'), path.join(ROOT, '3rd', 'bee.lua', 'binding')]) { + const t = newestMtime(d, ['.cpp', '.c', '.h']); + if (t > dllTime) { + newer.push(d); + } + } + if (newer.length > 0) { + log(`[repro] 警告:${LUADEBUG_DLL} 比这些目录里的源码旧,请 luamake -mode release 重新构建:`); + for (const d of newer) { + log(' ' + d); + } + } +} + +function markerLine(marker) { + const lines = fs.readFileSync(TARGET_SCRIPT, 'utf8').split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(marker)) { + return i + 1; + } + } + throw new Error('marker not found: ' + marker); +} + +function where(frame) { + if (!frame || !frame.source) { + return ''; + } + return `${frame.source.path || frame.source.name}:${frame.line}`; +} + +// 停止事件本身不带位置,位置要从 stackTrace 拿(VS Code 也是这么做的) +async function locate(dap, threadId) { + const st = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 1 }); + return st.body.stackFrames[0]; +} + +async function connect(host, port, timeout = 10000) { + const deadline = Date.now() + timeout; + for (;;) { + const socket = await new Promise(resolve => { + const s = net.connect({ host, port }); + s.once('connect', () => resolve(s)); + s.once('error', () => { s.destroy(); resolve(null); }); + }); + if (socket) { + socket.setNoDelay(true); + return socket; + } + if (Date.now() > deadline) { + throw new Error(`can not connect ${host}:${port}`); + } + await sleep(100); + } +} + +class DAP { + constructor(socket) { + this.socket = socket; + this.buffer = Buffer.alloc(0); + this.seq = 0; + this.pending = new Map(); + this.waiters = []; + this.events = []; + this.stops = []; + this.stopCursor = 0; + socket.on('data', d => { + this.buffer = Buffer.concat([this.buffer, d]); + this.parse(); + }); + } + + parse() { + for (;;) { + const idx = this.buffer.indexOf('\r\n\r\n'); + if (idx < 0) { + return; + } + const header = this.buffer.slice(0, idx).toString('latin1'); + const m = /Content-Length:\s*(\d+)/i.exec(header); + if (!m) { + this.buffer = this.buffer.slice(idx + 4); + continue; + } + const len = Number(m[1]); + if (this.buffer.length < idx + 4 + len) { + return; + } + const body = this.buffer.slice(idx + 4, idx + 4 + len).toString('utf8'); + this.buffer = this.buffer.slice(idx + 4 + len); + let msg; + try { + msg = JSON.parse(body); + } catch (e) { + continue; + } + this.dispatch(msg); + } + } + + dispatch(msg) { + if (msg.type === 'response') { + const p = this.pending.get(msg.request_seq); + if (p) { + this.pending.delete(msg.request_seq); + if (msg.success) { + p.resolve(msg); + } else { + p.reject(new Error(`${p.command} failed: ${msg.message}`)); + } + } + return; + } + if (msg.type !== 'event') { + return; + } + this.events.push(msg); + if (msg.event === 'stopped') { + this.stops.push(msg); + } + if (msg.event === 'stopped' || msg.event === 'thread' || msg.event === 'initialized' || msg.event === 'breakpoint') { + log(` <- event ${msg.event} ${JSON.stringify(msg.body || {})}`); + } + for (const w of this.waiters.slice()) { + if (w.pred(msg)) { + clearTimeout(w.timer); + this.waiters.splice(this.waiters.indexOf(w), 1); + w.resolve(msg); + } + } + } + + request(command, args, timeout = 10000) { + const seq = ++this.seq; + log(` -> ${command}`); + const pkg = { seq, type: 'request', command, arguments: args }; + const s = JSON.stringify(pkg); + this.socket.write(Buffer.from(`Content-Length: ${Buffer.byteLength(s)}\r\n\r\n${s}`, 'utf8')); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(seq); + reject(new Error(`timeout: ${command}`)); + }, timeout); + this.pending.set(seq, { + command, + resolve: v => { clearTimeout(timer); resolve(v); }, + reject: e => { clearTimeout(timer); reject(e); }, + }); + }); + } + + waitEvent(pred, timeout = 10000, fromHistory = true) { + if (fromHistory) { + const found = this.events.find(pred); + if (found) { + return Promise.resolve(found); + } + } + return new Promise((resolve, reject) => { + const w = { pred, resolve }; + w.timer = setTimeout(() => { + const i = this.waiters.indexOf(w); + if (i >= 0) { + this.waiters.splice(i, 1); + } + reject(new Error('timeout: wait event')); + }, timeout); + this.waiters.push(w); + }); + } + + waitStopped(timeout = 10000) { + // 只看还没消费过的 stopped,避免拿历史事件当新事件 + if (this.stops.length > this.stopCursor) { + return Promise.resolve(this.stops[this.stopCursor++]); + } + return new Promise((resolve, reject) => { + const w = { + pred: m => m.type === 'event' && m.event === 'stopped', + resolve: m => { this.stopCursor++; resolve(m); }, + }; + w.timer = setTimeout(() => { + const i = this.waiters.indexOf(w); + if (i >= 0) { + this.waiters.splice(i, 1); + } + reject(new Error('timeout: wait stopped')); + }, timeout); + this.waiters.push(w); + }); + } +} + +async function main() { + checkEnv(); + syncScripts(); + checkBinaries(); + const bpLine = markerLine('--@bp'); + const coroutineLine = markerLine('--@coroutine-first-line'); + log(`[repro] mode=${mode} bpLine=${bpLine} coroutineFirstLine=${coroutineLine}`); + + if (fs.existsSync(WORKER_LOG)) { + fs.unlinkSync(WORKER_LOG); + } + + const target = spawn(TARGET_EXE, [TARGET_SCRIPT, String(TARGET_PORT)], { + cwd: RUNTIME, + env: { ...process.env, LUA_DEBUG_ROOT: PUBLISH, LUA_DEBUG_CORE: LUADEBUG_DLL }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + target.stdout.on('data', d => log('[target] ' + d.toString().trimEnd())); + target.stderr.on('data', d => log('[target!] ' + d.toString().trimEnd())); + target.on('exit', (code, sig) => log(`[target] exit code=${code} signal=${sig}`)); + + const adapter = spawn(ADAPTER_EXE, [String(ADAPTER_PORT)], { + cwd: path.join(PUBLISH, 'bin'), + stdio: ['ignore', 'pipe', 'pipe'], + }); + adapter.stdout.on('data', d => log('[adapter] ' + d.toString().trimEnd())); + adapter.stderr.on('data', d => log('[adapter!] ' + d.toString().trimEnd())); + adapter.on('exit', (code, sig) => log(`[adapter] exit code=${code} signal=${sig}`)); + + let ok = false; + try { + const socket = await connect('127.0.0.1', ADAPTER_PORT); + const dap = new DAP(socket); + + await dap.request('initialize', { + adapterID: 'lua', + clientID: 'repro', + linesStartAt1: true, + columnsStartAt1: true, + pathFormat: 'path', + }); + + const attach = dap.request('attach', { + type: 'lua', + request: 'attach', + name: 'repro', + address: `127.0.0.1:${TARGET_PORT}`, + client: true, // 同 configurationProvider.js: 由调试器去 connect 目标监听的端口 + workspaceFolder: ROOT, + luaVersion: LUA, + stopOnEntry: mode === 'entry', + stopOnThreadEntry: false, + }); + const initialized = dap.waitEvent(m => m.type === 'event' && m.event === 'initialized', 15000); + await attach; + await initialized; + log('[repro] attached'); + + await dap.request('configurationDone', {}); + await dap.waitEvent(m => m.type === 'event' && m.event === 'thread', 10000); + log('[repro] target thread started'); + + let stop; + if (mode === 'bp') { + const res = await dap.request('setBreakpoints', { + source: { path: TARGET_SCRIPT }, + breakpoints: [{ line: bpLine }], + sourceModified: false, + }); + log(`[repro] breakpoint: ${JSON.stringify(res.body && res.body.breakpoints)}`); + stop = await dap.waitStopped(15000); + log(`[repro] 断点在 ${where(stop.body)} (reason=${stop.body.reason})`); + } else if (mode === 'stepbp') { + // 先暂停,再在停止状态下加断点,然后 stepIn 直到踩到断点行。 + // 这个停止由 event_breakpoint 直接 runLoop,不经过 event.step 的取消分支, + // 此时 step_in 的 stepL==0 仍然挂着。 + await dap.request('pause', { threadId: 1 }); + stop = await dap.waitStopped(15000); + log(`[repro] 暂停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); + const res = await dap.request('setBreakpoints', { + source: { path: TARGET_SCRIPT }, + breakpoints: [{ line: bpLine }], + sourceModified: false, + }); + log(`[repro] breakpoint: ${JSON.stringify(res.body && res.body.breakpoints)}`); + 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})`); + } + } else if (mode === 'entry') { + stop = await dap.waitStopped(15000); + log(`[repro] 入口停在 ${where(stop.body)} (reason=${stop.body.reason})`); + } else { + await dap.request('pause', { threadId: 1 }); + stop = await dap.waitStopped(15000); + log(`[repro] 暂停在 ${where(stop.body)} (reason=${stop.body.reason})`); + if (mode === 'step') { + await dap.request('next', { threadId: stop.body.threadId }); + stop = await dap.waitStopped(15000); + log(`[repro] 单步停在 ${where(stop.body)} (reason=${stop.body.reason})`); + } + } + + const threadId = stop.body.threadId; + const st = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 1 }); + const frameId = st.body.stackFrames[0].id; + log(`[repro] 停止位置 ${where(st.body.stackFrames[0])} frameId=${frameId}`); + + log(`[repro] 在调试控制台执行: ${EVAL_EXPR}`); + const evaluate = dap + .request('evaluate', { expression: EVAL_EXPR, frameId, context: 'repl' }, 60000) + .then(r => ({ kind: 'response', r }), e => ({ kind: 'error', e })); + + const secondStop = await Promise.race([ + dap.waitStopped(5000, false).then(m => ({ kind: 'stopped', m }), () => null), + evaluate.then(r => ({ kind: 'pending', r })), + ]); + + if (secondStop && secondStop.kind === 'stopped') { + const frames = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 3 }); + log('[repro] evaluate 还没返回,调试器却又停在:'); + for (const f of frames.body.stackFrames) { + log(` ${where(f)} ${f.name}`); + } + log('[repro] 发送 continue,看 evaluate 是否马上返回...'); + await dap.request('continue', { threadId, allThreadsContinued: true }); + const r = await evaluate; + log(`[repro] continue 之后 evaluate ${r.kind === 'response' ? '返回: ' + JSON.stringify(r.r.body.result) : '失败: ' + r.e}`); + const stopLine = (frames.body.stackFrames[0] || {}).line; + ok = stopLine !== coroutineLine; + log(''); + log('===== 结论 ====='); + log(`第二次 stopped 的位置是 target.lua:${stopLine},协程第一行是 target.lua:${coroutineLine}`); + log(ok + ? 'BUG 未复现:停止状态下执行协程没有触发额外的停止' + : 'BUG 已复现:停止状态下执行“创建协程并立即执行”,协程第一行被当成 step 停下来,调试控制台的表达式被阻塞'); + } else { + const r = await evaluate; + log(''); + log('===== 结论 ====='); + if (r.kind === 'response') { + log(`BUG 未复现:evaluate 正常返回 ${JSON.stringify(r.r.body.result)}(没有多余的停止)`); + ok = true; + } else { + log(`意外:evaluate 失败 ${r.e}`); + } + } + } finally { + target.kill(); + adapter.kill(); + await sleep(200); + } + + if (fs.existsSync(WORKER_LOG)) { + log('--- publish/worker.log ---'); + log(fs.readFileSync(WORKER_LOG, 'utf8')); + } + process.exit(ok ? 0 : 1); +} + +main().catch(e => { + console.error('ERROR: ' + (e && e.stack || e)); + process.exit(2); +}); diff --git a/test/repro/target.lua b/test/repro/target.lua new file mode 100644 index 00000000..f1c1c9df --- /dev/null +++ b/test/repro/target.lua @@ -0,0 +1,46 @@ +-- 复现场景的目标进程:VM 主动加载调试器、监听端口、关闭 autoUpdate,改为手动 update。 +-- +-- set LUA_DEBUG_ROOT=/publish +-- set LUA_DEBUG_CORE=/publish/runtime/win32-x64/lua54/luadebug.dll +-- lua.exe test/repro/target.lua + +local root = assert(os.getenv "LUA_DEBUG_ROOT", "LUA_DEBUG_ROOT is not set") +local port = assert(arg[1], "need port") + +local dbg = dofile(root .. "/script/debugger.lua") +dbg:start { + address = "127.0.0.1:" .. port, +} + +-- 关闭自动 update:调试器只在主循环手动抛 update 时才会处理 GUI 请求(新的断点/暂停等) +-- 带 REPRO_AUTOUPDATE=1 时不关,走调试器默认的 update hook 路径 +if os.getenv "REPRO_AUTOUPDATE" ~= "1" then + dbg:event("autoUpdate", false) +end + +-- 调试控制台里那句代码最终会跑到的协程体(真实文件,source 有效) +local function coroutineTask() + local acc = 0 --@coroutine-first-line + for i = 1, 3 do + acc = acc + i + end + return acc +end + +-- 调试控制台里执行 make_coroutine():创建协程并立即执行它 +function make_coroutine() + local co = coroutine.create(coroutineTask) + local ok, res = coroutine.resume(co) + return ok, res +end + +local frameCount = 0 +local function frame() + frameCount = frameCount + 1 --@bp +end + +-- 模拟游戏主循环 +while true do + dbg:event("update") + frame() +end From 031a910373972cc0a760802847c93be5dcdbaa53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=80=E8=90=8C=E5=B0=8F=E6=B1=90?= Date: Wed, 16 Sep 2026 18:36:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=E5=A4=8D=E7=8E=B0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=E5=88=A4=E5=AE=9A=E6=94=B6=E7=B4=A7=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E4=BA=8C=E8=BF=9B=E5=88=B6=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=97=B6=E7=9B=B4=E6=8E=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/repro/dap-client.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/repro/dap-client.js b/test/repro/dap-client.js index df29046f..0eb8e5b5 100644 --- a/test/repro/dap-client.js +++ b/test/repro/dap-client.js @@ -115,10 +115,9 @@ function checkBinaries() { } } if (newer.length > 0) { - log(`[repro] 警告:${LUADEBUG_DLL} 比这些目录里的源码旧,请 luamake -mode release 重新构建:`); - for (const d of newer) { - log(' ' + d); - } + fail(`${LUADEBUG_DLL} 比这些目录里的源码旧,请先 luamake -mode release 重新构建:\n ` + + newer.join('\n ') + + '\n (debug 构建会掩盖这个问题,不能用来判断复现结果)'); } } @@ -372,7 +371,7 @@ async function main() { }); log(`[repro] breakpoint: ${JSON.stringify(res.body && res.body.breakpoints)}`); stop = await dap.waitStopped(15000); - log(`[repro] 断点在 ${where(stop.body)} (reason=${stop.body.reason})`); + log(`[repro] 断点在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); } else if (mode === 'stepbp') { // 先暂停,再在停止状态下加断点,然后 stepIn 直到踩到断点行。 // 这个停止由 event_breakpoint 直接 runLoop,不经过 event.step 的取消分支, @@ -391,17 +390,20 @@ async function main() { stop = await dap.waitStopped(15000); log(`[repro] stepIn(${i + 1}) 停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); } + if (stop.body.reason !== 'breakpoint') { + fail('stepIn 10 次都没踩到断点,无法构造出"单步被断点打断"的停止状态'); + } } else if (mode === 'entry') { stop = await dap.waitStopped(15000); - log(`[repro] 入口停在 ${where(stop.body)} (reason=${stop.body.reason})`); + log(`[repro] 入口停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); } else { await dap.request('pause', { threadId: 1 }); stop = await dap.waitStopped(15000); - log(`[repro] 暂停在 ${where(stop.body)} (reason=${stop.body.reason})`); + log(`[repro] 暂停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); if (mode === 'step') { await dap.request('next', { threadId: stop.body.threadId }); stop = await dap.waitStopped(15000); - log(`[repro] 单步停在 ${where(stop.body)} (reason=${stop.body.reason})`); + log(`[repro] 单步停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); } } @@ -416,11 +418,12 @@ async function main() { .then(r => ({ kind: 'response', r }), e => ({ kind: 'error', e })); const secondStop = await Promise.race([ - dap.waitStopped(5000, false).then(m => ({ kind: 'stopped', m }), () => null), + dap.waitStopped(5000).then(m => ({ kind: 'stopped', m }), () => null), evaluate.then(r => ({ kind: 'pending', r })), ]); if (secondStop && secondStop.kind === 'stopped') { + // evaluate 还没返回就又收到 stopped:不管停在哪一行,都说明停止期间又被误触发了一次停止 const frames = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 3 }); log('[repro] evaluate 还没返回,调试器却又停在:'); for (const f of frames.body.stackFrames) { @@ -431,13 +434,10 @@ async function main() { const r = await evaluate; log(`[repro] continue 之后 evaluate ${r.kind === 'response' ? '返回: ' + JSON.stringify(r.r.body.result) : '失败: ' + r.e}`); const stopLine = (frames.body.stackFrames[0] || {}).line; - ok = stopLine !== coroutineLine; log(''); log('===== 结论 ====='); - log(`第二次 stopped 的位置是 target.lua:${stopLine},协程第一行是 target.lua:${coroutineLine}`); - log(ok - ? 'BUG 未复现:停止状态下执行协程没有触发额外的停止' - : 'BUG 已复现:停止状态下执行“创建协程并立即执行”,协程第一行被当成 step 停下来,调试控制台的表达式被阻塞'); + log('BUG 已复现:停止状态下执行“创建协程并立即执行”,evaluate 返回前又收到一次 stopped,调试控制台的表达式被阻塞'); + log(`(第二次 stopped 在 target.lua:${stopLine},协程第一行是 target.lua:${coroutineLine},仅作诊断)`); } else { const r = await evaluate; log(''); From 20f0efeb1fb85b5159b97f8afddc731efad99794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=80=E8=90=8C=E5=B0=8F=E6=B1=90?= Date: Tue, 22 Sep 2026 11:42:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E5=A4=8D=E7=8E=B0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=94=B9=E7=94=A8Lua=E5=AE=9E=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E6=8E=89Node=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入口改为 luamake lua test/repro/dap-client.lua ,跑在 luamake 自带的 Lua VM 上;复用调试器自己的 common/protocol.lua + common/json.lua;按平台推断 publish/runtime 目录和库后缀,不再硬编码 win32-x64/lua54。 --- test/repro/dap-client.js | 468 ---------------------------- test/repro/dap-client.lua | 630 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 630 insertions(+), 468 deletions(-) delete mode 100644 test/repro/dap-client.js create mode 100644 test/repro/dap-client.lua diff --git a/test/repro/dap-client.js b/test/repro/dap-client.js deleted file mode 100644 index 0eb8e5b5..00000000 --- a/test/repro/dap-client.js +++ /dev/null @@ -1,468 +0,0 @@ -'use strict'; - -// -// 用一个假的 DAP 客户端复现: -// 目标 VM 主动加载调试器并监听端口 -> 关闭 autoUpdate(手动 update)-> 附加 -// -> 点“暂停” -> 在调试控制台执行“创建协程并立即执行”的表达式 -// -// 用法: node test/repro/dap-client.js [pause|bp|step|stepbp|entry] -// pause : 用“暂停”进入停止状态(这个会复现问题) -// entry : 用 stopOnEntry 进入停止状态(同 pause,走的是同一条路径) -// stepbp : 暂停后单步、单步撞上断点停下(另一条不经过 event.step 的停止路径) -// bp : 用“命中断点”进入停止状态(对照组,正常) -// step : 暂停后再单步(对照组,正常) -// -// 必须用 release 构建的 publish/(luamake -mode release): -// debug 构建里 protected_area.h 的 check_recursive() 会让停止状态下 -// 重入的 rdebug.* 调用直接报 "can't recursive",反而掩盖这个 bug。 -// - -const net = require('net'); -const path = require('path'); -const fs = require('fs'); -const { spawn } = require('child_process'); - -const ROOT = path.resolve(__dirname, '..', '..'); -const PUBLISH = path.join(ROOT, 'publish'); -const LUA = (process.env.REPRO_LUA || 'lua54').trim(); -const RUNTIME = path.join(PUBLISH, 'runtime', 'win32-x64', LUA); -const EXTENSION = path.join(ROOT, 'extension'); -const SRC_SCRIPT = path.join(EXTENSION, 'script'); -const PUB_SCRIPT = path.join(PUBLISH, 'script'); -const TARGET_SCRIPT = path.join(__dirname, 'target.lua'); -const TARGET_EXE = path.join(RUNTIME, 'lua.exe'); -const LUADEBUG_DLL = path.join(RUNTIME, 'luadebug.dll'); -const ADAPTER_EXE = path.join(PUBLISH, 'bin', 'lua-debug.exe'); -const WORKER_LOG = path.join(PUBLISH, 'worker.log'); - -const TARGET_PORT = Number(process.env.REPRO_TARGET_PORT || 55321); -const ADAPTER_PORT = Number(process.env.REPRO_ADAPTER_PORT || 55322); -const EVAL_EXPR = 'make_coroutine()'; - -const mode = (process.argv[2] || 'pause').replace(/^--?mode=/, ''); -if (!['pause', 'bp', 'step', 'stepbp', 'entry'].includes(mode)) { - console.error('usage: node dap-client.js [pause|bp|step|stepbp|entry]'); - process.exit(2); -} - -const sleep = ms => new Promise(r => setTimeout(r, ms)); -const log = (...a) => console.log(...a); - -function fail(msg) { - console.error('FAIL: ' + msg); - process.exit(2); -} - -function checkEnv() { - for (const f of [TARGET_EXE, LUADEBUG_DLL, ADAPTER_EXE, path.join(PUB_SCRIPT, 'debugger.lua')]) { - if (!fs.existsSync(f)) { - fail(`缺少 ${f},请先 luamake -mode release 构建`); - } - } -} - -// publish/script 是 luamake 从 extension/script 拷贝出来的(copy_extension)。 -// 这里保证它是当前源码,免得测到旧脚本。 -function syncScripts() { - let copied = 0; - const walk = (rel) => { - for (const e of fs.readdirSync(path.join(SRC_SCRIPT, rel), { withFileTypes: true })) { - const r = path.join(rel, e.name); - if (e.isDirectory()) { - fs.mkdirSync(path.join(PUB_SCRIPT, r), { recursive: true }); - walk(r); - continue; - } - const src = path.join(SRC_SCRIPT, r); - const dst = path.join(PUB_SCRIPT, r); - const a = fs.readFileSync(src); - if (fs.existsSync(dst) && Buffer.compare(a, fs.readFileSync(dst)) === 0) { - continue; - } - fs.copyFileSync(src, dst); - copied++; - } - }; - walk(''); - if (copied > 0) { - log(`[repro] 已同步 ${copied} 个脚本到 publish/script(相当于 copy_extension)`); - } -} - -function newestMtime(dir, exts) { - let newest = 0; - const walk = d => { - for (const e of fs.readdirSync(d, { withFileTypes: true })) { - const p = path.join(d, e.name); - if (e.isDirectory()) { - walk(p); - } else if (!exts || exts.some(x => e.name.endsWith(x))) { - newest = Math.max(newest, fs.statSync(p).mtimeMs); - } - } - }; - walk(dir); - return newest; -} - -function checkBinaries() { - const dllTime = fs.statSync(LUADEBUG_DLL).mtimeMs; - const newer = []; - for (const d of [path.join(ROOT, 'src', 'luadebug'), path.join(ROOT, '3rd', 'lua'), path.join(ROOT, '3rd', 'bee.lua', 'binding')]) { - const t = newestMtime(d, ['.cpp', '.c', '.h']); - if (t > dllTime) { - newer.push(d); - } - } - if (newer.length > 0) { - fail(`${LUADEBUG_DLL} 比这些目录里的源码旧,请先 luamake -mode release 重新构建:\n ` - + newer.join('\n ') - + '\n (debug 构建会掩盖这个问题,不能用来判断复现结果)'); - } -} - -function markerLine(marker) { - const lines = fs.readFileSync(TARGET_SCRIPT, 'utf8').split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(marker)) { - return i + 1; - } - } - throw new Error('marker not found: ' + marker); -} - -function where(frame) { - if (!frame || !frame.source) { - return ''; - } - return `${frame.source.path || frame.source.name}:${frame.line}`; -} - -// 停止事件本身不带位置,位置要从 stackTrace 拿(VS Code 也是这么做的) -async function locate(dap, threadId) { - const st = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 1 }); - return st.body.stackFrames[0]; -} - -async function connect(host, port, timeout = 10000) { - const deadline = Date.now() + timeout; - for (;;) { - const socket = await new Promise(resolve => { - const s = net.connect({ host, port }); - s.once('connect', () => resolve(s)); - s.once('error', () => { s.destroy(); resolve(null); }); - }); - if (socket) { - socket.setNoDelay(true); - return socket; - } - if (Date.now() > deadline) { - throw new Error(`can not connect ${host}:${port}`); - } - await sleep(100); - } -} - -class DAP { - constructor(socket) { - this.socket = socket; - this.buffer = Buffer.alloc(0); - this.seq = 0; - this.pending = new Map(); - this.waiters = []; - this.events = []; - this.stops = []; - this.stopCursor = 0; - socket.on('data', d => { - this.buffer = Buffer.concat([this.buffer, d]); - this.parse(); - }); - } - - parse() { - for (;;) { - const idx = this.buffer.indexOf('\r\n\r\n'); - if (idx < 0) { - return; - } - const header = this.buffer.slice(0, idx).toString('latin1'); - const m = /Content-Length:\s*(\d+)/i.exec(header); - if (!m) { - this.buffer = this.buffer.slice(idx + 4); - continue; - } - const len = Number(m[1]); - if (this.buffer.length < idx + 4 + len) { - return; - } - const body = this.buffer.slice(idx + 4, idx + 4 + len).toString('utf8'); - this.buffer = this.buffer.slice(idx + 4 + len); - let msg; - try { - msg = JSON.parse(body); - } catch (e) { - continue; - } - this.dispatch(msg); - } - } - - dispatch(msg) { - if (msg.type === 'response') { - const p = this.pending.get(msg.request_seq); - if (p) { - this.pending.delete(msg.request_seq); - if (msg.success) { - p.resolve(msg); - } else { - p.reject(new Error(`${p.command} failed: ${msg.message}`)); - } - } - return; - } - if (msg.type !== 'event') { - return; - } - this.events.push(msg); - if (msg.event === 'stopped') { - this.stops.push(msg); - } - if (msg.event === 'stopped' || msg.event === 'thread' || msg.event === 'initialized' || msg.event === 'breakpoint') { - log(` <- event ${msg.event} ${JSON.stringify(msg.body || {})}`); - } - for (const w of this.waiters.slice()) { - if (w.pred(msg)) { - clearTimeout(w.timer); - this.waiters.splice(this.waiters.indexOf(w), 1); - w.resolve(msg); - } - } - } - - request(command, args, timeout = 10000) { - const seq = ++this.seq; - log(` -> ${command}`); - const pkg = { seq, type: 'request', command, arguments: args }; - const s = JSON.stringify(pkg); - this.socket.write(Buffer.from(`Content-Length: ${Buffer.byteLength(s)}\r\n\r\n${s}`, 'utf8')); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(seq); - reject(new Error(`timeout: ${command}`)); - }, timeout); - this.pending.set(seq, { - command, - resolve: v => { clearTimeout(timer); resolve(v); }, - reject: e => { clearTimeout(timer); reject(e); }, - }); - }); - } - - waitEvent(pred, timeout = 10000, fromHistory = true) { - if (fromHistory) { - const found = this.events.find(pred); - if (found) { - return Promise.resolve(found); - } - } - return new Promise((resolve, reject) => { - const w = { pred, resolve }; - w.timer = setTimeout(() => { - const i = this.waiters.indexOf(w); - if (i >= 0) { - this.waiters.splice(i, 1); - } - reject(new Error('timeout: wait event')); - }, timeout); - this.waiters.push(w); - }); - } - - waitStopped(timeout = 10000) { - // 只看还没消费过的 stopped,避免拿历史事件当新事件 - if (this.stops.length > this.stopCursor) { - return Promise.resolve(this.stops[this.stopCursor++]); - } - return new Promise((resolve, reject) => { - const w = { - pred: m => m.type === 'event' && m.event === 'stopped', - resolve: m => { this.stopCursor++; resolve(m); }, - }; - w.timer = setTimeout(() => { - const i = this.waiters.indexOf(w); - if (i >= 0) { - this.waiters.splice(i, 1); - } - reject(new Error('timeout: wait stopped')); - }, timeout); - this.waiters.push(w); - }); - } -} - -async function main() { - checkEnv(); - syncScripts(); - checkBinaries(); - const bpLine = markerLine('--@bp'); - const coroutineLine = markerLine('--@coroutine-first-line'); - log(`[repro] mode=${mode} bpLine=${bpLine} coroutineFirstLine=${coroutineLine}`); - - if (fs.existsSync(WORKER_LOG)) { - fs.unlinkSync(WORKER_LOG); - } - - const target = spawn(TARGET_EXE, [TARGET_SCRIPT, String(TARGET_PORT)], { - cwd: RUNTIME, - env: { ...process.env, LUA_DEBUG_ROOT: PUBLISH, LUA_DEBUG_CORE: LUADEBUG_DLL }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - target.stdout.on('data', d => log('[target] ' + d.toString().trimEnd())); - target.stderr.on('data', d => log('[target!] ' + d.toString().trimEnd())); - target.on('exit', (code, sig) => log(`[target] exit code=${code} signal=${sig}`)); - - const adapter = spawn(ADAPTER_EXE, [String(ADAPTER_PORT)], { - cwd: path.join(PUBLISH, 'bin'), - stdio: ['ignore', 'pipe', 'pipe'], - }); - adapter.stdout.on('data', d => log('[adapter] ' + d.toString().trimEnd())); - adapter.stderr.on('data', d => log('[adapter!] ' + d.toString().trimEnd())); - adapter.on('exit', (code, sig) => log(`[adapter] exit code=${code} signal=${sig}`)); - - let ok = false; - try { - const socket = await connect('127.0.0.1', ADAPTER_PORT); - const dap = new DAP(socket); - - await dap.request('initialize', { - adapterID: 'lua', - clientID: 'repro', - linesStartAt1: true, - columnsStartAt1: true, - pathFormat: 'path', - }); - - const attach = dap.request('attach', { - type: 'lua', - request: 'attach', - name: 'repro', - address: `127.0.0.1:${TARGET_PORT}`, - client: true, // 同 configurationProvider.js: 由调试器去 connect 目标监听的端口 - workspaceFolder: ROOT, - luaVersion: LUA, - stopOnEntry: mode === 'entry', - stopOnThreadEntry: false, - }); - const initialized = dap.waitEvent(m => m.type === 'event' && m.event === 'initialized', 15000); - await attach; - await initialized; - log('[repro] attached'); - - await dap.request('configurationDone', {}); - await dap.waitEvent(m => m.type === 'event' && m.event === 'thread', 10000); - log('[repro] target thread started'); - - let stop; - if (mode === 'bp') { - const res = await dap.request('setBreakpoints', { - source: { path: TARGET_SCRIPT }, - breakpoints: [{ line: bpLine }], - sourceModified: false, - }); - log(`[repro] breakpoint: ${JSON.stringify(res.body && res.body.breakpoints)}`); - stop = await dap.waitStopped(15000); - log(`[repro] 断点在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); - } else if (mode === 'stepbp') { - // 先暂停,再在停止状态下加断点,然后 stepIn 直到踩到断点行。 - // 这个停止由 event_breakpoint 直接 runLoop,不经过 event.step 的取消分支, - // 此时 step_in 的 stepL==0 仍然挂着。 - await dap.request('pause', { threadId: 1 }); - stop = await dap.waitStopped(15000); - log(`[repro] 暂停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); - const res = await dap.request('setBreakpoints', { - source: { path: TARGET_SCRIPT }, - breakpoints: [{ line: bpLine }], - sourceModified: false, - }); - log(`[repro] breakpoint: ${JSON.stringify(res.body && res.body.breakpoints)}`); - 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})`); - } - if (stop.body.reason !== 'breakpoint') { - fail('stepIn 10 次都没踩到断点,无法构造出"单步被断点打断"的停止状态'); - } - } else if (mode === 'entry') { - stop = await dap.waitStopped(15000); - log(`[repro] 入口停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); - } else { - await dap.request('pause', { threadId: 1 }); - stop = await dap.waitStopped(15000); - log(`[repro] 暂停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); - if (mode === 'step') { - await dap.request('next', { threadId: stop.body.threadId }); - stop = await dap.waitStopped(15000); - log(`[repro] 单步停在 ${where(await locate(dap, stop.body.threadId))} (reason=${stop.body.reason})`); - } - } - - const threadId = stop.body.threadId; - const st = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 1 }); - const frameId = st.body.stackFrames[0].id; - log(`[repro] 停止位置 ${where(st.body.stackFrames[0])} frameId=${frameId}`); - - log(`[repro] 在调试控制台执行: ${EVAL_EXPR}`); - const evaluate = dap - .request('evaluate', { expression: EVAL_EXPR, frameId, context: 'repl' }, 60000) - .then(r => ({ kind: 'response', r }), e => ({ kind: 'error', e })); - - const secondStop = await Promise.race([ - dap.waitStopped(5000).then(m => ({ kind: 'stopped', m }), () => null), - evaluate.then(r => ({ kind: 'pending', r })), - ]); - - if (secondStop && secondStop.kind === 'stopped') { - // evaluate 还没返回就又收到 stopped:不管停在哪一行,都说明停止期间又被误触发了一次停止 - const frames = await dap.request('stackTrace', { threadId, startFrame: 0, levels: 3 }); - log('[repro] evaluate 还没返回,调试器却又停在:'); - for (const f of frames.body.stackFrames) { - log(` ${where(f)} ${f.name}`); - } - log('[repro] 发送 continue,看 evaluate 是否马上返回...'); - await dap.request('continue', { threadId, allThreadsContinued: true }); - const r = await evaluate; - log(`[repro] continue 之后 evaluate ${r.kind === 'response' ? '返回: ' + JSON.stringify(r.r.body.result) : '失败: ' + r.e}`); - const stopLine = (frames.body.stackFrames[0] || {}).line; - log(''); - log('===== 结论 ====='); - log('BUG 已复现:停止状态下执行“创建协程并立即执行”,evaluate 返回前又收到一次 stopped,调试控制台的表达式被阻塞'); - log(`(第二次 stopped 在 target.lua:${stopLine},协程第一行是 target.lua:${coroutineLine},仅作诊断)`); - } else { - const r = await evaluate; - log(''); - log('===== 结论 ====='); - if (r.kind === 'response') { - log(`BUG 未复现:evaluate 正常返回 ${JSON.stringify(r.r.body.result)}(没有多余的停止)`); - ok = true; - } else { - log(`意外:evaluate 失败 ${r.e}`); - } - } - } finally { - target.kill(); - adapter.kill(); - await sleep(200); - } - - if (fs.existsSync(WORKER_LOG)) { - log('--- publish/worker.log ---'); - log(fs.readFileSync(WORKER_LOG, 'utf8')); - } - process.exit(ok ? 0 : 1); -} - -main().catch(e => { - console.error('ERROR: ' + (e && e.stack || e)); - process.exit(2); -}); diff --git a/test/repro/dap-client.lua b/test/repro/dap-client.lua new file mode 100644 index 00000000..1042aa58 --- /dev/null +++ b/test/repro/dap-client.lua @@ -0,0 +1,630 @@ +-- 用一个假的 DAP 客户端复现: +-- 目标 VM 主动加载调试器并监听端口 -> 关闭 autoUpdate(手动 update)-> 附加 +-- -> 点“暂停” -> 在调试控制台执行“创建协程并立即执行”的表达式 +-- +-- 用法(在仓库根目录执行): +-- luamake lua test/repro/dap-client.lua [pause|bp|step|stepbp|entry] +-- pause : 用“暂停”进入停止状态(这个会复现问题) +-- entry : 用 stopOnEntry 进入停止状态(同 pause,走的是同一条路径) +-- stepbp : 暂停后单步、单步撞上断点停下(另一条不经过 event.step 的停止路径) +-- bp : 用“命中断点”进入停止状态(对照组,正常) +-- step : 暂停后再单步(对照组,正常) +-- +-- 必须用 release 构建的 publish/(luamake -mode release): +-- debug 构建里 protected_area.h 的 check_recursive() 会让停止状态下 +-- 重入的 rdebug.* 调用直接报 "can't recursive",反而掩盖这个 bug。 +-- +-- 环境变量: +-- REPRO_LUA 运行时目录名,默认 lua54(luajit / lua51..lua55) +-- REPRO_RUNTIME publish/runtime 下的平台目录名,默认按当前平台推断 +-- REPRO_TARGET_PORT 默认 55321 +-- REPRO_ADAPTER_PORT 默认 55322 +-- REPRO_AUTOUPDATE=1 不关目标进程的 autoUpdate(走调试器默认的 update hook) + +local fs = require "bee.filesystem" +local platform = require "bee.platform" +local bselect = require "bee.select" +local socket = require "bee.socket" +local sp = require "bee.subprocess" +local thread = require "bee.thread" +local time = require "bee.time" + +local MODES = { + pause = true, + bp = true, + step = true, + stepbp = true, + entry = true, +} + +local proto +local json + +local function find_root() + local p = arg and arg[0] + if p then + p = fs.absolute(fs.path(p)):lexically_normal() + if p:filename():string() == "dap-client.lua" then + return p:parent_path():parent_path():parent_path() + end + end + return fs.current_path() +end + +local ROOT = find_root() +local PUBLISH = ROOT / "publish" +local SRC_SCRIPT = ROOT / "extension" / "script" +local PUB_SCRIPT = PUBLISH / "script" +local TARGET_SCRIPT = ROOT / "test" / "repro" / "target.lua" +local WORKER_LOG = PUBLISH / "worker.log" +local TARGET_LOG = PUBLISH / "repro-target.log" +local ADAPTER_LOG = PUBLISH / "repro-adapter.log" + +assert(fs.exists(ROOT / "make.lua"), ("请在仓库根目录运行(当前 %s)"):format(ROOT:string())) + +local EXE = platform.os == "windows" and ".exe" or "" +local SO = platform.os == "windows" and ".dll" or ".so" + +local function runtime_platform() + local env = os.getenv("REPRO_RUNTIME") + if env then + return env + end + local arch = platform.Arch + if platform.os == "windows" then + if arch == "x86_64" then + return "win32-x64" + elseif arch == "x86" then + return "win32-ia32" + end + return "win32-" .. arch + elseif platform.os == "macos" then + return arch == "arm64" and "darwin-arm64" or "darwin-x64" + elseif platform.os == "linux" then + return arch == "arm64" and "linux-arm64" or "linux-x64" + end + error(("不支持的平台 %s,请用 REPRO_RUNTIME 指定 publish/runtime 下的目录名"):format(platform.os)) +end + +local LUA_VER = os.getenv("REPRO_LUA") or "lua54" +local RUNTIME = PUBLISH / "runtime" / runtime_platform() / LUA_VER +local TARGET_EXE = RUNTIME / ("lua" .. EXE) +local LUADEBUG = RUNTIME / ("luadebug" .. SO) +local ADAPTER_EXE = PUBLISH / "bin" / ("lua-debug" .. EXE) + +local TARGET_PORT = tonumber(os.getenv("REPRO_TARGET_PORT") or 55321) +local ADAPTER_PORT = tonumber(os.getenv("REPRO_ADAPTER_PORT") or 55322) +local EVAL_EXPR = "make_coroutine()" + +package.path = ("%s/script/?.lua;%s"):format(PUBLISH:string(), package.path) + +local function log(...) + local n = select("#", ...) + local parts = {} + for i = 1, n do + parts[i] = tostring((select(i, ...))) + end + print(table.concat(parts, " ")) +end + +local function fail(msg) + error("FAIL: " .. msg, 0) +end + +local function read_file(p) + local f = io.open(p:string(), "rb") + if not f then + return nil + end + local data = f:read("a") + f:close() + return data +end + +local function same_content(a, b) + local da = read_file(a) + return da ~= nil and da == read_file(b) +end + +local function newest_mtime(dir, exts) + local newest = 0 + for path, entry in fs.pairs_r(dir) do + if entry:is_regular_file() then + local name = path:filename():string() + local hit = false + for _, ext in ipairs(exts) do + if name:sub(-#ext) == ext then + hit = true + break + end + end + if hit then + newest = math.max(newest, entry:last_write_time()) + end + end + end + return newest +end + +local function check_env() + for _, f in ipairs { + TARGET_EXE, + LUADEBUG, + ADAPTER_EXE, + PUB_SCRIPT / "debugger.lua", + PUB_SCRIPT / "common" / "json.lua", + PUB_SCRIPT / "common" / "protocol.lua", + } do + if not fs.exists(f) then + fail(("缺少 %s,请先 luamake -mode release 构建"):format(f:string())) + end + end +end + +-- publish/script 是 luamake 从 extension/script 拷贝出来的(copy_extension)。 +-- 这里保证它是当前源码,免得测到旧脚本。 +local function sync_scripts() + local copied = 0 + fs.create_directories(PUB_SCRIPT) + for path, entry in fs.pairs_r(SRC_SCRIPT) do + local dst = PUB_SCRIPT / fs.relative(path, SRC_SCRIPT) + if entry:is_directory() then + fs.create_directories(dst) + else + fs.create_directories(dst:parent_path()) + if not same_content(path, dst) then + fs.copy_file(path, dst, fs.copy_options.overwrite_existing) + copied = copied + 1 + end + end + end + if copied > 0 then + log(("[repro] 已同步 %d 个脚本到 publish/script(相当于 copy_extension)"):format(copied)) + end +end + +local function check_binaries() + -- last_write_time 是秒级,比 JS 的 mtimeMs 粗,够用 + local built = fs.last_write_time(LUADEBUG) + local newer = {} + for _, d in ipairs { + ROOT / "src" / "luadebug", + ROOT / "3rd" / "lua", + ROOT / "3rd" / "bee.lua" / "binding", + } do + if fs.exists(d) and newest_mtime(d, { ".cpp", ".c", ".h" }) > built then + newer[#newer + 1] = d + end + end + if #newer > 0 then + local names = {} + for i, d in ipairs(newer) do + names[i] = " " .. d:string() + end + fail(("%s 比这些目录里的源码旧,请先 luamake -mode release 重新构建:\n%s\n (debug 构建会掩盖这个问题,不能用来判断复现结果)") + :format(LUADEBUG:string(), table.concat(names, "\n"))) + end +end + +local function marker_line(marker) + local text = assert(read_file(TARGET_SCRIPT), "can not read " .. TARGET_SCRIPT:string()) + local line = 0 + local pos = 1 + while true do + local s, e = text:find("\n", pos, true) + line = line + 1 + local l = s and text:sub(pos, s - 1) or text:sub(pos) + if l:find(marker, 1, true) then + return line + end + if not s then + break + end + pos = e + 1 + end + error("marker not found: " .. marker) +end + +local function where(frame) + if not frame or not frame.source then + return "" + end + return ("%s:%s"):format(frame.source.path or frame.source.name, tostring(frame.line)) +end + +local function connect(host, port, timeout) + local deadline = time.monotonic() + (timeout or 10000) + while true do + local fd, err = socket.create("tcp") + if not fd then + error(err) + end + if fd:connect(host, port) == nil then + fd:close() + else + local s = bselect.create() + s:event_add(fd, bselect.SELECT_WRITE) + s:wait(math.max(1, deadline - time.monotonic())) + local connected = fd:status() + s:close() + if connected then + return fd + end + fd:close() + end + if time.monotonic() > deadline then + error(("can not connect %s:%d"):format(host, port)) + end + thread.sleep(100) + end +end + +-- 停止事件本身不带位置,位置要从 stackTrace 拿(VS Code 也是这么做的) +local function locate(dap, threadId) + local st = dap:request("stackTrace", { threadId = threadId, startFrame = 0, levels = 1 }) + return st.body.stackFrames[1] +end + +local DAP = {} +DAP.__index = DAP + +local function new_dap(fd) + local self = setmetatable({ + fd = fd, + stat = {}, + seq = 0, + responses = {}, + names = {}, + events = {}, + stops = {}, + stop_cursor = 0, + sel = bselect.create(), + }, DAP) + self.sel:event_add(fd, bselect.SELECT_READ) + return self +end + +function DAP:dispatch(msg) + if msg.type == "response" then + self.responses[msg.request_seq] = msg + return + end + if msg.type ~= "event" then + return + end + self.events[#self.events + 1] = msg + if msg.event == "stopped" then + self.stops[#self.stops + 1] = msg + end + if msg.event == "stopped" or msg.event == "thread" + or msg.event == "initialized" or msg.event == "breakpoint" then + log(" <- event " .. msg.event .. " " .. json.encode(msg.body or {})) + end +end + +-- 取出一条消息,超时返回 nil +function DAP:wait_message(timeout) + local deadline = time.monotonic() + timeout + while true do + local msg = proto.recv("", self.stat) + if msg then + self:dispatch(msg) + return msg + end + local remain = deadline - time.monotonic() + if remain <= 0 then + return nil + end + self.sel:wait(remain) + local data = self.fd:recv() + if data == nil then + error("adapter 连接已断开") + elseif data ~= false then + -- 一次 recv 可能带回多条消息,多出来的留在 stat 里,下一轮开头继续取 + local got = proto.recv(data, self.stat) + if got then + self:dispatch(got) + return got + end + end + end +end + +function DAP:send(data) + while data ~= "" do + local n = self.fd:send(data) + if n == nil then + error("adapter 连接已断开") + elseif n == false then + self.sel:event_mod(self.fd, bselect.SELECT_WRITE) + self.sel:wait(5000) + self.sel:event_mod(self.fd, bselect.SELECT_READ) + else + data = data:sub(n + 1) + end + end +end + +function DAP:send_request(command, args) + self.seq = self.seq + 1 + log(" -> " .. command) + self.names[self.seq] = command + self:send(proto.send({ + seq = self.seq, + type = "request", + command = command, + arguments = args, + }, self.stat)) + return self.seq +end + +function DAP:wait_response(seq, timeout) + local deadline = time.monotonic() + (timeout or 10000) + while true do + local msg = self.responses[seq] + if msg then + self.responses[seq] = nil + if not msg.success then + error(("%s failed: %s"):format(self.names[seq], msg.message)) + end + return msg + end + local remain = deadline - time.monotonic() + if remain <= 0 then + error("timeout: " .. self.names[seq]) + end + self:wait_message(remain) + end +end + +function DAP:request(command, args, timeout) + return self:wait_response(self:send_request(command, args), timeout) +end + +function DAP:wait_event(pred, timeout) + local deadline = time.monotonic() + (timeout or 10000) + while true do + for _, msg in ipairs(self.events) do + if pred(msg) then + return msg + end + end + local remain = deadline - time.monotonic() + if remain <= 0 then + error("timeout: wait event") + end + self:wait_message(remain) + end +end + +function DAP:wait_stopped(timeout) + local deadline = time.monotonic() + (timeout or 10000) + while true do + -- 只看还没消费过的 stopped,避免拿历史事件当新事件 + if self.stops[self.stop_cursor + 1] then + self.stop_cursor = self.stop_cursor + 1 + return self.stops[self.stop_cursor] + end + local remain = deadline - time.monotonic() + if remain <= 0 then + error("timeout: wait stopped") + end + self:wait_message(remain) + end +end + +local ctx = {} + +local function dump(file, tag) + local content = fs.exists(file) and read_file(file) + if not content or content == "" then + return + end + log(("--- %s (%s) ---"):format(file:string(), tag)) + io.write(content) +end + +local function run(mode) + check_env() + sync_scripts() + check_binaries() + + proto = require("common.protocol") + json = require("common.json") + + local bp_line = marker_line("--@bp") + local coroutine_line = marker_line("--@coroutine-first-line") + log(("[repro] mode=%s bpLine=%d coroutineFirstLine=%d"):format(mode, bp_line, coroutine_line)) + + fs.remove(WORKER_LOG) + local target_log = assert(io.open(TARGET_LOG:string(), "wb")) + local adapter_log = assert(io.open(ADAPTER_LOG:string(), "wb")) + local env = { + LUA_DEBUG_ROOT = PUBLISH:string(), + LUA_DEBUG_CORE = LUADEBUG:string(), + } + -- Windows 上 spawn 默认不搜 PATH,这里全部给绝对路径,searchPath 只是保险 + ctx.target = assert(sp.spawn({ + TARGET_EXE, + TARGET_SCRIPT, + tostring(TARGET_PORT), + cwd = RUNTIME, + env = env, + stdout = target_log, + stderr = target_log, + searchPath = true, + hideWindow = true, + })) + ctx.adapter = assert(sp.spawn({ + ADAPTER_EXE, + tostring(ADAPTER_PORT), + cwd = PUBLISH / "bin", + stdout = adapter_log, + stderr = adapter_log, + searchPath = true, + hideWindow = true, + })) + + local dap = new_dap(connect("127.0.0.1", ADAPTER_PORT)) + + dap:request("initialize", { + adapterID = "lua", + clientID = "repro", + linesStartAt1 = true, + columnsStartAt1 = true, + pathFormat = "path", + }) + + local attach = dap:send_request("attach", { + type = "lua", + request = "attach", + name = "repro", + address = ("127.0.0.1:%d"):format(TARGET_PORT), + client = true, -- 同 VS Code 扩展侧: 由调试器去 connect 目标监听的端口 + workspaceFolder = ROOT:string(), + luaVersion = LUA_VER, + stopOnEntry = mode == "entry", + stopOnThreadEntry = false, + }) + dap:wait_event(function (msg) return msg.event == "initialized" end, 15000) + dap:wait_response(attach) + log("[repro] attached") + + dap:request("configurationDone", {}) + dap:wait_event(function (msg) return msg.event == "thread" end, 10000) + log("[repro] target thread started") + + local stop + if mode == "bp" then + local res = dap:request("setBreakpoints", { + source = { path = TARGET_SCRIPT:string() }, + breakpoints = { { line = bp_line } }, + sourceModified = false, + }) + log("[repro] breakpoint: " .. json.encode(res.body and res.body.breakpoints or {})) + stop = dap:wait_stopped(15000) + log(("[repro] 断点在 %s (reason=%s)"):format(where(locate(dap, stop.body.threadId)), stop.body.reason)) + elseif mode == "stepbp" then + -- 先暂停,再在停止状态下加断点,然后 stepIn 直到踩到断点行。 + -- 这个停止由 event_breakpoint 直接 runLoop,不经过 event.step 的取消分支, + -- 此时 step_in 的 stepL==0 仍然挂着。 + dap:request("pause", { threadId = 1 }) + stop = dap:wait_stopped(15000) + log(("[repro] 暂停在 %s (reason=%s)"):format(where(locate(dap, stop.body.threadId)), stop.body.reason)) + local res = dap:request("setBreakpoints", { + source = { path = TARGET_SCRIPT:string() }, + breakpoints = { { line = bp_line } }, + sourceModified = false, + }) + log("[repro] breakpoint: " .. json.encode(res.body and res.body.breakpoints or {})) + for i = 1, 10 do + if stop.body.reason == "breakpoint" then + break + end + dap:request("stepIn", { threadId = stop.body.threadId }) + stop = dap:wait_stopped(15000) + log(("[repro] stepIn(%d) 停在 %s (reason=%s)") + :format(i, where(locate(dap, stop.body.threadId)), stop.body.reason)) + end + if stop.body.reason ~= "breakpoint" then + fail("stepIn 10 次都没踩到断点,无法构造出“单步被断点打断”的停止状态") + end + elseif mode == "entry" then + stop = dap:wait_stopped(15000) + log(("[repro] 入口停在 %s (reason=%s)"):format(where(locate(dap, stop.body.threadId)), stop.body.reason)) + else + dap:request("pause", { threadId = 1 }) + stop = dap:wait_stopped(15000) + log(("[repro] 暂停在 %s (reason=%s)"):format(where(locate(dap, stop.body.threadId)), stop.body.reason)) + if mode == "step" then + dap:request("next", { threadId = stop.body.threadId }) + stop = dap:wait_stopped(15000) + log(("[repro] 单步停在 %s (reason=%s)"):format(where(locate(dap, stop.body.threadId)), stop.body.reason)) + end + end + + local thread_id = stop.body.threadId + local st = dap:request("stackTrace", { threadId = thread_id, startFrame = 0, levels = 1 }) + local frame = st.body.stackFrames[1] + log(("[repro] 停止位置 %s frameId=%s"):format(where(frame), tostring(frame.id))) + + log("[repro] 在调试控制台执行: " .. EVAL_EXPR) + local evaluate = dap:send_request("evaluate", { + expression = EVAL_EXPR, + frameId = frame.id, + context = "repl", + }) + + -- evaluate 还没返回之前,是否又收到了 stopped + local deadline = time.monotonic() + 5000 + while not dap.responses[evaluate] and not dap.stops[dap.stop_cursor + 1] do + local remain = deadline - time.monotonic() + if remain <= 0 then + break + end + dap:wait_message(remain) + end + local second_stop = dap.stops[dap.stop_cursor + 1] ~= nil + + log("") + log("===== 结论 =====") + if second_stop then + -- evaluate 还没返回就又收到 stopped:不管停在哪一行,都说明停止期间又被误触发了一次停止 + local frames = dap:request("stackTrace", { threadId = thread_id, startFrame = 0, levels = 3 }) + log("[repro] evaluate 还没返回,调试器却又停在:") + for _, f in ipairs(frames.body.stackFrames) do + log((" %s %s"):format(where(f), f.name)) + end + log("[repro] 发送 continue,看 evaluate 是否马上返回...") + dap:request("continue", { threadId = thread_id, allThreadsContinued = true }) + local ok, res = pcall(dap.wait_response, dap, evaluate, 60000) + if ok then + log("[repro] continue 之后 evaluate 返回: " .. json.encode(res.body.result)) + else + log("[repro] continue 之后 evaluate 失败: " .. tostring(res)) + end + log("BUG 已复现:停止状态下执行“创建协程并立即执行”,evaluate 返回前又收到一次 stopped,调试控制台的表达式被阻塞") + log(("(第二次 stopped 在 target.lua:%s,协程第一行是 target.lua:%d,仅作诊断)") + :format(tostring((frames.body.stackFrames[1] or {}).line), coroutine_line)) + return false + end + + local ok, res = pcall(dap.wait_response, dap, evaluate, 60000) + if ok then + log("[repro] BUG 未复现:evaluate 正常返回 " .. json.encode(res.body.result) .. "(没有多余的停止)") + return true + end + log("[repro] 意外:evaluate 失败 " .. tostring(res)) + return false +end + +local function cleanup() + if ctx.target then + pcall(ctx.target.kill, ctx.target) + end + if ctx.adapter then + pcall(ctx.adapter.kill, ctx.adapter) + end + thread.sleep(200) +end + +local function main() + local mode = (arg and arg[1] or os.getenv("REPRO_MODE") or "pause"):gsub("^%-%-mode=", "") + if not MODES[mode] then + io.stderr:write("usage: luamake lua test/repro/dap-client.lua [pause|bp|step|stepbp|entry]\n") + os.exit(2) + end + + local ok, res = pcall(run, mode) + cleanup() + dump(WORKER_LOG, "worker") + dump(TARGET_LOG, "target") + dump(ADAPTER_LOG, "adapter") + if not ok then + io.stderr:write(tostring(res), "\n") + os.exit(2) + end + os.exit(res and 0 or 1) +end + +main()