From 3fd33a672fe7d8d29e1e33e8614e033527e56d69 Mon Sep 17 00:00:00 2001 From: tower-worker Date: Sun, 8 Nov 2026 20:12:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(m196):=20r1=20=E5=A4=8D=E5=AE=A1=20P1?= =?UTF-8?q?=EF=BC=88=E9=9D=9E=20Windows=20dead=5Fcode=EF=BC=89+=20P2=20?= =?UTF-8?q?=E5=9B=9B=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1(阻断):console.rs 的 `INVALID_HANDLE_VALUE` 只被 `#[cfg(target_os = "windows")]` 的 usable() 用到,而这个模块在 Linux/macOS 上也编译(platform/mod.rs 的 pub mod win32; 没有 cfg)⇒ ubuntu 的 `clippy -- -D warnings` 会把它升级成 dead_code error。 按「精确 cfg」修(不用 dwm.rs 那种文件级 allow(dead_code):那会永久掩掉本文件在非 Windows 上的所有死项)。自证方式是真实文件翻转 cfg 的 rustc probe:修前 exit 1 (`constant INVALID_HANDLE_VALUE is never used`)、修后 exit 0 零告警;同一条命令写进 了注释,下一个人可以照着跑。 P2-1:自测条数按实现改成 8 条(红 6 / 绿 2),三处(脚本 docstring、release-build.yml 注释、README §12)一致。 P2-2:文件头那组句柄读数换成 release 产物的实测值(out 0x0→0x1F0 / err 0x0→0x1F4), 并说明数字随实例变化、单测里的 0xE4/0xE8 是示例。 P2-3:README §15 补这段边角语义——但**按实测改写了复审的结论**:两种「既无控制台也不 重定向」的宿主形态(父有句柄不继承 / 父子都无句柄)× 两条命令,child_exit_code 全是 0, 不是 EXIT_INTERNAL;原因是 Rust std 在 Windows 上对取不到的句柄直接返回 Ok(独立探针 实测 SetStdHandle(out)=0x0 / 0x1234 / (HANDLE)-1 三种取值 write/flush 都是 Ok), 所以 runner.rs 那条「写失败 ⇒ EXIT_INTERNAL」的分支不会被触发。 P2-4:NoParentConsole → NoConsole(这个名字同时覆盖「attach 成功但句柄仍不可用」), 诊断文案与注释同步。 --- .github/workflows/release-build.yml | 2 +- crates/app-iced/src/platform/win32/console.rs | 43 ++++++++++++++----- packaging/README.md | 20 +++++++-- packaging/check_pe_subsystem.py | 5 ++- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ba58a28..e52c596 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -265,7 +265,7 @@ jobs: # 就是这么活到 2026-11-08 的。本步只认那 4 个字节:GUI 产物必须是 WINDOWS_GUI(2),非 2 即红。 # 反方向一并守:CLI 产物 `ull.exe` 必须仍是 CONSOLE(3)——它靠真控制台做 stdout/stderr, # 谁把属性抄到它头上,`ull --version | …` 会静默丢输出。守门本体在 packaging/check_pe_subsystem.py - # (零第三方依赖,本地同一份代码可跑;`--self-test` 注入 5 条回归证明它会红)。 + # (零第三方依赖,本地同一份代码可跑;`--self-test` 注入 8 条回归(红 6 / 绿 2)证明它会红)。 - name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3) if: runner.os == 'Windows' shell: bash diff --git a/crates/app-iced/src/platform/win32/console.rs b/crates/app-iced/src/platform/win32/console.rs index 554ba5b..0c2f7b8 100644 --- a/crates/app-iced/src/platform/win32/console.rs +++ b/crates/app-iced/src/platform/win32/console.rs @@ -18,10 +18,11 @@ //! | ④ 双击 | 0 / 0 | 丢了(本来也没人看) | 要(但会失败,见下) | //! //! ③ 的补救实测:`AttachConsole(ATTACH_PARENT_PROCESS)` 返回 1 之后 `GetStdHandle` 立刻给出 -//! 控制台句柄(实测 `0x0` → `0xE4` / `0xE8`),随后的 `WriteFile` 与 Rust 的 `eprintln!` -//! 都真的落进了屏幕缓冲区(同一个控制台模型宿主读回的文本见 M196 报告)。所以**不需要**再自己 -//! `CreateFileW("CONOUT$")` + `SetStdHandle`:那一层是多余的,而且它会覆盖用户自己重定向的 -//! 句柄——①②两种情形会被打坏。 +//! 控制台句柄(release 产物在 cmd 模型下实测 `out 0x0→0x1F0` / `err 0x0→0x1F4`),随后 +//! `WriteFile` 与 Rust 的 `eprintln!` 都真的落进了屏幕缓冲区(同一模型宿主读回的文本见 M196 +//! 报告)。具体数字随进程与控制台实例变化(调试构建里是 `0x208`/`0x20C`),**能跨实例复现的 +//! 是「从 0 变成非 0」这件事本身**。所以**不需要**再自己 `CreateFileW("CONOUT$")` + +//! `SetStdHandle`:那一层是多余的,而且它会覆盖用户自己重定向的句柄——①②两种情形会被打坏。 //! //! ④ 的边界:双击时祖先进程(explorer)没有控制台,`AttachConsole` 返回 0 //! (`GetLastError` = 6 `ERROR_INVALID_HANDLE`),`GetConsoleWindow` 仍是 0、`CONOUT$` 也打不开。 @@ -59,6 +60,22 @@ const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12 const ATTACH_PARENT_PROCESS: u32 = 0xFFFF_FFFF; /// `FALSE`/`TRUE` 之外唯一需要认的无效句柄:`(HANDLE)-1`。 +/// +/// **必须带下面那行 `cfg`**:唯一使用点 [`usable`] 只活在 Windows 分支里,而本模块在 +/// Linux/macOS 上也编译(`platform/mod.rs` 的 `pub mod win32;` 没有 cfg)。漏了它, +/// `ci.yml` 的 ubuntu clippy(`-- -D warnings`)会把它升级成 `dead_code` error——而本机只有 +/// windows-gnu 工具链,非 Windows 的 cfg 一次都编不到,本地抓不住(M196 r1 复审 P1)。 +/// 同目录 `transparency/dwm.rs` 用的是文件级 `#![cfg_attr(not(windows), allow(dead_code))]`; +/// 这里刻意**不用**那种写法:本文件的 Windows-only 面很小,每一项各自带 cfg 才能让编译器 +/// 继续在非 Windows 上替我们盯着「又漏了一个」。 +/// +/// 自证办法(本机只有 Windows triple、交叉编 iced 会卡在 x11/fontconfig 时):把本文件的 +/// `target_os = "windows"` 全换成不存在的目标名,再 +/// `rustc --edition 2021 --crate-type lib -D warnings --crate-name console_probe <那份拷贝>` +/// ——漏了上面那行 cfg 时它是 exit 1 + `constant INVALID_HANDLE_VALUE is never used`, +/// 补齐后 exit 0 且零告警(**其他**任何非 Windows 死项也会一起报出来,所以这一个 probe 就是 +/// 「本文件在 Linux 上能不能过 -D warnings」的整份答案)。 +#[cfg(target_os = "windows")] const INVALID_HANDLE_VALUE: isize = -1; #[cfg(target_os = "windows")] @@ -87,8 +104,11 @@ pub enum ConsoleOutcome { /// 附着后 `(stdout, stderr)`。 after: (isize, isize), }, - /// 祖先进程没有控制台(④,或者服务/计划任务):附着失败,**也没有新建任何窗口**。 - NoParentConsole { + /// 没有可写的控制台(④ / 服务 / 计划任务):附着失败**或**附着成功但句柄仍不可用。 + /// + /// 两种情况在调用点上是同一件事(都没有输出通道),所以合并成一个结论;`last_error` 与 + /// 两对句柄一起进诊断文本,读的人能自己分清是哪一种。**没有新建任何窗口**。 + NoConsole { /// 附着前 `(stdout, stderr)`。 before: (isize, isize), /// 附着后 `(stdout, stderr)`。 @@ -115,12 +135,12 @@ impl ConsoleOutcome { "已附着父进程控制台:out 0x{:X}→0x{:X},err 0x{:X}→0x{:X}", before.0, after.0, before.1, after.1 ), - Self::NoParentConsole { + Self::NoConsole { before, after, last_error, } => format!( - "父进程没有控制台(attach 失败,GetLastError={}):out 0x{:X}→0x{:X},err 0x{:X}→0x{:X};未新建窗口", + "没有可用的控制台(attach 失败或句柄仍不可用,GetLastError={}):out 0x{:X}→0x{:X},err 0x{:X}→0x{:X};未新建窗口", last_error, before.0, after.0, before.1, after.1 ), } @@ -130,7 +150,7 @@ impl ConsoleOutcome { #[must_use] pub fn output_visible(self) -> bool { match self { - Self::NotWindows | Self::NoParentConsole { .. } => false, + Self::NotWindows | Self::NoConsole { .. } => false, Self::AlreadyUsable { .. } | Self::Attached { .. } => true, } } @@ -192,7 +212,7 @@ pub fn attach_parent_console() -> ConsoleOutcome { } else { // 附着成功但句柄仍不可用,与「父进程根本没有控制台」在调用点上是同一件事: // 都没有可写的输出通道,所以合并成一个结论(`describe` 里照样带着读数)。 - ConsoleOutcome::NoParentConsole { + ConsoleOutcome::NoConsole { before, after, last_error, @@ -218,6 +238,7 @@ mod tests { #[test] fn describe_carries_the_runtime_readings() { // 报告里贴的是这些行:句柄值必须真的出现在文本里,不然「已附着」就只是一句话。 + // 下面的 0xE4/0xE8 是**示例值**(真实读数随进程与控制台实例变化,见模块文档 ③)。 let attached = ConsoleOutcome::Attached { before: (0, 0), after: (0xE4, 0xE8), @@ -227,7 +248,7 @@ mod tests { assert!(text.contains("0xE8"), "{text}"); assert!(attached.output_visible()); - let missing = ConsoleOutcome::NoParentConsole { + let missing = ConsoleOutcome::NoConsole { before: (0, 0), after: (0, 0), last_error: 6, diff --git a/packaging/README.md b/packaging/README.md index f3c145b..79e9666 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -65,7 +65,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 | -| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 5 条回归(见 §15) | ≤400 | +| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 8 条回归(红 6 / 绿 2)(见 §15) | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | @@ -158,10 +158,22 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 四种启动情形的实测表在该文件顶部):重定向/管道两个句柄本来就可用(一个字节都不动)、cmd 里直接跑 附着后输出落进控制台、双击时附着失败且**绝不新建窗口**。 - 守门:`python3 packaging/check_pe_subsystem.py --gui --cli `,读 `MZ`→`e_lfanew`(`0x3C`)→ - `PE`→COFF 头→可选头 `+0x44` 的 2 个字节,打印**文件偏移**(例:`Subsystem=2 (WINDOWS_GUI) @ - 文件偏移 0x1E6`)。两向断言:GUI 产物必须 2,CLI 产物 `ull.exe` 必须仍是 3(CONSOLE)——反方向 - 不守时,把属性贴错 crate 会被漏掉,而 `ull --version | …` 会静默丢输出。 + `PE\0\0`→COFF 头→可选头 `+0x44` 的 2 个字节,打印**文件偏移**(release 产物实测:`Subsystem=2 + (WINDOWS_GUI) @ 文件偏移 0xDC`,`e_lfanew=0x80`、PE32+)。两向断言:GUI 产物必须 2,CLI 产物 + `ull.exe` 必须仍是 3(CONSOLE)——反方向不守时,把属性贴错 crate 会被漏掉,而 `ull --version | …` + 会静默丢输出。 - 接在哪:`.github/workflows/release-build.yml`(发布侧,两个 Windows 槽位各跑一次)与 `.github/workflows/ci.yml` 的 `build` job(PR 阶段就红,不必等到 tag)。 - 抓不住(如实记):只看那 4 个字节,不看 GUI 双击的实际观感——「不再多一个黑框」在本仓库的验证环境 (RDP / 无干净无控制台桌面)只能由 PE 头 + 进程/窗口枚举间接证明,人眼验收归真机。 +- **既无控制台也不重定向的宿主(快捷方式 / 服务 / 计划任务):输出照旧无处可去,退出码不变** + (M196 r2 实测,覆盖 r1 复审 P2-3 提出的「退出码会变成 `EXIT_INTERNAL`(2)」——那条实测不成立)。 + 两种宿主形态各测两条命令,`child_exit_code` 全是 **0**:①父进程有 std 句柄但子进程不继承; + ②父子都没有 std 句柄(服务形状,子进程实测 `stdin/stdout/stderr = 0x0`、`AttachConsole` 返回 0 / + `GetLastError=6`)。原因在 Rust std 的 Windows 实现:句柄取不到时 `Write`/`flush` 直接返回 `Ok` + (独立探针实测 `SetStdHandle(out)` = `0x0` / `0x1234` / `(HANDLE)-1` 三种取值,`write=Ok(())`、 + `flush=Ok(())` 全成立),所以 `crates/cli/src/runner.rs` 那条「写失败 ⇒ `EXIT_INTERNAL`」的分支不会 + 被触发。结论:本 diff **没有**给 CLI 退出码引入可观察差异;真正的差异只有「没控制台就没人看得见输出」 + 这件事,而 CUI 时代在服务里同样是隐形的。想让这种情形有专属退出码/文案,得在 CLI 侧显式判一次能力 + (`platform::win32::console` 的 `output_visible()` 就是现成的判据),那在 `crates/cli/**`、不在 M196 的 + scope 内——这里只如实记录形态,不改结构。 diff --git a/packaging/check_pe_subsystem.py b/packaging/check_pe_subsystem.py index 474145a..7027950 100644 --- a/packaging/check_pe_subsystem.py +++ b/packaging/check_pe_subsystem.py @@ -23,10 +23,11 @@ COFF 头 20 字节 → 可选头起点 → `Subsystem` 在可选头 `+0x44`( python3 packaging/check_pe_subsystem.py --gui target/release/unlucky-launcher-iced.exe \\ --cli target/release/ull.exe - python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红 + python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红(8 条:红 6 / 绿 2) `--self-test` 是这条守门的自测证据:它造合成 PE 头(GUI / CUI / 非 MZ / 截断 / 缺文件), -断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」。守门抓不住回归时它自己先红。 +断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」——共 **8 条用例(红 6 / 绿 2)**, +另加一条「Subsystem 真落在报出来的那个偏移上」的偏移自证。守门抓不住回归时它自己先红。 """ from __future__ import annotations