Architecture Internals zh CN - D0n9X1n/SonicTerm GitHub Wiki

架构内部机制

English

本页列出测试必须保护的规则:准确记账、完整画面、安全的原生对象寿命,以及经过验证的 发布资产。建议先读架构;资源上限见内存。

以真实堆内存验证记账

集成测试通过计数型 #[global_allocator] 记录分配、释放和重新分配,把保留量报告与 实际存活堆内存比较,检查三项:

  • 报告值不能明显低于实际存活堆内存;
  • 报告值不能明显高于实际存活堆内存;
  • 实际堆内存必须停在已实施的上限与声明的容差内。

这类测试必须放在 tests/ 中。#[global_allocator] 作用于整个 crate,同级单元测试模块 无法把它隔离开。

分配器是进程全局状态。同一个测量测试二进制中的每个测试,都要在整个测量期间持有文件内 Mutex。夹具字符串和缓冲区要在测量窗口开始前创建。否则,并发测试或测试框架本身的分配 会被算进被测对象。

真实堆内存检查覆盖网格、超链接注册表、PTY 队列、VT 捕获暂存、内联媒体、所有者关闭和 长寿命图集。主要上限如下:

接缝 当前上限
网格存储 MAX_GRID_CELLS = 1,048,576;可见几何受 MAX_VISIBLE_GRID_CELLS = 524,288 限制
超链接注册表 MAX_HYPERLINKS = 16,384、MAX_HYPERLINK_URI_BYTES = 8 KiB、MAX_HYPERLINK_CLIENT_ID_BYTES = 1 KiB、MAX_HYPERLINK_METADATA_BYTES = 8 MiB
VT 媒体负载 MAX_MEDIA_PAYLOAD_BYTES = 16 MiB
进程级 VT 捕获暂存 MAX_PROCESS_CAPTURE_STAGING_BYTES = 64 MiB,保底值 MIN_CAPTURE_STAGING_BYTES = 4 MiB,GUARANTEED_CONCURRENT_CAPTURES = 13
PTY 输入 最多排队 4 条消息;每条最多 16 MiB
PTY 输出 最多 64 个排队数据块,另有一个阻塞中的发送数据块;每个读缓冲环为 64 KiB;结构最坏值为 65 个缓冲环,即 4.0625 MiB
常驻内联媒体 每窗格最多 128 张图和 64 MiB;进程目标为 256 MiB,回收收敛前每个存活窗格最多另保留一份 4 MiB 最新图像余量;参与渲染的图像单边最多 1,024 像素

捕获暂存和内联媒体各自按一个池计数。生产环境使用 CaptureStagingPool::process_default() 和 InlineMediaPool::process_default(),因此这些上限按进程生效。捕获暂存的真实堆内存 测试对照真实堆测量默认暂存池。内联媒体的真实堆内存测试检查窗格计费所依据的保留媒体数值, 不测量媒体池的总量。需要捕获被接纳、或测量接纳与预算的单元测试会注入私有池。

网格报告包含单元格、少见属性、组合文字、行容器和预留容量。回滚历史同时受配置行数和 常驻字节数限制。滚动路径按批次摊销检查字节预算。

超链接注册表会统计 URI 字符串和两张哈希表。clear 会收缩这些表。注册表满时可以清理 未被引用的条目,但不会在每条 OSC 8 超链接到来时遍历整个网格。

PTY 输出报告统计被排队 Bytes 视图占住的读缓冲环。它不会用槽位数乘一个假定的数据块 大小,也不会把负载长度误当成常驻分配量。

资源总账不变量

图形界面建立的实际所有者树如下:

flowchart TD
    process["Process"] --> window["Window"]
    window --> pane["AppPane"]
    process --> transport["已退役 PtyTransport"]
Loading

进程和窗口所有者只跟踪数据,不设置有限额度。每个 AppPane 所有者使用 PANE_COMMITTED_BUDGET_BYTES。该值等于 PANE_SEAM_CAP_SUM_BYTES 的两倍,后者由网格、 内联媒体、超链接、解析器捕获、PTY 输出和 PTY 输入接缝上限计算得出。

各接缝上限仍是主要限制点。窗格预算只是总账警戒线。代码先保留内存,再测量并计费,因此 计费失败不会撤销已经保留的内存。增长失败时保留原计费值。新类别计费失败时,该类别保持 缺失,并写一条 memory debug 记录。

窗格为每个已计费的 ResourceClass 持有一个 CommittedReservation。常驻内存采样通过 try_resize 原地调整,也支持字节增长而条目减少或相反的混合变化。最终准入按 当前总量 - 旧计费 + 新计费 计算,不使用逐维最大值,也不先释放再预留。任一维增长都要求 祖先仍开放;纯减少允许在关闭过程中结算。先获取状态锁,再获取类别锁和按所有者 id 升序的 用量锁;进程字节增长通过已有 CAS 完成最后一个可失败步骤。拒绝时令牌和所有余额都不变。 快照仍是观察性读数,而不是一次全局线性化读取。

单窗格协调使用 CommittedReservation::transfer_batch;标签页附加使用 transfer_many, 把每个移动窗格的计费及其各自目标所有者纳入同一账本事务。两者都保留分类,并在修改任何分片前 验证源余额、目标状态及最终所有者上限。不可变父节点的 id 总小于子节点,因此两者遵循同一套 状态、类别和所有者锁顺序。成功只修改所有者路径余额和令牌 owner id,不改变进程或分类总量; 拒绝时保留全部源令牌,并在恢复源托管状态前释放空的临时所有者。

关闭顺序不能改变:

  1. 通过 App::retire_pane 将每个 PTY 及预留移入唯一的进程直属 PtyTransport, 计为一个 ReaperWork 条目;
  2. 清空窗格计费;
  3. 关闭每个 AppPane 所有者;
  4. 关闭父级 Window 所有者。

PaneState 把 charges 声明在 owner 前面。WindowState 把 panes 声明在窗口 owner 前面。Rust 按声明顺序析构字段,因此普通析构路径也遵循先叶子、后父级的规则。 finish_close 会拒绝仍有计费或子节点的所有者。OwnerGuard::drop 遇到拒绝时会记录 warning 并保留该记录,不会重试。

窗口所有者注册失败时,窗口仍可使用,但该窗口及其窗格在剩余寿命内都不会进入层级记账。 窗格所有者注册失败时,窗格仍可使用;周期协调可以再次尝试。渲染器持有的表面、字形图集、 逐行字形与 quad 缓存及软件帧不进入这份总账,另行测量。两个行缓存类明确标记为 UnchargedRetention:报告给出当前精确分配,覆盖表记录保守的每渲染器高水位包络。 报告不会虚构 GPU 内存,也不会把未收费类写成 governor 预留。

ReapShutdownHandle 将关闭控制与 ReaperSupervisor::run_until 分开。 停止接纳会唤醒预留等待者,并且只能收紧共享清理期限;它不设置取消状态,也不在控制线程上 运行任务。运行中的循环在截止判断和时钟等待时采用较早的共享期限。存在延迟任务时, 每次时钟等待不超过 HELPER_POLL_INTERVAL,因此等待开始后发布的新期限仍会被观察到, 而不会提前轮询延迟任务。空循环直接返回,不进行轮询。超时 helper 仍被计数,其任务仍被 保留;发出关闭请求本身不能证明资源已清理完毕。

回收单元的任务数与原生句柄数在同一个计数器锁下同时预留,或者都不改变。预留后, 各句柄许可拥有独立的寿命。外层调用开始前,helper grant 要么整组接纳,要么全部拒绝; 安装到任务时不持有计数器锁,并由任务与 worker 克隆跨重试保留。一个 worker 槽位 不能被重复占用。最后一个 grant 克隆归还整组 helper 计数,settlement 不重复归还。 max_helpers 与 live_helpers 表示预留的 helper 容量,不是运行线程数。保留的整组 grant 在最后一个克隆释放前始终占用该容量,包括重试之间或没有 worker 运行时。 启动失败可能析构闭包,因此原生恢复状态必须在闭包之外仍有所有者。默认的 PerCall 任务契约保持不变。

选择完成收集的保留任务,在每轮运行循环顶部、待处理任务重试接纳之前收集。 收集要求整个任务完成且所有 worker 句柄已结束;join 与析构都在保留列表锁释放后执行, 即使展开退出,guard 也会归还任务许可。选择完成收集要求每个单元拥有唯一的传输 owner; 收集会撤回该 owner 的未解决记录。共享 owner 的任务保持默认的不可收集行为。 接纳关闭或迟到的失败仍保留终态托管与报告,直到显式释放。

正常运行期限到达时,选择延续的未启动任务保留任务许可及已创建的阻塞调用,进入独立 carry-over 队列,本次已到期运行不能再次消费它。下一次运行把 carry-over 移到队首。 每次唤醒、定时复查和运行返回后,调用方先收集已完成的保留任务,再检查控制消息,最后 调用 has_startable_work;carry-over 被阻塞时也遵循此顺序。该查询只判断接纳是否 就绪,不自行归还可收集的 grant。它使用与 worker 接纳相同的整组容量条件,先释放 队列锁,再获取计数器锁。接纳关闭后不再允许正常运行就绪;shutdown 清理延续任务, 不再次把它们放回队列。

UnresolvedSink 在独立锁下拥有类型擦除的载荷。接纳判断和 live-task 快照只读取原子 条目数,不在管理器计数器锁或保留任务锁下嵌套获取 sink 锁。终态任务释放可以先把载荷 交给 sink,再归还任务许可,因此未解决托管仍阻止新接纳。报告包含 sink owner、条目数 和仍打开的无槽位取消副本;其中任何一项未清零都不能视为干净关闭。管理器终态释放会 遗忘未完成载荷,使 owner、charge 和原生许可保留至进程退出,不调用不安全的原生清理。 副本 token 只在原生包装关闭对应副本后释放。GUI 账本只允许这一退役传输角色使用 Process → PtyTransport;窗口、窗格和本地 PTY 父节点仍非法。

前台探测映射受存活窗格约束:每个窗格最多一个条目和一个保存的结果,窗格 drop 时其 ProbeRegistration 删除该条目,已删除或身份已变化的窗格的迟到结果不会重建任何条目。最多只有 一个未送达的就绪事件,live_fg_probe_workers 报告该 App 的 worker 线程数(0 或 1)。

渲染正确性不变量

SonicTerm 会跨帧保留已经画好的像素。因此,损伤区域决定画面是否正确,不只是性能优化。

  • 主屏幕窗格贡献绘制其脏实时行的视口行位置条带的并集。实时行 r 对应绝对行 scrollback_len + r,在视图内时画在第 scrollback_len + r - view_top_abs 个行位置; 已滚出视图的脏行不贡献损伤。条带包含窗格内边距,并裁剪到窗格和表面。
  • 如果一帧只改变了窗格修订号,其脏行全部滚出视图,且没有活动覆盖层,两条路径都会 规划为损伤为空的 Noop,且不确认任何脏行。
  • 分类是穷尽的:WindowIdentity::classify 与 PaneIdentity::classify 不使用 .. 解构每个 字段,因此新增字段在被分类之前无法通过编译。光标、焦点、标签栏带、选区和滚动条的变化只 损伤各自的区域。窗格的 revision 与 dirty_generation 是脏行字段,通过其脏行位置造成 损伤;hovered_url_cells 损伤新旧悬停行。其他字段、旧键或新键有活动覆盖层时的任何帧键 变化、降级路径以及首帧都损伤整个表面;备用屏幕窗格上的类别变化损伤该窗格整体。
  • 窄损伤依赖组装与之相交的每个图元,并在裁剪下按顺序绘制。Noop(帧键未变化、只有修订号 变化且脏行全部滚出视图,或帧键变化但损伤为空且没有脏实时行)不组装任何内容;其他帧都是 Partial 或 Full。Full 组装每一行;Partial 组装每个脏行位置、扩边条带与损伤区域相交的 每一行,以及有效墨迹记录与损伤区域、绘制出的光标单元格或最近一次重着色范围相交的每一行, 非行图元完整组装。未组装的行保留其像素和记录。墨迹记录只由已呈现的帧提交,只对其绘制时的绝对行与 内容戳可信;干净的可见行缺少有效记录时该帧为 Full,最终损伤触及被跳过行的记录时在同一次 调用中以 Full 重新组装。此条取代“窄损伤需要 Full 组装”的旧规则。可能超出行条带边距的 墨迹也由记录下的范围损伤:光标重着色的字形 (前后两次记录)和标签标题字形(最近一次呈现的与当前的)。两者只由已呈现的帧保留,非有限 的范围退回到活动窗格或整个表面。
  • 备用屏幕窗格只要有脏行,就贡献整个经表面裁剪的窗格。没有脏行时不贡献损伤区域。
  • 替换完整表面时只清除一次保留 attachment;局部损伤在裁剪范围内使用无混合背景重置。 重置与内容共享一次缓冲上传并使用不同绘制区间,保留内容的 source-over 与 LCD 混合, 同时擦除旧墨迹而不累积 alpha。
  • 投影背景缓存的有效性包含视口行位置。每个 (pane id, absolute row) 只持有一个投影, 哈希变化时替换旧值;脏行失效仍按绝对行且限制在所属窗格,容量上限不变。
  • 内联图像可见性对原始目标、窗格内容和表面求交,同一结果控制图集驻留与绘制。可见 UV 保留源变换,独立的原始图块边界限制 GPU 与 CPU 双线性采样点。图像保留分数像素坐标, 不改变文字字形的对齐。
  • 终端单元格变化会在同一帧标记受影响的行,包括滚动、反向索引、插入或删除行、擦除、 调整大小和宽字符修复。
  • 擦除非空主屏幕历史会推进修订计数和精确淘汰计数,并将所有可见行标记为呈现脏行, 但不改变它们的内容序号。历史为空或备用屏幕中的 ED3 不修改任一屏幕。所有历史前缀 删除都会丢弃起点已删除的提示符,并重定位存活坐标;已保存主屏幕的提示符随所属行保存。
  • 光标位置回复把插入哨兵值钳制到物理列,不消耗延迟换行。LF/VT/FF/IND/NEL 硬换行只在 有效底边滚动,否则钳制在物理边界内;每种控制的填充和回车策略保持显式区分。
  • 每个 Line 会把“由前一行自动软换行而来”的 bit 打包进现有内容序号 word。只有真实的 右边界自动换行会设置它;硬换行、屏幕擦除、滚动、插入或删除行、行复用、不做 reflow 的 resize、row_mut,以及无法证明连续性的区域调整会清除相关边界。与 xterm 和 WezTerm 一样, 只有换行所在行的最后一列承载续行:改变该列的编辑(在该列写入、EL0、EL2、到达该列的 EL1/ECH、ICH、DCH,或在最后一列 cell 上添加组合符号)会撤销下一行的传入 bit,而从第 0 列 擦除或写入续行会保留其自身的 bit。该 bit 会随行进入 scrollback,并参与行相等性与 hash, 因此前驱被淘汰后仍可检测,同时不会增大行 header。
  • 记录换行的 URI 与本地目标会跨这些已记录边界重建最多 32 个可见行和 16 KiB,足以在常见宽度下 容纳 1024 个字符的目标;扫描器仍把每个目标限制在 4 KiB。URI 解析先于 本地目标配置开关,预览、每个高亮片段和激活时的重新查找都使用完整候选;不完整的链不会 回退到单行 URI 前缀。OSC 8 仍具有优先权,file URI 仍需文件系统授权。本地目标授权 key 绑定每个行 fingerprint 与 wrap bit、有序绝对 span、鼠标绝对 cell、viewport、screen epoch、scrollback 淘汰代次和准确 pane 的 OSC 7 状态。硬换行、不完整链、不安全 cell 或任一身份变化都会在 激活时原生重新验证之前 fail closed。主屏幕只连接已记录的换行。在备用屏幕上,若某个窗格段 的最后一列是文字,而下一可见行中窗格边缘相同的段以文字开头,该段也会延续到下一行,除非 这两行之间的已记录换行连接不同窗格;推断到达的行若自身的已记录换行来自视图之外,整次扫描 fail closed;这类推断链承载 URI 和路径,并在 32 行和视图边缘处停止。 到达链停止处窗格边缘的候选会被单独拒绝,指针所在且不含空格的文字到达这样的边缘时整次扫描 被拒绝。恰好填满窗格边缘的无关行可能连接成更长的 URI;按住修饰键悬停时的预览会在激活前 显示完整目标。
  • 完整引号路径的分隔符 cell 仍参与安全检查,但不进入可操作范围。分组源文件位置只为 鼠标指向的成员返回一个候选,验证范围包含完整路径锚点和分组;逗号和空格分隔符不会 触发激活。代码不执行 shell 展开。扫描结果分别携带显示范围和完整来源的字节范围。 scalar 到 cell 的映射把一个宽字符关联到其 lead/continuation 两格,不生成重复字节偏移。 有效宽字符文件名和边界的两格都检查组合字符、超链接与配对完整性。
  • 长于 clickable_path_max_chars 个 Unicode scalar 的文件系统候选会被丢弃;其余候选按显示 候选的 scalar 数从短到长探测,长度相同时起点较早者优先。候选全部不存在的层级会被跳过。 在第一个存在任一候选的层级中,被阻止的候选会以“被阻止”拒绝结束探测;否则最早的可操作 候选胜出。任何候选都不依赖更长字面文件不存在。打开 worker 会对所选层级及更早层级的每个 候选重复这次选择,只有仍得到相同路径和决定时才打开;该层级及更早层级出现新候选时会丢弃 缓存的授权。只有选中的路径会高亮;所有候选都不对应文件的自动检测文字使单击保持为普通 终端单击。
  • HTTP(S) 提取也支持最多 32 个可见硬换行范围内显式 ()/[] 包围的 URL,连接后的 URL 主体上限为 4 KiB。 首片段必须包含完整 authority 和路径斜杠;非末尾片段到达右边界,后续行使用一致且最多 8 个 ASCII 空格的缩进。必须有匹配闭括号,且现有扫描器精确匹配整个 URI。该三态扫描 先于逻辑 URI 回退:归属明确但不完整的片段不可操作,无关文字不受影响;完整目标沿用 相同预览、span 和激活时的重新查找。不安全 cell、内部空白或括号、多个协议及混合换行 类型不能授权连接。OSC 8 仍优先,文件系统目标绝不会使用该路径。
  • 界面浮层或窗口装饰变化会把损伤区域扩大到整个表面。一个 hover 目标最多携带 32 个有序 viewport 片段(MAX_HOVERED_URL_SPANS)进入帧键。OSC 8 片段沿已记录换行连接;在备用屏幕上, 若在同一窗格内,片段之后到窗格右边缘最多只有 2 个空白单元格,且下方片段之前只有最多 8 个 单元格的空白缩进(框线或规则字符不算空白),该片段也会延续到下一行中同一链接的片段。激活打开存储的目标,因此这只 改变下划线。加速路径上仅 hover 变化时,只损伤旧、新目标所在窗格行及其字形 墨迹留白,不扩大到整个窗口;预览与其他窗口装饰变化仍使用完整表面损伤。活动变色只把 相交行的片段列折叠进该行的字形内容键,下划线几何为每个片段发射一个经过裁剪的 quad。事件处理时解析器 繁忙则保留现有提示,但修饰键释放后立即移除仅由修饰键启用的反馈,并请求来源窗口重绘。 主窗口与子窗口在呈现前都从本帧已持锁的解析器快照重新解析 hover。每个窗口最多保留一个当前 epoch 的文件系统 probe 完成结果,并请求一帧用新目标验证它,而不是在解析器争用时丢弃。点击始终要求新的 查找,绝不以保留的视觉状态或尚未验证的完成结果授权。 一个 probe 工作线程按窗口首次排队的顺序服务。每个存活窗口最多保留一个等待中的 probe, 只有该窗口自己的新目标才能原位替换它,因此等待中的工作以每个存活窗口一个 probe 加一个正在执行的 probe 为上限。关闭窗口会丢弃其等待中的 probe;已关闭窗口或过期 epoch 的迟到结果都会被丢弃。 每个窗格的有效滚动条透明度也属于窗口装饰:量化后的窗格身份进入帧键, 桶值变化会损伤整个表面。
  • 已降级的 wgpu 帧只要有工作,就重画整个表面。Windows 降级呈现也会合成完整 CPU 表面。 降级滚动条直接跳变并只设置一个空闲隐藏截止时间;加速滚动条请求有限的淡入淡出帧。
  • 任一呈现器的帧键未变,或降级帧键变化但没有可见工作时,FramePlan 返回 RenderMode::Noop。 该路径不重新组装或清除脏行;设备仍接受工作时,未变化的 Windows CPU 帧仍可再次 blit。
  • Windows 软件字形呈现会在一对一光栅定位前,稳定 NDC 反算在整数与半像素原点附近的误差。 行字形缓存按内容为行建键,并在当前行位置投影与位置无关的记录;软件块字形行只在每个块 保持其光栅尺寸的位置才被接受。
  • 每个由项目生成的 quad 颜色都是有限值的预乘线性 RGBA。不透明度和覆盖率变化必须同时缩放 RGB 与 alpha;debug build 会在 GPU/软件 presenter 分流前立即校验两层 quad。Windows 软件 矩形会解码保留帧中的 sRGB 目标通道,在线性光空间混合相同的源颜色,只对 RGB 编码一次, 并把 alpha 作为线性 UNORM 做 source-over。
  • Windows LCD 文字只有在配置为不透明 backdrop、实际 opacity 为 1,并且使用 CPU/GDI presenter 或 wgpu 设备支持 DUAL_SOURCE_BLENDING 时才生效。实际 off/rgb/bgr 模式 进入帧键。GPU 与 CPU presenter 都在应用覆盖率之前变换前景色,在线性光空间分别衰减目标 RGB,并用最大通道权重计算 alpha。图像和彩色字形拥有更高分支优先级。
  • 显示缩放切换会通过 winit 的事件内 writer 一次提交一个物理 inner size。macOS 观察到的 原生尺寸已使用 AppKit 当前 backing scale,因此从该比例转换,而不是从保存的上一次事件 比例转换。其他平台保留以保存比例解释输入尺寸的契约。目标保持逻辑几何与 30×10 终端下限; Windows 还将其限制在目标显示器 work area 内(window_setup/windows.rs)。 renderer surface、pane grid/PTY、IME 几何与重绘都在原生尺寸提交前跟随同一个目标。

事件循环线程收集完整可见帧时,对 VT 工作线程只做有界等待:忙碌的可见解析器在所有窗格共用的一个 2 毫秒请求截止时间内等待,调度抢占可能超出该截止时间;图像存储只试探、不等待,未取得时仍按原有的 争用重试结算。VisibleFrameSources 先验证树叶编号 唯一且存活、活动窗格身份及缩放一致性,再只为可见布局持有句柄。源向量先声明,解析器保护 对象向量再从它借用。两个窗口角色都采用“解析器 → 可见图像”的顺序;这改变了主窗口原先先 图像后解析器的顺序,并保留子窗口原先先解析器的顺序。每个图像锁在克隆列表后立即释放。 这些快照不是原子的网格/媒体代际:解码后的媒体稍后合并,工作线程的重绘通知和渲染器的 图像身份会修复这一暂态。

任一可见锁不可用时,会先释放所有已取得的解析器保护对象和图像快照,再设置现有每窗口重试 下限。叶子缺失、编号重复或活动/缩放不一致则属于 StructuralInvalid:不呈现部分帧、不调用 渲染器、不设置新的重试期限。窗口标记将 frame_collection 警告限制到完整的持锁帧通过 视口重算之前只发一次;仅捕获布局不会重置。非单元测试的调试构建还会断言该不变量。 关闭中且没有活动标签页时属于静默的 NoLayout。结构异常跳过不写入 last_render 或 retry_not_before。归属窗口调度器消费捕获 的原因并停放全部帧期限,直到拓扑/输入/可见性/恢复原因到来。工作线程 Output 不解除停放, 但命令维护仍运行。警告标记独立于停放,仅在完整持锁帧成功重算后重置。收集器自身不增加 定时器或重绘请求。

只为可见窗格捕获 视口锚点及其公开投影,再通过 reconcile_held_viewports 在持有的解析器 网格上重算。每个窗格和活动帧的视口来自同一结果。保留已经验证的活动索引,不假定布局首项 就是活动窗格。

一个 FramePlan 从捕获的元数据组合帧键、模式、损伤、裁剪和视口行槽。复制模式身份覆盖每个 字段和快速选择提示,但不克隆其文本。计划只保留可见窗格和脏行元数据,不持有隐藏历史或 单元格行。解析器保护对象只覆盖有状态组装:render_releasing 在帧源唯一一次 lend 中组帧, 并在帧源连同保护对象被丢弃之后才呈现,因此 PTY 写入可以落在组帧与呈现之间。渲染器不清除任何 网格脏行。已呈现的帧为每个被确认的窗格返回一个仅含元数据的 AckReceipt:索引、窗格 id、 修订号、脏代次、尺寸代次、屏幕纪元和行。窗口把它们保存为待处理集合。在下一次成功收集时, 视口协调之后、规划之前,只有持有同一解析器且网格的尺寸代次与屏幕纪元仍然一致时,回执才会应用。 它清除回执行中最后一次在回执脏代次之时或之前被标脏的行,并保留组帧后再次被标脏的每一行:每个脏行 都记录设置它的代次。尺寸变化或屏幕切换会重新编号各行,因此这样的回执被丢弃,脏行保留,并计入 dirt_ack_dropped;窗格未被持有或解析器已变化的回执同样如此。每次设置脏位的写入都会推进 dirty_generation,它是帧键的一部分,因此已呈现但未确认的脏行永远不会走未变化帧键的捷径。

渲染器不清除任何网格脏行。只有帧为 Presented 时才发出元数据回执,随后调用 finish_successful_frame:

  • Windows CPU 呈现要等 SetDIBitsToDevice 成功返回;
  • wgpu 呈现要等命令提交并调用 queue.present(frame)。

两条路径还要求该帧的设备仍接受 GPU 工作,规则见下文的 GPU 错误隔离。回执只在窗口下一次 成功收集时清除脏行,且要求持有同一解析器、窗格的尺寸代次和屏幕纪元仍然一致,并且只清除组帧后未再次被标脏的行。

SetDIBitsToDevice 可以报告失败。wgpu 的 present 调用不会返回能够表示后续呈现失败的结果。 表面超时、遮挡、过期、次优或丢失时,代码会使帧键失效,但不确认脏行。带类型的超时重试 由应用控制节奏;后端遮挡抑制帧,只有渲染模式中说明的 Metal 可用性 探测。Result 适配器恢复这两种旧的自重试。过期和次优表面会重新配置,并保留呈现器拥有的重绘。丢失的表面会重新创建并配置;只有设备仍接受工作时,之后的帧才会从中获取纹理。 Validation 结果会停止设备,该帧返回错误。这些表面获取失败都不会清除脏行。 每个出口都报告一个带类型的 PresentOutcome,列表见渲染与字体的“呈现结果”。

网格几何记账包含保留的行分配,不只计算可见的 cols × rows。列数大幅减少时会压紧行。 相邻尺寸变化会保留可复用容量,避免反复分配。降低回滚历史上限会释放多余的 VecDeque 容量。缩小列数时仅检查每行的新右边界,把失去续格的 WIDE 首格替换为 尺寸调整填充;完整字符对和紧凑存储保持不变。同一个 Line 操作覆盖可见行、历史和 已保存主屏幕,不执行 reflow,也不会在以后恢复被裁剪文本。

复制到剪贴板时会保留孤立或不完整的右边框线。只有连贯的多行侧边框,并且最终以右下角 框线字符收尾时,才会删除该边框。在 Windows 上,成功的 OSC 52 写入最多只会延迟重写一次, 且仅当剪贴板回到写入前读取到的同一文本时执行;新的或无法读取的剪贴板所有者不会被覆盖。

rmux/tmux 内的程序会通过 DCS tmux passthrough 发出 OSC 52。可信会话必须同时启用以下 两项;只启用 set-clipboard 不会转发该 wrapper:

set -s set-clipboard on
set -g allow-passthrough on

两项设置都表示信任 pane 输出。任意 DCS passthrough 不进入 SonicTerm 的 OSC 52 解析器; multiplexer 必须先验证并解包。

CAN 和 SUB 会取消当前转义序列。解析器会先重置转义记账,防止被取消的 DCS 或 APC 媒体 序列输出不完整图像。主机取消停滞捕获后,解析器会丢弃剩余负载直到结束边界,不会把它打印 到网格。

GPU 错误隔离

没有被错误作用域捕获的 Validation、OutOfMemory 或 Internal 错误,wgpu 都会交给设备的未捕获 错误处理器。默认处理器会 panic,而 release profile 设置了 panic = "abort",因此一次 GPU 错误 就会连同所有窗口和 shell 结束整个进程。所以 GpuRenderer::new_async 在请求设备的位置安装 SonicTerm 自己的未捕获错误处理器和设备丢失回调。由同一个 GpuSharedContext 创建的所有渲染器 共享该设备唯一的 Arc<DeviceErrorState>:隔离以设备为单位,而不是以窗口为单位。之后的每个 窗口都复用第一个窗口的上下文,因此进程中的所有窗口共享同一设备和同一份错误状态。

flowchart LR
    usable["Usable"] -- "Validation、OutOfMemory 或 Internal 错误" --> unusable["Unusable"]
    usable -- "设备丢失或被销毁" --> lost["Lost"]
    unusable -- "设备丢失或被销毁" --> lost
Loading
  • DeviceState 在同一设备代次内只单向变化:先 Usable,再 Unusable,最后 Lost。每个设备 都有进程内唯一的代次编号。设备丢失(包括有意调用 Device::destroy)只会到达丢失回调,由它 记录 Lost;destroy_requested 是有意销毁前单独设置的标志。
  • 生产代码不压入任何错误作用域,因此这类错误都会到达处理器;wgpu 在引发错误的线程上内联调用 它。处理器和回调不获取任何应用、窗口或渲染器锁,也绝不 panic。
  • 按类别的计数器(validation、out_of_memory、internal、isolated、lost)合并重复错误。 每次状态变化最多通过投递携带其代次的 UserEvent::GpuDeviceGenerationChanged 唤醒应用一次; 已退役代次的回调和事件循环关闭后的投递都会被忽略。

每个发出 GPU 工作的渲染器方法,只在其设备处于 Usable 且没有请求销毁时运行。设备停止后:

  • GpuRenderer::new 和 new_with_shared_context 返回 Err,包括设备在初始表面配置或管线 创建期间停止的情况;
  • try_resize 照常验证尺寸;通过验证的尺寸会被记录并返回 true,但不配置表面;
  • set_software_render_degrade 记录标志,跳过表面配置和 GPU 图集上传重建;
  • set_scale_factor 与 force_rebuild_for_scale 重新计算 CPU 侧字体度量,跳过 GPU 上传重建;
  • allocator_snapshot 返回 None,render_releasing、render_with_outcome 与 render 不做任何工作。

为 LCD 策略读取设备特性不算 GPU 工作。

render_releasing 在组帧期间、紧接空窗格出口之后检查这道闸门,并把已停止的设备报告为 PresentOutcome::RenderingUnavailable,附带设备代次编号和闸门读数。每个渲染器只有第一个这样的 结果携带停止报告:render 把它映射为 Err,应用只记录一次;之后的结果映射为 Ok(())。这些帧 不做任何事,因此脏行保持未确认。只有提交之后设备仍接受 工作,帧才会呈现;只有呈现之后设备仍接受工作,帧才会被确认并推进 successful_frame_count。 提交使设备停止的帧会不经呈现丢弃表面纹理;每个停止的帧都会清空 last_frame_key,其计划保持 未确认。纯函数 decide_frame_outcome 把三次 闸门读数(帧开始前、提交后、呈现后)映射为 DeviceFrameOutcome:NotStarted、 SubmittedNotPresented、PresentedNotAcknowledged 或 Presented。Windows CPU 呈现器遵守 同样的停止规则。它对未变化帧的重新 blit 也经过同一闸门:只在设备仍接受工作时呈现;在它之前或 期间观察到停止时会清空 last_frame_key;它从不推进 successful_frame_count,也不确认计划。

有些 wgpu 路径无论安装什么处理器都是致命的。生产代码从不轮询设备或实例,不使用 render bundle,并且只从成功配置的表面获取纹理。WeztermPipeline 用 create_buffer 创建 uniform 缓冲区,而不使用 wgpu::util 的 create_buffer_init,后者在缓冲区无效时会通过 expect panic。 这些规则避开了固定版本 wgpu 源码中已知的致命路径,但并不证明后端永远不会 panic:测试故障钩子的 有界轮询和 wgpu 自身的析构函数仍保留例外的致命路径。

这个后果是有意的:任一窗口中的一次 wgpu 错误,都会停止所有窗口的渲染,因为这些窗口共享 设备,而无效对象的所有者并不总能被证明。PTY、输入、会话和窗口生命周期照常工作。不会绘制 GPU 提示,最后呈现的像素是否仍然可见由操作系统和驱动决定。收到当前代次的设备状态事件或 兼容事件 GpuDeviceStateChanged 时,应用会为渲染器拒绝工作的每个窗口请求重绘,让每个 渲染器各观察一次停止。恢复适配器先检查已安装渲染器的快照:只有可用、未请求销毁、且设备 代次不同的渲染器,才能清除该窗口的停止状态、解除停放,并最多排队一次可见重绘。仅有 DeviceRecovered 原因不能清除已停止代次;重复恢复事件不会再次请求帧。主窗口的设备停止 期间,预热池不创建 渲染器;在已停止的共享设备上创建渲染器会立即失败,因此不会出现创建重试或重复记录日志的循环。 拆出到设备已停止的预热备用窗口时,会在取出备用窗口之前拒绝:备用窗口留在池中,源窗口保留它的 标签页,拒绝只记录一次日志,与无法创建渲染器的新目标窗口相同。目标窗口在调整尺寸期间设备停止时 会被丢弃,源窗口随之恢复。提交还会在原生拖放目标注册之后、转移窗格所有权或显示窗口之前再次 检查设备;若设备停止,则撤销该注册、丢弃隐藏的目标窗口,并恢复源窗口及其最后一个标签页。 记录设备丢失后会开始共享上下文的有界恢复;没有丢失记录的不可用设备保持停止。全部可见和预热 渲染器在同一次事件循环回调中重新绑定,部分提交的候选设备会在继续分发前停止。脏修订号只有在真正 呈现后才被确认。重试、释放和稳定性限制见渲染模式。无法创建设备时启动 仍会失败,CPU 图集仍是唯一可信来源。

#[doc(hidden)] GpuRenderer::__inject_gpu_fault(GpuFaultKind) 编译进每个构建,因此测试可以在 任何构建中触发每种故障:

故障类型 效果
IsolatedOperation 在显式 Validation 错误作用域内以空 usage 调用 create_buffer;记录为隔离故障,设备保持 Usable,之后的帧仍会呈现
RetainedResourceCreation 让下一次字形上传重建创建无效纹理
FrameValidation 在之后的每一帧中记录一条无效命令
DestroyDevice 设置 destroy_requested,调用 Device::destroy,再以 5 秒有界等待轮询,使丢失回调运行

发行二进制运行时 smoke 的默认与 frame-validation 场景会禁用恢复,以验证已停止设备上的 故障隔离与 PTY 存活。显式 device-recovery 场景则在两个可见窗口和一个预热渲染器存在时销毁 共享设备,要求恰好一次替换,由替换代次从两个原有 PTY 各呈现可见视口中的新标记,再通过事件循环 队列重放旧代次唤醒,并在最终释放时复查已提交代次和请求次数。它释放子窗口,并通过生产维护路径缩小 预热池。恢复验证失败使用退出码 19,启动失败码不变。24 s 阶段期限从首次呈现后开始,而 30 s 进程 watchdog 包含启动时间,可能更早到期。日志区分 phase-deadline 与 watchdog,并标识 当前阶段。这些是原生 API 观察,不是像素或扫描输出证明。

在本地 macOS 硬件上运行时,通过规范包装器显式选择场景;继承的场景环境变量会被有意移除:

WGPU_BACKEND=metal python3 scripts/native-smoke-runner.py \
  --timeout-seconds 45 --scenario device-recovery \
  --state-dir /tmp/sonicterm-recovery-state \
  --log-file /tmp/sonicterm-recovery.log \
  -- target/release/sonicterm-mac --runtime-smoke

每次运行都使用新的临时路径,并使用对应确切源码构建的二进制。包装器保留 HOME,移除继承的 NO_COLOR,并返回该场景的退出码。本地 Metal 证据不能替代其它平台的原生验收;此命令也不表示 CI 已执行该场景。

保留资源和销毁阶段要求故障主窗口及其设备代次产生新的已停止 RedrawRequested 拒绝,状态 分别必须为 Unusable/未请求销毁或 Lost/已请求销毁。仅 smoke 使用的观察计数器在每次注入前 冻结,并在已停止边界递增;渲染器的一次性错误已经报告后仍会观察。该观察不算渲染尝试。 两阶段仍要求新的 shell 标记、呈现/成功帧总数保持不变、250 ms 静默间隔,以及原有 5 秒阶段 期限;销毁还要求设备丢失记录。FrameValidation 仍要求实际调用渲染器,单靠拒绝观察不能满足。 有界的 25 ms smoke 探测不会增加普通会话心跳,也不会在已停止设备上组装帧。

该钩子中的隔离作用域和有界轮询,是测试之外唯一的错误作用域和唯一的设备轮询。在 Windows 上,文档隐藏的 GpuRenderer::__stop_device_before_cached_present 会在下一次缓存的 CPU 重新 blit 之前停止设备; 这是生产 GPU 调用都到不了的唯一检查点,因此原生测试可以借它固定这道闸门。

图集与字体不变量

CPU 字形图集按翻倍增长到最大 2,048 × 2,048 个 BGRA8 像素,约 16 MiB。元数据最多保存 MAX_ATLAS_ENTRIES = 16,384 个条目,包括空白和缺失哨兵。增长会原地复制常驻像素、重新计算 UV 并 推进图集身份,不会重新光栅化。GlyphAtlas::grow_to 只接受增长策略的下一次翻倍:固定图集、已达 最大尺寸的图集以及其它任何尺寸都会被拒绝,且不改变图集。只有字形图集会增长,重置仍是清除它的唯一路径。

未命中时,图集先使用回收矩形,再使用分层打包器。元数据或打包空间不足时,可增长且小于 2,048 的图集先增长;达到 2,048 或条目上限时按确定规则淘汰最冷的四分之一。 帧和预编辑缓存以设备代次、渲染器持有的分配代次、图集局部内容身份以及增长次数组成的精确元组判断有效性, 不使用可归零的淘汰计数。组帧期间身份戳变化时,渲染器会在呈现前放弃该帧,并请求一次重试,不确认网格。 仅由增长引起的变化(同一设备、同一分配、无淘汰、增长次数增加)保留图集:重试会丢弃携带 UV 的缓存, 按新尺寸重建纹理,淘汰保持开启。其它任何变化,包括同一次组帧中既增长又淘汰、重置或替换,都会就地 重置图集、使携带 UV 的缓存失效,并关闭淘汰直到成功呈现一帧。该次呈现重新开启淘汰,并保留恢复帧已接纳的行:只有完整的行才进入行字形缓存, 之后任何淘汰都会改变这些行所校验的身份。只丢弃预编辑输出(PreeditGlyphCache),因为界面文本(chrome)布局会保留其字形被重试拒绝的文本段。诊断淘汰字段仍保存实际计数,重置或替换使用独立原因。 字形纹理在帧源释放解析器保护对象之后、任何呈现之前调整尺寸,因此增长后的图集不会同步进更小的纹理, 重建纹理也不会阻塞 PTY 解析。

每个让图集增长的帧开始一个增长片段。帧计数器在每次帧末检查时计入 glyph_atlas_growths,下一次成功 呈现记录该片段的 atlas_growth_to_present 时间。再也没有帧能呈现的片段计入 atlas_growth_abandoned: 设备停止时(包括 App 不渲染任何内容的设备已停止路径)、重新绑定替换设备之前,以及 App 为窗口退役或 退出结算统计时。该结算是幂等的,Drop 会作为后备再执行一次,此时不再增加任何计数。就地重置保留设备, 因此不放弃任何片段。

图像图集从 1 × 1 占位符开始,提升为固定的 2,048 × 2,048 分配;提升后不再增长。 core 的私有子模块 atlas_lifecycle 负责这些转换和既有上传门禁。 FrameBatches 只组织对自有批次的借用切片;网格和解析器保护对象只留在组帧阶段,并在呈现前释放,确认在窗口下一次收集时进行。

RowGlyphCache 为每个 (窗格编号, 内容键) 保存一个条目,用图集内容身份验证;软件块字形行 还要验证每个块在当前位置的尺寸。只接纳完整的行。每次组装先调用一次 begin_frame,释放未被 绘制和尺寸改变的窗格;每个窗格在第一次接纳前固定其已提交的行位置键和本次将发出的每一行, 暂存每个发出行位置的键,并且只有帧呈现时才经结算接缝提交暂存。每窗格配额(4 × rows 个 条目、4 × rows × cols 个单元格的负载)按最久未用顺序淘汰未固定的行;渲染器级的 448 MiB 负载预算和 64 MiB 跟踪预算会拒绝接纳或让窗格不被跟踪,因此其保留量从不超过 512 MiB 包络。 脏行不会删除字形行。LineQuadCache 为每个 (窗格编号, 绝对行号) 保存一个条目,用行哈希验证, 容量约为可见行总数的四倍;每个组装的帧(Full 或 Partial)都会删除表面上每个窗格的每个 脏实时行 r 对应的绝对行 scrollback_len + r,无论该行是否在屏幕上,每次删除都是一次按键删除, 容量已满时清空。字体、主题、缩放、表面尺寸和图集替换会使两个缓存失效。保留量会计算已分配的 表 bucket、行位置与固定向量,以及每个嵌套向量的容量。普通清空会保留可复用表容量,因此有界 变化形成高水位包络,而不是完全平坦的字节线。窗格离开渲染器时,一个由事件循环独占的操作会先 删除其字形缓存条目和行位置,再删除 quad 缓存条目,保留其它窗格的命中,随后请求压紧两个表。 并发保留量快照无法观察到只完成一半的有序淘汰。

行或界面文字布局本应绘制却无法绘制的每个字形都会被报告。在终端行中,带有真实字形编号的整形字形,若被图集 拒绝、光栅化结果为空(缺失哨兵),或其图块大于图集所能放置的最大尺寸(标记为 GlyphInfo::oversize 的零面积哨兵),就会被列入该帧的缺失字符(last_missing_tofu()),且其所在行不进入缓存。 字符回退字形(ASCII 快速路径与字形编号为 0 的整形字形)在被图集拒绝或被缓存为缺失时绘制为 tofu 并列为 缺失;其超尺寸哨兵则像空字形一样被跳过,不记录任何内容,所在行保持完整。界面文字布局把超尺寸字形记为 缺失的界面文字,与缺失字形相同。标签标题只按 最终绘制结果报告:整段文本从未完成整形的标题在标题绘制边界报告一次;适配时未能整形的截断文本会在绘制时 再次整形,只有该次重试失败时才由它报告;已绘制的标题(包括省略号测量失败的标题)以及所有缓存保留的标题都 不报告。有意为空的字形(例如空格)不记录任何内容,其所在行保持完整。字形工作集辅助工具把超尺寸的必需图块列入 oversize_required,与 raster_failed 和 unresolved_chars 并列;三者都计为不完整的必需字形。

内联图像图集从 1 × 1 的 CPU/GPU 占位符开始。出现可渲染媒体时,它扩展为 2,048 × 2,048。连续 240 个已渲染帧没有内联媒体后,它回到占位符。可渲染媒体最后一次可见 30 秒后,它也会在不组装帧的情况下 回到占位符:可见且空闲的窗口收集 DueCause::ImageAtlasRelease,处理时再次确认窗口仍空闲(没有延后的帧、没有在途 请求、没有未结算的原因)。设备已停止时只释放 CPU 图集,恢复时按它确定 GPU 镜像的尺寸。文字和图像使用独立 图集,因此图像压力不会淘汰文字字形。

Windows 降级呈现会保留完整 CPU 图集,同时把 GPU 图集纹理缩成 1 × 1 占位符。回到 wgpu 呈现时,代码重新创建匹配纹理、重置图集状态、使所有携带 UV 的缓存失效,并在采样新纹理前 强制完整重绘。

所有单色样式及回退字形都先选定字体,再统一调节粗细。固定字号和 DPI 时只改变覆盖率: 单元格间距、基线、位图边界、bearing 与推进量保持不变;彩色图像不参与调节。Windows 禁用 DirectWrite 网格拟合,并把支持彩色的字体交给 FreeType。

DirectWrite 次像素图块在 CPU 图集与 GPU unorm 覆盖率 view 中始终保留原生线性 BGRA 覆盖率;显式 weight_scale 控制之前不再存在隐藏的对比度曲线。它们绝不能经过彩色矩形转换或 sRGB 彩色 view。Alpha 保持为 RGB 覆盖率最大值,因此不满足 条件以及 off 呈现都有确定的灰度值。修改 LCD 模式只影响呈现,不得重建或重新解释任一图集。

字体发现、塑形和光栅化与渲染器策略分离。生成的 FFI 绑定只留在各自包装 crate 内。 可变字体元数据格式错误、缺失或越界时,代码回退到基础 OS/2 字重和字宽。FreeType 内嵌 位图字形会在解码像素前先检查 2,048 像素和 16 MiB 的字形分配上限。BGRA 裁剪边界采用 半开区间,保留全部非透明像素;移除 (crop_x, crop_y) 边距后,bearing 分别变为 bitmap_left + crop_x 与 bitmap_top - crop_y。全透明但非空的光栅保留尺寸、度量及 有效空白图集表示。

崩溃历史采用所选 filter、DEBUG 上限与显式负载排除规则的交集,并检查 log facade 的原始 目标。自有可变保留量受 50 条记录、每条 4 KiB(其中 target 最多 256 字节)及合计 64 KiB 限制。格式化在这些边界内停止;panic 负载与摘要各有独立的 4 KiB 上限。 固定元数据另受记录条数限制。Backtrace 与任意生产端 formatter 内的分配不在这些保证内; 这不是通用敏感信息清洗。

隐藏预热窗口池默认为 1。设为 0 会关闭它。普通硬件路径最多接受 5。真实软件适配器或最终 降级状态启用时,任何非零目标都限制为 1。实时配置重载会清空池;后续 about_to_wait 每次最多重建一个条目。

PTY 与原生线程不变量

终端输入采用非阻塞入队。PtyHandle::send_input_nonblocking 对四条消息的通道调用 try_send。消息超过 16 MiB、队列已满或 writer 已断开时,会返回保留原始字节的 PtyInputError。应用丢弃负载后,发送只含元数据的 UserEvent::PtyInputRejected, 记录窗格、当前窗口、类型化来源及并发队列/writer 观察值;窗格仍存在时在其窗口显示通知。 它不会自动重放被拒绝的离散输入。入队前,指针移动在窗格固定 64 字节槽内合并;队列满时保留 最新位置,通过不重绘的唤醒重试。后续离散输入在总长度允许时合并该位置为一次准入,否则取代 该位置。鼠标模式变化会使待发槽失效;模式暂不可用时延迟仅移动的重试,离散输入则取代 尚未验证的移动。队列占用不包含正在进行的原生写入或 flush;其阶段、大小、 持续时间和进度会单独观察。

指针处理不持有解析器锁即可读取窗格的鼠标模式。每次解析后,VT worker 把 Parser::pointer_input_snapshot 存入窗格的 pointer_input 字节(bit 0-1 为跟踪模式,bit 2 为 SGR,bit 3 为备用屏幕,bit 4 为应用光标);窗格构建和解析器测试 hook 也会写入它。一次 Relaxed 存储保证五个位相互一致。不需要新鲜度上界,因为指针事件与模式变化本来就无序: 解析进行中的移动按上一次完成的解析的模式路由。主窗口和子窗口的移动、滚轮与按下路由只读取 该字节。解析器锁只保留在子窗口滚动条拖动的视口基线、LocalScrollback 滚轮滚动和网格尺寸 调整上。

每个窗格有一个 output_outstanding 令牌,即与其 VT 工作线程共享的 Arc<AtomicBool>,随窗格 跨窗口转移。有目标的 flush 以 AcqRel 把它交换为 true,只有原值为假时才发送 UserEvent::PaneOutput;事件循环拒收的发送以 Release 存回 false。事件循环在当前持有该窗格的 窗口中查找它(窗格编号从不复用,因此已退役窗格的迟到事件找不到窗格,不做任何事),在任何检查 之前以 AcqRel 把令牌交换为 false,然后才读取输出代际。双方都用读-改-写修改令牌,因此无论 谁先发生,服务要么读到被抑制批次的代际,要么后来的 flush 发送新的事件。每个存活窗格最多排队 一个 PaneOutput。

PTY 子进程的身份是 pid 加启动令牌,在 spawn 时、子进程尚未被回收期间捕获一次:macOS 使用 pidinfo 的启动时间,Windows 对保留的子进程句柄调用 GetProcessTimes;其它平台不捕获。在 任何可能释放该身份的操作之前,都会先发布 exit_observed(Release)。has_exited 用不消费 状态的 waitid(WNOWAIT) 窥探,子进程仍在运行时不等待直接返回;有待处理的退出时,先发布再 执行消费状态的 try_wait。退出探测在向进程组发信号前发布;终止、kill、into_teardown 和 drop 在入口处发布。窥探永远不会把存活的子进程标记为已退出。

前台进程在事件循环线程之外采样。帧和 Windows 定时器只设置需求并读取每个窗格的缓存。每个 App 有一个 sonicterm-fg-probe 线程,在首次需求时启动,为需要采样的窗格采样,并在共享映射中 为每个窗格保存最新结果。macOS 上它在遍历前后重新读取每个 pid 的启动令牌;Windows 上一次快照 服务整批。事件循环在收到 ForegroundProbeReady 时清空映射,只接受仍存活、以相同启动令牌注册 且其子进程尚未发布退出的窗格的结果。spawn 失败或 worker 死亡时进入一次 Unavailable 并只警告 一次;此后需求同步解析为没有进程。App drop 时设置 shutdown、清空映射并丢弃唤醒发送端,因此 已缓冲的唤醒不会探测就退出。

PTY 尺寸调整是可失败的,且只在成功时缓存。回调把原生调用和最后一次成功应用的 (cols, rows) 放在同一把锁后面,因此原生尺寸调整是串行的,缓存记录的是最后一次成功 的原生调用。某一维为零时,在原生调用之前、也在缓存变化之前以 InvalidInput 错误 拒绝;与最后一次已应用尺寸相同的请求会被跳过;只有原生调用成功才会缓存尺寸。因此 失败的请求不会被当作重复请求去重——下一次相同的请求会再次到达原生调用——而最后一次 成功的尺寸仍保留在缓存中。第一次请求一定会到达原生调用,因为缓存初始为空,不会用 spawn 时的尺寸预填。

宿主先调用 Parser::resize:应用网格尺寸上限,实际行数或列数变化时清除解析器自有的 滚动边距,但不把光标移到起点。相同尺寸的布局刷新保留局部边距。原生调用失败时网格 绝不回滚:窗格保留请求的几何尺寸,只有子进程看到的尺寸会滞后。PaneState::resize_pty 在每一轮连续失败中只报告一次——第一次失败会记录 窗格 id、请求的列数与行数以及错误,随后保持静默,直到一次成功清除该闩锁。闩锁只控制 日志行。告警抑制绝不会抑制一次尺寸调整尝试;无效尺寸和成功的重复请求由 IO 边界决定, 与闩锁无关。

PTY reader 使用可复用的 64 KiB BytesMut 分配,并通过 64 槽通道发送 PtyOutputChunk 视图。通道满时 reader 阻塞,让操作系统施加背压;输出不会被丢弃。

每窗格 VT 工作线程只在推进 VT 状态和快照解析器所拥有的模式时持有该窗格的解析器锁。 它会把自有的 VT 事件移出该锁保护区,然后在解锁后处理剪贴板和命令事件、解码或缩放内联 媒体,并更新常驻存储。主窗口和子窗口中创建的窗格使用同一个宿主事件处理器。拆出操作只 修改共享的重绘 WindowId,因此工作线程可以跟随窗格,无需持有 Arc<Window>。

开启帧计数器时,重绘会测量它持有解析器守卫的时间:一个保管令牌从一次收集获取的第一个守卫开始, 在被丢弃时记录,而每个退出路径(包括展开)都在释放最后一个守卫后立即丢弃它:重绘把收集到的帧保持为一个值,其守卫先于计时被丢弃。在取得守卫后失败的收集也会被测量。 同一时刻开始的分派时钟在渲染器调用返回时结束;对于从未到达渲染器的收集,则在其释放时结束,并计入单独的总体。 工作线程把每个分段的锁等待以纳秒总量记录在其直方图旁边。关闭计数器时两个令牌都不会创建,剩下的是 Option 与门控检查以及对空令牌的处理,不读取时钟,也不分配内存。 这些是按阶段的总量,不会把工作线程的等待归到某一特定帧。

开启计数器时,App 还在 guard_correlation.rs 中保存原始的守卫关联记录,供离线连接使用。 每个窗格的 VT 工作线程在读取 before_lock 之前、在解析器守卫作用域之外,把分段登记到该窗格的日志, 并用分段计数器自己的 before_lock 与 locked_at 读数发布它。展开的分段在其解析器守卫被丢弃之后才被放弃, 因此持有解析器守卫时从不获取日志互斥锁。每次计数收集在第一次获取解析器之前签发收集标识, 并在获取每个守卫时记下它。保管的唯一一次释放读数同时结束保管总量和该收集的每个跨度; 超过 16 个守卫时,其余的由一个包络覆盖。每个缓冲区都在记录之前、并在每次提取之前再次分配, 因此记录从不分配内存。放不下的记录会被计数,可定位的还会加入一个丢失区间,任何内容都不会被覆盖。 分段、收集和提取序列止于 u64::MAX,拒绝不会签发标识。所有时间都从一个进程级时钟纪元起算, 该纪元在首次使用时设定;无法换算的时间计为未定位。App::take_guard_correlation_v1 在各自的互斥锁下 交换每个日志,从不获取解析器锁。没有计数器时它返回 GateOff,并在已关闭的日志被取空且没有待定分段后将其剪除。 App 本身不做任何关联。

PtyHandle::into_teardown 发布关闭状态并转移自有载荷,不执行原生等待。有预留的窗格 交给 App 唯一的回收驱动线程;无槽位退役重试接纳一次,再明确采用同步回退。活跃窗格 转移时,预留随 PTY 一起移动。直接析构测试夹具仍采用有界内联清理,不使用 App 管理器。

每轮都先发出取消信号,在平台支持时取消同步 I/O,然后终止子进程。Unix 使用 waitid(P_PID, ..., WEXITED | WNOHANG | WNOWAIT) 观察自然退出而不释放会话编号。 拆除终止原进程组,反复终止同会话中的活动成员,关闭主端,等待读写线程,最后回收主进程。 终止失败时禁止回收,保留会话身份。子进程锁、主端锁和 IO join 都采用有界等待;一个阶段 超时不会跳过后续阶段。

Windows 通过 grant worker 取消 reader 和 writer,两者共用 500 ms 期限,然后给每个 IO 线程最多 500 ms。sonic-conpty-drain 上的克隆 reader 与 sonic-conpty-close 并行排空和关闭,各有 2 s 等待预算。未结束的句柄继续被持有,之后再 join。原生资源在 worker 启动前留在共享恢复槽中,因此启动拒绝不会丢掉载荷或许可。排空启动被拒绝时绝不 析构尚未排空的主端。无槽位取消使用有界回退入口,并持有不透明计数 token,直到实际 副本关闭。原生终止检查同一个保留的进程句柄,绝不按数字 PID 重新打开。

完成状态只对成功阶段计数一次,以 Release/Acquire 发布最终标志,并发送有界唤醒提示。 观察者单独检查线程确实退出,不等待载荷锁。只有整体完成且 worker 已 join 才释放托管; 通道断开和中间阶段成功都不能代替整体完成。明确阶段失败返回 Failed,等待到期返回 TimedOut,两者都不释放记账。最终仍未完成的托管整体转入 UnresolvedSink。 PTY_TEARDOWN_TAIL_BOUND 来自各阶段配置的等待预算,不证明已分离外部客户端退出。 Windows 对仍附着客户端的清理不承诺强制终止已分离后代或拒绝关闭通知的自定义客户端。

只有一个驱动线程执行正常运行与关闭运行。每次唤醒、定时复查和运行返回后,它收集已完成 的保留托管并复查控制和就绪状态。已完成但尚未 join 的托管每 50 ms 复查;空闲或只有 sink 托管时不轮询。App::finish_session 在关闭接纳前退役所有窗口(包括隐藏主窗口) 的窗格,并缓存最终结果。共享 shell 返回 ShellRunResult,分别保留原运行结果与拆除 结算状态。只有报告干净,且没有 sink 条目、打开的回退副本或分离的驱动线程,才允许写入 干净会话标记。

拆除过程不会新增任何合成输入:销毁 writer 不会向子进程输入流写入任何内容。普通终端输入 和解析器生成的应答不受影响,因为应答是终端自身产生的合法输入。这是 writer 构建的 不变量,而非拆除顺序的不变量:writer 的析构本身就是子进程输入流的一部分,因此由 pty_writer 按平台一次性决定,而不是在 spawn 处决定。Unix 通过主端描述符的 F_DUPFD_CLOEXEC 副本写入,关闭该副本是静默的。该副本与主端共享同一个打开文件描述, 因此 O_NONBLOCK 等文件状态标志保持共享;而 FD_CLOEXEC 是每个描述符独立的标志,只 设置在该副本上。副本的生命期是独立的:主端释放后它仍能继续投递。Unix 主端若不提供 描述符,则返回 Unsupported 错误;不存在回退到 portable-pty Unix writer 的路径—— 只要线路规程报告了 VEOF,那个 writer 在析构时就会写入一个换行和该 VEOF。Windows 仍使用该 crate 的 writer,其 ConPTY 析构不写入任何内容。

验证边界是真实 PTY 的子进程一侧。pty_tests.rs 打开一对 PTY,将线路规程设为原始模式并 显式设置非零 VEOF,并通过主端断言该前置条件——即上游析构实际读取的那个描述符—— 因为 VEOF 为零会抑制那次合成写入,使后续所有断言变得空洞。writer 线程结束的四种方式 ——取消、输入通道断开、原生写入失败、原生 flush 失败——都先消费掉已输入的字节,再要求 该线程自行退出后才 join,最后要求子进程一侧的字节流为空。要求证明退出,是因为关闭等待 超时后会保留 writer 的原生托管,因此字节流为空本身不能证明析构已经运行。写入与 flush 失败 由一个持有真实生产 writer 的测试专用包装注入,因此生产析构仍会运行;子进程一侧从不 关闭,所以结果为空表示"没有发送任何字节",而不是"读不到字节"。另有测试断言该副本为 close-on-exec 且比其主端存活更久;断言显式 Ctrl+D 仍能结束规范模式 shell,其就绪状态 由一个只有真正执行才能产生的标记证明;并断言释放句柄会在有界时间内回收 shell leader 及其会话中的后台后代。"不回退"这一契约另有专门测试:一个不提供描述符的 master 替身必须 让 pty_writer 返回 Unsupported 错误,且该替身的 take_writer 从未被调用。真实 PTY 总会提供描述符,因此没有其他测试会走到该分支;而一次静默回退会在其他测试全部通过的情况下 让缺陷复原。这些测试仅在 Unix 上编译,在 Windows 上不生成任何代码。

发布验证边界

根目录 Cargo.toml 的 [workspace.package] 是版本来源。发布工作流只由匹配 v[0-9]+.[0-9]+.[0-9]+* 的 tag 启动。它首先把 tag ref 解引用到对应 commit,要求该 commit 位于 origin/main 历史中,并要求它存在一个完全相同、已完成且成功的 CI push run。随后 prepare-release-assets.py check-version 必须把 tag 解析为语义版本,确认该版本与每个 workspace package 一致,并要求普通源码一致性 gate 通过。

工作流构建五组必需包:

平台 架构 包
macOS aarch64 .dmg
macOS x86_64 .dmg
Windows x86_64 .msi
Linux x86_64 .deb
Linux x86_64 .tar.gz

每个包都有类型化 JSON 片段。汇总步骤要求五组全部存在,拒绝重复文件名或重复元组,重新 计算哈希,并拒绝 dist 中未登记的 .dmg、.msi、.deb 和 .tar.gz 文件。它生成 release-assets.json、确定性 SHA256SUMS.txt 和 release-upload-paths.txt。发布 action 只上传该路径清单中的文件。

Windows CI tests shard 运行:

cargo test -p sonicterm-gpu --test windows_warp_allocator_baseline -- --nocapture

该闸门要求 WARP 和分配器报告可用。生产策略预留字节必须低于 64 MiB,最大块必须低于 128 MiB。在相同分配负载下,MemoryHints::MemoryUsage 候选必须比 MemoryHints::Performance 对照预留更少字节。Release 要求 tag commit 存在完全相同且成功的 main CI run,之后才启动 build-windows,因此失败会阻止 MSI 和发布,且无需在 tag 阶段重跑。

Linux 包验证会构建 .deb 与 .tar.gz 两种布局。运行冒烟测试在 X11/Xvfb 和 Wayland/Weston 上使用 Vulkan/lavapipe。与 macOS 和 Windows 二进制 smoke 相同,它要求 原生窗口与渲染器/设备、实时 grid 中观察到的平台 shell PTY marker、之后的原生呈现,以及 默认预热渲染器的创建/报告/采用/释放并恢复进程渲染器计数。

macOS 打包会检查二进制架构和应用的 ad-hoc 签名。每个刚构建的 macOS 架构以及刚构建的 Windows 可执行文件都必须先通过共享原生运行 smoke,打包才能继续。每个 macOS 架构的包验证 会挂载 DMG,将其中的应用复制到安装布局,并在禁止访问 Homebrew 的条件下运行该可执行文件的 runtime smoke。工作流不会执行 Developer ID 签名、公证、MSI 签名或 MSI 安装后运行;这些仍在 已验证的发布边界之外。

发布验证要求 tag commit 存在完全相同、已完成且成功的 main CI run,随后核对每个 workspace package 版本并验证 release asset 工具。它不会重复源码、unit、integration、文档、 平台 runtime、allocator、coverage 或普通 CI 的 package gate。各平台 job 从该 provenance 边界直接开始,只执行原生 release build、原生二进制 smoke、package 验证、MSI metadata 验证,以及安装布局中的 DMG 应用和 Linux package smoke。Release Rust target build 不读写 Rust cache;Windows job 可以恢复由普通 CI 立即发布的 vcpkg binary cache。

源码与检查索引

契约 主要源码或检查
真实堆内存测试 crates/sonicterm-{grid,io,vt,app,resource,text}/tests/
资源清单与基线 scripts/test-resource-inventory.sh、scripts/test-resource-baseline-evidence.sh
损伤区域与呈现完成 crates/sonicterm-gpu/src/core.rs
GPU 故障 smoke 阶段 crates/sonicterm-app/src/app/runtime_smoke.rs、crates/sonicterm-app/src/app/event_loop.rs、crates/sonicterm-app/src/app/window_event.rs、scripts/native-smoke-runner.py
GPU 错误隔离 crates/sonicterm-gpu/src/{device_errors,core,present,wezterm_pipeline}.rs
字形图集与行缓存 crates/sonicterm-text/src/{glyph_atlas,row_glyph_cache}.rs、crates/sonicterm-gpu/src/row_quad_cache.rs
PTY 拆除 crates/sonicterm-io/src/pty.rs
不持解析器锁的指针模式 crates/sonicterm-vt/src/vt.rs(PointerModes)、crates/sonicterm-app/src/app/{pane_state,spawn_pane,window_pointer,child_window_pointer}.rs
退出发布与进程身份 crates/sonicterm-io/src/pty.rs、crates/sonicterm-io/src/pty_tests.rs
前台探测 worker 与结果映射 crates/sonicterm-app/src/app/fg_probe.rs、crates/sonicterm-app/src/app/fg_probe_tests.rs
不产生整窗口脏行的指针手势 crates/sonicterm-app/src/app/window_state.rs(TopologyDirt)、crates/sonicterm-app/src/app/window_state_tests.rs
所有者与计费顺序 crates/sonicterm-app/src/app/{mod,owners,window_state,retention}.rs
发布资产契约 scripts/prepare-release-assets.py、scripts/test-release-assets.sh
发布任务依赖图 .github/workflows/release.yml
⚠️ **GitHub.com Fallback** ⚠️