Agent Skills: 后台启动,suspend=y,JVM 等待调试器附加才会真正启动 Spring

在无 GUI 调试器的 CLI 环境中,对运行中的 JVM 服务(Spring Boot bootRun 启动)做交互式断点调试。通过 JDWP + jdb + tmux 组合:bootRun 暴露 5005 端口,jdb 跑在持久 tmux session 里,由 send-keys/capture-pane 跨调用读写 REPL 状态。适用于:(1) 设断点观察请求现场;(2) 在断点处求值/调用方法;(3) 排查只能在运行时复现的问题。

UncategorizedID: coooolfan/unirhy/jvm-live-debug

Install this agent skill to your local

pnpm dlx add-skill https://github.com/Coooolfan/UniRhy/tree/HEAD/.agents/skills/jvm-live-debug

Skill Files

Browse the full folder contents for jvm-live-debug.

Download Skill

Loading file tree…

.agents/skills/jvm-live-debug/SKILL.md

Skill Metadata

Name
jvm-live-debug
Description
在无 GUI 调试器的 CLI 环境中,对运行中的 JVM 服务(Spring Boot bootRun 启动)做交互式断点调试。通过 JDWP + jdb + tmux 组合:bootRun 暴露 5005 端口,jdb 跑在持久 tmux session 里,由 send-keys/capture-pane 跨调用读写 REPL 状态。适用于:(1) 设断点观察请求现场;(2) 在断点处求值/调用方法;(3) 排查只能在运行时复现的问题。

适用场景

当需要在运行中的 server 进程上设断点、看变量、调方法,而没有 IDEA 等 GUI 可用时使用本流程。不适合长时间多线程 step 调试——那种场景效率远不如让用户开 IDEA。

完整流程

1. 启动后端(带 JDWP)

# 后台启动,suspend=y,JVM 等待调试器附加才会真正启动 Spring
cd server && ./gradlew bootRun --debug-jvm > /tmp/unirhy-debug/server.log 2>&1

工具调用上用 run_in_background: true。然后用 Monitor 等待 Listening for transport dt_socket at address: 5005 出现,再做下一步。

如果不想让 JVM suspend(让服务先正常起来、调试器随时来去),改用:

./gradlew bootRun -PjvmArgs='-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005'

但 --debug-jvm 的 suspend=y 模式在本流程中其实更好用——延迟断点能在类加载前就挂上。

2. 用 tmux 启动 jdb

tmux kill-session -t jdb 2>/dev/null
tmux new-session -d -s jdb -x 200 -y 50 'jdb -attach localhost:5005'
sleep 3
tmux capture-pane -t jdb -p -S -50

attach 成功后看到 VM 已启动 和 main[1] 提示符,说明 JVM 已经从 suspend 状态接管。

3. 设断点 → cont

tmux send-keys -t jdb 'stop at com.example.Foo:30' Enter
sleep 1
tmux send-keys -t jdb 'cont' Enter

类还没加载时 jdb 会显示 正在延迟断点 ... 将在加载类后设置,正常现象。

4. 等待 Spring 启动完成

用 Monitor 等服务端日志出现 Started UnirhyApplicationKt in ... 即可。

5. 命中后取现场

请求把断点打中后,tmux pane 会出现 Breakpoint hit: 和当前栈,提示符变成 <thread-name>[1]。

tmux send-keys -t jdb 'locals' Enter         # 局部变量
tmux send-keys -t jdb 'print someVar' Enter  # 求值表达式
tmux send-keys -t jdb 'where' Enter          # 调用栈
sleep 2
tmux capture-pane -t jdb -p | grep -v '^$' | tail -30

6. 放行 + 清理

tmux send-keys -t jdb 'cont' Enter           # 放当前断点
tmux kill-session -t jdb                     # 退出 jdb(不杀 JVM)
# 用 TaskStop 杀掉 bootRun 和 yarn dev 后台任务

jdb 常用命令速查

| 命令 | 作用 | |---|---| | stop at FQCN:LINE | 在某文件某行设断点 | | stop in FQCN.method | 在方法入口设断点 | | clear | 列出所有断点 | | clear FQCN:LINE | 删除断点 | | cont | 继续执行 | | step / next | 单步进入 / 跳过 | | step up | 执行到当前方法返回 | | locals | 当前栈帧的局部变量和方法参数 | | print EXPR / eval EXPR | 求值表达式(可调用方法,见下) | | where | 当前线程调用栈 | | up / down | 切换栈帧 | | threads / thread ID | 列线程 / 切线程 | | exit | 退出 jdb(不杀被调试 JVM) |

关键技巧

Kotlin 顶层 / 扩展函数的类名

文件 PasswordConfig.kt 中的顶层函数或扩展函数会被编译成 <FileName>Kt 类的静态方法:

  • package com.foo.bar 下的 PasswordConfig.kt 中的扩展函数
  • 设断点用 com.foo.bar.PasswordConfigKt:行号,不是 PasswordConfig

在断点处调用方法

print / eval 可以求值任意表达式,包括调用方法。在 Spring 注入的 service 里:

print this.passwordEncoder.encode(password)

this.field 必须显式带 this.——jdb 不会自动解析隐式 receiver。返回值会原样打印。

延迟断点

JVM 是 lazy class loading:服务刚启动时大量类还没加载。stop at 一个未加载类的位置时 jdb 会 defer,等类加载时再激活。所以任何时候都可以下断点,不必担心时序。

tmux capture-pane 看不到东西时

capture-pane -p 默认只看可见区域。pane 越大空白行越多,输出可能被 tail 掉。两个办法:

  • tmux capture-pane -t jdb -p -S -50 | grep -v '^$' | tail -30(带 scrollback、过滤空行)
  • 创建 session 时给小一点的尺寸:tmux new-session -d -s jdb -x 200 -y 50 ...

踩坑记录

bootRun --debug-jvm 默认 suspend=y

JVM 启动后会卡在 Listening for transport ...,不附加 jdb 永远不会进入 Spring 启动流程。所以必须:(1) 启动 bootRun → (2) 等 JDWP listener → (3) attach jdb → (4) JVM 才开始真正启动 Spring。

zsh glob 空目录会让整条命令失败

mkdir -p /tmp/x && rm -f /tmp/x/*.log    # 目录刚建好没文件时整条命令 exit 1

zsh 默认 nomatch 开启。要么先创建占位文件,要么把 rm 分开写,要么避免用 glob。

monitor where 副作用很吵

monitor where 会让每次断点命中都打印整个栈(Spring/Tomcat 通常有 60+ 帧)。一旦开了就用 unmonitor 1 关掉。命中后真要看栈用一次性的 where。

jdb 中文版 where N 不工作

where 5 在中文版 jdb 里会被解释成「线程 ID 5」并报错。直接用 where 看全栈,或者用 up/down 切帧。

命中后请求会挂住

JVM 被 jdb 暂停了,发起这次请求的 HTTP 连接也卡在那里。前端可能报超时。处理完务必 cont 放行,或者前端开发自己接受这个等待。

Spring Bean 是 BCryptPasswordEncoder

PasswordEncoder 是接口,注入进来的实际对象是 BCryptPasswordEncoder。print this.passwordEncoder.encode(x) 能直接工作是因为 jdb 走的是运行时多态。

完整模板(供 Claude 调用时粘贴)

# === 1. 启动后端 (run_in_background) ===
mkdir -p /tmp/unirhy-debug && cd /Users/yang/Documents/code/UniRhy/server && \
  ./gradlew bootRun --debug-jvm > /tmp/unirhy-debug/server.log 2>&1

# === 2. Monitor: 等 JDWP listener ===
until grep -qE "Listening for transport|BUILD FAILED|FAILURE:" /tmp/unirhy-debug/server.log; do sleep 1; done

# === 3. tmux + jdb ===
tmux kill-session -t jdb 2>/dev/null
tmux new-session -d -s jdb -x 200 -y 50 'jdb -attach localhost:5005'
sleep 3

# === 4. 设断点 + cont ===
tmux send-keys -t jdb 'stop at com.coooolfan.unirhy.service.AccountService:30' Enter
sleep 1
tmux send-keys -t jdb 'cont' Enter

# === 5. 启动前端 (run_in_background) ===
cd /Users/yang/Documents/code/UniRhy/web && yarn dev > /tmp/unirhy-debug/web.log 2>&1

# === 6. Monitor: 等 Spring + Vite 就绪 ===
until grep -q "Started .* in [0-9]" /tmp/unirhy-debug/server.log && \
      grep -q "Local:.*http" /tmp/unirhy-debug/web.log; do sleep 2; done

# === 7. 等用户触发,命中后取现场 ===
tmux send-keys -t jdb 'locals' Enter
sleep 1
tmux capture-pane -t jdb -p | grep -v '^$' | tail -30

# === 8. 放行 + 清理 ===
tmux send-keys -t jdb 'cont' Enter
tmux kill-session -t jdb
# 然后用 TaskStop 停掉 bootRun / yarn dev 后台任务