Skip to content

fix(core): skip sleep inhibitor in headless ssh#5295

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/sleep-inhibitor-headless-ssh
Jun 18, 2026
Merged

fix(core): skip sleep inhibitor in headless ssh#5295
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/sleep-inhibitor-headless-ssh

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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, or SSH_CLIENT) and no display-server variable is set (DISPLAY, WAYLAND_DISPLAY, or MIR_SOCKET); in that case acquire() becomes a no-op instead of spawning systemd-inhibit, and a Sleep 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 injectable env config option (defaulting to process.env), so both the headless-SSH detection and the existing systemd-inhibit env 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-inhibit to 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-sleep prompt appears in the TUI and the TUI stops responding to input. Observed after this change: no auth prompt; the TUI is responsive, and a Sleep inhibition skipped for headless SSH session. debug message is emitted instead of spawning systemd-inhibit.

Automated verification (commands from this PR):

  • npm run test --workspace=packages/core -- src/services/sleepInhibitor.test.ts
  • npx eslint packages/core/src/services/sleepInhibitor.ts packages/core/src/services/sleepInhibitor.test.ts
  • npm run typecheck --workspace=packages/core
  • git diff --check

The unit tests assert each branch: headless SSH skips systemd-inhibit (and counts active handles without spawning), SSH-with-DISPLAY still spawns systemd-inhibit, local headless Linux still spawns it, and the curated env allow-list still forwards DBUS_SESSION_BUS_ADDRESS while 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

OS Status
🍏 macOS ✅ CI
🪟 Windows ✅ CI
🐧 Linux ✅ CI

✅ tested · ⚠️ not tested · N/A

Environment (optional)

Unit tests only (npm workspaces, packages/core).

Risk & Scope

  • Main risk or tradeoff: Low; scoped to 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).
  • Not validated / out of scope: No unrelated refactors, no public API changes, no UI redesigns; macOS and Windows paths are untouched.
  • Breaking changes / migration notes: None.

Linked Issues

Fixes #5281

中文说明

这个 PR 做了什么

当 Qwen Code 运行在无显示服务器的 SSH 会话(headless SSH)中时,跳过 Linux 的睡眠抑制器(systemd-inhibit)。判定为 headless SSH 的条件是:存在某个 SSH 环境变量(SSH_CONNECTIONSSH_TTYSSH_CLIENT),且没有设置任何显示服务器变量(DISPLAYWAYLAND_DISPLAYMIR_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.ts
  • npx eslint packages/core/src/services/sleepInhibitor.ts packages/core/src/services/sleepInhibitor.test.ts
  • npm run typecheck --workspace=packages/core
  • git diff --check

单元测试覆盖了每个分支:headless SSH 跳过 systemd-inhibit(在不启动子进程的情况下仍对活跃句柄计数)、带 DISPLAY 的 SSH 仍然启动 systemd-inhibit、本地 headless Linux 仍然启动它,以及精选的环境变量白名单仍会转发 DBUS_SESSION_BUS_ADDRESS 并丢弃无关变量。

证据(前后对比)

N/A —— 非可见的逻辑修复;已由上述单元测试覆盖。用户可感知的效果是 PolicyKit 认证提示的消失,而不是新增某个 TUI 元素。

测试平台

操作系统 状态
🍏 macOS ✅ CI
🪟 Windows ✅ CI
🐧 Linux ✅ CI

✅ 已测试 · ⚠️ 未测试 · N/A

环境(可选)

仅单元测试(npm workspaces,packages/core)。

风险与范围

  • 主要风险或权衡:低;范围限定在 packages/core/src/services/sleepInhibitor.ts。行为变化仅限于 Linux 的 headless SSH 会话,在这种场景下有意不再尝试睡眠抑制(主机是远端的,因此让本地机器保持唤醒并不是用户在此关心的事)。
  • 未验证 / 范围之外:没有无关的重构,没有公共 API 变更,没有 UI 改版;macOS 和 Windows 路径保持不变。
  • 破坏性变更 / 迁移说明:无。

关联 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.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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:

Suggested change
return hasSshSession && !hasDisplay;
return hasSshSession;

@@ -240,6 +249,9 @@ export class SleepInhibitor {
// systemd-inhibit semantics on battery.
return { command: 'caffeinate', args: ['-is'] };
case 'linux':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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.

Suggested change
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;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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:

Suggested change
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();
},
);

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: clearly aligned — this fixes a real TUI-breaking bug where systemd-inhibit triggers a PolicyKit auth prompt in headless SSH sessions, completely locking up the interface. The sleep inhibition feature (added in #4434, patched in #4865) has had a rough edge on Linux SSH ever since it shipped, and this is the right place to fix it.

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 env config is a clean testability seam with no production cost. The diff is focused — two files, no drive-by refactors, no scope creep. The getUnavailableMessage() addition is slightly unusual (repurposing the "unsupported platform" path for a deliberate skip), but it works because the latch prevents log spam and the message is accurate. One minor question: the isHeadlessSshSession() function gets called twice per acquire() (once in getCommand(), once in getUnavailableMessage()) — a cached boolean on the instance would be marginally cleaner, but it's a pure function on a handful of env vars so the cost is negligible.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:明确对齐——修复了一个真实的 TUI 阻断 bug,systemd-inhibit 在无头 SSH 会话中触发 PolicyKit 认证提示,导致界面完全无法操作。睡眠抑制功能(#4434 引入,#4865 修补)自上线以来在 Linux SSH 场景一直存在这个问题,此处修复位置正确。

方案:范围合理。环境变量启发式检测(存在 SSH 变量 + 无显示变量 = 无头)务实且覆盖了报告的场景。可注入的 env 配置是干净的可测试性接缝,生产环境无额外成本。Diff 聚焦——两个文件,无顺手重构,无范围蔓延。getUnavailableMessage() 的加入稍显特殊(将「平台不支持」路径复用于主动跳过),但因为 latch 机制防止了日志刷屏且消息本身准确,所以可以接受。一个小问题:isHeadlessSshSession() 在每次 acquire() 中被调用两次(getCommand()getUnavailableMessage() 各一次)——在实例上缓存一个布尔值会更清晰,但它只是对几个环境变量的纯函数,开销可忽略。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ 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

Box systemd as PID 1, /usr/bin/systemd-inhibit present
Session SSH_CONNECTION / SSH_CLIENT / SSH_TTY set, no DISPLAY / WAYLAND_DISPLAY / MIR_SOCKETisHeadlessSshSession(process.env) === true
Build PR head d23459a64 built with npm ci; base = merge-base 53349a0b2. A/B done by swapping only the compiled packages/core/dist/.../sleepInhibitor.js (same toolchain, only the PR delta varies).
Model (live TUI) deepseek-chat, real streaming + a real run_shell_command tool call.

1. Root cause confirmed on real hardware

The inhibitor spawns systemd-inhibit --what=sleep --who=Qwen Code --why=… --mode=block sleep infinity, gated by preventSystemSleep which defaults to true, and acquire() fires at the start of every model stream and every tool execution. Taking the inhibit-block-sleep lock requires a polkit authorization that a headless SSH session does not have:

# root (authorized)            → systemd-inhibit … echo  ⇒ "INHIBIT_RAN_OK", rc=0
# non-root user (the reporter) → systemd-inhibit … echo  ⇒ "Failed to inhibit: Access denied", rc=1

On a machine that has a desktop's polkit text agent installed, that same authorization step is what surfaces the interactive AUTHENTICATING FOR org.freedesktop.login1.inhibit-block-sleep prompt. It leaks into the TUI even though the child is spawned with stdio: 'ignore', because pkttyagent opens /dev/tty directly. (Note: the literal password prompt can't be reproduced on this particular box — it runs as root, which polkit auto-authorizes, and has no pkttyagent installed — but the underlying authorization requirement that produces it is reproduced above.)

Real-world proof the released behavior bites here: systemd-inhibit --list showed two orphaned Qwen Code sleep-block holders (PPID=1, ~17 h old) left over from earlier headless-SSH runs of the shipped CLI.

2. Static checks (PR test plan) — all green

Check Result
vitest src/services/sleepInhibitor.test.ts 18/18 pass, incl. the 3 new cases: skips … for headless SSH, starts … for SSH + display server, starts … for local headless Linux
eslint (both changed files) rc=0
npm run typecheck --workspace=packages/core rc=0, 0 errors
git diff --check rc=0

3. E2E-A — deterministic harness on the real compiled SleepInhibitor, real env matrix

Imports the exact compiled class the CLI loads; env defaults to the real process.env; observes the in-process decision and the real OS-level systemd-inhibit --list delta.

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 / -Y sets DISPLAY, 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 / su after SSH can drop SSH_*, 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_SOCKETisHeadlessSshSession(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.

@tt-a1i
tt-a1i marked this pull request as ready for review June 18, 2026 17:01
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 #5281 in 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

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The diff is clean and minimal — two files, no drive-by refactors. The isHeadlessSshSession() function is a pure predicate on env vars, exactly what this needs. The injectable env config is a clean testability seam with zero production cost. The getUnavailableMessage() refactor repurposes the "unsupported platform" log path for the deliberate skip, which works because the latch (spawnFailedForCurrentRun) prevents repeated logging.

No correctness bugs, no security concerns, no AGENTS.md violations. The tests were refactored well — the old env-allowlist test no longer mutates process.env directly, instead passing env through the config. Cleaner.

Static Checks

Check Result
vitest sleepInhibitor.test.ts 18/18 pass ✅
eslint (both files) clean ✅
typecheck --workspace=packages/core 0 errors ✅

Real-Scenario E2E Test (tmux)

Imported the compiled SleepInhibitor class directly from each build's dist/, exercised all three environment configurations with a fake spawn that logs calls. Same harness, only the build differs.

===== BASE (main) =====
--- headless-ssh ---
=== Scenario: headless-ssh (BASE) ===
Env: {"SSH_CONNECTION":"10.0.0.1 55555 10.0.0.2 22","SSH_TTY":"/dev/pts/0","SSH_CLIENT":"10.0.0.1 55555 22"}
isRunning: true
activeCount: 1
Logs:
  [SPAWN] systemd-inhibit --what=sleep --who=Qwen Code --why=test reason --mode=block sleep infinity
After release - activeCount: 0
--- ssh-with-display ---
=== Scenario: ssh-with-display (BASE) ===
Env: {"SSH_TTY":"/dev/pts/3","DISPLAY":":10","DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/user/1000/bus"}
isRunning: true
Logs:
  [SPAWN] systemd-inhibit --what=sleep --who=Qwen Code --why=test reason --mode=block sleep infinity
--- local-headless ---
=== Scenario: local-headless (BASE) ===
Env: {"DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/user/1000/bus"}
isRunning: true
Logs:
  [SPAWN] systemd-inhibit --what=sleep --who=Qwen Code --why=test reason --mode=block sleep infinity

===== PR (fix) =====
--- headless-ssh ---
=== Scenario: headless-ssh (PR) ===
Env: {"SSH_CONNECTION":"10.0.0.1 55555 10.0.0.2 22","SSH_TTY":"/dev/pts/0","SSH_CLIENT":"10.0.0.1 55555 22"}
isRunning: false
activeCount: 1
Logs:
  [DEBUG] Sleep inhibition skipped for headless SSH session.
After release - activeCount: 0
--- ssh-with-display ---
=== Scenario: ssh-with-display (PR) ===
Env: {"SSH_TTY":"/dev/pts/3","DISPLAY":":10","DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/user/1000/bus"}
isRunning: true
Logs:
  [SPAWN] systemd-inhibit --what=sleep --who=Qwen Code --why=test reason --mode=block sleep infinity
--- local-headless ---
=== Scenario: local-headless (PR) ===
Env: {"DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/user/1000/bus"}
isRunning: true
Logs:
  [SPAWN] systemd-inhibit --what=sleep --who=Qwen Code --why=test reason --mode=block sleep infinity

Result: The fix works exactly as intended. BASE spawns systemd-inhibit in all three scenarios (including headless SSH — the bug). PR skips it only for headless SSH while preserving behavior for SSH-with-display and local-headless sessions. No regressions.

中文说明

代码审查

Diff 干净且最小化——两个文件,无顺手重构。isHeadlessSshSession() 是对环境变量的纯谓词函数,正是这个场景所需要的。可注入的 env 配置是干净的可测试性接缝,生产环境零成本。getUnavailableMessage() 的重构将「平台不支持」的日志路径复用于主动跳过,由于 latch(spawnFailedForCurrentRun)防止了重复日志,因此可行。

无正确性 bug,无安全问题,无 AGENTS.md 违规。测试重构得当——旧的环境变量白名单测试不再直接修改 process.env,而是通过 config 传入。更干净。

静态检查

检查 结果
vitest sleepInhibitor.test.ts 18/18 通过 ✅
eslint(两个文件) 无问题 ✅
typecheck --workspace=packages/core 0 错误 ✅

真实场景 E2E 测试(tmux)

直接从各构建的 dist/ 导入编译后的 SleepInhibitor 类,使用记录调用的假 spawn 函数覆盖全部三种环境配置。相同的测试框架,仅构建版本不同。

结果: 修复完全符合预期。BASE 在所有三种场景下都启动 systemd-inhibit(包括无头 SSH——即 bug)。PR 在无头 SSH 场景下跳过,同时保留了 SSH+显示服务器和本地无头场景的行为。无回归。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Final Assessment

This 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:

  • The bug is real and impactful (TUI completely unusable in headless SSH)
  • The fix is correctly scoped — only Linux headless SSH is affected, no regressions in SSH-with-display or local sessions
  • The injectable env config is a clean testability seam, not a speculative abstraction
  • Tests were refactored to stop mutating process.env directly — a genuine improvement
  • @wenshao's independent verification on real hardware with A/B testing against the merge-base adds strong corroboration

No reservations. Approving.

中文说明

最终评估

这是一个干净、精准的真实 TUI 锁定 bug 修复。方案——通过环境变量检测无头 SSH 并跳过睡眠抑制器——正是我会独立提出的方案。Diff 最小化(2 个文件,+105/-28),每一行都服务于既定目标,无范围蔓延。

这个 PR 的优点:

  • Bug 真实且影响大(无头 SSH 中 TUI 完全无法使用)
  • 修复范围正确——仅影响 Linux 无头 SSH,SSH+显示和本地会话无回归
  • 可注入的 env 配置是干净的可测试性接缝,不是投机性抽象
  • 测试被重构为不再直接修改 process.env——真正的改进
  • @wenshao 在真实硬件上与 merge-base 的 A/B 独立验证增加了强有力的佐证

没有顾虑。批准。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao
wenshao merged commit f3e0dd6 into QwenLM:main Jun 18, 2026
63 checks passed
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.

TUI becomes unresponsive when login1.inhibit-block-sleep triggers authentication

3 participants