diff --git a/.github/workflows/conpty-compat.yml b/.github/workflows/conpty-compat.yml index 06ae433a..be4a9795 100644 --- a/.github/workflows/conpty-compat.yml +++ b/.github/workflows/conpty-compat.yml @@ -74,4 +74,84 @@ jobs: name: conpty-resize-reply-r145 retention-days: 14 if-no-files-found: warn - path: build/r145-* \ No newline at end of file + path: build/r145-* + + windows-2025-original-probe-repeat: + # #145 second slice: repeat the *original* lifecycle sample on the hosted + # image that produced the stale reply. Each iteration keeps its own result; + # a red sample carries the probe's native failure diagnostics. + runs-on: windows-2025 + timeout-minutes: 25 + env: + PYTHONUTF8: '1' + PYTHONPATH: ${{ github.workspace }} + TERMFORGE_TEST_ARTIFACTS: ${{ github.workspace }}/build/test-artifacts + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - name: Configure + run: cmake -S . -B build + - name: Build lifecycle probe + run: cmake --build build --config Release --target termforge_conpty_probe --parallel 2 + - name: Repeat the recorded lifecycle sample + shell: pwsh + run: | + $binary = Get-ChildItem build -Recurse -Filter termforge_conpty_probe.exe | Select-Object -First 1 + if (-not $binary) { throw "lifecycle probe binary missing" } + python -m experiments.conpty.repeat_run --binary $binary.FullName --iterations 40 --repeats 3 --mode normal --out build/r145-repeat + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Preserve repeat evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conpty-probe-repeat-r145 + retention-days: 14 + if-no-files-found: warn + path: | + build/r145-repeat + build/test-artifacts/conpty + + windows-2025-probe-after-conpty-tests: + # #145: the recorded failure happened inside build.yml's long ctest job, not + # in an isolated probe job. This job runs the console-heavy ConPTY tests + # first (their exit is recorded, not gated), then samples the original probe. + runs-on: windows-2025 + timeout-minutes: 25 + env: + PYTHONUTF8: '1' + PYTHONPATH: ${{ github.workspace }} + TERMFORGE_TEST_ARTIFACTS: ${{ github.workspace }}/build/test-artifacts + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - name: Build the console-heavy fixtures + run: | + cmake -S . -B build + cmake --build build --config Release --target termforge_conpty_probe conpty_ctrl_c_probe conpty_margin_probe --parallel 2 + - name: Run the console-heavy tests first + shell: pwsh + run: | + ctest --test-dir build -C Release -R "^(conpty_ctrl_c|conpty_margin)$" --output-on-failure + Write-Output "predecessor_exit=$LASTEXITCODE" + exit 0 + - name: Sample the original probe after the predecessors + shell: pwsh + run: | + $binary = Get-ChildItem build -Recurse -Filter termforge_conpty_probe.exe | Select-Object -First 1 + if (-not $binary) { throw "lifecycle probe binary missing" } + python -m experiments.conpty.repeat_run --binary $binary.FullName --iterations 20 --repeats 3 --mode normal --out build/r145-after-conpty + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Preserve post-predecessor evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conpty-probe-after-tests-r145 + retention-days: 14 + if-no-files-found: warn + path: | + build/r145-after-conpty + build/test-artifacts diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 26edb7d7..e36b6486 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -70,6 +70,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 行存储封存与结构单一发布(#169 第二片) | [固定结果](../experiments/core/results/2026-10-29-sealed-row-storage/README.md)、[验证身份](../experiments/core/results/2026-10-29-sealed-row-storage/validation.json) | `Screen::lines/history` 私有,只读口 + 23 文件/363 处迁移;Find overlay 改文档 rank;默认 Release 构建 exit 0、核心 8/8、扩展快测 20/20、candidate 源 /W4 编译 0。无新 VT/重排行为;运行中跨重排 Search、Qt 与候选运行由宿主作业复核。 | | 默认核心缩小与重绘交错(#156) | [固定四-case机制诊断](../experiments/shell/results/2026-10-29-resize-repaint-witness/README.md)、[脱敏验证](../experiments/shell/results/2026-10-29-resize-repaint-witness/validation.json) | A/C保留2499B,B/D在第五段重绘首次失效且行分配路径不同;只见证机制,原CI切点/根因未知,未修生产。witness结束后Job仍active1,发起一次终止后归零;成员身份/逐句柄退出未知,不称全树自然退出。 | | 系统 ConPTY resize 后的客户端尺寸回复(#145 未关闭) | [有界重复采样](../experiments/conpty/results/2026-10-29-resize-reply-r145/README.md)、[固定验证身份](../experiments/conpty/results/2026-10-29-resize-reply-r145/validation.json) | 新增 tag 化探针:hosted 2000 次交替 resize + 500 次全新会话、本机 200 + 100,均无陈旧回复,buffer/window 无分歧,resize VT 全部先于回复,回复中位 8.7–16.1ms。只否定“resize 后立即查询存在必然竞态”;原两条 CI 失败、原始探针/5 秒等待与 #145/#38 状态不变。 | +| 原生命周期探针重复与控制台测试后采样(#145 未关闭) | [固定结果](../experiments/conpty/results/2026-10-29-probe-repeat-r145/README.md)、[验证身份](../experiments/conpty/results/2026-10-29-probe-repeat-r145/validation.json) | 隔离重复 40×4=160 会话 0 红;先跑 conpty_ctrl_c/conpty_margin 后采样 20×4=80 会话,1 红但 resize 回复正确(resized=1、120x40、exit 37),原因是会话后线程基线 4→6 超过 1s 结算窗口。旧尺寸回复仍未复现;该线程稳态红归 #38 族,不并入 #145 根因。 | | 持续 cmd 输出与原始 Ctrl-C 回归(#36 未关闭) | [受监督入口](../experiments/conpty/README.md#持续-cmd-输出与-ctrlc-独立回归)、[三次 fresh 公开记录及本地字节复核](../experiments/conpty/results/2026-10-26-ctrl-c-retest/validation.json) | 原百万次输出、单次 ETX、五秒期限和退出 37 保留;三次 fresh 原始字节/Job0 本地重算。后续成功未解释历史失败,未改产品 Ctrl-C 路径。 | | 持续输出中断的原失败记录 | [#36](https://github.com/JavaZeroo/termforge/issues/36)、[ConPTY 输入机制研究](research/conpty-ctrl-c-input.md)、[原 R1 历史证据](../experiments/shell/results/2026-10-23-r1-foundations/validation.json) | 原 Windows 11 回归中的 normal/resize 超时与 ETW/插桩观察;调试扰动及管道读完成缺口保留。不是当前全套结果,后续未复现不能抹去此失败。 | | controller B 正常结束后 cmd 退出未验收(#44) | [原失败脱敏记录](../experiments/shell/results/2026-10-26-controller-exit/validation.json)、[新退出诊断及有界重放](../experiments/shell/results/2026-10-26-controller-exit-observation/validation.json)、[关闭合同](https://github.com/JavaZeroo/termforge/issues/44) | 原 child 收完 32B、退出 0/join/Job0;cmd 只留下 close 后 0xC000013A,close 前未知。新观察分列输入、wait、pre-close 与清理;多轮未复现不构成历史修复,继续阻塞 #38。 | diff --git a/experiments/conpty/repeat_run.py b/experiments/conpty/repeat_run.py new file mode 100644 index 00000000..3d0d00e9 --- /dev/null +++ b/experiments/conpty/repeat_run.py @@ -0,0 +1,98 @@ +"""Bounded repeat of the recorded lifecycle sample (issue #145). + +Every iteration runs one gated probe host inside its own owned job and keeps the +independent result. A failing iteration retains the native failure record +instead of being retried to green. +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import platform +import statistics +import time +from datetime import datetime, timezone +from pathlib import Path + + +def load_run(): + path = Path(__file__).resolve().with_name("run.py") + spec = importlib.util.spec_from_file_location("termforge_conpty_run", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def summarize(records, iterations, repeats, mode): + failures = [row for row in records if row["status"] != "passed"] + elapsed = [row["elapsed_s"] for row in records] + return { + "iterations": iterations, + "sessions_per_iteration": repeats + 1, + "sessions_total": iterations * (repeats + 1), + "mode": mode, + "failures": len(failures), + "elapsed_s": {"min": min(elapsed), "median": statistics.median(elapsed), "max": max(elapsed)} if elapsed else {}, + "failure_samples": failures[:5], + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--iterations", type=int, default=40) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--mode", choices=("normal", "large", "cancel", "close"), default="normal") + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + if not 1 <= args.iterations <= 200: + parser.error("iterations must be 1..200") + if not 1 <= args.repeats <= 20: + parser.error("repeats must be 1..20") + if not args.binary.is_file(): + parser.error("probe binary missing") + args.out.mkdir(parents=True, exist_ok=False) + run = load_run() + summary = { + "schema": "termforge-conpty-probe-repeat-v1", + "issue": 145, + "status": "running", + "started_utc": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), + "binary": str(args.binary.resolve()), + "performance_claim": False, + } + records = [] + for index in range(1, args.iterations + 1): + begin = time.monotonic() + try: + result = run.execute(args.binary, args.mode, args.repeats, timeout=120) + record = {"iteration": index, "status": result["status"], + "elapsed_s": round(time.monotonic() - begin, 3)} + except BaseException as error: + detail = getattr(error, "conpty_result", {}) + record = {"iteration": index, "status": "failed", + "elapsed_s": round(time.monotonic() - begin, 3), + "error": str(error), + # Keep the native diagnostics that the lifecycle probe + # already collects for a red sample. + "failure": {key: detail.get(key) for key in + ("returncode", "timed_out", "job_after", "startup", + "stdout", "stderr", "error") if key in detail}} + records.append(record) + summary.update(summarize(records, args.iterations, args.repeats, args.mode)) + summary["completed_iterations"] = index + (args.out / "repeat.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + print(f"iteration {index}: {record['status']} ({record['elapsed_s']}s)", flush=True) + summary["status"] = "passed" if not summary["failures"] else "failed" + summary["finished_utc"] = datetime.now(timezone.utc).isoformat() + (args.out / "repeat.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + # A red sample is the evidence we are looking for, not a job failure. + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/conpty/results/2026-10-29-probe-repeat-r145/README.md b/experiments/conpty/results/2026-10-29-probe-repeat-r145/README.md new file mode 100644 index 00000000..2b13b6d1 --- /dev/null +++ b/experiments/conpty/results/2026-10-29-probe-repeat-r145/README.md @@ -0,0 +1,44 @@ +# 原生命周期探针重复与“测试后采样”(#145) + +本片回答一个具体问题:**把 [#145](../..#145) 记录的同一条样本原样重复,或在其它 ConPTY 测试之后采样,能否复现 resize 后仍回复旧尺寸?** + +结论:**都没有复现 resize 旧尺寸**;但在“先跑控制台密集测试、再采样”的条件下,复现了另一类红色——探针的会话后**线程基线**在 1 秒结算窗口内未回到基线(4→6),`steady_resources=false`,该样本的 resize 回复本身是正确的(`resized=1`、`TFSIZE:120x40`、child exit 37)。 + +## 方法 + +新增 `experiments/conpty/repeat_run.py`:每次迭代复用既有 `run.execute()`(自有 Job、GO 门、原 5 秒等待、原断言),保留每次独立结果;红色迭代保留探针原始失败字段,不重跑取绿。两个 dispatch-only 作业: + +| 作业 | 前置 | 采样 | +|---|---|---| +| `windows-2025-original-probe-repeat` | 无(隔离) | 40 次 ×(1 startup + 3 normal) | +| `windows-2025-probe-after-conpty-tests` | 先跑 `conpty_ctrl_c`(5.31s)与 `conpty_margin`(0.31s),两者通过;其退出只记录不设门 | 20 次 ×(1 startup + 3 normal) | + +## 结果 + +| 条件 | run/job | 样本 | resize 旧尺寸 | 其它红色 | +|---|---|---|---|---| +| isolated(hosted windows-2025) | [40847785625](https://github.com/JavaZeroo/termforge/actions/runs/40847785625) | 160 | 0 | 0 | +| 控制台密集测试之后 | [40849464556](https://github.com/JavaZeroo/termforge/actions/runs/40849464556) | 80 | 0 | 1(iteration 10, repeat 1) | +| 对照:克隆探针(#171) | 40841303342 / 40841934880 | 2800 | 0 | 0 | + +- 隔离重复:每迭代 1.312 / 1.344 / 1.390 s(min/median/max),40/40 完成。 +- 测试后采样:20/20 完成;唯一红色样本为 `valid=false`,原因 `steady_resources=false`:`threads_before=4`、`threads_after=6`、`resource_settle_ms=1011.02`;同一样本 `resized=1`、`output_bytes=608`、`exit_code=37`、`read_error=109`,最终 VT 以 `TFSIZE:120x40:END` 结束。同迭代的 startup 样本因 `startup=true` 豁免基线比较(`handles 45→51` 属已知首次初始化)。 +- 未捕获任何 `resized=0` 或 80×24 旧回复。 + +## 解释与边界 + +- `#145` 的旧尺寸回复在这三种条件下都没有复现;因此“原样本 × 原宿主 × 隔离或轻度前置”仍不足以触发,根因继续未知。 +- 复现的红是**另一类机构**:会话结束后控制台宿主线程比基线多 2 个且超过 1 秒结算窗口。它属于既有 `steady_resources` 夹具合同的边界(首次初始化只对 startup 豁免),与 resize 回复无关;记录为 #38 族的可靠性观察,不并入 #145 根因。 +- 本片没有改变原探针、其 5 秒等待、断言或任何产品代码;两个作业都是 `workflow_dispatch`,不进入常规 CI 门槛。 +- 分支上曾有“先跑完整 ctest 再采样”的作业,因该 dispatch 作业缺少 build.yml 的夹具准备而在 `benchmark_tools`/`differential`/`workflow_*`/`shell` 失败,未进入采样阶段;该失败保留在 run 40848143873,作为“作业环境不等价”的反例,不用于 #145 推断。 + +## 复现 + +```powershell +cmake -S . -B build +cmake --build build --config Release --target termforge_conpty_probe --parallel 2 +python -m experiments.conpty.repeat_run --binary build/bin/Release/termforge_conpty_probe.exe ` + --iterations 40 --repeats 3 --mode normal --out build/r145-repeat +``` + +作业入口见 [conpty-compat.yml](../../../../.github/workflows/conpty-compat.yml) 的两个 `windows-2025-*` 作业。 diff --git a/experiments/conpty/results/2026-10-29-probe-repeat-r145/validation.json b/experiments/conpty/results/2026-10-29-probe-repeat-r145/validation.json new file mode 100644 index 00000000..294d9997 --- /dev/null +++ b/experiments/conpty/results/2026-10-29-probe-repeat-r145/validation.json @@ -0,0 +1,57 @@ +{ + "schema": "termforge-conpty-probe-repeat-r145-v1", + "issue": 145, + "commit": "f1c48d9", + "runs": { + "isolated": { + "run": 40847785625, + "job": "windows-2025-original-probe-repeat", + "iterations": 40, + "sessions": 160, + "failures": 0, + "elapsed_s": { + "min": 1.312, + "median": 1.344, + "max": 1.39 + } + }, + "after_console_tests": { + "run": 40849464556, + "job": "windows-2025-probe-after-conpty-tests", + "predecessors": [ + "conpty_ctrl_c (5.31s, passed)", + "conpty_margin (0.31s, passed)" + ], + "iterations": 20, + "sessions": 80, + "failures": 1 + }, + "clone_control": { + "runs": [ + 40841303342, + 40841934880 + ], + "samples": 2800, + "stale": 0 + } + }, + "revealed_red": { + "iteration": 10, + "sample": "normal repeat 1", + "valid": false, + "reason": "steady_resources=false", + "threads_before": 4, + "threads_after": 6, + "resource_settle_ms": 1011.02, + "resized": 1, + "output_bytes": 608, + "exit_code": 37, + "read_error": 109, + "note": "resize reply was correct (TFSIZE:120x40); the red is the post-session thread baseline, not a stale dimension" + }, + "preserved_failures": [ + "run 40848143873: whole-suite predecessor variant failed on fixtures the dispatch job does not prepare (benchmark_tools, differential, workflow_sessions, workflow_resources, workflow_resource_phases, shell); sampling stage never ran. Environment not equivalent to build.yml." + ], + "conclusion": "#145 的旧尺寸回复在隔离重复、控制台测试后采样和克隆探针放大三种条件下均未复现;复现的是另一类线程稳态红色。", + "performance_claim": false +}