diff --git a/.github/workflows/conpty-compat.yml b/.github/workflows/conpty-compat.yml index ecbef4a7..06ae433a 100644 --- a/.github/workflows/conpty-compat.yml +++ b/.github/workflows/conpty-compat.yml @@ -37,3 +37,41 @@ jobs: build/Testing/Temporary/ build/conpty-results.xml build/test-artifacts/conpty/ + + windows-2025-resize-reply: + # Bounded diagnostic for #145: the stale resize reply was observed on + # windows-2025 in build.yml. This job repeats resize + client size query on + # that host and keeps the raw per-round evidence. It asserts only structural + # integrity; a stale count is reported in the artifact, not used as a gate. + runs-on: windows-2025 + timeout-minutes: 20 + env: + PYTHONUTF8: '1' + PYTHONPATH: ${{ github.workspace }} + 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 resize-reply diagnostic + run: cmake --build build --config Release --target conpty_resize_reply_probe --parallel 2 + - name: Sample resize replies across fresh sessions + shell: pwsh + run: | + $binary = "build/bin/Release/conpty_resize_reply_probe.exe" + foreach ($iteration in 1..5) { + python -m experiments.conpty.resize_reply_run --binary $binary --rounds 200 --mode raw --out "build/r145-session-$iteration" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -m experiments.conpty.resize_reply_run --binary $binary --rounds 100 --mode startup --out "build/r145-startup-$iteration" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Preserve resize-reply evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conpty-resize-reply-r145 + retention-days: 14 + if-no-files-found: warn + path: build/r145-* \ No newline at end of file diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 484daba3..694a3ffb 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -67,6 +67,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 范围 | 结果与复现入口 | 可以支持的结论与限制 | |---|---|---| | 默认核心缩小与重绘交错(#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 状态不变。 | | 持续 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。 | @@ -185,7 +186,4 @@ python -m unittest experiments.shell.test_workflow_sessions -v | 初始候选 interactive feed/投影成本 | [2026-10-25 interactive 原始批次](../experiments/measure/results/2026-10-25-core-interactive/) | 旧候选的固定 ASCII 编辑回放与交错样本;不是键盘到呈现延迟,也不能用于验收新字素实现的预算。 | | 当前成本调查 | 结果与合同 | 结论边界 | -|---|---|---| -| 候选 `.3`→`.4` 字素改动 | [固定字素验证](../experiments/shell/results/2026-10-26-streaming-graphemes/validation.json)、[性能限制与原始样本身份](research/streaming-graphemes.md)、[#54](https://github.com/JavaZeroo/termforge/issues/54) | 同宿主交错诊断中 project/interactive 吞吐中位数约下降 16%/9%;共享宿主、allocator 包装、无真实呈现/输入时延,使其不能作为正式预算或性能通过。#54 受控定位仍阻塞 #34 的性能裁决。 | - -目前没有足够证据判断 TermForge 距离 WindTerm 快或慢多少。正式结论必须同时提供负载/配置/环境身份、正确性、完成端点、观测开销、重复样本与限制。未知字段保持未知;历史校准未完成不转成要求用户反复手动运行的前置。执行规则见 [ADR-0002](adr/0002-automation-first-investigation.md),核心选型重审见 [ADR-0003](adr/0003-core-reference-and-reuse.md)。 +|---|---|---| \ No newline at end of file diff --git a/experiments/conpty/CMakeLists.txt b/experiments/conpty/CMakeLists.txt index 4c75bdaf..989bb706 100644 --- a/experiments/conpty/CMakeLists.txt +++ b/experiments/conpty/CMakeLists.txt @@ -13,3 +13,15 @@ if(MSVC) else() target_compile_options(termforge_conpty_probe PRIVATE -Wall -Wextra -Wpedantic) endif() + +# Diagnostic only (issue #145): repeats resize+client size query without +# changing the lifecycle probe's contract. Built explicitly, not registered. +add_executable(conpty_resize_reply_probe EXCLUDE_FROM_ALL resize_reply_probe.cpp) +target_compile_features(conpty_resize_reply_probe PRIVATE cxx_std_17) +target_compile_definitions(conpty_resize_reply_probe PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) +if(MSVC) + target_compile_options(conpty_resize_reply_probe PRIVATE /W4 /EHsc) +else() + target_compile_options(conpty_resize_reply_probe PRIVATE -Wall -Wextra -Wpedantic) +endif() + diff --git a/experiments/conpty/resize_reply_probe.cpp b/experiments/conpty/resize_reply_probe.cpp new file mode 100644 index 00000000..5bd8c71c --- /dev/null +++ b/experiments/conpty/resize_reply_probe.cpp @@ -0,0 +1,300 @@ +// Bounded diagnostic for the system ConPTY resize/reply ordering question in +// issue #145. It does not replace the lifecycle probe's oracle: the child is +// still the only source of truth for its own console size. Original, MIT. +#include "session.hpp" +#include +#include +#include +#include +#include +#include + +using tf_conpty::Session; +using tf_conpty::fail; + +namespace { +std::wstring executable_path() { + std::wstring result(32768, L'\0'); + const DWORD size = GetModuleFileNameW(nullptr, result.data(), static_cast(result.size())); + if (!size || size == result.size()) fail("GetModuleFileName"); + result.resize(size); return result; +} +std::string utf8(const std::wstring& text) { + if (text.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), nullptr, 0, nullptr, nullptr); + if (!size) fail("WideCharToMultiByte(size)"); + std::string result(static_cast(size), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text.data(), static_cast(text.size()), + result.data(), size, nullptr, nullptr) != size) fail("WideCharToMultiByte"); + return result; +} +std::string hex_bytes(const std::string& bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result; + result.reserve(bytes.size() * 2); + for (unsigned char byte : bytes) { result += digits[byte >> 4]; result += digits[byte & 15]; } + return result; +} +void write_child(const std::string& bytes) { + size_t offset = 0; + while (offset < bytes.size()) { + DWORD written = 0; + if (!WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), bytes.data() + offset, + static_cast(bytes.size() - offset), &written, nullptr) || !written) fail("child WriteFile"); + offset += written; + } +} +// The client reports both console dimensions so a stale buffer can be told +// apart from a stale window. Neither value is asserted here. +struct ChildSize { int buffer_columns = 0, buffer_rows = 0, window_columns = 0, window_rows = 0; }; + +std::string describe(const ChildSize& size) { + std::ostringstream out; + out << size.buffer_columns << 'x' << size.buffer_rows << ':' << size.window_columns << 'x' << size.window_rows; + return out.str(); +} +bool parse_unsigned(const std::string& text, size_t& offset, int& value) { + const size_t begin = offset; + while (offset < text.size() && text[offset] >= '0' && text[offset] <= '9') ++offset; + if (begin == offset || offset - begin > 5) return false; + value = std::stoi(text.substr(begin, offset - begin)); + return true; +} +bool parse_size(const std::string& raw, ChildSize& value) { + size_t offset = 0; + int bc = 0, br = 0, wc = 0, wr = 0; + if (!parse_unsigned(raw, offset, bc) || offset >= raw.size() || raw[offset++] != 'x') return false; + if (!parse_unsigned(raw, offset, br) || offset >= raw.size() || raw[offset++] != ':') return false; + if (!parse_unsigned(raw, offset, wc) || offset >= raw.size() || raw[offset++] != 'x') return false; + if (!parse_unsigned(raw, offset, wr) || offset != raw.size()) return false; + value = {bc, br, wc, wr}; + return true; +} +void require(bool condition, const char* detail) { + if (!condition) throw std::runtime_error(detail); +} +long long micros(std::chrono::steady_clock::time_point from, std::chrono::steady_clock::time_point to) { + return std::chrono::duration_cast(to - from).count(); +} +std::string quoted(const std::string& text) { + std::ostringstream result; result << '"'; + for (unsigned char c : text) { + if (c == '"' || c == '\\') result << '\\' << static_cast(c); + else if (c < 32) result << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(c) << std::dec; + else result << static_cast(c); + } + result << '"'; return result.str(); +} + +int child() { + const HANDLE input = GetStdHandle(STD_INPUT_HANDLE), output = GetStdHandle(STD_OUTPUT_HANDLE); + if (!SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8)) fail("SetConsoleCP"); + if (!SetConsoleMode(input, ENABLE_VIRTUAL_TERMINAL_INPUT) || + !SetConsoleMode(output, ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING | + DISABLE_NEWLINE_AUTO_RETURN)) fail("SetConsoleMode"); + write_child("TFREADY\r\n"); + std::wstring pending; + wchar_t buffer[128]; DWORD count = 0; + while (ReadConsoleW(input, buffer, 128, &count, nullptr) && count) { + for (DWORD i = 0; i < count; ++i) { + const wchar_t c = buffer[i]; + if (c != L'\r' && c != L'\n') { + if (pending.size() >= 256) return 12; + pending += c; continue; + } + if (pending.empty()) continue; + const std::string command = utf8(pending); + pending.clear(); + if (command.rfind("SIZE:", 0) == 0) { + CONSOLE_SCREEN_BUFFER_INFO info{}; + if (!GetConsoleScreenBufferInfo(output, &info)) fail("GetConsoleScreenBufferInfo"); + const ChildSize size{info.dwSize.X, info.dwSize.Y, + info.srWindow.Right - info.srWindow.Left + 1, + info.srWindow.Bottom - info.srWindow.Top + 1}; + write_child("TFSIZE:" + command.substr(5) + ":" + describe(size) + ":END\r\n"); + } else if (command.rfind("ECHO:", 0) == 0) { + write_child("TFINPUTHEX:" + hex_bytes(command) + ":END\r\n"); + write_child("TF" + command + ":END\r\n"); + } else if (command == "EXIT") { + write_child("TFEXIT:37\r\n"); + return 37; + } else { + return 13; + } + } + } + return 0; +} + +// The reply carries its own tag so a late reply from an earlier round can +// never satisfy a later request. +bool wait_reply(Session& session, const std::string& tag, std::string& raw, unsigned timeout_ms) { + const std::string prefix = "TFSIZE:" + tag + ":"; + if (!session.wait_text(prefix, timeout_ms)) return false; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + for (;;) { + const std::string output = session.output(); + const auto begin = output.find(prefix); + if (begin != std::string::npos) { + const auto end = output.find(":END", begin); + if (end != std::string::npos) { raw = output.substr(begin, end + 4 - begin); return true; } + } + if (std::chrono::steady_clock::now() >= deadline) return false; + Sleep(1); + } +} +bool reply_size(const std::string& raw, const std::string& tag, ChildSize& value) { + const std::string prefix = "TFSIZE:" + tag + ":"; + if (raw.rfind(prefix, 0) != 0) return false; + const std::string body = raw.substr(prefix.size()); + if (body.size() < 4 || body.compare(body.size() - 4, 4, ":END") != 0) return false; + return parse_size(body.substr(0, body.size() - 4), value); +} + +// Mirrors the recorded failing sample: a brand-new session, one initial size +// query, one UTF-8 echo, then resize + size query. +int host_startup(unsigned sessions) { + int exit_status = 0; + std::cout << "{\"ready\":true,\"mode\":\"startup\"}" << std::endl; + for (unsigned index = 1; index <= sessions; ++index) { + Session session; + std::string raw; + ChildSize initial{}, reply{}; + bool ready = false, echo = false, replied = false, parsed = false; + long long resize_to_send_us = 0, resize_to_reply_us = 0; + bool vt_before_reply = false; + std::exception_ptr failure; + try { + session.start(executable_path()); + ready = session.wait_text("TFREADY"); + require(ready, "ready marker timeout"); + session.send("SIZE:init\r"); + require(wait_reply(session, "init", raw, 5000), "initial size reply timeout"); + require(reply_size(raw, "init", initial), "initial size reply malformed"); + const std::string payload = "alpha-\xe6\xb1\x89\xe5\xad\x97-123"; + session.send("ECHO:" + payload + "\r"); + echo = session.wait_text("TFECHO:" + payload + ":END"); + require(echo, "UTF-8 echo mismatch"); + const auto resize_begin = std::chrono::steady_clock::now(); + session.resize(120, 40); + const std::string tag = "s" + std::to_string(index); + session.send("SIZE:" + tag + "\r"); + const auto send_done = std::chrono::steady_clock::now(); + replied = wait_reply(session, tag, raw, 5000); + const auto reply_done = std::chrono::steady_clock::now(); + parsed = replied && reply_size(raw, tag, reply); + resize_to_send_us = micros(resize_begin, send_done); + resize_to_reply_us = micros(resize_begin, reply_done); + vt_before_reply = session.output().find("\x1b[8;40;120t") != std::string::npos; + session.send("EXIT\r"); + const bool exited = session.wait_exit(5000); + require(exited && session.exit_code == 37, "normal exit code mismatch"); + } catch (const std::exception& error) { + std::cerr << "session " << index << ": " << error.what() << '\n'; + exit_status = 1; + } + session.close(); + std::cout << "{\"session\":" << index + << ",\"ready\":" << (ready ? "true" : "false") + << ",\"echo\":" << (echo ? "true" : "false") + << ",\"request\":\"120x40\"" + << ",\"initial\":" << quoted(describe(initial)) + << ",\"replied\":" << (replied ? "true" : "false") + << ",\"reply\":" << quoted(parsed ? describe(reply) : std::string{}) + << ",\"buffer_matches_request\":" << (parsed && reply.buffer_columns == 120 && reply.buffer_rows == 40 ? "true" : "false") + << ",\"window_matches_request\":" << (parsed && reply.window_columns == 120 && reply.window_rows == 40 ? "true" : "false") + << ",\"resize_vt_before_reply\":" << (vt_before_reply ? "true" : "false") + << ",\"resize_to_send_us\":" << resize_to_send_us + << ",\"resize_to_reply_us\":" << resize_to_reply_us + << ",\"children_after\":" << session.active_after + << ",\"exit_code\":" << session.exit_code + << ",\"cleanup_complete\":" << (session.cleanup_complete ? "true" : "false") + << "}" << std::endl; + if (exit_status) break; + } + std::cout << "{\"complete\":" << (exit_status == 0 ? "true" : "false") << "}" << std::endl; + return exit_status; +} + +int host(unsigned rounds, bool barrier) { + Session session; + int exit_status = 0; + try { + session.start(executable_path()); + require(session.wait_text("TFREADY"), "ready marker timeout"); + session.send("SIZE:init\r"); + std::string raw; + require(wait_reply(session, "init", raw, 5000), "initial size reply timeout"); + ChildSize initial{}; + require(reply_size(raw, "init", initial), "initial size reply malformed"); + std::cout << "{\"ready\":true,\"initial\":" << quoted(describe(initial)) + << ",\"backend\":" << quoted(session.backend_identity()) << "}" << std::endl; + for (unsigned index = 1; index <= rounds; ++index) { + // Alternate so each round has to apply a real size change. + const bool wide = (index % 2) == 1; + const short columns = wide ? 120 : 80, rows = wide ? 40 : 24; + const auto resize_begin = std::chrono::steady_clock::now(); + session.resize(columns, rows); + const auto resize_return = std::chrono::steady_clock::now(); + std::ostringstream resize_vt; + resize_vt << "\x1b[8;" << rows << ';' << columns << 't'; + const bool barrier_vt_seen = barrier && session.wait_text(resize_vt.str(), 500); + const std::string tag = "r" + std::to_string(index); + session.send("SIZE:" + tag + "\r"); + const auto send_done = std::chrono::steady_clock::now(); + const bool replied = wait_reply(session, tag, raw, 5000); + const auto reply_done = std::chrono::steady_clock::now(); + ChildSize reply{}; + const bool parsed = replied && reply_size(raw, tag, reply); + const std::string output = session.output(); + std::cout << "{\"round\":" << index + << ",\"request\":" << quoted(std::to_string(columns) + "x" + std::to_string(rows)) + << ",\"replied\":" << (replied ? "true" : "false") + << ",\"parsed\":" << (parsed ? "true" : "false") + << ",\"reply\":" << quoted(parsed ? describe(reply) : std::string{}) + << ",\"buffer_matches_request\":" << (parsed && reply.buffer_columns == columns && reply.buffer_rows == rows ? "true" : "false") + << ",\"window_matches_request\":" << (parsed && reply.window_columns == columns && reply.window_rows == rows ? "true" : "false") + << ",\"resize_vt_before_reply\":" << (output.find(resize_vt.str()) != std::string::npos ? "true" : "false") + << ",\"barrier\":" << (barrier ? "true" : "false") + << ",\"barrier_vt_seen\":" << (barrier_vt_seen ? "true" : "false") + << ",\"resize_to_send_us\":" << micros(resize_begin, send_done) + << ",\"resize_to_reply_us\":" << micros(resize_begin, reply_done) + << "}" << std::endl; + if (!parsed) { exit_status = 1; break; } + } + session.send("EXIT\r"); + const bool exited = session.wait_exit(5000); + if (!exited || session.exit_code != 37) { + std::cerr << "normal exit mismatch\n"; + exit_status = 1; + } + std::cout << "{\"complete\":" << (exit_status == 0 ? "true" : "false") << "}" << std::endl; + } catch (const std::exception& error) { + session.close(); + std::cerr << error.what() << '\n'; + return 1; + } + session.close(); + return exit_status; +} +} // namespace + +int main(int argc, char** argv) { + try { + if (argc == 2 && std::string(argv[1]) == "--child") return child(); + if (argc != 4 || std::string(argv[1]) != "--host") return 2; + const unsigned rounds = static_cast(std::stoul(argv[2])); + const std::string mode = argv[3]; + if (!rounds || rounds > 1000 || (mode != "raw" && mode != "barrier" && mode != "startup")) return 2; + // The supervisor assigns this host to its job before sending this gate. + std::string gate; + if (!std::getline(std::cin, gate) || gate != "GO") return 2; + if (mode == "startup") return host_startup(rounds); + return host(rounds, mode == "barrier"); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} \ No newline at end of file diff --git a/experiments/conpty/resize_reply_run.py b/experiments/conpty/resize_reply_run.py new file mode 100644 index 00000000..1126f610 --- /dev/null +++ b/experiments/conpty/resize_reply_run.py @@ -0,0 +1,116 @@ +"""Bounded diagnostic runner for the system ConPTY resize reply (issue #145). + +The probe host runs inside an owned job; every round resizes the pseudo console +and immediately asks the client for its own console size. The runner only +collects evidence; it asserts nothing about the observed size. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import platform +import statistics +import subprocess +from datetime import datetime, timezone +from pathlib import Path + + +def load_owned_job(): + # Reuse the lifecycle suite's supervisor rather than copying job code. + 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.OwnedJob + + +def summarize(rounds): + stale_buffer = [row for row in rounds if not row["buffer_matches_request"]] + stale_window = [row for row in rounds if not row["window_matches_request"]] + disagreement = [row for row in rounds if row["buffer_matches_request"] != row["window_matches_request"]] + send_us = [row["resize_to_send_us"] for row in rounds] + reply_us = [row["resize_to_reply_us"] for row in rounds] + timing = lambda values: ({"min": min(values), "median": statistics.median(values), "max": max(values)} + if values else {}) + return { + "rounds": len(rounds), + "stale_buffer": len(stale_buffer), + "stale_window": len(stale_window), + "buffer_window_disagreement": len(disagreement), + "resize_vt_before_reply": sum(1 for row in rounds if row["resize_vt_before_reply"]), + "resize_to_send_us": timing(send_us), + "resize_to_reply_us": timing(reply_us), + "stale_samples": stale_buffer[:20], + "disagreement_samples": disagreement[:20], + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--rounds", type=int, default=100) + parser.add_argument("--mode", choices=("raw", "barrier", "startup"), default="raw") + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + if not 1 <= args.rounds <= 1000: + parser.error("rounds must be 1..1000") + if not args.binary.is_file(): + parser.error("probe binary missing") + args.out.mkdir(parents=True, exist_ok=False) + OwnedJob = load_owned_job() + job = OwnedJob() + process = None + result = { + "schema": "termforge-conpty-resize-reply-v1", + "status": "failed", + "mode": args.mode, + "rounds_requested": args.rounds, + "started_utc": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), + "binary": str(args.binary.resolve()), + "binary_sha256": hashlib.sha256(args.binary.read_bytes()).hexdigest(), + "source_sha256": { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(Path(__file__).resolve().with_name(name) + for name in ("resize_reply_probe.cpp", "session.hpp", "backend.hpp", + "resize_reply_run.py"))}, + "performance_claim": False, + } + try: + process = subprocess.Popen( + [str(args.binary.resolve()), "--host", str(args.rounds), args.mode], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW) + job.assign(process.pid) + stdout, stderr = process.communicate(b"GO\n", timeout=300) + result["returncode"] = process.returncode + result["stderr"] = stderr.decode("utf-8", "replace") + result["job_after"] = job.drain() + records = [json.loads(line) for line in stdout.decode("utf-8", "replace").splitlines() if line.strip()] + if len(records) < 2 or not records[0].get("ready"): + raise RuntimeError("probe did not report a ready record") + result["ready"] = records[0] + result["complete"] = records[-1] + result.update(summarize(records[1:-1])) + if process.returncode != 0: + raise RuntimeError(f"probe exit {process.returncode}: {result['stderr'][:500]}") + if result["job_after"]["active"] != 0: + raise RuntimeError(f"owned job not drained: {result['job_after']}") + result["status"] = "passed" + except BaseException as error: + result["error"] = str(error) + raise + finally: + if process is not None and process.poll() is None: + job.terminate() + process.kill() + process.communicate(timeout=10) + job.close() + (args.out / "resize-reply.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/experiments/conpty/results/2026-10-29-resize-reply-r145/README.md b/experiments/conpty/results/2026-10-29-resize-reply-r145/README.md new file mode 100644 index 00000000..3618d776 --- /dev/null +++ b/experiments/conpty/results/2026-10-29-resize-reply-r145/README.md @@ -0,0 +1,46 @@ +# 系统 ConPTY resize 后客户端尺寸回复:有界重复采样(#145) + +结论先行:**把“resize 成功后立即排入 SIZE 查询”这一孤立时序放大到 2800 个样本,没有复现 [#145](../..#145) 记录的旧尺寸回复。** 客户端 buffer 与 window 从未分歧,resize VT 在全部样本中先于回复出现。因此“ResizePseudoConsole 与紧随的输入之间存在必然的时序竞态”不能解释已记录的 CI 失败;那两条失败仍是真实失败,根因未知。 + +## 已记录的失败(不因本切片改变) + +- [PR141 默认工作流 40742159646](https://github.com/JavaZeroo/termforge/actions/runs/40742159646):watchdog 前的 startup(normal) 样本,`resized dimensions mismatch`,ready/echo 1、resized 0,child 回复仍为 `TFSIZE:80x24:END`。 +- [PR168 push 40814207316](https://github.com/JavaZeroo/termforge/actions/runs/40814207316):close 第三轮的 resize reply gate,同样未见 120x40;该次 596B 捕获与上一条逐字节一致(SHA256 `c63fc584…6085`)。 +- 同一 tree 的 PR 运行为 61/61。原失败、原件保留在该 issue,未被本切片重跑或覆盖。 + +## 本切片做了什么 + +新增独立诊断(不改原探针、其 5 秒等待与新尺寸 oracle): + +- `conpty_resize_reply_probe`:真实系统 ConPTY 会话;客户端在每条输入上报告**自己**的 `GetConsoleScreenBufferInfo`,同时给出 buffer(`dwSize`) 与 window(`srWindow`) 两个尺寸,回复带独立 tag,慢回复不会被下一条请求误认。 +- `raw` 模式:同一会话交替 120×40 / 80×24,每次 resize 返回后立即排入查询,共 200 轮/会话。 +- `startup` 模式:每次全新会话,复刻记录的失败序列 `ready → SIZE → ECHO → resize(120×40) → SIZE`。 +- `resize_reply_run.py`:复用生命周期套件的 OwnedJob 监管,记录平台、源/二进制身份、每轮尺寸与微秒级时序;只断言结构完整性(可解析、tag 匹配、Job 归零),陈旧计数写进证据而不作为本切片的门槛。 + +## 结果 + +| 环境 | 模式 | 样本 | 陈旧回复 | buffer/window 分歧 | resize VT 先于回复 | 回复中位耗时 | +|---|---|---|---|---|---|---| +| Windows 11 10.0.26200(本机) | raw | 200 | 0 | 0 | 200/200 | 11.4 ms | +| Windows 11 10.0.26200(本机) | startup | 100 | 0 | 0 | 100/100 | 15.6 ms | +| hosted Windows Server 2025 10.0.26100(run 40841303342) | raw | 1000 | 0 | 0 | 1000/1000 | 8.7–12.6 ms | +| hosted Windows Server 2025 10.0.26100(run 40841934880) | raw | 1000 | 0 | 0 | 1000/1000 | 9.6–12.6 ms | +| hosted Windows Server 2025 10.0.26100(run 40841934880) | startup | 500 | 0 | 0 | 500/500 | 16.0 ms | + +- 全部 2800 个样本中位回复耗时 8.7–16.1 ms,最大 64.7 ms,远低于原探针 5 秒等待:失败样本不是“等得不够久”。 +- 若陈旧回复来自“window 已更新、buffer 未更新”的瞬时状态,本探针应记录到 buffer/window 分歧;实际为 0。 +- 运行入口见 [conpty-compat.yml](../../../../.github/workflows/conpty-compat.yml) 的 `windows-2025-resize-reply` job(仅 workflow_dispatch,不进入常规 CI 门槛)。 + +固定身份见 [validation.json](validation.json)(源哈希、各环境二进制哈希、运行编号与样本计数)。 + +## 边界 + +- 本切片不解释那两条 CI 失败,也不关闭 #145/#38;只否定一个具体时序假设。 +- 未覆盖:原始探针在完整 ctest 套件中的上下文、hosted 运行器镜像/host 版本差异、以及“每次运行的第一条会话”这类稀有例外。本切片的 job 在全新 VM 上启动,但没有复刻 build.yml 同期其它测试的并发与顺序。 +- 探针二进制在本地与 hosted 构建之间哈希不同(构建路径/PDB 身份差异),源哈希一致;逐运行 EXE 与系统 DLL 身份未单独取证。 +- 这里的耗时只用于排除 5 秒等待,不是性能测量,也不构成任何预算。 + +## 下一步(保持有界) + +1. 在同一 hosted 运行器上跑**原探针本身**的有界重复批次,确认 clone 与原 oracle 在同宿主同镜像下的失败率一致;若 clone 与原探针都为零,说明失败依赖尚未建模的宿主/镜像条件。 +2. 若继续追查,优先固定“首个会话/首条 resize”的宿主状态(会话创建时刻、渲染器就绪、并发测试顺序),而不是扩大同一时序的样本量。 diff --git a/experiments/conpty/results/2026-10-29-resize-reply-r145/validation.json b/experiments/conpty/results/2026-10-29-resize-reply-r145/validation.json new file mode 100644 index 00000000..3b5f4062 --- /dev/null +++ b/experiments/conpty/results/2026-10-29-resize-reply-r145/validation.json @@ -0,0 +1,79 @@ +{ + "schema": "termforge-conpty-resize-reply-r145-v1", + "issue": 145, + "commit": "d66f9f3", + "source_sha256": { + "resize_reply_probe.cpp": "03efd353c46a3f8ced23b32450c3af9b8c893ef77b820a479243956c1cefa356", + "resize_reply_run.py": "e3ff4b07ee7d844d4b9a81132575fbe1c40878c810c007125f0153acdf481fe7", + "session.hpp": "c85872fdf3ee58d4e61ad3bfd015caf2cba740682e077c93671e460079e6ff3c", + "backend.hpp": "c64309b7abe229186dece465b795883c2303389ffe93a9fcc648021d057b9d6a" + }, + "binary_sha256": { + "Windows-11-10.0.26200-SP0": "9581295695efc909c33219413f5ec12c1f8cce9989b213ec9c6b5349a4a6c9a0", + "Windows-2025Server-10.0.26100-SP0": "03287079fe1ae557a19e15bece3b04644057d3340d3130b451e584397da8499b" + }, + "hosted_runs": [ + { + "run": 40841303342, + "job": "windows-2025-resize-reply", + "mode": "raw", + "sessions": 5, + "rounds_per_session": 200, + "samples": 1000 + }, + { + "run": 40841934880, + "job": "windows-2025-resize-reply", + "mode": "raw", + "sessions": 5, + "rounds_per_session": 200, + "samples": 1000 + }, + { + "run": 40841934880, + "job": "windows-2025-resize-reply", + "mode": "startup", + "sessions": 5, + "sessions_per_job": 5, + "samples": 500 + }, + { + "run": "local", + "job": "win11", + "mode": "raw", + "samples": 200 + }, + { + "run": "local", + "job": "win11", + "mode": "startup", + "samples": 100 + } + ], + "totals": { + "hosted_alternating_resize_samples": 2000, + "hosted_fresh_session_samples": 500, + "local_alternating_resize_samples": 200, + "local_fresh_session_samples": 100, + "stale_buffer_replies": 0, + "stale_window_replies": 0, + "buffer_window_disagreement": 0, + "replies_with_resize_vt_before_reply": "2800/2800" + }, + "observed": { + "resize_to_reply_us_median_range": [ + 8686.5, + 16072 + ], + "resize_to_reply_us_max": 64735, + "original_probe_wait_ms": 5000 + }, + "conclusion": "resize 成功后立即排入 size 查询这一孤立模式在 win11 与 hosted windows-2025 上均未复现旧尺寸回复;buffer 与 window 从未分歧,成对时序假设不成立。已记录的两条 CI 失败仍为真实失败,根因未知。", + "limitations": [ + "未覆盖原始探针在 ctest 下的完整套件上下文、运行器镜像差异或首次会话例外", + "本切片不修改原始探针、其 5 秒等待或新尺寸 oracle,也不改变 #145/#38 状态", + "成对样本不是性能测量;机器身份分别为 Windows 11 10.0.26200 与 hosted Windows Server 2025 10.0.26100", + "探针二进制在本地与 hosted 构建间哈希不同(构建路径/PDB 身份差异),源哈希一致" + ], + "performance_claim": false +}