From 480681263fdd270c73469cf2bab406f5dc6bb83a Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:33:45 +0800 Subject: [PATCH 1/4] test(qt): verify DSR 6n reply chain through Qt write-back to real child (#28) Add a bounded, test-only layered fixture for the implemented cursor-position query reply on the formal C++/Qt/fixed-ConPTY path. The fixed scenario writes two printable ASCII cells from home and asks ESC[6n on one primary 80x24 screen; the independent expectation ESC[1;3R comes from the confirmed core reply contract. - raw fixed-ConPTY silent control: conhost does not answer the child itself; the child observes its bounded timeout only - raw fixed-ConPTY distinct-reply control: host-written bytes reach the real child unchanged - product SessionModel path: parsed query at transport offset 23, core reply written back through the shared input queue, real child receives exactly ESC[1;3R with matching PID and verified cleanup No product behavior, default entry or query support is changed. DA1 stays strict-only/recovery-silent; DA2/OSC remain unimplemented. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- CMakeLists.txt | 14 ++ src/qt/query_reply_test.cpp | 442 ++++++++++++++++++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 src/qt/query_reply_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9929279..ed925eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -882,6 +882,20 @@ if(TERMFORGE_BUILD_QT_SHELL) "$" "$/platforms/" VERBATIM) + add_executable(qt_query_reply_test src/qt/query_reply_test.cpp) + set_target_properties(qt_query_reply_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-query-reply-bin/$") + termforge_qt_entry(qt_query_reply_test) + target_link_libraries(qt_query_reply_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_query_reply_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_query_reply COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_query_reply PROPERTIES TIMEOUT 45 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") if(TERMFORGE_ENABLE_RESIZE_REPRO) add_test(NAME qt_terminal_modes_resize_repro COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$" --resize) diff --git a/src/qt/query_reply_test.cpp b/src/qt/query_reply_test.cpp new file mode 100644 index 0000000..b8b1a1c --- /dev/null +++ b/src/qt/query_reply_test.cpp @@ -0,0 +1,442 @@ +// Layered DSR 6n reply evidence for the formal C++/Qt/fixed-ConPTY path. +// +// The confirmed core contract (experiments/core/reply_test.cpp) answers a cursor +// position query with ESC[row;colR, both 1-based. This test fixes one bounded +// primary-screen scenario (write two printable ASCII cells from home, then ask +// ESC[6n) and separates four layers: +// +// 1. raw fixed-ConPTY transport with no host reply: does conhost answer by +// itself? (it must not; the pinned source's ConPTY path suppresses replies) +// 2. raw fixed-ConPTY transport with a distinct diagnostic reply: do bytes +// written by the host reach the real child unchanged? +// 3. the product SessionModel path: core reply -> Qt write-back -> ConPTY -> +// the real child receives the contract bytes ESC[1;3R. +// +// The child is this same executable re-entered through --query-child. It only +// enables the console's VT input/output modes, writes the fixed source once and +// records the exact UTF-16 input units it receives, so the observed bytes do not +// depend on the implementation under test. Test-only: no product behavior, +// default entry or query support is changed. +#include "terminal_session.hpp" +#include "../../experiments/conpty/session.hpp" +#include "../../experiments/core/text_projection.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using termforge::qt::SessionModel; + +// Fixed bounded scenario: a fresh 80x24 primary screen writes two printable +// ASCII cells from home and then asks for the cursor position. The independent +// literal expectation follows the existing reply contract, not this test's +// implementation: 1-based row 1, column 3 => ESC[1;3R. +constexpr std::string_view query_source = "AB\x1b[6n"; +constexpr std::string_view contract_reply = "\x1b[1;3R"; +// A deliberately wrong position used only by the raw diagnostic control; it +// proves the host-written bytes reach the child and are not produced by conhost. +constexpr std::string_view distinct_reply = "\x1b[9;9R"; +constexpr DWORD child_reply_timeout_ms = 4000; +constexpr ULONGLONG query_observe_timeout_ms = 5000; +constexpr ULONGLONG child_exit_timeout_ms = 8000; +constexpr ULONGLONG product_finish_timeout_ms = 12000; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +std::string hex(std::string_view bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result; + for (const unsigned char byte : bytes) { + result += digits[byte >> 4]; + result += digits[byte & 15]; + } + return result; +} + +std::string read_binary(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + require(bool(input), "expected child result file is missing"); + return std::string(std::istreambuf_iterator(input), std::istreambuf_iterator()); +} + +void write_binary(const std::filesystem::path& path, std::string_view bytes) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + require(bool(output), "child result file could not be created"); + output.write(bytes.data(), static_cast(bytes.size())); + require(bool(output), "child result file write failed"); +} + +// Minimal field extraction for the child's own bounded metadata envelope. The +// raw received bytes are also written separately and cross-checked; this parser +// never feeds the expected reply value. +std::string metadata_field(const std::string& json, std::string_view key) { + const std::string quoted = "\"" + std::string(key) + "\":"; + const auto begin = json.find(quoted); + require(begin != std::string::npos, "child metadata field is missing"); + auto value = begin + quoted.size(); + auto end = json.find_first_of(",}", value); + require(end != std::string::npos, "child metadata field is unterminated"); + return json.substr(value, end - value); +} + +std::string metadata_hex(const std::string& json, std::string_view key) { + const auto field = metadata_field(json, key); + require(field.size() >= 2 && field.front() == '"' && field.back() == '"', + "child metadata hex field is not quoted"); + return field.substr(1, field.size() - 2); +} + +bool metadata_bool(const std::string& json, std::string_view key) { + const auto field = metadata_field(json, key); + require(field == "true" || field == "false", "child metadata boolean field is invalid"); + return field == "true"; +} + +unsigned long metadata_number(const std::string& json, std::string_view key) { + const auto field = metadata_field(json, key); + require(!field.empty() && field.find_first_not_of("0123456789") == std::string::npos, + "child metadata number field is invalid"); + return std::stoul(field); +} + +struct ChildObservation { + std::string raw; + std::string metadata; + unsigned long pid = 0; + bool timed_out = false; + bool complete = false; +}; + +ChildObservation read_child(const std::filesystem::path& result) { + ChildObservation observation; + observation.raw = read_binary(result); + observation.metadata = read_binary(result.string() + ".json"); + observation.pid = metadata_number(observation.metadata, "pid"); + observation.timed_out = metadata_bool(observation.metadata, "timed_out"); + observation.complete = metadata_bool(observation.metadata, "complete"); + // The independently written raw bytes and the metadata projection must agree. + require(metadata_hex(observation.metadata, "received_hex") == hex(observation.raw), + "child metadata disagrees with its raw received bytes"); + require(metadata_hex(observation.metadata, "source_hex") == hex(query_source), + "child metadata source differs from the fixed source"); + require(metadata_hex(observation.metadata, "contract_reply_hex") == hex(contract_reply), + "child metadata contract expectation differs from the confirmed reply contract"); + return observation; +} + +// Child-side receiver. It never reads the screen or console APIs beyond modes, +// so it cannot trigger conhost's cursor-sync DSR path; the only observed input +// is what the host actually delivered through ConPTY. +int query_child(const std::filesystem::path& result) { + try { + const HANDLE input = GetStdHandle(STD_INPUT_HANDLE); + const HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE); + require(input && input != INVALID_HANDLE_VALUE, "query child console input unavailable"); + require(output && output != INVALID_HANDLE_VALUE, "query child console output unavailable"); + BOOL owned = FALSE; + require(IsProcessInJob(GetCurrentProcess(), nullptr, &owned) && owned, + "query child must run inside the owned Session Job"); + require(SetConsoleCP(CP_UTF8) && SetConsoleOutputCP(CP_UTF8), + "query child UTF-8 codepage setup failed"); + require(SetConsoleMode(input, ENABLE_VIRTUAL_TERMINAL_INPUT), + "query child VT input mode setup failed"); + require(SetConsoleMode(output, ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | + ENABLE_VIRTUAL_TERMINAL_PROCESSING | + DISABLE_NEWLINE_AUTO_RETURN), + "query child VT output mode setup failed"); + DWORD written = 0; + require(WriteFile(output, query_source.data(), static_cast(query_source.size()), &written, + nullptr) && + written == query_source.size(), + "query child source write failed"); + + std::string received; + bool timed_out = false; + bool complete = false; + const ULONGLONG deadline = GetTickCount64() + child_reply_timeout_ms; + while (!complete) { + const ULONGLONG now = GetTickCount64(); + if (now >= deadline) { + timed_out = true; + break; + } + const DWORD state = WaitForSingleObject(input, static_cast(deadline - now)); + if (state == WAIT_TIMEOUT) { + timed_out = true; + break; + } + require(state == WAIT_OBJECT_0, "query child console wait failed"); + wchar_t units[64]; + DWORD count = 0; + require(ReadConsoleW(input, units, 64, &count, nullptr) && count, + "query child console read failed"); + for (DWORD i = 0; i < count; ++i) { + require(units[i] >= 0 && units[i] <= 127, "query child reply is not ASCII"); + received.push_back(static_cast(units[i])); + require(received.size() <= 1024, "query child reply exceeds its fixed budget"); + if (units[i] == L'R') { + complete = true; + break; + } + } + } + + write_binary(result, received); + std::ofstream metadata(result.string() + ".json", std::ios::binary | std::ios::trunc); + require(bool(metadata), "query child metadata file could not be created"); + metadata << "{\"schema\":\"termforge-query-child-v1\",\"pid\":" << GetCurrentProcessId() + << ",\"source_hex\":\"" << hex(query_source) << "\",\"contract_reply_hex\":\"" + << hex(contract_reply) << "\",\"received_hex\":\"" << hex(received) + << "\",\"received_bytes\":" << received.size() + << ",\"timed_out\":" << (timed_out ? "true" : "false") + << ",\"complete\":" << (complete ? "true" : "false") << ",\"vt_input_mode\":true}\n"; + metadata.close(); + require(bool(metadata), "query child metadata write failed"); + return complete ? 0 : (timed_out ? 42 : 43); + } catch (const std::exception& error) { + std::fprintf(stderr, "query child: %s\n", error.what()); + return 5; + } +} + +std::wstring child_arguments(const std::filesystem::path& result) { + return L"--query-child \"" + result.wstring() + L"\""; +} + +struct RawStage { + std::string backend; + DWORD creation_flags = 0; + std::string transport_hex; + std::string received_hex; + bool query_seen = false; + bool timed_out = false; + bool complete = false; + DWORD exit_code = 0; + DWORD child_pid = 0; + bool cleanup_verified = false; +}; + +// Direct fixed-ConPTY run without SessionModel. Passing no reply keeps the host +// silent (does conhost answer the child itself?); passing distinct_reply proves +// the host->ConPTY->child byte path. Neither run uses the product reply code. +RawStage run_raw(const std::filesystem::path& executable, const std::filesystem::path& directory, + const std::filesystem::path& result, std::optional reply) { + RawStage stage; + tf_conpty::Session session(tf_conpty::Backend::configured()); + stage.backend = session.backend_identity(); + stage.creation_flags = session.backend_creation_flags(); + require(stage.backend != "system", "raw control must load the fixed bundled ConPTY backend"); + session.start(executable.wstring(), child_arguments(result), {}, directory.wstring(), {80, 24}); + stage.child_pid = session.pid; + const ULONGLONG observe_deadline = GetTickCount64() + query_observe_timeout_ms; + while (GetTickCount64() < observe_deadline) { + if (session.output().find(query_source) != std::string::npos) { + stage.query_seen = true; + break; + } + if (session.wait_exit(2)) break; + Sleep(2); + } + require(stage.query_seen, "raw control did not observe the child's fixed source on the ConPTY transport"); + if (reply) session.send(*reply); + bool exited = false; + const ULONGLONG exit_deadline = GetTickCount64() + child_exit_timeout_ms; + while (GetTickCount64() < exit_deadline) { + if (session.wait_exit(5)) { + exited = true; + break; + } + } + stage.transport_hex = hex(session.output()); + require(exited, "raw control child did not exit within its bounded deadline"); + stage.exit_code = session.exit_code; + session.close(); + session.check_reader(); + require(session.cleanup_complete && session.active_after == 0 && !session.capture_overflow, + "raw control did not reclaim its owned child, host and pipes"); + stage.cleanup_verified = true; + const auto child = read_child(result); + require(child.pid == session.pid, "raw control child identity mismatch"); + stage.received_hex = hex(child.raw); + stage.timed_out = child.timed_out; + stage.complete = child.complete; + return stage; +} + +struct ProductStage { + std::string backend; + std::string transport_hex; + size_t query_offset = 0; + bool da1_startup_observed = false; + int core_row = 0; + int core_column = 0; + std::string core_row0_hex; + std::string expected_hex = hex(contract_reply); + std::string received_hex; + bool timed_out = false; + bool complete = false; + DWORD exit_code = 0; + DWORD child_pid = 0; + bool natural_exit = false; + bool cleanup_verified = false; + bool output_eof = false; + DWORD active_after = 0; +}; + +// Product path: the real SessionModel feeds parsed transport into the recovery +// Parser and writes take_replies() back through its shared bounded input queue. +ProductStage run_product(const std::filesystem::path& executable, const std::filesystem::path& directory, + const std::filesystem::path& result) { + SessionModel model; + model.enable_transport_capture(); + require(model.start(executable.wstring(), child_arguments(result), directory.wstring()), + "product SessionModel did not start the fixed ConPTY child"); + QElapsedTimer lifecycle; + lifecycle.start(); + while (!model.finished() && lifecycle.elapsed() < static_cast(product_finish_timeout_ms)) { + QTest::qWait(5); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + } + require(model.finished(), "product SessionModel did not finish within its bounded deadline"); + + ProductStage stage; + stage.backend = model.configuration_identity().toStdString(); + const auto transport = model.transport_capture(); + stage.transport_hex = hex(transport); + stage.da1_startup_observed = transport.find("\x1b[c") != std::string::npos; + const auto query = transport.find(query_source); + require(query != std::string::npos, + "product transport did not carry the child's exact fixed source"); + stage.query_offset = query; + const auto& screen = model.screen(); + stage.core_row = screen.row; + stage.core_column = screen.column; + require(screen.width() == 80 && screen.height() == 24, + "product core grid is not the fixed 80x24 primary screen"); + const auto row0 = termforge::project_line(screen.visible_lines()[0]).text; + stage.core_row0_hex = hex(row0); + require(row0 == "AB", "product core primary row does not contain the fixed AB source"); + require(stage.core_row == 0 && stage.core_column == 2, + "product core protocol cursor after the fixed source is not row 1 column 3"); + stage.exit_code = model.exit_code(); + stage.child_pid = model.resize_state().child_pid; + stage.natural_exit = model.natural_exit(); + stage.cleanup_verified = model.cleanup_verified(); + stage.output_eof = model.output_eof(); + stage.active_after = model.active_processes_after(); + require(model.natural_exit() && model.cleanup_verified() && model.active_processes_after() == 0, + "product SessionModel natural exit or owned cleanup was not verified"); + + const auto child = read_child(result); + require(child.pid == stage.child_pid, "product child identity mismatch"); + stage.received_hex = hex(child.raw); + stage.timed_out = child.timed_out; + stage.complete = child.complete; + std::cerr << "{\"schema\":\"termforge-qt-query-reply-product-v1\",\"backend\":\"" + << stage.backend << "\",\"transport_hex\":\"" << stage.transport_hex + << "\",\"query_offset\":" << stage.query_offset << ",\"da1_startup_observed\":" + << (stage.da1_startup_observed ? "true" : "false") << ",\"core_cursor\":[" + << stage.core_row << ',' << stage.core_column << "],\"core_row0_hex\":\"" + << stage.core_row0_hex << "\",\"expected_hex\":\"" << stage.expected_hex + << "\",\"received_hex\":\"" << stage.received_hex << "\",\"timed_out\":" + << (stage.timed_out ? "true" : "false") << ",\"complete\":" + << (stage.complete ? "true" : "false") << ",\"child_pid\":" << stage.child_pid + << ",\"exit_code\":" << stage.exit_code << ",\"natural_exit\":" + << (stage.natural_exit ? "true" : "false") << ",\"cleanup_verified\":" + << (stage.cleanup_verified ? "true" : "false") << ",\"active_after\":" + << stage.active_after << ",\"presented_done\":null}\n"; + return stage; +} + +void emit_raw(const char* name, const RawStage& stage) { + std::cerr << "{\"schema\":\"termforge-qt-query-reply-raw-v1\",\"stage\":\"" << name + << "\",\"backend\":\"" << stage.backend << "\",\"creation_flags\":" << stage.creation_flags + << ",\"child_pid\":" << stage.child_pid << ",\"query_seen\":" + << (stage.query_seen ? "true" : "false") << ",\"timed_out\":" + << (stage.timed_out ? "true" : "false") << ",\"complete\":" + << (stage.complete ? "true" : "false") << ",\"exit_code\":" << stage.exit_code + << ",\"received_hex\":\"" << stage.received_hex << "\",\"transport_hex\":\"" + << stage.transport_hex << "\",\"cleanup_verified\":" + << (stage.cleanup_verified ? "true" : "false") << ",\"presented_done\":null}\n"; +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc == 3 && std::string_view(argv[1]) == "--query-child") { + return query_child(std::filesystem::path(argv[2])); + } + if (argc != 1) { + std::cerr << "usage: qt_query_reply_test\n"; + return 2; + } + char gate[3]{}; + DWORD count = 0; + BOOL owned = FALSE; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || count != 3 || + std::string_view(gate, count) != "GO\n" || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || + !owned) + return 2; + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + try { + QTemporaryDir workspace; + require(workspace.isValid(), "query reply workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "query reply test executable path unavailable"); + const std::filesystem::path exe(executable); + const std::filesystem::path directory(workspace.path().toStdWString()); + + const auto silent = run_raw(exe, directory, directory / "raw-silent.bin", std::nullopt); + emit_raw("raw-silent", silent); + require(!silent.complete && silent.received_hex.empty(), + "fixed ConPTY answered the child's DSR itself instead of leaving it to the host"); + require(silent.exit_code == 42 && silent.timed_out, + "silent-host control child did not observe its bounded timeout"); + require(silent.cleanup_verified, "silent-host control did not verify cleanup"); + + const auto distinct = run_raw(exe, directory, directory / "raw-distinct.bin", + std::string(distinct_reply)); + emit_raw("raw-distinct", distinct); + require(distinct.complete && distinct.received_hex == hex(distinct_reply), + "host-written diagnostic bytes did not reach the real child unchanged"); + require(distinct.exit_code == 0, "distinct-reply control child did not complete"); + + const auto product = run_product(exe, directory, directory / "product.bin"); + require(product.complete, "product child did not complete its bounded reply wait"); + require(product.exit_code == 0, "product child exited before receiving its reply"); + require(product.received_hex == product.expected_hex, + "product path did not deliver the literal ESC[1;3R contract reply to the real child"); + std::cout << "{\"schema\":\"termforge-qt-query-reply-v1\",\"scenario\":" + "\"dsr-6n-fixed-primary\",\"source_hex\":\"" + << hex(query_source) << "\",\"contract_reply_hex\":\"" << hex(contract_reply) + << "\",\"raw_silent\":{\"received_hex\":\"" << silent.received_hex + << "\",\"timed_out\":true,\"exit_code\":" << silent.exit_code + << "},\"raw_distinct\":{\"received_hex\":\"" << distinct.received_hex + << "\",\"exit_code\":" << distinct.exit_code << "},\"product\":{\"received_hex\":\"" + << product.received_hex << "\",\"core_cursor\":[" << product.core_row << ',' + << product.core_column << "],\"query_offset\":" << product.query_offset + << ",\"child_pid\":" << product.child_pid << ",\"exit_code\":" << product.exit_code + << ",\"cleanup_verified\":true},\"child_count\":3,\"presented_done\":null}\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 1ab5ac348587013b1c3e50da07643613ffc0baaa Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:35:37 +0800 Subject: [PATCH 2/4] docs(evidence): record layered DSR 6n reply chain verification (#28) Record the bounded formal Qt / fixed-ConPTY query-reply evidence: core contract, raw ConPTY silent/distinct controls, SessionModel write-back and real-child receipt, plus exact bytes, child identity, cleanup and limitations. Also point the earlier Vim trajectory parenthetical about conhost answering DSR to the corrected observation without rewriting the original record. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- docs/EVIDENCE.md | 1 + .../research/tui-vim-trajectory-2026-11-02.md | 2 +- .../2026-11-09-qt-query-reply/README.md | 69 +++++++ .../2026-11-09-qt-query-reply/validation.json | 169 ++++++++++++++++++ 4 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 experiments/shell/results/2026-11-09-qt-query-reply/README.md create mode 100644 experiments/shell/results/2026-11-09-qt-query-reply/validation.json diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index fd08814..163b83e 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -147,6 +147,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | More 尾部 ECH 与捕获时序(#42 限定修复) | [合同及来源](research/more-capture-ech.md)、[修复前后与同输入对照](../experiments/differential/results/2026-10-26-more-ech/validation.json)、[历史触发记录](../experiments/shell/results/2026-10-26-margin-state/validation.json) | Recovery ECH 缺失和旧捕获过早分开修正,同原件 whole/byte 红→绿;完整 More 提示、单 q/自然退出/EOF/Job0 独立核对。候选 pending-wrap 差异仍保留。 | | More 分页器轨迹冻结与产品配置冒烟(#28 首批应用切片) | [轨迹与独立预期](research/tui-more-trajectory-2026-11-02.md)、[冒烟记录](../experiments/shell/results/2026-11-02-tui-more/validation.json)、[固定进入样本](../experiments/core/fixtures/more-pager-fixed-411.bin)、[系统变体样本](../experiments/core/fixtures/more-pager-system-375.bin) | Windows 11 x64 固定 More.com 10.0.26100.1(SHA-256 dea4ed89…53,248 字节)的版本/入口/动作(进入→翻页→行滚→q 退出;搜索不适用)与逐步协议预期;固定 ConPTY 1.25 进入/退出 strict+recovery 双通且 4 次捕获确定,系统 ConPTY 进入非确定(ECH70/CUF79 两变体)且 strict 因 OSC 标题失败。DA1(ESC[c)在固定启动包出现(strict 回复 ESC[?6c、recovery 静默);6n、1049、滚动区未在 More 轨迹出现;ED2 仅系统路径进入出现、固定路径用空格+CR。只冻结轨迹不改产品行为,= 行号/F/P/S 交互键、二进制输入、其他语言提示未覆盖。 | | Vim for Windows 轨迹冻结与产品配置冒烟(#28 应用矩阵第 2 项) | [轨迹与独立预期](research/tui-vim-trajectory-2026-11-02.md)、[冒烟记录](../experiments/shell/results/2026-11-02-tui-vim/validation.json)、[80×24 冻结样本](../experiments/core/fixtures/vim-fixed-80x24-trajectory.bin)、[60×12 冻结样本](../experiments/core/fixtures/vim-fixed-60x12-trajectory.bin) | Windows 11 x64 固定 Vim 9.1 patches 1–532(Git for Windows,EXE SHA-256 2e0fda63…,msys-2.0.dll 3096d2ba…)的版本/入口/动作(进入→插入→保存退出→主屏恢复)与逐步协议预期;固定 ConPTY 1.25 全轨迹 3 次捕获逐字节一致,recovery 回放进入/插入/保存/退出/主屏恢复全程通过(exit 0),strict 在偏移 31 的 ESC[22;0;0t 以 too many CSI parameters 失败(terminal.hpp:81-82 参数门限早于 t 分支)。1049 进出(偏移 23/2385)、DA1(偏移 4)、DSR 6n(4 次)、DECSTBM(1;24r)、ED2 均在轨迹出现且核心已处理;DA2、括号粘贴、DCS 同步、多参数 XTWINOPS 标题未实现。60×12 为 ConPTY 60 列×12 行视口,非原版 60×3000 缓冲区,二者不合并。 | +| 正式 Qt / 固定 ConPTY 的 DSR 6n 回复分层证据(#28 回复合同调查) | [分层结果与限制](../experiments/shell/results/2026-11-09-qt-query-reply/README.md)、[固定验证身份](../experiments/shell/results/2026-11-09-qt-query-reply/validation.json)、[核心 reply 合同](../experiments/core/reply_test.cpp) | 80×24 主屏、单会话、home 起点写 `AB`+`ESC[6n`:既有核心合同预期 `ESC[1;3R`;固定 ConPTY 静默对照中 child 仅超时(exit 42,收不到字节),host 写入 `ESC[9;9R` 原样到达 child;正式 SessionModel 在 transport offset 23 解析查询并经有界输入队列回写,真实 child 恰收 `ESC[1;3R`,raw/metadata 一致、PID 绑定、cleanup_verified/active_after=0,两次运行字节一致。仅单场景、不 resize/备用屏/滚动区、不验证其它查询/TUI 消费/WindTerm 对齐,`presented_done=null`;child 启用 VT 输入模式;「conhost 代答」旧括注与该固定版本不符。 | ## 核心路线、参考与历史验证 diff --git a/docs/research/tui-vim-trajectory-2026-11-02.md b/docs/research/tui-vim-trajectory-2026-11-02.md index 1403be5..5d4080b 100644 --- a/docs/research/tui-vim-trajectory-2026-11-02.md +++ b/docs/research/tui-vim-trajectory-2026-11-02.md @@ -116,7 +116,7 @@ - strict 拒绝多参数 XTWINOPS t、括号粘贴、DCS 同步与 DA2 是否应实现、以何种字节回复,未决定;本切片只记录失败位置,不猜原版回复。 - 原版 WindTerm 的 60×3000 缓冲区与 60×12 窗口(dwSize vs srWindow)与 ConPTY 60×12 的映射关系未验证。 - #52/#56 记录的 resize 中途丢输入(MSYS 运行时 SIGWINCH 竞态)未在 ConPTY 产品路径复现,本切片未做中途 resize,只做 60×12 固定起点变体。 -- DSR 6n 的回复字节(ESC[row;colR)在本切片只核对到核心有回复能力(reply_test),未核对 Vim 实际消费回复后继续的行为(本切片 Vim 在 ConPTY 下不等待回复即可进入编辑,ConPTY 由 conhost 代答)。 +- DSR 6n 的回复字节(ESC[row;colR)在本切片只核对到核心有回复能力(reply_test),未核对 Vim 实际消费回复后继续的行为(本切片 Vim 在 ConPTY 下不等待回复即可进入编辑,ConPTY 由 conhost 代答)。**后续修正(2026-11-09)**:固定 ConPTY 1.25 的静默/不同值对照显示 conhost 不代答 DSR,host 回复可原样到达启用 VT 输入模式的 child;见 [分层结果](../../experiments/shell/results/2026-11-09-qt-query-reply/README.md)。该括注保留为原假设。 - 其他终端类型(TERM 变量)、其他 vim 启动参数、多文件/多窗口、鼠标模式、宽字符正文均未覆盖。 - 未观测 DA1/DA2 的原版 WindTerm 回复字节(既有 issue28-probe.md 记录原版 DA/OSC 探针未能采集)。 diff --git a/experiments/shell/results/2026-11-09-qt-query-reply/README.md b/experiments/shell/results/2026-11-09-qt-query-reply/README.md new file mode 100644 index 0000000..39dae48 --- /dev/null +++ b/experiments/shell/results/2026-11-09-qt-query-reply/README.md @@ -0,0 +1,69 @@ +# 正式 Qt / 固定 ConPTY:DSR 6n 回复的分层证据(#28 回复合同调查切片) + +本切片在**正式 C++ / Qt Widgets / 随包固定 Microsoft ConPTY** 构建中,对一个有界、确定的单会话场景核对“核心回复 → Qt 回写 → 真实 child 收到字节”的完整证据链,并分别保留核心孤立合同、ConPTY 原始传输、Qt 产品回写和 child 实际接收四层观察。 + +**结论先行(限定场景)**:在 80×24 固定主屏、单会话、无 resize/备用屏/滚动区、child 已启用 VT 输入模式的场景下,正式 `SessionModel` 在 transport offset 23 解析到 child 的 `ESC[6n`,按既有 reply 合同生成 `ESC[1;3R` 并经同一有界输入队列回写;真实 child 的独立接收文件恰好收到 `1b5b313b3352`(`ESC[1;3R`),PID 与 SessionModel 绑定,自然退出与 OwnedJob 回收均通过。固定 ConPTY 在本配置下**不会自行代答** DSR:host 静默对照中 child 只观察到自己的超时(exit 42),host 写入的另一个位置值也能原样到达 child。 + +不修改产品行为、默认入口或 query 支持;不新增 DA1/DA2/OSC 回复;不进入 #28 整体 TUI 验收,也不宣称 WindTerm 对齐或性能。 + +## 场景与独立预期 + +- 产品入口:正式 `SessionModel`(`src/qt/terminal_session.hpp`,`ParseMode::recovery`,80×24,随包固定 ConPTY 1.25.260930003),不是旧 candidate shell,也不是 strict replay。 +- 固定输入源(child 只写一次,6 B):`41421b5b366e` = `AB` + `ESC[6n`。child 位于主屏 home,先写两个可打印 ASCII 单元,再查询光标位置。 +- 独立字面预期**来自已确认的现有 reply 合同**(`experiments/core/reply_test.cpp`:`CSI 6n` 回复为 `ESC[row;colR`、行列均为 1 基;同一测试以 “ab”+`ESC[6n` 断言 `ESC[1;3R`),不是从被测产品输出反推:home 写 2 个单元后协议光标为 0 基 (0,2),故预期 `1b5b313b3352` = `ESC[1;3R`。 +- child(本测试可执行文件的 `--query-child` 重入入口)打开控制台标准句柄,设置 UTF-8、`ENABLE_VIRTUAL_TERMINAL_INPUT` 与 VT 输出模式,写入固定源后以有界 `ReadConsoleW` 收集实际输入单元并写原始字节文件 + metadata;不调用 `GetConsoleScreenBufferInfo` 等可能触发 conhost 光标同步的 API。 + +## 四层证据 + +| 层 | 入口/源码 | 本层实际观察 | 能支持到哪一步 | +|---|---|---|---| +| 1. 核心孤立合同 | 既有 `experiments/core/reply_test.cpp` + CTest `reply_core`(`CMakeLists.txt` 注册) | 本轮复跑通过(0.01 s);契约断言 “ab”+`ESC[6n` → `ESC[1;3R`,`5n` 负例不回复,DA1 仅 strict 回复 `ESC[?6c`、recovery 静默 | 只有核心 Parser 能生成该字节;不含 Qt/ConPTY/child | +| 2. ConPTY 原始传输(host 静默) | 本切片 `qt_query_reply_test` 的 raw-silent 阶段:`tf_conpty::Session` + 固定后端,观察到 query 后**不写任何回复** | transport = `1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e`(`ESC[1t`、`ESC[c`、`ESC[?1004h`、`ESC[?9001h` 启动包 + `AB` + `ESC[6n`);child 接收为空、exit 42(自身 4 s 超时),`cleanup_complete=true`、`active_after=0` | 固定 ConPTY 转发了 child 查询,但**没有自行产生回复**;此时 child 收不到任何 DSR 字节 | +| 3. ConPTY 原始传输(host 写入诊断值) | 同一 raw 阶段,host 在观察到 query 后写入 `1b5b393b3952`(`ESC[9;9R`,仅诊断用) | child 原始接收文件 = `1b5b393b3952`,exit 0;transport 与层 2 相同 | host→ConPTY→child 的写入路径确实送达真实 child,且未被 conhost 改写为实际位置 | +| 4. Qt 产品回写 + child 实际接收 | 正式 `SessionModel`:`poll()` 中 `parser_.feed()` → `take_replies()` → `send(QByteArray)` → 同一有界 `InputQueue` | transport 含启动包 + `AB` + `ESC[6n`(query_offset=23)与关闭 `ESC[?1004l/ESC[?9001l`;`da1_startup_observed=true`;core 主屏行 0 = `AB`、协议光标 (0,2);child 原始接收 = `1b5b313b3352`,metadata 自报接收一致;child PID 与 `model.resize_state().child_pid` 一致;`natural_exit=true`、`cleanup_verified=true`、`output_eof=true`、`active_after=0` | 产品路径把合同回复送达真实 child;启动包中的 DA1 请求未产生回写(child 精确等于 DSR 回复,未收到 `ESC[?6c`) | + +两次完整运行(实现提交后的干净工作树)字节结果一致,仅 PID 不同: + +| run | product child PID | product 接收 hex | raw-silent 接收/exit | raw-distinct 接收/exit | supervisor job_after | +|---|---|---|---|---|---| +| 1 | 16284 | `1b5b313b3352` | 空 / 42 | `1b5b393b3952` / 0 | `{active:0,total:8,terminated:0}` | +| 2 | 13780 | `1b5b313b3352` | 空 / 42 | `1b5b393b3952` / 0 | `{active:0,total:8,terminated:0}` | + +`presented_done=null`;本切片不抓图、不比较像素,也不测量呈现时间。 + +## 与已有证据的关系(为什么需要新切片) + +- 核心层已有 `reply_test` / `reply_core`;本切片未复制核心测试,只引用其契约并在产品路径复用同一字面预期。 +- 既有 Vim 轨迹(`docs/research/tui-vim-trajectory-2026-11-02.md`)只证明固定 ConPTY transport 中出现过 4 次 `ESC[6n`、核心有回复能力;该文档同时明确“未核对 Vim 实际消费回复后继续的行为”。其第 119 行的括注“ConPTY 由 conhost 代答”与本轮固定配置的动态对照和 pinned 源码都不一致:pinned `outputStream.cpp` 的 `ConhostInternalGetSet::ReturnResponse` 在 VTIO(ConPTY)模式下直接返回,注释写明 “ConPTY should not respond to requests. That's the job of the terminal.”。因此该括注应视为未经核对的假设;本切片保留原记录,不重写历史结论,只以新证据修正该说法。 +- 旧 candidate shell 曾用 `shell_query_client.exe` 核对“产品侧回复 child”,但那是旧候选后端组合,不是正式 Qt / 固定 ConPTY;当前 Qt CTest 没有覆盖该链路。本切片改用正式 `SessionModel` 与同一可执行文件的独立 child 模式,补齐 Qt 回写与真实接收。 + +pinned 源码旁证(静态解释,不替代动态证据):child 的 VT 输出由 `WriteCharsVT` 先更新内部 state machine、再把输入文本转发给 host(`src/host/_stream.cpp`),所以 host 能看到 `ESC[6n`;host 写入的 `CSI R` 在 `InputStateMachineEngine` 中仅在 `IsVtInputEnabled()` 时作为未处理序列透传给 child(`ActionCsiDispatch` 的 `CSI_F3` 分支),与本次 child 启用 VT 输入模式的观察一致。 + +## 环境与身份 + +- Windows 11 x64 build 26200;MSVC 19.42.34433.0;Qt 6.10.3 win64_msvc2022_64 offscreen;随包固定 ConPTY 1.25.260930003(`conpty.dll` `feeef341…805a`、`OpenConsole.exe` `3d66b23d…0586`);app-local CRT 14.51.36247.0;80×24。 +- 后端身份:`reviewed-side-by-side-1.25.260930003`,creation flags = 24(`PSEUDOCONSOLE_GLYPH_WIDTH_CONSOLE` 0x18);产品配置身份 `cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24`。 +- 实现提交:`4806812`(测试 + CMake 注册 + 本次结果记录前置的实现提交;验证文件为后续文档提交)。完整机器可读身份、命令和两次运行原始记录见 [validation.json](validation.json)。 + +## 局限(保持开放) + +- 只覆盖**一个** 80×24 固定主屏、单会话、home 起点的确定位置;不含 resize、备用屏、滚动区、宽字符/结合字、历史、选择、Find。 +- 只覆盖已实现的 `DSR 6n`;DA1 产品路径仍 strict-only/recovery-silent,DA2、OSC 10/11、括号粘贴、鼠标/焦点模式等未实现或未验证,本切片不改变这些结论。 +- child 已启用 `ENABLE_VIRTUAL_TERMINAL_INPUT`。这是 pinned ConPTY 把未处理 CSI 透传给 child 的条件;未启用该模式的 child 在本轮未测试,不能据此推断其接收字节(例如是否被解释为按键事件)。 +- 未和 WindTerm 原版对照,未验证真实 TUI(Vim/More 等)消费回复后的继续行为;#28 的整体 TUI 矩阵、#27/#29/#30 联合验收仍开放。 +- 单次运行约 4.5 s,不是性能或呈现测量;`presented_done=null`。 + +## 复验 + +```powershell +python -m tools.prepare_qt_build # 或复用已准备且身份匹配的 local/qt-build-deps +cmake -S . -B build/query-reply -G "Visual Studio 17 2022" -A x64 ` + -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON ` + -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF ` + -DQt6_DIR="$PWD/local/qt-build-deps/qt/lib/cmake/Qt6" ` + -DTERMFORGE_CONPTY_BACKEND_DIR="$PWD/local/qt-build-deps/conpty" ` + -DTERMFORGE_MSVC_RUNTIME_DIR="$PWD/local/qt-build-deps/crt" +cmake --build build/query-reply --config Release --target qt_query_reply_test reply_test private_mode_test --parallel 2 +ctest --test-dir build/query-reply -C Release -R '^qt_query_reply$' --no-tests=error --output-on-failure +ctest --test-dir build/query-reply -C Release -R '^(reply_core|private_mode_core)$' --output-on-failure +``` diff --git a/experiments/shell/results/2026-11-09-qt-query-reply/validation.json b/experiments/shell/results/2026-11-09-qt-query-reply/validation.json new file mode 100644 index 0000000..958328a --- /dev/null +++ b/experiments/shell/results/2026-11-09-qt-query-reply/validation.json @@ -0,0 +1,169 @@ +{ + "schema": "termforge-qt-query-reply-validation-v1", + "source_commit": "480681263fdd270c73469cf2bab406f5dc6bb83a", + "source_tree": "826dd68dfea4a1ed774095c4a2ecb699d6aa2ce9", + "source_binding": "Implementation, test and CMake/CTest wiring were committed before the final two runs; this validation file is a documentation follow-up. The working tree was clean when both recorded runs started.", + "scope": "One bounded, deterministic DSR 6n scenario on the formal C++/Qt/fixed-ConPTY path: 80x24 primary screen, single session, no resize, alternate screen or scroll region; child writes AB + ESC[6n from home; independent expected reply ESC[1;3R comes from the existing core reply contract (reply_test.cpp), not from the product output.", + "passed": true, + "scoped_validation_passed": true, + "dsr6n_product_path_verified": true, + "core_reply_contract_verified": true, + "conpty_independent_reply_observed": false, + "host_written_bytes_reach_child_verified": true, + "product_behavior_changed": false, + "query_support_expanded": false, + "windterm_parity": false, + "full_tui_verified": false, + "presented_done": null, + "environment": { + "os": "Windows 11 x64 build 26200", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 win64_msvc2022_64, offscreen platform", + "conpty": "1.25.260930003", + "conpty_files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + }, + "msvc_runtime": "14.51.36247.0", + "python_for_ctest": "3.12.10", + "backend_identity": "reviewed-side-by-side-1.25.260930003", + "creation_flags": 24, + "product_configuration_identity": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24", + "grid": [80, 24] + }, + "dependencies": { + "qt_archive_sha256": "4db84dee7fe3c558f242bef0a88852613af76580dc6d2b24596479f47004dad7", + "sdk_tree_sha256": "a35ab6fe76b62e4d9bd6d3c6b0d476c63583070cea5c9118ef5bb02042ffe266", + "conpty_package_sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "msvc_runtime_package_sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", + "input_manifests": { + "qt": "2796294f254f7fe0370be99d2de8f7745734c7352ac2648b1f8d9a8fab5ad2cd", + "conpty": "e75e522a3355c0a66c6d60cafa02413a83d8757262747f650b77c959c09ed439", + "msvc_runtime": "154176c8b4ae6dd57fce453e2c2002ff484344b39451fa9880b07db4794f3758" + } + }, + "commands": { + "configure": "cmake -S . -B build/query-reply -G \"Visual Studio 17 2022\" -A x64 -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF -DQt6_DIR=/qt/lib/cmake/Qt6 -DTERMFORGE_CONPTY_BACKEND_DIR=/conpty -DTERMFORGE_MSVC_RUNTIME_DIR=/crt", + "build": "cmake --build build/query-reply --config Release --target qt_query_reply_test reply_test private_mode_test --parallel 2", + "new_test": "ctest --test-dir build/query-reply -C Release -R '^qt_query_reply$' --no-tests=error --output-on-failure", + "core_regression": "ctest --test-dir build/query-reply -C Release -R '^(reply_core|private_mode_core)$' --no-tests=error --output-on-failure" + }, + "inputs": { + "scenario": "dsr-6n-fixed-primary", + "child_source_hex": "41421b5b366e", + "child_source_bytes": 6, + "child_source_decode": "AB + ESC[6n written once from the primary-screen home position", + "expected_reply_hex": "1b5b313b3352", + "expected_reply_decode": "ESC[1;3R", + "expected_derivation": "Existing confirmed core reply contract in experiments/core/reply_test.cpp: CSI 6n replies ESC[row;colR with 1-based row and column; the same test asserts ab + ESC[6n => ESC[1;3R. After two printable cells from home the protocol cursor is (0,2), so the literal expectation is ESC[1;3R.", + "diagnostic_distinct_reply_hex": "1b5b393b3952", + "diagnostic_distinct_reply_scope": "Raw host control only; it is not product output and does not change query support." + }, + "results": { + "runs": [ + { + "label": "run1", + "supervisor_status": "passed", + "exit_code": 0, + "job_after": {"active": 0, "total": 8, "terminated": 0}, + "raw_silent": { + "child_pid": 15976, + "query_seen": true, + "timed_out": true, + "complete": false, + "received_hex": "", + "exit_code": 42, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "raw_distinct": { + "child_pid": 41008, + "query_seen": true, + "timed_out": false, + "complete": true, + "received_hex": "1b5b393b3952", + "exit_code": 0, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "product": { + "child_pid": 16284, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e1b5b3f313030346c1b5b3f393030316c", + "query_offset": 23, + "da1_startup_observed": true, + "core_cursor": [0, 2], + "core_row0_hex": "4142", + "expected_hex": "1b5b313b3352", + "received_hex": "1b5b313b3352", + "timed_out": false, + "complete": true, + "exit_code": 0, + "natural_exit": true, + "cleanup_verified": true, + "active_after": 0 + } + }, + { + "label": "run2", + "supervisor_status": "passed", + "exit_code": 0, + "job_after": {"active": 0, "total": 8, "terminated": 0}, + "raw_silent": { + "query_seen": true, + "timed_out": true, + "complete": false, + "received_hex": "", + "exit_code": 42, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "raw_distinct": { + "query_seen": true, + "timed_out": false, + "complete": true, + "received_hex": "1b5b393b3952", + "exit_code": 0, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "product": { + "child_pid": 13780, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e1b5b3f313030346c1b5b3f393030316c", + "query_offset": 23, + "da1_startup_observed": true, + "core_cursor": [0, 2], + "core_row0_hex": "4142", + "expected_hex": "1b5b313b3352", + "received_hex": "1b5b313b3352", + "timed_out": false, + "complete": true, + "exit_code": 0, + "natural_exit": true, + "cleanup_verified": true, + "active_after": 0 + } + } + ], + "core_regression": { + "reply_core": "passed 0.01 s", + "private_mode_core": "passed 0.02 s" + }, + "ctest_query_reply": { + "status": "passed", + "wall_seconds": 4.34 + } + }, + "recorded_observations": { + "transport_decode": "startup ESC[1t ESC[c ESC[?1004h ESC[?9001h (23 B) + AB + ESC[6n, then shutdown ESC[?1004l ESC[?9001l; the product capture includes shutdown, the raw stages were captured before close.", + "silent_control": "With the host silent the real child received no bytes and exited through its own 4 s timeout (42); the fixed ConPTY therefore did not answer the DSR query itself in this configuration.", + "distinct_control": "The host-written diagnostic value ESC[9;9R reached the child unchanged, proving the host -> ConPTY -> child write path.", + "product_chain": "SessionModel parsed the exact fixed source at transport offset 23, held the core cursor at (0,2) with primary row 0 = AB, and the real child received exactly ESC[1;3R; the child raw file and metadata cross-check, PID identity and cleanup all agree." + }, + "limitations": [ + "Single 80x24 primary-screen, single-session, home-start scenario; no resize, alternate screen, scroll region, wide/combining text, history, selection or Find.", + "Only the implemented DSR 6n query. DA1 stays strict-only/recovery-silent; DA2, OSC 10/11, bracketed paste and mouse/focus modes remain unimplemented or unverified; this slice adds no product query support.", + "The child enables ENABLE_VIRTUAL_TERMINAL_INPUT, which is the pinned ConPTY condition for passing unhandled CSI through to the child; non-VT-input children were not tested and their byte delivery is unknown.", + "No WindTerm original comparison and no real TUI consumption evidence (e.g. Vim continuing after the reply); #28 overall TUI matrix and #27/#29/#30 joint acceptance remain open.", + "Runs take about 4.5 s and are not performance or presentation measurements; presented_done is null." + ] +} From 38107b103c939212dc16eea37b190b3031dce89f Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:40:46 +0800 Subject: [PATCH 3/4] ci(qt): run the DSR 6n reply test in the Qt fixed-ConPTY job (#28) Build qt_query_reply_test, record its runtime manifest, and include qt_query_reply in the existing offscreen CTest selection so the new evidence is reproducible in CI. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- .github/workflows/qt-cpp.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 8a12dba..a45342b 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_eight_session_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_find_test qt_scrollbar_size_test qt_clipboard_system_test --parallel 2 + run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_eight_session_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_find_test qt_scrollbar_size_test qt_clipboard_system_test qt_query_reply_test --parallel 2 - name: Stage the frozen Qt product and verify every file identity run: | python -m tools.stage_qt_product stage ` @@ -93,6 +93,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-clipboard-bin/Release --executable qt_clipboard_system_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-query-reply-bin/Release --executable qt_query_reply_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve runtime file identities without binaries if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -112,8 +114,9 @@ jobs: build/qt-cpp/qt-history-bin/Release/runtime-manifest.json build/qt-cpp/qt-selection-bin/Release/runtime-manifest.json build/qt-cpp/qt-clipboard-bin/Release/runtime-manifest.json + build/qt-cpp/qt-query-reply-bin/Release/runtime-manifest.json - name: Run Qt offscreen smoke in its owned Job - run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find|qt_scrollbar_size)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find|qt_scrollbar_size|qt_query_reply)$' --no-tests=error --verbose - name: Run hosted-only Qt system clipboard copy run: python src/qt/run_smoke.py build/qt-cpp/qt-clipboard-bin/Release/qt_clipboard_system_test.exe --hosted-copy - name: Run hosted Windows QPA product-writer clipboard copy From 61c9868e44a0f881d81516b369c301defe2bbb8b Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:23:18 +0800 Subject: [PATCH 4/4] docs(evidence): clarify zero-based offsets of the fixed DSR source (#28) query_offset=23 is the start of the fixed 6-byte source AB+ESC[6n; the ESC[6n sequence itself starts at zero-based byte 25 (AB occupies 23/24). Clarify the README, EVIDENCE row and validation prose only. Raw transport hex, historical results, schema/field names and product/test logic are unchanged. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- docs/EVIDENCE.md | 2 +- experiments/shell/results/2026-11-09-qt-query-reply/README.md | 4 ++-- .../shell/results/2026-11-09-qt-query-reply/validation.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 163b83e..9124d1e 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -147,7 +147,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | More 尾部 ECH 与捕获时序(#42 限定修复) | [合同及来源](research/more-capture-ech.md)、[修复前后与同输入对照](../experiments/differential/results/2026-10-26-more-ech/validation.json)、[历史触发记录](../experiments/shell/results/2026-10-26-margin-state/validation.json) | Recovery ECH 缺失和旧捕获过早分开修正,同原件 whole/byte 红→绿;完整 More 提示、单 q/自然退出/EOF/Job0 独立核对。候选 pending-wrap 差异仍保留。 | | More 分页器轨迹冻结与产品配置冒烟(#28 首批应用切片) | [轨迹与独立预期](research/tui-more-trajectory-2026-11-02.md)、[冒烟记录](../experiments/shell/results/2026-11-02-tui-more/validation.json)、[固定进入样本](../experiments/core/fixtures/more-pager-fixed-411.bin)、[系统变体样本](../experiments/core/fixtures/more-pager-system-375.bin) | Windows 11 x64 固定 More.com 10.0.26100.1(SHA-256 dea4ed89…53,248 字节)的版本/入口/动作(进入→翻页→行滚→q 退出;搜索不适用)与逐步协议预期;固定 ConPTY 1.25 进入/退出 strict+recovery 双通且 4 次捕获确定,系统 ConPTY 进入非确定(ECH70/CUF79 两变体)且 strict 因 OSC 标题失败。DA1(ESC[c)在固定启动包出现(strict 回复 ESC[?6c、recovery 静默);6n、1049、滚动区未在 More 轨迹出现;ED2 仅系统路径进入出现、固定路径用空格+CR。只冻结轨迹不改产品行为,= 行号/F/P/S 交互键、二进制输入、其他语言提示未覆盖。 | | Vim for Windows 轨迹冻结与产品配置冒烟(#28 应用矩阵第 2 项) | [轨迹与独立预期](research/tui-vim-trajectory-2026-11-02.md)、[冒烟记录](../experiments/shell/results/2026-11-02-tui-vim/validation.json)、[80×24 冻结样本](../experiments/core/fixtures/vim-fixed-80x24-trajectory.bin)、[60×12 冻结样本](../experiments/core/fixtures/vim-fixed-60x12-trajectory.bin) | Windows 11 x64 固定 Vim 9.1 patches 1–532(Git for Windows,EXE SHA-256 2e0fda63…,msys-2.0.dll 3096d2ba…)的版本/入口/动作(进入→插入→保存退出→主屏恢复)与逐步协议预期;固定 ConPTY 1.25 全轨迹 3 次捕获逐字节一致,recovery 回放进入/插入/保存/退出/主屏恢复全程通过(exit 0),strict 在偏移 31 的 ESC[22;0;0t 以 too many CSI parameters 失败(terminal.hpp:81-82 参数门限早于 t 分支)。1049 进出(偏移 23/2385)、DA1(偏移 4)、DSR 6n(4 次)、DECSTBM(1;24r)、ED2 均在轨迹出现且核心已处理;DA2、括号粘贴、DCS 同步、多参数 XTWINOPS 标题未实现。60×12 为 ConPTY 60 列×12 行视口,非原版 60×3000 缓冲区,二者不合并。 | -| 正式 Qt / 固定 ConPTY 的 DSR 6n 回复分层证据(#28 回复合同调查) | [分层结果与限制](../experiments/shell/results/2026-11-09-qt-query-reply/README.md)、[固定验证身份](../experiments/shell/results/2026-11-09-qt-query-reply/validation.json)、[核心 reply 合同](../experiments/core/reply_test.cpp) | 80×24 主屏、单会话、home 起点写 `AB`+`ESC[6n`:既有核心合同预期 `ESC[1;3R`;固定 ConPTY 静默对照中 child 仅超时(exit 42,收不到字节),host 写入 `ESC[9;9R` 原样到达 child;正式 SessionModel 在 transport offset 23 解析查询并经有界输入队列回写,真实 child 恰收 `ESC[1;3R`,raw/metadata 一致、PID 绑定、cleanup_verified/active_after=0,两次运行字节一致。仅单场景、不 resize/备用屏/滚动区、不验证其它查询/TUI 消费/WindTerm 对齐,`presented_done=null`;child 启用 VT 输入模式;「conhost 代答」旧括注与该固定版本不符。 | +| 正式 Qt / 固定 ConPTY 的 DSR 6n 回复分层证据(#28 回复合同调查) | [分层结果与限制](../experiments/shell/results/2026-11-09-qt-query-reply/README.md)、[固定验证身份](../experiments/shell/results/2026-11-09-qt-query-reply/validation.json)、[核心 reply 合同](../experiments/core/reply_test.cpp) | 80×24 主屏、单会话、home 起点写 `AB`+`ESC[6n`:既有核心合同预期 `ESC[1;3R`;固定 ConPTY 静默对照中 child 仅超时(exit 42,收不到字节),host 写入 `ESC[9;9R` 原样到达 child;正式 SessionModel 在 transport offset 23(固定源串 `AB`+`ESC[6n` 起点;`ESC[6n` 本身从零基字节 25 开始)解析查询并经有界输入队列回写,真实 child 恰收 `ESC[1;3R`,raw/metadata 一致、PID 绑定、cleanup_verified/active_after=0,两次运行字节一致。仅单场景、不 resize/备用屏/滚动区、不验证其它查询/TUI 消费/WindTerm 对齐,`presented_done=null`;child 启用 VT 输入模式;「conhost 代答」旧括注与该固定版本不符。 | ## 核心路线、参考与历史验证 diff --git a/experiments/shell/results/2026-11-09-qt-query-reply/README.md b/experiments/shell/results/2026-11-09-qt-query-reply/README.md index 39dae48..193863b 100644 --- a/experiments/shell/results/2026-11-09-qt-query-reply/README.md +++ b/experiments/shell/results/2026-11-09-qt-query-reply/README.md @@ -2,7 +2,7 @@ 本切片在**正式 C++ / Qt Widgets / 随包固定 Microsoft ConPTY** 构建中,对一个有界、确定的单会话场景核对“核心回复 → Qt 回写 → 真实 child 收到字节”的完整证据链,并分别保留核心孤立合同、ConPTY 原始传输、Qt 产品回写和 child 实际接收四层观察。 -**结论先行(限定场景)**:在 80×24 固定主屏、单会话、无 resize/备用屏/滚动区、child 已启用 VT 输入模式的场景下,正式 `SessionModel` 在 transport offset 23 解析到 child 的 `ESC[6n`,按既有 reply 合同生成 `ESC[1;3R` 并经同一有界输入队列回写;真实 child 的独立接收文件恰好收到 `1b5b313b3352`(`ESC[1;3R`),PID 与 SessionModel 绑定,自然退出与 OwnedJob 回收均通过。固定 ConPTY 在本配置下**不会自行代答** DSR:host 静默对照中 child 只观察到自己的超时(exit 42),host 写入的另一个位置值也能原样到达 child。 +**结论先行(限定场景)**:在 80×24 固定主屏、单会话、无 resize/备用屏/滚动区、child 已启用 VT 输入模式的场景下,正式 `SessionModel` 在 transport 的固定源串起点 23(`AB`+`ESC[6n` 的 `AB` 起点;`ESC[6n` 本身从零基字节 25 开始)解析到 child 的查询,按既有 reply 合同生成 `ESC[1;3R` 并经同一有界输入队列回写;真实 child 的独立接收文件恰好收到 `1b5b313b3352`(`ESC[1;3R`),PID 与 SessionModel 绑定,自然退出与 OwnedJob 回收均通过。固定 ConPTY 在本配置下**不会自行代答** DSR:host 静默对照中 child 只观察到自己的超时(exit 42),host 写入的另一个位置值也能原样到达 child。 不修改产品行为、默认入口或 query 支持;不新增 DA1/DA2/OSC 回复;不进入 #28 整体 TUI 验收,也不宣称 WindTerm 对齐或性能。 @@ -20,7 +20,7 @@ | 1. 核心孤立合同 | 既有 `experiments/core/reply_test.cpp` + CTest `reply_core`(`CMakeLists.txt` 注册) | 本轮复跑通过(0.01 s);契约断言 “ab”+`ESC[6n` → `ESC[1;3R`,`5n` 负例不回复,DA1 仅 strict 回复 `ESC[?6c`、recovery 静默 | 只有核心 Parser 能生成该字节;不含 Qt/ConPTY/child | | 2. ConPTY 原始传输(host 静默) | 本切片 `qt_query_reply_test` 的 raw-silent 阶段:`tf_conpty::Session` + 固定后端,观察到 query 后**不写任何回复** | transport = `1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e`(`ESC[1t`、`ESC[c`、`ESC[?1004h`、`ESC[?9001h` 启动包 + `AB` + `ESC[6n`);child 接收为空、exit 42(自身 4 s 超时),`cleanup_complete=true`、`active_after=0` | 固定 ConPTY 转发了 child 查询,但**没有自行产生回复**;此时 child 收不到任何 DSR 字节 | | 3. ConPTY 原始传输(host 写入诊断值) | 同一 raw 阶段,host 在观察到 query 后写入 `1b5b393b3952`(`ESC[9;9R`,仅诊断用) | child 原始接收文件 = `1b5b393b3952`,exit 0;transport 与层 2 相同 | host→ConPTY→child 的写入路径确实送达真实 child,且未被 conhost 改写为实际位置 | -| 4. Qt 产品回写 + child 实际接收 | 正式 `SessionModel`:`poll()` 中 `parser_.feed()` → `take_replies()` → `send(QByteArray)` → 同一有界 `InputQueue` | transport 含启动包 + `AB` + `ESC[6n`(query_offset=23)与关闭 `ESC[?1004l/ESC[?9001l`;`da1_startup_observed=true`;core 主屏行 0 = `AB`、协议光标 (0,2);child 原始接收 = `1b5b313b3352`,metadata 自报接收一致;child PID 与 `model.resize_state().child_pid` 一致;`natural_exit=true`、`cleanup_verified=true`、`output_eof=true`、`active_after=0` | 产品路径把合同回复送达真实 child;启动包中的 DA1 请求未产生回写(child 精确等于 DSR 回复,未收到 `ESC[?6c`) | +| 4. Qt 产品回写 + child 实际接收 | 正式 `SessionModel`:`poll()` 中 `parser_.feed()` → `take_replies()` → `send(QByteArray)` → 同一有界 `InputQueue` | transport 含启动包 + `AB` + `ESC[6n` 与关闭 `ESC[?1004l/ESC[?9001l`;`query_offset`=23 是固定源串 `AB`+`ESC[6n` 的起点(`AB` 占 23/24,`ESC[6n` 从零基字节 25 开始);`da1_startup_observed=true`;core 主屏行 0 = `AB`、协议光标 (0,2);child 原始接收 = `1b5b313b3352`,metadata 自报接收一致;child PID 与 `model.resize_state().child_pid` 一致;`natural_exit=true`、`cleanup_verified=true`、`output_eof=true`、`active_after=0` | 产品路径把合同回复送达真实 child;启动包中的 DA1 请求未产生回写(child 精确等于 DSR 回复,未收到 `ESC[?6c`) | 两次完整运行(实现提交后的干净工作树)字节结果一致,仅 PID 不同: diff --git a/experiments/shell/results/2026-11-09-qt-query-reply/validation.json b/experiments/shell/results/2026-11-09-qt-query-reply/validation.json index 958328a..30728a9 100644 --- a/experiments/shell/results/2026-11-09-qt-query-reply/validation.json +++ b/experiments/shell/results/2026-11-09-qt-query-reply/validation.json @@ -154,10 +154,10 @@ } }, "recorded_observations": { - "transport_decode": "startup ESC[1t ESC[c ESC[?1004h ESC[?9001h (23 B) + AB + ESC[6n, then shutdown ESC[?1004l ESC[?9001l; the product capture includes shutdown, the raw stages were captured before close.", + "transport_decode": "startup ESC[1t ESC[c ESC[?1004h ESC[?9001h (23 B) + AB + ESC[6n, then shutdown ESC[?1004l ESC[?9001l; the product capture includes shutdown, the raw stages were captured before close. Zero-based offsets: the fixed 6-byte source AB+ESC[6n starts at byte 23 (AB at 23/24; the ESC[6n sequence itself starts at byte 25).", "silent_control": "With the host silent the real child received no bytes and exited through its own 4 s timeout (42); the fixed ConPTY therefore did not answer the DSR query itself in this configuration.", "distinct_control": "The host-written diagnostic value ESC[9;9R reached the child unchanged, proving the host -> ConPTY -> child write path.", - "product_chain": "SessionModel parsed the exact fixed source at transport offset 23, held the core cursor at (0,2) with primary row 0 = AB, and the real child received exactly ESC[1;3R; the child raw file and metadata cross-check, PID identity and cleanup all agree." + "product_chain": "SessionModel parsed the exact fixed source at transport offset 23 (start of AB+ESC[6n; ESC[6n itself starts at zero-based byte 25), held the core cursor at (0,2) with primary row 0 = AB, and the real child received exactly ESC[1;3R; the child raw file and metadata cross-check, PID identity and cleanup all agree." }, "limitations": [ "Single 80x24 primary-screen, single-session, home-start scenario; no resize, alternate screen, scroll region, wide/combining text, history, selection or Find.",