diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a959cf8..7c0cf628 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,10 @@ add_executable(search_test experiments/core/search_test.cpp) target_link_libraries(search_test PRIVATE termforge_core) termforge_warnings(search_test) +add_executable(buffer_state_test experiments/core/buffer_state_test.cpp) +target_link_libraries(buffer_state_test PRIVATE termforge_core) +termforge_warnings(buffer_state_test) + add_executable(paste_test experiments/shell/paste_test.cpp) termforge_warnings(paste_test) add_executable(clipboard_policy_test experiments/shell/clipboard_policy_test.cpp) @@ -274,6 +278,7 @@ if(BUILD_TESTING) add_test(NAME history_core COMMAND history_test) add_test(NAME selection_core COMMAND selection_test) add_test(NAME search_core COMMAND search_test) + add_test(NAME buffer_state_core COMMAND buffer_state_test) add_test(NAME paste COMMAND paste_test) add_test(NAME clipboard_policy COMMAND clipboard_policy_test) add_test(NAME keyboard COMMAND keyboard_test) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 0988c1db..84f74250 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -8,6 +8,7 @@ | 配置 | 证据定位 | |---|---| +| 自主 C++ 双缓冲存储与文档 epoch 基础 | [固定结果](../experiments/core/results/2026-10-29-buffer-state-r2/README.md)、[验证身份](../experiments/core/results/2026-10-29-buffer-state-r2/validation.json):拥有状态、共享 ID、隐藏帧 resize 回滚与跨缓冲句柄失效;Qt/candidate 关闭配置的完整 50 项及 12 条内部 skip 分列。未接 private CSI,不是原版模式、Qt 产品路线或完整 #27 验收 | | Qt cmd 标签与退出前回收 | [固定结果](../experiments/shell/results/2026-10-28-qt-tabs/README.md)、[验证身份](../experiments/shell/results/2026-10-28-qt-tabs/validation.json):双 cmd 隔离、失败页、关闭与四个实际事件循环边界;Qt 1 项和其他 49 项诊断及内部跳过分列,旧中断轮不拼接 | | Qt 正式入口的独立 hosted 构建 | [固定结果](../experiments/shell/results/2026-10-28-qt-cpp-ci/README.md)、[验证身份](../experiments/shell/results/2026-10-28-qt-cpp-ci/validation.json):固定 SDK、VS 2026 hosted 与真实 cmd 的 offscreen / OwnedJob 检查;原依赖换行和 generator 失败保留。没有原生上屏、完整部署哈希或产品包验收 | | 自主 C++ + Qt Widgets + 固定微软 ConPTY 正式目标 | [ADR-0007](adr/0007-bundled-microsoft-conpty.md) / [ADR-0008](adr/0008-cpp-core-qt-widgets.md) 记录用户确认;新组合的实现与验证另行留证,不引用旧默认或 Alacritty 通过结果代替 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cfe67a5f..ce6fa6b1 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -25,6 +25,8 @@ |---|---|---|---| | 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供固定 80×24 的 cmd 标签、基本 ASCII/16 色及各会话独立有界队列;双 cmd 输入、关闭和错误隔离及真实事件循环退出前回收有[限定验证](../experiments/shell/results/2026-10-28-qt-tabs/README.md)。Qt 1 项与其余 49 项既有/独立诊断分列 | 分屏、其他 shell、工作区恢复、动态 resize、历史/选择/Find、Unicode/IME、真实 DPI、应用兼容与性能仍需实现或迁移;应用/标签退出策略沿原型合同,完整 WindTerm 标签行为对照未完成;当前不是可发布开发包 | R2 | +自主 C++ 核心的 [#130 双缓冲存储基础](../experiments/core/results/2026-10-29-buffer-state-r2/README.md)已在独立模块边界实现拥有状态、交换激活、共享行 ID、双帧物理 resize 与文档 epoch 隔离,旧位置、选择、搜索及 viewport 不会在切换返回后复活。该基础尚未接入 private CSI;1049 的样式、重复进入清空及历史文档策略与完整 [#27](https://github.com/JavaZeroo/termforge/issues/27)另行验收,不表示产品已支持备用屏或 Qt 交互已迁移。 + 官方参考的基础文字行为已按 [R1-01 合同](research/windterm-r1-text-contract.md)完成两组各两轮的自动取证;这为独立实现提供行为依据,产品能力仍按下表分别验收。 | 能力 | 当前状态与边界 | 目标差距 | 路标 | diff --git a/experiments/core/buffer_state_test.cpp b/experiments/core/buffer_state_test.cpp new file mode 100644 index 00000000..e5ae7fbe --- /dev/null +++ b/experiments/core/buffer_state_test.cpp @@ -0,0 +1,319 @@ +#include "terminal.hpp" +#include "text_document.hpp" +#include "text_selection.hpp" +#include "text_search.hpp" +#include "history_view.hpp" +#include +#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 row_text(const Line& line) { + std::string value; + for (const auto& cell : line) value += cell.text; + return value; +} +static std::string snapshot(const Screen& screen) { + std::ostringstream out; + screen.json(out); + return out.str(); +} +static void independent_text_and_cursor() { + Parser parser(2, 8, 2); + parser.feed("MAIN\r\nBASE\r\nEND"); + auto& screen = parser.screen; + const auto main_top = screen.lines.front().id; + const auto main_bottom = screen.lines.back().id; + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + screen.move(1, 1); + const auto alternate_top = screen.lines.front().id; + screen.activate_buffer(ScreenBuffer::primary); + check(screen.active_buffer() == ScreenBuffer::primary && screen.row == 1 && screen.column == 3, + "primary cursor was not restored"); + check(screen.history.size() == 1 && row_text(screen.history.front()) == "MAIN " && + row_text(screen.lines[0]) == "BASE " && row_text(screen.lines[1]) == "END ", + "primary retained text was changed by alternate output"); + check(screen.lines.front().id == main_top && screen.lines.back().id == main_bottom, + "activation replaced saved primary row identities"); + check(alternate_top > main_bottom, "alternate rows reused primary row identities"); + parser.feed("!"); + screen.activate_buffer(ScreenBuffer::alternate); + check(screen.row == 1 && screen.column == 1 && row_text(screen.lines.front()) == "ALT ", + "alternate text or cursor was not preserved"); + screen.activate_buffer(ScreenBuffer::primary); + check(row_text(screen.lines.back()) == "END! ", "alternate activation overwrote later primary output"); +} +static void writing_context_roundtrip() { + Parser parser(2, 4, 2); + parser.feed("\x1b[31;44mABCD"); + auto& screen = parser.screen; + screen.activate_buffer(ScreenBuffer::alternate); + screen.move(0, 0); + parser.feed("\x1b[32;45mZ"); + screen.move(1, 1); + screen.activate_buffer(ScreenBuffer::primary); + check(screen.row == 0 && screen.column == 3 && screen.wrap_pending && + screen.style.fg == 1 && screen.style.bg == 4, + "activation lost the saved writing context"); + parser.feed("\xcc\x81"); + check(screen.lines[0][3].text == "D\xcc\x81" && screen.lines[0][3].marks == 1, + "restored combining attachment did not target its saved base"); + parser.feed("E"); + check(row_text(screen.lines[0]) == "ABCD\xcc\x81" && row_text(screen.lines[1]) == "E " && + screen.lines[0].ending == LineEnding::soft_wrap && screen.lines[1][0].style.fg == 1 && + screen.lines[1][0].style.bg == 4, + "restored pending wrap did not resume with saved text and style"); + screen.activate_buffer(ScreenBuffer::alternate); + check(screen.row == 1 && screen.column == 1 && screen.style.fg == 2 && screen.style.bg == 5, + "primary output changed the alternate cursor or style"); +} +static void bounded_independent_history() { + Parser parser(2, 8, 2); + parser.feed("ONE\r\nTWO\r\nTHREE\r\nFOUR\r\nFIVE"); + auto& screen = parser.screen; + const auto main_last = screen.lines.back().id; + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("A\r\nB\r\nC\r\nD\r\nE"); + const auto alternate_last = screen.lines.back().id; + check(screen.history.empty() && screen.discarded == 3, + "alternate retention was not bounded independently of primary history"); + check(alternate_last > main_last, "alternate scrolling recycled line identities"); + screen.activate_buffer(ScreenBuffer::primary); + check(screen.history.size() == 2 && screen.discarded == 1 && + row_text(screen.history[0]) == "TWO " && row_text(screen.history[1]) == "THREE " && + row_text(screen.lines[0]) == "FOUR " && row_text(screen.lines[1]) == "FIVE ", + "alternate scrolling changed saved primary history or eviction state"); + parser.feed("\r\nSIX"); + check(screen.history.size() == 2 && screen.discarded == 2 && + row_text(screen.history.front()) == "THREE " && screen.lines.back().id > alternate_last, + "primary scrolling lost its budget or global identity monotonicity"); +} +static void resize_both_frames_and_rollback() { + Parser parser(3, 6, 2); + parser.feed("A\xe4\xb8\xad" "B\r\nCDEF\r\nGHIJ"); + auto& screen = parser.screen; + const auto main_first = screen.lines.front().id; + const auto main_second = screen.lines[1].id; + const auto main_third = screen.lines[2].id; + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + screen.move(2, 5); + screen.resize(2, 3); + check(screen.height() == 2 && screen.width() == 3 && screen.history.empty() && screen.discarded == 1, + "alternate resize used primary retention"); + screen.activate_buffer(ScreenBuffer::primary); + check(screen.height() == 2 && screen.width() == 3 && screen.row == 1 && screen.column == 2 && + screen.history.size() == 1 && screen.history[0].id == main_first && + screen.lines[0].id == main_second && screen.lines[1].id == main_third && + row_text(screen.history[0]) == "A\xe4\xb8\xad" && + row_text(screen.lines[0]) == "CDE" && row_text(screen.lines[1]) == "GHI", + "resize did not preserve the hidden primary frame's physical-row policy"); + const auto primary = snapshot(screen); + screen.activate_buffer(ScreenBuffer::alternate); + const auto alternate = snapshot(screen); + bool rejected = false; + try { screen.resize(2, 0); } catch (const std::invalid_argument&) { rejected = true; } + check(rejected && snapshot(screen) == alternate && screen.width() == 3, + "invalid resize partially changed the active frame"); + screen.activate_buffer(ScreenBuffer::primary); + check(snapshot(screen) == primary, "invalid resize partially changed the hidden frame"); + screen.activate_buffer(ScreenBuffer::alternate); + screen.resize(2, 2); + screen.activate_buffer(ScreenBuffer::primary); + check(screen.history[0].size() == 2 && screen.history[0][1].width == 1 && + screen.history[0][1].text == " " && screen.history[0].cropped, + "hidden-frame width crop left half of a wide glyph"); +} +static void growth_preserves_metadata_and_unique_ids() { + Parser parser(3, 4, 2); + parser.feed("ABCDxy\r\nZ"); + auto& screen = parser.screen; + const auto soft = screen.lines[0], hard = screen.lines[1]; + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + const auto alternate_last = screen.lines.back().id; + screen.resize(5, 6); + const auto alternate_new_first = screen.lines[3].id, alternate_new_last = screen.lines[4].id; + check(alternate_new_first > alternate_last && alternate_new_last > alternate_new_first && + row_text(screen.lines[0]) == "ALT " && row_text(screen.lines.back()) == " ", + "alternate growth did not append fresh default rows"); + screen.activate_buffer(ScreenBuffer::primary); + TextDocument document(screen); + check(screen.lines[0].id == soft.id && screen.lines[0].version == soft.version + 1 && + screen.lines[0].ending == LineEnding::soft_wrap && screen.lines[0].next_id == soft.next_id && + screen.lines[0].wrap_columns == soft.wrap_columns && document.meta(0).joins_next && + screen.lines[1].id == hard.id && screen.lines[1].ending == LineEnding::hard_break && + !document.meta(1).joins_next, + "hidden growth changed soft-wrap links or hard-break metadata"); + check(screen.lines[3].id > alternate_new_last && screen.lines[4].id > screen.lines[3].id && + row_text(screen.lines[0]) == "ABCD " && row_text(screen.lines[1]) == "xy " && + row_text(screen.lines.back()) == " ", + "two-frame growth reused ids or reflowed physical rows"); +} +static void hidden_resize_failure_rolls_back_both_frames() { + Parser parser(2, 4, 2); + parser.feed("MAIN"); + auto& screen = parser.screen; + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + const auto alternate = snapshot(screen); + screen.activate_buffer(ScreenBuffer::primary); + const auto primary = snapshot(screen); + // Reserve imported identities near exhaustion. Active growth can prepare + // one row, but hidden growth cannot allocate its own row afterwards. + auto imported = screen.lines; + imported.resize(1); + imported[0].id = (std::numeric_limits::max)() - 2; + screen.normalize_identities(imported); + bool rejected = false; + try { screen.resize(3, 4); } catch (const std::overflow_error&) { rejected = true; } + check(rejected && screen.height() == 2 && snapshot(screen) == primary, + "hidden resize failure committed active geometry or content"); + screen.activate_buffer(ScreenBuffer::alternate); + check(screen.height() == 2 && snapshot(screen) == alternate, + "hidden resize failure changed the alternate frame"); + screen.activate_buffer(ScreenBuffer::primary); + parser.feed("\r\nA\r\nB"); + check(screen.lines.back().id == (std::numeric_limits::max)() - 1, + "failed hidden resize consumed the shared identity allocator"); +} +static void positions_do_not_revive() { + Parser parser(2, 8, 2); + parser.feed("MAIN"); + auto& screen = parser.screen; + TextDocument document(screen); + const auto original = *document.position(0, 0); + check(document.valid(TextPosition{original.line_id, original.version, original.column}), + "initial document no longer accepts the legacy position shape"); + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + screen.activate_buffer(ScreenBuffer::primary); + check(screen.lines[0].id == original.line_id && screen.lines[0].version == original.version, + "activation changed primary content identity to invalidate positions"); + check(!document.valid(original), "old primary position revived after a buffer roundtrip"); + const auto current = *document.position(0, 0); + check(document.valid(current) && current != original, "new document position lost its buffer context"); + screen.activate_buffer(ScreenBuffer::primary); + check(document.valid(current), "idempotent storage activation invalidated current positions"); +} +static void selection_does_not_revive() { + Parser parser(2, 8, 2); + parser.feed("MAIN"); + auto& screen = parser.screen; + TextDocument document(screen); + TextSelection selection; + check(selection.set(document, {*document.position(0, 0), *document.position(0, 4)}) && + selection.copy(document, 1024).text == "MAIN", "primary selection setup failed"); + screen.activate_buffer(ScreenBuffer::alternate); + screen.activate_buffer(ScreenBuffer::primary); + check(selection.copy(document, 1024).status == SelectionCopyStatus::invalidated && selection.empty(), + "selection reconciled an old range into the returned primary frame"); + check(selection.set(document, {*document.position(0, 0), *document.position(0, 4)}) && + selection.copy(document, 1024).text == "MAIN", "fresh selection failed after activation"); + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + check(selection.copy(document, 1024).status == SelectionCopyStatus::invalidated && selection.empty(), + "a primary selection stayed usable in the alternate frame"); + check(selection.set(document, {*document.position(0, 0), *document.position(0, 3)}) && + selection.copy(document, 1024).text == "ALT", "fresh alternate selection failed"); + screen.activate_buffer(ScreenBuffer::primary); + check(!selection.reconcile(document), "an alternate selection stayed usable in the primary frame"); +} +static void finish_search(TextSearch& search, const TextDocument& document, uint64_t generation) { + for (int step = 0; search.status() == SearchStatus::running && step < 100; ++step) + search.step(document, generation, 64); + check(search.status() == SearchStatus::complete, "bounded search did not finish"); +} +static void search_does_not_revive() { + Parser parser(2, 8, 2); + parser.feed("MAIN"); + auto& screen = parser.screen; + TextDocument document(screen); + TextSearch search; + auto generation = search.start(document, "MAIN"); + finish_search(search, document, generation); + check(search.matches().size() == 1 && search.match(document, 0).has_value(), "primary search setup failed"); + screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + check(!search.match(document, 0), "a primary search match stayed usable in the alternate frame"); + generation = search.start(document, "ALT"); + finish_search(search, document, generation); + check(search.matches().size() == 1 && search.match(document, 0).has_value(), + "fresh alternate search positions did not carry the active document context"); + screen.activate_buffer(ScreenBuffer::primary); + search.notify_change(document); + check(search.status() == SearchStatus::invalidated && search.matches().empty(), + "search kept an old query scan after a buffer roundtrip"); + generation = search.start(document, "MAIN"); + finish_search(search, document, generation); + check(search.matches().size() == 1 && search.match(document, 0).has_value(), + "fresh search positions did not carry the active document context"); + screen.activate_buffer(ScreenBuffer::alternate); + screen.activate_buffer(ScreenBuffer::primary); + check(!search.match(document, 0), "completed search match revived before notification"); + const auto complete_step = search.step(document, generation, 0); + check(!complete_step.accepted && complete_step.status == SearchStatus::invalidated && search.matches().empty(), + "zero-budget step did not invalidate a completed search after activation"); + generation = search.start(document, "MAIN"); + search.step(document, generation, 2); + screen.activate_buffer(ScreenBuffer::alternate); + screen.activate_buffer(ScreenBuffer::primary); + const auto running_step = search.step(document, generation, 64); + check(!running_step.accepted && running_step.status == SearchStatus::invalidated && search.matches().empty(), + "a partial scan continued across activation without notification"); +} +static void viewport_does_not_revive() { + Parser parser(2, 8, 3); + parser.feed("ONE\r\nTWO\r\nTHREE\r\nFOUR"); + auto& screen = parser.screen; + HistoryViewport viewport; + const auto paused = viewport.scroll_to(screen, 0); + check(!paused.following && paused.top == 0, "primary viewport setup failed"); + screen.activate_buffer(ScreenBuffer::alternate); + HistoryViewport new_view; + const auto new_frame = new_view.snapshot(screen); + check(new_frame.viewport.following && new_frame.viewport.top == 0 && new_frame.cursor_row == screen.row, + "a new viewport did not initialize in a nonzero epoch"); + check(new_view.scroll_to(screen, 0).following, "a new alternate viewport could not scroll"); + screen.activate_buffer(ScreenBuffer::primary); + const auto resumed = viewport.snapshot(screen); + check(resumed.viewport.following && resumed.viewport.top == 2 && resumed.cursor_row == screen.row && + row_text(resumed.lines.front()) == "THREE ", + "a paused history viewport revived after a buffer roundtrip"); + screen.activate_buffer(ScreenBuffer::alternate); + screen.activate_buffer(ScreenBuffer::primary); + const auto requested = viewport.scroll_to(screen, 0); + check(!requested.following && requested.top == 0 && requested.anchor == paused.anchor, + "epoch reset overrode an explicit scroll in the current frame"); + HistoryViewport new_primary_view; + check(new_primary_view.snapshot(screen).viewport.following && + !new_primary_view.scroll_to(screen, 0).following && new_primary_view.snapshot(screen).viewport.top == 0, + "a new primary viewport could not follow and scroll in a nonzero epoch"); +} + +int main() { + try { + independent_text_and_cursor(); + writing_context_roundtrip(); + bounded_independent_history(); + resize_both_frames_and_rollback(); + growth_preserves_metadata_and_unique_ids(); + hidden_resize_failure_rolls_back_both_frames(); + positions_do_not_revive(); + selection_does_not_revive(); + search_does_not_revive(); + viewport_does_not_revive(); + std::cout << "owned buffer state checks passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/experiments/core/history_view.hpp b/experiments/core/history_view.hpp index 2d6fd413..b948f5dd 100644 --- a/experiments/core/history_view.hpp +++ b/experiments/core/history_view.hpp @@ -14,17 +14,25 @@ struct ViewSnapshot { int cursor_row = -1, cursor_column = 0; }; class HistoryViewport { - uint64_t anchor_ = 0; + uint64_t anchor_ = 0, observed_epoch_ = 0; bool following_ = true; + void sync_epoch(const Screen& screen) { + if (observed_epoch_ == screen.buffer_epoch()) return; + observed_epoch_ = screen.buffer_epoch(); + anchor_ = 0; + following_ = true; + } public: // A viewport is owned by exactly one session. Never share it across Screens. ViewportState reconcile(const Screen& screen) { + sync_epoch(screen); const size_t maximum = screen.history.size(); const size_t top = following_ ? maximum : (std::min)(screen.lower_bound_line(anchor_), maximum); anchor_ = screen.retained_line(top).id; return {top, screen.retained_size(), static_cast(screen.height()), maximum, anchor_, following_}; } ViewportState scroll_to(const Screen& screen, size_t top) { + sync_epoch(screen); top = (std::min)(top, screen.history.size()); following_ = top == screen.history.size(); anchor_ = screen.retained_line(top).id; diff --git a/experiments/core/results/2026-10-29-buffer-state-r2/README.md b/experiments/core/results/2026-10-29-buffer-state-r2/README.md new file mode 100644 index 00000000..568087ec --- /dev/null +++ b/experiments/core/results/2026-10-29-buffer-state-r2/README.md @@ -0,0 +1,40 @@ +# 自主 C++ 双缓冲存储与文档世代基础(#130) + +本记录只验证 [#130](https://github.com/JavaZeroo/termforge/issues/130) 的独立工程基础:两个缓冲拥有各自状态,交换激活并隔离旧读者。**未接入 private CSI,也不验收 1049、完整 #27 或 R2。** + +运行源码固定为 `63589fa21b68c3029501772b7bb415e66aa99e97`,已合入 main 基点 `337812bc27b10a8f4dee5e4e35ded21c90f4a151`。后续能力矩阵和本证据整理是文档提交,不改变该运行身份。字段、命令、源码/EXE 及原始本地记录哈希见 [validation.json](validation.json)。 + +## 实现与断言边界 + +- `Parser::screen` 保留原对象和主屏接口;另一帧私有拥有文本、光标、样式、pending-wrap、combining attachment、历史与丢弃计数。激活只交换状态,不复制保留历史;共享行 ID 分配器不随帧恢复。 +- 主屏保持原历史容量,备用存储历史上限为零;两帧共享尺寸。物理行 resize 先准备两帧,再提交;隐藏帧分配 ID 耗尽时,两帧内容、几何和分配器均不半更新。零历史是工程界限,不代表原版文档浏览合同。 +- `TextPosition` 带单调切换 epoch;selection、search 和 history viewport 检查世代。旧句柄不会因主屏返回且行 ID/version 保留而复活;非零 epoch 下新建的 alt/main 选择、查询及 viewport 仍可使用。未切换的默认 epoch 为零,现有三字段位置初始化及 JSON schema 保持兼容。 +- [独立公开边界测试](../../buffer_state_test.cpp)使用自写字面内容验证两帧往返、光标/样式/wrap/附件、隔离历史、宽字裁剪、软硬行元数据、增长 ID、resize 回滚与旧句柄失效;没有通过测试内部调用次数代替可见内容断言。 + +原版样式继承、重复进入清空、保存/恢复协议策略及历史文档呈现均不在本项选择。存储激活的幂等性不能用来宣称原版 1049 重复进入也幂等。 + +## 固定配置与完整回归 + +Windows 11 `10.0.26200`、x64 Release,Visual Studio 2022 / MSVC `19.42.34433.0`,Python `3.12.10`。`TERMFORGE_BUILD_QT_SHELL=OFF`,`TERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF`;既有 Windows 诊断使用系统 ConPTY。**本轮不构建 Qt/固定 ConPTY 产品组合,也不构建 Alacritty 参考。** + +完整 configure 和 build 均退出 **0**;完整 CTest **50/50、退出 0**,耗时 **355.09 秒**。这是测试耗时,不是终端性能指标。运行前已观察固定提交及干净源码,完成后采集精选源码、45 个 EXE 和完整日志身份;没有单独的运行前冻结 manifest,也不声称可复现构建证明。 + +主体命令为: + +```powershell +cmake -S . -B build/core-buffer-state -G 'Visual Studio 17 2022' -A x64 -DTERMFORGE_BUILD_QT_SHELL=OFF -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF -DPython3_EXECUTABLE= +cmake --build build/core-buffer-state --config Release +ctest --test-dir build/core-buffer-state -C Release --output-on-failure --output-junit <工作树>/local/buffer-state-r2/13-main-all-ctest.xml +``` + +仅测试进程的 PATH 前置 Git `usr/bin`,并设置 `PYTHONUTF8=1`、`TERMFORGE_REQUIRE_SYSTEM_CLIPBOARD=0`。路径占位符替代私人绝对路径。原始 configure/build/CTest/JUnit、退出码文件及完整 `LastTest.log` 保存在本地;JSON 公开其实际字节数与哈希,不发布原件。 + +CTest 注册级没有 skip,但 Python 框架内部有 **12 条 skip**,逐条名称及原因在 JSON:4 个可选参考整类、7 个需要 disposable hosted VM 的实际系统剪贴板 UI 场景,以及1个 opt-in 原版失败注入。成功 JUnit 输出被 CTest 截短,内部 skip 以完整 `LastTest.log` 为准。`paste_system` 注册及其中的系统 API 检查实际通过,不能替代上述跳过的 UI 场景或完整剪贴板验收,也不转为 Qt 能力。 + +## 分次失败与审查 + +7 条实际运行红分别覆盖 writing context、备用历史界限、隐藏 resize、旧 position、selection、search 和 viewport;每条的失败日志及当时源码/测试/EXE 身份记录均保留,脱敏哈希见 JSON。接口编译失败、选择测试夹具参数错误、早期只构建部分 target 导致缺 `receive.exe` 的回归准备失败另列,不计为算法运行红;最终整轮已完整构建该 target。 + +双轴审查无未解决阻断;原字段交换清单的重复已用私有同一 helper 消除,复审确认没有引入 copy 或改变 `noexcept` 边界。没有独立审查报告附件,因此不补造报告哈希。远端 hosted 检查属于 PR 的独立运行,不并入本地 50 项计数。 + +本公开记录仅含合成断言、脱敏配置、计数及身份哈希;不包含 WindTerm 私有材料、资源、会话、原始抓取或私人路径。它不证明原版一致性、完整备用屏、GUI/TUI 组合、IME/真实 DPI、全平台完整产品、可分发包或性能达标。 diff --git a/experiments/core/results/2026-10-29-buffer-state-r2/validation.json b/experiments/core/results/2026-10-29-buffer-state-r2/validation.json new file mode 100644 index 00000000..ddf04712 --- /dev/null +++ b/experiments/core/results/2026-10-29-buffer-state-r2/validation.json @@ -0,0 +1,570 @@ +{ + "schema": "termforge-cpp-buffer-state-validation-v1", + "date": "2026-10-29", + "issue": "https://github.com/JavaZeroo/termforge/issues/130", + "runtime_source_commit": "63589fa21b68c3029501772b7bb415e66aa99e97", + "merged_main_base": "337812bc27b10a8f4dee5e4e35ded21c90f4a151", + "scope": "Owned bounded screen frames and document epoch foundation only; no private CSI dispatch or WindTerm mode acceptance", + "provenance": "Independently authored synthetic literals and existing synthetic regression suites; no WindTerm artifacts or private profiles published", + "configuration": { + "platform": "Windows-11-10.0.26200-SP0", + "architecture": "x64", + "generator": "Visual Studio 17 2022", + "compiler_version": "19.42.34433.0", + "configuration": "Release", + "python_version": "3.12.10", + "qt_shell": false, + "candidate_adapter": false, + "existing_windows_diagnostics_backend": "system ConPTY; not bundled product-route validation" + }, + "environment": { + "PATH_prepend": "/usr/bin", + "PYTHONUTF8": "1", + "TERMFORGE_REQUIRE_SYSTEM_CLIPBOARD": "0" + }, + "commands": [ + "cmake -S . -B build/core-buffer-state -G 'Visual Studio 17 2022' -A x64 -DTERMFORGE_BUILD_QT_SHELL=OFF -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF -DPython3_EXECUTABLE=", + "cmake --build build/core-buffer-state --config Release", + "ctest --test-dir build/core-buffer-state -C Release --output-on-failure --output-junit /local/buffer-state-r2/13-main-all-ctest.xml" + ], + "local_result": { + "configure_exit_code": 0, + "build_exit_code": 0, + "ctest_exit_code": 0, + "ctest_registered": 50, + "ctest_passed": 50, + "ctest_skipped": 0, + "internal_skip_source": "Complete LastTest.log; successful JUnit system-out sections are truncated and do not contain all skips", + "internal_skip_entries": [ + { + "ctest": "differential", + "detail": "setUpClass (test_candidate_compare.CandidateComparisonTests) ... skipped 'optional Alacritty binary is not built; core-candidate workflow runs these checks'" + }, + { + "ctest": "differential", + "detail": "setUpClass (test_capability_matrix.CapabilityMatrixTests) ... skipped 'optional Alacritty binary is not built; core-candidate workflow runs these checks'" + }, + { + "ctest": "differential", + "detail": "setUpClass (test_fuzz_matrix.FuzzMatrixTests) ... skipped 'optional Alacritty binary is not built; core-candidate workflow runs these checks'" + }, + { + "ctest": "differential", + "detail": "setUpClass (test_real_app_matrix.RealApplicationMatrixTests) ... skipped 'optional real application capture dependencies are not built'" + }, + { + "ctest": "text_actions", + "detail": "test_actual_system_copy_paste_and_shortcuts_twice (experiments.shell.test_actions.ActionWindowTest.test_actual_system_copy_paste_and_shortcuts_twice) ... skipped 'actual system Copy/Paste requires a disposable hosted VM'" + }, + { + "ctest": "research_guards", + "detail": "test_suspended_target_is_reclaimed_if_job_assignment_fails (test_ui_r1.EvidenceBoundaryTests.test_suspended_target_is_reclaimed_if_job_assignment_fails) ... skipped 'opt-in pinned reference failure injection'" + }, + { + "ctest": "shell", + "detail": "test_mouse_selection_to_real_system_clipboard (experiments.shell.test_shell.ShellTests.test_mouse_selection_to_real_system_clipboard) ... skipped 'system copy UI gate requires a disposable hosted VM'" + }, + { + "ctest": "shell", + "detail": "test_system_clipboard_product_read_errors_cancel_and_close (experiments.shell.test_shell.ShellTests.test_system_clipboard_product_read_errors_cancel_and_close) ... skipped 'product clipboard read gate requires a disposable hosted VM'" + }, + { + "ctest": "shell", + "detail": "test_system_clipboard_shortcut_to_window_confirmation_and_receiver (experiments.shell.test_shell.ShellTests.test_system_clipboard_shortcut_to_window_confirmation_and_receiver) ... skipped 'real system clipboard UI gate requires a disposable hosted VM'" + }, + { + "ctest": "selection_bytes", + "detail": "test_owned_clipboard_contention_preserves_write_and_read_failures (experiments.shell.test_selection_bytes.SelectionBytesWindowTest.test_owned_clipboard_contention_preserves_write_and_read_failures) ... skipped 'system CF requires a disposable hosted VM'" + }, + { + "ctest": "selection_bytes", + "detail": "test_real_system_copy_shortcuts_for_styled_fixture_twice (experiments.shell.test_selection_bytes.SelectionBytesWindowTest.test_real_system_copy_shortcuts_for_styled_fixture_twice) ... skipped 'system CF requires a disposable hosted VM'" + }, + { + "ctest": "selection_context", + "detail": "test_real_system_copy_and_edit_context_twice (experiments.shell.test_selection_context.SelectionContextWindowTest.test_real_system_copy_and_edit_context_twice) ... skipped 'real CF/EDIT Copy requires a disposable hosted VM'" + } + ], + "ctest_elapsed_seconds": 355.09, + "time_is_test_duration_not_terminal_performance": true + }, + "review": { + "standards_unresolved_blockers": 0, + "spec_unresolved_findings": 0, + "swap_helper_followup": "Parent reviewer confirmed shared private field-list swap is equivalent and introduces no new blocker; no separate report attachment" + }, + "runtime_reds": [ + { + "stage": "02-context", + "identity": { + "screen_sha256": "2015EB6E0B3A4BF70151AF0F566341FDDB0F7CCE51E47E08032A448313A2285E", + "test_sha256": "FDA5955B6702AF3A79E4A81DFC75CCE9B5536F945C6A8104F35CD6AA8A03C1D2", + "exe_sha256": "4B92EA7BBBA2C52BC548C167BAAA5222E541DD270C7B38B6A82B24A1DB40C889", + "exit_code": 1 + }, + "identity_record": { + "path": "local/buffer-state-r2/02-context-red-identity.json", + "bytes": 283, + "sha256": "5629acc21d04f3f3280c5c3e0b09810d0f987981f41107781e8d0fccfc4ff99f" + }, + "runtime_log": { + "path": "local/buffer-state-r2/02-context-red.log", + "bytes": 43, + "sha256": "cf79521f5b48970f8a73488c1c49d7acd7f882e5beace91ba15450f61464fb85" + } + }, + { + "stage": "03-history", + "identity": { + "screen_sha256": "06489F0F855FC41848EBABB74E9E284221963299902BBD7BE3F10FD30EEBEFDF", + "test_sha256": "54E965C24E7E00A5656E6020CA29FF408E29FE46FA6CFE0E3F9195B9FF5167EA", + "exe_sha256": "5BF7B736BFE636CE4CA704A190F0819D007F5DEEE1BB1C975DE068C5B10F23AC", + "exit_code": 1 + }, + "identity_record": { + "path": "local/buffer-state-r2/03-history-red-identity.json", + "bytes": 283, + "sha256": "9028dfa690a67a8cd9bff793ed4e7ba1fb8c59aa9c9b8004c64eac52a1dc6a62" + }, + "runtime_log": { + "path": "local/buffer-state-r2/03-history-red.log", + "bytes": 70, + "sha256": "cea8e2327b71792e137bcc96f1acfdfda97a4bf621370d331f59a45779ba26ba" + } + }, + { + "stage": "04-resize", + "identity": { + "screen_sha256": "4DE1F23A2BA0165AE9B2AB5D7AF0585EB5D662E81BF571CAD187551DA8423C88", + "test_sha256": "76F408D3EB7D23E76E268E5D3D8818CCC70BC16A77E4694A4A9C71D909EC1B57", + "exe_sha256": "FEC89BA8B516F8A7B5F4F6CDC8BC99BE0879CE54227C3B94E4F71968DD8D3925", + "exit_code": 1 + }, + "identity_record": { + "path": "local/buffer-state-r2/04-resize-red-identity.json", + "bytes": 283, + "sha256": "a050de7f729d17a2d3d2742427e755e21f1490041c23f15da70a1a071d6c41e7" + }, + "runtime_log": { + "path": "local/buffer-state-r2/04-resize-red.log", + "bytes": 41, + "sha256": "20ecb09b2ffc21ce93ce4b83cde5c1cbb7f6df5922f24fd424baab9cd53ef1a1" + } + }, + { + "stage": "05-position", + "identity": { + "screen_sha256": "58CCBE99894094BDA250A0C1A65BA4E7A7DBB99CCF03E48C3C820AAB8CFED8CC", + "document_sha256": "87EFAE9D6A9FCCB88DD353CDD1BE6A570480B6942FABBDF3C9DAC2C6370E026A", + "test_sha256": "324D846C7041C9646728874B4B8AD66AE430601FCC2F72FA9C1078575B90A6E8", + "exe_sha256": "4DACDEF02CC1D55E616DD6C82979EF330BAD5EFBF96DEA7EE019D8218176BD5D", + "exit_code": 1 + }, + "identity_record": { + "path": "local/buffer-state-r2/05-position-red-identity.json", + "bytes": 373, + "sha256": "715c4e07624dbdd5a72db3c63da9766b8685c3d336af8f1245ab9a9073f91e48" + }, + "runtime_log": { + "path": "local/buffer-state-r2/05-position-red.log", + "bytes": 55, + "sha256": "a7756a0c28cbe7770e9525a0a561dc189457bbd0cf5794bf0391de293b2406a4" + } + }, + { + "stage": "06-selection", + "identity": { + "selection_sha256": "7D41D58D434AD3C32CE2F67C043E337277ABBBC69E4A7A2B6BDF7E519F9F5462", + "test_sha256": "9F84B71EF85E56085851A8C11A8F423868FDBA8AEB72E0B1B70F100CE3E1B082", + "exe_sha256": "BDE0CEBEFDAB410741A5A6BC00F3BF246FD25A9E3249D6AA9C8ACE8D8D1BCDBC", + "exit_code": 1 + }, + "identity_record": { + "path": "local/buffer-state-r2/06-selection-red-identity.json", + "bytes": 286, + "sha256": "2f682c18496f1392d9fcf9e67504a3f8abd147c3b1f8a87dad8a04f20782ec85" + }, + "runtime_log": { + "path": "local/buffer-state-r2/06-selection-red.log", + "bytes": 67, + "sha256": "b40793bab48cb3a322585ac19f27a79ea64c02400e4742c8ee00aac15f64ab6f" + } + }, + { + "stage": "07-search", + "identity": { + "search_sha256": "FCC64B96C1F8D39B3A09DC45FFE537E7DC0FD70727B4D46768706AB55467CAAC", + "test_sha256": "6AFED61869F5EC83608949DE3B4AD1E6C93ECB2DFC921711A8AC016DA63E9F02", + "exe_sha256": "598DB3EFFD3E094C23ED1462635C14C9133EB68CA778DF41860F36F1804FBA4A", + "exit_code": 1 + }, + "identity_record": { + "path": "local/buffer-state-r2/07-search-red-identity.json", + "bytes": 283, + "sha256": "18ec93a7fe301f4132897fecdbb296d14800869b7741cb82bfe8f42a9af97fb6" + }, + "runtime_log": { + "path": "local/buffer-state-r2/07-search-red.log", + "bytes": 56, + "sha256": "88e72fcaf25e0390ae58e3c90174067428957396df80792df7bcc92c4d89ddc6" + } + }, + { + "stage": "08-viewport", + "identity": { + "exit_code": 1, + "viewport_sha256": "BC6218D05CC331AD3F39DB545575FAF0D100713DC8CB8AD09DBF3EE635C4696D", + "exe_sha256": "384192AB8699123E42003F882A600183F20974A03BA8AD641CC0EFCC9BFB18C7", + "test_sha256": "80A744959414BD94467BC0DF6C234C89F80D13F8AD4E21075D67AAB03AD83208" + }, + "identity_record": { + "path": "local/buffer-state-r2/08-viewport-red-identity.json", + "bytes": 285, + "sha256": "487fa9de1055bd52372a15bb7ded27b634b6492c9be6be053aa209c2ed9dcda7" + }, + "runtime_log": { + "path": "local/buffer-state-r2/08-viewport-red.log", + "bytes": 60, + "sha256": "be49afc085679fc3c7050e9be1a778b7f6f86389c2a87237263ea70a1315cc3a" + } + } + ], + "preparation_failures": [ + { + "kind": "interface compile failure", + "record": { + "path": "local/buffer-state-r2/01-interface-red.log", + "bytes": 4377, + "sha256": "c5553816dee0cd70011cdcfb96644681dc82c4eee53c778dc3520d9e1a6d45ab" + }, + "counts_as_runtime_red": false + }, + { + "kind": "selection fixture missing required copy limit", + "record": { + "path": "local/buffer-state-r2/06-selection-fixture-compile-error.log", + "bytes": 1826, + "sha256": "fb3da1789af0cb2c05f699d8a9bcc38f52f899d7b26d90c28e4320af12987feb" + }, + "counts_as_runtime_red": false + }, + { + "kind": "earlier related CTest core group missing unbuilt receive.exe", + "record": { + "path": "local/buffer-state-r2/04-existing-ctest.log", + "bytes": 5312, + "sha256": "640f1d27bf749d73fce97a7d2b56bd09ff61e4df78fba21178066de207f187a8" + }, + "counts_as_runtime_red": false + } + ], + "post_run_source_identity": [ + { + "path": "CMakeLists.txt", + "bytes": 23846, + "sha256": "2d5a395ee1890c12f4e4f560122bc6ae682386beed5629f5206fc7ac4121d78f" + }, + { + "path": "experiments/core/screen.hpp", + "bytes": 16884, + "sha256": "534068ce7fc2d9b5436c87b84626d9cf5b9e9bf2811a9a789c03c77925786ccc" + }, + { + "path": "experiments/core/terminal.hpp", + "bytes": 11155, + "sha256": "e5ca6b065b38b98ba4bb32f579e4f0e8e126a7872de147ff37c2c68aac414358" + }, + { + "path": "experiments/core/text_document.hpp", + "bytes": 3850, + "sha256": "a51903b9091471b0d52c47f34b966f56b23045042c007fe8cd6f6e1b1302934c" + }, + { + "path": "experiments/core/text_selection.hpp", + "bytes": 9883, + "sha256": "5c3848d06bcb34f036aabec7d3e8a953796caed1ba90a8c8d40b62d3e2242bb0" + }, + { + "path": "experiments/core/text_search.hpp", + "bytes": 18533, + "sha256": "77b2aa3b0989fca32ec953ca5376085bf141011be74d014cac646eb0bde15d2f" + }, + { + "path": "experiments/core/history_view.hpp", + "bytes": 3121, + "sha256": "74370e3d3a45920d495cf7edce1cc57916ec8e6d0c221e1144a5a3bcdd8ecda6" + }, + { + "path": "experiments/core/buffer_state_test.cpp", + "bytes": 17118, + "sha256": "9b7a6705b7c43774bb35c44adcf3c10f9490c4a31993c08c4480024ac4ebe4a4" + } + ], + "post_run_executable_identity": [ + { + "path": "build/core-buffer-state/bin/Release/alpha_normal_smoke.exe", + "bytes": 68608, + "sha256": "e9d509ed0b708922f9893f73e01fb91792c99a1a7ba29200831c3229ce57deb0" + }, + { + "path": "build/core-buffer-state/bin/Release/buffer_state_test.exe", + "bytes": 132096, + "sha256": "0926180f356e24ac0c201d1bf65bb5f2728d12283b1f4e85f3c0f46bd7c3e5a5" + }, + { + "path": "build/core-buffer-state/bin/Release/clipboard_policy_test.exe", + "bytes": 31232, + "sha256": "8fc64bbde65da8e9ba0fa4c0e76c2233f79b20aa294fd1747d34c67d8d9e4230" + }, + { + "path": "build/core-buffer-state/bin/Release/conpty_ctrl_c_probe.exe", + "bytes": 60416, + "sha256": "83666324bb263d6ad1003f37d85cd0596f149f86e241e6d478aabdfe85eac798" + }, + { + "path": "build/core-buffer-state/bin/Release/conpty_margin_probe.exe", + "bytes": 109056, + "sha256": "787761091e18016959e2352b3969132edb793d9cc729672d41212265b39c4f43" + }, + { + "path": "build/core-buffer-state/bin/Release/core_measure.exe", + "bytes": 83968, + "sha256": "ad761e8b5df12b4bec75cce4ac07ec33dc6f50afa2b09de9108c7219cb676929" + }, + { + "path": "build/core-buffer-state/bin/Release/find_bar_test.exe", + "bytes": 53760, + "sha256": "609ba5bf24e935c4163a3aee8edede2b649d421c781f55bc03400fe36991eece" + }, + { + "path": "build/core-buffer-state/bin/Release/history_test.exe", + "bytes": 115200, + "sha256": "fa0d6b2139885c573dc3bf72907f7276973c67edd18e1d6e6d380d0d7bfdf119" + }, + { + "path": "build/core-buffer-state/bin/Release/ime_context_test.exe", + "bytes": 47104, + "sha256": "776a4762ffdb27f7de1584cab0e1c5cd6d3fdf34f2d92cbdee86162b6b8617f4" + }, + { + "path": "build/core-buffer-state/bin/Release/ime_input_expectation_test.exe", + "bytes": 24576, + "sha256": "647829e99cbe771ba72555ac0703ec13e17f4c976b7f0c1e1aaf688264f2d1e1" + }, + { + "path": "build/core-buffer-state/bin/Release/keyboard_test.exe", + "bytes": 33792, + "sha256": "c205b6dc869928b1efa5da7dd56c7e98ba5bec9796ff463d8b733a0603ab0cea" + }, + { + "path": "build/core-buffer-state/bin/Release/layout_state_test.exe", + "bytes": 99328, + "sha256": "26aa719ed7f7aeb107f2f644bdda1c1f50efe34d6c889e3a8334ad20392ce703" + }, + { + "path": "build/core-buffer-state/bin/Release/paste_test.exe", + "bytes": 47104, + "sha256": "e4077916d42ee9d8b5388af8985364104d5293916e3794b06424f74a2bbefe1d" + }, + { + "path": "build/core-buffer-state/bin/Release/presentation_probe.exe", + "bytes": 124928, + "sha256": "a4e16aa3a52a438a217702bcf749deeb293115f7fbda796faa1014c590accacb" + }, + { + "path": "build/core-buffer-state/bin/Release/raw_console_receiver_test.exe", + "bytes": 94720, + "sha256": "053bcd7e2122648d6136dcfcd7fac6ad4c4939782bda0a31ce1f9583a2e0f06b" + }, + { + "path": "build/core-buffer-state/bin/Release/real_app_capture.exe", + "bytes": 90624, + "sha256": "6e6962d5c28bae9e31a5786f7d1423da8f63d7ab4425468edc2d00d79ad2b633" + }, + { + "path": "build/core-buffer-state/bin/Release/receive.exe", + "bytes": 91648, + "sha256": "8f27a353e696c5f8e1bd4f744f4e9322b31bf058ba42c26ef4617ed3ca0ef019" + }, + { + "path": "build/core-buffer-state/bin/Release/recovery_probe.exe", + "bytes": 84992, + "sha256": "d3dad682985d88ba2a85d9961c3aed0e9540138c4132831877fba7e8e5d111e6" + }, + { + "path": "build/core-buffer-state/bin/Release/renderer_metrics_test.exe", + "bytes": 129024, + "sha256": "e0f7b6b1d6c86a92c5fd1b51a7156855c8ec9875fc4eb87717f8ea273f5d036c" + }, + { + "path": "build/core-buffer-state/bin/Release/replay.exe", + "bytes": 75776, + "sha256": "6c5ffc3c7e538f3a07df1a15090e70e231d345bea8727897f1879d28c35f40e2" + }, + { + "path": "build/core-buffer-state/bin/Release/resize_test.exe", + "bytes": 102912, + "sha256": "661ef6340995b6bd289d0fb8c392d76642c15fc1497059f532722df53afb9919" + }, + { + "path": "build/core-buffer-state/bin/Release/runtime_observation_test.exe", + "bytes": 361984, + "sha256": "5a4922858c0ab736d4e5d330571fe08f9268997733ea462849229e312f6c24cd" + }, + { + "path": "build/core-buffer-state/bin/Release/scenario_tick_gate_test.exe", + "bytes": 17408, + "sha256": "cf45cba8fa48d16ed94fdf4c36ab09ec7f1a5d74c57219e2b491135048c4bdf6" + }, + { + "path": "build/core-buffer-state/bin/Release/search_test.exe", + "bytes": 146944, + "sha256": "7147508550102adac00e9eb07399d6ae5e0e2b4986c1d3d6b2b22ae331693366" + }, + { + "path": "build/core-buffer-state/bin/Release/selection_test.exe", + "bytes": 119808, + "sha256": "81d9ef66f38a600aae0cad8be12c116d9fbaff05e371ce25019019301250b023" + }, + { + "path": "build/core-buffer-state/bin/Release/sent_message_wait_test.exe", + "bytes": 34304, + "sha256": "ccd3046f50b3663159bf072d80ec41ea05c776df56f0a6f5bf1ea7fc349f9d47" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_clipboard_reader.exe", + "bytes": 22528, + "sha256": "33747c661ed4c5ef7707c1ce7477774cd695482c982692ed313c89d94b9ca14f" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_ime_client.exe", + "bytes": 126976, + "sha256": "b66d3ba9c77b4eee3c1093c805f6616a662519b7be2458f8877296923ef9d9e5" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_input_client.exe", + "bytes": 41984, + "sha256": "bade38e969d86392175ba6a1d143fb195e4e9a06d79549756ea42590c0fee7de" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_launch_test.exe", + "bytes": 50176, + "sha256": "1824b32a18c4c5c4ed7199344da06cb0720f2c619878f380e0d5ee7059cbd886" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_paste_probe.exe", + "bytes": 195072, + "sha256": "a7e2d81759cd7b44fdd9481865d85125a00edf1ab314500d20571478f27e4fdf" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_query_client.exe", + "bytes": 32768, + "sha256": "3b6c8cfbc2be8ef53f82880046c7473439db205292fd47c33e36465f65baf41e" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_queue_probe.exe", + "bytes": 121344, + "sha256": "a994327ee92c127b350928e84ba89dd00e1329743781a8c8b90a098c42ce0bc9" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_selection_text_client.exe", + "bytes": 56832, + "sha256": "29e675d87225bd28a2c78cdf7f3035449202017eccdc4ae98e32579b0b1c259f" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_size_client.exe", + "bytes": 18432, + "sha256": "b2f39f8c47ee9eb354461fadefe535fc8c7559b2a2294139cba194500fd01296" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_test_launcher.exe", + "bytes": 47616, + "sha256": "5885bf37f40a18e2f310a5a9a233b46e1b6df6fc7b6edcdf334a758baaa7266e" + }, + { + "path": "build/core-buffer-state/bin/Release/shell_text_client.exe", + "bytes": 147968, + "sha256": "496504291e46831915150a983efec60079f7473d00797892944558e102be06f4" + }, + { + "path": "build/core-buffer-state/bin/Release/termforge_conpty_probe.exe", + "bytes": 75776, + "sha256": "0d98ab38b6d05859e002db9a60902621a09a6503021f8cbbdb63972f9217f30d" + }, + { + "path": "build/core-buffer-state/bin/Release/termforge_ime_probe.exe", + "bytes": 65024, + "sha256": "e3a1cf0e37b70a1636f1c0a35adcd1024cccb6a2df1746d4a7c69336564b3892" + }, + { + "path": "build/core-buffer-state/bin/Release/termforge_shell.exe", + "bytes": 1234432, + "sha256": "49ccfaf3d1261a3665f9d7acff99eca4cdd167abc32a780627163dabe7dd144e" + }, + { + "path": "build/core-buffer-state/bin/Release/terminal_trace.exe", + "bytes": 81920, + "sha256": "2467e31fe631162702d8512cbb442df62a7102a1d6745bc0c7c0562d4be6cc90" + }, + { + "path": "build/core-buffer-state/bin/Release/text_action_policy_test.exe", + "bytes": 19968, + "sha256": "c0b19e83213b61b7c94164262d3189bf61443ca21293349071991f6a10ae9f49" + }, + { + "path": "build/core-buffer-state/bin/Release/transport_completion_test.exe", + "bytes": 44032, + "sha256": "dcde924f99c1d6ed1848aeb08b8c0c942b52c47f374772465442fb1b21dcadc8" + }, + { + "path": "build/core-buffer-state/bin/Release/workflow_fixture_test.exe", + "bytes": 134144, + "sha256": "3a5e97f189796bb66035a2dc28703057c88cc3ca84470e65c1ed7a82a955e5de" + }, + { + "path": "build/core-buffer-state/bin/Release/workspace_state_test.exe", + "bytes": 140288, + "sha256": "2ee17b20f94a341415f953107a8325926c716b70c71833f09ae42c27fc2c4d26" + } + ], + "raw_local_records": [ + { + "path": "local/buffer-state-r2/13-main-configure.log", + "bytes": 232, + "sha256": "12fe34e68441bfa729e3d50f163eaedbf5f74112a45d7614c7c80aca684cf5fd" + }, + { + "path": "local/buffer-state-r2/13-main-all-build.log", + "bytes": 10582, + "sha256": "e8b4c49735c565790c8c5261c5e4089423c1d19d2dc136ad1f4f46a73ce89f0c" + }, + { + "path": "local/buffer-state-r2/13-main-all-ctest.log", + "bytes": 5752, + "sha256": "0ad6c321a56f37d6f99605808948b17da372cabf5da453d3030b9f0e5c82f592" + }, + { + "path": "local/buffer-state-r2/13-main-all-ctest.xml", + "bytes": 40706, + "sha256": "aa03971dc335d48d994e0a0542e06cd991ee8b737ba9fd7d84b36f00f372ee55" + }, + { + "path": "local/buffer-state-r2/13-main-all-ctest.exit", + "bytes": 3, + "sha256": "13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354" + }, + { + "path": "build/core-buffer-state/Testing/Temporary/LastTest.log", + "bytes": 133444, + "sha256": "023edcdfdcd45bf2059d3d5fab0454a91b8db73184459e6d84cef4034630ffba" + } + ], + "identity_limit": "Commit and clean source were observed before the supervised build/test. Listed hashes were collected after completion, not a separately frozen pre-run input manifest or reproducible-build proof.", + "hosted_checks": "Independent PR checks pending at local evidence publication; results are not folded into local counts", + "limits": [ + "No private CSI/1049 handler wiring", + "No choice of original style inheritance, repeat-entry clearing or history document presentation", + "Alternate storage history cap zero is an engineering bound, not a WindTerm comparison", + "No product Qt/fixed-ConPTY, original mode parity, complete #27, R2, clipboard/IME or performance acceptance claim" + ] +} diff --git a/experiments/core/screen.hpp b/experiments/core/screen.hpp index c05d37f1..26dd2d83 100644 --- a/experiments/core/screen.hpp +++ b/experiments/core/screen.hpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include namespace termforge { @@ -35,6 +37,7 @@ struct Cell { bool leading_spacer = false; // Autowrap padding; paint it but never copy it as text. }; enum class LineEnding { none, hard_break, soft_wrap }; +enum class ScreenBuffer { primary, alternate }; // A value container: moving/copying a row preserves its identity and metadata. // Screen alone creates identities; callers must mutate through Screen. struct Line : std::vector { @@ -50,9 +53,34 @@ struct Line : std::vector { }; class Screen { + struct FrameState { + int row = 0, column = 0; + int last_row = -1, last_column = -1; + bool wrap_pending = false; + size_t discarded = 0; + Style style; + std::vector lines; + std::deque history; + template void swap(State& other) noexcept { + std::swap(row, other.row); std::swap(column, other.column); + std::swap(last_row, other.last_row); std::swap(last_column, other.last_column); + std::swap(wrap_pending, other.wrap_pending); std::swap(discarded, other.discarded); + std::swap(style, other.style); + lines.swap(other.lines); history.swap(other.history); + } + }; int columns_; uint64_t next_id_ = 1; + uint64_t buffer_epoch_ = 0; int last_row = -1, last_column = -1; + ScreenBuffer active_buffer_ = ScreenBuffer::primary; + std::optional inactive_; + FrameState active_state_copy() const { + return {row, column, last_row, last_column, wrap_pending, discarded, style, lines, history}; + } + void swap_active(FrameState& state) noexcept { + state.swap(*this); + } static void changed(Line& line) { if (line.version == (std::numeric_limits::max)()) throw std::overflow_error("line version exhausted"); @@ -64,6 +92,32 @@ class Screen { Line line(columns, cell); line.id = identity++; line.version = 1; return line; } + static void resize_frame(FrameState& state, int rows, int columns, size_t limit, uint64_t& identity) { + const int shift = (std::max)(0, state.row - rows + 1); + for (int index = 0; index < shift; ++index) { + if (limit) { + state.history.push_back(std::move(state.lines[index])); + if (state.history.size() > limit) { state.history.pop_front(); ++state.discarded; } + } else ++state.discarded; + } + state.lines.erase(state.lines.begin(), state.lines.begin() + shift); + // Bottom rows outside the new viewport are dropped; their ids stay spent. + if (state.lines.size() > static_cast(rows)) state.lines.resize(rows); + while (state.lines.size() < static_cast(rows)) + state.lines.push_back(make_line(columns, Cell{}, identity)); + const auto fit = [columns](Line& line) { + if (line.size() == static_cast(columns)) return; + changed(line); + if (line.size() > static_cast(columns)) line.cropped = true; + line.resize(columns); + if (line.back().width == 2) line.back() = Cell{}; + }; + for (auto& line : state.lines) fit(line); + for (auto& line : state.history) fit(line); + state.row -= shift; state.column = (std::min)(state.column, columns - 1); + state.wrap_pending = false; + state.last_row = state.last_column = -1; + } Cell blank() const { return {" ", 1, style, 0}; } void break_glyph(Line& line, int col) { if (line[col].width == 0 && col > 0) line[col - 1] = blank(); @@ -113,6 +167,30 @@ class Screen { } int height() const { return static_cast(lines.size()); } int width() const { return columns_; } + ScreenBuffer active_buffer() const { return active_buffer_; } + uint64_t buffer_epoch() const { return buffer_epoch_; } + // The configured limit belongs to primary history; the independent + // alternate storage budget is zero. This does not define a document view. + size_t active_history_limit() const { return active_buffer_ == ScreenBuffer::primary ? history_limit : 0; } + // Activation exchanges owned state without applying a terminal mode's + // clearing, inheritance or cursor-reset policy. + void activate_buffer(ScreenBuffer buffer) { + if (buffer == active_buffer_) return; + if (buffer_epoch_ == (std::numeric_limits::max)()) + throw std::overflow_error("buffer epoch exhausted"); + auto identity = next_id_; + if (!inactive_) { + FrameState target; + target.lines.reserve(lines.size()); + for (int index = 0; index < height(); ++index) + target.lines.push_back(make_line(width(), Cell{}, identity)); + inactive_.emplace(std::move(target)); + } + swap_active(*inactive_); + active_buffer_ = buffer; + next_id_ = identity; + ++buffer_epoch_; + } size_t retained_size() const { return history.size() + lines.size(); } const Line& retained_line(size_t index) const { if (index >= retained_size()) throw std::out_of_range("retained line index"); @@ -150,39 +228,21 @@ class Screen { next_id_ = maximum == (std::numeric_limits::max)() ? maximum : maximum + 1; } // Physical-row policy: no reflow, no pulling old history into the viewport. - // Prepare copies first so invalid sizes/allocation failure leave state intact. + // Both frames share geometry. Prepare both before committing so invalid + // dimensions or allocation failure cannot leave one frame half-resized. void resize(int rows, int columns) { if (rows < 1 || rows > 200 || columns < 1 || columns > 500) throw std::invalid_argument("invalid screen dimensions"); if (rows == height() && columns == width()) return; - auto next = lines; - auto past = history; - auto lost = discarded; + auto next = active_state_copy(); + auto hidden = inactive_; auto identity = next_id_; - const int shift = (std::max)(0, row - rows + 1); - for (int i = 0; i < shift; ++i) { - if (history_limit) { - past.push_back(std::move(next[i])); - if (past.size() > history_limit) { past.pop_front(); ++lost; } - } else ++lost; - } - next.erase(next.begin(), next.begin() + shift); - // Bottom rows outside the new viewport are dropped. Never reuse their ids. - if (next.size() > static_cast(rows)) next.resize(rows); - while (next.size() < static_cast(rows)) next.push_back(make_line(columns, Cell{}, identity)); - const auto fit = [columns](Line& line) { - if (line.size() == static_cast(columns)) return; - changed(line); - if (line.size() > static_cast(columns)) line.cropped = true; - line.resize(columns); - // A truncated wide glyph must never leave a dangling leading cell. - if (line.back().width == 2) line.back() = Cell{}; - }; - for (auto& line : next) fit(line); - for (auto& line : past) fit(line); - lines.swap(next); history.swap(past); discarded = lost; columns_ = columns; next_id_ = identity; - row -= shift; column = (std::min)(column, columns - 1); - wrap_pending = false; invalidate_attachment(); + resize_frame(next, rows, columns, active_history_limit(), identity); + if (hidden) + resize_frame(*hidden, rows, columns, active_buffer_ == ScreenBuffer::primary ? 0 : history_limit, identity); + swap_active(next); + if (hidden) inactive_->swap(*hidden); + columns_ = columns; next_id_ = identity; } void invalidate_attachment() { last_row = last_column = -1; } void move(int r, int c) { @@ -198,9 +258,10 @@ class Screen { previous.next_id = automatic ? (row + 1 < height() ? lines[row + 1].id : next_id_) : 0; wrap_pending = false; invalidate_attachment(); if (row + 1 < height()) { ++row; return; } - if (history_limit) { + const auto limit = active_history_limit(); + if (limit) { history.push_back(std::move(lines.front())); - if (history.size() > history_limit) { history.pop_front(); ++discarded; } + if (history.size() > limit) { history.pop_front(); ++discarded; } } else ++discarded; lines.erase(lines.begin()); lines.push_back(make_line(width(), Cell{}, next_id_)); } diff --git a/experiments/core/text_document.hpp b/experiments/core/text_document.hpp index 96465ee7..ac104aed 100644 --- a/experiments/core/text_document.hpp +++ b/experiments/core/text_document.hpp @@ -7,9 +7,10 @@ namespace termforge { struct TextPosition { uint64_t line_id = 0, version = 0; size_t column = 0; + uint64_t epoch = 0; }; inline bool operator==(TextPosition a, TextPosition b) { - return a.line_id == b.line_id && a.version == b.version && a.column == b.column; + return a.line_id == b.line_id && a.version == b.version && a.column == b.column && a.epoch == b.epoch; } inline bool operator!=(TextPosition a, TextPosition b) { return !(a == b); } struct TextRange { TextPosition begin, end; }; // Half-open physical-cell positions. @@ -31,6 +32,7 @@ class TextDocument { const Screen& screen_; public: explicit TextDocument(const Screen& screen) : screen_(screen) {} + uint64_t epoch() const { return screen_.buffer_epoch(); } size_t size() const { return screen_.retained_size(); } DocumentLineMetadata meta(size_t index) const { const auto& value = screen_.retained_line(index); @@ -47,6 +49,7 @@ class TextDocument { return index; } bool valid(TextPosition value) const { + if (value.epoch != epoch()) return false; const auto index = index_of(value.line_id); if (!index) return false; const auto current = meta(*index); @@ -56,7 +59,7 @@ class TextDocument { if (index >= size()) return std::nullopt; const auto value = meta(index); if (column > value.columns) return std::nullopt; - return TextPosition{value.id, value.version, column}; + return TextPosition{value.id, value.version, column, epoch()}; } // A range is physical-cell based and half-open. Endpoints inside a width-2 // glyph expand outward, so no consumer can copy or paint half of a glyph. diff --git a/experiments/core/text_search.hpp b/experiments/core/text_search.hpp index 5e964dcc..5f285d77 100644 --- a/experiments/core/text_search.hpp +++ b/experiments/core/text_search.hpp @@ -35,7 +35,7 @@ struct SearchNavigation { SearchMatch match; size_t index = 0; bool wrapped = fa class TextSearch { struct ScannedRow { DocumentLineMetadata meta; size_t bytes = 0, prefix_before = 0; uint64_t successor_id = 0; }; struct Origin { TextPosition begin, end; }; - uint64_t generation_ = 0, change_serial_ = 0, audit_serial_ = 0; + uint64_t generation_ = 0, change_serial_ = 0, audit_serial_ = 0, document_epoch_ = 0; SearchStatus status_ = SearchStatus::idle; SearchOptions options_; std::string query_; @@ -62,7 +62,7 @@ class TextSearch { return a.line_id < b.line_id || (a.line_id == b.line_id && a.column < b.column); } static bool equal(const TextPosition& a, const TextPosition& b) { - return a.line_id == b.line_id && a.version == b.version && a.column == b.column; + return a == b; } static bool valid_utf8(const std::string& value) { uint32_t scalar = 0, minimum = 0; unsigned remaining = 0; @@ -97,6 +97,15 @@ class TextSearch { audit_cursor_ = 0; audit_requested_ = loaded_ = boundary_done_ = at_end_ = cap_reached_ = false; next_id_ = emit_from_id_ = 0; } + bool sync_epoch(const TextDocument& doc) { + if (document_epoch_ == doc.epoch()) return false; + document_epoch_ = doc.epoch(); + if (status_ == SearchStatus::running || status_ == SearchStatus::complete || status_ == SearchStatus::capped) { + invalidate(); + return true; + } + return false; + } bool row_valid(const TextDocument& doc, const DocumentLineMetadata& expected, uint64_t successor_id) const { const auto index = doc.index_of(expected.id); if (!index) return false; @@ -195,8 +204,8 @@ class TextSearch { if (span_ == spans.size()) throw std::logic_error("projected search byte has no cell span"); const auto& span = spans[span_]; consume(static_cast(text[byte_++]), - {{current_meta_.id, current_meta_.version, span.column_begin}, - {current_meta_.id, current_meta_.version, span.column_end}}); + {{current_meta_.id, current_meta_.version, span.column_begin, document_epoch_}, + {current_meta_.id, current_meta_.version, span.column_end, document_epoch_}}); ++progress.bytes_scanned; return true; } const auto index = doc.index_of(current_meta_.id); @@ -205,7 +214,8 @@ class TextSearch { if (!boundary_done_ && has_next && !current_.joins_next) { const auto next = doc.meta(*index + 1); const auto end = current_.projection.spans.empty() ? 0 : current_.projection.spans.back().column_end; - consume('\n', {{current_meta_.id, current_meta_.version, end}, {next.id, next.version, 0}}); + consume('\n', {{current_meta_.id, current_meta_.version, end, document_epoch_}, + {next.id, next.version, 0, document_epoch_}}); boundary_done_ = true; ++progress.bytes_scanned; return true; } rows_.push_back({current_meta_, text.size() + (has_next && !current_.joins_next ? 1u : 0u), current_prefix_before_, current_successor_id_}); @@ -214,12 +224,12 @@ class TextSearch { } public: static constexpr size_t query_byte_limit = 4096, result_hard_limit = 4096; - uint64_t start(const TextDocument&, std::string query, SearchOptions options = {}) { + uint64_t start(const TextDocument& doc, std::string query, SearchOptions options = {}) { if (query.size() > query_byte_limit || !valid_utf8(query)) throw std::invalid_argument("search query must be valid UTF-8 within 4096 bytes"); if (!options.result_limit || options.result_limit > result_hard_limit) throw std::invalid_argument("search result limit must be between 1 and 4096"); - advance_generation(); reset_scan(); query_ = std::move(query); options_ = options; + advance_generation(); reset_scan(); query_ = std::move(query); options_ = options; document_epoch_ = doc.epoch(); prefix_.assign(query_.size(), 0); for (size_t i = 1, length = 0; i < query_.size(); ++i) { const auto c = folded(static_cast(query_[i])); @@ -230,7 +240,8 @@ class TextSearch { status_ = query_.empty() ? SearchStatus::complete : SearchStatus::running; return generation_; } - uint64_t notify_change(const TextDocument&) { + uint64_t notify_change(const TextDocument& doc) { + if (sync_epoch(doc)) return generation_; advance_generation(); if (query_.empty() || status_ == SearchStatus::cancelled || status_ == SearchStatus::closed || status_ == SearchStatus::invalidated || status_ == SearchStatus::idle) return generation_; @@ -241,6 +252,7 @@ class TextSearch { at_end_ = false; status_ = SearchStatus::running; return generation_; } SearchStep step(const TextDocument& doc, uint64_t generation, size_t budget) { + sync_epoch(doc); SearchStep progress{status_, 0, 0, 0, generation == generation_}; if (!progress.accepted || status_ != SearchStatus::running || !budget) return progress; if (loaded_ && !row_valid(doc, current_meta_, current_successor_id_)) { @@ -270,7 +282,7 @@ class TextSearch { const std::vector& matches() const { return matches_; } size_t retained_rows() const { return rows_.size(); } std::optional match(const TextDocument& doc, size_t index) const { - if (index >= matches_.size()) return {}; + if (document_epoch_ != doc.epoch() || index >= matches_.size()) return {}; const auto hit = matches_[index]; if (!doc.valid(hit.range.begin) || !doc.valid(hit.range.end)) return {}; const auto first = std::lower_bound(rows_.begin(), rows_.end(), hit.range.begin.line_id, diff --git a/experiments/core/text_selection.hpp b/experiments/core/text_selection.hpp index 8af73a58..824d02ec 100644 --- a/experiments/core/text_selection.hpp +++ b/experiments/core/text_selection.hpp @@ -65,6 +65,8 @@ class TextSelection { // both the saved and live rows have no text or spans. bool reconcile(const TextDocument& document) { if (!range_) return !invalidated_; + if (range_->begin.epoch != document.epoch() || range_->end.epoch != document.epoch()) + return invalidate(); const auto begin = document.index_of(range_->begin.line_id); if (!begin || saved_.size() > document.size() - *begin) return invalidate(); for (size_t i = 0; i < saved_.size(); ++i) {