Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -340,6 +344,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 decom_origin_core COMMAND decom_origin_test)
add_test(NAME reply_core COMMAND reply_test)
add_test(NAME edit_csi_core COMMAND edit_csi_test)
Expand Down
88 changes: 88 additions & 0 deletions experiments/core/private_mode_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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 <iostream>
#include <sstream>
#include <stdexcept>
#include <string>

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_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[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() {
// 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_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;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
12 changes: 12 additions & 0 deletions experiments/core/reply_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions experiments/core/terminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class Parser {
screen.set_origin_mode(final == 'h');
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) {
Expand Down Expand Up @@ -84,6 +90,15 @@ class Parser {
params.size() < 2 || params[1] == 0 ? -1 : params[1] - 1);
break;
case 'M': screen.delete_lines(n); break;
case 't':
// 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;
Expand All @@ -99,6 +114,20 @@ class Parser {
return;
}
throw InputError("unsupported CSI command");
case 'c':
// 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;
}
throw InputError("unsupported CSI command");
default: throw InputError("unsupported CSI command");
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/qt/terminal_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<qsizetype>(reply.size())));
}
} catch (const std::exception& error) {
parser_failed_ = true;
remember_error(QString::fromUtf8(error.what()));
Expand Down