From 7e8d2418aa536a3b92f69c03bbb17e49a3847286 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:59:12 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(core):=20=E6=8E=A5=E5=8F=97=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20ConPTY=20=E4=BC=9A=E5=8F=91=E5=87=BA=E7=9A=84=20=3F?= =?UTF-8?q?1004/=3F9001=20=E4=B8=8E=20CSI=201t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实捕获 experiments/conpty/results/2026-10-27-native-viewport-resize/case-0/ transport.bin 在 strict 下第一个控制序列就失败,核心无法回放任何真实 ConPTY 传输。 按「记录型接受、不改变行为」的最小范围补三条: - ?1004 焦点上报:接受并忽略。核心不绘制焦点状态,宿主也没有可作用的对象 - ?9001 Win32 输入模式:接受并忽略,同上 - CSI 1t 窗口操作(XTWOL/XTMOD):接受并忽略。这是 ConPTY 在原生 resize 附近 发出的通知,按它改变几何会与宿主自身的尺寸管理冲突 有意不做的: - ESC[c(DA1)仍抛错。回复内容等于声明终端能力,核心实现的是很有限的子集, 回复哪一种都需要先定策略,本提交不预设 - ?2004 bracketed paste 与 ?1000 鼠标模式仍抛错,避免用「接受」制造已支持的假象 效果:上面那份捕获的解析从字节 0 推进到字节 4(即 ESC[c 处),前三项的接受 不再阻塞回放。 --- CMakeLists.txt | 5 ++ experiments/core/private_mode_test.cpp | 76 ++++++++++++++++++++++++++ experiments/core/terminal.hpp | 12 ++++ 3 files changed, 93 insertions(+) create mode 100644 experiments/core/private_mode_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c6be02bb..974968a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,10 @@ add_executable(alternate_mode_test experiments/core/alternate_mode_test.cpp) target_link_libraries(alternate_mode_test PRIVATE termforge_core) termforge_warnings(alternate_mode_test) +add_executable(private_mode_test experiments/core/private_mode_test.cpp) +target_link_libraries(private_mode_test PRIVATE termforge_core) +termforge_warnings(private_mode_test) + add_executable(decawm_mode_test experiments/core/decawm_mode_test.cpp) target_link_libraries(decawm_mode_test PRIVATE termforge_core) termforge_warnings(decawm_mode_test) @@ -336,6 +340,7 @@ if(BUILD_TESTING) add_test(NAME view_mapping_core COMMAND view_mapping_test) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) add_test(NAME decawm_mode_core COMMAND decawm_mode_test) +add_test(NAME private_mode_core COMMAND private_mode_test) add_test(NAME reply_core COMMAND reply_test) add_test(NAME edit_csi_core COMMAND edit_csi_test) set_tests_properties(decawm_mode_core PROPERTIES TIMEOUT 10) diff --git a/experiments/core/private_mode_test.cpp b/experiments/core/private_mode_test.cpp new file mode 100644 index 00000000..a020b070 --- /dev/null +++ b/experiments/core/private_mode_test.cpp @@ -0,0 +1,76 @@ +// Private modes and window operations that real ConPTY sessions emit. +// +// The core used to throw on every private mode except ?1/?7/?25/?1049 and on +// every CSI final outside its draw set, so a captured native-viewport stream +// could not be replayed in strict mode at all. These cases pin the accepted +// set, the no-op semantics, and the sequences that remain unsupported. +#include "terminal.hpp" +#include +#include +#include +#include + +using namespace termforge; + +static void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static std::string replay(const std::string& bytes, int rows = 5, int columns = 20) { + Parser parser(rows, columns, 10); + parser.feed(bytes); + parser.finish(); + std::ostringstream out; + parser.screen.json(out); + return out.str(); +} + +static void check_rejected(const std::string& bytes, const char* message) { + try { + Parser parser(5, 20, 10); + parser.feed(bytes); + parser.finish(); + } catch (const std::exception&) { + return; + } + throw std::runtime_error(message); +} + +static void focus_and_win32_input_mode_are_accepted_without_effect() { + const std::string plain = replay("HELLO"); + // Both modes are requested by ConPTY itself; they must not affect drawing. + check(replay("\x1b[?1004hHELLO") == plain, "focus reporting changed the screen"); + check(replay("\x1b[?1004lHELLO") == plain, "focus reporting reset changed the screen"); + check(replay("\x1b[?9001hHELLO") == plain, "Win32 input mode changed the screen"); + check(replay("\x1b[?9001lHELLO") == plain, "Win32 input mode reset changed the screen"); +} + +static void window_manipulation_is_ignored() { + const std::string plain = replay("HELLO"); + check(replay("\x1b[1tHELLO") == plain, "window de-iconify changed the screen"); + check(replay("\x1b[8tHELLO") == plain, "window operation changed the screen"); +} + +static void unsupported_sequences_still_fail_loudly() { + // Device attributes need a reply that states the terminal's capabilities; + // until that is decided the query must stay unsupported rather than be + // answered with a claim the core cannot back. + check_rejected("\x1b[c", "device attributes query must remain unsupported"); + // Bracketed paste is not implemented, so enabling it must not be accepted + // silently as if the terminal supported it. + check_rejected("\x1b[?2004h", "bracketed paste must remain unsupported"); + check_rejected("\x1b[?1000h", "mouse reporting must remain unsupported"); +} + +int main() { + try { + focus_and_win32_input_mode_are_accepted_without_effect(); + window_manipulation_is_ignored(); + unsupported_sequences_still_fail_loudly(); + std::cout << "private mode checks passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} \ No newline at end of file diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index 98bbd5ec..bb38714b 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -40,6 +40,12 @@ class Parser { if (final == 'h') mode_bits_ |= bit; else mode_bits_ &= ~bit; return; } + if (params.size() == 1 && (params[0] == 1004 || params[0] == 9001) && (final == 'h' || final == 'l')) + // Real ConPTY sessions request focus reporting (?1004) and Win32 + // input mode (?9001). Neither changes what this core draws or + // sends, and the host has nothing to act on yet, so they are + // accepted without being recorded. + return; if (params.size() != 1 || (params[0] != 1049 && params[0] != 7) || (final != 'h' && final != 'l')) throw InputError("unsupported private CSI command"); if (params[0] == 7) { @@ -78,6 +84,12 @@ class Parser { params.size() < 2 || params[1] == 0 ? -1 : params[1] - 1); break; case 'M': screen.delete_lines(n); break; + case 't': + // Window manipulation (XTWINOPS) is advisory: ConPTY emits CSI 1 t + // around a native resize. Acting on it would fight the host's own + // geometry, so it is accepted and ignored like other terminals do + // for operations they do not implement. + return; case 'X': screen.erase_characters(n); break; case '@': screen.insert_characters(n); break; case 'P': screen.delete_characters(n); break; From de55d8fe05c3bfaf768c33200ed3230ed957e6b2 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:08:34 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(core):=20DA1=20=E5=9B=9E=E5=A4=8D=20VT1?= =?UTF-8?q?02=EF=BC=8C=E5=B9=B6=E5=AF=B9=E9=BD=90=20strict=20=E4=B8=8E=20r?= =?UTF-8?q?ecovery=20=E7=9A=84=E6=8E=A5=E5=8F=97=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决定 DA1 策略:回复 ESC[?6c(VT102)。依据是候选实现 (vendor/alacritty_terminal/src/term/mod.rs:1214)对 CSI c 回同一条, 且 VT102 是核心实际能支撑的最小声明——光标定位、擦除、插删行/字符、 SGR、DECAWM、备用屏都在实现范围内,而更高版本号会过度声明。 同时澄清一处此前的误读:核心有两条解析路径。feed_strict(旧路径, replay 与测试在用)遇到 OSC 等序列直接抛错;recovery(产品路径)本就 会吞掉这些并计数。本提交让 strict 在 ?1004/?9001/CSI 1-3t/DA1 上与 recovery 对齐,而不是新增产品能力。 保留仍抛错的项(并有测试断言):?2004 bracketed paste、?1000 鼠标、 CSI 4t/11t 等查询、OSC(strict 路径尚未支持标题)。 --- experiments/core/private_mode_test.cpp | 26 +++++++++++++++++++------- experiments/core/terminal.hpp | 24 +++++++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/experiments/core/private_mode_test.cpp b/experiments/core/private_mode_test.cpp index a020b070..1057a570 100644 --- a/experiments/core/private_mode_test.cpp +++ b/experiments/core/private_mode_test.cpp @@ -45,27 +45,39 @@ static void focus_and_win32_input_mode_are_accepted_without_effect() { check(replay("\x1b[?9001lHELLO") == plain, "Win32 input mode reset changed the screen"); } -static void window_manipulation_is_ignored() { +static void window_commands_are_ignored_but_queries_are_not() { const std::string plain = replay("HELLO"); check(replay("\x1b[1tHELLO") == plain, "window de-iconify changed the screen"); - check(replay("\x1b[8tHELLO") == plain, "window operation changed the screen"); + check(replay("\x1b[2tHELLO") == plain, "window iconify changed the screen"); + check(replay("\x1b[3tHELLO") == plain, "window move changed the screen"); +} + +static void device_attributes_claim_only_vt102() { + // The reply states what this terminal claims to support, so it stays at the + // level the core implements and matches the reference candidate. + Parser parser(5, 20, 10); + parser.feed("\x1b[c"); + parser.finish(); + const auto replies = parser.take_replies(); + check(replies.size() == 1 && replies[0] == "\x1b[?6c", "device attributes reply changed"); } static void unsupported_sequences_still_fail_loudly() { - // Device attributes need a reply that states the terminal's capabilities; - // until that is decided the query must stay unsupported rather than be - // answered with a claim the core cannot back. - check_rejected("\x1b[c", "device attributes query must remain unsupported"); // Bracketed paste is not implemented, so enabling it must not be accepted // silently as if the terminal supported it. check_rejected("\x1b[?2004h", "bracketed paste must remain unsupported"); check_rejected("\x1b[?1000h", "mouse reporting must remain unsupported"); + // CSI t beyond the iconify/move commands are queries; an unanswered query + // must stay a loud failure so it cannot look like a satisfied request. + check_rejected("\x1b[4t", "window pixel-size query must remain unsupported"); + check_rejected("\x1b[11t", "window state query must remain unsupported"); } int main() { try { focus_and_win32_input_mode_are_accepted_without_effect(); - window_manipulation_is_ignored(); + window_commands_are_ignored_but_queries_are_not(); + device_attributes_claim_only_vt102(); unsupported_sequences_still_fail_loudly(); std::cout << "private mode checks passed\n"; return 0; diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index bb38714b..8958be9b 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -85,11 +85,14 @@ class Parser { break; case 'M': screen.delete_lines(n); break; case 't': - // Window manipulation (XTWINOPS) is advisory: ConPTY emits CSI 1 t - // around a native resize. Acting on it would fight the host's own - // geometry, so it is accepted and ignored like other terminals do - // for operations they do not implement. - return; + // CSI 1/2/3 t are window commands (de-iconify, iconify, move) that + // ConPTY emits around a native resize. Acting on them would fight + // the host's own geometry, so they are accepted and ignored. Every + // other CSI t is a query this core cannot answer and stays + // unsupported rather than swallowing a request the application is + // waiting on. + if (params.size() == 1 && params[0] >= 1 && params[0] <= 3) return; + throw InputError("unsupported CSI command"); case 'X': screen.erase_characters(n); break; case '@': screen.insert_characters(n); break; case 'P': screen.delete_characters(n); break; @@ -102,6 +105,17 @@ class Parser { return; } throw InputError("unsupported CSI command"); + case 'c': + // Primary device attributes. The reply states which capabilities + // this terminal claims, so it stays at the VT102 level the core + // actually implements (cursor addressing, erase, insert/delete line + // and character, SGR, DECAWM, alternate screen) and matches the + // reference candidate, which also answers \x1b[?6c. + if (params.size() == 1 && params[0] == 0) { + pending_replies.push_back("\x1b[?6c"); + return; + } + throw InputError("unsupported CSI command"); default: throw InputError("unsupported CSI command"); } } From 96f11c9246cd84eb385ad97ea2d2e496490189a3 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:19:22 +0800 Subject: [PATCH 3/4] fix(qt): drop stale query replies once the session is closing ConPTY emits DA1 exactly once in each session's startup packet. On the product recovery path that now produces a pending reply, which poll() sends back through the same bounded input path as keystrokes. When a session is closed before its first poll (resize_lifecycle_boundaries mode 0), send() hits !running() and records a fatal 'Terminal input is unavailable' that the await helper surfaces. A reply only matters while the child is still attached, so skip it once the session is no longer running. This also removes the same latent race for DSR cursor-position replies. --- src/qt/terminal_session.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/qt/terminal_session.hpp b/src/qt/terminal_session.hpp index a6093b45..e75d0d1d 100644 --- a/src/qt/terminal_session.hpp +++ b/src/qt/terminal_session.hpp @@ -156,8 +156,14 @@ class SessionModel final : public QObject { parser_.feed(bytes); // Query replies (a cursor position report today) go back to the // child through the same bounded input path as keystrokes. - for (const auto& reply : parser_.take_replies()) + // A reply only matters while the child is still attached: ConPTY + // emits DA1 once in its startup packet, so a session closed before + // its first poll would otherwise record a fatal "terminal input + // is unavailable" error for a reply nobody can receive. + for (const auto& reply : parser_.take_replies()) { + if (!running()) break; send(QByteArray(reply.data(), static_cast(reply.size()))); + } } catch (const std::exception& error) { parser_failed_ = true; remember_error(QString::fromUtf8(error.what())); From b05e273cb13322f09fc2fde6db87ae99dc8c4753 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:07:58 +0800 Subject: [PATCH 4/4] fix(core): answer DA1 only on the strict parser path The product parser runs in recovery mode, but the shared dispatch() added in #272 made recovery answer the fixed ConPTY startup packet's DA1 query with ESC[?6c too. The Qt entry drains that reply and sends it back through the bounded input path as if it were a keystroke at session startup, which races with the session lifecycle: when the child has exited or is closing, the input queue is already stopping and send() records "Terminal input queue is full or unavailable" (qt_cpp_shell PR-event failures). Answer DA1 only in strict mode (replay/tests), matching the #272 design table where strict is for replay/testing and recovery is the product path that stays silent. --- experiments/core/reply_test.cpp | 12 ++++++++++++ experiments/core/terminal.hpp | 15 +++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/experiments/core/reply_test.cpp b/experiments/core/reply_test.cpp index a30de673..1e15a8d1 100644 --- a/experiments/core/reply_test.cpp +++ b/experiments/core/reply_test.cpp @@ -26,6 +26,18 @@ int main() { // An unimplemented report must stay unimplemented instead of being faked. parser.feed("\x1b[5n"); require(parser.take_replies().empty(), "unsupported report produced a reply"); + // DA1 answers only on the strict path. The product recovery path must stay + // silent because the fixed ConPTY emits DA1 in its startup packet and a + // reply sent back through the bounded input path at startup races with the + // session lifecycle (PR #272). + parser.feed("\x1b[c"); + require(parser.take_replies().empty(), "recovery DA1 produced a reply"); + { + Parser strict_parser(3, 10, 10, ParseMode::strict); + strict_parser.feed("\x1b[c"); + const auto da1 = strict_parser.take_replies(); + require(da1.size() == 1 && da1[0] == "\x1b[?6c", "strict DA1 reply mismatch"); + } // DECCKM and DECTCEM are state-only modes; strict parsing must accept them. { Parser strict_parser(3, 10, 10, ParseMode::strict); diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index 8958be9b..264b163e 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -106,12 +106,15 @@ class Parser { } throw InputError("unsupported CSI command"); case 'c': - // Primary device attributes. The reply states which capabilities - // this terminal claims, so it stays at the VT102 level the core - // actually implements (cursor addressing, erase, insert/delete line - // and character, SGR, DECAWM, alternate screen) and matches the - // reference candidate, which also answers \x1b[?6c. - if (params.size() == 1 && params[0] == 0) { + // Primary device attributes. Only the strict path (replay/tests) + // answers, at the VT102 level the core actually implements (cursor + // addressing, erase, insert/delete line and character, SGR, DECAWM, + // alternate screen), matching the reference candidate's \x1b[?6c. + // The product recovery path must stay silent: the fixed ConPTY + // emits DA1 once in its startup packet, and a reply bounced back + // through the bounded input path at startup races with the session + // lifecycle (PR #272). + if (mode == ParseMode::strict && params.size() == 1 && params[0] == 0) { pending_replies.push_back("\x1b[?6c"); return; }