diff --git a/.github/workflows/core-candidate.yml b/.github/workflows/core-candidate.yml index 09070262..5f9d6458 100644 --- a/.github/workflows/core-candidate.yml +++ b/.github/workflows/core-candidate.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index fb84f1da..cb1a01c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) @@ -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) @@ -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) diff --git a/experiments/core/candidate_resize_replay.cpp b/experiments/core/candidate_resize_replay.cpp new file mode 100644 index 00000000..bd470308 --- /dev/null +++ b/experiments/core/candidate_resize_replay.cpp @@ -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 +#include +#include +#include +#include + +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(a), static_cast(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; } +} diff --git a/experiments/core/test_candidate_resize_replay.py b/experiments/core/test_candidate_resize_replay.py new file mode 100644 index 00000000..a5b1da52 --- /dev/null +++ b/experiments/core/test_candidate_resize_replay.py @@ -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() diff --git a/experiments/shell/README.md b/experiments/shell/README.md index 79968db9..9df2016b 100644 --- a/experiments/shell/README.md +++ b/experiments/shell/README.md @@ -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 --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)。 diff --git a/experiments/shell/resize.hpp b/experiments/shell/resize.hpp index da9f739d..20843d15 100644 --- a/experiments/shell/resize.hpp +++ b/experiments/shell/resize.hpp @@ -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 before_resize_; + std::function observation_; mutable std::mutex mutex_; std::condition_variable ready_; std::thread thread_; @@ -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 before_resize={}) - : session_(session),before_resize_(std::move(before_resize)) {} + explicit ResizeWorker(tf_conpty::Session& session,std::function before_resize={}, + std::function observation={}) + : session_(session),before_resize_(std::move(before_resize)),observation_(std::move(observation)) {} ~ResizeWorker() { stop(); } Snapshot snapshot() const { std::lock_guard lock(mutex_); @@ -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(columns), static_cast(rows)); + if(observation_) observation_(false, generation, rows, columns); lock.lock(); completed_ = generation; working_ = false; working_generation_ = 0; } } catch (...) { diff --git a/experiments/shell/resize_trace.hpp b/experiments/shell/resize_trace.hpp new file mode 100644 index 00000000..9feeef78 --- /dev/null +++ b/experiments/shell/resize_trace.hpp @@ -0,0 +1,86 @@ +// Opt-in observations for synthetic resize scenarios; payloads stay local. +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 enabled_{false}; + mutable std::mutex mutex_; + std::vector 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 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(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 lock(mutex_); + std::ofstream bytes(directory / "resize-trace.transport.bin", std::ios::binary | std::ios::trunc); + bytes.write(transport_.data(), static_cast(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 diff --git a/experiments/shell/resize_trace.py b/experiments/shell/resize_trace.py new file mode 100644 index 00000000..48057014 --- /dev/null +++ b/experiments/shell/resize_trace.py @@ -0,0 +1,158 @@ +"""Bounded private replay of the observed feed/local-resize order; no timing claim.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess + + +KINDS = {'desired', 'parser_begin', 'parser_end', 'request', 'host_begin', 'host_end', 'feed', 'child_ack'} +TRACE_FIELDS = {'schema', 'observer_order_only', 'qpc_frequency', 'events_overflow', + 'transport_overflow', 'consumed_bytes', 'events'} +EVENT_FIELDS = {'kind', 'qpc', 'offset', 'generation', 'rows', 'columns', 'stage', 'bytes'} +# Schema 1 records the ShellWindow parser from its fixed construction geometry. +INITIAL_GRID = (24, 80) + + +def integer(value, minimum, maximum): + return type(value) is int and minimum <= value <= maximum + + +def replay_plan(trace, transport): + if (not isinstance(trace, dict) or set(trace) != TRACE_FIELDS or + type(trace.get('schema')) is not int or trace['schema'] != 1 or + trace.get('observer_order_only') is not True or + trace.get('events_overflow') is not False or trace.get('transport_overflow') is not False or + not integer(trace.get('qpc_frequency'), 1, (1 << 63) - 1)): + raise ValueError('incomplete or unknown resize trace') + events = trace.get('events') + if (not isinstance(events, list) or not events or len(events) > 1024 or + not isinstance(transport, bytes) or len(transport) > 128 * 1024 or + type(trace.get('consumed_bytes')) is not int or + trace['consumed_bytes'] != len(transport)): + raise ValueError('invalid resize trace size') + offset, plan, grid, last_qpc = 0, [], INITIAL_GRID, -1 + ui_phase, desired = 'idle', None + requests, active_host, last_host_generation = {}, None, 0 + for event in events: + if (not isinstance(event, dict) or set(event) != EVENT_FIELDS or + type(event.get('kind')) is not str or event['kind'] not in KINDS): + raise ValueError('unknown resize trace event') + if (not integer(event['offset'], 0, len(transport)) or event['offset'] != offset or + not integer(event['bytes'], 0, len(transport))): + raise ValueError('resize trace byte ordering invalid') + if (not integer(event['qpc'], 0, (1 << 64) - 1) or event['qpc'] < last_qpc or + not integer(event['generation'], 0, (1 << 32) - 1) or + not integer(event['stage'], 0, (1 << 31) - 1)): + raise ValueError('resize trace clock or generation invalid') + last_qpc = event['qpc'] + if not integer(event['rows'], 1, 200) or not integer(event['columns'], 1, 500): + raise ValueError('resize trace dimensions invalid') + dimensions = event['rows'], event['columns'] + kind = event['kind'] + generation = event['generation'] + if kind != 'feed' and event['bytes']: + raise ValueError('non-feed trace event owns bytes') + if kind in ('host_begin', 'host_end'): + if not generation or event['stage'] != 0: + raise ValueError('resize trace host identity invalid') + if kind == 'host_begin': + if active_host is not None or generation <= last_host_generation: + raise ValueError('resize trace host calls overlap or regress') + # The worker can record begin/end before the UI records request. + # A coalesced request need not have a host call, and a worker + # already holding an older generation can run after newer UI + # requests. Neither observation is a completion fence. + if generation in requests: + expected = requests[generation] + elif generation == len(requests) + 1 and ui_phase == 'request': + expected = grid + else: + raise ValueError('resize trace host generation has no request') + if dimensions != expected: + raise ValueError('resize trace host request dimensions differ') + active_host = generation, dimensions + last_host_generation = generation + else: + if active_host != (generation, dimensions): + raise ValueError('resize trace host boundary invalid') + active_host = None + continue + if kind != 'request' and generation: + raise ValueError('resize trace UI generation invalid') + if kind == 'feed': + count = event['bytes'] + if not count or count > len(transport) - offset or ui_phase != 'idle' or dimensions != grid: + raise ValueError('resize trace feed partition invalid') + plan.append(f'F {offset} {count}\n') + offset += count + else: + if kind == 'desired': + if ui_phase != 'idle': + raise ValueError('resize trace desired boundary invalid') + desired, ui_phase = dimensions, 'parser_begin' + elif kind == 'parser_begin': + if ui_phase != 'parser_begin' or dimensions != desired: + raise ValueError('resize trace parser boundary invalid') + ui_phase = 'parser_end' + plan.append(f'R {dimensions[0]} {dimensions[1]}\n') + elif kind == 'parser_end': + if ui_phase != 'parser_end' or dimensions != desired: + raise ValueError('resize trace parser boundary invalid') + grid, ui_phase = dimensions, 'request' + elif kind == 'request': + if ui_phase != 'request' or dimensions != grid or generation != len(requests) + 1: + raise ValueError('resize trace request boundary invalid') + requests[generation] = dimensions + desired, ui_phase = None, 'idle' + elif kind == 'child_ack': + if ui_phase != 'idle' or dimensions != grid: + raise ValueError('resize trace child acknowledgement geometry invalid') + if offset != len(transport) or ui_phase != 'idle' or active_host is not None: + raise ValueError('resize trace does not cover transport') + return ''.join(plan) + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('capture', type=Path) + parser.add_argument('output', type=Path) + parser.add_argument('--exe', required=True, type=Path) + args = parser.parse_args() + trace_path = args.capture / 'resize-trace.json' + transport_path = args.capture / 'resize-trace.transport.bin' + if trace_path.stat().st_size > 512 * 1024 or transport_path.stat().st_size > 128 * 1024: + raise ValueError('resize capture files exceed bound') + trace = json.loads(trace_path.read_text(encoding='utf-8')) + transport = transport_path.read_bytes() + plan = replay_plan(trace, transport) + args.output.mkdir(parents=True, exist_ok=False) + plan_path = args.output / 'plan.txt' + plan_path.write_text(plan, encoding='ascii') + # Full snapshots contain the captured terminal text. They are private artifacts. + timed_out = False + try: + process = subprocess.run([str(args.exe.resolve(strict=True)), str(transport_path.resolve()), + str(plan_path.resolve())], capture_output=True, timeout=10) + stdout, stderr, exit_code = process.stdout, process.stderr, process.returncode + except subprocess.TimeoutExpired as error: + stdout, stderr, exit_code = error.stdout or b'', error.stderr or b'', None + timed_out = True + (args.output / 'states.jsonl').write_bytes(stdout) + (args.output / 'stderr.txt').write_bytes(stderr) + summary = dict(schema=1, replay_exit_code=exit_code, timed_out=timed_out, + trace_sha256=sha(trace_path), transport_sha256=sha(transport_path), + plan_sha256=sha(plan_path), replay_exe_sha256=sha(args.exe), + captured_bytes=len(transport), replay_steps=len(plan.splitlines()), + timing_or_native_acceptance=False) + (args.output / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n', encoding='utf-8') + print(json.dumps(summary)) + return 0 if exit_code == 0 else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/experiments/shell/results/2026-10-26-resize-order/validation.json b/experiments/shell/results/2026-10-26-resize-order/validation.json new file mode 100644 index 00000000..4bb81fc2 --- /dev/null +++ b/experiments/shell/results/2026-10-26-resize-order/validation.json @@ -0,0 +1,276 @@ +{ + "schema": 1, + "date": "2026-10-26", + "scope": "R51 bounded resize-order diagnosis; no behavior fix or R2 acceptance", + "source_commit": "386ebe725aab691b13a7cf962a09b56f858b0804", + "parent_commit": "3e5b96d0e0854b691b6dd41821ab10b6124b83eb", + "platform": "Windows-11-10.0.26200-SP0", + "source_git_blob_sha1": { + "CMakeLists.txt": "2ce3ac63485b81a7140cdb87c3f32c7f24270c82", + "experiments/core/candidate_resize_replay.cpp": "7c8c58240e27f23cda1e1a24d10037f2e25a8cc6", + "experiments/core/candidate_terminal.cpp": "cc12246ee7e94382bf95835cd45e6ef6b8949a19", + "experiments/core/candidate_terminal.hpp": "55de3176ec220b2e404cc7e108f6d0063af3c004", + "experiments/shell/resize.hpp": "20843d154fffdd982e1a9221984245eb0d4cc654", + "experiments/shell/resize_trace.hpp": "9feeef78fa42eee3cbbc60d1ab94ccdef7fc539b", + "experiments/shell/resize_trace.py": "1f182627f341494b2ca07fe02f457596900abd06", + "experiments/shell/test_resize_trace.py": "29faf4557592932d9c4f2cbc543fa043d103fb4b", + "experiments/shell/shell.cpp": "e440a83a98f9e86093cb82bd30f5ffc28e9f8299", + "experiments/shell/size_client.cpp": "251220197098b1b654b7554b0a727e4a2e85d7a9", + "experiments/conpty/backend.hpp": "3195dceaefa166b1251839108d29c5fd034aa2fb", + "experiments/conpty/session.hpp": "d44f36661594a5b76b62d1ecb95cf166db22e90a", + "experiments/candidates/alacritty/Cargo.lock": "6501189a98bfe0610ebcb5e3c31d3abc2ae3f132" + }, + "source_raw_sha256": { + "CMakeLists.txt": "613d9a5a3285af26bc3cdce151ce87968f0795a5f13ab409e87a9f81f55efa06", + "experiments/core/candidate_resize_replay.cpp": "dd3f38062312592e39d6e63f63bebc5839c16efcf5b53b75127f2497b34714d8", + "experiments/core/candidate_terminal.cpp": "0d0eaabe5ef896aa7b263c8151622f579f37c421bf9473fc3d69ada010a64e61", + "experiments/core/candidate_terminal.hpp": "328411c1af6ddb509623ded8fe9c814cb6ac56cd502a2952e8cedd0a3bb2d997", + "experiments/shell/resize.hpp": "37d22ba31fc3fee8004a3dd555f53fa55cb398057b6576f5a2d9b5403e2f5cd0", + "experiments/shell/resize_trace.hpp": "a33f06302c9573b71d2432a4e962dc79d3e5916b69bfba210599f071a3cd6853", + "experiments/shell/resize_trace.py": "ffc6b0924ce5777ce3695e869179f479afa4d02e483c0547158e1f2dea2b34a0", + "experiments/shell/test_resize_trace.py": "31956a0aca0fdee2a8ea6bd26911bb1f3a4ded4233a485a4d04806aae31cfac7", + "experiments/shell/shell.cpp": "872f4d0c5f4ca4f38f074761d1ac5f3ac728fcc732ef5058c98ccfa8a4384b84", + "experiments/shell/size_client.cpp": "cb11661bec9551053eec0dbb474bce2ac899a06ee2e50830a370af4816df39f4", + "experiments/conpty/backend.hpp": "5ee3ab95dc4b9aefce23ff7900cda2ecdd9cd09d1b1044074de2f5e572c31489", + "experiments/conpty/session.hpp": "6945e6ffba1d61e66266f867d3cd124eb63c39da37e27662a5eaf544f60ed96b", + "experiments/candidates/alacritty/Cargo.lock": "896c6e60909d55d61fb26d277b1a98daf9245e8aee811caff37fc3e9d06ca627" + }, + "binaries_sha256": { + "termforge_shell.exe": "7c06cbf1b605e17aa2d70914565370088111dc6e48257a8e120e883f77c860a0", + "shell_test_launcher.exe": "05a18755d869de52608c32518291281161446ca70c794dde65f9a50f06ecaa97", + "shell_size_client.exe": "df1c8cbdc0ea13d583f4b616bba0e6b8df10c492afdcfba1308231912084905d", + "candidate_resize_replay.exe": "d17f350c4a673fa73cf806c2df734fe282ae005e0b671b03700d45f48a0c9a54" + }, + "rust_library_sha256": "e1897a65b7a06a174ac0dbc89c3c54a01672d1373037c27c8becdc329a6f27eb", + "candidate": "0.26.1-dev+termforge.4", + "backend": "reviewed-side-by-side-1.25.260930003", + "creation_flags": 24, + "native_deadline_seconds": 25, + "outer_deadline_seconds": 40, + "replay_deadline_seconds": 10, + "instrumentation": { + "opt_in": true, + "event_limit": 1024, + "transport_limit_bytes": 131072, + "observer_order_only": true + }, + "runs": [ + { + "index": 0, + "scenario": "resize", + "stage": 43, + "verdict": "known_red_reproduced", + "process_exit": 2, + "outer_timed_out": false, + "event_overflow": false, + "transport_overflow": false, + "captured_bytes": 8412, + "replay_steps": 29, + "replay_exit": 0, + "replay_full_screen_equals_live_failure": true, + "failing_line": { + "row_zero_based": 10, + "expected": "TF_SIZE_100x28", + "actual": "TF_SIZE_100x28OW" + }, + "actual_host_resize_calls": [ + { + "rows": 12, + "columns": 60, + "generation": 1 + }, + { + "rows": 30, + "columns": 90, + "generation": 2 + }, + { + "rows": 10, + "columns": 50, + "generation": 3 + }, + { + "rows": 28, + "columns": 100, + "generation": 4 + } + ], + "outer_job_after": { + "active": 0, + "total": 8, + "terminated": 0 + }, + "outer_job_after_cleanup": { + "active": 0, + "total": 8, + "terminated": 0 + }, + "desktop_gone": true, + "private_artifact_sha256": { + "sample.json": "f8a340201bd2628ae48fcf3b4fece393ea9f5d210cc2bbef19c63a4343683d32", + "resize-trace.json": "204bdb643cdc1863b28a734ffa2b9ce5702d659021e262ed70ac030cfd4057c1", + "resize-trace.transport.bin": "38d09be70c1f18d512be5beb3966cea922423bf45d2aaee7c7bc56a61678aaf8" + }, + "private_replay_sha256": { + "plan.txt": "6f9d840e1d93847be1babf7cd36b65897f8feb246dbd731d26631fd300f86d1b", + "states.jsonl": "8bf1f05bf4304934f806e722c2e8516714669f11dd59c798519a8ceeccc8baac", + "summary.json": "76ad3cdaa39387f64a63d2dc77fbbde6bdcfefa85ff72b940c453b45666991c4" + } + }, + { + "index": 1, + "scenario": "resize", + "stage": 43, + "verdict": "known_red_reproduced", + "process_exit": 2, + "outer_timed_out": false, + "event_overflow": false, + "transport_overflow": false, + "captured_bytes": 8871, + "replay_steps": 22, + "replay_exit": 0, + "replay_full_screen_equals_live_failure": true, + "failing_line": { + "row_zero_based": 10, + "expected": "TF_SIZE_100x28", + "actual": "TF_SIZE_100x28OW" + }, + "actual_host_resize_calls": [ + { + "rows": 12, + "columns": 60, + "generation": 1 + }, + { + "rows": 30, + "columns": 90, + "generation": 2 + }, + { + "rows": 10, + "columns": 50, + "generation": 3 + }, + { + "rows": 28, + "columns": 100, + "generation": 4 + } + ], + "outer_job_after": { + "active": 0, + "total": 8, + "terminated": 0 + }, + "outer_job_after_cleanup": { + "active": 0, + "total": 8, + "terminated": 0 + }, + "desktop_gone": true, + "private_artifact_sha256": { + "sample.json": "be125db57708bd7988aac013c90e6510f9fb2078ce195bd68bc696767f1d9f98", + "resize-trace.json": "02d915b8c3f560039ab367393f77223a8ad41baa5a54613eef9db5e4bc5144a0", + "resize-trace.transport.bin": "7808e2e2acfd47a79f8e1f5fe4c2f90aaf4687be164ad537f081f30ee0ca8dc5" + }, + "private_replay_sha256": { + "plan.txt": "f257a0af38e6a728e277d5f35d1857a26924e3fb2d224377a3098cae80287409", + "states.jsonl": "031084cab09d80bd9bee69bac7d2ee96583fa2fe4fc34f72ccb27fe94e7df9ea", + "summary.json": "71db0675197bc34fc7aef126237a196f259a968970739120173e1b1bea69a8a2" + } + }, + { + "index": 2, + "scenario": "resize", + "stage": 43, + "verdict": "known_red_reproduced", + "process_exit": 2, + "outer_timed_out": false, + "event_overflow": false, + "transport_overflow": false, + "captured_bytes": 7237, + "replay_steps": 29, + "replay_exit": 0, + "replay_full_screen_equals_live_failure": true, + "failing_line": { + "row_zero_based": 10, + "expected": "TF_SIZE_100x28", + "actual": "TF_SIZE_100x28OW" + }, + "actual_host_resize_calls": [ + { + "rows": 12, + "columns": 60, + "generation": 1 + }, + { + "rows": 30, + "columns": 90, + "generation": 2 + }, + { + "rows": 10, + "columns": 50, + "generation": 3 + }, + { + "rows": 28, + "columns": 100, + "generation": 4 + } + ], + "outer_job_after": { + "active": 0, + "total": 8, + "terminated": 0 + }, + "outer_job_after_cleanup": { + "active": 0, + "total": 8, + "terminated": 0 + }, + "desktop_gone": true, + "private_artifact_sha256": { + "sample.json": "9418216aed5386bdace578e1031b533031bdb22b2f458af57e95f1d2516c500e", + "resize-trace.json": "776240939667bec7bd071d1a1f7d447a3b2474891a6e7fc725f48561bab9581d", + "resize-trace.transport.bin": "2a07279ca1df863f4ef9506d1e1ddf4cdd307d6a079a5800e26e1581146785b1" + }, + "private_replay_sha256": { + "plan.txt": "41bd8ed8c07abe1062f2b8a3f7628ed54819351b3657178b9c3d33551bf62a3b", + "states.jsonl": "78d7d10747d7019f9c50fee747de5990207863ffdcb7b3cfd09900c631438ec2", + "summary.json": "7ea79948a04acf18c4b05f8a671a8c91e1e090175bb7b079808d30824d47ae8b" + } + } + ], + "additional_validation": { + "pure_trace_tests_passed": 2, + "negative_capture_cases": 10, + "normal_mode_with_trace_environment": "passed_without_trace_files", + "normal_mode_job_after_cleanup": { + "active": 0, + "total": 6, + "terminated": 0 + } + }, + "observations": [ + "All three original resize runs retain the stage43 failure, and all four host API calls occurred; coalescing alone cannot explain these samples.", + "Replaying exactly the observed UI parser resizes and feed partitions reproduces the full live failed screen, not only the failing marker.", + "The candidate grows the visible buffer by pulling history into it. In these samples the later host stream does not repaint the whole resized buffer, and its cursor-addressed echo and size output overwrite different existing rows.", + "The native size helper output contains the independent TF_SIZE_100x28 result; no CHAR_INFO snapshot at this failure boundary was captured.", + "Pinned host source already documents divergent host/terminal reflow and lazy DSR CPR synchronization at console-info queries. This is relevant source evidence, not proof of an exhaustive root cause or a completed fix." + ], + "primary_sources": [ + "https://github.com/microsoft/terminal/blob/dc8ae365847d41f62d1ebca93815bb00959bc12f/src/host/screenInfo.cpp#L1412", + "https://github.com/microsoft/terminal/issues/18725", + "https://github.com/microsoft/terminal/pull/19535" + ], + "limitations": [ + "Opt-in observers alter scheduling. The uninstrumented joint baseline separately has both a red and a green; neither is overwritten by these results.", + "The scenario code and native deadline are unchanged, but the absolute synthetic helper launch path follows this isolated build directory; its transport is not byte-identical to the root joint-baseline capture.", + "This is a fixed diagnostic batch of three, not a pass/failure-rate estimate or performance measurement.", + "No native/transport/terminal synchronization fix has been applied. R51 remains open.", + "Raw transport, full screen states, frame and desktop identity remain local; this record publishes hashes and synthetic marker metadata only.", + "No input, oracle, load, native deadline or history/reflow feature was weakened. API return is not a host completion fence.", + "Default-core, system-backend, full R1/R2, native buffer contents, actual presentation and WindTerm parity are not validated by this batch." + ] +} diff --git a/experiments/shell/results/2026-10-26-resize-replay-guards/validation.json b/experiments/shell/results/2026-10-26-resize-replay-guards/validation.json new file mode 100644 index 00000000..bf6289f2 --- /dev/null +++ b/experiments/shell/results/2026-10-26-resize-replay-guards/validation.json @@ -0,0 +1,209 @@ +{ + "schema": "termforge-resize-replay-guard-validation-v1", + "source_base_commit": "3cdfda92c40666e214006df86f36de8c40cf1cda", + "source_identity": "source_sha256 identifies reviewed checkout bytes before this commit; Git blobs can differ by checkout newline normalization", + "source_sha256": { + "CMakeLists.txt": "aeb3802d438c96b160ef8185969b39416231b91c044fda4695a7f56f650d9f9e", + "experiments/core/candidate_resize_replay.cpp": "e5c2e29b29a63edc0b5d3671f0fd3e9cdc1df9567088c4242855206053439081", + "experiments/core/test_candidate_resize_replay.py": "eab1fefd6cbbc7eb9eda6271792af12704f8d03b46d99690312d83a46b88574e", + "experiments/shell/resize_trace.py": "e4b23211ae1b1f1a1435de744558ad04de053872db136f67a22dac476a8ec5cd", + "experiments/shell/test_resize_trace.py": "5438f9aeef805f90b789a1a881fa974b525e000f14365cab54b4507479dd6d6d", + "experiments/shell/README.md": "a218d43988f8f9e8cbcc4ce9d314e3a14d006e4eab91ac10c46b9ce3728c41b1" + }, + "binaries_sha256": { + "before": "d17f350c4a673fa73cf806c2df734fe282ae005e0b671b03700d45f48a0c9a54", + "after": "e859d743a54ae13177bdf0fba7834a12a1029b5de760cb194fb7929fe0809e05" + }, + "scope": "Diagnostic parser/schema validation only; no terminal, transport, worker or oracle behavior change", + "actual_exe_red_green": [ + { + "trailing_record": "R 12", + "before_exit": 0, + "after_exit": 1 + }, + { + "trailing_record": "F 3", + "before_exit": 0, + "after_exit": 1 + }, + { + "trailing_record": "X", + "before_exit": 0, + "after_exit": 1 + }, + { + "trailing_record": "R 12 999999999999999999999999999999999", + "before_exit": 0, + "after_exit": 1 + }, + { + "trailing_record": "F 3 999999999999999999999999999999999", + "before_exit": 0, + "after_exit": 1 + } + ], + "original_trace_mutation_red_green": [ + { + "capture": 0, + "case": "host-negative-generation", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 0, + "case": "host-end-wrong-generation", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 0, + "case": "host-zero-columns", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 0, + "case": "feed-wrong-current-grid", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 0, + "case": "qpc-regresses", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 1, + "case": "host-negative-generation", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 1, + "case": "host-end-wrong-generation", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 1, + "case": "host-zero-columns", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 1, + "case": "feed-wrong-current-grid", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 1, + "case": "qpc-regresses", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 2, + "case": "host-negative-generation", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 2, + "case": "host-end-wrong-generation", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 2, + "case": "host-zero-columns", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 2, + "case": "feed-wrong-current-grid", + "before_accepted": true, + "after_rejected": true + }, + { + "capture": 2, + "case": "qpc-regresses", + "before_accepted": true, + "after_rejected": true + } + ], + "original_offline_replays": [ + { + "index": 0, + "trace_sha256": "204bdb643cdc1863b28a734ffa2b9ce5702d659021e262ed70ac030cfd4057c1", + "transport_sha256": "38d09be70c1f18d512be5beb3966cea922423bf45d2aaee7c7bc56a61678aaf8", + "plan_sha256": "6f9d840e1d93847be1babf7cd36b65897f8feb246dbd731d26631fd300f86d1b", + "states_sha256": "8bf1f05bf4304934f806e722c2e8516714669f11dd59c798519a8ceeccc8baac", + "previous_states_sha256": "8bf1f05bf4304934f806e722c2e8516714669f11dd59c798519a8ceeccc8baac", + "replay_exit": 0, + "steps": 29, + "original_plan_unchanged": true, + "full_states_byte_identical": true, + "captured_bytes": 8412 + }, + { + "index": 1, + "trace_sha256": "02d915b8c3f560039ab367393f77223a8ad41baa5a54613eef9db5e4bc5144a0", + "transport_sha256": "7808e2e2acfd47a79f8e1f5fe4c2f90aaf4687be164ad537f081f30ee0ca8dc5", + "plan_sha256": "f257a0af38e6a728e277d5f35d1857a26924e3fb2d224377a3098cae80287409", + "states_sha256": "031084cab09d80bd9bee69bac7d2ee96583fa2fe4fc34f72ccb27fe94e7df9ea", + "previous_states_sha256": "031084cab09d80bd9bee69bac7d2ee96583fa2fe4fc34f72ccb27fe94e7df9ea", + "replay_exit": 0, + "steps": 22, + "original_plan_unchanged": true, + "full_states_byte_identical": true, + "captured_bytes": 8871 + }, + { + "index": 2, + "trace_sha256": "776240939667bec7bd071d1a1f7d447a3b2474891a6e7fc725f48561bab9581d", + "transport_sha256": "2a07279ca1df863f4ef9506d1e1ddf4cdd307d6a079a5800e26e1581146785b1", + "plan_sha256": "41bd8ed8c07abe1062f2b8a3f7628ed54819351b3657178b9c3d33551bf62a3b", + "states_sha256": "78d7d10747d7019f9c50fee747de5990207863ffdcb7b3cfd09900c631438ec2", + "previous_states_sha256": "78d7d10747d7019f9c50fee747de5990207863ffdcb7b3cfd09900c631438ec2", + "replay_exit": 0, + "steps": 29, + "original_plan_unchanged": true, + "full_states_byte_identical": true, + "captured_bytes": 7237 + } + ], + "focused_ctest": { + "tests": 2, + "passed": 2, + "failures": 0, + "internal_unit_tests": 8, + "log_sha256": "a90b9931d34986bcb980ae7da386b29c77d27d275883020cabddd5a155e90349" + }, + "before_test_logs": { + "exe_sha256": "d33fd8c72a470e740730424bf8526890c82715a7049320b788a228500098d7ce", + "trace_sha256": "7fa1e64b5d2a1ed4460219c44374884ec26fcb2a29b4847f00e686d16ae8d41a" + }, + "original_proof_sha256": "74769899db987da8842f2b37db5a78e5088eb413691426900c06fb5c0eae0c47", + "supported_observation_orders": [ + "host begin/end before the matching request observation", + "coalesced requests without host calls", + "in-flight older worker calls interleaved with later UI parser resizes", + "equal QPC values in mutex observation order" + ], + "deadlines": { + "exe_seconds": 10, + "original_live_native_seconds": 25, + "original_live_outer_seconds": 40 + }, + "limitations": [ + "No new live GUI or native run; original native failure and proof unchanged", + "Offline replay does not establish host completion, actual presentation or timing", + "Host request mapping validates observed metadata; no synchronization fence is inferred", + "Raw original trace, transport and complete states remain private" + ], + "r51_behavior_fixed": false, + "r2_accepted": false +} diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 1f5d3c98..e440a83a 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -5,6 +5,7 @@ #endif #include "queues.hpp" #include "resize.hpp" +#include "resize_trace.hpp" #include "keyboard.hpp" #include "ime.hpp" #include "hosted_ime_input.hpp" @@ -38,8 +39,12 @@ class ShellWindow { const DWORD ui_thread_id_ = GetCurrentThreadId(); OutputQueue output_; tf_conpty::Session session_; + tf_shell_test::ResizeTrace resize_trace_; InputQueue input_{session_}; - ResizeWorker resize_{session_}; + ResizeWorker resize_{session_, {}, [this](bool begin, unsigned generation, int rows, int columns) { + resize_trace_.observe(begin ? tf_shell_test::ResizeTrace::host_begin : tf_shell_test::ResizeTrace::host_end, + rows, columns, generation); + }}; #ifdef TERMFORGE_CANDIDATE_CORE termforge::CandidateTerminal parser_{24, 80, 100}; #else @@ -150,6 +155,7 @@ class ShellWindow { const int columns = std::clamp(width/renderer_->cell_width(), 1, 500); const int rows = std::clamp(height/renderer_->cell_height(), 1, 200); if (!force && !metrics_dirty_ && columns == parser_.screen.width() && rows == parser_.screen.height()) return; + resize_trace_.observe(tf_shell_test::ResizeTrace::desired, rows, columns, 0, stage_); auto surface = std::make_unique(renderer_->x(columns), renderer_->y(rows)); selection_end(); // Preserve a current hit only across exact unchanged row projections. @@ -158,15 +164,19 @@ class ShellWindow { find_resize_pending_=find_resize_anchor_.set(termforge::TextDocument(parser_.screen),*current); find_resize_wrapped_=find_current_->wrapped; } + resize_trace_.observe(tf_shell_test::ResizeTrace::parser_begin, rows, columns, 0, stage_); #ifdef TERMFORGE_CANDIDATE_CORE parser_.resize(rows, columns); #else parser_.screen.resize(rows, columns); #endif + resize_trace_.observe(tf_shell_test::ResizeTrace::parser_end, rows, columns, 0, stage_); search_changed(); dirty_first_ = 0; dirty_last_ = rows; surface_ = std::move(surface); metrics_dirty_ = false; publish(); resize_.request(rows, columns); + if (resize_trace_.enabled()) resize_trace_.observe(tf_shell_test::ResizeTrace::request, rows, columns, + resize_.snapshot().requested_generation, stage_); require(InvalidateRect(hwnd_, nullptr, FALSE) != FALSE, "resize invalidate failed"); } void change_metrics(int dpi, int zoom, bool defer_resize = false) { @@ -717,6 +727,7 @@ class ShellWindow { } else if (stage_ == 40 && resize_.settled()) { query_size(); stage_ = 41; } else if (stage_ == 41 && line_is("TF_SIZE_60x12")) { + resize_trace_.observe(tf_shell_test::ResizeTrace::child_ack, 12, 60, 0, stage_); require(parser_.screen.width() == 60 && parser_.screen.height() == 12, "core size mismatch"); ++result_.resize_verified; // Coalesce rapid requests without a queue of allocated surfaces. @@ -724,6 +735,7 @@ class ShellWindow { } else if (stage_ == 42 && resize_.settled()) { query_size(); stage_ = 43; } else if (stage_ == 43 && line_is("TF_SIZE_100x28")) { + resize_trace_.observe(tf_shell_test::ResizeTrace::child_ack, 28, 100, 0, stage_); require(parser_.screen.width() == 100 && parser_.screen.height() == 28, "grown core size mismatch"); ++result_.resize_verified; type(L"for /L %i in (1,1,1000000) do @echo TF_BUSY_%i\r"); stage_ = 4; flood_start_ = Clock::now(); @@ -772,6 +784,7 @@ class ShellWindow { observed_output_.append(bytes); if(observed_output_.size()>8192) observed_output_.erase(0,observed_output_.size()-8192); } + resize_trace_.observe(tf_shell_test::ResizeTrace::feed, parser_.screen.height(), parser_.screen.width(), 0, stage_, bytes); parser_.feed(bytes); batch += bytes.size(); #ifdef TERMFORGE_CANDIDATE_CORE route_terminal_events(); @@ -955,6 +968,10 @@ class ShellWindow { DispatchMessageW(&message); } ShellWindow(std::string mode, std::string image) : mode_(std::move(mode)), image_path_(std::move(image)) { + wchar_t trace[2]{}; + if ((mode_ == "resize" || mode_ == "resize-close") && + GetEnvironmentVariableW(L"TERMFORGE_RESIZE_TRACE", trace, 2) == 1 && trace[0] == L'1') + resize_trace_.enable(); snapshot_ = std::make_shared(viewport_.snapshot(parser_.screen), 0); } ~ShellWindow() { @@ -1272,6 +1289,9 @@ class ShellWindow { Result collect() { require_ui_thread(); if (closer_.joinable()) { closer_.join(); closer_joined_=true; } + if (resize_trace_.enabled() && !image_path_.empty() && + !resize_trace_.save(std::filesystem::path(image_path_).parent_path())) + std::cerr << "resize diagnostic write failed\n"; if(runtime_enabled_ && closer_joined_) { if(session_closed_on_closer_) { tf_shell::RuntimeSnapshot::FinalSession final; diff --git a/experiments/shell/test_resize_trace.py b/experiments/shell/test_resize_trace.py new file mode 100644 index 00000000..4a1680f0 --- /dev/null +++ b/experiments/shell/test_resize_trace.py @@ -0,0 +1,128 @@ +"""A replay must preserve geometry/feed ordering and reject incomplete capture.""" +import copy +import unittest + +from experiments.shell.resize_trace import replay_plan + + +def fixture(): + events = [dict(kind='feed', offset=0, bytes=1, rows=24, columns=80), + dict(kind='desired', offset=1, bytes=0, rows=12, columns=60), + dict(kind='parser_begin', offset=1, bytes=0, rows=12, columns=60), + dict(kind='parser_end', offset=1, bytes=0, rows=12, columns=60), + dict(kind='host_begin', offset=1, bytes=0, rows=12, columns=60, generation=1), + dict(kind='host_end', offset=1, bytes=0, rows=12, columns=60, generation=1), + dict(kind='request', offset=1, bytes=0, rows=12, columns=60, generation=1), + dict(kind='feed', offset=1, bytes=2, rows=12, columns=60)] + return trace(events) + + +def trace(events): + for index, event in enumerate(events): + event.setdefault('qpc', index + 1) + event.setdefault('generation', 0) + event.setdefault('stage', 0) + return dict(schema=1, observer_order_only=True, qpc_frequency=10000000, events_overflow=False, + transport_overflow=False, consumed_bytes=3, events=events) + + +def find(trace, kind): + return next(event for event in trace['events'] if event['kind'] == kind) + + +class ResizeTraceTests(unittest.TestCase): + def test_preserves_feed_split_and_ui_geometry_without_turning_host_observation_into_resize(self): + self.assertEqual(replay_plan(fixture(), b'abc'), 'F 0 1\nR 12 60\nF 1 2\n') + + def test_rejects_missing_overflowing_and_reordered_capture(self): + for corruption in ('event-overflow', 'transport-overflow', 'count', 'gap', 'duplicate', + 'missing-feed', 'unknown', 'missing-end', 'feed-during-resize', 'bad-size'): + changed = copy.deepcopy(fixture()) + if corruption == 'event-overflow': + changed['events_overflow'] = True + elif corruption == 'transport-overflow': + changed['transport_overflow'] = True + elif corruption == 'count': + changed['consumed_bytes'] = 4 + elif corruption == 'gap': + changed['events'][-1]['offset'] = 2 + elif corruption == 'duplicate': + changed['events'].append(copy.deepcopy(changed['events'][-1])) + elif corruption == 'missing-feed': + changed['events'].pop() + elif corruption == 'unknown': + changed['events'][2]['kind'] = 'synthetic-ack' + elif corruption == 'missing-end': + changed['events'].remove(find(changed, 'parser_end')) + elif corruption == 'feed-during-resize': + changed['events'][3], changed['events'][-1] = changed['events'][-1], changed['events'][3] + else: + find(changed, 'parser_begin')['columns'] = 0 + with self.subTest(corruption=corruption), self.assertRaises(ValueError): + replay_plan(changed, b'abc') + + def test_schema_generation_host_pair_geometry_and_qpc_corruptions_are_rejected(self): + cases = [('host_begin', 'generation', -1), ('host_end', 'generation', 999), + ('host_begin', 'columns', 0), ('feed', 'rows', 200), ('host_end', 'rows', 13), + ('request', 'generation', 2), ('request', 'rows', 13), + ('parser_end', 'generation', 1), ('feed', 'generation', 1), + ('feed', 'qpc', -1), ('host_begin', 'qpc', 1), + ('host_end', 'qpc', True), ('feed', 'qpc', 1 << 64), + ('host_begin', 'generation', 1 << 32), ('feed', 'rows', True), + ('host_begin', 'stage', -1), ('feed', 'extra', 0)] + for kind, field, value in cases: + changed = fixture() + find(changed, kind)[field] = value + with self.subTest(kind=kind, field=field, value=value), self.assertRaises(ValueError): + replay_plan(changed, b'abc') + for field, value in [('schema', True), ('qpc_frequency', 0), ('qpc_frequency', False), + ('qpc_frequency', -1), ('qpc_frequency', 1.5), ('extra', 0)]: + changed = fixture() + changed[field] = value + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + replay_plan(changed, b'abc') + for kind in ('desired', 'parser_begin', 'parser_end', 'request', 'host_begin', 'host_end'): + changed = fixture() + changed['events'].remove(find(changed, kind)) + with self.subTest(missing=kind), self.assertRaises(ValueError): + replay_plan(changed, b'abc') + for field in ('qpc', 'generation', 'rows', 'columns', 'stage'): + changed = fixture() + del find(changed, 'feed')[field] + with self.subTest(missing=field), self.assertRaises(ValueError): + replay_plan(changed, b'abc') + + def test_coalescing_and_worker_observation_before_request_are_valid(self): + events = [] + for generation, (rows, columns) in enumerate(((12, 60), (30, 90), (10, 50), (28, 100)), 1): + for kind in ('desired', 'parser_begin', 'parser_end'): + events.append(dict(kind=kind, rows=rows, columns=columns, offset=0, bytes=0)) + if generation == 3 and kind == 'parser_begin': + events.append(dict(kind='host_end', generation=2, rows=30, columns=90, offset=0, bytes=0)) + if generation == 2: + events.append(dict(kind='host_begin', generation=2, rows=rows, columns=columns, offset=0, bytes=0)) + events.append(dict(kind='request', generation=generation, rows=rows, columns=columns, offset=0, bytes=0)) + if generation == 4: + for kind in ('host_begin', 'host_end'): + events.append(dict(kind=kind, generation=4, rows=rows, columns=columns, offset=0, bytes=0)) + events.append(dict(kind='feed', rows=28, columns=100, offset=0, bytes=3)) + self.assertEqual(replay_plan(trace(events), b'abc'), 'R 12 60\nR 30 90\nR 10 50\nR 28 100\nF 0 3\n') + + def test_equal_qpc_is_valid_but_future_host_generation_and_overlapping_calls_are_not(self): + changed = fixture() + find(changed, 'host_end')['qpc'] = find(changed, 'host_begin')['qpc'] + self.assertEqual(replay_plan(changed, b'abc'), 'F 0 1\nR 12 60\nF 1 2\n') + changed = fixture() + begin = find(changed, 'host_begin') + begin['generation'] = 2 + find(changed, 'host_end')['generation'] = 2 + with self.assertRaises(ValueError): + replay_plan(changed, b'abc') + changed = fixture() + changed['events'][5] = copy.deepcopy(changed['events'][4]) + with self.assertRaises(ValueError): + replay_plan(changed, b'abc') + + +if __name__ == '__main__': + unittest.main()