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
2 changes: 2 additions & 0 deletions .github/workflows/core-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ jobs:
run: cmake --build build --config Release --target core_measure
- name: Build and test candidate C++ adapter
run: cmake --build build --config Release --target candidate_core_test && ctest --test-dir build -C Release -R candidate_core --output-on-failure
- name: Verify resize trace and actual replay input rejection
run: cmake --build build --config Release --target candidate_resize_replay && ctest --test-dir build -C Release -R "^(resize_trace|candidate_resize_replay)$" --no-tests=error --output-on-failure
- name: Build and test candidate shell on Windows
if: runner.os == 'Windows'
run: cmake --build build --config Release --target shell_test_launcher && ctest --test-dir build -C Release -R candidate_shell_tui --output-on-failure
Expand Down
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ if(TERMFORGE_BUILD_CANDIDATE_ADAPTER)
else()
target_link_libraries(candidate_core_test PRIVATE pthread dl m)
endif()
add_executable(candidate_resize_replay experiments/core/candidate_resize_replay.cpp experiments/core/candidate_terminal.cpp)
target_link_libraries(candidate_resize_replay PRIVATE termforge_core termforge_candidate_core)
termforge_warnings(candidate_resize_replay)
if(WIN32)
target_link_libraries(candidate_resize_replay PRIVATE ws2_32 userenv ntdll)
else()
target_link_libraries(candidate_resize_replay PRIVATE pthread dl m)
endif()
endif()

add_executable(resize_test experiments/core/resize_test.cpp)
Expand Down Expand Up @@ -298,6 +306,10 @@ if(BUILD_TESTING)
"TERMFORGE_EXPECT_CONPTY_BACKEND=reviewed-side-by-side-1.25.260930003")
endif()
endfunction()
if(TERMFORGE_BUILD_CANDIDATE_ADAPTER)
termforge_add_python_test(candidate_resize_replay -m unittest experiments.core.test_candidate_resize_replay -v)
set_tests_properties(candidate_resize_replay PROPERTIES TIMEOUT 30)
endif()
if(TERMFORGE_BUILD_CANDIDATE_ADAPTER AND WIN32)
termforge_add_python_test(candidate_shell_tui -m unittest experiments.shell.test_candidate_tui -v)
set_tests_properties(candidate_shell_tui PROPERTIES TIMEOUT 120)
Expand All @@ -321,6 +333,7 @@ if(BUILD_TESTING)
termforge_add_python_test(workflow_system_runner -m unittest research.ime.test_workflow_system_runner -v)
set_tests_properties(workflow_system_runner PROPERTIES TIMEOUT 30)
termforge_add_python_test(copy_error_frame -m unittest experiments.shell.test_copy_error_frame -v)
termforge_add_python_test(resize_trace -m unittest experiments.shell.test_resize_trace -v)
if(WIN32)
termforge_add_python_test(research_guards -m unittest discover -s research/tests -v)
termforge_add_python_test(receive -m unittest experiments.receive.test_receive -v)
Expand Down
65 changes: 65 additions & 0 deletions experiments/core/candidate_resize_replay.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Reproduce a captured parser/feed ordering without a console or UI.
// Input and full screen snapshots can contain local paths; keep them private.
#include "candidate_terminal.hpp"
#include <charconv>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>

namespace {
std::string read_bounded(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input || input.tellg() < 0 || input.tellg() > 128 * 1024)
throw std::runtime_error("invalid resize replay input size");
input.seekg(0);
std::ostringstream out; out << input.rdbuf();
if (!input) throw std::runtime_error("resize replay input read failed");
return out.str();
}

size_t decimal(const std::string& token) {
size_t value = 0;
const auto parsed = std::from_chars(token.data(), token.data() + token.size(), value);
if (parsed.ec != std::errc{} || parsed.ptr != token.data() + token.size())
throw std::runtime_error("resize replay integer invalid");
return value;
}
}

int main(int argc, char** argv) {
try {
if (argc != 3) throw std::runtime_error("usage: candidate_resize_replay transport.bin plan.txt");
const auto bytes = read_bounded(argv[1]);
const auto commands = read_bounded(argv[2]);
std::istringstream plan(commands);
termforge::CandidateTerminal terminal;
size_t offset = 0, step = 0, traced_cells = 0;
std::string line;
while (std::getline(plan, line)) {
if (++step > 1024) throw std::runtime_error("resize replay event limit exceeded");
std::istringstream record(line);
std::string kind, first, second, extra;
if (!(record >> kind >> first >> second) || (record >> extra) ||
(kind != "R" && kind != "F"))
throw std::runtime_error("resize replay record invalid");
const auto a = decimal(first), b = decimal(second);
if (kind == "R") {
if (a < 1 || a > 200 || b < 1 || b > 500)
throw std::runtime_error("resize replay dimensions invalid");
terminal.resize(static_cast<int>(a), static_cast<int>(b));
} else {
if (a != offset || !b || b > bytes.size() - offset)
throw std::runtime_error("resize replay feed partition invalid");
terminal.feed(std::string_view(bytes).substr(offset, b)); offset += b;
}
const auto cells = (terminal.screen.history.size() + terminal.screen.lines.size()) * terminal.screen.width();
if (cells > 1000000 - traced_cells) throw std::runtime_error("resize replay snapshot limit exceeded");
traced_cells += cells;
std::cout << "{\"step\":" << step << ",\"offset\":" << offset << ",\"kind\":\"" << kind << "\",\"state\":";
terminal.screen.json(std::cout); std::cout << "}\n";
}
if (!plan.eof() || !step || offset != bytes.size()) throw std::runtime_error("resize replay plan incomplete");
return 0;
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
}
50 changes: 50 additions & 0 deletions experiments/core/test_candidate_resize_replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Exercise the actual replay executable's complete-line command grammar."""
import json
from pathlib import Path
import subprocess
import tempfile
import unittest

from experiments.build_paths import binary


class CandidateResizeReplayTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.executable = binary('candidate_resize_replay')

def replay(self, plan, payload=b'abc'):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / 'transport.bin').write_bytes(payload)
(root / 'plan.txt').write_text(plan, encoding='ascii')
return subprocess.run([str(self.executable), str(root / 'transport.bin'), str(root / 'plan.txt')],
capture_output=True, timeout=10)

def test_complete_lines_and_final_line_without_newline_are_valid(self):
for plan in ('F 0 3\n', 'F 0 3', 'R 12 60\r\nF 0 3\r\n'):
with self.subTest(plan=plan):
result = self.replay(plan)
self.assertEqual(result.returncode, 0, result.stderr)
states = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(states[-1]['offset'], 3)

def test_trailing_partial_unknown_extra_and_overflow_records_are_rejected(self):
cases = ('R 12', 'F 3', 'X', 'X 1 2', 'R 12 60 extra', 'F 3 0 extra',
'R 12\n60', 'R 12 999999999999999999999999999999999',
'F 999999999999999999999999999999999 1', 'R -1 80', 'R +1 80',
'R 1.0 80', 'R 0x10 80', 'F 3 1', 'F 0 1', '\n', ' ')
for tail in cases:
with self.subTest(tail=tail):
result = self.replay('F 0 3\n' + tail)
self.assertNotEqual(result.returncode, 0, 'accepted malformed trailing record')
self.assertTrue(result.stderr)

def test_feed_partition_must_cover_all_transport(self):
for plan in ('', 'R 12 60\n', 'F 0 2\n', 'F 1 2\n', 'F 0 4\n'):
with self.subTest(plan=plan):
self.assertNotEqual(self.replay(plan).returncode, 0)


if __name__ == '__main__':
unittest.main()
6 changes: 6 additions & 0 deletions experiments/shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ E2驱动是唯一默认传入`resources=True`的场景驱动,依赖项目Pytho

resize 两类场景各重复三次,通过真实 `SetWindowPos` 缩放并由子进程查询实际尺寸;包括快速连续请求、洪流中缩放、随后 Ctrl-C 或关闭。正常 resize 验证 60×12、100×28、72×18;关闭场景只查询前两个尺寸。脚本还将自有窗口移至非输入 desktop 右下边缘,验证 retained DIB 不依赖可见更新矩形。每次绘制与全量重绘作 RGB 对比;这些带检查的样本不能作为低开销延迟数据。

需要定位 resize 时序时,只在合成 `resize`/`resize-close` 场景的进程环境设置 `TERMFORGE_RESIZE_TRACE=1`。默认关闭;开启后最多保留 1024 个元数据事件和前 128 KiB 实际 feed 字节,在结束时写入 `resize-trace.json` 与 `resize-trace.transport.bin`。UI desired、parser resize 前后、worker 的真实 API 调用前后及 generation、feed 分段/offset 和 child 尺寸 marker 分列。worker API 返回仍不代表宿主或子进程已经应用尺寸;事件次序是观察次序,不是新增同步栅栏。它不改变原场景的输入、完整行 oracle 或 25 秒期限,开启后的运行也不是无插桩对照。

候选构建提供 `candidate_resize_replay`,可用 `python -m experiments.shell.resize_trace <capture> <new-output> --exe <candidate_resize_replay.exe>` 在 10 秒监督期限内,按实际 feed 分段与 UI parser resize 次序离线重放。校验器拒绝溢出、不完整字节分区和未知事件;回放成功只表示执行了所捕获的模型操作,不表示原场景通过,也不证明原生宿主状态、真实 glyph 或性能。原运输和逐步完整屏幕可含测试启动路径,保留在 `local/`,公开结果仅发布经审查的身份及脱敏结论。

Schema 1 必须包含 recorder 的全部字段与有效整数范围、非递减 QPC、初始 24×80 网格及每次 parser resize 后一致的 feed 网格。UI 的 desired/parser_begin/parser_end/request 和 worker 的 generation/begin/end 分别配对;worker 观察可以早于对应 request 观察,也允许请求被合并而没有 host 调用,不据观察次序补造同步保证。回放计划每行仅允许 `R rows columns` 或 `F offset bytes` 的完整十进制记录;残缺、未知、多余字段、空行和整数溢出都失败,末行可无换行。边界拒绝与三份原离线复验见[校验器验证](results/2026-10-26-resize-replay-guards/validation.json),不改变原 #51 失败。

每次运行依次验证普通命令、中文、退格、左右方向键编辑,再运行一百万轮的输出命令。至少持续一秒且消费 64 KiB 后发送 Ctrl-C,再执行 `echo TF_AFTER`;确认对应状态与已提交帧后 `exit 37`。[#36](https://github.com/JavaZeroo/termforge/issues/36) 保留历史中断超时与根因缺口;本次 normal/resize 各十轮未复现,不能写成产品修复。独立的[原始 ConPTY 回归](../conpty/README.md#持续-cmd-输出与-ctrlc-独立回归)另保留输入/输出与系统文件身份。另测输出中关闭,正常/关闭各重复三次,检查 Job 活跃进程归零与自有 desktop 消失。监督器用真实存活 shell 验证超时清理。独立队列探针验证容量、背压、顺序、停止唤醒与输入拒绝。短回归不代表长时无泄漏。

产品真实 IME 单独由 [`research.ime.product_runner`](../../research/ime/README.md) 显式托管运行,使用严格受控的真实 `SendInput`,不在这些私有 desktop 场景中合成 IME 消息。五个 `ui-ime-*` 场景有独立输入桌面/焦点/代次检查和内外 Job/GO 门控,开发机拒绝运行;历史/Find 与 rename 双 Escape 已有[固定源码系统审计](../../research/ime/results/2026-10-24-product-ime-contexts/validation.json)。
Expand Down
8 changes: 6 additions & 2 deletions experiments/shell/resize.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class ResizeWorker {
// performs Session::resize; a working generation means worker ownership,
// not proof that the kernel call is blocked or a child applied the size.
std::function<void()> before_resize_;
std::function<void(bool, unsigned, int, int)> observation_;
mutable std::mutex mutex_;
std::condition_variable ready_;
std::thread thread_;
Expand All @@ -24,8 +25,9 @@ class ResizeWorker {
unsigned requested_generation = 0, completed_generation = 0, working_generation = 0, pending_generation = 0;
bool working = false, pending = false, stopping = false, started = false, joined = false, failed = false;
};
explicit ResizeWorker(tf_conpty::Session& session,std::function<void()> before_resize={})
: session_(session),before_resize_(std::move(before_resize)) {}
explicit ResizeWorker(tf_conpty::Session& session,std::function<void()> before_resize={},
std::function<void(bool, unsigned, int, int)> observation={})
: session_(session),before_resize_(std::move(before_resize)),observation_(std::move(observation)) {}
~ResizeWorker() { stop(); }
Snapshot snapshot() const {
std::lock_guard<std::mutex> lock(mutex_);
Expand All @@ -49,7 +51,9 @@ class ResizeWorker {
working_ = true; working_generation_ = generation;
lock.unlock();
if(before_resize_) before_resize_();
if(observation_) observation_(true, generation, rows, columns);
session_.resize(static_cast<short>(columns), static_cast<short>(rows));
if(observation_) observation_(false, generation, rows, columns);
lock.lock(); completed_ = generation; working_ = false; working_generation_ = 0;
}
} catch (...) {
Expand Down
86 changes: 86 additions & 0 deletions experiments/shell/resize_trace.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Opt-in observations for synthetic resize scenarios; payloads stay local.
#pragma once
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <mutex>
#include <string>
#include <string_view>
#include <vector>

namespace tf_shell_test {
class ResizeTrace {
public:
enum Kind { desired, parser_begin, parser_end, request, host_begin, host_end, feed, child_ack };
private:
struct Event {
Kind kind;
uint64_t qpc, consumed;
unsigned generation;
int rows, columns, stage;
size_t bytes;
};
std::atomic<bool> enabled_{false};
mutable std::mutex mutex_;
std::vector<Event> events_;
std::string transport_;
uint64_t consumed_ = 0;
bool events_overflow_ = false, transport_overflow_ = false;
static constexpr size_t event_limit = 1024, transport_limit = 128 * 1024;
public:
void enable() {
events_.reserve(event_limit);
transport_.reserve(transport_limit);
enabled_.store(true);
}
bool enabled() const { return enabled_.load(); }
void observe(Kind kind, int rows, int columns, unsigned generation = 0, int stage = 0,
std::string_view bytes = {}) {
if (!enabled()) return;
std::lock_guard<std::mutex> lock(mutex_);
LARGE_INTEGER now{};
QueryPerformanceCounter(&now);
// Events give observer ordering only, not a host/child synchronization fence.
if (events_.size() < event_limit)
events_.push_back({kind, static_cast<uint64_t>(now.QuadPart), consumed_, generation,
rows, columns, stage, bytes.size()});
else events_overflow_ = true;
if (kind == feed) {
const auto count = (std::min)(bytes.size(), transport_limit - transport_.size());
transport_.append(bytes.data(), count);
transport_overflow_ = transport_overflow_ || count != bytes.size();
consumed_ += bytes.size();
}
}
bool save(const std::filesystem::path& directory) const {
if (!enabled()) return true;
std::lock_guard<std::mutex> lock(mutex_);
std::ofstream bytes(directory / "resize-trace.transport.bin", std::ios::binary | std::ios::trunc);
bytes.write(transport_.data(), static_cast<std::streamsize>(transport_.size()));
bytes.close();
std::ofstream out(directory / "resize-trace.json", std::ios::binary | std::ios::trunc);
LARGE_INTEGER frequency{};
QueryPerformanceFrequency(&frequency);
out << "{\"schema\":1,\"observer_order_only\":true,\"qpc_frequency\":" << frequency.QuadPart
<< ",\"events_overflow\":" << (events_overflow_ ? "true" : "false")
<< ",\"transport_overflow\":" << (transport_overflow_ ? "true" : "false")
<< ",\"consumed_bytes\":" << consumed_ << ",\"events\":[";
static const char* names[] = {"desired", "parser_begin", "parser_end", "request", "host_begin", "host_end", "feed", "child_ack"};
bool first = true;
for (const auto& event : events_) {
if (!first) out << ',';
first = false;
out << "{\"kind\":\"" << names[event.kind] << "\",\"qpc\":" << event.qpc
<< ",\"offset\":" << event.consumed << ",\"generation\":" << event.generation
<< ",\"rows\":" << event.rows << ",\"columns\":" << event.columns
<< ",\"stage\":" << event.stage << ",\"bytes\":" << event.bytes << '}';
}
out << "]}";
out.close();
return bool(bytes) && bool(out);
}
};
} // namespace tf_shell_test
Loading