fix(core): skip sleep inhibitor in headless ssh#5295
Conversation
| function isHeadlessSshSession(env: NodeJS.ProcessEnv): boolean { | ||
| const hasSshSession = SSH_ENV_VARS.some((key) => Boolean(env[key])); | ||
| const hasDisplay = LINUX_DISPLAY_ENV_VARS.some((key) => Boolean(env[key])); | ||
| return hasSshSession && !hasDisplay; |
There was a problem hiding this comment.
[Suggestion] ssh -X (X11 forwarding) sets DISPLAY (e.g. localhost:10.0) but does not register a polkit authentication agent — it only tunnels the X11 protocol. polkit still falls back to pkttyagent on /dev/tty, reproducing issue #5281. So the !hasDisplay condition gives a false sense of safety for X11-forwarded sessions.
Additionally, terminal multiplexers (tmux/screen) freeze SSH_* env vars from the session that created the multiplexer, not the one that attached. This causes false positives (local re-attach after SSH-detach → wrongly skips) and false negatives (SSH-attach after local-create → wrongly spawns).
Since an SSH session never has a local polkit agent regardless of X11 forwarding, consider dropping !hasDisplay and skipping for all SSH sessions:
| return hasSshSession && !hasDisplay; | |
| return hasSshSession; |
| @@ -240,6 +249,9 @@ export class SleepInhibitor { | |||
| // systemd-inhibit semantics on battery. | |||
| return { command: 'caffeinate', args: ['-is'] }; | |||
| case 'linux': | |||
There was a problem hiding this comment.
[Suggestion] Add a comment explaining why this check exists. The connection between SSH detection and polkit behavior is non-obvious: polkit writes its prompt directly to /dev/tty (not to the child's stdio), so stdio: 'ignore' doesn't help, and child.once('error') can't catch it because the spawn itself succeeds. Without this context, a future maintainer could reasonably remove this check thinking the error handler covers spawn failures.
| case 'linux': | |
| // systemd-inhibit --mode=block triggers a polkit auth prompt on | |
| // non-active sessions (headless SSH). polkit writes to /dev/tty, | |
| // bypassing stdio:'ignore' — the spawn succeeds but the prompt | |
| // hijacks the TUI input stream (issue #5281). Skip rather than | |
| // rely on the error handler, which cannot intercept polkit. | |
| if (isHeadlessSshSession(this.env)) { |
| return undefined; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
[Suggestion] preventSystemSleep defaults to true, so this is a default-on feature being silently disabled. The skip message is emitted only via createDebugLogger — invisible unless QWEN_DEBUG_LOG_FILE is set. There's no config override to force inhibition when the user knows their session supports it.
If the heuristic is kept, consider either (a) a config escape hatch (e.g. preventSystemSleep: 'force') to bypass the SSH detection, or (b) elevating the one-time skip message to warn so it surfaces in normal logging.
| expect.arrayContaining(['--what=sleep']), | ||
| expect.any(Object), | ||
| ); | ||
| handle.release(); |
There was a problem hiding this comment.
[Suggestion] WAYLAND_DISPLAY and MIR_SOCKET are listed as display triggers but never tested — only DISPLAY is. Similarly, SSH_CLIENT is never tested alone as the sole SSH trigger (only SSH_CONNECTION in the headless test, and SSH_TTY paired with DISPLAY). If any array entry is accidentally removed, the suite won't catch it.
Consider parameterized tests covering each env var as the sole trigger:
| handle.release(); | |
| it.each(['SSH_CONNECTION', 'SSH_TTY', 'SSH_CLIENT'] as const)( | |
| 'skips systemd-inhibit when only %s is set', | |
| (sshVar) => { | |
| const { inhibitor, spawn } = createHarness('linux', { | |
| [sshVar]: '10.0.0.1 55555 10.0.0.2 22', | |
| }); | |
| const handle = inhibitor.acquire('headless SSH'); | |
| expect(spawn).not.toHaveBeenCalled(); | |
| handle.release(); | |
| }, | |
| ); | |
| it.each(['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'] as const)( | |
| 'starts systemd-inhibit for SSH sessions with %s', | |
| (displayVar) => { | |
| const { inhibitor, spawn } = createHarness('linux', { | |
| SSH_TTY: '/dev/pts/3', | |
| [displayVar]: displayVar === 'MIR_SOCKET' ? '/run/mir_socket' : ':10', | |
| }); | |
| const handle = inhibitor.acquire('display SSH work'); | |
| expect(spawn).toHaveBeenCalledWith( | |
| 'systemd-inhibit', | |
| expect.arrayContaining(['--what=sleep']), | |
| expect.any(Object), | |
| ); | |
| handle.release(); | |
| }, | |
| ); |
|
@qwen-code /triage |
|
Thanks for the PR! Template looks good ✓ On direction: clearly aligned — this fixes a real TUI-breaking bug where On approach: the scope feels right. The env-var heuristic (SSH vars present + no display vars = headless) is pragmatic and covers the reported scenario. The injectable Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 方向:明确对齐——修复了一个真实的 TUI 阻断 bug, 方案:范围合理。环境变量启发式检测(存在 SSH 变量 + 无显示变量 = 无头)务实且覆盖了报告的场景。可注入的 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
✅ Verification report — PR #5295 (fixes #5281)Verdict: verified, recommend merge. I reproduced the root cause on a real machine and confirmed the fix with a live A/B (base vs. PR) on the actual built CLI, plus the full unit / lint / type-check suite. This was an unusually clean verification because the test box is itself a genuine headless SSH session — exactly the configuration #5281 is about. Test environment
1. Root cause confirmed on real hardwareThe inhibitor spawns On a machine that has a desktop's polkit text agent installed, that same authorization step is what surfaces the interactive Real-world proof the released behavior bites here: 2. Static checks (PR test plan) — all green
3. E2E-A — deterministic harness on the real compiled
|
| Arm | Environment | isRunning() |
debug log | real OS holders Δ |
|---|---|---|---|---|
| PR | headless SSH (this box) | false | Sleep inhibition skipped for headless SSH session. |
0 |
| PR | SSH + DISPLAY=:0 |
true | — | +1 |
| PR | no SSH (local) | true | — | +1 |
| BASE | headless SSH (this box) | true | — | +1 |
→ The fix skips the spawn only in the targeted case; SSH-with-display and local sessions still inhibit (no regression). Same env on base → it spawns (the bug).
4. E2E-B — the real qwen TUI in tmux, same headless SSH session
Identical prompt / model / env on both arms; only the compiled sleepInhibitor.js differs. A poller watched for new systemd-inhibit PIDs during the turn.
| Real TUI arm | new systemd-inhibit holders during turn |
[SLEEP_INHIBITOR] debug line |
turn result |
|---|---|---|---|
| BASE | 4 spawned | (skip message absent) | ✦ DONE |
| PR | 0 | Sleep inhibition skipped for headless SSH session. |
✦ DONE |
Both arms completed the same workload (model streamed + ran sleep 15 via the shell tool, replied DONE); only base leaves sleep-inhibitor processes behind.
Residual edges (non-blocking, heuristic limits — noted for completeness)
ssh -X/-YsetsDISPLAY, so a forwarded-X session is treated as non-headless and still inhibits — it could in theory still hit the polkit prompt if that remote session isn't polkit-authorized. The heuristic deliberately errs toward keeping inhibition when a display is present; the reported plain-SSH case is fully covered.sudo/suafter SSH can dropSSH_*, so a user-switched headless session would be re-detected as "local" and inhibit again. Minor and not a regression.
Neither affects the reported scenario.
Conclusion
The change is correctly scoped to the Linux branch, the skip is consistent between getCommand() (returns undefined) and getUnavailableMessage(), it latches so there's no per-acquire log spam, macOS/Windows are untouched, and the new env seam is a no-op in production (pure testability). Behavior verified on a real headless SSH host. LGTM.
🇨🇳 中文版验证报告(点击展开)
✅ 验证报告 — PR #5295(修复 #5281)
结论:已验证,建议合并。 我在真实机器上复现了根因,并用实际构建的 CLI 做了基线/PR 的 A/B 对比验证,同时跑通了完整的单测 / lint / 类型检查。
这次验证条件很理想:测试机本身就是一个真实的「无显示服务器的 SSH 会话」,正是 #5281 描述的场景。
测试环境
| 机器 | systemd 为 PID 1,存在 /usr/bin/systemd-inhibit |
| 会话 | 设置了 SSH_CONNECTION / SSH_CLIENT / SSH_TTY,没有 DISPLAY / WAYLAND_DISPLAY / MIR_SOCKET → isHeadlessSshSession(process.env) === true |
| 构建 | PR 头 d23459a64,用 npm ci 构建;基线 = merge-base 53349a0b2。A/B 仅替换编译产物 packages/core/dist/.../sleepInhibitor.js(工具链一致,只有 PR 改动这一处变量)。 |
| 模型(真实 TUI) | deepseek-chat,真实流式响应 + 真实 run_shell_command 工具调用。 |
1. 在真实机器上确认根因
inhibitor 会启动 systemd-inhibit --what=sleep --who=Qwen Code --why=… --mode=block sleep infinity,由 preventSystemSleep 控制(默认 true),且 acquire() 会在每次模型流式响应开始和每次工具执行时触发。获取 inhibit-block-sleep 锁需要 polkit 授权,而无头 SSH 会话并不具备该授权:
# root(已授权) → systemd-inhibit … echo ⇒ "INHIBIT_RAN_OK", rc=0
# 非 root 用户(报告者)→ systemd-inhibit … echo ⇒ "Failed to inhibit: Access denied", rc=1
在装有桌面 polkit 文本代理的机器上,正是这一步授权会弹出交互式的 AUTHENTICATING FOR org.freedesktop.login1.inhibit-block-sleep 提示。尽管子进程是用 stdio: 'ignore' 启动的,提示仍会泄漏进 TUI——因为 pkttyagent 直接打开 /dev/tty。(说明:本机无法复现字面意义上的密码提示——它以 root 运行,会被 polkit 自动授权,且未安装 pkttyagent——但上面已复现了产生该提示的底层授权要求。)
线上行为确实会在此触发的真实证据:systemd-inhibit --list 显示了两个游离的 Qwen Code sleep-block 持有者(PPID=1,约 17 小时前),是已发布 CLI 早先在无头 SSH 下运行残留的。
2. 静态检查(PR 测试计划)—— 全部通过
| 检查 | 结果 |
|---|---|
vitest src/services/sleepInhibitor.test.ts |
18/18 通过,含 3 个新用例:无头 SSH 跳过、SSH+显示服务器启动、本地无头 Linux 启动 |
eslint(两个改动文件) |
rc=0 |
npm run typecheck --workspace=packages/core |
rc=0,0 错误 |
git diff --check |
rc=0 |
3. E2E-A —— 针对真实编译产物 SleepInhibitor 的确定性测试,覆盖真实环境矩阵
直接导入 CLI 实际加载的那份编译类;env 默认取真实 process.env;同时观测进程内决策与真实 OS 层 systemd-inhibit --list 的增量。
| 分支 | 环境 | isRunning() |
调试日志 | OS 持有者增量 |
|---|---|---|---|---|
| PR | 无头 SSH(本机) | false | Sleep inhibition skipped for headless SSH session. |
0 |
| PR | SSH + DISPLAY=:0 |
true | — | +1 |
| PR | 无 SSH(本地) | true | — | +1 |
| BASE | 无头 SSH(本机) | true | — | +1 |
→ 修复仅在目标场景跳过启动;SSH+显示、以及本地会话仍会 inhibit(无回归)。同样环境下基线会启动(即 bug)。
4. E2E-B —— 真实 qwen TUI 在 tmux 中运行,同一无头 SSH 会话
两个分支用完全相同的 prompt / 模型 / 环境,仅编译产物 sleepInhibitor.js 不同。轮询器在整个回合期间监控新出现的 systemd-inhibit 进程。
| 真实 TUI 分支 | 回合期间新增 systemd-inhibit 持有者 |
[SLEEP_INHIBITOR] 调试行 |
回合结果 |
|---|---|---|---|
| BASE | 新增 4 个 | (无跳过消息) | ✦ DONE |
| PR | 0 | Sleep inhibition skipped for headless SSH session. |
✦ DONE |
两个分支都完成了相同任务(模型流式响应 + 通过 shell 工具执行 sleep 15,回复 DONE);只有基线会残留 sleep-inhibitor 进程。
残留边界情况(不阻塞合并,启发式判断的固有局限——仅供完整记录)
ssh -X/-Y会设置DISPLAY,因此带 X 转发的会话被判为「非无头」、仍会 inhibit——理论上若该远端会话未获 polkit 授权,仍可能触发提示。该启发式有意倾向于「有显示就保留 inhibit」;报告的纯 SSH 场景已完全覆盖。- SSH 后再
sudo/su可能丢掉SSH_*,于是切换用户后的无头会话会被重新判为「本地」并再次 inhibit。属次要问题,且非回归。
两者都不影响报告中的场景。
结论
改动正确地限定在 Linux 分支;getCommand()(返回 undefined)与 getUnavailableMessage() 的跳过判断保持一致;做了 latch 因此不会每次 acquire 都刷日志;macOS/Windows 不受影响;新增的 env 注入口在生产环境是 no-op(纯为可测性)。已在真实无头 SSH 主机上验证行为。LGTM。
Verification methodology: real headless-SSH host with systemd; dist-level A/B against the merge-base; deterministic harness + real TUI under tmux; live systemd-inhibit --list and per-session debug log as independent observables.
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Hi @tt-a1i — thanks for the fix! The PR body doesn't follow the PR template. Could you update it to match? A few sections are missing:
- What this PR does / Why it's needed (currently "Summary")
- Reviewer Test Plan with "How to verify", "Evidence (Before & After)", and "Tested on" (the OS table)
- Risk & Scope
- Linked Issues (you have
Fixes #5281in the body — just needs its own heading)
The fix itself looks focused and the testing is thorough — just a template reshuffle. 🙏
中文说明
你好 @tt-a1i — 感谢修复!PR 正文没有按照 PR 模板 的格式。能否更新一下?缺少的部分:
- What this PR does / Why it's needed(目前用的是 "Summary")
- Reviewer Test Plan,包含 "How to verify"、"Evidence (Before & After)" 和 "Tested on"(操作系统表格)
- Risk & Scope
- Linked Issues(正文里有
Fixes #5281,只需加个标题即可)
修复本身很聚焦,测试也很充分——只是需要调整一下模板格式。🙏
— Qwen Code · qwen3.7-max
|
@qwen-code /triage |
Code ReviewThe diff is clean and minimal — two files, no drive-by refactors. The No correctness bugs, no security concerns, no AGENTS.md violations. The tests were refactored well — the old env-allowlist test no longer mutates Static Checks
Real-Scenario E2E Test (tmux)Imported the compiled Result: The fix works exactly as intended. BASE spawns 中文说明代码审查Diff 干净且最小化——两个文件,无顺手重构。 无正确性 bug,无安全问题,无 AGENTS.md 违规。测试重构得当——旧的环境变量白名单测试不再直接修改 静态检查
真实场景 E2E 测试(tmux)直接从各构建的 结果: 修复完全符合预期。BASE 在所有三种场景下都启动 — Qwen Code · qwen3.7-max |
Final AssessmentThis is a clean, surgical fix for a real TUI-locking bug. The approach — detecting headless SSH via env vars and skipping the sleep inhibitor — is exactly what I'd propose independently. The diff is minimal (2 files, +105/-28), every line serves the stated goal, and there's no scope creep. What makes this PR strong:
No reservations. Approving. 中文说明最终评估这是一个干净、精准的真实 TUI 锁定 bug 修复。方案——通过环境变量检测无头 SSH 并跳过睡眠抑制器——正是我会独立提出的方案。Diff 最小化(2 个文件,+105/-28),每一行都服务于既定目标,无范围蔓延。 这个 PR 的优点:
没有顾虑。批准。 — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
What this PR does
Skips the Linux sleep inhibitor (
systemd-inhibit) when Qwen Code is running in a headless SSH session. A session is treated as headless SSH when an SSH environment variable is present (SSH_CONNECTION,SSH_TTY, orSSH_CLIENT) and no display-server variable is set (DISPLAY,WAYLAND_DISPLAY, orMIR_SOCKET); in that caseacquire()becomes a no-op instead of spawningsystemd-inhibit, and aSleep inhibition skipped for headless SSH session.debug message is logged.SSH sessions that do have a display server (e.g. X11 forwarding) and local headless Linux sessions are unaffected and keep using
systemd-inhibit. macOS (caffeinate) and Windows behavior is unchanged. As part of the change, the inhibitor now resolves its environment from an injectableenvconfig option (defaulting toprocess.env), so both the headless-SSH detection and the existingsystemd-inhibitenv allow-list read from the same source.Why it's needed
When connecting over SSH to a Linux machine that has a desktop environment installed but where no DE session is logged in, launching Qwen Code caused
systemd-inhibitto trigger a PolicyKit authentication prompt (AUTHENTICATING FOR org.freedesktop.login1.inhibit-block-sleep). That prompt was written directly into the TUI and consumed the input stream, leaving the TUI unable to respond to user input. The only workaround was disabling the feature in/settings. Skipping the inhibitor in headless SSH sessions avoids the auth prompt entirely while preserving sleep inhibition everywhere it can work without prompting.Reviewer Test Plan
How to verify
Repro (before this change): SSH into a Linux host that has a desktop environment but no logged-in DE session, then start Qwen Code. Expected before: a
AUTHENTICATING FOR org.freedesktop.login1.inhibit-block-sleepprompt appears in the TUI and the TUI stops responding to input. Observed after this change: no auth prompt; the TUI is responsive, and aSleep inhibition skipped for headless SSH session.debug message is emitted instead of spawningsystemd-inhibit.Automated verification (commands from this PR):
npm run test --workspace=packages/core -- src/services/sleepInhibitor.test.tsnpx eslint packages/core/src/services/sleepInhibitor.ts packages/core/src/services/sleepInhibitor.test.tsnpm run typecheck --workspace=packages/coregit diff --checkThe unit tests assert each branch: headless SSH skips
systemd-inhibit(and counts active handles without spawning), SSH-with-DISPLAYstill spawnssystemd-inhibit, local headless Linux still spawns it, and the curated env allow-list still forwardsDBUS_SESSION_BUS_ADDRESSwhile dropping unrelated vars.Evidence (Before & After)
N/A — non-visible logic fix; covered by the unit tests above. The user-facing effect is the absence of the PolicyKit auth prompt rather than a new TUI element.
Tested on
✅ tested ·⚠️ not tested · N/A
Environment (optional)
Unit tests only (npm workspaces,
packages/core).Risk & Scope
packages/core/src/services/sleepInhibitor.ts. Behavior change is limited to Linux headless SSH sessions, where sleep inhibition is intentionally not attempted (the host is remote, so keeping the local machine awake is not the user's concern there).Linked Issues
Fixes #5281
中文说明
这个 PR 做了什么
当 Qwen Code 运行在无显示服务器的 SSH 会话(headless SSH)中时,跳过 Linux 的睡眠抑制器(
systemd-inhibit)。判定为 headless SSH 的条件是:存在某个 SSH 环境变量(SSH_CONNECTION、SSH_TTY或SSH_CLIENT),且没有设置任何显示服务器变量(DISPLAY、WAYLAND_DISPLAY或MIR_SOCKET);此时acquire()变为空操作而不再启动systemd-inhibit,并记录一条Sleep inhibition skipped for headless SSH session.的 debug 日志。带有显示服务器的 SSH 会话(例如 X11 转发)以及本地的 headless Linux 会话不受影响,仍然使用
systemd-inhibit。macOS(caffeinate)和 Windows 的行为保持不变。作为本次改动的一部分,抑制器现在通过一个可注入的env配置项来解析环境变量(默认仍为process.env),因此 headless SSH 的检测和原有的systemd-inhibit环境变量白名单读取的是同一来源。为什么需要它
当通过 SSH 连接到一台安装了桌面环境、但没有登入任何 DE 会话的 Linux 机器时,启动 Qwen Code 会导致
systemd-inhibit触发 PolicyKit 认证提示(AUTHENTICATING FOR org.freedesktop.login1.inhibit-block-sleep)。该提示被直接输出到 TUI 中并占用了输入流,导致 TUI 无法响应用户输入。当时唯一的规避方法是在/settings中禁用该功能。在 headless SSH 会话中跳过抑制器可以彻底避免该认证提示,同时在所有能够无提示工作的场景下保留睡眠抑制。Reviewer 测试计划
如何验证
复现步骤(在本改动之前):SSH 登入一台安装了桌面环境但没有登入 DE 会话的 Linux 主机,然后启动 Qwen Code。改动前的预期:TUI 中出现
AUTHENTICATING FOR org.freedesktop.login1.inhibit-block-sleep提示,且 TUI 不再响应输入。本改动后的实际表现:不再出现认证提示;TUI 正常响应,并且会发出Sleep inhibition skipped for headless SSH session.的 debug 日志,而不是启动systemd-inhibit。自动化验证(本 PR 中的命令):
npm run test --workspace=packages/core -- src/services/sleepInhibitor.test.tsnpx eslint packages/core/src/services/sleepInhibitor.ts packages/core/src/services/sleepInhibitor.test.tsnpm run typecheck --workspace=packages/coregit diff --check单元测试覆盖了每个分支:headless SSH 跳过
systemd-inhibit(在不启动子进程的情况下仍对活跃句柄计数)、带DISPLAY的 SSH 仍然启动systemd-inhibit、本地 headless Linux 仍然启动它,以及精选的环境变量白名单仍会转发DBUS_SESSION_BUS_ADDRESS并丢弃无关变量。证据(前后对比)
N/A —— 非可见的逻辑修复;已由上述单元测试覆盖。用户可感知的效果是 PolicyKit 认证提示的消失,而不是新增某个 TUI 元素。
测试平台
✅ 已测试 ·⚠️ 未测试 · N/A
环境(可选)
仅单元测试(npm workspaces,
packages/core)。风险与范围
packages/core/src/services/sleepInhibitor.ts。行为变化仅限于 Linux 的 headless SSH 会话,在这种场景下有意不再尝试睡眠抑制(主机是远端的,因此让本地机器保持唤醒并不是用户在此关心的事)。关联 Issue
Fixes #5281
AI Assistance Disclosure
I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.