feat!: replace the custom graphics api with optimized native kitty rendering

This commit is contained in:
Ogulcan Celik
2026-09-23 23:32:54 +03:00
parent 21fd121a63
commit cbc2eba731
73 changed files with 8839 additions and 6205 deletions
-254
View File
@@ -2598,110 +2598,6 @@
],
"type": "object"
},
"PaneGraphicsClearParams": {
"properties": {
"layer_id": {
"type": [
"string",
"null"
]
},
"pane_id": {
"type": "string"
}
},
"required": [
"pane_id"
],
"type": "object"
},
"PaneGraphicsFormat": {
"enum": [
"png",
"rgb",
"rgba",
"bgra"
],
"type": "string"
},
"PaneGraphicsPlacementParams": {
"properties": {
"grid_cols": {
"default": 0,
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"grid_rows": {
"default": 0,
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"viewport_col": {
"default": 0,
"format": "int32",
"type": "integer"
},
"viewport_row": {
"default": 0,
"format": "int32",
"type": "integer"
}
},
"type": "object"
},
"PaneGraphicsSetParams": {
"properties": {
"data_base64": {
"default": "",
"type": "string"
},
"format": {
"$ref": "#/schemas/request/$defs/PaneGraphicsFormat"
},
"image_height": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"image_width": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"layer_id": {
"type": [
"string",
"null"
]
},
"pane_id": {
"type": "string"
},
"placement": {
"$ref": "#/schemas/request/$defs/PaneGraphicsPlacementParams",
"default": {
"grid_cols": 0,
"grid_rows": 0,
"viewport_col": 0,
"viewport_row": 0
}
},
"z_index": {
"default": 0,
"format": "int32",
"type": "integer"
}
},
"required": [
"pane_id",
"format",
"image_width",
"image_height"
],
"type": "object"
},
"PaneInputSetParams": {
"properties": {
"pane_id": {
@@ -6013,54 +5909,6 @@
],
"type": "object"
},
{
"properties": {
"method": {
"const": "pane.graphics.set",
"type": "string"
},
"params": {
"$ref": "#/schemas/request/$defs/PaneGraphicsSetParams"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
"const": "pane.graphics.clear",
"type": "string"
},
"params": {
"$ref": "#/schemas/request/$defs/PaneGraphicsClearParams"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
"const": "pane.graphics.info",
"type": "string"
},
"params": {
"$ref": "#/schemas/request/$defs/PaneTarget"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
@@ -10027,108 +9875,6 @@
],
"type": "object"
},
{
"properties": {
"revision": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"sequence": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"type": {
"const": "pane_graphics_frame_ack",
"type": "string"
}
},
"required": [
"type",
"sequence",
"revision"
],
"type": "object"
},
{
"properties": {
"cell_height_px": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"cell_width_px": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"file_frame_damage": {
"default": false,
"description": "Accepts damage metadata while still consuming a complete canonical file.",
"type": "boolean"
},
"file_frame_direct_max_bytes": {
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"file_frame_directory": {
"type": [
"string",
"null"
]
},
"file_frame_formats": {
"items": {
"type": "string"
},
"type": "array"
},
"file_frame_max_bytes": {
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"file_frame_transport": {
"type": [
"string",
"null"
]
},
"max_layers_per_pane": {
"default": 0,
"format": "uint",
"minimum": 0,
"type": "integer"
},
"pane_visible": {
"description": "True only when this pane is on the currently rendered terminal surface.",
"type": "boolean"
},
"pixel_mouse": {
"default": false,
"type": "boolean"
},
"type": {
"const": "pane_graphics_info",
"type": "string"
}
},
"required": [
"type",
"cell_width_px",
"cell_height_px",
"pane_visible"
],
"type": "object"
},
{
"properties": {
"explain": true,
@@ -512,9 +512,19 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne
## Kitty graphics
Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds.
Applications and plugins integrate through the standard Kitty graphics protocol emitted in pane terminal output. Herdr renders these images by default in compatible outer terminals. The external socket pane-overlay API has been removed; there is no replacement socket image API.
To disable graphics rendering and the pane graphics API:
Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds.
On local Unix Ghostty clients, Herdr can send RGB/RGBA pixels and encoded PNG images through temporary files instead of encoding them into terminal output. Applications continue using standard Kitty graphics; Herdr still manages placement, clipping, and visibility. Herdr first checks whether the outer terminal consumes a small test file and keeps inline output if it does not. Other terminals, SSH-launched clients, and nested multiplexers retain inline output. Set `HERDR_KITTY_FILE_TRANSPORT=0` before launching the client to disable this optimization.
For testing only, `HERDR_EXPERIMENTAL_KITTY_PNG_FORWARD=1` on the server preserves eligible quiet PNG uploads as encoded PNG instead of decoding them into pixels. Animation operations decode them when needed; queries and uploads requesting error replies keep full validation. This mode is off by default because corruption inside compressed image data may be detected only downstream, after Herdr has placed the image. It does not forward mutable producer file paths.
For local testing, set `HERDR_EXPERIMENTAL_KITTY_NATIVE_FILES=1` on both the server and client to try file-backed delivery of native RGBA images. Herdr creates its own image snapshot and sends its path instead of pixel bytes to the client; it does not borrow the application's original file. This also enables experimental stable outer image IDs. Unsupported clients or failed transfers fall back to inline delivery. This mode is off by default.
On Linux, `HERDR_EXPERIMENTAL_KITTY_SOURCE_FILES=1` on the server additionally tries to snapshot whole-file, uncompressed native RGBA uploads before reading pixels. This requires a filesystem that supports copy-on-write cloning into Herdr's private `/var/tmp` store. Unsupported filesystems, temporary-file uploads, shared memory, and other formats keep the existing loader. Combine it with native file delivery above to avoid copying pixels between the server and local client. Snapshots are limited to 16 MiB each and 64 MiB total; animation and unsupported clients read their pixels when needed. This experiment is off by default.
To disable Kitty graphics rendering:
```toml
[terminal]
@@ -523,7 +533,7 @@ kitty_graphics = false
The legacy `experimental.kitty_graphics` setting remains accepted for existing configurations. `terminal.kitty_graphics` takes precedence when both are set.
Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls pane graphics parsing and API availability, while the local client's setting controls output to the outer terminal.
Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls Kitty graphics parsing, while the local client's setting controls output to the outer terminal.
## Agent session restore
@@ -99,7 +99,7 @@ herdr pane read w1:p2 --source recent --lines 50
| ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` |
| Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` |
| タブ | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` |
| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` |
| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` |
| ポップアップ | `popup.close` |
| レイアウト | `layout.export`、`layout.apply`、`layout.set_split_ratio` |
| エージェント | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` |
@@ -147,20 +147,9 @@ CLI の `herdr api snapshot` は、クライアントやエージェントが簡
### ペイングラフィックス
ペイングラフィックスを使うと、プラグインがペイン上に画像データを配置できます。デフォルトで利用でき、サーバーで `[terminal].kitty_graphics = false` が設定されている場合は、すべてのペイングラフィックスメソッドが `feature_disabled` を返します。この設定の変更にはサーバーの再起動が必要です。`pane.graphics.info` はグラフィックスレイヤーを作成せず、接続中クライアントのセルの幅と高さをピクセル単位で返します。`pane.graphics.set` は `data_base64` の `png`、`rgb`、`rgba` データを受け取り、`pane.graphics.clear` はレイヤーを削除します。
アプリケーションとプラグインは、ペインのターミナル出力を通じて標準の Kitty グラフィックスプロトコルを送信してください。Herdr が対応する外側ターミナルで画像の配置、クリッピング、表示状態を管理します。
```json
{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}}
{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}}
{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}}
```
フレームを繰り返し送る場合は、`pane.graphics.stream` で専用ソケットを開きます。Herdr が `ok` を返した後、フレームごとに 1 つの JSON ヘッダーと正確に `data_length` バイトの生データを送ります。ソケットが閉じるまでストリームがそのペインのグラフィックスレイヤーを所有し、同時に set、clear、stream を要求すると `stream_conflict` を返します。
```json
{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}}
{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}
```
外部ソケットのペイン画像オーバーレイ API は削除されました。`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream` は利用できず、代替のソケット画像 API もありません。旧サーバーとの内部通信プロトコルの互換性は、これらの公開メソッドを復活させるものではありません。
`pane.layout` は、`workspace_id`、`tab_id`、`zoomed`、外側の `area`、`focused_pane_id`、ペインの矩形、分割の矩形/比率を含むタブレイアウトのスナップショットを返します。`pane.neighbor` と `pane.edges` は同じレイアウトスナップショットを含むので、クライアントは非公開のレイアウト状態なしに次の判断を下せます。
@@ -103,7 +103,7 @@ Raw socket method names use dot notation:
| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.move_block`, `workspace.report_metadata`, `workspace.close` |
| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` |
| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` |
| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
| Popup | `popup.close` |
| Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` |
| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start`, `agent.view.set`, `agent.view.clear` |
@@ -176,63 +176,14 @@ Clients can treat `offset_from_bottom == 0` as at-bottom state.
### Pane graphics
Pane graphics let a plugin place image data over a pane. They are available by
default; when the server has `[terminal].kitty_graphics = false`, every pane
graphics method returns `feature_disabled`. Changing this setting requires a
server restart. Calling `pane.graphics.info` explicitly
activates capability discovery and returns the attached client's cell size,
file-frame options, pixel-mouse support, the 16-layer limit, and `pane_visible`.
`pane_visible` is true only when the target is in the active workspace and tab
and is not hidden by zoom. Short-lived UI modes do not change it.
Applications and plugins should emit the standard Kitty graphics protocol through
their pane's terminal output. Herdr manages image placement, clipping, and
visibility in compatible outer terminals.
`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` accept an
optional `layer_id` (default `primary`). Set and stream also accept `z_index`;
layers are placed in stable `(z_index, layer_id)` order. Each stream exclusively owns its layer, and
closing it removes that layer. Inline frames accept `png`, `rgb`, `rgba`, or
`bgra`; BGRA is normalized once to owned RGBA. Herdr advances the host cache
one image transaction per render pass, so arbitrary layer sets progress without
an aggregate frame. Client transport keeps each transaction within its 32 MiB
wire limit.
```json
{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}}
{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}}
{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}}
```
For repeated frames, open a dedicated socket with `pane.graphics.stream`. After
Herdr replies with `ok`, send one JSON header and then exactly `data_length` raw
bytes per inline frame. Concurrent operations on that layer return
`stream_conflict`.
```json
{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1","z_index":0}}
{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}
```
When `pane.graphics.info` advertises `file_frame_transport: "direct-kitty"`, an
eligible local Ghostty, kitty, or WezTerm client may submit an immutable private
`rgba` or `bgra` file with `file.path`, `sequence`, and `revision`. Direct Kitty
file transport is reserved for the default `primary` page layer; named secondary
layers use owned inline RGBA. BGRA is always copied, swizzled, and rendered
inline. Herdr replies with a `pane_graphics_frame_ack` only after the terminal
accepts the file, or after a safe owned inline fallback is installed. Confirmed
file-transport failure disables direct files for that client connection without
disabling exact pixel mouse. A timeout or client loss closes the stream without
acknowledging source reuse. Clients that cannot negotiate direct file transport
remain on the owned inline fallback.
Direct files are always complete canonical `width * height * 4` RGBA frames.
`file_frame_max_bytes` is the limit that remains eligible for owned inline fallback.
Primary-layer RGBA files may use the larger `file_frame_direct_max_bytes` limit when
`file_frame_transport` is available. Frames above the fallback limit are acknowledged
only when the terminal accepts the direct transfer; rejection closes the stream. If a
frame cannot use owned inline fallback while its pane is temporarily hidden or cannot be
placed during a redraw, Herdr uploads the image without displaying it and replays its
placement when the pane becomes visible again.
`file_frame_damage: true` means Herdr accepts optional damage metadata for
producer-side canonical-ring efficiency; it still copies or presents the full file.
Resize and full redraw replay placements without retransmitting pixels.
The external socket pane-overlay API has been removed: `pane.graphics.info`,
`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` are no
longer available. There is no replacement socket image API. Internal wire
compatibility with older servers does not restore these public methods.
`pane.layout` returns the tab layout snapshot with `workspace_id`, `tab_id`,
`zoomed`, outer `area`, `focused_pane_id`, pane rects, and split rects/ratios.
@@ -99,7 +99,7 @@ herdr pane read w1:p2 --source recent --lines 50
| 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` |
| Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` |
| 标签页 | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` |
| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` |
| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` |
| 弹窗 | `popup.close` |
| 布局 | `layout.export`、`layout.apply`、`layout.set_split_ratio` |
| 智能体 | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` |
@@ -147,20 +147,9 @@ CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSO
### 窗格图形
窗格图形允许插件在窗格上放置图像数据。该功能默认可用;当服务器设置 `[terminal].kitty_graphics = false` 时,所有窗格图形方法都返回 `feature_disabled`。更改此设置需要重启服务器。调用 `pane.graphics.info` 会返回所连接客户端的单元格像素宽度和高度,但不会创建图形层。`pane.graphics.set` 接受 `data_base64` 中的 `png`、`rgb` 或 `rgba` 数据,`pane.graphics.clear` 删除该图层。
应用和插件应通过窗格的终端输出发送标准 Kitty 图形协议。Herdr 在兼容的外层终端中管理图像的位置、裁剪和可见性。
```json
{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}}
{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}}
{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}}
```
要重复发送帧,用 `pane.graphics.stream` 打开专用 socket。Herdr 回复 `ok` 后,每一帧发送一个 JSON 头,再发送恰好 `data_length` 字节的原始数据。socket 关闭前,该流拥有对应窗格的图形层;并发 set、clear 或 stream 请求返回 `stream_conflict`。
```json
{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}}
{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}
```
外部 socket 窗格图像叠加 API 已移除:`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear` 和 `pane.graphics.stream` 不再可用,也没有替代的 socket 图像 API。与旧服务器的内部传输协议兼容性不会恢复这些公开方法。
`pane.layout` 返回标签页布局快照,包含 `workspace_id`、`tab_id`、`zoomed`、外层 `area`、`focused_pane_id`、窗格矩形和分割矩形/比例。`pane.neighbor` 和 `pane.edges` 也包含同一份布局快照,让客户端不需要私有布局状态就能做出下一步决策。
@@ -439,7 +439,7 @@
"key": "terminal.kitty_graphics",
"type": "boolean",
"default": "true",
"description": "Render pane images in Kitty graphics-compatible outer terminals. Set false to disable graphics rendering and the pane graphics API. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls parsing and API availability while local client configuration controls outer-terminal output."
"description": "Render pane images in Kitty graphics-compatible outer terminals. Applications use the standard Kitty graphics protocol through pane terminal output; the external socket pane-overlay API is no longer available. Set false to disable graphics rendering. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls Kitty graphics parsing while local client configuration controls outer-terminal output."
}
]
},
-8
View File
@@ -64,13 +64,6 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::PaneFocus(_)
| Method::PaneInputSet(_)
| Method::PaneRename(_)
| Method::PaneGraphicsSet(_)
| Method::PaneGraphicsClear(_)
| Method::PaneGraphicsStream(_)
| Method::PaneGraphicsStreamSet(_)
| Method::PaneGraphicsStreamDirect(_)
| Method::PaneGraphicsStreamOpen(_)
| Method::PaneGraphicsStreamClose(_)
| Method::PaneReportAgent(_)
| Method::PaneReportAgentSession(_)
| Method::PaneReportMetadata(_)
@@ -91,7 +84,6 @@ pub struct ApiRequestMessage {
pub request: Request,
pub respond_to: std::sync::mpsc::Sender<String>,
pub response_write_complete: Option<std::sync::mpsc::Receiver<()>>,
pub stream_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
pub type ApiRequestSender = mpsc::UnboundedSender<ApiRequestMessage>;
-21
View File
@@ -201,27 +201,6 @@ pub enum Method {
PaneSendInput(PaneSendInputParams),
#[serde(rename = "pane.read")]
PaneRead(PaneReadParams),
#[serde(rename = "pane.graphics.set")]
PaneGraphicsSet(PaneGraphicsSetParams),
#[serde(rename = "pane.graphics.clear")]
PaneGraphicsClear(PaneGraphicsClearParams),
#[serde(rename = "pane.graphics.info")]
PaneGraphicsInfo(PaneTarget),
#[serde(rename = "pane.graphics.stream")]
#[schemars(skip)]
PaneGraphicsStream(PaneGraphicsStreamParams),
#[serde(skip)]
#[schemars(skip)]
PaneGraphicsStreamSet(PaneGraphicsSetParams),
#[serde(skip)]
#[schemars(skip)]
PaneGraphicsStreamDirect(PaneGraphicsDirectParams),
#[serde(skip)]
#[schemars(skip)]
PaneGraphicsStreamOpen(PaneGraphicsStreamParams),
#[serde(skip)]
#[schemars(skip)]
PaneGraphicsStreamClose(PaneGraphicsStreamParams),
#[serde(rename = "pane.report_agent")]
PaneReportAgent(PaneReportAgentParams),
#[serde(rename = "pane.report_agent_session")]
-86
View File
@@ -2,14 +2,6 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub(crate) const PANE_GRAPHICS_SET_MAX_BYTES: usize = 512 * 1024;
pub(crate) const PANE_GRAPHICS_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024;
pub(crate) const PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES: usize = 400 * 1024 * 1024;
pub(crate) const PANE_GRAPHICS_MAX_LAYERS_PER_PANE: usize = 16;
pub(crate) const PANE_GRAPHICS_MAX_LAYERS_TOTAL: usize = 64;
pub(crate) const PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL: usize = 64 * 1024 * 1024;
pub(crate) const PANE_GRAPHICS_PRIMARY_LAYER_ID: &str = "primary";
use super::agents::AgentSessionInfo;
use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection};
@@ -366,84 +358,6 @@ pub struct PaneReadParams {
pub(crate) intent: super::common::ReadIntent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PaneGraphicsFormat {
Png,
Rgb,
Rgba,
Bgra,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneGraphicsSetParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_id: Option<String>,
#[serde(default)]
pub z_index: i32,
#[serde(skip)]
#[schemars(skip)]
pub owner: String,
pub format: PaneGraphicsFormat,
pub image_width: u32,
pub image_height: u32,
#[serde(skip)]
pub data: Option<Vec<u8>>,
#[serde(default)]
pub data_base64: String,
#[serde(default)]
pub placement: PaneGraphicsPlacementParams,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default,
)]
pub struct PaneGraphicsPlacementParams {
#[serde(default)]
pub viewport_col: i32,
#[serde(default)]
pub viewport_row: i32,
#[serde(default)]
pub grid_cols: u32,
#[serde(default)]
pub grid_rows: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneGraphicsClearParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneGraphicsDirectParams {
pub pane_id: String,
pub layer_id: Option<String>,
pub z_index: i32,
pub owner: String,
pub image_width: u32,
pub image_height: u32,
pub format: PaneGraphicsFormat,
pub path: String,
pub sequence: u64,
pub revision: u64,
pub placement: PaneGraphicsPlacementParams,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneGraphicsStreamParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_id: Option<String>,
#[serde(default)]
pub z_index: i32,
#[serde(skip)]
#[schemars(skip)]
pub owner: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportAgentParams {
pub pane_id: String,
-27
View File
@@ -181,33 +181,6 @@ pub enum ResponseResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
current_global: Option<u64>,
},
PaneGraphicsFrameAck {
sequence: u64,
revision: u64,
},
PaneGraphicsInfo {
cell_width_px: u32,
cell_height_px: u32,
/// True only when this pane is on the currently rendered terminal surface.
pane_visible: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_directory: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
file_frame_formats: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_max_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_direct_max_bytes: Option<usize>,
/// Accepts damage metadata while still consuming a complete canonical file.
#[serde(default)]
file_frame_damage: bool,
#[serde(default)]
max_layers_per_pane: usize,
#[serde(default)]
pixel_mouse: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_transport: Option<String>,
},
AgentExplain {
explain: serde_json::Value,
},
+117 -103
View File
@@ -22,8 +22,6 @@ use crate::ipc::{
socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity,
};
mod pane_graphics_stream;
#[cfg(test)]
mod subscription_socket_tests;
@@ -162,6 +160,30 @@ fn start_server_inner(
})
}
fn retired_pane_graphics_method_error(line: &str, id: &str) -> Option<ErrorResponse> {
#[derive(serde::Deserialize)]
struct RequestMethod {
method: String,
}
let envelope = serde_json::from_str::<RequestMethod>(line).ok()?;
let method = envelope.method.as_str();
if !matches!(
method,
"pane.graphics.info" | "pane.graphics.set" | "pane.graphics.clear" | "pane.graphics.stream"
) {
return None;
}
Some(ErrorResponse {
id: id.into(),
error: ErrorBody {
code: "unknown_method".into(),
message: format!("unknown method: {method}"),
},
})
}
fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
crate::ipc::prepare_socket_path(path, |path| {
format!(
@@ -233,16 +255,15 @@ fn handle_connection_with_stop(
} else {
String::new()
};
write_json_line_allow_disconnect(
&mut stream,
&ErrorResponse {
let response =
retired_pane_graphics_method_error(line, &id).unwrap_or_else(|| ErrorResponse {
id,
error: ErrorBody {
code: "invalid_request".into(),
message: format!("invalid request: {request_error}"),
},
},
)?;
});
write_json_line_allow_disconnect(&mut stream, &response)?;
return Ok(());
}
};
@@ -296,22 +317,6 @@ fn handle_connection_with_stop(
}
Ok(())
}
Method::PaneGraphicsStream(params) => {
let result =
pane_graphics_stream::serve(stream, request_id.clone(), params, api_tx, running);
match &result {
Ok(()) => crate::logging::api_request_completed(
&request_id,
method,
"stream_closed",
changes_ui,
),
Err(err) => {
crate::logging::api_request_failed(&request_id, method, &err.to_string())
}
}
result
}
Method::EventsSubscribe(params) => {
let result = stream_subscriptions(
stream,
@@ -478,7 +483,7 @@ fn handle_request(
);
}
dispatch_to_app(request, api_tx, None, response_write_complete, None, None)
dispatch_to_app(request, api_tx, None, response_write_complete, None)
}
pub(crate) fn api_method_name(method: &Method) -> &'static str {
@@ -561,14 +566,6 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str {
Method::PaneSendKeys(_) => "pane.send_keys",
Method::PaneSendInput(_) => "pane.send_input",
Method::PaneRead(_) => "pane.read",
Method::PaneGraphicsSet(_) => "pane.graphics.set",
Method::PaneGraphicsClear(_) => "pane.graphics.clear",
Method::PaneGraphicsInfo(_) => "pane.graphics.info",
Method::PaneGraphicsStream(_) => "pane.graphics.stream",
Method::PaneGraphicsStreamSet(_) => "pane.graphics.stream.set",
Method::PaneGraphicsStreamDirect(_) => "pane.graphics.stream.direct",
Method::PaneGraphicsStreamOpen(_) => "pane.graphics.stream.open",
Method::PaneGraphicsStreamClose(_) => "pane.graphics.stream.close",
Method::PaneReportAgent(_) => "pane.report_agent",
Method::PaneReportAgentSession(_) => "pane.report_agent_session",
Method::PaneReportMetadata(_) => "pane.report_metadata",
@@ -928,7 +925,7 @@ pub(super) fn dispatch_to_app_with_timeout(
api_tx: &ApiRequestSender,
timeout: Option<Duration>,
) -> String {
dispatch_to_app(request, api_tx, timeout, None, None, None)
dispatch_to_app(request, api_tx, timeout, None, None)
}
pub(super) fn dispatch_to_app_with_caller_timeout(
@@ -941,55 +938,24 @@ pub(super) fn dispatch_to_app_with_caller_timeout(
api_tx,
timeout,
None,
None,
Some(("timeout", "timed out waiting for agent status")),
)
}
pub(super) fn dispatch_stream_open(
request: Request,
api_tx: &ApiRequestSender,
timeout: Duration,
active: Arc<AtomicBool>,
) -> String {
dispatch_to_app(request, api_tx, Some(timeout), None, Some(active), None)
}
pub(super) fn dispatch_stream_frame(
request: Request,
api_tx: &ApiRequestSender,
active: Arc<AtomicBool>,
) -> String {
dispatch_to_app(
request,
api_tx,
Some(crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT),
None,
Some(active),
None,
)
}
fn dispatch_to_app(
request: Request,
api_tx: &ApiRequestSender,
timeout: Option<Duration>,
response_write_complete: Option<std::sync::mpsc::Receiver<()>>,
stream_active: Option<Arc<AtomicBool>>,
timeout_response: Option<(&str, &str)>,
) -> String {
let request_id = request.id.clone();
let request_active = stream_active.clone();
let (respond_to, response_rx) = std::sync::mpsc::channel();
if let Err(err) = api_tx.send(ApiRequestMessage {
request,
respond_to,
response_write_complete,
stream_active,
}) {
if let Some(active) = request_active {
active.store(false, Ordering::Release);
}
return error_response_json(
request_id,
"server_unavailable",
@@ -1019,9 +985,6 @@ fn dispatch_to_app(
match response {
Ok(response) => response,
Err(err) => {
if let Some(active) = request_active {
active.store(false, Ordering::Release);
}
if err.kind() == std::io::ErrorKind::TimedOut {
if let Some((code, message)) = timeout_response {
return error_response_json(request_id, code, message.into());
@@ -1299,6 +1262,93 @@ mod tests {
assert_eq!(api_response_outcome(generic_error), "error");
}
#[test]
fn removed_pane_graphics_methods_return_unknown_method_without_stream_upgrade() {
for method in [
"pane.graphics.info",
"pane.graphics.set",
"pane.graphics.clear",
"pane.graphics.stream",
] {
let (mut client, server, _path) = local_stream_pair("removed-pane-graphics");
let (api_tx, mut api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
writeln!(
client,
"{{\"id\":\"removed\",\"method\":\"{method}\",\"params\":{{}}}}"
)
.unwrap();
client.flush().unwrap();
handle_connection(
server,
&api_tx,
&EventHub::default(),
&Arc::new(AtomicBool::new(true)),
None,
)
.unwrap();
let response = read_line(&mut client);
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["id"], "removed", "{method}");
assert_eq!(response["error"]["code"], "unknown_method", "{method}");
assert!(response["error"]["message"]
.as_str()
.is_some_and(|message| message.contains(method)));
assert!(response.get("result").is_none(), "{method}");
assert!(api_rx.try_recv().is_err(), "{method} reached the app");
}
}
#[test]
fn unrelated_unknown_method_retains_standard_invalid_request_response() {
let (mut client, server, _path) = local_stream_pair("unknown-api-request");
let (api_tx, mut api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
client
.write_all(b"{\"id\":\"unknown\",\"method\":\"nope\",\"params\":{}}\n")
.unwrap();
client.flush().unwrap();
handle_connection(
server,
&api_tx,
&EventHub::default(),
&Arc::new(AtomicBool::new(true)),
None,
)
.unwrap();
let response = read_line(&mut client);
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["id"], "unknown");
assert_eq!(response["error"]["code"], "invalid_request");
assert!(api_rx.try_recv().is_err());
}
#[test]
fn ordinary_api_request_still_uses_normal_connection_path() {
let (mut client, server, _path) = local_stream_pair("ordinary-api-request");
let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
client
.write_all(b"{\"id\":\"ordinary\",\"method\":\"ping\",\"params\":{}}\n")
.unwrap();
client.flush().unwrap();
handle_connection(
server,
&api_tx,
&EventHub::default(),
&Arc::new(AtomicBool::new(true)),
None,
)
.unwrap();
let response = read_line(&mut client);
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["id"], "ordinary");
assert_eq!(response["result"]["type"], "pong");
}
#[test]
fn ping_request_returns_pong() {
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1758,39 +1808,3 @@ mod tests {
server_thread.join().unwrap();
}
}
#[cfg(test)]
mod pane_graphics_request_tests {
use super::*;
use base64::Engine as _;
#[test]
fn maximum_public_graphics_request_fits_initial_json_line() {
let request = Request {
id: "graphics-max".into(),
method: Method::PaneGraphicsSet(crate::api::schema::PaneGraphicsSetParams {
pane_id: "pane_1".into(),
layer_id: None,
z_index: 0,
owner: String::new(),
format: crate::api::schema::PaneGraphicsFormat::Png,
image_width: 1,
image_height: 1,
data_base64: base64::engine::general_purpose::STANDARD
.encode(vec![1_u8; crate::api::schema::PANE_GRAPHICS_SET_MAX_BYTES]),
data: None,
placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
}),
};
let encoded = serde_json::to_vec(&request).unwrap();
assert!(encoded.len() < MAX_INITIAL_REQUEST_BYTES);
}
#[test]
fn duplicate_method_cannot_be_reinterpreted_as_graphics_stream() {
let encoded = r#"{"id":"duplicate","method":"ping","method":"pane.graphics.stream","params":{"pane_id":"pane_1"}}"#;
assert!(serde_json::from_str::<Request>(encoded).is_err());
}
}
File diff suppressed because it is too large Load Diff
-29
View File
@@ -5,7 +5,6 @@ mod agents;
mod env;
mod integrations;
mod layouts;
mod pane_graphics;
mod panes;
pub(crate) mod plugins;
pub(super) mod responses;
@@ -1152,34 +1151,6 @@ impl App {
}
Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
Method::PaneRead(params) => return self.handle_pane_read(request.id, params),
Method::PaneGraphicsSet(params) => {
return self.handle_pane_graphics_set(request.id, params);
}
Method::PaneGraphicsClear(params) => {
return self.handle_pane_graphics_clear(request.id, params);
}
Method::PaneGraphicsInfo(params) => {
return self.handle_pane_graphics_info(request.id, params);
}
Method::PaneGraphicsStream(_) => {
return responses::encode_error(
request.id,
"stream_transport_required",
"pane.graphics.stream requires the streaming socket transport",
);
}
Method::PaneGraphicsStreamSet(params) => {
return self.handle_pane_graphics_stream_set(request.id, params);
}
Method::PaneGraphicsStreamDirect(params) => {
return self.handle_pane_graphics_stream_direct(request.id, params);
}
Method::PaneGraphicsStreamOpen(params) => {
return self.handle_pane_graphics_stream_open(request.id, params);
}
Method::PaneGraphicsStreamClose(params) => {
return self.handle_pane_graphics_stream_close(request.id, params);
}
Method::PaneReportAgent(params) => {
return self.handle_pane_report_agent(request.id, params);
}
File diff suppressed because it is too large Load Diff
-7
View File
@@ -17,7 +17,6 @@ mod creation;
mod custom_commands;
mod git_refresh;
mod ids;
pub(crate) mod pane_graphics;
mod popup;
mod runtime;
mod session;
@@ -103,9 +102,6 @@ impl AppPolicy {
pub struct App {
pub state: AppState,
pub(crate) pane_graphics: pane_graphics::Runtime,
pub(crate) pane_graphics_files: Arc<crate::pane_graphics_files::FileStore>,
pub(crate) direct_graphics_available: bool,
pub(crate) pixel_mouse_available: bool,
pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry,
pub event_tx: mpsc::Sender<AppEvent>,
@@ -575,9 +571,6 @@ impl App {
toast_deadline: None,
last_api_notification_at: None,
state,
pane_graphics: pane_graphics::Runtime::default(),
pane_graphics_files: Arc::new(crate::pane_graphics_files::FileStore::default()),
direct_graphics_available: false,
pixel_mouse_available: false,
terminal_runtimes: restored_terminal_runtimes,
event_tx,
-350
View File
@@ -1,350 +0,0 @@
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use crate::api::schema::{PaneGraphicsFormat, PaneGraphicsPlacementParams};
use crate::layout::PaneId;
pub(crate) type Key = (PaneId, String);
pub(crate) const DIRECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub(crate) const DIRECT_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
pub(crate) const DIRECT_OUTER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(9);
#[derive(Debug)]
pub(crate) enum Backing {
Inline(Vec<u8>),
Direct(crate::pane_graphics_files::Lease),
Resident { len: usize, client_id: u64 },
}
#[derive(Debug)]
pub(crate) struct Layer {
pub(crate) format: PaneGraphicsFormat,
pub(crate) image_width: u32,
pub(crate) image_height: u32,
pub(crate) backing: Backing,
pub(crate) data_fingerprint: u64,
pub(crate) render: PaneGraphicsPlacementParams,
pub(crate) z_index: i32,
}
#[derive(Debug)]
pub(crate) struct DirectGate {
pub(crate) transfer_id: u64,
pub(crate) image_id: u32,
pub(crate) client_id: u64,
pub(crate) deadline: std::time::Instant,
pub(crate) written: bool,
pub(crate) success_response: String,
pub(crate) respond_to: std::sync::mpsc::Sender<String>,
}
#[derive(Debug)]
pub(crate) struct Slot {
pub(crate) host_image_id: u32,
pub(crate) layer: Option<Layer>,
pub(crate) stream_owner: Option<String>,
pub(crate) stream_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
pub(crate) direct_gate: Option<DirectGate>,
}
impl Slot {
#[cfg(test)]
pub(crate) fn test(host_image_id: u32, layer: Option<Layer>) -> Self {
Self {
host_image_id,
layer,
stream_owner: None,
stream_active: None,
direct_gate: None,
}
}
pub(crate) fn stream_is_active(&self) -> bool {
self.stream_active
.as_ref()
.is_some_and(|active| active.load(std::sync::atomic::Ordering::Acquire))
}
pub(crate) fn direct_client(&self) -> Option<u64> {
self.direct_gate
.as_ref()
.map(|gate| gate.client_id)
.or_else(|| self.layer.as_ref().and_then(Layer::resident_client))
}
}
impl Drop for Slot {
fn drop(&mut self) {
if let Some(active) = &self.stream_active {
active.store(false, std::sync::atomic::Ordering::Release);
}
}
}
#[derive(Debug)]
pub(crate) struct Runtime {
pub(crate) slots: HashMap<Key, Slot>,
revision: u64,
next_host_image_id: u32,
}
impl Default for Runtime {
fn default() -> Self {
Self {
slots: HashMap::new(),
revision: 0,
next_host_image_id: 1 << 31,
}
}
}
impl Runtime {
pub(crate) fn revision(&self) -> u64 {
self.revision
}
pub(crate) fn mark_changed(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
#[cfg(test)]
pub(crate) fn clear(&mut self) {
self.slots.clear();
self.mark_changed();
}
pub(crate) fn reserve_image_id(&mut self, key: &Key) -> Option<u32> {
if let Some(id) = self.slots.get(key).map(|slot| slot.host_image_id) {
return Some(id);
}
for _ in 0..=self.slots.len() {
let id = self.next_host_image_id | (1 << 31);
self.next_host_image_id = id.wrapping_add(1) | (1 << 31);
if self.slots.values().all(|slot| slot.host_image_id != id) {
return Some(id);
}
}
None
}
pub(crate) fn layer_count(&self, pane_id: PaneId) -> usize {
self.slots.keys().filter(|(id, _)| *id == pane_id).count()
}
pub(crate) fn can_add_slot(&self, key: &Key) -> bool {
self.slots.contains_key(key)
|| self.slots.len() < crate::api::schema::panes::PANE_GRAPHICS_MAX_LAYERS_TOTAL
}
pub(crate) fn can_store_inline(&self, key: &Key, len: usize) -> bool {
let replaced = self
.slots
.get(key)
.and_then(|slot| slot.layer.as_ref())
.and_then(Layer::inline_data)
.map_or(0, <[u8]>::len);
self.inline_bytes()
.saturating_sub(replaced)
.saturating_add(len)
<= crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL
}
fn inline_bytes(&self) -> usize {
self.slots
.values()
.filter_map(|slot| slot.layer.as_ref())
.filter_map(Layer::inline_data)
.map(<[u8]>::len)
.sum()
}
pub(crate) fn attach_stream_active(
&mut self,
key: &Key,
owner: &str,
active: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
if let Some(slot) = self
.slots
.get_mut(key)
.filter(|slot| slot.stream_owner.as_deref() == Some(owner))
{
slot.stream_active = Some(active);
}
}
pub(crate) fn retain_live_panes(&mut self, state: &crate::app::state::AppState) -> bool {
if self.slots.is_empty() {
return false;
}
let before = self.slots.len();
self.slots.retain(|(pane_id, _), _| {
state
.workspaces
.iter()
.any(|workspace| workspace.pane_state(*pane_id).is_some())
});
if self.slots.len() == before {
return false;
}
self.mark_changed();
true
}
}
impl Layer {
pub(crate) fn inline(
format: PaneGraphicsFormat,
image_width: u32,
image_height: u32,
data: Vec<u8>,
render: PaneGraphicsPlacementParams,
z_index: i32,
) -> Self {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
Self {
format,
image_width,
image_height,
backing: Backing::Inline(data),
data_fingerprint: hasher.finish(),
render,
z_index,
}
}
pub(crate) fn direct(
image_width: u32,
image_height: u32,
lease: crate::pane_graphics_files::Lease,
render: PaneGraphicsPlacementParams,
z_index: i32,
) -> Self {
let data_fingerprint = lease.fingerprint();
Self {
format: PaneGraphicsFormat::Rgba,
image_width,
image_height,
backing: Backing::Direct(lease),
data_fingerprint,
render,
z_index,
}
}
pub(crate) fn data_len(&self) -> usize {
match &self.backing {
Backing::Inline(data) => data.len(),
Backing::Direct(lease) => lease.len(),
Backing::Resident { len, .. } => *len,
}
}
pub(crate) fn inline_data(&self) -> Option<&[u8]> {
match &self.backing {
Backing::Inline(data) => Some(data),
Backing::Direct(_) | Backing::Resident { .. } => None,
}
}
pub(crate) fn direct_lease(&self) -> Option<&crate::pane_graphics_files::Lease> {
match &self.backing {
Backing::Direct(lease) => Some(lease),
Backing::Inline(_) | Backing::Resident { .. } => None,
}
}
pub(crate) fn mark_resident(&mut self, client_id: u64) -> bool {
let Backing::Direct(lease) = &self.backing else {
return false;
};
self.backing = Backing::Resident {
len: lease.len(),
client_id,
};
true
}
pub(crate) fn resident_client(&self) -> Option<u64> {
match self.backing {
Backing::Resident { client_id, .. } => Some(client_id),
Backing::Inline(_) | Backing::Direct(_) => None,
}
}
pub(crate) fn terminal_only(&self) -> bool {
matches!(self.backing, Backing::Direct(_) | Backing::Resident { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_liveness_attaches_only_to_the_exact_owned_layer() {
let mut runtime = Runtime::default();
let first = (PaneId::from_raw(1), "first".into());
let second = (PaneId::from_raw(2), "second".into());
for (key, id) in [(&first, 1), (&second, 2)] {
runtime.slots.insert(
key.clone(),
Slot {
host_image_id: (1 << 31) | id,
layer: None,
stream_owner: Some("shared-owner".into()),
stream_active: None,
direct_gate: None,
},
);
}
let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
runtime.attach_stream_active(&second, "shared-owner", active.clone());
assert!(runtime.slots[&first].stream_active.is_none());
assert!(std::sync::Arc::ptr_eq(
runtime.slots[&second].stream_active.as_ref().unwrap(),
&active
));
}
#[test]
fn runtime_bounds_total_slots_and_inline_bytes() {
let mut runtime = Runtime::default();
for index in 0..crate::api::schema::panes::PANE_GRAPHICS_MAX_LAYERS_TOTAL {
let key = (PaneId::from_raw(1), format!("layer-{index}"));
runtime
.slots
.insert(key, Slot::test((1 << 31) | index as u32, None));
}
assert!(!runtime.can_add_slot(&(PaneId::from_raw(2), "extra".into())));
let mut runtime = Runtime::default();
let key = (PaneId::from_raw(1), "primary".into());
runtime.slots.insert(
key.clone(),
Slot::test(
1 << 31,
Some(Layer::inline(
PaneGraphicsFormat::Rgba,
1,
1,
vec![0; 4],
PaneGraphicsPlacementParams::default(),
0,
)),
),
);
assert!(runtime.can_store_inline(
&key,
crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL,
));
assert!(!runtime.can_store_inline(
&(PaneId::from_raw(2), "primary".into()),
crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL,
));
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub(super) fn apply_profiles(
endpoints.disconnect(&endpoint_id);
let cancelled = commands.disconnect(&endpoint_id);
#[cfg(unix)]
state.retire_endpoint_graphics(&endpoint_id);
state.forget_endpoint_graphics(&endpoint_id);
if let Some(shell) = state.shell.as_mut() {
for request_id in cancelled {
shell.cancel_endpoint_request(&request_id);
+77 -2
View File
@@ -1,11 +1,86 @@
use std::collections::HashSet;
use std::io;
use std::io::{self, Write as _};
use std::sync::{Mutex, OnceLock};
use crate::protocol::render_ansi;
use crate::kitty_graphics::{GraphicsOperation, GraphicsOutput};
use crate::protocol::{render_ansi, FrameData};
use base64::Engine as _;
/// Local output only; raw uploads are never added to a published frame codec.
#[derive(Debug)]
pub(crate) struct ComposedFrame {
pub(crate) frame: FrameData,
pub(crate) graphics: GraphicsOutput,
}
impl From<FrameData> for ComposedFrame {
fn from(mut frame: FrameData) -> Self {
let graphics = GraphicsOutput::from_bytes(std::mem::take(&mut frame.graphics));
Self { frame, graphics }
}
}
impl std::ops::Deref for ComposedFrame {
type Target = FrameData;
fn deref(&self) -> &Self::Target {
&self.frame
}
}
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
pub(super) fn write_composed_frame(
mut writer: impl io::Write,
encoded: &[u8],
graphics: &GraphicsOutput,
files: &mut super::image_files::FileTransport,
) -> io::Result<()> {
if graphics.is_empty() {
return writer.write_all(encoded);
}
let mut writer = io::BufWriter::with_capacity(64 * 1024, writer);
let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len());
writer.write_all(&encoded[..insertion])?;
writer.write_all(b"\x1b7")?;
for operation in &graphics.operations {
match operation {
GraphicsOperation::Bytes(bytes) => {
record_received_kitty_graphics(bytes);
writer.write_all(bytes)?;
}
GraphicsOperation::Upload { control, data } => {
let file_eligible = control
.split(',')
.any(|part| matches!(part, "f=24" | "f=32" | "f=100"));
if file_eligible {
if let Some(path) = files
.probe()
.and_then(|path| path.to_str().map(str::to_owned))
{
let path =
base64::engine::general_purpose::STANDARD.encode(path.as_bytes());
write!(writer, "\x1b_Ga=q,t=t,f=32,s=1,v=1,i=1,q=2;{path}\x1b\\")?;
}
}
let path = file_eligible.then(|| files.prepare(data)).flatten();
let header = format!("\x1b_G{control};\x1b\\");
record_received_kitty_graphics(header.as_bytes());
if let Some(path) = path.as_ref().and_then(|path| path.to_str()) {
let control = control.replace(",t=d,", ",t=t,");
let path = base64::engine::general_purpose::STANDARD.encode(path.as_bytes());
write!(writer, "\x1b_G{control};{path}\x1b\\")?;
} else {
crate::kitty_graphics::write_kitty_data(&mut writer, control, data)?;
}
}
}
}
writer.write_all(b"\x1b8")?;
writer.write_all(&encoded[insertion..])?;
io::Write::flush(&mut writer)
}
pub(super) fn write_encoded_frame_with_graphics(
mut writer: impl io::Write,
encoded: &[u8],
+192
View File
@@ -0,0 +1,192 @@
use super::frame_output::*;
use super::image_files::FileTransport;
use crate::kitty_graphics::{GraphicsOperation, GraphicsOutput};
use std::sync::Arc;
fn output(format: u32) -> GraphicsOutput {
GraphicsOutput {
operations: vec![
GraphicsOperation::Bytes(b"before".to_vec()),
GraphicsOperation::Upload {
control: format!("a=t,t=d,f={format},s=1,v=1,i=42,q=2"),
data: Arc::from([1, 2, 3, 4]),
},
GraphicsOperation::Bytes(b"after".to_vec()),
],
}
}
#[test]
fn deferred_inline_output_matches_existing_frame_envelope() {
let graphics = output(32);
let encoded = b"\x1b[?2026htext\x1b[?2026l";
let mut expected = Vec::new();
write_encoded_frame_with_graphics(
&mut expected,
encoded,
&graphics.clone().into_inline_bytes(),
)
.unwrap();
let mut actual = Vec::new();
write_composed_frame(
&mut actual,
encoded,
&graphics,
&mut FileTransport::default(),
)
.unwrap();
assert_eq!(actual, expected);
}
#[test]
fn native_swap_cleanup_and_placements_share_the_frame_sync_envelope() {
let graphics = GraphicsOutput::from_bytes(
concat!(
"\x1b_Ga=d,d=I,i=41,q=2;\x1b\\",
"\x1b_Ga=p,i=42,p=1,q=2;\x1b\\",
"\x1b_Ga=p,i=42,p=2,q=2;\x1b\\",
)
.as_bytes()
.to_vec(),
);
let mut actual = Vec::new();
write_composed_frame(
&mut actual,
b"\x1b[?2026hframe\x1b[?2026l",
&graphics,
&mut FileTransport::default(),
)
.unwrap();
let text = std::str::from_utf8(&actual).unwrap();
let sync_begin = text.find("\x1b[?2026h").unwrap();
let sync_end = text.rfind("\x1b[?2026l").unwrap();
let deletion = text.find("a=d,d=I,i=41").unwrap();
let placements = text
.match_indices("a=p,i=42")
.map(|(at, _)| at)
.collect::<Vec<_>>();
assert!(deletion > sync_begin && deletion < sync_end);
assert!(placements.len() >= 2);
assert!(placements
.iter()
.all(|placement| *placement > sync_begin && *placement < sync_end));
}
#[test]
fn deferred_output_propagates_write_failure() {
struct Broken;
impl std::io::Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
assert_eq!(
write_composed_frame(Broken, b"text", &output(32), &mut FileTransport::default())
.unwrap_err()
.kind(),
std::io::ErrorKind::BrokenPipe
);
}
#[cfg(unix)]
mod local_files {
use super::*;
use base64::Engine as _;
use std::path::PathBuf;
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = PathBuf::from(format!(
"/var/tmp/herdr-output-test-{}-{id}",
std::process::id()
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn command_path(bytes: &[u8], header: &str) -> PathBuf {
let text = std::str::from_utf8(bytes).unwrap();
let command = text
.split("\x1b_G")
.find(|part| part.starts_with(header))
.unwrap();
let payload = command
.split_once(';')
.unwrap()
.1
.split("\x1b\\")
.next()
.unwrap();
PathBuf::from(
String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(payload)
.unwrap(),
)
.unwrap(),
)
}
#[test]
fn files_begin_only_after_probe_consumption_and_keep_order() {
for format in [24, 32, 100] {
let root = Scratch::new();
let mut files = FileTransport::for_test(root.0.clone());
let graphics = output(format);
let mut first = Vec::new();
write_composed_frame(&mut first, b"text", &graphics, &mut files).unwrap();
let first_text = std::str::from_utf8(&first).unwrap();
assert!(first_text.contains("a=q,t=t"));
assert!(first_text.contains("a=t,t=d"));
let probe = command_path(&first, "a=q,");
assert_eq!(std::fs::read(&probe).unwrap().len(), 4);
let mut waiting = Vec::new();
write_composed_frame(&mut waiting, b"text", &graphics, &mut files).unwrap();
assert!(!std::str::from_utf8(&waiting).unwrap().contains("a=q,"));
assert!(std::str::from_utf8(&waiting).unwrap().contains("a=t,t=d"));
std::fs::remove_file(&probe).unwrap();
let mut ready = Vec::new();
write_composed_frame(&mut ready, b"text", &graphics, &mut files).unwrap();
let text = std::str::from_utf8(&ready).unwrap();
assert!(text.contains(&format!("before\x1b_Ga=t,t=t,f={format}")));
assert!(text.contains("\x1b\\after"));
assert!(!text.contains("AQIDBA=="));
let image = command_path(&ready, "a=t,");
assert_eq!(std::fs::read(&image).unwrap(), [1, 2, 3, 4]);
drop(files);
assert!(!image.exists());
}
}
#[test]
fn unsupported_format_and_file_creation_failure_remain_inline() {
let root = Scratch::new();
let mut files = FileTransport::for_test(root.0.clone());
let mut unsupported = Vec::new();
write_composed_frame(&mut unsupported, b"", &output(999), &mut files).unwrap();
assert!(std::str::from_utf8(&unsupported)
.unwrap()
.contains("a=t,t=d,f=999"));
assert_eq!(std::fs::read_dir(&root.0).unwrap().count(), 0);
let mut broken = FileTransport::for_test(root.0.join("absent"));
let mut actual = Vec::new();
write_composed_frame(&mut actual, b"", &output(32), &mut broken).unwrap();
let mut expected = Vec::new();
write_composed_frame(
&mut expected,
b"",
&output(32),
&mut FileTransport::default(),
)
.unwrap();
assert_eq!(actual, expected);
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Client-owned Kitty `t=t` files, prepared only at the final output boundary.
//!
//! A returned path is published: it must stay readable until the terminal unlinks
//! it, or the client shuts down. Never use `cleanup` to cancel a frame. This ledger
//! does not encode pixels or accept paths from a server. A silent raw-file query
//! gates conversion for the local Ghostty allowlist; an explicit kill switch
//! always wins. Local writes alone do not prove support. Non-consumption disables
//! new files for this client, without deleting files the terminal may still open.
use std::path::PathBuf;
#[derive(Default)]
pub(crate) struct FileTransport {
#[cfg(unix)]
inner: Option<crate::platform::unix_image_files::Ledger>,
}
impl FileTransport {
#[cfg(all(test, unix))]
pub(crate) fn for_test(root: PathBuf) -> Self {
Self {
inner: Some(crate::platform::unix_image_files::Ledger::for_test(root)),
}
}
pub(crate) fn from_environment() -> Self {
Self {
#[cfg(unix)]
inner: environment_allows().then(crate::platform::unix_image_files::Ledger::new),
}
}
/// Once per client, return a private 1x1 RGBA query payload. The output
/// boundary must emit this as `a=q,t=t,f=32,s=1,v=1,q=2` before inline image
/// output. No real upload is converted until Ghostty consumes this file.
pub(crate) fn probe(&mut self) -> Option<PathBuf> {
#[cfg(unix)]
{
self.inner.as_mut()?.probe()
}
#[cfg(not(unix))]
{
None
}
}
/// Returns a complete, closed file containing exactly `data`, or requests
/// inline fallback. The caller must preserve output ordering and encode the
/// returned local path as Kitty's base64 filename payload with `t=t`.
pub(crate) fn prepare(&mut self, data: &[u8]) -> Option<PathBuf> {
#[cfg(unix)]
{
self.inner.as_mut()?.prepare(data)
}
#[cfg(not(unix))]
{
let _ = data;
None
}
}
}
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::io::IsTerminal;
#[cfg(unix)]
fn environment_allows() -> bool {
allowed(
std::io::stdin().is_terminal(),
std::io::stdout().is_terminal(),
|key| std::env::var_os(key),
)
}
#[cfg(unix)]
fn allowed(stdin_tty: bool, stdout_tty: bool, env: impl Fn(&str) -> Option<OsString>) -> bool {
stdin_tty
&& stdout_tty
&& env("HERDR_KITTY_FILE_TRANSPORT").as_deref() != Some(std::ffi::OsStr::new("0"))
&& env("TERM_PROGRAM").as_deref() == Some(std::ffi::OsStr::new("ghostty"))
&& [
"SSH_CONNECTION",
"SSH_TTY",
"SSH_CLIENT",
"TMUX",
"STY",
"HERDR_PANE_ID",
"HERDR_PANE_RUNTIME_ID",
]
.iter()
.all(|key| env(key).is_none())
// Named-session selection (HERDR_SESSION) and HERDR_REMOTE_KEYBINDINGS
// deliberately do not affect client locality.
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn conservative_gate_without_process_environment_mutation() {
let base = |key: &str| match key {
"TERM_PROGRAM" => Some(OsString::from("ghostty")),
"HERDR_SESSION" => Some(OsString::from("named-session")),
"HERDR_REMOTE_KEYBINDINGS" => Some(OsString::from("1")),
_ => None,
};
assert!(allowed(true, true, base));
assert!(!allowed(false, true, base));
assert!(!allowed(true, false, base));
for key in [
"SSH_CONNECTION",
"SSH_TTY",
"SSH_CLIENT",
"TMUX",
"STY",
"HERDR_PANE_ID",
"HERDR_PANE_RUNTIME_ID",
] {
assert!(!allowed(true, true, |name| if name == key {
Some(OsString::new())
} else {
base(name)
}));
}
assert!(!allowed(true, true, |key| {
if key == "HERDR_KITTY_FILE_TRANSPORT" {
Some(OsString::from("0"))
} else {
base(key)
}
}));
assert!(allowed(true, true, |key| {
if key == "HERDR_KITTY_FILE_TRANSPORT" {
Some(OsString::from("1"))
} else {
base(key)
}
}));
assert!(!allowed(true, true, |key| if key == "TERM_PROGRAM" {
Some(OsString::from("kitty"))
} else {
base(key)
}));
}
#[test]
fn default_is_disabled() {
let mut transport = FileTransport::default();
assert!(transport.probe().is_none());
assert!(transport.prepare(b"inline").is_none());
}
}
+161 -82
View File
@@ -24,7 +24,10 @@ mod endpoint_commands;
mod errors;
mod events;
mod frame_output;
#[cfg(test)]
mod frame_output_tests;
mod handshake;
mod image_files;
mod input;
mod loop_config;
mod notifications;
@@ -48,6 +51,8 @@ use events::ClientLoopEvent;
use loop_config::ClientLoopConfig;
use shell_runtime::*;
use state::ClientState;
#[cfg(unix)]
use state::RetiredDirectGraphicsMatch;
use transport::*;
#[cfg(test)]
@@ -135,7 +140,7 @@ use tracing::{debug, info, warn};
use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
use crate::protocol::{self, ClientMessage, FrameData, ServerMessage, MAX_GRAPHICS_FRAME_SIZE};
use crate::protocol::{self, ClientMessage, ServerMessage, MAX_GRAPHICS_FRAME_SIZE};
#[cfg(test)]
use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind};
use crate::server::socket_paths::client_socket_path;
@@ -364,6 +369,22 @@ fn run_client_with_mode(
Ok(())
}
#[cfg(unix)]
fn graphics_owner_is_active(
state: &ClientState,
endpoints: &endpoint::EndpointRegistry,
owner: &endpoint::ClientEndpointId,
) -> bool {
endpoints.active_id() == owner
&& endpoints
.connection(owner)
.is_some_and(|connection| connection.surface_active)
&& state
.shell
.as_ref()
.is_some_and(|shell| shell.endpoint_is_active(owner))
}
/// The main client event loop.
///
/// Uses a threaded architecture:
@@ -392,6 +413,7 @@ async fn run_client_loop(
let mut state = ClientState {
blit_encoder: render_ansi::BlitEncoder::new(),
image_files: image_files::FileTransport::from_environment(),
mouse_capture_active: config.mouse_capture_active,
endpoint_mouse_capture_requested: false,
endpoint_sgr_pixels_requested: false,
@@ -410,7 +432,10 @@ async fn run_client_loop(
#[cfg(unix)]
direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())),
#[cfg(unix)]
retired_direct_graphics: None,
retired_direct_graphics: HashMap::new(),
#[cfg(unix)]
disabled_native_graphics: Default::default(),
pending_native_cleanup: Vec::new(),
#[cfg(unix)]
pending_surface_graphics: HashMap::new(),
attach_escape,
@@ -690,10 +715,10 @@ async fn run_client_loop(
let frozen = state.presentation_frozen;
state.presentation_frozen = false;
state.present_graphics(&cleanup);
if let Some(frame) = frame {
state.present_frame(frame);
}
state.presentation_frozen = frozen;
if let Some(frame) = frame {
state.present_frozen_chrome(frame);
}
}
}
}
@@ -937,42 +962,70 @@ async fn run_client_loop(
let pending_key = state
.pending_surface_graphics
.keys()
.find(|(_, transfer_id, image_id)| {
.find(|(_, _, transfer_id, image_id)| {
*transfer_id == response.transfer_id && *image_id == response.image_id
})
.cloned();
let owner = pending_key
.as_ref()
.map(|(endpoint_id, _, _)| endpoint_id.clone());
let composed = pending_key
.and_then(|key| {
state
.pending_surface_graphics
.remove(&key)
.map(|asset| (key, asset))
})
.filter(|_| response.success)
.and_then(|((endpoint_id, _, _), asset)| {
if write_stream.active_id() != &endpoint_id {
let pending = pending_key.and_then(|key| {
state
.pending_surface_graphics
.remove(&key)
.map(|asset| (key, asset))
});
let Some(((owner, _, _, _), asset)) = pending else {
// Raw/non-surface transfers retain their existing response handling.
continue;
};
let eligible = response.success
&& graphics_owner_is_active(&state, &write_stream, &owner)
&& !state.presentation_frozen
&& state.shell.as_ref().is_some_and(|shell| {
shell.accepts_direct_graphics_asset(&asset, response.image_id)
});
let size = state.reported_size;
let prepared = eligible
.then(|| {
let shell = state.shell.as_mut()?;
let checkpoint = shell.direct_graphics_checkpoint();
if !shell.trust_direct_graphics_asset(&asset, response.image_id) {
shell.restore_direct_graphics_checkpoint(checkpoint);
return None;
}
let shell = state.shell.as_mut()?;
shell
.trust_direct_graphics_asset(&asset, response.image_id)
.then(|| shell.compose(state.reported_size.0, state.reported_size.1))
.flatten()
});
match shell.compose(size.0, size.1) {
Some(frame) => Some((frame, checkpoint)),
None => {
shell.restore_direct_graphics_checkpoint(checkpoint);
None
}
}
})
.flatten();
let accepted = if let Some((frame, checkpoint)) = prepared {
if state.try_present_frame(frame) {
true
} else {
state
.shell
.as_mut()
.expect("prepared direct graphics requires a shell")
.restore_direct_graphics_checkpoint(checkpoint);
false
}
} else {
false
};
if !accepted {
// The upload may have reached the terminal even when its response, owner, or
// composed swap cannot be accepted. Never leave that unowned image resident.
// Restoring the checkpoint also restores old-bank stale-image bookkeeping.
state.queue_native_image_cleanup(response.image_id);
}
let message = ClientMessage::GraphicsTransmissionResult {
transfer_id: response.transfer_id,
image_id: response.image_id,
success: response.success,
success: accepted,
};
if let Some(owner) = owner {
write_stream.send_to(&owner, &message);
}
if let Some(frame) = composed {
state.present_frame(frame);
}
write_stream.send_to(&owner, &message);
}
#[cfg(unix)]
ClientLoopEvent::PixelMouse(data, geometry) => {
@@ -1228,6 +1281,8 @@ async fn run_client_loop(
shell.compose(state.reported_size.0, state.reported_size.1)
});
let reader_quit = writer.stop_handle();
#[cfg(unix)]
state.start_endpoint_graphics_generation(&endpoint_id, generation);
write_stream.insert(
endpoint_id.clone(),
writer,
@@ -1287,6 +1342,30 @@ async fn run_client_loop(
continue;
}
write_stream.received(&endpoint_id, generation, now);
// Retirements belong to the exact live connection, even after deactivation.
// They must not be dropped with frozen/inactive presentation effects.
if matches!(
message.as_ref(),
ServerMessage::GraphicsTransmissionRetired { .. }
) {
#[cfg(unix)]
if let ServerMessage::GraphicsTransmissionRetired {
transfer_id,
image_id,
} = message.as_ref()
{
let owner_active =
graphics_owner_is_active(&state, &write_stream, &endpoint_id);
state.receive_graphics_retirement(
&endpoint_id,
generation,
*transfer_id,
*image_id,
owner_active,
);
}
continue;
}
let endpoint_active = write_stream.active_id() == &endpoint_id
&& write_stream
.connection(&endpoint_id)
@@ -1465,30 +1544,57 @@ async fn run_client_loop(
} => {
#[cfg(unix)]
{
if state.retired_direct_graphics.take()
== Some((endpoint_id.clone(), transfer_id, image_id))
{
let retirement = state.match_retired_direct_graphics(
&endpoint_id,
generation,
transfer_id,
image_id,
);
if retirement == RetiredDirectGraphicsMatch::Exact {
continue;
}
let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) {
(Some(shell), Some(asset)) => {
crate::kitty_graphics::surface::host_image_id(
shell.graphics_scope(),
asset,
) == image_id
shell.accepts_direct_graphics_asset(asset, image_id)
}
(None, None) => true,
_ => false,
};
let valid = state.kitty_graphics_enabled
let native = surface_asset.as_ref().is_some_and(|asset| {
matches!(
asset.source,
crate::protocol::SurfaceGraphicsSource::Terminal { .. }
)
});
let native_valid = !native
|| surface_asset.as_ref().is_some_and(|asset| {
!state.presentation_frozen
&& state.disabled_native_graphics.get(&endpoint_id)
!= Some(&generation)
&& graphics_owner_is_active(
&state,
&write_stream,
&endpoint_id,
)
&& leading.is_empty()
&& asset.format
== crate::protocol::SurfaceGraphicsFormat::Rgba
&& expected_len == asset.data_len
&& control
== format!(
"a=t,f=32,s={},v={},i={image_id},q=0",
asset.image_width, asset.image_height
)
});
let valid = retirement != RetiredDirectGraphicsMatch::Saturated
&& state.kitty_graphics_enabled
&& native_valid
&& surface_asset_valid
&& usize::try_from(expected_len).ok().is_some_and(|len| {
crate::pane_graphics_files::validate_direct_source(
std::path::Path::new(&path),
len,
)
.is_ok()
&& direct_graphics::valid_control(&control, image_id, len)
let path = std::path::Path::new(&path);
let valid_source = crate::pane_graphics_files::validate_direct_source(path, len).is_ok()
|| (native && crate::pane_graphics_files::validate_native_source(path, len).is_ok());
valid_source && direct_graphics::valid_control(&control, image_id, len)
})
&& state
.direct_graphics_response
@@ -1503,8 +1609,9 @@ async fn run_client_loop(
&path,
);
let mut stdout = io::stdout();
let written = stdout
.write_all(&command)
let written = state
.flush_native_cleanup(&mut stdout)
.and_then(|()| stdout.write_all(&command))
.and_then(|()| stdout.flush())
.is_ok();
if written {
@@ -1517,7 +1624,7 @@ async fn run_client_loop(
if sent {
if let Some(asset) = surface_asset {
state.pending_surface_graphics.insert(
(endpoint_id.clone(), transfer_id, image_id),
(endpoint_id.clone(), generation, transfer_id, image_id),
asset,
);
}
@@ -1528,12 +1635,11 @@ async fn run_client_loop(
transfer_id,
image_id,
};
if let Err(err) = write_to_server(&mut write_stream, &started) {
return Err(ClientError::ConnectionLost(err));
}
write_stream.send_to(&endpoint_id, &started);
} else {
state.pending_surface_graphics.remove(&(
endpoint_id.clone(),
generation,
transfer_id,
image_id,
));
@@ -1549,9 +1655,7 @@ async fn run_client_loop(
image_id,
success: false,
};
if let Err(err) = write_to_server(&mut write_stream, &result) {
return Err(ClientError::ConnectionLost(err));
}
write_stream.send_to(&endpoint_id, &result);
}
}
#[cfg(not(unix))]
@@ -1565,33 +1669,8 @@ async fn run_client_loop(
surface_asset,
);
}
ServerMessage::GraphicsTransmissionRetired {
transfer_id,
image_id,
} => {
#[cfg(unix)]
{
state.retired_direct_graphics =
Some((endpoint_id.clone(), transfer_id, image_id));
state.pending_surface_graphics.remove(&(
endpoint_id.clone(),
transfer_id,
image_id,
));
let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| {
shell.retire_direct_graphics_image(image_id);
shell
.compose(state.reported_size.0, state.reported_size.1)
.map(|frame| frame.graphics)
.unwrap_or_else(|| shell.take_pending_graphics_cleanup())
});
state.present_graphics(&cleanup);
if let Ok(mut matcher) = state.direct_graphics_response.lock() {
matcher.retire(transfer_id);
}
}
#[cfg(not(unix))]
let _ = (transfer_id, image_id);
ServerMessage::GraphicsTransmissionRetired { .. } => {
unreachable!("retirements are handled before presentation gating")
}
ServerMessage::ServerShutdown { reason } => {
if !federated && endpoint_id.is_local() {
+8 -4
View File
@@ -147,7 +147,11 @@ impl ClientShellState {
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[])
}
pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> {
pub(crate) fn compose(
&mut self,
cols: u16,
rows: u16,
) -> Option<crate::client::frame_output::ComposedFrame> {
self.last_composed_at = Some(std::time::Instant::now());
self.selection_repaint_deadline = None;
if self.last_composed_size != Some((cols, rows)) && self.mode == ClientShellMode::Navigate {
@@ -167,7 +171,7 @@ impl ClientShellState {
&& self.navigation_target_valid(&pending.target)
});
if self.snapshot.is_none() || self.pane_surface.is_none() {
return Some(self.compose_unavailable(cols, rows));
return Some(self.compose_unavailable(cols, rows).into());
}
let snapshot = self.snapshot.as_deref()?;
// Do not compose a retained surface while waiting for its matching snapshot or
@@ -716,8 +720,8 @@ impl ClientShellState {
self.hits.pane_splits.clear();
self.hits.popup = None;
}
self.compose_graphics(&mut frame, layout, &occlusion);
Some(frame)
let graphics = self.compose_graphics(layout, &occlusion);
Some(crate::client::frame_output::ComposedFrame { frame, graphics })
}
}
+29 -5
View File
@@ -1,11 +1,36 @@
use super::*;
#[cfg(unix)]
pub(crate) struct ClientGraphicsCheckpoint(crate::kitty_graphics::surface::ClientState);
impl ClientShellState {
#[cfg(unix)]
#[cfg(all(test, unix))]
pub(crate) fn graphics_scope(&self) -> &str {
self.graphics.scope()
}
#[cfg(unix)]
pub(crate) fn accepts_direct_graphics_asset(
&self,
key: &crate::protocol::SurfaceGraphicsAssetKey,
image_id: u32,
) -> bool {
self.graphics.accepts_direct_asset(key, image_id)
}
#[cfg(unix)]
pub(crate) fn direct_graphics_checkpoint(&self) -> ClientGraphicsCheckpoint {
ClientGraphicsCheckpoint(self.graphics.clone())
}
#[cfg(unix)]
pub(crate) fn restore_direct_graphics_checkpoint(
&mut self,
checkpoint: ClientGraphicsCheckpoint,
) {
self.graphics = checkpoint.0;
}
#[cfg(unix)]
pub(crate) fn trust_direct_graphics_asset(
&mut self,
@@ -33,10 +58,9 @@ impl ClientShellState {
pub(super) fn compose_graphics(
&mut self,
frame: &mut FrameData,
layout: ClientShellLayout,
occlusion: &crate::kitty_graphics::surface::Occlusion,
) {
) -> crate::kitty_graphics::GraphicsOutput {
let visibility = if self.endpoint_error.is_some() {
crate::kitty_graphics::surface::Visibility::Hidden
} else if self.hits.popup.is_some() {
@@ -49,12 +73,12 @@ impl ClientShellState {
.popup
.as_ref()
.map(|popup| (popup.inner_rect.x, popup.inner_rect.y));
frame.graphics = self.graphics.encode(
self.graphics.encode_output(
visibility,
(layout.pane_surface.x, layout.pane_surface.y),
popup_origin,
self.graphics_cell_size,
occlusion,
);
)
}
}
+1 -1
View File
@@ -1015,7 +1015,7 @@ impl ClientShellState {
pane_surface_generation: None,
pane_surface: None,
pending_pane_surface: None,
graphics: crate::kitty_graphics::surface::ClientState::default(),
graphics: crate::kitty_graphics::surface::ClientState::new(),
graphics_cell_size: crate::kitty_graphics::HostCellSize {
width_px: 1,
height_px: 1,
+1 -1
View File
@@ -1527,7 +1527,7 @@ fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() {
navigator.query = "界".repeat(100).as_str().into();
let frame = state.compose(width, height).expect("navigator frame");
let popup = state.hits.navigator_popup;
let cursor = frame.cursor.expect("search cursor");
let cursor = frame.cursor.as_ref().expect("search cursor");
assert!(super::super::contains(popup, (cursor.x, cursor.y)));
assert!(state.hits.navigator_rows.is_empty());
assert!(popup.right() <= width && popup.bottom() <= height);
+124 -24
View File
@@ -63,6 +63,48 @@ fn add_main_image(
surface.graphics.placements.push(placement);
}
#[cfg(unix)]
#[test]
fn failed_direct_ack_composition_restores_graphics_for_inline_retry() {
use crate::kitty_graphics::surface::host_image_id;
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
let mut pane = surface();
let (mut asset, mut placement) = image(
SurfaceGraphicsTarget::Pane {
pane_id: "pane_1".into(),
},
0,
0,
71,
);
asset.key.source = SurfaceGraphicsSource::PaneLayer {
pane_id: "pane_1".into(),
layer_id: "direct-rollback".into(),
};
placement.asset = asset.key.clone();
let key = asset.key.clone();
pane.graphics.assets.push(asset);
pane.graphics.placements.push(placement);
state.set_pane_surface(pane);
// Model an ACK arriving while projection pairing makes composition unavailable.
state.pending_pane_surface = Some(surface());
let image_id = host_image_id(state.graphics_scope(), &key);
let graphics_before = format!("{:?}", state.graphics);
let checkpoint = state.direct_graphics_checkpoint();
assert!(state.trust_direct_graphics_asset(&key, image_id));
assert!(state.compose(106, 20).is_none());
state.restore_direct_graphics_checkpoint(checkpoint);
assert_eq!(format!("{:?}", state.graphics), graphics_before);
// Once pairing is available, fallback still owns the asset and uploads it normally.
state.pending_pane_surface = None;
let retry = state.compose(106, 20).expect("fallback frame");
assert!(String::from_utf8_lossy(&retry.graphics.into_inline_bytes()).contains("a=t"));
}
fn is_placed(bytes: &[u8], point: (u16, u16)) -> bool {
String::from_utf8_lossy(bytes).contains(&format!("\x1b[{};{}H", point.1 + 1, point.0 + 1))
}
@@ -85,11 +127,11 @@ fn assert_graphics_cover(state: &mut ClientShellState, covered: Rect, cols: u16,
state.set_pane_surface(surface);
let frame = state.compose(cols, rows).unwrap();
assert!(
is_placed(&frame.graphics, outside),
is_placed(&frame.graphics.clone().into_inline_bytes(), outside),
"outside={outside:?} cover={covered:?}"
);
assert!(
!is_placed(&frame.graphics, inside),
!is_placed(&frame.graphics.clone().into_inline_bytes(), inside),
"inside={inside:?} cover={covered:?}"
);
}
@@ -327,18 +369,38 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
state.compose(106, 40).unwrap();
state.overlay = Some(overlay);
let frame = state.compose(106, 40).unwrap();
assert!(is_placed(&frame.graphics, outside), "{:?}", state.overlay);
assert!(
is_placed(&frame.graphics.clone().into_inline_bytes(), outside),
"{:?}",
state.overlay
);
if let Some(border) = border {
assert!(!is_placed(&frame.graphics, border), "{:?}", state.overlay);
assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i"));
assert!(
!is_placed(&frame.graphics.clone().into_inline_bytes(), border),
"{:?}",
state.overlay
);
assert!(
String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes())
.contains("a=d,d=i")
);
}
state.overlay = None;
let restored = state.compose(106, 40).unwrap();
assert!(is_placed(&restored.graphics, outside));
assert!(is_placed(
&restored.graphics.clone().into_inline_bytes(),
outside
));
if let Some(border) = border {
assert!(is_placed(&restored.graphics, border));
assert!(is_placed(
&restored.graphics.clone().into_inline_bytes(),
border
));
}
assert!(!String::from_utf8_lossy(&restored.graphics).contains("a=t"));
assert!(
!String::from_utf8_lossy(&restored.graphics.clone().into_inline_bytes())
.contains("a=t")
);
}
}
@@ -377,17 +439,26 @@ fn selection_copy_cursor_and_search_hide_only_the_highlighted_images() {
});
let frame = state.compose(106, 20).unwrap();
for point in &points[..3] {
assert!(!is_placed(&frame.graphics, *point), "{point:?}");
assert!(
!is_placed(&frame.graphics.clone().into_inline_bytes(), *point),
"{point:?}"
);
}
assert!(is_placed(&frame.graphics, points[3]));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
points[3]
));
state.mode = ClientShellMode::Terminal;
state.copy_mode = None;
state.selection = None;
let frame = state.compose(106, 20).unwrap();
for point in points {
assert!(is_placed(&frame.graphics, point));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
point
));
}
assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t"));
assert!(!String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=t"));
}
#[test]
@@ -400,15 +471,26 @@ fn mobile_switcher_still_hides_the_entire_underlying_surface() {
add_main_image(&mut surface, layout, point, 1);
state.set_pane_surface(surface);
let frame = state.compose(40, 24).unwrap();
assert!(is_placed(&frame.graphics, point));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
point
));
state.mode = ClientShellMode::Navigate;
let frame = state.compose(40, 24).unwrap();
assert!(!is_placed(&frame.graphics, point));
assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i"));
assert!(!is_placed(
&frame.graphics.clone().into_inline_bytes(),
point
));
assert!(
String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=d,d=i")
);
state.mode = ClientShellMode::Terminal;
let frame = state.compose(40, 24).unwrap();
assert!(is_placed(&frame.graphics, point));
assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t"));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
point
));
assert!(!String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=t"));
}
#[test]
@@ -437,15 +519,33 @@ fn popup_terminal_keeps_own_graphics_and_hides_only_background_overlap() {
surface.graphics.placements.push(placement);
state.set_pane_surface(surface);
let frame = state.compose(106, 20).unwrap();
assert!(is_placed(&frame.graphics, outside));
assert!(!is_placed(&frame.graphics, border));
assert!(is_placed(&frame.graphics, inside));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
outside
));
assert!(!is_placed(
&frame.graphics.clone().into_inline_bytes(),
border
));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
inside
));
state.overlay = Some(ClientShellOverlay::Onboarding);
let frame = state.compose(106, 20).unwrap();
assert!(is_placed(&frame.graphics, outside));
assert!(!is_placed(&frame.graphics, inside));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
outside
));
assert!(!is_placed(
&frame.graphics.clone().into_inline_bytes(),
inside
));
state.overlay = None;
let frame = state.compose(106, 20).unwrap();
assert!(is_placed(&frame.graphics, inside));
assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t"));
assert!(is_placed(
&frame.graphics.clone().into_inline_bytes(),
inside
));
assert!(!String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=t"));
}
+10 -4
View File
@@ -216,18 +216,24 @@ fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility(
state.set_pane_surface(pane_surface);
let visible = state.compose(106, 20).expect("visible graphics frame");
let visible = String::from_utf8_lossy(&visible.graphics);
let visible = visible.graphics.clone().into_inline_bytes();
let visible = String::from_utf8_lossy(&visible);
assert!(visible.contains("a=t,t=d"));
assert!(visible.contains("\u{1b}[2;27H"));
state.overlay = Some(ClientShellOverlay::Onboarding);
let uncovered = state.compose(106, 20).expect("overlay frame");
assert!(!String::from_utf8_lossy(&uncovered.graphics).contains("a=d"));
assert!(String::from_utf8_lossy(&uncovered.graphics).contains("a=p"));
assert!(
!String::from_utf8_lossy(&uncovered.graphics.clone().into_inline_bytes()).contains("a=d")
);
assert!(
String::from_utf8_lossy(&uncovered.graphics.clone().into_inline_bytes()).contains("a=p")
);
state.overlay = None;
let restored = state.compose(106, 20).expect("restored graphics frame");
let restored = String::from_utf8_lossy(&restored.graphics);
let restored = restored.graphics.clone().into_inline_bytes();
let restored = String::from_utf8_lossy(&restored);
assert!(restored.contains("a=p"));
assert!(!restored.contains("a=t,t=d"));
}
+1 -1
View File
@@ -384,7 +384,7 @@ fn every_field_renders_long_unicode_across_resize_without_mutation() {
for (width, height) in [(120, 40), (60, 20), (12, 6), (1, 1), (120, 40)] {
let before = editor(&mut state).clone();
if let Some(frame) = state.compose(width, height) {
if let Some(cursor) = frame.cursor.filter(|cursor| cursor.visible) {
if let Some(cursor) = frame.cursor.as_ref().filter(|cursor| cursor.visible) {
assert!(cursor.x < width && cursor.y < height, "field {field}");
}
}
+3 -11
View File
@@ -385,14 +385,6 @@ pub(super) fn complete_endpoint_activation(
completion,
endpoint::ActivationCompletion::AwaitingPresentationSync { .. }
) {
#[cfg(unix)]
if let endpoint::ActivationCompletion::AwaitingPresentationSync { previous, endpoint } =
&completion
{
if previous != endpoint {
state.retire_endpoint_graphics(previous);
}
}
// The coherent target frame can replace the frozen source now, but the registry keeps
// pane input disabled until a second projection epoch has replayed host modes/effects.
state.unfreeze_presentation();
@@ -511,7 +503,7 @@ pub(super) fn handle_endpoint_disconnect(
) -> bool {
supervisors.disconnected(endpoint_id, generation, now);
#[cfg(unix)]
state.retire_endpoint_graphics(endpoint_id);
state.retire_endpoint_graphics(endpoint_id, generation);
if pending_activation
.as_ref()
.is_some_and(|pending| pending.involves_endpoint(endpoint_id))
@@ -572,7 +564,7 @@ pub(super) fn handle_endpoint_attention(
now,
);
#[cfg(unix)]
state.retire_endpoint_graphics(endpoint_id);
state.retire_endpoint_graphics(endpoint_id, generation);
if pending_activation
.as_ref()
.is_some_and(|pending| pending.involves_endpoint(endpoint_id))
@@ -681,7 +673,7 @@ pub(super) fn install_client_shell_snapshot(
pub(super) fn finish_client_shell_input(
state: &mut ClientState,
outcome: shell::ClientShellInput,
frame: Option<FrameData>,
frame: Option<super::frame_output::ComposedFrame>,
endpoints: &mut endpoint::EndpointRegistry,
pending_activation: &mut Option<endpoint::PendingEndpointActivation>,
endpoint_commands: &mut endpoint_commands::EndpointCommands,
+592 -27
View File
@@ -1,9 +1,39 @@
use super::*;
#[cfg(unix)]
const MAX_RETIRED_DIRECT_GRAPHICS: usize = 64;
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum RetiredDirectGraphicsMatch {
None,
Exact,
Saturated,
}
#[cfg(unix)]
pub(super) struct RetiredDirectGraphics {
generation: u64,
transfers: Vec<(u64, u32)>,
saturated: bool,
}
#[cfg(unix)]
impl RetiredDirectGraphics {
fn new(generation: u64) -> Self {
Self {
generation,
transfers: Vec::new(),
saturated: false,
}
}
}
/// State tracking for the thin client.
pub(super) struct ClientState {
/// Stateful semantic-frame encoder used when the server sends FrameData.
pub(super) blit_encoder: render_ansi::BlitEncoder,
pub(super) image_files: image_files::FileTransport,
pub(super) mouse_capture_active: bool,
pub(super) endpoint_mouse_capture_requested: bool,
pub(super) endpoint_sgr_pixels_requested: bool,
@@ -24,10 +54,15 @@ pub(super) struct ClientState {
#[cfg(unix)]
pub(super) direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>,
#[cfg(unix)]
pub(super) retired_direct_graphics: Option<(endpoint::ClientEndpointId, u64, u32)>,
pub(super) retired_direct_graphics: HashMap<endpoint::ClientEndpointId, RetiredDirectGraphics>,
#[cfg(unix)]
pub(super) pending_surface_graphics:
HashMap<(endpoint::ClientEndpointId, u64, u32), crate::protocol::SurfaceGraphicsAssetKey>,
pub(super) disabled_native_graphics: HashMap<endpoint::ClientEndpointId, u64>,
pub(super) pending_native_cleanup: Vec<u8>,
#[cfg(unix)]
pub(super) pending_surface_graphics: HashMap<
(endpoint::ClientEndpointId, u64, u64, u32),
crate::protocol::SurfaceGraphicsAssetKey,
>,
pub(super) attach_escape: Option<AttachEscapeState>,
#[cfg(unix)]
pub(super) mouse_scroll_lines: usize,
@@ -63,6 +98,7 @@ impl ClientState {
pub(super) fn test_new() -> Self {
Self {
blit_encoder: render_ansi::BlitEncoder::new(),
image_files: image_files::FileTransport::default(),
mouse_capture_active: false,
endpoint_mouse_capture_requested: false,
endpoint_sgr_pixels_requested: false,
@@ -81,7 +117,10 @@ impl ClientState {
#[cfg(unix)]
direct_graphics_response: Default::default(),
#[cfg(unix)]
retired_direct_graphics: None,
retired_direct_graphics: HashMap::new(),
#[cfg(unix)]
disabled_native_graphics: Default::default(),
pending_native_cleanup: Vec::new(),
#[cfg(unix)]
pending_surface_graphics: HashMap::new(),
attach_escape: None,
@@ -162,11 +201,109 @@ impl ClientState {
/// Present a composed error/chrome frame while retaining the handoff input freeze. The pane
/// cells are still the last coherent surface; only client chrome (including the error) moves.
pub(super) fn present_frozen_chrome(&mut self, frame_data: FrameData) {
pub(super) fn present_frozen_chrome(
&mut self,
frame_data: impl Into<frame_output::ComposedFrame>,
) {
let frozen = self.presentation_frozen;
// Chrome can repaint the frozen source, but cannot retire its staged images.
let deferred_cleanup = frozen.then(|| std::mem::take(&mut self.pending_native_cleanup));
self.presentation_frozen = false;
self.present_frame(frame_data);
self.presentation_frozen = frozen;
if let Some(cleanup) = deferred_cleanup {
self.pending_native_cleanup = cleanup;
}
}
#[cfg(unix)]
pub(super) fn queue_native_image_cleanup(&mut self, image_id: u32) {
crate::kitty_graphics::encode_delete_image(&mut self.pending_native_cleanup, image_id);
}
/// Retirement is lifecycle bookkeeping, not a presentation effect. The caller has
/// already checked the endpoint generation, even for inactive/frozen owners.
#[cfg(unix)]
pub(super) fn receive_graphics_retirement(
&mut self,
endpoint_id: &endpoint::ClientEndpointId,
generation: u64,
transfer_id: u64,
image_id: u32,
owner_active: bool,
) {
if transfer_id & crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT != 0 {
self.disabled_native_graphics
.insert(endpoint_id.clone(), generation);
}
self.record_retired_direct_graphics(endpoint_id.clone(), generation, transfer_id, image_id);
let upload_pending = self
.pending_surface_graphics
.remove(&(endpoint_id.clone(), generation, transfer_id, image_id))
.is_some();
// Retirement cleanup is terminal-owned. A native retirement owns the
// ID only while its exact upload is pending: collision rejection and a
// late post-ACK retirement must not delete the currently visible bank.
// API/non-native retirement retains its prior ownership behavior.
let owns_image = self.queue_retired_graphics_cleanup(
transfer_id,
image_id,
upload_pending,
owner_active,
);
let frame = if owns_image && owner_active {
let frozen = self.presentation_frozen;
let size = self.reported_size;
self.shell.as_mut().and_then(|shell| {
shell.retire_direct_graphics_image(image_id);
(!frozen).then(|| shell.compose(size.0, size.1)).flatten()
})
} else {
None
};
// A retirement repaint is a normal synchronized frame. In particular,
// do not extract its graphics into a graphics-only swap.
if let Some(frame) = frame {
self.present_frame(frame);
}
if let Ok(mut matcher) = self.direct_graphics_response.lock() {
matcher.retire(transfer_id);
}
}
/// Returns whether a retirement owns an image that may be removed from the terminal/cache.
/// Native rejections can retire a proposed colliding ID without ever uploading it; only an
/// exact still-pending native upload gives that retirement ownership of the image.
#[cfg(unix)]
pub(super) fn queue_retired_graphics_cleanup(
&mut self,
transfer_id: u64,
image_id: u32,
upload_pending: bool,
owner_active: bool,
) -> bool {
let native = transfer_id & crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT != 0;
if native && !upload_pending {
return false;
}
if native || !owner_active {
self.queue_native_image_cleanup(image_id);
}
true
}
#[cfg(unix)]
pub(super) fn flush_native_cleanup(
&mut self,
writer: &mut impl std::io::Write,
) -> io::Result<()> {
if self.presentation_frozen || self.pending_native_cleanup.is_empty() {
return Ok(());
}
writer.write_all(&self.pending_native_cleanup)?;
writer.flush()?;
self.pending_native_cleanup.clear();
Ok(())
}
pub(super) fn present_graphics(&mut self, graphics: &[u8]) {
@@ -182,7 +319,10 @@ impl ClientState {
&mut self,
patch: shell::ClientComposedSurfacePatch,
) -> io::Result<bool> {
if self.presentation_frozen || self.repaint_pending {
if self.presentation_frozen
|| self.repaint_pending
|| (self.kitty_graphics_enabled && !self.pending_native_cleanup.is_empty())
{
crate::render_prof::event("client_surface_patch.fallback.repaint");
return Ok(false);
}
@@ -222,21 +362,28 @@ impl ClientState {
}
#[cfg(unix)]
pub(super) fn retire_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) {
let transfer_ids = self
fn retire_pending_endpoint_graphics(
&mut self,
endpoint_id: &endpoint::ClientEndpointId,
generation: Option<u64>,
) {
let retired = self
.pending_surface_graphics
.keys()
.filter(|(owner, _, _)| owner == endpoint_id)
.map(|(_, transfer_id, _)| *transfer_id)
.filter(|(owner, pending_generation, _, _)| {
owner == endpoint_id
&& generation.is_none_or(|generation| *pending_generation == generation)
})
.cloned()
.collect::<Vec<_>>();
self.pending_surface_graphics
.retain(|(owner, _, _), _| owner != endpoint_id);
if self
.retired_direct_graphics
.as_ref()
.is_some_and(|(owner, _, _)| owner == endpoint_id)
{
self.retired_direct_graphics = None;
let mut transfer_ids = Vec::with_capacity(retired.len());
for key in retired {
let (_, _, transfer_id, image_id) = &key;
if transfer_id & crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT != 0 {
self.queue_native_image_cleanup(*image_id);
}
transfer_ids.push(*transfer_id);
self.pending_surface_graphics.remove(&key);
}
if let Ok(mut matcher) = self.direct_graphics_response.lock() {
for transfer_id in transfer_ids {
@@ -245,10 +392,162 @@ impl ClientState {
}
}
pub(super) fn present_frame(&mut self, frame_data: FrameData) {
if self.presentation_frozen {
#[cfg(unix)]
pub(super) fn record_retired_direct_graphics(
&mut self,
endpoint_id: endpoint::ClientEndpointId,
generation: u64,
transfer_id: u64,
image_id: u32,
) {
let retired = self
.retired_direct_graphics
.entry(endpoint_id)
.or_insert_with(|| RetiredDirectGraphics::new(generation));
if retired.generation != generation {
*retired = RetiredDirectGraphics::new(generation);
}
if retired.saturated || retired.transfers.contains(&(transfer_id, image_id)) {
return;
}
if retired.transfers.len() == MAX_RETIRED_DIRECT_GRAPHICS {
// Never evict an older tombstone: a delayed file for an evicted tuple could otherwise
// overwrite a visible image. Reject every unrecognized file until generation reset.
retired.saturated = true;
return;
}
retired.transfers.push((transfer_id, image_id));
}
#[cfg(unix)]
pub(super) fn match_retired_direct_graphics(
&mut self,
endpoint_id: &endpoint::ClientEndpointId,
generation: u64,
transfer_id: u64,
image_id: u32,
) -> RetiredDirectGraphicsMatch {
let Some(retired) = self.retired_direct_graphics.get_mut(endpoint_id) else {
return RetiredDirectGraphicsMatch::None;
};
if retired.generation != generation {
return RetiredDirectGraphicsMatch::None;
}
if let Some(index) = retired
.transfers
.iter()
.position(|transfer| *transfer == (transfer_id, image_id))
{
retired.transfers.swap_remove(index);
if retired.transfers.is_empty() && !retired.saturated {
self.retired_direct_graphics.remove(endpoint_id);
}
return RetiredDirectGraphicsMatch::Exact;
}
if retired.saturated {
RetiredDirectGraphicsMatch::Saturated
} else {
RetiredDirectGraphicsMatch::None
}
}
/// Forget graphics guards only for the connection generation which actually disconnected.
/// A delayed disconnect from an older generation must not clear its replacement's guards.
#[cfg(unix)]
pub(super) fn retire_endpoint_graphics(
&mut self,
endpoint_id: &endpoint::ClientEndpointId,
generation: u64,
) {
self.retire_pending_endpoint_graphics(endpoint_id, Some(generation));
if self.disabled_native_graphics.get(endpoint_id) == Some(&generation) {
self.disabled_native_graphics.remove(endpoint_id);
}
if self
.retired_direct_graphics
.get(endpoint_id)
.is_some_and(|retired| retired.generation == generation)
{
self.retired_direct_graphics.remove(endpoint_id);
}
}
/// Reset bounded per-endpoint state when a replacement connection becomes authoritative.
#[cfg(unix)]
pub(super) fn start_endpoint_graphics_generation(
&mut self,
endpoint_id: &endpoint::ClientEndpointId,
generation: u64,
) {
self.retire_pending_endpoint_graphics(endpoint_id, None);
if self.disabled_native_graphics.get(endpoint_id) != Some(&generation) {
self.disabled_native_graphics.remove(endpoint_id);
}
if self
.retired_direct_graphics
.get(endpoint_id)
.is_some_and(|retired| retired.generation != generation)
{
self.retired_direct_graphics.remove(endpoint_id);
}
}
#[cfg(unix)]
pub(super) fn forget_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) {
self.retire_pending_endpoint_graphics(endpoint_id, None);
self.disabled_native_graphics.remove(endpoint_id);
self.retired_direct_graphics.remove(endpoint_id);
}
pub(super) fn present_frame(&mut self, frame_data: impl Into<frame_output::ComposedFrame>) {
let _ = self.try_present_frame(frame_data);
}
fn write_composed_output(
&mut self,
writer: &mut impl io::Write,
encoded: &[u8],
mut graphics: crate::kitty_graphics::GraphicsOutput,
) -> io::Result<()> {
if self.kitty_graphics_enabled {
if !self.pending_native_cleanup.is_empty() {
graphics.operations.insert(
0,
crate::kitty_graphics::GraphicsOperation::Bytes(
self.pending_native_cleanup.clone(),
),
);
}
frame_output::write_composed_frame(
writer.by_ref(),
encoded,
&graphics,
&mut self.image_files,
)?;
} else {
writer.write_all(encoded)?;
}
writer.flush()?;
if self.kitty_graphics_enabled {
self.pending_native_cleanup.clear();
}
Ok(())
}
/// Presents and commits a frame only after all terminal output has been written successfully.
/// Callers which acknowledge presentation-sensitive work use the return value rather than
/// treating composition as presentation.
pub(super) fn try_present_frame(
&mut self,
frame_data: impl Into<frame_output::ComposedFrame>,
) -> bool {
if self.presentation_frozen {
return false;
}
let frame_output::ComposedFrame {
frame: frame_data,
graphics,
} = frame_data.into();
let frame_data = if self.draw_host_cursor {
render_ansi::frame_with_drawn_cursor(frame_data)
} else {
@@ -261,14 +560,280 @@ impl ClientState {
self.blit_encoder.encode(&frame_data, self.repaint_pending)
};
let mut stdout = io::stdout();
let graphics = if self.kitty_graphics_enabled {
frame_data.graphics.as_slice()
} else {
&[]
};
let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics);
let _ = stdout.flush();
if let Err(error) = self.write_composed_output(&mut stdout, &encoded.bytes, graphics) {
tracing::warn!(%error, "failed to present client frame");
self.repaint_pending = true;
return false;
}
self.blit_encoder.commit(frame_data, encoded);
self.repaint_pending = false;
true
}
}
#[cfg(all(test, unix))]
mod native_cleanup_tests {
use super::*;
#[test]
fn retired_graphics_tombstones_survive_handoff_and_only_exact_files_consume_them() {
let mut state = ClientState::test_new();
let endpoint = endpoint::ClientEndpointId::Local;
state.record_retired_direct_graphics(endpoint.clone(), 7, 11, 1234);
state.record_retired_direct_graphics(endpoint.clone(), 7, 12, 5678);
state.disabled_native_graphics.insert(endpoint.clone(), 7);
// Switching away and back does not reset guards, and an unrelated queued file cannot
// consume either of two sequential API retirement tombstones.
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 7, 13, 9999),
RetiredDirectGraphicsMatch::None
);
assert_eq!(
state
.retired_direct_graphics
.get(&endpoint)
.unwrap()
.transfers
.len(),
2
);
assert_eq!(state.disabled_native_graphics.get(&endpoint), Some(&7));
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 7, 11, 1234),
RetiredDirectGraphicsMatch::Exact
);
assert!(state
.retired_direct_graphics
.get(&endpoint)
.unwrap()
.transfers
.contains(&(12, 5678)));
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 7, 12, 5678),
RetiredDirectGraphicsMatch::Exact
);
assert!(!state.retired_direct_graphics.contains_key(&endpoint));
}
#[test]
fn replacement_generation_resets_bounded_graphics_guards() {
let mut state = ClientState::test_new();
let endpoint = endpoint::ClientEndpointId::Local;
let native = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 11;
state.record_retired_direct_graphics(endpoint.clone(), 7, native, 1234);
state.disabled_native_graphics.insert(endpoint.clone(), 7);
state.start_endpoint_graphics_generation(&endpoint, 8);
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 7, native, 1234),
RetiredDirectGraphicsMatch::None
);
assert!(!state.disabled_native_graphics.contains_key(&endpoint));
}
#[test]
fn retired_graphics_tombstone_overflow_fails_closed_until_generation_reset() {
let mut state = ClientState::test_new();
let endpoint = endpoint::ClientEndpointId::Local;
for transfer_id in 0..=MAX_RETIRED_DIRECT_GRAPHICS as u64 {
state.record_retired_direct_graphics(
endpoint.clone(),
7,
transfer_id,
transfer_id as u32,
);
}
let retired = state.retired_direct_graphics.get(&endpoint).unwrap();
assert_eq!(retired.transfers.len(), MAX_RETIRED_DIRECT_GRAPHICS);
assert!(retired.saturated);
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 7, 1, 1),
RetiredDirectGraphicsMatch::Exact
);
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 7, 10_000, 10_000),
RetiredDirectGraphicsMatch::Saturated
);
assert!(
state
.retired_direct_graphics
.get(&endpoint)
.unwrap()
.saturated
);
state.start_endpoint_graphics_generation(&endpoint, 8);
assert!(!state.retired_direct_graphics.contains_key(&endpoint));
state.record_retired_direct_graphics(endpoint.clone(), 8, 1, 2);
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 8, 9, 9),
RetiredDirectGraphicsMatch::None
);
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 8, 1, 2),
RetiredDirectGraphicsMatch::Exact
);
}
#[test]
fn pending_none_native_retirement_does_not_touch_cleanup_or_shell_cache() {
let mut state = ClientState::test_new();
state.pending_native_cleanup.extend_from_slice(b"existing");
let transfer = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 7;
assert!(!state.queue_retired_graphics_cleanup(transfer, 1234, false, true));
assert_eq!(state.pending_native_cleanup, b"existing");
assert!(state
.shell
.as_mut()
.expect("test shell")
.take_pending_graphics_cleanup()
.is_empty());
}
#[test]
fn queued_cleanup_is_synchronized_and_respects_graphics_capability() {
let mut state = ClientState::test_new();
state.kitty_graphics_enabled = true;
state.queue_native_image_cleanup(42);
let encoded = b"\x1b[?2026htext\x1b[?2026l";
let mut output = Vec::new();
state
.write_composed_output(&mut output, encoded, Default::default())
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.find("\x1b[?2026h").unwrap() < text.find("a=d,d=I,i=42").unwrap());
assert!(text.find("a=d,d=I,i=42").unwrap() < text.find("\x1b[?2026l").unwrap());
assert!(state.pending_native_cleanup.is_empty());
state.kitty_graphics_enabled = false;
state.queue_native_image_cleanup(43);
let mut output = Vec::new();
state
.write_composed_output(&mut output, encoded, Default::default())
.unwrap();
assert_eq!(output, encoded);
assert!(!state.pending_native_cleanup.is_empty());
state.kitty_graphics_enabled = true;
let mut no_capacity = &mut [][..];
assert!(state
.write_composed_output(&mut no_capacity, encoded, Default::default())
.is_err());
assert!(!state.pending_native_cleanup.is_empty());
}
#[test]
fn active_api_retirement_leaves_cleanup_to_composition() {
let mut state = ClientState::test_new();
assert!(state.queue_retired_graphics_cleanup(7, 1234, true, true));
assert!(state.pending_native_cleanup.is_empty());
assert!(state.queue_retired_graphics_cleanup(8, 5678, true, false));
assert!(!state.pending_native_cleanup.is_empty());
}
#[test]
fn unacknowledged_native_upload_cleanup_survives_frozen_disconnect() {
let mut state = ClientState::test_new();
let endpoint = endpoint::ClientEndpointId::Local;
let key = crate::protocol::SurfaceGraphicsAssetKey {
source: crate::protocol::SurfaceGraphicsSource::Terminal {
target: crate::protocol::SurfaceGraphicsTarget::Pane {
pane_id: "p".into(),
},
image_id: 1,
},
image_width: 1,
image_height: 1,
format: crate::protocol::SurfaceGraphicsFormat::Rgba,
data_len: 4,
data_fingerprint: 1,
};
let transfer = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 2;
state
.pending_surface_graphics
.insert((endpoint.clone(), 9, transfer, 1234), key);
state.presentation_frozen = true;
state.retire_endpoint_graphics(&endpoint, 9);
assert!(state.pending_surface_graphics.is_empty());
let mut output = Vec::new();
state.flush_native_cleanup(&mut output).unwrap();
assert!(output.is_empty());
assert!(!state.pending_native_cleanup.is_empty());
state.unfreeze_presentation();
state.flush_native_cleanup(&mut output).unwrap();
assert!(String::from_utf8(output.clone())
.unwrap()
.contains("a=d,d=I,i=1234"));
state.flush_native_cleanup(&mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap().matches("a=d,").count(),
1
);
}
#[test]
fn unacknowledged_native_upload_retirement_survives_frozen_handoff() {
let mut state = ClientState::test_new();
let endpoint = endpoint::ClientEndpointId::Local;
let key = crate::protocol::SurfaceGraphicsAssetKey {
source: crate::protocol::SurfaceGraphicsSource::Terminal {
target: crate::protocol::SurfaceGraphicsTarget::Pane {
pane_id: "p".into(),
},
image_id: 1,
},
image_width: 1,
image_height: 1,
format: crate::protocol::SurfaceGraphicsFormat::Rgba,
data_len: 4,
data_fingerprint: 1,
};
let transfer = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 2;
state
.pending_surface_graphics
.insert((endpoint.clone(), 9, transfer, 1234), key);
state.presentation_frozen = true;
// The source connection is still live, but no longer owns the active shell.
state.receive_graphics_retirement(&endpoint, 8, transfer, 1234, false);
assert_eq!(state.pending_surface_graphics.len(), 1);
assert!(state.pending_native_cleanup.is_empty());
state.receive_graphics_retirement(&endpoint, 9, transfer, 1234, false);
assert_eq!(
state.match_retired_direct_graphics(&endpoint, 9, transfer, 1234),
RetiredDirectGraphicsMatch::Exact
);
assert!(state
.shell
.as_mut()
.unwrap()
.take_pending_graphics_cleanup()
.is_empty());
assert!(state.pending_surface_graphics.is_empty());
let mut output = Vec::new();
state.flush_native_cleanup(&mut output).unwrap();
assert!(output.is_empty());
assert!(!state.pending_native_cleanup.is_empty());
let cleanup = state.pending_native_cleanup.clone();
let frame = crate::protocol::FrameData::from_ratatui_buffer(
&ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 1, 1)),
None,
);
state.kitty_graphics_enabled = true;
assert!(!state.try_present_frame(frame.clone()));
assert_eq!(state.pending_native_cleanup, cleanup);
state.present_frozen_chrome(frame);
assert!(state.presentation_frozen);
assert_eq!(state.pending_native_cleanup, cleanup);
state.unfreeze_presentation();
state.flush_native_cleanup(&mut output).unwrap();
assert!(String::from_utf8(output.clone())
.unwrap()
.contains("a=d,d=I,i=1234"));
state.flush_native_cleanup(&mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap().matches("a=d,").count(),
1
);
}
}
+92 -4
View File
@@ -2069,16 +2069,28 @@ pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT:
#[doc = " Compression of the image. Always\n GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are\n inflated before storage.\n\n Output type: GhosttyKittyImageCompression *"]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION:
GhosttyKittyGraphicsImageData = 6;
#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when\n the image metadata is resident but its pixel payload is pending.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload this\n directly to the GPU without any decode step.\n\n For an animated image (Kitty graphics animation, actions a=f/a=a)\n this is the pixel data of the current animation frame. The\n image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the\n current frame changes, so generation-keyed caches remain\n coherent.\n\n Output type: const uint8_t **"]
#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when\n the image metadata is resident but its pixel payload is pending, or\n when the experimental PRESERVE_PNG option retained encoded PNG data.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are normally decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload returned pixels\n directly to the GPU without any decode step.\n\n For an animated image (Kitty graphics animation, actions a=f/a=a)\n this is the pixel data of the current animation frame. The\n image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the\n current frame changes, so generation-keyed caches remain\n coherent.\n\n Output type: const uint8_t **"]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR:
GhosttyKittyGraphicsImageData = 7;
#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format. For a\n pending image, this is the expected length reserved against the\n storage limit even though DATA_PTR is not available yet.\n\n Output type: size_t *"]
#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format. For a\n pending or encoded PNG image, this is the expected decoded length reserved against the\n storage limit even though DATA_PTR is not available yet.\n\n Output type: size_t *"]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN:
GhosttyKittyGraphicsImageData = 8;
#[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers. Pending\n payload completion preserves this value to retain image age; consumers\n detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION\n and retry DATA_PTR.\n\n Output type: uint64_t *"]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION:
GhosttyKittyGraphicsImageData = 9;
#[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers. Pending\n payload completion preserves this value to retain image age; consumers\n detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION\n and retry DATA_PTR.\n\n Output type: uint64_t *"]
#[doc = " Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE\n unless experimentally retained. Output type: const uint8_t **."]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR:
GhosttyKittyGraphicsImageData = 10;
#[doc = " Encoded PNG length; NO_VALUE unless retained. Output type: size_t *."]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN:
GhosttyKittyGraphicsImageData = 11;
#[doc = " Borrowed void* backing context; valid only while image is unchanged."]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT:
GhosttyKittyGraphicsImageData = 12;
#[doc = " uint64_t immutable revision fingerprint; never reads image pixels."]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY:
GhosttyKittyGraphicsImageData = 13;
#[doc = " uint64_t immutable revision fingerprint; never reads image pixels."]
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE:
GhosttyKittyGraphicsImageData = 2147483647;
#[doc = " Queryable data kinds for ghostty_kitty_graphics_image_get().\n\n @ingroup kitty_graphics"]
@@ -2282,6 +2294,76 @@ unsafe extern "C" {
out_info: *mut GhosttyKittyGraphicsPlacementRenderInfo,
) -> GhosttyResult;
}
#[doc = " Borrowed opened descriptor; size is sizeof this request. No path is supplied."]
#[repr(C)]
#[derive(Debug, Default, Copy, Clone)]
pub struct GhosttyKittyImageSnapshotFileRequest {
pub size: usize,
pub fd: i64,
pub expected_len: u64,
}
#[allow(clippy::unnecessary_operation, clippy::identity_op)]
const _: () = {
["Size of GhosttyKittyImageSnapshotFileRequest"]
[::std::mem::size_of::<GhosttyKittyImageSnapshotFileRequest>() - 24usize];
["Alignment of GhosttyKittyImageSnapshotFileRequest"]
[::std::mem::align_of::<GhosttyKittyImageSnapshotFileRequest>() - 8usize];
["Offset of field: GhosttyKittyImageSnapshotFileRequest::size"]
[::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, size) - 0usize];
["Offset of field: GhosttyKittyImageSnapshotFileRequest::fd"]
[::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, fd) - 8usize];
["Offset of field: GhosttyKittyImageSnapshotFileRequest::expected_len"]
[::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, expected_len) - 16usize];
};
pub type GhosttyKittyImageFileReadFn = ::std::option::Option<
unsafe extern "C" fn(context: *mut ::std::os::raw::c_void, dest: *mut u8, len: usize) -> bool,
>;
pub type GhosttyKittyImageFileReleaseFn =
::std::option::Option<unsafe extern "C" fn(context: *mut ::std::os::raw::c_void)>;
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct GhosttyKittyImageFileBacking {
pub context: *mut ::std::os::raw::c_void,
pub identity: u64,
pub len: usize,
pub read: GhosttyKittyImageFileReadFn,
pub release: GhosttyKittyImageFileReleaseFn,
}
#[allow(clippy::unnecessary_operation, clippy::identity_op)]
const _: () = {
["Size of GhosttyKittyImageFileBacking"]
[::std::mem::size_of::<GhosttyKittyImageFileBacking>() - 40usize];
["Alignment of GhosttyKittyImageFileBacking"]
[::std::mem::align_of::<GhosttyKittyImageFileBacking>() - 8usize];
["Offset of field: GhosttyKittyImageFileBacking::context"]
[::std::mem::offset_of!(GhosttyKittyImageFileBacking, context) - 0usize];
["Offset of field: GhosttyKittyImageFileBacking::identity"]
[::std::mem::offset_of!(GhosttyKittyImageFileBacking, identity) - 8usize];
["Offset of field: GhosttyKittyImageFileBacking::len"]
[::std::mem::offset_of!(GhosttyKittyImageFileBacking, len) - 16usize];
["Offset of field: GhosttyKittyImageFileBacking::read"]
[::std::mem::offset_of!(GhosttyKittyImageFileBacking, read) - 24usize];
["Offset of field: GhosttyKittyImageFileBacking::release"]
[::std::mem::offset_of!(GhosttyKittyImageFileBacking, release) - 32usize];
};
impl Default for GhosttyKittyImageFileBacking {
fn default() -> Self {
let mut s = ::std::mem::MaybeUninit::<Self>::uninit();
unsafe {
::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1);
s.assume_init()
}
}
}
#[doc = " Optional synchronous callback, default NULL. Verify exact descriptor size\n and snapshot immutable bytes before returning true. True transfers one context\n reference; false transfers nothing. Do not close the borrowed descriptor or\n reenter the terminal. Read must fill exactly len bytes; release is called once.\n identity identifies the immutable revision, not a pathname or source inode."]
pub type GhosttyKittyImageSnapshotFileFn = ::std::option::Option<
unsafe extern "C" fn(
terminal: GhosttyTerminal,
userdata: *mut ::std::os::raw::c_void,
request: *const GhosttyKittyImageSnapshotFileRequest,
out_backing: *mut GhosttyKittyImageFileBacking,
) -> bool,
>;
#[doc = " Perform one bounded compression step suitable for idle scheduling."]
pub const GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL:
GhosttyTerminalCompressionMode = 0;
@@ -3119,7 +3201,13 @@ pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ: GhosttyTerm
#[doc = " Set the maximum total decoded bytes a single Kitty clipboard protocol\n (OSC 5522) write transaction may accumulate. The limit is captured\n when a transaction begins; an in-flight transaction keeps the limit\n it started with.\n\n Data beyond the limit fails the whole transaction with EFBIG. The\n transaction is discarded, later write-related packets are ignored\n until a new write begins, and nothing reaches the clipboard write\n callback.\n\n Transactions are buffered in memory, so this limit bounds how much\n memory a single write can make the terminal allocate. Pass SIZE_MAX\n to remove the limit. A NULL value pointer reverts to the built-in\n default of 64MiB, the minimum required by the protocol.\n\n This limit doesn't apply to OSC 52 writes, which are bounded by the\n maximum length of an escape sequence instead.\n\n Input type: size_t*"]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES:
GhosttyTerminalOption = 39;
#[doc = " Set the maximum total decoded bytes a single Kitty clipboard protocol\n (OSC 5522) write transaction may accumulate. The limit is captured\n when a transaction begins; an in-flight transaction keeps the limit\n it started with.\n\n Data beyond the limit fails the whole transaction with EFBIG. The\n transaction is discarded, later write-related packets are ignored\n until a new write begins, and nothing reaches the clipboard write\n callback.\n\n Transactions are buffered in memory, so this limit bounds how much\n memory a single write can make the terminal allocate. Pass SIZE_MAX\n to remove the limit. A NULL value pointer reverts to the built-in\n default of 64MiB, the minimum required by the protocol.\n\n This limit doesn't apply to OSC 52 writes, which are bounded by the\n maximum length of an escape sequence instead.\n\n Input type: size_t*"]
#[doc = " Experimental, default-off PNG retention for quiet base transmissions.\n Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only\n be detected when an animation operation materializes the image."]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG:
GhosttyTerminalOption = 40;
#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE:
GhosttyTerminalOption = 41;
#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MAX_VALUE: GhosttyTerminalOption = 2147483647;
#[doc = " Terminal option identifiers.\n\n These values are used with ghostty_terminal_set() to configure\n terminal callbacks and associated state.\n\n @ingroup terminal"]
pub type GhosttyTerminalOption = ::std::os::raw::c_int;
+214 -31
View File
@@ -22,7 +22,10 @@ use std::ops::RangeInclusive;
use std::os::raw::c_char;
use std::ptr;
use std::slice;
use std::sync::{Mutex, Once, OnceLock};
use std::sync::{Arc, Mutex, Once, OnceLock};
use crate::pane_graphics_files::OwnedExport;
mod native_source;
pub use bindings as ffi;
@@ -227,6 +230,7 @@ pub struct KittyImagePlacement {
pub data_len: usize,
pub data_fingerprint: u64,
pub data: Vec<u8>,
pub(crate) source_file: Option<Arc<OwnedExport>>,
pub render: KittyPlacementRenderInfo,
}
@@ -239,8 +243,11 @@ pub struct KittyImageDescriptor {
pub format: KittyImageFormat,
pub data_len: usize,
pub data_fingerprint: u64,
pub source_file: bool,
}
type KittyPlacementPayload = (KittyImageDescriptor, Vec<u8>, Option<Arc<OwnedExport>>);
#[derive(Debug, Clone, Copy)]
struct KittyImageFingerprintEntry {
generation: u64,
@@ -690,6 +697,16 @@ fn install_png_decoder_once() {
});
}
#[cfg(test)]
thread_local! {
static PNG_DECODE_CALLS: Cell<usize> = const { Cell::new(0) };
}
#[cfg(test)]
mod native_source_tests;
#[cfg(test)]
mod png_forward_tests;
unsafe extern "C" fn decode_png_trampoline(
_userdata: *mut c_void,
allocator: *const ffi::GhosttyAllocator,
@@ -701,6 +718,8 @@ unsafe extern "C" fn decode_png_trampoline(
return false;
}
let bytes = unsafe { slice::from_raw_parts(data, data_len) };
#[cfg(test)]
PNG_DECODE_CALLS.with(|calls| calls.set(calls.get() + 1));
let Some(rgba) = decode_png_rgba(bytes) else {
return false;
};
@@ -820,6 +839,7 @@ pub struct Terminal {
callback_state: Box<TerminalCallbackState>,
kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>,
kitty_empty_generation: Cell<Option<u64>>,
kitty_png_forwarding: bool,
}
impl Terminal {
@@ -845,6 +865,7 @@ impl Terminal {
}),
kitty_fingerprints: Mutex::new(HashMap::new()),
kitty_empty_generation: Cell::new(None),
kitty_png_forwarding: false,
};
let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast();
let glyph_protocol = false;
@@ -1072,6 +1093,29 @@ impl Terminal {
)
.into_result()?;
}
if std::env::var("HERDR_EXPERIMENTAL_KITTY_PNG_FORWARD").as_deref() == Ok("1") {
self.set_kitty_png_forwarding(true)?;
}
if std::env::var("HERDR_EXPERIMENTAL_KITTY_SOURCE_FILES").as_deref() == Ok("1") {
self.set_kitty_source_forwarding(true)?;
}
Ok(())
}
pub fn set_kitty_source_forwarding(&mut self, enabled: bool) -> Result<(), Error> {
native_source::set_forwarding(self.raw, enabled)
}
fn set_kitty_png_forwarding(&mut self, enabled: bool) -> Result<(), Error> {
unsafe {
ffi::ghostty_terminal_set(
self.raw,
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG,
(&enabled as *const bool).cast(),
)
.into_result()?;
}
self.kitty_png_forwarding = enabled;
Ok(())
}
@@ -1882,6 +1926,65 @@ impl Terminal {
}
}
// Retained files are authoritative: never ask the native lazy decoder for
// pixels just to describe a placement or calculate its cache identity.
fn kitty_placement_payload<F>(
&self,
image: ffi::GhosttyKittyGraphicsImage,
image_id: u32,
placement_id: u32,
image_width: u32,
image_height: u32,
decoded_format: KittyImageFormat,
needs_data: &mut F,
) -> Result<KittyPlacementPayload, Error>
where
F: FnMut(KittyImageDescriptor) -> bool,
{
let source_file = native_source::image_source(image)?;
let (format, data_ptr, data_len, data_fingerprint) = if let Some(source) = &source_file {
(
KittyImageFormat::Rgba,
ptr::null(),
source.len(),
source.fingerprint(),
)
} else {
let (format, data_ptr, data_len) =
kitty_image_transmission_payload(image, decoded_format, self.kitty_png_forwarding)?;
let fingerprint = self.kitty_image_fingerprint_cached(
image,
image_id,
(data_ptr, data_len),
image_width,
image_height,
format,
);
(format, data_ptr, data_len, fingerprint)
};
let descriptor = KittyImageDescriptor {
image_id,
placement_id,
image_width,
image_height,
format,
data_len,
data_fingerprint,
source_file: source_file.is_some(),
};
let data = if needs_data(descriptor) {
match &source_file {
Some(source) => source
.copy_rgba()
.map_err(|_| Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE))?,
None => kitty_image_data_from_ptr(data_ptr, data_len),
}
} else {
Vec::new()
};
Ok((descriptor, data, source_file))
}
fn kitty_image_placement<F>(
&self,
graphics: ffi::GhosttyKittyGraphics,
@@ -1937,29 +2040,21 @@ impl Terminal {
iterator,
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID,
)?;
let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?;
let data_fingerprint = self.kitty_image_fingerprint_cached(
let (descriptor, data, source_file) = self.kitty_placement_payload(
image,
image_id,
(data_ptr, data_len),
image_width,
image_height,
format,
);
let descriptor = KittyImageDescriptor {
image_id,
placement_id,
image_width,
image_height,
format,
needs_data,
)?;
let KittyImageDescriptor {
format,
data_len,
data_fingerprint,
};
let data = if needs_data(descriptor) {
kitty_image_data_from_ptr(data_ptr, data_len)
} else {
Vec::new()
};
..
} = descriptor;
let x_offset = kitty_placement_u32(
iterator,
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET,
@@ -1985,6 +2080,7 @@ impl Terminal {
data_len,
data_fingerprint,
data,
source_file,
render: KittyPlacementRenderInfo {
pixel_width: raw_info.pixel_width,
pixel_height: raw_info.pixel_height,
@@ -2081,29 +2177,21 @@ impl Terminal {
continue;
};
let placement_id = run.synthetic_placement_id();
let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?;
let data_fingerprint = self.kitty_image_fingerprint_cached(
let (descriptor, data, source_file) = self.kitty_placement_payload(
image,
image_id,
(data_ptr, data_len),
image_width,
image_height,
format,
);
let descriptor = KittyImageDescriptor {
image_id,
placement_id,
image_width,
image_height,
format,
needs_data,
)?;
let KittyImageDescriptor {
format,
data_len,
data_fingerprint,
};
let data = if needs_data(descriptor) {
kitty_image_data_from_ptr(data_ptr, data_len)
} else {
Vec::new()
};
..
} = descriptor;
placements.push(KittyImagePlacement {
image_id,
placement_id,
@@ -2116,6 +2204,7 @@ impl Terminal {
data_len,
data_fingerprint,
data,
source_file,
render: geometry.render,
});
}
@@ -2585,6 +2674,40 @@ fn kitty_image_compression(
Ok(out)
}
fn kitty_image_transmission_payload(
image: ffi::GhosttyKittyGraphicsImage,
decoded_format: KittyImageFormat,
forwarding: bool,
) -> Result<(KittyImageFormat, *const u8, usize), Error> {
if !forwarding {
let (ptr, len) = kitty_image_data_ptr_len(image)?;
return Ok((decoded_format, ptr, len));
}
let mut ptr_out: *const u8 = ptr::null();
let result = unsafe {
ffi::ghostty_kitty_graphics_image_get(
image,
ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR,
(&mut ptr_out as *mut *const u8).cast(),
)
};
if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
let (ptr, len) = kitty_image_data_ptr_len(image)?;
return Ok((decoded_format, ptr, len));
}
result.into_result()?;
let mut len = 0usize;
unsafe {
ffi::ghostty_kitty_graphics_image_get(
image,
ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN,
(&mut len as *mut usize).cast(),
)
.into_result()?;
}
Ok((KittyImageFormat::Png, ptr_out, len))
}
fn kitty_image_data_ptr_len(
image: ffi::GhosttyKittyGraphicsImage,
) -> Result<(*const u8, usize), Error> {
@@ -3592,6 +3715,66 @@ mod tests {
assert!(newest.contains("10000"));
}
#[test]
fn kitty_png_replacement_rejects_invalid_payload_without_placing_it() {
use base64::Engine as _;
let mut png = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png, 2, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255, 0, 0, 255, 255])
.unwrap();
}
let mut terminal = Terminal::new(10, 5, 0).unwrap();
terminal.enable_kitty_graphics().unwrap();
let upload = |payload: &[u8]| {
format!(
"\x1b[H\x1b_Ga=T,f=100,i=7,p=3,c=2,r=1,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(payload)
)
};
terminal.write(upload(&png).as_bytes());
let before = terminal.kitty_image_placements().unwrap();
assert_eq!(before.len(), 1);
assert_eq!((before[0].image_width, before[0].image_height), (2, 1));
assert_eq!(before[0].data, [255, 0, 0, 255, 0, 0, 255, 255]);
// The parser retires the old placement, but never places corrupt data.
terminal.write(upload(&png[..png.len() / 2]).as_bytes());
assert!(terminal.kitty_image_placements().unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn kitty_file_image_survives_source_mutation_and_unlink() {
use base64::Engine as _;
let path = std::path::PathBuf::from(format!(
"/var/tmp/herdr-kitty-source-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, [255, 0, 0, 255]).unwrap();
let mut terminal = Terminal::new(10, 5, 0).unwrap();
terminal.enable_kitty_graphics().unwrap();
terminal.write(
format!(
"\x1b_Ga=T,f=32,t=f,i=7,p=3,s=1,v=1,c=1,r=1,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap())
)
.as_bytes(),
);
std::fs::write(&path, [0, 0, 255, 255]).unwrap();
std::fs::remove_file(&path).unwrap();
let placements = terminal.kitty_image_placements().unwrap();
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].data, [255, 0, 0, 255]);
}
#[test]
fn kitty_image_fingerprint_covers_full_payload() {
let mut data = vec![1u8; 4096 * 4];
+165
View File
@@ -0,0 +1,165 @@
//! Host-owned immutable file snapshots retained by the native image lifetime.
use super::{ffi, Error, GhosttyResultExt};
use crate::pane_graphics_files::{FileStore, OwnedExport};
use std::ffi::c_void;
use std::ptr;
use std::sync::{Arc, OnceLock};
static SOURCES: OnceLock<FileStore> = OnceLock::new();
// These getters never materialize native pixels. The image borrow protects the
// host's strong reference until we have acquired our own.
pub(super) fn image_source(
image: ffi::GhosttyKittyGraphicsImage,
) -> Result<Option<Arc<OwnedExport>>, Error> {
let mut context: *mut c_void = ptr::null_mut();
let result = unsafe {
ffi::ghostty_kitty_graphics_image_get(
image,
ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT,
(&mut context as *mut *mut c_void).cast(),
)
};
if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
return Ok(None);
}
result.into_result()?;
if context.is_null() {
return Ok(None);
}
let mut identity = 0u64;
unsafe {
ffi::ghostty_kitty_graphics_image_get(
image,
ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY,
(&mut identity as *mut u64).cast(),
)
.into_result()?;
let source = context.cast::<OwnedExport>();
// All terminal snapshot callbacks in this wrapper produce this type.
// Identity is immutable and does not depend on decoded pixel storage.
if (*source).fingerprint() != identity {
return Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE));
}
Arc::increment_strong_count(source);
Ok(Some(Arc::from_raw(source)))
}
}
pub(super) fn set_forwarding(terminal: ffi::GhosttyTerminal, enabled: bool) -> Result<(), Error> {
let callback = if enabled && cfg!(target_os = "linux") {
snapshot as *const () as *const c_void
} else {
ptr::null()
};
unsafe {
ffi::ghostty_terminal_set(
terminal,
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE,
callback,
)
.into_result()
}
}
unsafe extern "C" fn snapshot(
_terminal: ffi::GhosttyTerminal,
_userdata: *mut c_void,
request: *const ffi::GhosttyKittyImageSnapshotFileRequest,
out: *mut ffi::GhosttyKittyImageFileBacking,
) -> bool {
std::panic::catch_unwind(|| {
if request.is_null() || out.is_null() || !cfg!(target_os = "linux") {
return false;
}
let request = unsafe { &*request };
if request.size != std::mem::size_of::<ffi::GhosttyKittyImageSnapshotFileRequest>() {
return false;
}
let Ok(len) = usize::try_from(request.expected_len) else {
return false;
};
let Ok(source) = SOURCES
.get_or_init(FileStore::native_sources)
.snapshot(request.fd, len)
else {
return false;
};
let source = Arc::new(source);
let backing = ffi::GhosttyKittyImageFileBacking {
identity: source.fingerprint(),
len: source.len(),
context: Arc::into_raw(source).cast_mut().cast(),
read: Some(read),
release: Some(release),
};
unsafe {
out.write(backing);
}
true
})
.unwrap_or(false)
}
unsafe extern "C" fn read(context: *mut c_void, dest: *mut u8, len: usize) -> bool {
std::panic::catch_unwind(|| {
if context.is_null() || dest.is_null() {
return false;
}
let source = unsafe { &*context.cast::<OwnedExport>() };
if len != source.len() || len > isize::MAX as usize {
return false;
}
source
.read_into(unsafe { std::slice::from_raw_parts_mut(dest, len) })
.is_ok()
})
.unwrap_or(false)
}
unsafe extern "C" fn release(context: *mut c_void) {
let _ = std::panic::catch_unwind(|| {
if !context.is_null() {
drop(unsafe { Arc::from_raw(context.cast::<OwnedExport>()) });
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_requests_decline_without_transferring_ownership() {
let mut out = ffi::GhosttyKittyImageFileBacking::default();
let mut request = ffi::GhosttyKittyImageSnapshotFileRequest {
size: std::mem::size_of::<ffi::GhosttyKittyImageSnapshotFileRequest>(),
fd: -1,
expected_len: 4,
};
unsafe {
assert!(!snapshot(
ptr::null_mut(),
ptr::null_mut(),
&request,
&mut out
));
assert!(out.context.is_null());
request.size = 0;
assert!(!snapshot(
ptr::null_mut(),
ptr::null_mut(),
&request,
&mut out
));
assert!(!snapshot(
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
&mut out
));
assert!(out.context.is_null());
release(ptr::null_mut());
}
}
}
+147
View File
@@ -0,0 +1,147 @@
use super::*;
use base64::Engine as _;
fn terminal() -> Terminal {
let mut terminal = Terminal::new(20, 10, 0).unwrap();
terminal.enable_kitty_graphics().unwrap();
terminal.set_kitty_source_forwarding(true).unwrap();
terminal.resize(20, 10, 8, 16).unwrap();
terminal
}
#[test]
fn decoded_upload_remains_bytes_backed() {
let mut terminal = terminal();
let bytes = [17u8; 16];
terminal.write(
format!(
"\x1b_Ga=T,f=32,s=2,v=2,i=7,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
.as_bytes(),
);
let metadata = terminal
.kitty_image_placements_with_data_filter(|descriptor| {
assert!(!descriptor.source_file);
assert_eq!(descriptor.data_len, bytes.len());
false
})
.unwrap();
assert_eq!(metadata.len(), 1);
assert!(metadata[0].data.is_empty());
assert!(metadata[0].source_file.is_none());
let compatible = terminal.kitty_image_placements().unwrap();
assert_eq!(compatible[0].data, bytes);
assert_eq!(compatible[0].data_fingerprint, metadata[0].data_fingerprint);
}
#[cfg(unix)]
#[test]
fn regular_file_placement_survives_alternate_screen_replacement() {
let store = crate::pane_graphics_files::FileStore::default();
let mut terminal = terminal();
terminal.set_kitty_source_forwarding(false).unwrap();
terminal.write(b"\x1b[?1049h\x1b[2J\x1b[H");
for value in [17u8, 99] {
let bytes = [value; 16];
let source = store.export(&bytes).unwrap();
terminal.write(
format!(
"\x1b[H\x1b_Ga=T,t=f,f=32,s=2,v=2,i=77,p=1,c=2,r=1,C=1,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD
.encode(source.path().as_os_str().as_encoded_bytes())
)
.as_bytes(),
);
let placements = terminal.kitty_image_placements().unwrap();
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].data, bytes);
assert_eq!(placements[0].render.viewport_row, 0);
assert_eq!(placements[0].render.grid_rows, 1);
assert_eq!(placements[0].render.grid_cols, 2);
}
}
#[cfg(target_os = "linux")]
#[test]
fn file_upload_retains_snapshot_when_platform_supports_clone() {
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let base = std::env::var_os("TMPDIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "/var/tmp".into());
let path = base.join(format!(
"herdr-native-source-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let bytes = [42u8; 16];
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&path)
.unwrap();
use std::io::Write;
(&file).write_all(&bytes).unwrap();
// Probe the actual platform, not filesystem names: overlayfs/tmpfs must
// decline rather than silently produce a userspace copy.
let probe = crate::pane_graphics_files::FileStore::native_sources()
.snapshot(i64::from(file.as_raw_fd()), bytes.len());
let supported = match probe {
Ok(snapshot) => {
drop(snapshot);
true
}
Err(error) => {
eprintln!("native snapshot unsupported on test filesystem: {error}");
false
}
};
let mut terminal = terminal();
terminal.write(
format!(
"\x1b_Ga=T,t=f,f=32,s=2,v=2,i=7,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes())
)
.as_bytes(),
);
let metadata = terminal
.kitty_image_placements_with_data_filter(|descriptor| {
assert_eq!(descriptor.source_file, supported);
false
})
.unwrap();
assert_eq!(metadata.len(), 1);
assert!(metadata[0].data.is_empty());
assert_eq!(metadata[0].source_file.is_some(), supported);
let mut unicode = self::terminal();
unicode.write(
format!(
"\x1b_Ga=T,t=f,f=32,s=2,v=2,U=1,c=1,r=1,i=8,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes())
)
.as_bytes(),
);
unicode.write("\x1b[H\x1b[38;2;0;0;8m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes());
let unicode_metadata = unicode
.kitty_image_placements_with_data_filter(|descriptor| {
assert_eq!(descriptor.source_file, supported);
false
})
.unwrap();
assert_eq!(unicode_metadata.len(), 1);
assert!(unicode_metadata[0].data.is_empty());
assert_eq!(unicode_metadata[0].source_file.is_some(), supported);
std::fs::write(&path, [99u8; 16]).unwrap();
assert_eq!(unicode.kitty_image_placements().unwrap()[0].data, bytes);
let compatible = terminal.kitty_image_placements().unwrap();
assert_eq!(compatible[0].data, bytes);
assert_eq!(compatible[0].data_fingerprint, metadata[0].data_fingerprint);
drop(terminal);
if let Some(source) = &metadata[0].source_file {
assert_eq!(source.copy_rgba().unwrap(), bytes);
}
std::fs::remove_file(path).unwrap();
}
+212
View File
@@ -0,0 +1,212 @@
use super::*;
use base64::Engine as _;
fn fixture() -> Vec<u8> {
fixture_size(32, 16)
}
fn fixture_size(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&vec![42; (width * height * 4) as usize])
.unwrap();
}
bytes
}
fn terminal() -> Terminal {
let mut terminal = Terminal::new(20, 10, 0).unwrap();
terminal.enable_kitty_graphics().unwrap();
terminal.set_kitty_png_forwarding(true).unwrap();
terminal.resize(20, 10, 8, 16).unwrap();
terminal
}
#[test]
#[ignore = "manual fixed-geometry native PNG ingestion/extraction scaling profile"]
fn native_png_render_scale_profile() {
let png = fixture_size(800, 480);
let upload = format!(
"\x1b[H\x1b_Ga=T,f=100,i=7,c=60,r=20,C=1,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(&png)
);
for count in [1, 15] {
for forwarding in [false, true] {
let mut panes = (0..count)
.map(|_| {
let mut pane = Terminal::new(120, 40, 0).unwrap();
pane.enable_kitty_graphics().unwrap();
pane.set_kitty_png_forwarding(forwarding).unwrap();
pane.resize(120, 40, 8, 16).unwrap();
pane
})
.collect::<Vec<_>>();
let mut samples = Vec::new();
for iteration in 0..65 {
let begin = std::time::Instant::now();
for pane in &mut panes {
pane.write(upload.as_bytes());
let placements = pane.kitty_image_placements().unwrap();
assert_eq!(placements.len(), 1);
assert_eq!(
placements[0].data.len(),
if forwarding { png.len() } else { 800 * 480 * 4 }
);
std::hint::black_box(placements);
}
if iteration >= 5 {
samples.push(begin.elapsed().as_micros());
}
}
samples.sort_unstable();
eprintln!("native PNG panes={count} forwarding={forwarding} median_us={} p95_us={} png_bytes={}", samples[30], samples[57], png.len());
}
}
}
#[test]
fn quiet_png_forwarding_retains_exact_payload_without_pixel_decode() {
let bytes = fixture();
let mut terminal = terminal();
let before = PNG_DECODE_CALLS.get();
terminal.write(
format!(
"\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(&bytes)
)
.as_bytes(),
);
let placements = terminal.kitty_image_placements().unwrap();
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].format, KittyImageFormat::Png);
assert_eq!(placements[0].data, bytes);
assert_eq!(
(placements[0].image_width, placements[0].image_height),
(32, 16)
);
assert_eq!(PNG_DECODE_CALLS.get(), before);
}
#[test]
fn placeholder_png_forwarding_and_lazy_animation_decode() {
let bytes = fixture();
let mut terminal = terminal();
let before = PNG_DECODE_CALLS.get();
terminal.write(
format!(
"\x1b_Ga=T,f=100,U=1,i=7,c=4,r=1,q=2;{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(&bytes)
)
.as_bytes(),
);
terminal.write("\x1b[H\x1b[38;2;0;0;7m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes());
let placements = terminal.kitty_image_placements().unwrap();
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].format, KittyImageFormat::Png);
assert_eq!(placements[0].data, bytes);
assert_eq!(PNG_DECODE_CALLS.get(), before);
let fingerprint = placements[0].data_fingerprint;
terminal.write(b"\x1b_Ga=a,i=7,q=2;\x1b\\");
let materialized = terminal.kitty_image_placements().unwrap();
assert_eq!(PNG_DECODE_CALLS.get(), before + 1);
assert_eq!(materialized[0].format, KittyImageFormat::Rgba);
assert_eq!(materialized[0].data, [42; 32 * 16 * 4]);
assert_ne!(materialized[0].data_fingerprint, fingerprint);
}
#[test]
fn experimental_quiet_png_defers_crc_valid_compressed_data_errors() {
let mut bytes = fixture();
let mut offset = 8;
loop {
let len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
if &bytes[offset + 4..offset + 8] == b"IDAT" {
bytes[offset + 8] = 0; // Invalid zlib header, but structurally valid PNG chunks.
let mut crc = !0u32;
for &byte in &bytes[offset + 4..offset + 8 + len] {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb88320u32 & 0u32.wrapping_sub(crc & 1));
}
}
bytes[offset + 8 + len..offset + 12 + len].copy_from_slice(&(!crc).to_be_bytes());
break;
}
offset += len + 12;
}
for quiet in [0, 2] {
let mut terminal = terminal();
let before = PNG_DECODE_CALLS.get();
terminal.write(
format!(
"\x1b_Ga=T,f=100,i=7,c=4,r=1,q={quiet};{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(&bytes)
)
.as_bytes(),
);
let placements = terminal.kitty_image_placements().unwrap();
if quiet == 0 {
assert!(placements.is_empty());
assert_eq!(PNG_DECODE_CALLS.get(), before + 1);
} else {
assert_eq!(placements[0].data, bytes);
assert_eq!(PNG_DECODE_CALLS.get(), before);
terminal.write(b"\x1b_Ga=a,i=7,q=2;\x1b\\");
assert_eq!(PNG_DECODE_CALLS.get(), before + 1);
assert_eq!(terminal.kitty_image_placements().unwrap()[0].data, bytes);
}
}
}
#[test]
fn response_bearing_png_and_queries_keep_full_validation() {
let bytes = fixture();
for (action, quiet) in [('T', 0), ('T', 1), ('q', 2)] {
let mut terminal = terminal();
let before = PNG_DECODE_CALLS.get();
terminal.write(
format!(
"\x1b_Ga={action},f=100,i=7,c=4,r=1,q={quiet};{}\x1b\\",
base64::engine::general_purpose::STANDARD.encode(&bytes)
)
.as_bytes(),
);
assert_eq!(
PNG_DECODE_CALLS.get(),
before + 1,
"action {action}, quiet {quiet}"
);
let placements = terminal.kitty_image_placements().unwrap();
if action == 'q' {
assert!(placements.is_empty());
} else {
assert_eq!(placements[0].format, KittyImageFormat::Rgba);
}
}
}
#[test]
fn chunked_png_forwarding_inherits_quiet_mode() {
let bytes = fixture();
let mut terminal = terminal();
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let split = (encoded.len() / 8) * 4;
let before = PNG_DECODE_CALLS.get();
terminal.write(
format!(
"\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2,m=1;{}\x1b\\",
&encoded[..split]
)
.as_bytes(),
);
assert!(terminal.kitty_image_placements().unwrap().is_empty());
terminal.write(format!("\x1b_Gm=0;{}\x1b\\", &encoded[split..]).as_bytes());
assert_eq!(terminal.kitty_image_placements().unwrap()[0].data, bytes);
assert_eq!(PNG_DECODE_CALLS.get(), before);
}
+134 -558
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
//! Ordered, local-only graphics output. Upload pixels stay raw until output I/O.
#[cfg(test)]
use std::io::{self, Write};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub(crate) enum GraphicsOperation {
Bytes(Vec<u8>),
Upload { control: String, data: Arc<[u8]> },
}
#[derive(Clone, Debug, Default)]
pub(crate) struct GraphicsOutput {
pub(crate) operations: Vec<GraphicsOperation>,
}
impl GraphicsOutput {
pub(crate) fn from_bytes(bytes: Vec<u8>) -> Self {
let mut output = Self::default();
output.push_bytes(bytes);
output
}
pub(crate) fn push_bytes(&mut self, bytes: Vec<u8>) {
if !bytes.is_empty() {
self.operations.push(GraphicsOperation::Bytes(bytes));
}
}
pub(crate) fn is_empty(&self) -> bool {
self.operations.is_empty()
}
pub(crate) fn extend(&mut self, other: Self) {
self.operations.extend(other.operations);
}
#[cfg(test)]
pub(crate) fn into_inline_bytes(self) -> Vec<u8> {
let mut bytes = Vec::new();
for operation in self.operations {
match operation {
GraphicsOperation::Bytes(next) if bytes.is_empty() => bytes = next,
GraphicsOperation::Bytes(next) => bytes.extend(next),
GraphicsOperation::Upload { control, data } => {
super::encode_kitty_data(&mut bytes, &control, &data);
}
}
}
bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_fallback_preserves_order_and_chunk_encoding() {
let data: Arc<[u8]> = Arc::from(vec![42; super::super::KITTY_CHUNK_BYTES + 1]);
let control = "a=t,t=d,f=24,s=1,v=1,i=7,q=2";
let mut output = GraphicsOutput::default();
assert!(output.is_empty());
output.extend(GraphicsOutput::from_bytes(b"before".to_vec()));
output.operations.push(GraphicsOperation::Upload {
control: control.into(),
data: Arc::clone(&data),
});
output.extend(GraphicsOutput::from_bytes(b"after".to_vec()));
let mut expected = b"before".to_vec();
super::super::encode_kitty_data(&mut expected, control, &data);
expected.extend_from_slice(b"after");
assert_eq!(output.into_inline_bytes(), expected);
}
#[test]
fn inline_write_propagates_io_errors() {
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
assert_eq!(
super::super::write_kitty_data(&mut Broken, "a=t,t=d,f=32", &[1, 2, 3, 4])
.unwrap_err()
.kind(),
io::ErrorKind::BrokenPipe
);
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -3605,6 +3605,12 @@ impl PaneRuntime {
#[cfg(test)]
impl PaneRuntime {
pub(crate) fn test_enable_kitty_source_forwarding(&self) {
let mut core = self.terminal.ghostty.core.lock().unwrap();
core.terminal.enable_kitty_graphics().unwrap();
core.terminal.set_kitty_source_forwarding(true).unwrap();
}
pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) {
Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], 4)
}
+76 -3
View File
@@ -24,9 +24,16 @@ enum DecscusrParseState {
}
impl DecscusrTracker {
pub(crate) fn observe(&mut self, bytes: &[u8]) {
for &byte in bytes {
self.observe_byte(byte);
pub(crate) fn observe(&mut self, mut bytes: &[u8]) {
while !bytes.is_empty() {
if matches!(self.state, DecscusrParseState::Ground) {
let Some(escape) = bytes.iter().position(|&byte| byte == 0x1b) else {
return;
};
bytes = &bytes[escape..];
}
self.observe_byte(bytes[0]);
bytes = &bytes[1..];
}
}
@@ -256,6 +263,72 @@ fn is_jump(settled: TerminalCursorState, current: TerminalCursorState) -> bool {
mod tests {
use super::*;
fn assert_decscusr_matches_bytewise(chunks: &[&[u8]]) {
let mut optimized = DecscusrTracker::default();
let mut bytewise = DecscusrTracker::default();
for chunk in chunks {
optimized.observe(chunk);
for &byte in *chunk {
bytewise.observe_byte(byte);
}
assert_eq!(
optimized.cursor_shape_overridden(),
bytewise.cursor_shape_overridden(),
"chunks: {chunks:?}"
);
// Compare the complete parser state, including incomplete CSI parameters.
assert_eq!(
format!("{:?}", optimized.state),
format!("{:?}", bytewise.state),
"chunks: {chunks:?}"
);
}
}
#[test]
fn decscusr_bulk_search_matches_bytewise_control_sequences_and_splits() {
let cases: &[&[u8]] = &[
b"",
b"plain text\n\twithout escapes",
b"text\x1b[1 qmore\x1b[0 qend",
b"\x1b[ q\x1b[2 q\x1b[3 q\x1b[4 q\x1b[5 q\x1b[6 q\x1b[7 q",
b"\x1b\x1b[1 q\x1b[2;9 q\x1b[0:4 q",
b"\x1b[1q\x1b[? q\x1b[12$ q\x1b[1\x00 q\x1b[2\xff q",
b"\x1b[12\x1b[5 q\x1b]0;title\x07\x1bPdata\x1b\\",
b"\x1b[1 q\x1b",
b"\x1b[1 q\x1b[",
b"\x1b[1 q\x1b[0 ",
];
for &bytes in cases {
for split in 0..=bytes.len() {
assert_decscusr_matches_bytewise(&[&bytes[..split], &[], &bytes[split..]]);
}
let chunks: Vec<_> = bytes.chunks(1).collect();
assert_decscusr_matches_bytewise(&chunks);
}
}
#[test]
fn decscusr_bulk_search_matches_bytewise_all_byte_values() {
let all_bytes: Vec<u8> = (0..=255).collect();
for split in 0..=all_bytes.len() {
assert_decscusr_matches_bytewise(&[&all_bytes[..split], &all_bytes[split..]]);
}
// Exercise every possible byte in ground, escape, and partial CSI states.
let prefixes: &[&[u8]] = &[b"", b"\x1b", b"\x1b[", b"\x1b[2", b"\x1b[0 ", b"\x1b[2;"];
for &prefix in prefixes {
for byte in 0..=255u8 {
let mut bytes = b"\x1b[1 q".to_vec();
bytes.extend_from_slice(prefix);
bytes.push(byte);
bytes.extend_from_slice(b" qtext\x1b[0 q\x1b[6 q");
for split in 0..=bytes.len() {
assert_decscusr_matches_bytewise(&[&bytes[..split], &bytes[split..]]);
}
}
}
}
fn cursor(x: u16, y: u16, visible: bool, shape: u8) -> TerminalCursorState {
TerminalCursorState {
x,
+93 -4
View File
@@ -63,8 +63,19 @@ fn is_ignored_string_intro(byte: u8) -> bool {
impl DefaultColorOscTracker {
pub(super) fn observe(&mut self, bytes: &[u8]) -> bool {
let mut saw_default_color_set = false;
for &byte in bytes {
let mut cursor = 0;
while cursor < bytes.len() {
if matches!(
self.state,
DefaultColorOscTrackerState::Ground | DefaultColorOscTrackerState::IgnoreString
) {
let Some(offset) = bytes[cursor..].iter().position(|&byte| byte == 0x1b) else {
break;
};
cursor += offset;
}
let byte = bytes[cursor];
cursor += 1;
match self.state {
DefaultColorOscTrackerState::Ground => {
if byte == 0x1b {
@@ -157,7 +168,20 @@ pub(super) struct DefaultColorEventTracker {
impl DefaultColorEventTracker {
pub(super) fn observe(&mut self, bytes: &[u8]) {
for (index, &byte) in bytes.iter().enumerate() {
let mut cursor = 0;
while cursor < bytes.len() {
if matches!(
self.state,
DefaultColorOscTrackerState::Ground | DefaultColorOscTrackerState::IgnoreString
) {
let Some(offset) = bytes[cursor..].iter().position(|&byte| byte == 0x1b) else {
break;
};
cursor += offset;
}
let index = cursor;
let byte = bytes[cursor];
cursor += 1;
match self.state {
DefaultColorOscTrackerState::Ground => {
if byte == 0x1b {
@@ -347,7 +371,19 @@ impl OscStreamCollector {
const MAX_BODY_BYTES: usize = 4096;
fn observe(&mut self, bytes: &[u8], mut receive: impl FnMut(&[u8])) {
for &byte in bytes {
let mut cursor = 0;
while cursor < bytes.len() {
if matches!(
self.state,
OscStreamState::Ground | OscStreamState::IgnoringString
) {
let Some(offset) = bytes[cursor..].iter().position(|&byte| byte == 0x1b) else {
break;
};
cursor += offset;
}
let byte = bytes[cursor];
cursor += 1;
match self.state {
OscStreamState::Ground => {
if byte == 0x1b {
@@ -805,6 +841,59 @@ pub(super) fn restore_host_terminal_theme_if_needed(
#[cfg(test)]
mod tests {
#[test]
fn bulk_osc_scans_match_bytewise_state_and_response_offsets() {
use super::*;
let mut input = b"text\x1b_Gm=1;".to_vec();
input.extend(std::iter::repeat_n(b'A', 8192));
input.extend_from_slice(b"\x1b\\\x1b]10;?\x07\x1b]11;red\x1b\\\x1bPignored");
input.extend(0u8..=255);
input.extend_from_slice(b"\x1b\\\x1b]12;");
input.extend(std::iter::repeat_n(b'B', 4200));
input.extend_from_slice(b"\x07\x1b]10;?\x1b\\\x1b]11;?\x07\x1b");
for chunk_size in [1, 2, 3, 17, 4096, input.len()] {
let mut bulk = DefaultColorOscTracker::default();
let mut scalar = DefaultColorOscTracker::default();
let mut bulk_events = DefaultColorEventTracker::default();
let mut scalar_events = DefaultColorEventTracker::default();
let mut bulk_stream = OscStreamCollector::default();
let mut scalar_stream = OscStreamCollector::default();
for chunk in input.chunks(chunk_size) {
let changed = bulk.observe(chunk);
let mut scalar_changed = false;
let mut expected_events = Vec::new();
let mut expected_bodies = Vec::new();
for (offset, byte) in chunk.iter().enumerate() {
let byte = std::slice::from_ref(byte);
scalar_changed |= scalar.observe(byte);
scalar_events.observe(byte);
expected_events.extend(scalar_events.drain_pending().into_iter().map(
|mut event| {
event.end_offset += offset;
event
},
));
scalar_stream.observe(byte, |body| expected_bodies.push(body.to_vec()));
}
bulk_events.observe(chunk);
let mut bodies = Vec::new();
bulk_stream.observe(chunk, |body| bodies.push(body.to_vec()));
assert_eq!(changed, scalar_changed);
assert_eq!((bulk.state, &bulk.body), (scalar.state, &scalar.body));
assert_eq!(bulk_events.drain_pending(), expected_events);
assert_eq!(
(bulk_events.state, &bulk_events.body),
(scalar_events.state, &scalar_events.body)
);
assert_eq!(bodies, expected_bodies);
assert_eq!(
(bulk_stream.state, &bulk_stream.body),
(scalar_stream.state, &scalar_stream.body)
);
}
}
}
use tokio::sync::mpsc;
use super::*;
+95 -1
View File
@@ -38,7 +38,21 @@ enum C1XtgettcapTrackerState {
impl C1XtgettcapQueryTracker {
pub(super) fn observe(&mut self, bytes: &[u8]) {
for (index, &byte) in bytes.iter().enumerate() {
let mut index = 0;
while index < bytes.len() {
if self.state == C1XtgettcapTrackerState::IgnoreString {
// Payload cannot affect this state. CAN/SUB still need the
// global native-DCS bookkeeping when a dispatch is pending.
let next = bytes[index..].iter().position(|&byte| {
matches!(byte, 0x1b | 0x9c)
|| (self.native_dcs_pending && matches!(byte, 0x18 | 0x1a))
});
match next {
Some(offset) => index += offset,
None => break,
}
}
let byte = bytes[index];
// With a 7-bit intro and raw ST, the native parser still holds the
// DCS open. At its eventual unhook it can answer earlier keys in a
// multi-key request again. Discard only that dispatch's XTGETTCAP
@@ -164,6 +178,7 @@ impl C1XtgettcapQueryTracker {
self.body.clear();
self.state = C1XtgettcapTrackerState::OversizedDcs;
}
index += 1;
}
}
@@ -239,3 +254,82 @@ fn append_upper_hex(bytes: &[u8], output: &mut Vec<u8>) {
output.push(HEX[usize::from(byte & 0x0f)]);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn observe_chunks(chunks: &[&[u8]]) -> (C1XtgettcapQueryTracker, Vec<C1XtgettcapResponse>) {
let mut tracker = C1XtgettcapQueryTracker::default();
let mut pending = Vec::new();
let mut offset = 0;
for chunk in chunks {
tracker.observe(chunk);
pending.extend(tracker.drain_pending().into_iter().map(|mut response| {
response.end_offset += offset;
response
}));
offset += chunk.len();
}
(tracker, pending)
}
fn assert_chunk_equivalence(bytes: &[u8]) {
let one_byte_chunks: Vec<_> = bytes.chunks(1).collect();
let (expected, expected_pending) = observe_chunks(&one_byte_chunks);
// Includes a single bulk call and every split, especially ESC / ST.
for split in 0..=bytes.len() {
let (actual, actual_pending) = observe_chunks(&[&bytes[..split], &bytes[split..]]);
assert_eq!(actual_pending, expected_pending, "split {split}: {bytes:?}");
assert_eq!(actual.state, expected.state);
assert_eq!(actual.raw_c1_intro, expected.raw_c1_intro);
assert_eq!(actual.native_dcs_pending, expected.native_dcs_pending);
assert_eq!(actual.body, expected.body);
assert_eq!(actual.pending, expected.pending);
}
}
#[test]
fn ignored_string_bulk_matches_single_bytes() {
let mut all_bytes = b"\x1b_Gordinary kitty payload".to_vec();
all_bytes.extend(0..=255u8);
all_bytes.extend_from_slice(b"\x1b\\\x90+q5463\x9c\x90+q5247");
assert_chunk_equivalence(&all_bytes);
// Exercise every byte while actually inside IgnoreString, both with
// and without a native dispatch waiting for ESC, CAN, or SUB.
for native_pending in [false, true] {
for intro in [b"\x1b_".as_slice(), b"\x98", b"\x9e", b"\x9f"] {
for byte in 0..=255u8 {
let mut bytes = Vec::new();
if native_pending {
bytes.extend_from_slice(b"\x1bP+q5463;524742\x9c");
}
bytes.extend_from_slice(intro);
bytes.extend_from_slice(b"Gordinary payload");
bytes.push(byte);
assert_chunk_equivalence(&bytes);
bytes.extend_from_slice(b"more payload\x1b\\\x90+q5463\x9c\x90+q5247");
assert_chunk_equivalence(&bytes);
}
}
}
}
#[test]
fn ignored_string_preserves_native_dispatch_offsets() {
for cancel in [0x18, 0x1a, 0x1b] {
let mut bytes = b"\x1bP+q5463\x9c\x9fignored payload".to_vec();
bytes.push(cancel);
let end_offset = bytes.len();
bytes.extend_from_slice(b"\x1b\\\x90+q524742\x9c");
let (_, pending) = observe_chunks(&[&bytes]);
assert_eq!(pending.len(), 3);
assert!(pending[1].suppress_native);
assert!(pending[1].bytes.is_empty());
assert_eq!(pending[1].end_offset, end_offset);
assert_eq!(pending[2].end_offset, bytes.len());
assert_chunk_equivalence(&bytes);
}
}
}
+524 -33
View File
@@ -1,13 +1,13 @@
#[cfg(unix)]
use std::fs::OpenOptions;
use std::fs::{self, File};
use std::io;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
#[cfg(unix)]
use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt, PermissionsExt};
use std::os::unix::fs::{DirBuilderExt, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt};
#[cfg(unix)]
const DIRECTORY_MODE: u32 = 0o700;
@@ -19,6 +19,7 @@ pub(crate) struct FileStore {
base: PathBuf,
generation: OnceLock<Arc<Generation>>,
next_fingerprint: AtomicU64,
native_budget: Option<Arc<Mutex<NativeBudget>>>,
}
#[derive(Debug)]
@@ -27,13 +28,60 @@ struct Generation {
source: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct Lease {
inner: Arc<LeaseInner>,
/// Keep this owned snapshot alive until the terminal has consumed its path.
#[derive(Debug)]
pub(crate) struct OwnedExport {
lease: Lease,
_file: ExportFile,
_reservation: Option<Reservation>,
}
// Also owns cleanup during writing/validation, before an export is published.
#[derive(Debug)]
struct ExportFile {
path: PathBuf,
_generation: Arc<Generation>,
}
#[derive(Debug, Default)]
struct NativeBudget {
bytes: usize,
objects: usize,
}
#[derive(Debug)]
struct LeaseInner {
struct Reservation {
budget: Arc<Mutex<NativeBudget>>,
len: usize,
}
impl Reservation {
fn acquire(budget: &Arc<Mutex<NativeBudget>>, len: usize) -> io::Result<Self> {
let mut state = budget.lock().unwrap_or_else(|err| err.into_inner());
if state.objects >= 64 || len > 64 * 1024 * 1024 - state.bytes {
return Err(io::Error::other("native snapshot budget exhausted"));
}
state.objects += 1;
state.bytes += len;
Ok(Self {
budget: Arc::clone(budget),
len,
})
}
}
impl Drop for Reservation {
fn drop(&mut self) {
let mut state = self.budget.lock().unwrap_or_else(|err| err.into_inner());
state.objects -= 1;
state.bytes -= self.len;
}
}
const MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug)]
struct Lease {
path: PathBuf,
file: File,
generation: Arc<Generation>,
@@ -54,14 +102,53 @@ impl FileStore {
base,
generation: OnceLock::new(),
next_fingerprint: AtomicU64::new(1),
native_budget: None,
}
}
pub(crate) fn native_sources() -> Self {
let mut store = Self::new(native_base());
store.native_budget = Some(Arc::new(Mutex::new(NativeBudget::default())));
store
}
/// Kernel-only bounded snapshot; any unsupported clone returns an error.
/// The caller keeps the borrowed descriptor alive throughout this call.
pub(crate) fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> {
if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid native RGBA length",
));
}
let budget = self.native_budget.as_ref().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "not a native source store")
})?;
let reservation = Reservation::acquire(budget, expected_len)?;
let generation = self.generation()?;
let id = self.next_fingerprint.fetch_add(1, Ordering::Relaxed);
let path = generation.source.join(format!("native-{id}.rgba"));
let destination = create_export(&path)?;
let file = ExportFile {
path,
_generation: generation,
};
crate::platform::clone_native_image_source(source_fd, &destination, expected_len)?;
drop(destination);
let lease = self.lease(&file.path, expected_len)?;
Ok(OwnedExport {
lease,
_file: file,
_reservation: Some(reservation),
})
}
#[cfg(test)]
pub(crate) fn source_directory(&self) -> io::Result<PathBuf> {
Ok(self.generation()?.source.clone())
}
pub(crate) fn lease(&self, path: &Path, expected_len: usize) -> io::Result<Lease> {
fn lease(&self, path: &Path, expected_len: usize) -> io::Result<Lease> {
let generation = self.generation()?;
validate_child(path, &generation.source)?;
let file = open_no_follow(path)?;
@@ -70,14 +157,46 @@ impl FileStore {
validate_path_identity(path, &metadata)?;
let fingerprint = self.next_fingerprint.fetch_add(1, Ordering::Relaxed);
Ok(Lease {
inner: Arc::new(LeaseInner {
path: path.to_owned(),
file,
generation,
metadata,
len: expected_len,
fingerprint,
}),
path: path.to_owned(),
file,
generation,
metadata,
len: expected_len,
fingerprint,
})
}
/// Snapshot decoded bytes into a private, uniquely named file. Aggregate
/// outstanding-export limits are the caller's responsibility.
pub(crate) fn export(&self, data: &[u8]) -> io::Result<OwnedExport> {
if data.len() > MAX_EXPORT_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"decoded export exceeds 16 MiB",
));
}
let reservation = self
.native_budget
.as_ref()
.map(|budget| Reservation::acquire(budget, data.len()))
.transpose()?;
let generation = self.generation()?;
// Share the lease identity allocator; create_new also protects against
// collisions with producer files. Never overwrite or remove a collision.
let id = self.next_fingerprint.fetch_add(1, Ordering::Relaxed);
let path = generation.source.join(format!("decoded-{id}.rgba"));
let mut writer = create_export(&path)?;
let file = ExportFile {
path,
_generation: generation,
};
writer.write_all(data)?;
drop(writer);
let lease = self.lease(&file.path, data.len())?;
Ok(OwnedExport {
lease,
_file: file,
_reservation: reservation,
})
}
@@ -102,33 +221,84 @@ impl FileStore {
}
}
impl Lease {
impl PartialEq for OwnedExport {
fn eq(&self, other: &Self) -> bool {
self.path() == other.path() && self.fingerprint() == other.fingerprint()
}
}
impl Eq for OwnedExport {}
impl OwnedExport {
pub(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> {
self.lease.copy_rgba()
}
pub(crate) fn read_into(&self, data: &mut [u8]) -> io::Result<()> {
self.lease.read_into(data)
}
pub(crate) fn path(&self) -> &Path {
&self.inner.path
self.lease.path()
}
pub(crate) fn len(&self) -> usize {
self.inner.len
self.lease.len()
}
pub(crate) fn fingerprint(&self) -> u64 {
self.inner.fingerprint
self.lease.fingerprint()
}
}
impl Drop for ExportFile {
fn drop(&mut self) {
if let Err(err) = fs::remove_file(&self.path) {
if err.kind() != io::ErrorKind::NotFound {
tracing::warn!(path = %self.path.display(), err = %err, "failed to remove decoded graphics export");
}
}
}
}
impl Lease {
fn path(&self) -> &Path {
&self.path
}
pub(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> {
let _keep_generation_alive = &self.inner.generation;
validate_path_identity(&self.inner.path, &self.inner.metadata)?;
let mut data = vec![0; self.inner.len];
read_exact_at(&self.inner.file, &mut data)?;
if has_byte_at(&self.inner.file, self.inner.len as u64)? {
fn len(&self) -> usize {
self.len
}
fn fingerprint(&self) -> u64 {
self.fingerprint
}
fn copy_rgba(&self) -> io::Result<Vec<u8>> {
let mut data = vec![0; self.len];
self.read_into(&mut data)?;
Ok(data)
}
fn read_into(&self, data: &mut [u8]) -> io::Result<()> {
if data.len() != self.len {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"incorrect output length",
));
}
let _keep_generation_alive = &self.generation;
validate_path_identity(&self.path, &self.metadata)?;
read_exact_at(&self.file, data)?;
if has_byte_at(&self.file, self.len as u64)? {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame length changed while leased",
));
}
validate_metadata(&self.inner.file.metadata()?, self.inner.len)?;
validate_path_identity(&self.inner.path, &self.inner.metadata)?;
Ok(data)
validate_metadata(&self.file.metadata()?, self.len)?;
validate_path_identity(&self.path, &self.metadata)?;
Ok(())
}
}
@@ -144,12 +314,17 @@ impl Drop for Generation {
#[cfg(unix)]
pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> {
validate_source_under(path, expected_len, runtime_base())
}
#[cfg(unix)]
fn validate_source_under(path: &Path, expected_len: usize, base: PathBuf) -> io::Result<()> {
let source = path.parent().ok_or_else(invalid_path)?;
let generation = source.parent().ok_or_else(invalid_path)?;
if !path.is_absolute()
|| path.file_name().is_none()
|| source.file_name().and_then(|name| name.to_str()) != Some("source")
|| generation.parent() != Some(runtime_base().as_path())
|| generation.parent() != Some(base.as_path())
|| !generation
.file_name()
.and_then(|name| name.to_str())
@@ -157,7 +332,7 @@ pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Re
{
return Err(invalid_path());
}
for directory in [runtime_base(), generation.to_owned(), source.to_owned()] {
for directory in [base, generation.to_owned(), source.to_owned()] {
validate_directory(&directory)?;
}
let file = open_no_follow(path)?;
@@ -166,6 +341,26 @@ pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Re
validate_path_identity(path, &metadata)
}
/// Unlike the API validator, accepts only the dedicated native hierarchy.
#[cfg(unix)]
pub(crate) fn validate_native_source(path: &Path, expected_len: usize) -> io::Result<()> {
if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) {
return Err(invalid_path());
}
validate_source_under(path, expected_len, native_base())
}
fn native_base() -> PathBuf {
#[cfg(unix)]
{
PathBuf::from(format!("/var/tmp/herdr-native-sources-{}", effective_uid()))
}
#[cfg(not(unix))]
{
PathBuf::from("native-sources-unavailable")
}
}
fn create_generation(base: &Path) -> io::Result<Generation> {
#[cfg(not(unix))]
{
@@ -177,8 +372,16 @@ fn create_generation(base: &Path) -> io::Result<Generation> {
}
#[cfg(unix)]
{
fs::create_dir_all(base)?;
fs::set_permissions(base, fs::Permissions::from_mode(DIRECTORY_MODE))?;
if base == native_base() {
match fs::DirBuilder::new().mode(DIRECTORY_MODE).create(base) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
} else {
fs::create_dir_all(base)?;
fs::set_permissions(base, fs::Permissions::from_mode(DIRECTORY_MODE))?;
}
validate_directory(base)?;
remove_stale_generations(base);
let nonce = std::time::SystemTime::now()
@@ -275,6 +478,23 @@ fn validate_child(path: &Path, directory: &Path) -> io::Result<()> {
Ok(())
}
fn create_export(path: &Path) -> io::Result<File> {
#[cfg(unix)]
{
OpenOptions::new()
.write(true)
.create_new(true)
.mode(FILE_MODE)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)
}
#[cfg(not(unix))]
{
let _ = path;
Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only"))
}
}
fn open_no_follow(path: &Path) -> io::Result<File> {
#[cfg(unix)]
{
@@ -389,6 +609,277 @@ mod tests {
path
}
#[test]
fn native_budget_is_per_store_and_released_only_at_final_owner_drop() {
let store = FileStore::native_sources();
let other = FileStore::native_sources();
let budget = store.native_budget.as_ref().unwrap();
let mut holds = Vec::new();
for _ in 0..64 {
holds.push(Reservation::acquire(budget, 4).unwrap());
}
assert!(Reservation::acquire(budget, 4).is_err());
assert!(Reservation::acquire(other.native_budget.as_ref().unwrap(), 4).is_ok());
holds.clear();
for _ in 0..4 {
holds.push(Reservation::acquire(budget, MAX_EXPORT_BYTES).unwrap());
}
assert!(Reservation::acquire(budget, 4).is_err());
holds.clear();
// Exercise the exact OwnedExport lifetime independently of FS reflink support.
let export = store.export(&[1, 2, 3, 4]).unwrap();
let first = Arc::new(export);
let last = Arc::clone(&first);
let path = first.path().to_owned();
drop(first);
assert_eq!(budget.lock().unwrap().objects, 1);
assert!(path.exists());
drop(last);
assert_eq!(budget.lock().unwrap().objects, 0);
assert!(!path.exists());
}
#[test]
fn native_validation_is_separate_and_private() {
let store = FileStore::native_sources();
let export = store.export(&[1, 2, 3, 4]).unwrap();
validate_native_source(export.path(), 4).unwrap();
assert!(validate_direct_source(export.path(), 4).is_err());
assert!(validate_native_source(export.path(), 8).is_err());
let decoded = FileStore::default().export(&[1, 2, 3, 4]).unwrap();
assert!(validate_native_source(decoded.path(), 4).is_err());
let link = export.path().with_extension("link");
symlink(export.path(), &link).unwrap();
assert!(validate_native_source(&link, 4).is_err());
fs::remove_file(&link).unwrap();
fs::hard_link(export.path(), &link).unwrap();
assert!(validate_native_source(export.path(), 4).is_err());
fs::remove_file(link).unwrap();
fs::set_permissions(export.path(), fs::Permissions::from_mode(0o644)).unwrap();
assert!(validate_native_source(export.path(), 4).is_err());
}
#[test]
fn snapshot_validation_releases_budget_and_cleans_staging() {
let store = FileStore::native_sources();
for len in [0, 3, MAX_EXPORT_BYTES + 4] {
assert!(store.snapshot(-1, len).is_err());
}
assert!(!store.is_initialized());
assert!(store.snapshot(-1, 4).is_err());
assert_eq!(
store
.native_budget
.as_ref()
.unwrap()
.lock()
.unwrap()
.objects,
0
);
assert_eq!(
fs::read_dir(store.source_directory().unwrap())
.unwrap()
.count(),
0
);
}
#[test]
fn snapshot_is_independent_and_preserves_borrowed_fd_offset_when_supported() {
use std::io::{Seek, SeekFrom};
use std::os::fd::AsRawFd;
let store = FileStore::native_sources();
let path = frame(&store, "producer", &vec![7; 4096]);
let mut producer = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
producer.seek(SeekFrom::Start(13)).unwrap();
assert!(store.snapshot(producer.as_raw_fd() as i64, 4).is_err());
let result = store.snapshot(producer.as_raw_fd() as i64, 4096);
assert_eq!(producer.stream_position().unwrap(), 13);
let snapshot = match result {
Ok(snapshot) => snapshot,
Err(err) => {
// Unsupported FS/range/cross-device is a normal parent-reader fallback.
assert!(
err.kind() == io::ErrorKind::Unsupported
|| matches!(
err.raw_os_error(),
Some(
libc::EOPNOTSUPP
| libc::ENOTTY
| libc::EXDEV
| libc::EINVAL
| libc::ENOSYS
)
),
"{err}"
);
assert_eq!(
fs::read_dir(store.source_directory().unwrap())
.unwrap()
.count(),
1
);
assert_eq!(
store
.native_budget
.as_ref()
.unwrap()
.lock()
.unwrap()
.objects,
0
);
return;
}
};
producer.write_at(&vec![9; 4096], 0).unwrap();
assert_eq!(snapshot.copy_rgba().unwrap(), vec![7; 4096]);
producer.set_len(0).unwrap();
fs::remove_file(path).unwrap();
let mut bytes = vec![0; 4096];
snapshot.read_into(&mut bytes).unwrap();
assert_eq!(bytes, vec![7; 4096]);
assert!(snapshot.read_into(&mut [0; 4]).is_err());
assert_eq!(producer.stream_position().unwrap(), 13);
}
#[test]
fn tmpfs_source_clone_failure_leaves_no_artifact() {
use std::os::fd::AsRawFd;
let path = PathBuf::from(format!(
"/dev/shm/herdr-snapshot-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let Ok(mut producer) = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(FILE_MODE)
.open(&path)
else {
return;
};
producer.write_all(&[1, 2, 3, 4]).unwrap();
fs::remove_file(path).unwrap();
let store = FileStore::native_sources();
assert!(store.snapshot(producer.as_raw_fd() as i64, 4).is_err());
assert_eq!(
fs::read_dir(store.source_directory().unwrap())
.unwrap()
.count(),
0
);
assert_eq!(
store
.native_budget
.as_ref()
.unwrap()
.lock()
.unwrap()
.objects,
0
);
}
#[test]
fn decoded_exports_snapshot_bytes_and_have_distinct_identities() {
let (store, base) = store();
let mut data = vec![1, 2, 3, 4];
let first = store.export(&data).unwrap();
data.fill(9);
let second = store.export(&data).unwrap();
assert_eq!(first.len(), 4);
assert_eq!(fs::read(first.path()).unwrap(), [1, 2, 3, 4]);
assert_eq!(first.lease.copy_rgba().unwrap(), [1, 2, 3, 4]);
assert_eq!(second.lease.copy_rgba().unwrap(), data);
assert_ne!(first.path(), second.path());
assert_ne!(first.fingerprint(), second.fingerprint());
assert_eq!(
first.path().parent().unwrap(),
store.source_directory().unwrap()
);
validate_metadata(&fs::metadata(first.path()).unwrap(), 4).unwrap();
let path = first.path().to_owned();
drop(first);
assert!(!path.exists());
assert!(second.path().exists());
drop(second);
drop(store);
let _ = fs::remove_dir(base);
}
#[test]
fn decoded_export_guard_retains_generation_until_consumed() {
let (store, base) = store();
let export = store.export(&[1, 2, 3, 4]).unwrap();
let path = export.path().to_owned();
let source = store.source_directory().unwrap();
drop(store);
assert_eq!(fs::read(&path).unwrap(), [1, 2, 3, 4]);
assert!(source.exists());
drop(export);
assert!(!path.exists());
assert!(!source.exists());
let _ = fs::remove_dir(base);
}
#[test]
fn decoded_export_rejects_oversize_and_never_overwrites_collisions() {
let (store, base) = store();
assert!(store.export(&vec![0; MAX_EXPORT_BYTES + 1]).is_err());
assert!(!store.is_initialized());
let collision = frame(&store, "decoded-1.rgba", &[7, 8]);
assert!(store.export(&[1, 2, 3, 4]).is_err());
assert_eq!(fs::read(&collision).unwrap(), [7, 8]);
assert_eq!(
fs::read_dir(store.source_directory().unwrap())
.unwrap()
.count(),
1
);
let export = store.export(&[1, 2, 3, 4]).unwrap();
assert_ne!(export.path(), collision);
drop(export);
drop(store);
let _ = fs::remove_dir(base);
}
#[test]
fn unpublished_export_guard_removes_partial_file_on_error() {
let (store, base) = store();
let generation = store.generation().unwrap();
let path = generation.source.join("partial.rgba");
let result = (|| -> io::Result<()> {
let mut writer = create_export(&path)?;
let _cleanup = ExportFile {
path: path.clone(),
_generation: generation,
};
writer.write_all(&[1, 2])?;
drop(writer);
store.lease(&path, 4)?;
Ok(())
})();
assert!(result.is_err());
assert!(!path.exists());
assert!(store.source_directory().unwrap().exists());
drop(store);
let _ = fs::remove_dir(base);
}
#[test]
fn decoded_export_passes_direct_source_validation() {
let store = FileStore::default();
let export = store.export(&[1, 2, 3, 4]).unwrap();
validate_direct_source(export.path(), export.len()).unwrap();
}
#[test]
fn source_directory_is_lazy_private_and_removed_with_store() {
let (store, base) = store();
+3
View File
@@ -1,3 +1,6 @@
mod native_image_sources;
pub(crate) use native_image_sources::clone_native_image_source;
use std::{
collections::{HashSet, VecDeque},
io::{Read, Write},
@@ -0,0 +1,96 @@
//! Bounded kernel CoW snapshots. No read/write or whole-file clone fallback.
use std::fs::File;
use std::io;
use std::os::fd::AsRawFd;
#[repr(C)]
struct CloneRange {
src_fd: i64,
src_offset: u64,
src_length: u64,
dest_offset: u64,
}
fn stat(fd: libc::c_int) -> io::Result<libc::stat> {
let mut result = std::mem::MaybeUninit::<libc::stat>::uninit();
// SAFETY: fstat initializes the supplied stat on success; fd remains borrowed.
if unsafe { libc::fstat(fd, result.as_mut_ptr()) } != 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: successful fstat initialized the value.
Ok(unsafe { result.assume_init() })
}
fn invalid() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"invalid native snapshot source or destination",
)
}
pub(crate) fn clone_native_image_source(
source_fd: i64,
destination: &File,
expected_len: usize,
) -> io::Result<()> {
let fd = libc::c_int::try_from(source_fd).map_err(|_| invalid())?;
if fd < 0
|| expected_len == 0
|| expected_len > 16 * 1024 * 1024
|| !expected_len.is_multiple_of(4)
{
return Err(invalid());
}
let source = stat(fd)?;
let dest = stat(destination.as_raw_fd())?;
// SAFETY: geteuid has no preconditions.
let uid = unsafe { libc::geteuid() };
let valid_source = |value: &libc::stat| {
value.st_mode & libc::S_IFMT == libc::S_IFREG
&& value.st_uid == uid
&& value.st_size == expected_len as i64
&& value.st_dev == source.st_dev
&& value.st_ino == source.st_ino
};
if !valid_source(&source)
|| dest.st_mode & libc::S_IFMT != libc::S_IFREG
|| dest.st_mode & 0o777 != 0o600
|| dest.st_uid != uid
|| dest.st_nlink != 1
|| dest.st_size != 0
|| (dest.st_dev == source.st_dev && dest.st_ino == source.st_ino)
{
return Err(invalid());
}
let range = CloneRange {
src_fd: source_fd,
src_offset: 0,
src_length: expected_len as u64,
dest_offset: 0,
};
// Explicit nonzero length is essential: FICLONE (or a zero range length)
// clones through EOF and source growth could exceed the reservation before
// post-validation. This request cannot extend the destination past the
// reservation even if the producer changes size concurrently. Filesystems
// requiring block alignment may reject an EOF range after concurrent growth;
// that is a normal fallback, never a reason to try an unbounded clone.
// SAFETY: the request consumes a valid repr(C) range, both descriptors stay
// open throughout the call, and the source descriptor is never owned here.
if unsafe { libc::ioctl(destination.as_raw_fd(), libc::FICLONERANGE, &range) } != 0 {
return Err(io::Error::last_os_error());
}
let after = stat(destination.as_raw_fd())?;
if !valid_source(&stat(fd)?)
|| after.st_dev != dest.st_dev
|| after.st_ino != dest.st_ino
|| after.st_size != expected_len as i64
|| after.st_nlink != 1
|| after.st_uid != uid
|| after.st_mode & 0o777 != 0o600
{
return Err(invalid());
}
// The kernel serializes clone against writes. These stats establish exact
// length at validation points, not producer quiescence or a content hash.
Ok(())
}
+15
View File
@@ -328,6 +328,8 @@ mod remote_bridge_tests;
#[cfg(unix)]
mod unix_common;
#[cfg(unix)]
pub(crate) mod unix_image_files;
#[cfg(unix)]
pub(crate) use unix_common::{
begin_cli_output, end_cli_output, forward_remote_bridge_stdio, RemoteBridgeWake,
};
@@ -725,3 +727,16 @@ pub(crate) fn shared_ssh_control_path(
"interactive SSH recovery requires Unix OpenSSH multiplexing",
))
}
/// Kernel CoW snapshots are deliberately unsupported outside Linux.
#[cfg(not(target_os = "linux"))]
pub(crate) fn clone_native_image_source(
_source_fd: i64,
_destination: &std::fs::File,
_expected_len: usize,
) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"native source cloning requires Linux",
))
}
+602
View File
@@ -0,0 +1,602 @@
//! Unix-owned Kitty temporary-file ledger and bounded crash recovery.
use std::fs::{self, DirBuilder, OpenOptions};
use std::io::{self, Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
const MAX_FILES: usize = 8;
const MAX_BYTES: usize = 64 * 1024 * 1024;
const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
// Bound even abandoned generations: at most 512 MiB per Unix user. When all
// slots are live/recent, a new client uses inline rather than allocating more.
const MAX_GENERATIONS: usize = 8;
const STALE_GRACE: Duration = Duration::from_secs(60);
const GENERATION_PREFIX: &str = "herdr-tty-graphics-protocol-";
const CONSUMPTION_TIMEOUT: Duration = Duration::from_secs(10);
struct Pending {
path: PathBuf,
bytes: usize,
published: Instant,
}
enum ProbeState {
Unsent,
Pending(PathBuf),
Consumed,
}
pub(crate) struct Ledger {
root: PathBuf,
directory: Option<Generation>,
pending: Vec<Pending>,
next: u64,
disabled: bool,
probe: ProbeState,
}
impl Ledger {
#[cfg(test)]
pub(crate) fn for_test(root: PathBuf) -> Self {
let mut ledger = Self::new();
ledger.root = root;
ledger
}
pub(crate) fn new() -> Self {
Self {
// Do not use TMPDIR/XDG_RUNTIME_DIR: a client override need not
// be in the terminal's accepted temporary-file roots. /tmp is
// explicitly accepted by Ghostty graphics_image.zig in v1.1.3,
// v1.2.3 and main, independent of its TMPDIR. /var/tmp is NOT
// generally accepted, so this bounded ledger must use /tmp.
root: PathBuf::from("/tmp"),
directory: None,
pending: Vec::new(),
next: 0,
disabled: false,
probe: ProbeState::Unsent,
}
}
pub(crate) fn probe(&mut self) -> Option<PathBuf> {
if self.disabled || !matches!(self.probe, ProbeState::Unsent) {
return None;
}
let path = self.prepare_file_at(&[0, 0, 0, 0], Instant::now())?;
self.probe = ProbeState::Pending(path.clone());
Some(path)
}
pub(crate) fn prepare(&mut self, data: &[u8]) -> Option<PathBuf> {
self.prepare_at(data, Instant::now())
}
fn prepare_at(&mut self, data: &[u8], now: Instant) -> Option<PathBuf> {
self.reap(now);
if !matches!(self.probe, ProbeState::Consumed) {
return None;
}
self.prepare_file_at(data, now)
}
fn prepare_file_at(&mut self, data: &[u8], now: Instant) -> Option<PathBuf> {
let bytes: usize = self.pending.iter().map(|entry| entry.bytes).sum();
if self.disabled
|| data.is_empty()
|| data.len() > MAX_IMAGE_BYTES
|| self.pending.len() >= MAX_FILES
|| data.len() > MAX_BYTES - bytes
{
return None;
}
match self.write_file(data) {
Ok(path) => {
self.pending.push(Pending {
path: path.clone(),
bytes: data.len(),
published: now,
});
Some(path)
}
Err(_) => {
// Do not repeatedly perform failing I/O on every frame.
self.disabled = true;
None
}
}
}
fn reap(&mut self, now: Instant) {
if let ProbeState::Pending(path) = &self.probe {
if matches!(fs::symlink_metadata(path), Err(error) if error.kind() == io::ErrorKind::NotFound)
{
// With the allowlisted raw query this demonstrates local medium
// consumption, not arbitrary image acceptance or presentation.
self.probe = ProbeState::Consumed;
}
}
self.pending
.retain(|entry| match fs::symlink_metadata(&entry.path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
_ => true, // Permission/I/O errors are NOT evidence of consumption.
});
if self
.pending
.iter()
.any(|entry| now.saturating_duration_since(entry.published) >= CONSUMPTION_TIMEOUT)
{
self.disabled = true;
}
}
fn write_file(&mut self, data: &[u8]) -> io::Result<PathBuf> {
if self.directory.is_none() {
self.directory = Some(create_generation(&self.root, SystemTime::now())?);
}
let number = self.next;
self.next = self
.next
.checked_add(1)
.ok_or_else(|| io::Error::other("file sequence exhausted"))?;
let path = self
.directory
.as_ref()
.ok_or_else(|| io::Error::other("missing file generation"))?
.path
.join(format!("tty-graphics-protocol-{number}"));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)?;
let result = file
.set_permissions(fs::Permissions::from_mode(0o600))
.and_then(|()| file.write_all(data));
// Close before publishing, including on write errors. No fsync is
// needed: the terminal reads the same filesystem/page cache.
drop(file);
if let Err(error) = result {
if fs::remove_file(&path).is_err() {
// Never published, but retain ownership if immediate
// cleanup failed so shutdown can retry the exact path.
self.pending.push(Pending {
path,
bytes: data.len(),
published: Instant::now(),
});
}
return Err(error);
}
Ok(path)
}
pub(crate) fn cleanup(&mut self) {
self.disabled = true;
for entry in self.pending.drain(..) {
let _ = fs::remove_file(entry.path);
}
if let Some(directory) = self.directory.take() {
// Never recursively delete: remove only paths we created.
let _ = fs::remove_dir(&directory.path);
}
}
}
impl Drop for Ledger {
fn drop(&mut self) {
self.cleanup();
}
}
/// Keeping this directory descriptor locked prevents recovery by another client.
struct Generation {
path: PathBuf,
_lock: fs::File,
}
fn effective_uid() -> u32 {
// SAFETY: geteuid takes no pointers and has no preconditions.
unsafe { libc::geteuid() }
}
fn open_private_directory(path: &Path) -> io::Result<fs::File> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)?;
let metadata = file.metadata()?;
if metadata.uid() != effective_uid() || metadata.mode() & 0o7777 != 0o700 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"untrusted image directory",
));
}
Ok(file)
}
fn lock_directory(file: &fs::File) -> io::Result<()> {
// SAFETY: this live File owns the descriptor; flock neither consumes it nor
// dereferences pointers. Drop releases the advisory lock automatically.
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
fn create_generation(root: &Path, now: SystemTime) -> io::Result<Generation> {
let parent = root.join(format!("herdr-kitty-files-{}", effective_uid()));
match DirBuilder::new().mode(0o700).create(&parent) {
Ok(()) => fs::set_permissions(&parent, fs::Permissions::from_mode(0o700))?,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
// The persistent per-user parent is never removed: replacing its inode
// would split its lock domain. Other users cannot populate or rename it.
let parent_lock = open_private_directory(&parent)?;
lock_directory(&parent_lock)?;
if recover_generations(&parent, now)? >= MAX_GENERATIONS {
return Err(io::Error::other("image generation budget exhausted"));
}
let path = private_directory(&parent)?;
let result = open_private_directory(&path).and_then(|file| {
lock_directory(&file)?;
Ok(file)
});
match result {
Ok(file) => Ok(Generation { path, _lock: file }),
Err(error) => {
let _ = fs::remove_dir(path);
Err(error)
}
}
}
/// Caller holds the parent lock. Only unlocked, old generations can be removed;
/// terminal reads belonging to live clients are never invalidated by recovery.
/// Crash recovery is opportunistic at the next client's first file preparation.
fn recover_generations(parent: &Path, now: SystemTime) -> io::Result<usize> {
let entries = fs::read_dir(parent)?
.take(MAX_GENERATIONS + 1)
.collect::<io::Result<Vec<_>>>()?;
if entries.len() > MAX_GENERATIONS {
return Err(io::Error::other("unexpected image generation count"));
}
let mut remaining = entries.len();
for entry in entries {
let name = entry.file_name();
let Some(suffix) = name
.to_str()
.and_then(|name| name.strip_prefix(GENERATION_PREFIX))
else {
continue;
};
if suffix.len() != 32 || !suffix.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
let path = entry.path();
let Ok(lock) = open_private_directory(&path) else {
continue;
};
if lock_directory(&lock).is_err() {
continue;
}
let metadata = lock.metadata()?;
let old = metadata
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age >= STALE_GRACE);
if old && remove_stale_generation(&path)? {
remaining -= 1;
}
}
Ok(remaining)
}
/// Inspect the entire bounded set before deleting anything. Never follow links,
/// recurse, or remove unknown content, even inside our own namespace.
fn remove_stale_generation(path: &Path) -> io::Result<bool> {
let entries = fs::read_dir(path)?
.take(MAX_FILES + 1)
.collect::<io::Result<Vec<_>>>()?;
if entries.len() > MAX_FILES {
return Ok(false);
}
for entry in &entries {
let name = entry.file_name();
let valid_name = name
.to_str()
.and_then(|name| name.strip_prefix("tty-graphics-protocol-"))
.is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()));
let metadata = fs::symlink_metadata(entry.path())?;
if !valid_name
|| !metadata.is_file()
|| metadata.uid() != effective_uid()
|| metadata.mode() & 0o7777 != 0o600
|| metadata.nlink() != 1
{
return Ok(false);
}
}
for entry in entries {
fs::remove_file(entry.path())?;
}
fs::remove_dir(path)?;
Ok(true)
}
fn private_directory(root: &Path) -> io::Result<PathBuf> {
for _ in 0..8 {
let mut random = [0u8; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut random)?;
let name = u128::from_ne_bytes(random);
let path = root.join(format!("{GENERATION_PREFIX}{name:032x}"));
match DirBuilder::new().mode(0o700).create(&path) {
Ok(()) => {
if let Err(error) = fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) {
let _ = fs::remove_dir(&path);
return Err(error);
}
return Ok(path);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"temporary directory collisions",
))
}
#[cfg(test)]
mod tests {
use super::*;
struct TestLedger {
ledger: Ledger,
scratch: PathBuf,
}
impl std::ops::Deref for TestLedger {
type Target = Ledger;
fn deref(&self) -> &Ledger {
&self.ledger
}
}
impl std::ops::DerefMut for TestLedger {
fn deref_mut(&mut self) -> &mut Ledger {
&mut self.ledger
}
}
impl Drop for TestLedger {
fn drop(&mut self) {
self.ledger.cleanup();
// Only this test's uniquely allocated private scratch tree.
let _ = fs::remove_dir_all(&self.scratch);
}
}
fn test_ledger() -> TestLedger {
let scratch = private_directory(Path::new("/var/tmp")).unwrap();
let mut ledger = Ledger::new();
ledger.root = scratch.clone();
// Most ledger tests start after successful query consumption.
ledger.probe = ProbeState::Consumed;
TestLedger { ledger, scratch }
}
#[test]
fn private_complete_unique_files_and_shutdown() {
let mut ledger = test_ledger();
let first = ledger.prepare(b"raw\0pixels").unwrap();
let second = ledger.prepare(b"other").unwrap();
let dir = first.parent().unwrap().to_owned();
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"raw\0pixels");
assert_eq!(
fs::metadata(&first).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
0o700
);
assert!(first
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("tty-graphics-protocol"));
let mut other = test_ledger();
assert_ne!(
other.prepare(b"other client").unwrap().parent().unwrap(),
dir
);
drop(ledger);
assert!(!first.exists());
assert!(!second.exists());
assert!(!dir.exists());
}
#[test]
fn count_cap_and_terminal_consumption() {
let mut ledger = test_ledger();
for _ in 0..MAX_FILES {
assert!(ledger.prepare(b"x").is_some());
}
assert!(ledger.prepare(b"inline").is_none());
let consumed = ledger.pending[0].path.clone();
fs::remove_file(consumed).unwrap();
assert!(ledger.prepare(b"replacement").is_some());
assert_eq!(ledger.pending.len(), MAX_FILES);
}
#[test]
fn byte_and_image_caps() {
let mut ledger = test_ledger();
assert!(ledger.prepare(&[]).is_none());
let mut data = vec![0; MAX_IMAGE_BYTES + 1];
assert!(ledger.prepare(&data).is_none());
assert!(ledger.directory.is_none());
data.pop();
for _ in 0..MAX_BYTES / MAX_IMAGE_BYTES {
assert!(ledger.prepare(&data).is_some());
}
assert!(ledger.prepare(b"x").is_none());
fs::remove_file(&ledger.pending[0].path).unwrap();
assert!(ledger.prepare(&data).is_some());
}
#[test]
fn timeout_disables_without_unlinking_published_files() {
let mut ledger = test_ledger();
let start = Instant::now();
let path = ledger.prepare_at(b"pending", start).unwrap();
assert!(ledger
.prepare_at(b"inline", start + CONSUMPTION_TIMEOUT)
.is_none());
assert_eq!(fs::read(&path).unwrap(), b"pending");
fs::remove_file(path).unwrap();
assert!(ledger
.prepare_at(b"still inline", start + CONSUMPTION_TIMEOUT)
.is_none());
}
#[test]
fn local_creation_errors_fall_back_and_never_overwrite() {
let mut ledger = test_ledger();
let path = ledger.prepare(b"published").unwrap();
ledger.next = 0; // Force an exclusive-create collision.
assert!(ledger.prepare(b"must not overwrite").is_none());
assert_eq!(fs::read(path).unwrap(), b"published");
let mut broken = test_ledger();
broken.root = ledger.pending[0].path.clone(); // Not a directory.
assert!(broken.prepare(b"inline").is_none());
assert!(broken.disabled);
}
#[test]
fn probe_is_once_bounded_and_required_before_real_uploads() {
let mut ledger = test_ledger();
ledger.probe = ProbeState::Unsent;
assert!(ledger.prepare(b"real").is_none());
assert!(ledger.directory.is_none());
let query = ledger.probe().unwrap();
assert_eq!(fs::read(&query).unwrap(), [0, 0, 0, 0]);
assert_eq!(ledger.pending.len(), 1);
assert_eq!(ledger.pending[0].bytes, 4);
assert!(ledger.probe().is_none());
assert!(ledger.prepare(b"real").is_none());
fs::remove_file(query).unwrap();
assert!(ledger.prepare(b"real").is_some());
assert!(ledger.probe().is_none());
}
#[test]
fn probe_timeout_preserves_file_and_permanently_disables_conversion() {
let mut ledger = test_ledger();
ledger.probe = ProbeState::Unsent;
let query = ledger.probe().unwrap();
let late = ledger.pending[0].published + CONSUMPTION_TIMEOUT;
assert!(ledger.prepare_at(b"real", late).is_none());
assert!(ledger.disabled);
assert_eq!(fs::read(&query).unwrap(), [0, 0, 0, 0]);
fs::remove_file(query).unwrap();
assert!(ledger.prepare_at(b"real", late).is_none());
assert!(ledger.probe().is_none());
}
fn recover_for_test(parent: &Path, now: SystemTime) -> usize {
let lock = open_private_directory(parent).unwrap();
lock_directory(&lock).unwrap();
recover_generations(parent, now).unwrap()
}
#[test]
fn recovery_preserves_live_and_recent_but_removes_abandoned_generations() {
let mut ledger = test_ledger();
let image = ledger.prepare(b"live").unwrap();
let generation_path = image.parent().unwrap().to_owned();
let parent = generation_path.parent().unwrap();
let later = SystemTime::now() + STALE_GRACE + Duration::from_secs(1);
assert_eq!(recover_for_test(parent, later), 1);
assert!(image.exists()); // Advisory lock protects live clients even if old.
let generation = ledger.directory.take().unwrap();
ledger.pending.clear(); // Simulate process death without Drop cleanup.
drop(generation);
assert_eq!(recover_for_test(parent, SystemTime::now()), 1);
assert!(image.exists()); // Grace protects terminal work after a crash.
assert_eq!(recover_for_test(parent, later), 0);
assert!(!generation_path.exists());
}
#[test]
fn generation_cap_bounds_crash_leftovers_and_parent_lock_is_nonblocking() {
let fixture = test_ledger();
let root = &fixture.scratch;
let now = SystemTime::now();
let mut generations = Vec::new();
for _ in 0..MAX_GENERATIONS {
generations.push(create_generation(root, now).unwrap());
}
assert!(create_generation(root, now).is_err());
let parent = generations[0].path.parent().unwrap().to_owned();
let lock = open_private_directory(&parent).unwrap();
lock_directory(&lock).unwrap();
assert!(create_generation(root, now).is_err());
drop(lock);
drop(generations); // Empty abandoned generations, as after an early crash.
assert!(create_generation(root, now).is_err());
let recovered =
create_generation(root, now + STALE_GRACE + Duration::from_secs(1)).unwrap();
assert_eq!(fs::read_dir(parent).unwrap().count(), 1);
drop(recovered);
}
#[test]
fn recovery_rejects_symlinks_and_unknown_content() {
use std::os::unix::fs::symlink;
let mut ledger = test_ledger();
let image = ledger.prepare(b"keep").unwrap();
let generation_path = image.parent().unwrap().to_owned();
let parent = generation_path.parent().unwrap();
fs::remove_file(&image).unwrap();
let outside = ledger.scratch.join("outside");
fs::write(&outside, b"outside").unwrap();
symlink(&outside, &image).unwrap();
ledger.pending.clear();
drop(ledger.directory.take());
let later = SystemTime::now() + STALE_GRACE + Duration::from_secs(1);
assert_eq!(recover_for_test(parent, later), 1);
assert_eq!(fs::read(&outside).unwrap(), b"outside");
fs::remove_file(&image).unwrap();
fs::write(generation_path.join("unknown"), b"keep").unwrap();
assert_eq!(recover_for_test(parent, later), 1);
assert!(generation_path.exists());
}
#[test]
fn existing_parent_must_be_owned_private_directory_not_symlink() {
use std::os::unix::fs::symlink;
let fixture = test_ledger();
let parent = fixture
.scratch
.join(format!("herdr-kitty-files-{}", effective_uid()));
let destination = fixture.scratch.join("destination");
fs::create_dir(&destination).unwrap();
symlink(&destination, &parent).unwrap();
assert!(create_generation(&fixture.scratch, SystemTime::now()).is_err());
fs::remove_file(&parent).unwrap();
fs::create_dir(&parent).unwrap();
fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
assert!(create_generation(&fixture.scratch, SystemTime::now()).is_err());
}
}
+125
View File
@@ -1190,9 +1190,55 @@ pub struct SurfaceGraphicsAssetKey {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceGraphicsAsset {
pub key: SurfaceGraphicsAssetKey,
#[serde(
serialize_with = "serialize_graphics_bytes",
deserialize_with = "deserialize_graphics_bytes"
)]
pub data: Vec<u8>,
}
fn serialize_graphics_bytes<S: serde::Serializer>(
data: &[u8],
serializer: S,
) -> Result<S::Ok, S::Error> {
// Bincode's byte slice has the same length+bytes layout as Vec<u8>,
// but avoids per-byte serialization. Keep human-readable codecs unchanged.
if serializer.is_human_readable() {
data.serialize(serializer)
} else {
serializer.serialize_bytes(data)
}
}
fn deserialize_graphics_bytes<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<u8>, D::Error> {
if deserializer.is_human_readable() {
return Vec::<u8>::deserialize(deserializer);
}
struct BytesVisitor;
impl<'de> serde::de::Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("image bytes")
}
fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
Ok(bytes.to_vec())
}
fn visit_byte_buf<E: serde::de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> {
Ok(bytes)
}
}
// The framed slice decoder checks the length against available input before
// handing bytes to the visitor, so a forged length cannot cause an allocation.
deserializer.deserialize_bytes(BytesVisitor)
}
/// One already-clipped desired placement relative to its target surface.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bincode::Decode)]
pub struct SurfaceGraphicsPlacement {
@@ -1733,6 +1779,85 @@ pub fn check_client_version(client_version: u32) -> VersionCheck {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graphics_bulk_codec_preserves_legacy_wire_and_json() {
#[derive(Serialize, Deserialize)]
struct LegacyAsset {
key: SurfaceGraphicsAssetKey,
data: Vec<u8>,
}
for len in [0, 1, 250, 251, 65535, 65536, 800 * 480 * 4] {
let asset = SurfaceGraphicsAsset {
key: SurfaceGraphicsAssetKey {
source: SurfaceGraphicsSource::Terminal {
target: SurfaceGraphicsTarget::Pane {
pane_id: "pane-1".into(),
},
image_id: 7,
},
image_width: 800,
image_height: 480,
format: SurfaceGraphicsFormat::Rgba,
data_len: len as u64,
data_fingerprint: 42,
},
data: (0..len).map(|i| (i % 256) as u8).collect(),
};
let legacy = LegacyAsset {
key: asset.key.clone(),
data: asset.data.clone(),
};
let config = bincode::config::standard();
let before = bincode::serde::encode_to_vec(&legacy, config).unwrap();
let after = bincode::serde::encode_to_vec(&asset, config).unwrap();
assert_eq!(after, before);
let (decoded, used): (SurfaceGraphicsAsset, _) =
bincode::serde::decode_from_slice(&before, config).unwrap();
assert_eq!(decoded, asset);
assert_eq!(used, before.len());
let (decoded, used): (LegacyAsset, _) =
bincode::serde::decode_from_slice(&after, config).unwrap();
assert_eq!(decoded.data, asset.data);
assert_eq!(decoded.key, asset.key);
assert_eq!(used, after.len());
let json = serde_json::to_value(&legacy).unwrap();
assert_eq!(serde_json::to_value(&asset).unwrap(), json);
assert_eq!(
serde_json::from_value::<SurfaceGraphicsAsset>(json).unwrap(),
asset
);
assert!(
bincode::serde::decode_from_slice::<SurfaceGraphicsAsset, _>(
&before[..before.len() - 1],
config
)
.is_err()
);
}
}
#[test]
fn graphics_bulk_decode_rejects_truncated_and_forged_lengths() {
#[derive(Debug, Deserialize)]
struct Bytes(#[serde(deserialize_with = "deserialize_graphics_bytes")] Vec<u8>);
let config = bincode::config::standard();
let original: Vec<u8> = (0..=255).collect();
let encoded = bincode::serde::encode_to_vec(&original, config).unwrap();
for end in 0..encoded.len() {
assert!(
bincode::serde::decode_from_slice::<Bytes, _>(&encoded[..end], config).is_err()
);
}
let (decoded, consumed): (Bytes, _) =
bincode::serde::decode_from_slice(&encoded, config).unwrap();
assert_eq!(decoded.0, original);
assert_eq!(consumed, encoded.len());
for length in [MAX_GRAPHICS_FRAME_SIZE as u64 + 1, u64::MAX] {
let forged = bincode::serde::encode_to_vec(length, config).unwrap();
assert!(bincode::serde::decode_from_slice::<Bytes, _>(&forged, config).is_err());
}
}
use ratatui::style::{Color, Modifier};
use sha2::{Digest, Sha256};
+13 -10
View File
@@ -274,6 +274,7 @@ pub(super) struct RenderedPaneSurface {
pub(super) popup: Option<Box<protocol::ClientShellPopupSurface>>,
pub(super) graphics: protocol::SurfaceGraphicsScene,
pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache,
pub(super) graphics_sources: crate::kitty_graphics::surface::SourceFiles,
}
#[derive(Debug)]
@@ -438,16 +439,17 @@ pub(super) fn render_pane_surface(
let popup = show_popup
.then(|| render_popup_surface(app, area, resize_panes, cell_size))
.flatten();
let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect(
app,
&layout.pane_infos,
&layout.split_borders,
popup.as_deref(),
target,
cell_size,
graphics_delivery,
client_id,
);
let (graphics, next_graphics_delivery, graphics_sources) =
crate::server::client_shell_graphics::collect(
app,
&layout.pane_infos,
&layout.split_borders,
popup.as_deref(),
target,
cell_size,
graphics_delivery,
client_id,
);
if let Some(target) = target {
for (&pane_id, &(epoch, _)) in &content_revisions_before {
if let Some(runtime) = app.state.runtime_for_pane_in_workspace(
@@ -488,6 +490,7 @@ pub(super) fn render_pane_surface(
popup,
graphics,
graphics_delivery: next_graphics_delivery,
graphics_sources,
})
}
+3 -3
View File
@@ -1,4 +1,4 @@
use crate::kitty_graphics::surface::DeliveryCache;
use crate::kitty_graphics::surface::{DeliveryCache, SourceFiles};
use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene};
pub(crate) fn collect_retained(
@@ -8,7 +8,7 @@ pub(crate) fn collect_retained(
cell_size: crate::kitty_graphics::HostCellSize,
delivered: &DeliveryCache,
client_id: u64,
) -> Option<(SurfaceGraphicsScene, DeliveryCache)> {
) -> Option<(SurfaceGraphicsScene, DeliveryCache, SourceFiles)> {
let rect = |rect: crate::protocol::SurfaceRect| {
ratatui::layout::Rect::new(rect.x, rect.y, rect.width, rect.height)
};
@@ -52,7 +52,7 @@ pub(crate) fn collect(
cell_size: crate::kitty_graphics::HostCellSize,
delivered: &DeliveryCache,
client_id: u64,
) -> (SurfaceGraphicsScene, DeliveryCache) {
) -> (SurfaceGraphicsScene, DeliveryCache, SourceFiles) {
let popup_content_size = popup.map(|popup| (popup.frame.width, popup.frame.height));
crate::kitty_graphics::surface::collect_scene(
app,
+18
View File
@@ -132,6 +132,24 @@ impl ClientWriter {
self.render.queue.discard_pending_render();
}
#[cfg(test)]
pub(crate) fn test_paused() -> Self {
let queue = ClientWriterQueue::new();
Self {
control: ClientControlWriter::queue(queue.clone()),
render: ClientRenderWriter::queue(queue),
}
}
#[cfg(test)]
pub(crate) fn test_drain(&self) -> Vec<Vec<u8>> {
let mut state = self.render.queue.lock_state();
let mut frames = state.control.drain(..).collect::<Vec<_>>();
frames.extend(state.ordered.drain(..));
frames.extend(state.render.take());
frames
}
#[cfg(test)]
pub(crate) fn test_fill_render(&self, data: Vec<u8>) {
self.render.try_send(data).unwrap();
+25 -208
View File
@@ -76,8 +76,8 @@ mod bootstrap;
mod client_views;
mod endpoint_requests;
mod lifecycle;
mod native_graphics;
mod notifications;
mod pane_graphics;
mod render;
mod retained_surface;
mod surface_interest;
@@ -132,31 +132,16 @@ enum LoopEvent {
RenderRequested,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
/// Presentation work caused by a server event.
///
/// Keeping this classification separate from event delivery makes the input
/// contract explicit: events may reach a PTY without necessarily repainting.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RenderImpact {
#[default]
None,
Graphics,
Full,
}
impl RenderImpact {
fn merge(&mut self, other: Self) {
*self = (*self).max(other);
}
}
fn record_render_impact(source: &'static str, impact: RenderImpact) {
let event = match (source, impact) {
("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests",
("api_requests", RenderImpact::Full) => "full_render_cause.api_requests",
("server_events", RenderImpact::Graphics) => "graphics_render_cause.server_events",
("server_events", RenderImpact::Full) => "full_render_cause.server_events",
_ => return,
};
crate::render_prof::event(event);
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -205,6 +190,7 @@ pub struct HeadlessServer {
client_socket_path: PathBuf,
client_socket_identity: SocketFileIdentity,
clients: HashMap<u64, ClientConnection>,
native_graphics: native_graphics::NativeGraphics,
#[cfg(unix)]
next_client_id: u64,
/// The client currently driving session-wide host presentation and side effects.
@@ -350,6 +336,7 @@ impl HeadlessServer {
client_socket_path: client_path,
client_socket_identity,
clients: HashMap::new(),
native_graphics: Default::default(),
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
@@ -463,22 +450,7 @@ impl HeadlessServer {
}
// 3. Drain API requests.
if self.pane_graphics_runtime_active() {
let api_impact = self.drain_api_requests_with_render_impact();
record_render_impact("api_requests", api_impact);
match api_impact {
RenderImpact::None => {}
RenderImpact::Graphics => {
needs_render = true;
needs_graphics_render = true;
}
RenderImpact::Full => {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
}
} else if self.drain_api_requests_with_shutdown_check() {
if self.drain_api_requests_with_shutdown_check() {
needs_render = true;
needs_full_render = true;
crate::render_prof::event("full_render_cause.api_requests");
@@ -494,22 +466,7 @@ impl HeadlessServer {
self.accept_client_connections()?;
// 5. Drain server events from client threads.
if self.pane_graphics_runtime_active() {
let server_impact = self.drain_server_events_with_render_impact();
record_render_impact("server_events", server_impact);
match server_impact {
RenderImpact::None => {}
RenderImpact::Graphics => {
needs_render = true;
needs_graphics_render = true;
}
RenderImpact::Full => {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
}
} else if self.drain_server_events() {
if self.drain_server_events() {
needs_render = true;
needs_full_render = true;
crate::render_prof::event("full_render_cause.server_events");
@@ -541,13 +498,10 @@ impl HeadlessServer {
crate::render_prof::event("full_render_cause.default_workspace");
}
if self.retain_live_pane_graphics() {
if self.expire_native_graphics(now) {
needs_render = true;
needs_graphics_render = true;
}
if self.expire_direct_graphics(now) {
needs_render = true;
needs_graphics_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
self.drain_client_config_reload_request();
@@ -689,43 +643,13 @@ impl HeadlessServer {
}
}
LoopEvent::Api(msg) => {
if self.pane_graphics_runtime_active() {
let impact = self.handle_api_request_with_render_impact(*msg);
record_render_impact("api_requests", impact);
match impact {
RenderImpact::None => {}
RenderImpact::Graphics => {
needs_render = true;
needs_graphics_render = true;
}
RenderImpact::Full => {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
}
} else if self.handle_api_request_with_shutdown_check(*msg) {
if self.handle_api_request_with_shutdown_check(*msg) {
needs_render = true;
needs_full_render = true;
}
}
LoopEvent::ServerEvent(ev) => {
if self.pane_graphics_runtime_active() {
let impact = self.handle_server_event_with_render_impact(ev);
record_render_impact("server_events", impact);
match impact {
RenderImpact::None => {}
RenderImpact::Graphics => {
needs_render = true;
needs_graphics_render = true;
}
RenderImpact::Full => {
needs_render = true;
needs_full_render = true;
needs_graphics_render = false;
}
}
} else if self.handle_server_event(ev) {
if self.handle_server_event_with_render_impact(ev) == RenderImpact::Full {
needs_render = true;
needs_full_render = true;
}
@@ -825,15 +749,11 @@ impl HeadlessServer {
}
fn sync_foreground_client_state(&mut self) {
self.app.direct_graphics_available = self.direct_graphics_available();
self.app.pixel_mouse_available = self.foreground_client_id.is_some_and(|id| {
self.clients
.get(&id)
.is_some_and(|client| client.pixel_mouse)
});
if !self.app.direct_graphics_available {
self.retire_all_direct_graphics();
}
let Some(client_id) = self.foreground_client_id else {
self.effective_size = self.headless_size;
self.app.state.outer_terminal_focus = None;
@@ -966,53 +886,12 @@ impl HeadlessServer {
})
}
fn existing_direct_graphics_client(&self) -> Option<u64> {
self.app
.pane_graphics
.slots
.values()
.filter_map(crate::app::pane_graphics::Slot::direct_client)
.filter_map(|client_id| {
let client = self.clients.get(&client_id)?;
self.client_supports_direct_graphics(client_id)
.then_some((client.last_activity, client_id))
})
.max()
.map(|(_, client_id)| client_id)
}
fn direct_graphics_client(&self) -> Option<u64> {
self.existing_direct_graphics_client().or_else(|| {
self.foreground_client_id
.filter(|client_id| self.client_supports_direct_graphics(*client_id))
})
}
fn direct_graphics_client_for_key(&self, key: &crate::app::pane_graphics::Key) -> Option<u64> {
match self
.app
.pane_graphics
.slots
.get(key)
.and_then(crate::app::pane_graphics::Slot::direct_client)
{
Some(client_id) => self
.client_supports_direct_graphics(client_id)
.then_some(client_id),
None => self.direct_graphics_client(),
}
}
fn direct_graphics_available(&self) -> bool {
self.direct_graphics_client().is_some()
}
fn has_app_client(&self) -> bool {
self.app_client_count() > 0
}
fn remove_client(&mut self, client_id: u64) -> bool {
self.retire_direct_graphics_for_client(client_id);
self.disconnect_native_graphics(client_id);
let disconnected_focus = self
.clients
.get(&client_id)
@@ -1052,7 +931,6 @@ impl HeadlessServer {
self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost);
}
}
self.app.direct_graphics_available = self.direct_graphics_available();
if was_foreground {
self.promote_latest_remaining_client()
} else {
@@ -1131,31 +1009,17 @@ impl HeadlessServer {
}
/// Drains server events from the dedicated channel.
///
/// Uses the original full-render semantics when pane graphics are dormant.
fn drain_server_events(&mut self) -> bool {
let mut changed = false;
while !self.should_quit.load(Ordering::Acquire) {
let Ok(ev) = self.server_event_rx.try_recv() else {
break;
};
changed |= self.handle_server_event(ev);
changed |= self.handle_server_event_with_render_impact(ev) == RenderImpact::Full;
}
changed
}
/// Returns the strongest render impact from the drained event batch.
fn drain_server_events_with_render_impact(&mut self) -> RenderImpact {
let mut impact = RenderImpact::None;
while !self.should_quit.load(Ordering::Acquire) {
let Ok(ev) = self.server_event_rx.try_recv() else {
break;
};
impact.merge(self.handle_server_event_with_render_impact(ev));
}
impact
}
async fn reject_late_client_connections(&mut self) {
self.server_event_rx.close();
while let Some(event) = self.server_event_rx.recv().await {
@@ -2112,12 +1976,17 @@ impl HeadlessServer {
transfer_id,
image_id,
success,
} => self.complete_direct_graphics(client_id, transfer_id, image_id, success),
} => self
.native_result(client_id, transfer_id, image_id, success)
.unwrap_or(false),
ServerEvent::GraphicsTransmissionStarted {
client_id,
transfer_id,
image_id,
} => self.start_direct_graphics_response(client_id, transfer_id, image_id),
} => {
self.native_started(client_id, transfer_id, image_id);
false
}
ServerEvent::ClientAttachTerminal {
client_id,
terminal_id,
@@ -2937,35 +2806,6 @@ impl HeadlessServer {
}
}
fn drain_api_requests_with_render_impact(&mut self) -> RenderImpact {
let mut impact = RenderImpact::None;
while !self.should_quit.load(Ordering::Acquire) {
let Ok(msg) = self.app.api_rx.try_recv() else {
break;
};
impact.merge(self.handle_api_request_with_render_impact(msg));
}
impact
}
fn handle_api_request_with_render_impact(
&mut self,
msg: api::ApiRequestMessage,
) -> RenderImpact {
if matches!(
&msg.request.method,
api::schema::Method::PaneGraphicsStreamSet(_)
| api::schema::Method::PaneGraphicsStreamDirect(_)
) {
return self.handle_pane_graphics_stream_frame(msg);
}
if self.handle_api_request_with_shutdown_check(msg) {
RenderImpact::Full
} else {
RenderImpact::None
}
}
fn handle_api_request_with_shutdown_check_inner(
&mut self,
msg: api::ApiRequestMessage,
@@ -2999,12 +2839,6 @@ impl HeadlessServer {
};
let metadata_expired = self.app.expire_due_metadata(Instant::now());
let stream_open = match &msg.request.method {
api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()),
_ => None,
};
let stream_active = msg.stream_active.clone();
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
let handoff_result = self.perform_live_handoff(params.clone());
let handoff_succeeded = handoff_result.is_ok();
@@ -3054,16 +2888,7 @@ impl HeadlessServer {
_ => {}
}
let pane_graphics_revision_before = matches!(
&msg.request.method,
api::schema::Method::PaneGraphicsSet(_)
| api::schema::Method::PaneGraphicsClear(_)
| api::schema::Method::PaneGraphicsStreamOpen(_)
| api::schema::Method::PaneGraphicsStreamClose(_)
)
.then_some(self.app.pane_graphics.revision());
let mut changed = metadata_expired
| (pane_graphics_revision_before.is_none() && api::request_changes_ui(&msg.request));
let mut changed = metadata_expired | api::request_changes_ui(&msg.request);
let skip_default_workspace = skip_default_workspace_for_request
|| matches!(
&msg.request.method,
@@ -3184,10 +3009,6 @@ impl HeadlessServer {
}
}
}
if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) {
self.app
.attach_pane_graphics_stream_active(params, active, &response);
}
if let Some(spec) = alt_screen_read_spec {
if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) {
if let api::schema::ResponseResult::PaneRead { read } = success.result {
@@ -3210,10 +3031,6 @@ impl HeadlessServer {
}
let _ = msg.respond_to.send(response);
if let Some(revision_before) = pane_graphics_revision_before {
changed |= revision_before != self.app.pane_graphics.revision();
}
// Forward new toast state only when a client-local delivery mode is selected.
// Herdr delivery renders the toast in-frame and must not ask clients to
// show a terminal or system notification.
-1
View File
@@ -127,7 +127,6 @@ impl HeadlessServer {
request: *request,
respond_to,
response_write_complete: None,
stream_active: None,
},
)
}
+605
View File
@@ -0,0 +1,605 @@
//! Experimental decoded native exports. Deliberately independent of API slots/leases.
use super::HeadlessServer;
use crate::kitty_graphics::surface::{DeliveryCache, SourceFiles, NATIVE_SLOT_BIT};
use crate::pane_graphics_files::{FileStore, OwnedExport};
use crate::protocol::{
ServerMessage, SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat,
SurfaceGraphicsScene, SurfaceGraphicsSource,
};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT as NATIVE_BIT;
const MAX_FILE: usize = 16 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(5);
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(3);
pub(super) struct Pending {
export: Arc<OwnedExport>,
transfer_id: u64,
image_id: u32,
source: SurfaceGraphicsSource,
asset: SurfaceGraphicsAssetKey,
slot: bool,
deadline: Instant,
written: bool,
refresh_needed: bool,
scene: SurfaceGraphicsScene,
delivery: DeliveryCache,
}
struct AcknowledgedSlot {
asset: SurfaceGraphicsAssetKey,
slot: bool,
}
pub(super) struct NativeGraphics {
pub(super) enabled: bool,
store: FileStore,
pending: HashMap<u64, Pending>,
disabled: HashSet<u64>,
next_transfer: u64,
source_retries: HashMap<u64, Instant>,
// The key and bank currently addressed for each logical source. Native state
// advances on ACK; inline state and source pruning advance on queued scenes.
acknowledged_slots: HashMap<u64, HashMap<SurfaceGraphicsSource, AcknowledgedSlot>>,
}
impl Default for NativeGraphics {
fn default() -> Self {
Self {
enabled: std::env::var("HERDR_EXPERIMENTAL_KITTY_NATIVE_FILES").as_deref() == Ok("1"),
store: FileStore::default(),
pending: HashMap::new(),
disabled: HashSet::new(),
next_transfer: 1,
source_retries: HashMap::new(),
acknowledged_slots: HashMap::new(),
}
}
}
impl NativeGraphics {
#[cfg(all(test, unix))]
pub(super) fn busy(&self) -> bool {
!self.pending.is_empty()
}
pub(super) fn is_pending(&self, client: u64) -> bool {
self.pending.contains_key(&client)
}
pub(super) fn can_hold(&self, client: u64, scene: &SurfaceGraphicsScene) -> bool {
let Some(pending) = self.pending.get(&client) else {
return true;
};
let same_image = |a: &crate::protocol::SurfaceGraphicsAssetKey,
b: &crate::protocol::SurfaceGraphicsAssetKey| {
a.source == b.source
&& a.image_width == b.image_width
&& a.image_height == b.image_height
&& a.format == b.format
&& a.data_len == b.data_len
};
scene.placements.len() == pending.scene.placements.len()
&& scene
.placements
.iter()
.zip(&pending.scene.placements)
.all(|(current, held)| {
if !same_image(&current.asset, &held.asset) {
return false;
}
let mut geometry = current.clone();
geometry.asset = held.asset.clone();
geometry == *held
})
&& scene.retained_assets.len() == pending.scene.retained_assets.len()
&& scene
.retained_assets
.iter()
.zip(&pending.scene.retained_assets)
.all(|(current, held)| same_image(current, held))
}
pub(super) fn hold(&self, client: u64) -> Option<(SurfaceGraphicsScene, DeliveryCache)> {
self.pending
.get(&client)
.map(|p| (p.scene.clone(), p.delivery.clone()))
}
/// Records bank state only after the scene metadata has been queued.
///
/// `inline_assets` contains only payloads actually queued on the wire; a
/// selected native upload has already been removed and changes bank only
/// after its ACK. A new inline key addresses bank 0, while replaying the
/// exact native key keeps the client's existing key-to-bank mapping.
/// Pruning is committed here as well: a scene rejected by the writer must
/// not mutate bank history for pixels the client is still displaying.
pub(super) fn commit_scene(
&mut self,
client: u64,
scene: &SurfaceGraphicsScene,
inline_assets: &[SurfaceGraphicsAssetKey],
) {
let present_sources: HashSet<_> = scene
.placements
.iter()
.map(|placement| &placement.asset.source)
.chain(scene.retained_assets.iter().map(|asset| &asset.source))
.cloned()
.collect();
let slots = self.acknowledged_slots.entry(client).or_default();
slots.retain(|source, _| present_sources.contains(source));
for asset in inline_assets
.iter()
.filter(|asset| matches!(asset.source, SurfaceGraphicsSource::Terminal { .. }))
{
let same_resident_key = slots
.get(&asset.source)
.is_some_and(|resident| resident.asset == *asset);
if !same_resident_key {
slots.insert(
asset.source.clone(),
AcknowledgedSlot {
asset: asset.clone(),
slot: false,
},
);
}
}
if slots.is_empty() {
self.acknowledged_slots.remove(&client);
}
}
#[cfg(test)]
pub(super) fn prepare(
&mut self,
client: u64,
scope: &str,
scene: &mut SurfaceGraphicsScene,
delivery: &DeliveryCache,
) -> Option<(Pending, ServerMessage)> {
self.prepare_with_sources(client, scope, scene, delivery, &mut SourceFiles::default())
}
pub(super) fn prepare_with_sources(
&mut self,
client: u64,
scope: &str,
scene: &mut SurfaceGraphicsScene,
delivery: &DeliveryCache,
sources: &mut SourceFiles,
) -> Option<(Pending, ServerMessage)> {
if !self.enabled
|| self.disabled.contains(&client)
|| self.pending.contains_key(&client)
|| self.pending.len() >= 8
{
return None;
}
let index = scene
.assets
.iter()
.position(|asset| eligible(asset, sources))?;
let asset = &scene.assets[index];
if self.pending.values().map(|p| p.export.len()).sum::<usize>()
+ asset.key.data_len as usize
> MAX_BYTES
|| self.next_transfer >= NATIVE_BIT
{
return None;
}
let export = if let Some(source) = sources.get(&asset.key) {
Arc::clone(source)
} else {
Arc::new(self.store.export(&asset.data).ok()?)
};
let transfer_id = NATIVE_BIT | self.next_transfer;
self.next_transfer += 1;
let asset_key = asset.key.clone();
let source = asset_key.source.clone();
// Start in bank 1: an older client may still be displaying an inline image
// under the unchanged logical base ID. Thereafter always stage into the
// bank opposite the one this client currently addresses.
let slot = !self
.acknowledged_slots
.get(&client)
.and_then(|slots| slots.get(&source))
.map(|resident| resident.slot)
.unwrap_or(false);
let base = crate::kitty_graphics::surface::native_host_image_id(scope, &asset.key);
let image_id = base ^ if slot { NATIVE_SLOT_BIT } else { 0 };
let message = ServerMessage::GraphicsFile {
path: export.path().to_string_lossy().into_owned(),
expected_len: export.len() as u64,
image_id,
transfer_id,
leading: Vec::new(),
control: format!(
"a=t,f=32,s={},v={},i={image_id},q=0",
asset.key.image_width, asset.key.image_height
),
surface_asset: Some(asset_key.clone()),
};
sources.remove(&asset_key);
scene.assets.remove(index);
let mut held = scene.clone();
held.assets.clear();
Some((
Pending {
export,
transfer_id,
image_id,
source,
asset: asset_key,
slot,
deadline: Instant::now() + DELIVERY_TIMEOUT,
written: false,
refresh_needed: delivery.has_pending(),
scene: held,
delivery: delivery.clone(),
},
message,
))
}
pub(super) fn commit(&mut self, client: u64, pending: Pending) {
self.pending.insert(client, pending);
}
fn matches(&self, client: u64, transfer: u64, image: u32) -> bool {
self.pending
.get(&client)
.is_some_and(|p| p.transfer_id == transfer && p.image_id == image)
}
}
fn materialize_sources(
scene: &mut SurfaceGraphicsScene,
delivery: &mut DeliveryCache,
sources: &mut SourceFiles,
) -> bool {
let mut failed = false;
scene.assets.retain_mut(|asset| {
let Some(source) = sources.remove(&asset.key) else {
return true;
};
match source.copy_rgba() {
Ok(data) => {
asset.data = data;
true
}
Err(error) => {
tracing::debug!(%error, "native source fallback could not be read");
failed = true;
delivery.forget_asset(&asset.key);
false
}
}
});
sources.clear();
failed
}
fn eligible(asset: &SurfaceGraphicsAsset, sources: &SourceFiles) -> bool {
matches!(asset.key.source, SurfaceGraphicsSource::Terminal { .. })
&& asset.key.format == SurfaceGraphicsFormat::Rgba
&& asset.key.data_len > 0
&& asset.key.data_len <= MAX_FILE as u64
&& sources.get(&asset.key).map_or_else(
|| asset.key.data_len == asset.data.len() as u64,
|source| asset.key.data_len == source.len() as u64,
)
&& u64::from(asset.key.image_width)
.checked_mul(u64::from(asset.key.image_height))
.and_then(|pixels| pixels.checked_mul(4))
== Some(asset.key.data_len)
}
impl HeadlessServer {
pub(super) fn defer_changed_native_geometry(
&mut self,
client: u64,
scene: &SurfaceGraphicsScene,
) -> bool {
if self.native_graphics.can_hold(client, scene) {
return false;
}
self.retire_native_graphics_for_client(client);
self.app.render_dirty.request_generic();
if let Some(client) = self.clients.get_mut(&client) {
client.defer_full_render();
}
true
}
fn materialize_native_sources(
&mut self,
client: u64,
scene: &mut SurfaceGraphicsScene,
delivery: &mut DeliveryCache,
sources: &mut SourceFiles,
) {
if materialize_sources(scene, delivery, sources) {
self.native_graphics
.source_retries
.entry(client)
.or_insert_with(|| Instant::now() + std::time::Duration::from_secs(1));
} else {
self.native_graphics.source_retries.remove(&client);
}
}
pub(super) fn prepare_native_scene(
&mut self,
client: u64,
scene: &mut SurfaceGraphicsScene,
delivery: &mut DeliveryCache,
sources: &mut SourceFiles,
) -> Option<(Pending, ServerMessage)> {
if !self.native_graphics.enabled {
self.materialize_native_sources(client, scene, delivery, sources);
return None;
}
if let Some(pending) = self.native_graphics.pending.get_mut(&client) {
pending.refresh_needed |= !scene.assets.is_empty()
|| scene.placements != pending.scene.placements
|| scene.retained_assets != pending.scene.retained_assets;
}
if let Some((held, held_delivery)) = self.native_graphics.hold(client) {
*scene = held;
*delivery = held_delivery;
sources.clear();
return None;
}
if !self.client_supports_direct_graphics(client) {
self.materialize_native_sources(client, scene, delivery, sources);
return None;
}
let mut prepared = self.native_graphics.prepare_with_sources(
client,
&self.client_shell_boot_id,
scene,
delivery,
sources,
);
self.materialize_native_sources(client, scene, delivery, sources);
if let Some((pending, _)) = &mut prepared {
pending.delivery = delivery.clone();
pending.refresh_needed |= delivery.has_pending();
}
prepared
}
pub(super) fn native_started(&mut self, client: u64, transfer: u64, image: u32) -> bool {
if transfer & NATIVE_BIT == 0 {
return false;
}
if !self.native_graphics.disabled.contains(&client)
&& self.native_graphics.matches(client, transfer, image)
{
let p = self
.native_graphics
.pending
.get_mut(&client)
.expect("matched");
p.written = true;
p.deadline = Instant::now() + RESPONSE_TIMEOUT;
}
true
}
pub(super) fn native_result(
&mut self,
client: u64,
transfer: u64,
image: u32,
success: bool,
) -> Option<bool> {
if transfer & NATIVE_BIT == 0 {
return None;
}
if !self.native_graphics.matches(client, transfer, image) {
return Some(false);
}
if self.native_graphics.disabled.contains(&client) {
return Some(self.expire_native_graphics(Instant::now()));
}
if success {
if !self.native_graphics.pending[&client].written {
return Some(false);
}
let pending = self
.native_graphics
.pending
.remove(&client)
.expect("matched");
self.native_graphics
.acknowledged_slots
.entry(client)
.or_default()
.insert(
pending.source,
AcknowledgedSlot {
asset: pending.asset,
slot: pending.slot,
},
);
let refresh = pending.refresh_needed;
if refresh {
if let Some(c) = self.clients.get_mut(&client) {
c.defer_full_render();
}
}
return Some(refresh);
}
self.native_graphics.disabled.insert(client);
self.native_graphics
.pending
.get_mut(&client)
.expect("matched")
.deadline = Instant::now();
Some(self.expire_native_graphics(Instant::now()))
}
pub(super) fn expire_native_graphics(&mut self, now: Instant) -> bool {
let expired: Vec<_> = self
.native_graphics
.pending
.iter()
.filter(|(_, p)| p.deadline <= now)
.map(|(id, p)| (*id, p.transfer_id, p.image_id))
.collect();
let mut changed = false;
self.native_graphics.source_retries.retain(|id, deadline| {
if *deadline > now {
return true;
}
if let Some(client) = self.clients.get_mut(id) {
client.defer_full_render();
changed = true;
}
false
});
for (id, transfer_id, image_id) in expired {
self.native_graphics.disabled.insert(id);
let message = ServerMessage::GraphicsTransmissionRetired {
transfer_id,
image_id,
};
// Control messages precede queued renders. The client permanently rejects
// retired native transfers, including a file still in the render queue.
let sent = self.send_to_client(id, message);
if !sent {
continue;
} // Keep the export alive until retirement is queued or disconnect cleans it.
self.native_graphics.pending.remove(&id);
self.native_graphics.disabled.insert(id);
if let Some(c) = self.clients.get_mut(&id) {
c.shell_graphics_delivery = DeliveryCache::default();
c.defer_full_render();
}
changed = true;
}
changed
}
pub(super) fn retire_native_graphics_for_client(&mut self, id: u64) {
if let Some(p) = self.native_graphics.pending.get_mut(&id) {
p.deadline = Instant::now();
self.native_graphics.disabled.insert(id);
if self.expire_native_graphics(Instant::now()) {
self.app.render_dirty.request_generic();
}
}
}
pub(super) fn disconnect_native_graphics(&mut self, id: u64) {
self.native_graphics.pending.remove(&id);
self.native_graphics.disabled.remove(&id);
self.native_graphics.source_retries.remove(&id);
self.native_graphics.acknowledged_slots.remove(&id);
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::protocol::{SurfaceGraphicsAssetKey, SurfaceGraphicsTarget};
fn asset() -> SurfaceGraphicsAsset {
SurfaceGraphicsAsset {
key: SurfaceGraphicsAssetKey {
source: SurfaceGraphicsSource::Terminal {
target: SurfaceGraphicsTarget::Pane {
pane_id: "native-test".into(),
},
image_id: 1,
},
image_width: 1,
image_height: 1,
format: SurfaceGraphicsFormat::Rgba,
data_len: 4,
data_fingerprint: 123,
},
data: vec![1, 2, 3, 255],
}
}
#[test]
fn eligibility_is_decoded_rgba_terminal_only() {
let mut a = asset();
assert!(eligible(&a, &SourceFiles::default()));
a.key.format = SurfaceGraphicsFormat::Png;
assert!(!eligible(&a, &SourceFiles::default()));
a.key.format = SurfaceGraphicsFormat::Rgba;
a.key.data_len = 5;
assert!(!eligible(&a, &SourceFiles::default()));
a.key.data_len = 4;
a.key.source = SurfaceGraphicsSource::PaneLayer {
pane_id: "p".into(),
layer_id: "l".into(),
};
assert!(!eligible(&a, &SourceFiles::default()));
let mut a = asset();
a.data.resize(MAX_FILE + 1, 0);
a.key.data_len = a.data.len() as u64;
assert!(!eligible(&a, &SourceFiles::default()));
}
#[test]
fn pending_guard_and_client_identity_are_independent() {
let mut state = NativeGraphics {
enabled: true,
..Default::default()
};
let mut scene = SurfaceGraphicsScene {
assets: vec![asset()],
..Default::default()
};
let (pending, _) = state
.prepare(7, "scope", &mut scene, &DeliveryCache::default())
.unwrap();
let path = pending.export.path().to_owned();
let transfer = pending.transfer_id;
let image = pending.image_id;
assert_ne!(transfer & NATIVE_BIT, 0);
assert!(scene.assets.is_empty());
assert!(!state.busy()); // prepare is not a delivery commit
state.commit(7, pending);
assert!(state.matches(7, transfer, image));
assert!(!state.matches(8, transfer, image));
assert!(!state.matches(7, transfer + 1, image));
assert!(!state.matches(7, transfer, image + 1));
assert!(path.exists());
assert!(state.hold(7).unwrap().0.assets.is_empty());
state.pending.remove(&7);
assert!(!path.exists());
state.disabled.insert(7);
scene.assets.push(asset());
assert!(state
.prepare(7, "scope", &mut scene, &DeliveryCache::default())
.is_none());
assert_eq!(scene.assets.len(), 1); // native fallback keeps authoritative inline bytes
}
#[test]
fn one_pending_per_client_and_global_count_limit() {
let mut state = NativeGraphics {
enabled: true,
..Default::default()
};
for id in 0..8 {
let mut scene = SurfaceGraphicsScene {
assets: vec![asset()],
..Default::default()
};
let (pending, _) = state
.prepare(id, "scope", &mut scene, &DeliveryCache::default())
.unwrap();
state.commit(id, pending);
scene.assets.push(asset());
assert!(state
.prepare(id, "scope", &mut scene, &DeliveryCache::default())
.is_none());
}
let mut scene = SurfaceGraphicsScene {
assets: vec![asset()],
..Default::default()
};
assert!(state
.prepare(9, "scope", &mut scene, &DeliveryCache::default())
.is_none());
assert_eq!(scene.assets.len(), 1);
}
}
-513
View File
@@ -1,513 +0,0 @@
use super::{HeadlessServer, RenderImpact};
use crate::api;
use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE};
use crate::server::clients::ClientConnectionMode;
impl HeadlessServer {
pub(super) fn pane_graphics_runtime_active(&self) -> bool {
!self.app.pane_graphics.slots.is_empty()
}
pub(super) fn handle_pane_graphics_stream_frame(
&mut self,
msg: api::ApiRequestMessage,
) -> RenderImpact {
if self.shutting_down {
let _ = self.handle_api_request_with_shutdown_check_inner(msg, false, false);
return RenderImpact::None;
}
let internal_changed = self.drain_all_internal_events_with_forwarding();
let direct = match &msg.request.method {
api::schema::Method::PaneGraphicsStreamDirect(params) => Some(params.clone()),
_ => None,
};
let direct_key = direct.as_ref().and_then(|params| {
self.app.parse_pane_id(&params.pane_id).map(|(_, pane_id)| {
(
pane_id,
params
.layer_id
.clone()
.unwrap_or_else(|| api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()),
)
})
});
let direct_client = direct_key
.as_ref()
.and_then(|key| self.direct_graphics_client_for_key(key));
let gate_busy = self
.app
.pane_graphics
.slots
.values()
.any(|slot| slot.direct_gate.is_some());
self.app.direct_graphics_available = direct_client.is_some() && !gate_busy;
let response = self
.app
.handle_api_request_after_internal_events_drained(msg.request);
self.app.direct_graphics_available = self.direct_graphics_available();
let succeeded = serde_json::from_str::<api::schema::SuccessResponse>(&response).is_ok();
if succeeded {
if let (Some(params), Some(client_id)) = (direct, direct_client) {
let direct_frame = direct_key.as_ref().and_then(|key| {
self.app.pane_graphics.slots.get(key).and_then(|slot| {
let active_owner = slot.stream_owner.as_deref() == Some(&params.owner)
&& slot.stream_is_active();
active_owner.then_some(())?;
slot.layer
.as_ref()
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.map(|lease| {
(
lease.path().to_string_lossy().into_owned(),
lease.len() as u64,
lease.fingerprint(),
)
})
})
});
if let (Some(key), Some((path, expected_len, transfer_id))) =
(direct_key.clone(), direct_frame)
{
let prepared = self.clients.get(&client_id).and_then(|client| {
matches!(client.mode, ClientConnectionMode::ClientShell).then_some(())?;
let layer = self
.app
.pane_graphics
.slots
.get(&key)
.and_then(|slot| slot.layer.as_ref())?;
let asset = crate::kitty_graphics::surface::pane_layer_asset_key(
&self.app, &key, layer,
)?;
let (image_id, control) =
crate::kitty_graphics::surface::direct_upload_control(
&self.client_shell_boot_id,
&asset,
);
Some((
crate::kitty_graphics::DirectFileCommand {
leading: Vec::new(),
control,
},
Some(asset),
image_id,
))
});
let Some((command, surface_asset, image_id)) = prepared else {
if self.install_inline_fallback(&key) {
if msg.respond_to.send(response).is_err() {
self.retire_direct_gate(&key);
}
return if internal_changed {
RenderImpact::Full
} else {
RenderImpact::Graphics
};
}
self.retire_direct_gate(&key);
return if internal_changed {
RenderImpact::Full
} else {
RenderImpact::Graphics
};
};
let message = ServerMessage::GraphicsFile {
path,
expected_len,
image_id,
transfer_id,
leading: command.leading,
control: command.control,
surface_asset,
};
let send =
Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE)
.map_err(|_| std::sync::mpsc::TrySendError::Full(Vec::new()))
.and_then(|framed| {
let Some(writer) = self
.clients
.get(&client_id)
.and_then(|client| client.writer.as_ref())
else {
return Err(std::sync::mpsc::TrySendError::Disconnected(
framed,
));
};
writer.render.send_ordered(framed)
});
match send {
Ok(()) => {
if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) {
slot.direct_gate = Some(crate::app::pane_graphics::DirectGate {
transfer_id,
image_id,
client_id,
deadline: std::time::Instant::now()
+ crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT,
written: false,
success_response: response,
respond_to: msg.respond_to,
});
}
return if internal_changed {
RenderImpact::Full
} else {
RenderImpact::None
};
}
Err(error) => {
self.handle_unwritten_direct_failure(
&key,
response,
msg.respond_to,
error,
);
return RenderImpact::Graphics;
}
}
}
}
}
let response_failed = msg.respond_to.send(response).is_err();
if succeeded && response_failed {
if let Some(key) = direct_key {
self.retire_direct_gate(&key);
}
}
if internal_changed {
RenderImpact::Full
} else if succeeded {
RenderImpact::Graphics
} else {
RenderImpact::None
}
}
fn install_inline_fallback(&mut self, key: &crate::app::pane_graphics::Key) -> bool {
let len = self
.app
.pane_graphics
.slots
.get(key)
.and_then(|slot| slot.layer.as_ref())
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.map(crate::pane_graphics_files::Lease::len);
let Some(len) = len else {
return false;
};
if len > crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES
|| !self.app.pane_graphics.can_store_inline(key, len)
{
return false;
}
let data = self
.app
.pane_graphics
.slots
.get(key)
.and_then(|slot| slot.layer.as_ref())
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.and_then(|lease| lease.copy_rgba().ok());
let Some(data) = data else {
return false;
};
let slot = self
.app
.pane_graphics
.slots
.get_mut(key)
.expect("matched slot");
if !slot.stream_is_active() {
return false;
}
let layer = slot.layer.take().expect("direct layer");
slot.layer = Some(crate::app::pane_graphics::Layer::inline(
layer.format,
layer.image_width,
layer.image_height,
data,
layer.render,
layer.z_index,
));
self.app.pane_graphics.mark_changed();
true
}
pub(super) fn handle_unwritten_direct_failure(
&mut self,
key: &crate::app::pane_graphics::Key,
response: String,
respond_to: std::sync::mpsc::Sender<String>,
error: std::sync::mpsc::TrySendError<Vec<u8>>,
) -> bool {
if matches!(error, std::sync::mpsc::TrySendError::Full(_))
&& self.install_inline_fallback(key)
&& respond_to.send(response).is_ok()
{
return true;
}
self.retire_direct_gate(key);
false
}
pub(super) fn retire_direct_gate(&mut self, key: &crate::app::pane_graphics::Key) {
if self.app.pane_graphics.slots.remove(key).is_some() {
self.app.pane_graphics.mark_changed();
}
self.app.direct_graphics_available = self.direct_graphics_available();
}
pub(super) fn start_direct_graphics_response(
&mut self,
client_id: u64,
transfer_id: u64,
image_id: u32,
) -> bool {
if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| {
slot.stream_is_active()
.then_some(slot.direct_gate.as_mut())
.flatten()
.filter(|gate| {
gate.client_id == client_id
&& gate.transfer_id == transfer_id
&& gate.image_id == image_id
})
}) {
gate.written = true;
gate.deadline =
std::time::Instant::now() + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT;
}
false
}
pub(super) fn complete_direct_graphics(
&mut self,
client_id: u64,
transfer_id: u64,
image_id: u32,
success: bool,
) -> bool {
if self.shutting_down {
self.retire_all_direct_graphics();
return false;
}
let key = self.app.pane_graphics.slots.iter().find_map(|(key, slot)| {
slot.direct_gate
.as_ref()
.is_some_and(|gate| {
slot.stream_is_active()
&& gate.client_id == client_id
&& gate.transfer_id == transfer_id
&& gate.image_id == image_id
&& (!success || gate.written)
})
.then(|| key.clone())
});
let Some(key) = key else {
return false;
};
let pane_is_live = self
.app
.state
.workspaces
.iter()
.any(|workspace| workspace.pane_state(key.0).is_some());
if !pane_is_live {
self.retire_direct_gate_with_client_notice(&key);
return false;
}
if success {
let gate = {
let slot = self
.app
.pane_graphics
.slots
.get_mut(&key)
.expect("matched slot");
if !slot.stream_is_active() {
return false;
}
if let Some(layer) = slot.layer.as_mut() {
layer.mark_resident(client_id);
}
slot.direct_gate.take().expect("matched gate")
};
if gate.respond_to.send(gate.success_response).is_err() {
self.retire_direct_gate(&key);
return true;
}
self.app.direct_graphics_available = self.direct_graphics_available();
return true;
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.direct_graphics = false;
}
self.app.direct_graphics_available = false;
if !self.install_inline_fallback(&key) {
self.retire_direct_gate(&key);
self.retire_all_direct_graphics();
return true;
}
let gate = self
.app
.pane_graphics
.slots
.get_mut(&key)
.filter(|slot| slot.stream_is_active())
.and_then(|slot| slot.direct_gate.take());
let Some(gate) = gate else {
self.retire_direct_gate(&key);
return true;
};
if gate.respond_to.send(gate.success_response).is_err() {
self.retire_direct_gate(&key);
return true;
}
self.retire_all_direct_graphics();
true
}
pub(super) fn expire_direct_graphics(&mut self, now: std::time::Instant) -> bool {
let expired = self
.app
.pane_graphics
.slots
.iter()
.filter_map(|(key, slot)| {
slot.direct_gate
.as_ref()
.filter(|gate| gate.deadline <= now)
.map(|gate| (key.clone(), gate.client_id))
})
.collect::<Vec<_>>();
for (key, client_id) in &expired {
if let Some(slot) = self.app.pane_graphics.slots.get(key) {
if let Some(gate) = &slot.direct_gate {
self.send_to_client(
*client_id,
ServerMessage::GraphicsTransmissionRetired {
transfer_id: gate.transfer_id,
image_id: gate.image_id,
},
);
}
}
if let Some(client) = self.clients.get_mut(client_id) {
client.direct_graphics = false;
}
self.retire_direct_gate(key);
}
if !expired.is_empty() {
self.app.direct_graphics_available = false;
self.retire_all_direct_graphics();
}
!expired.is_empty()
}
fn retire_direct_gate_with_client_notice(&mut self, key: &crate::app::pane_graphics::Key) {
if let Some((client_id, transfer_id, image_id)) = self
.app
.pane_graphics
.slots
.get(key)
.and_then(|slot| slot.direct_gate.as_ref())
.map(|gate| (gate.client_id, gate.transfer_id, gate.image_id))
{
self.send_to_client(
client_id,
ServerMessage::GraphicsTransmissionRetired {
transfer_id,
image_id,
},
);
}
self.retire_direct_gate(key);
}
pub(super) fn retain_live_pane_graphics(&mut self) -> bool {
let dead_direct = self
.app
.pane_graphics
.slots
.iter()
.filter(|((pane_id, _), slot)| {
slot.direct_gate.is_some()
&& !self
.app
.state
.workspaces
.iter()
.any(|workspace| workspace.pane_state(*pane_id).is_some())
})
.map(|(key, _)| key.clone())
.collect::<Vec<_>>();
let changed = !dead_direct.is_empty();
for key in dead_direct {
self.retire_direct_gate_with_client_notice(&key);
}
let retained = self.app.pane_graphics.retain_live_panes(&self.app.state);
changed || retained
}
pub(super) fn retire_all_direct_graphics(&mut self) {
let retired = self
.app
.pane_graphics
.slots
.iter()
.filter(|(_, slot)| {
slot.layer
.as_ref()
.is_some_and(crate::app::pane_graphics::Layer::terminal_only)
})
.map(|(key, slot)| {
let notification = slot
.direct_gate
.as_ref()
.map(|gate| (gate.client_id, gate.transfer_id, gate.image_id));
(key.clone(), notification)
})
.collect::<Vec<_>>();
for (_, notification) in &retired {
if let Some((client_id, transfer_id, image_id)) = notification {
self.send_to_client(
*client_id,
ServerMessage::GraphicsTransmissionRetired {
transfer_id: *transfer_id,
image_id: *image_id,
},
);
}
}
for (key, _) in retired {
self.retire_direct_gate(&key);
}
}
pub(super) fn retire_direct_graphics_for_client(&mut self, client_id: u64) {
let keys = self
.app
.pane_graphics
.slots
.iter()
.filter_map(|(key, slot)| {
let layer = slot.layer.as_ref()?;
let owned = layer.resident_client() == Some(client_id)
|| (layer.direct_lease().is_some()
&& slot
.direct_gate
.as_ref()
.is_some_and(|gate| gate.client_id == client_id));
(layer.terminal_only() && owned).then(|| key.clone())
})
.collect::<Vec<_>>();
for key in keys {
self.retire_direct_gate(&key);
}
}
}
+71 -21
View File
@@ -665,8 +665,16 @@ impl HeadlessServer {
popup,
graphics,
graphics_delivery: next_graphics_delivery,
graphics_sources,
} = shell_render.expect("active shell surface");
surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery));
surface_parts = Some((
panes,
splits,
popup,
graphics,
next_graphics_delivery,
graphics_sources,
));
frame
}
ClientConnectionMode::TerminalPending => continue,
@@ -725,6 +733,20 @@ impl HeadlessServer {
}
};
if surface_parts
.as_ref()
.is_some_and(|(_, _, _, graphics, _, _)| {
self.defer_changed_native_geometry(client_id, graphics)
})
{
continue;
}
let native_upload =
surface_parts
.as_mut()
.and_then(|(_, _, _, graphics, delivery, sources)| {
self.prepare_native_scene(client_id, graphics, delivery, sources)
});
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
};
@@ -734,29 +756,31 @@ impl HeadlessServer {
};
let has_graphics = surface_parts
.as_ref()
.is_some_and(|(_, _, _, graphics, _)| {
.is_some_and(|(_, _, _, graphics, _, _)| {
!graphics.assets.is_empty()
|| !graphics.placements.is_empty()
|| !graphics.retained_assets.is_empty()
});
let mut next_shell_graphics_delivery = None;
let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts {
next_shell_graphics_delivery = Some(delivery);
client
.render_state
.prepare_pane_surface(protocol::PaneSurfaceFrame {
boot_id: self.client_shell_boot_id.clone(),
projection_revision: shell_projection_revision,
surface_revision: 0,
frame,
panes,
splits,
popup,
graphics,
})
} else {
client.render_state.prepare_frame(frame)
};
let prepared =
if let Some((panes, splits, popup, graphics, delivery, _)) = surface_parts {
next_shell_graphics_delivery = Some(delivery);
client.render_state.prepare_pane_surface_with_file(
protocol::PaneSurfaceFrame {
boot_id: self.client_shell_boot_id.clone(),
projection_revision: shell_projection_revision,
surface_revision: 0,
frame,
panes,
splits,
popup,
graphics,
},
native_upload.is_some(),
)
} else {
client.render_state.prepare_frame(frame)
};
let Some(mut prepared) = prepared else {
client.clear_deferred_render();
crate::render_prof::event("full_render.skip_identical");
@@ -768,13 +792,18 @@ impl HeadlessServer {
crate::protocol::MAX_FRAME_SIZE
};
let mut shell_assets_deferred = false;
let serialized = match Self::frame_server_message_with_max(prepared.message(), max) {
let mut serialized = match Self::frame_server_message_with_max(prepared.message(), max)
{
Ok(frame) => frame,
Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => {
warn!(
client_id,
claimed, max, "dropping graphics assets from oversized pane surface"
);
if native_upload.is_some() {
client.defer_full_render();
continue;
}
if !prepared.strip_pane_surface_assets() {
crate::render_prof::event("full_render.serialize_oversized");
continue;
@@ -809,8 +838,29 @@ impl HeadlessServer {
let shell_graphics_pending = next_shell_graphics_delivery
.as_ref()
.is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending);
match writer.render.try_send(serialized) {
if let Some((_, message)) = &native_upload {
let Ok(file_frame) =
Self::frame_server_message_with_max(message, MAX_GRAPHICS_FRAME_SIZE)
else {
client.defer_full_render();
continue;
};
serialized.extend_from_slice(&file_frame);
}
let send = if native_upload.is_some() || self.native_graphics.is_pending(client_id) {
writer.render.send_ordered(serialized)
} else {
writer.render.try_send(serialized)
};
match send {
Ok(()) => {
if let Some((graphics, inline_assets)) = prepared.queued_surface_graphics() {
self.native_graphics
.commit_scene(client_id, graphics, inline_assets);
}
if let Some((pending, _)) = native_upload {
self.native_graphics.commit(client_id, pending);
}
if let Some(delivery) = next_shell_graphics_delivery {
client.shell_graphics_delivery = delivery;
}
+41 -7
View File
@@ -203,6 +203,7 @@ struct RetainedRecipientUpdate {
graphics: Option<(
protocol::PaneSurfaceFrame,
crate::kitty_graphics::surface::DeliveryCache,
crate::kitty_graphics::surface::SourceFiles,
)>,
}
@@ -446,7 +447,7 @@ impl HeadlessServer {
let client = &self.clients[&client_id];
let mut next_surface = surface.clone();
crate::server::render_stream::apply_pane_surface_patch(&mut next_surface, &patch);
let Some((graphics, delivery)) =
let Some((graphics, delivery, sources)) =
crate::server::client_shell_graphics::collect_retained(
&self.app,
&next_surface,
@@ -460,7 +461,7 @@ impl HeadlessServer {
};
graphics_changed = graphics != surface.graphics;
next_surface.graphics = graphics;
Some((next_surface, delivery))
Some((next_surface, delivery, sources))
} else {
None
};
@@ -490,8 +491,17 @@ impl HeadlessServer {
let RetainedRecipientUpdate {
client_id,
patch,
graphics,
mut graphics,
} = update;
if graphics.as_ref().is_some_and(|(surface, _, _)| {
self.defer_changed_native_geometry(client_id, &surface.graphics)
}) {
deferred += 1;
continue;
}
let native_upload = graphics.as_mut().and_then(|(surface, delivery, sources)| {
self.prepare_native_scene(client_id, &mut surface.graphics, delivery, sources)
});
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
};
@@ -502,9 +512,11 @@ impl HeadlessServer {
};
// The published row patch cannot carry images. Reuse the retained text/layout
// in a graphics-capable surface message rather than invoking the full renderer.
let (prepared, graphics_delivery) = if let Some((surface, delivery)) = graphics {
let (prepared, graphics_delivery) = if let Some((surface, delivery, _)) = graphics {
(
client.render_state.prepare_pane_surface(surface),
client
.render_state
.prepare_pane_surface_with_file(surface, native_upload.is_some()),
Some(delivery),
)
} else {
@@ -520,7 +532,7 @@ impl HeadlessServer {
} else {
protocol::MAX_FRAME_SIZE
};
let serialized =
let mut serialized =
match Self::frame_server_message_with_max(prepared.message(), max_frame_size) {
Ok(serialized) => serialized,
Err(error) => {
@@ -534,9 +546,31 @@ impl HeadlessServer {
continue;
}
};
if let Some((_, message)) = &native_upload {
let Ok(file_frame) =
Self::frame_server_message_with_max(message, MAX_GRAPHICS_FRAME_SIZE)
else {
client.defer_full_render();
deferred += 1;
continue;
};
serialized.extend_from_slice(&file_frame);
}
crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64);
match writer.render.try_send(serialized) {
let send = if native_upload.is_some() || self.native_graphics.is_pending(client_id) {
writer.render.send_ordered(serialized)
} else {
writer.render.try_send(serialized)
};
match send {
Ok(()) => {
if let Some((graphics, inline_assets)) = prepared.queued_surface_graphics() {
self.native_graphics
.commit_scene(client_id, graphics, inline_assets);
}
if let Some((pending, _)) = native_upload {
self.native_graphics.commit(client_id, pending);
}
let graphics_pending = graphics_delivery
.as_ref()
.is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending);
+1 -1
View File
@@ -57,7 +57,7 @@ impl HeadlessServer {
if let Some(held_inputs) = held_inputs {
self.release_client_shell_inputs(client_id, held_inputs);
self.retire_direct_graphics_for_client(client_id);
self.retire_native_graphics_for_client(client_id);
}
if changed {
self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before);
+4 -20
View File
@@ -1,9 +1,10 @@
use super::*;
#[path = "pane_graphics.rs"]
mod pane_graphics_tests;
mod native_graphics;
#[path = "pane_move.rs"]
mod pane_move_tests;
#[path = "pane_graphics.rs"]
mod retained_graphics_tests;
#[path = "surface_delta.rs"]
mod surface_delta_tests;
#[path = "surface_interest.rs"]
@@ -102,6 +103,7 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ
client_socket_path: socket_path,
client_socket_identity,
clients: HashMap::new(),
native_graphics: Default::default(),
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
@@ -245,7 +247,6 @@ fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo>
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response: api::schema::SuccessResponse =
serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
@@ -303,7 +304,6 @@ fn headless_api_request_drains_all_pending_internal_events_before_reading_state(
},
respond_to,
response_write_complete: None,
stream_active: None,
})
);
let response = response_rx
@@ -2100,7 +2100,6 @@ async fn client_local_navigation_does_not_emit_global_focus_transitions() {
},
respond_to,
response_write_complete: None,
stream_active: None,
},
);
server.app.sync_focus_events();
@@ -2191,7 +2190,6 @@ async fn client_local_navigation_emits_pane_focused_only_when_that_client_moves(
},
respond_to,
response_write_complete: None,
stream_active: None,
},
);
let response = response_rx.recv().expect("navigation response");
@@ -2323,7 +2321,6 @@ async fn repeated_layout_action_reapplies_controller_geometry() {
},
respond_to,
response_write_complete: None,
stream_active: None,
},
));
@@ -2368,7 +2365,6 @@ async fn public_close_reapplies_controller_geometry() {
},
respond_to,
response_write_complete: None,
stream_active: None,
})
);
@@ -2572,7 +2568,6 @@ async fn public_background_tab_create_preserves_client_locations() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert_eq!(
@@ -2620,7 +2615,6 @@ async fn public_workspace_focus_preserves_each_clients_remembered_tabs() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let first_location = server.clients[&41].shell_location.as_ref().unwrap();
@@ -2701,7 +2695,6 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response: crate::api::schema::SuccessResponse =
serde_json::from_str(&response_rx.recv().expect("agent focus response")).unwrap();
@@ -2776,7 +2769,6 @@ async fn public_api_focus_replaces_every_client_shell_projection() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert_eq!(server.app.state.active, Some(1));
server.render_and_stream();
@@ -3393,7 +3385,6 @@ async fn worktree_discovery_does_not_block_client_typing() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
entered
.recv_timeout(Duration::from_secs(5))
@@ -6997,7 +6988,6 @@ fn notification_show_api_forwards_one_semantic_client_notification() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@@ -7060,7 +7050,6 @@ fn notification_show_api_preserves_colon_in_forwarded_title() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@@ -7106,7 +7095,6 @@ fn notification_show_api_validates_empty_title_before_disabled_delivery() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@@ -7137,7 +7125,6 @@ fn notification_show_api_reports_no_foreground_client() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@@ -7188,7 +7175,6 @@ fn notification_show_api_includes_sound_in_semantic_event() {
},
respond_to,
response_write_complete: None,
stream_active: None,
})
);
@@ -7250,7 +7236,6 @@ fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema:
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response = response_rx
.recv_timeout(Duration::from_millis(100))
@@ -7585,7 +7570,6 @@ fn stale_api_agent_report_does_not_forward_done_sound() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -29,7 +29,6 @@ fn public_move(
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response = response_rx.recv().expect("pane move response");
match serde_json::from_str::<SuccessResponse>(&response) {
+58 -2
View File
@@ -7,7 +7,7 @@ use crate::app::state::AppState;
use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit};
use crate::protocol::{
CursorState, FrameData, PaneSurfaceFrame, PaneSurfacePatch, RenderEncoding, ServerMessage,
TerminalFrame,
SurfaceGraphicsAssetKey, SurfaceGraphicsScene, TerminalFrame,
};
use crate::terminal::TerminalRuntimeRegistry;
@@ -152,9 +152,18 @@ impl ClientRenderState {
}
}
#[cfg(test)]
pub(crate) fn prepare_pane_surface(
&mut self,
surface: PaneSurfaceFrame,
) -> Option<PreparedRender> {
self.prepare_pane_surface_with_file(surface, false)
}
pub(crate) fn prepare_pane_surface_with_file(
&mut self,
mut surface: PaneSurfaceFrame,
has_file_upload: bool,
) -> Option<PreparedRender> {
let Self::Semantic {
last_surface,
@@ -166,7 +175,8 @@ impl ClientRenderState {
else {
return None;
};
if !*recompute_pending
if !has_file_upload
&& !*recompute_pending
&& surface.graphics.assets.is_empty()
&& last_surface.as_deref().is_some_and(|last| {
last.projection_revision == surface.projection_revision
@@ -182,6 +192,7 @@ impl ClientRenderState {
}
surface.surface_revision = surface_revision.saturating_add(1);
let assets = std::mem::take(&mut surface.graphics.assets);
let queued_graphics_assets = assets.iter().map(|asset| asset.key.clone()).collect();
let committed_surface = surface.clone();
surface.graphics.assets = assets;
let mut message = ServerMessage::PaneSurface(surface);
@@ -217,6 +228,7 @@ impl ClientRenderState {
Some(PreparedRender::Semantic {
message: delta.or(reused).unwrap_or(message),
committed_surface: Box::new(committed_surface),
queued_graphics_assets,
})
}
@@ -342,6 +354,7 @@ pub(crate) enum PreparedRender {
Semantic {
message: ServerMessage,
committed_surface: Box<PaneSurfaceFrame>,
queued_graphics_assets: Vec<SurfaceGraphicsAssetKey>,
},
SemanticPatch {
message: ServerMessage,
@@ -362,9 +375,26 @@ impl PreparedRender {
}
}
/// Graphics metadata represented by this semantic update plus only the
/// asset keys whose pixel payloads were queued. This is independent of the
/// selected wire codec and avoids cloning asset byte vectors.
pub(crate) fn queued_surface_graphics(
&self,
) -> Option<(&SurfaceGraphicsScene, &[SurfaceGraphicsAssetKey])> {
match self {
Self::Semantic {
committed_surface,
queued_graphics_assets,
..
} => Some((&committed_surface.graphics, queued_graphics_assets)),
Self::SemanticPatch { .. } | Self::TerminalAnsi { .. } => None,
}
}
pub(crate) fn strip_pane_surface_assets(&mut self) -> bool {
let Self::Semantic {
message: ServerMessage::PaneSurface(surface),
queued_graphics_assets,
..
} = self
else {
@@ -374,6 +404,7 @@ impl PreparedRender {
return false;
}
surface.graphics.assets.clear();
queued_graphics_assets.clear();
true
}
}
@@ -730,6 +761,31 @@ mod tests {
assert!(bytes.len() < crate::protocol::MAX_FRAME_SIZE);
}
#[test]
fn deferred_file_upload_keeps_identical_metadata_and_retries_without_committing() {
for reuse in [false, true] {
let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame);
state.enable_surface_reuse(reuse);
let surface = popup_surface("native");
let first = state.prepare_pane_surface(surface.clone()).unwrap();
state.commit_sent_frame(first);
assert!(state.prepare_pane_surface(surface.clone()).is_none());
let file = state
.prepare_pane_surface_with_file(surface.clone(), true)
.unwrap();
let retry = state
.prepare_pane_surface_with_file(surface.clone(), true)
.unwrap();
let config = bincode::config::standard();
assert_eq!(
bincode::serde::encode_to_vec(file.message(), config).unwrap(),
bincode::serde::encode_to_vec(retry.message(), config).unwrap()
);
state.commit_sent_frame(retry);
assert!(state.prepare_pane_surface(surface).is_none());
}
}
#[test]
fn popup_only_surface_changes_are_not_deduplicated() {
let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame);
+4
View File
@@ -593,6 +593,10 @@ impl TerminalRuntime {
#[cfg(test)]
impl TerminalRuntime {
pub(crate) fn test_enable_kitty_source_forwarding(&self) {
self.0.test_enable_kitty_source_forwarding();
}
pub(crate) fn test_contend_during_dirty_collection(
&self,
bytes: Vec<u8>,
+59
View File
@@ -170,3 +170,62 @@ just test-one clear_pane
just maintenance-test
just check
```
## 0007 experimental encoded PNG and immutable source retention
status: active
patch: `vendor/patches/libghostty-vt/0007-experimental-png-retention.patch`
herdr issue: none; maintainer-directed native Kitty forwarding experiment
upstream discussion: not opened
upstream pr: not opened
vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51`
local files:
- `vendor/libghostty-vt/include/ghostty/vt/terminal.h`
- `vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h`
- `vendor/libghostty-vt/src/terminal/c/terminal.zig`
- `vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig`
- `vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig`
- `vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig`
- `vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig`
reason: An explicitly enabled embedding mode retains structurally validated,
quiet PNG uploads as encoded bytes, avoiding pixel decoding before forwarding
through a multiplexer. The existing raw getters retain their meaning; separate
getters expose retained PNG bytes. Queries and response-bearing uploads retain
full validation, and animation operations materialize pixels transactionally.
Storage reserves both encoded bytes and expected decoded size.
This is default-off and experimental: CRC-valid corrupt compressed pixels may
be rejected later than in normal mode, including after placement. Quiet mode
suppresses replies, not validation semantics; this patch is not a claim of full
protocol-equivalent transparent forwarding. Herdr enables it only with
`HERDR_EXPERIMENTAL_KITTY_PNG_FORWARD=1`.
A separate default-off snapshot callback retains host-owned immutable raw RGBA
file backing before reading pixels. Herdr enables it only with
`HERDR_EXPERIMENTAL_KITTY_SOURCE_FILES=1`, using Linux same-filesystem CoW
snapshots. It never retains a mutable producer pathname. Unsupported snapshots
use the original loader; animation materializes pixels transactionally. Backing
ownership and bounded reads are explicit in the embedding ABI.
remove when: upstream provides an equivalent opt-in owned encoded-image
representation and immutable host-backed raw sources with bounded storage,
strict query handling and lazy pixel materialization, or this experiment is retired.
verification:
```sh
just test-one native_source
just test-one png_forward_tests
just test-one kitty_png_replacement
just test-one kitty_file_image_survives
(cd vendor/libghostty-vt && zig build test-lib-vt -Dtest-filter='experimental PNG')
just check
```
+16 -4
View File
@@ -391,12 +391,13 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Borrowed pointer to the raw pixel data. Valid as long as the
* underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when
* the image metadata is resident but its pixel payload is pending.
* the image metadata is resident but its pixel payload is pending, or
* when the experimental PRESERVE_PNG option retained encoded PNG data.
*
* The data is always fully decoded, uncompressed pixels in the
* format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads
* are inflated and PNG payloads are decoded to RGBA at transmission
* time, before the image is stored. Consumers can upload this
* are inflated and PNG payloads are normally decoded to RGBA at transmission
* time, before the image is stored. Consumers can upload returned pixels
* directly to the GPU without any decode step.
*
* For an animated image (Kitty graphics animation, actions a=f/a=a)
@@ -412,7 +413,7 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Length of the raw pixel data in bytes. Always equal to
* width * height * bytes-per-pixel for the reported format. For a
* pending image, this is the expected length reserved against the
* pending or encoded PNG image, this is the expected decoded length reserved against the
* storage limit even though DATA_PTR is not available yet.
*
* Output type: size_t *
@@ -439,6 +440,17 @@ typedef enum GHOSTTY_ENUM_TYPED {
*/
GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9,
/** Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE
* unless experimentally retained. Output type: const uint8_t **.
*/
GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR = 10,
/** Encoded PNG length; NO_VALUE unless retained. Output type: size_t *. */
GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN = 11,
/** Borrowed void* backing context; valid only while image is unchanged. */
GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT = 12,
/** uint64_t immutable revision fingerprint; never reads image pixels. */
GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY = 13,
GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyKittyGraphicsImageData;
+41
View File
@@ -27,6 +27,39 @@
extern "C" {
#endif
/** Borrowed opened descriptor; size is sizeof this request. No path is supplied. */
typedef struct {
size_t size;
int64_t fd;
uint64_t expected_len;
} GhosttyKittyImageSnapshotFileRequest;
typedef bool (*GhosttyKittyImageFileReadFn)(void *context, uint8_t *dest, size_t len);
typedef void (*GhosttyKittyImageFileReleaseFn)(void *context);
typedef struct {
void *context;
uint64_t identity;
size_t len;
GhosttyKittyImageFileReadFn read;
GhosttyKittyImageFileReleaseFn release;
} GhosttyKittyImageFileBacking;
/** Optional synchronous callback, default NULL. Verify exact descriptor size
* and snapshot immutable bytes before returning true. True transfers one context
* reference; false transfers nothing. Do not close the borrowed descriptor or
* reenter the terminal. Both read and release MUST be non-NULL on success.
* Read must fill exactly len bytes; release is called once. An incomplete
* backing is rejected and ordinary file loading is used instead. If release
* is provided, rejection calls it once; without release, the host remains
* responsible for cleanup (the terminal cannot release that context).
* identity identifies the immutable revision, not a pathname or source inode.
*/
typedef bool (*GhosttyKittyImageSnapshotFileFn)(
GhosttyTerminal terminal, void *userdata,
const GhosttyKittyImageSnapshotFileRequest *request,
GhosttyKittyImageFileBacking *out_backing);
/** @defgroup terminal Terminal
*
* Complete terminal emulator state and rendering.
@@ -1545,6 +1578,14 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39,
/** Experimental, default-off PNG retention for quiet base transmissions.
* Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only
* be detected when an animation operation materializes the image.
*/
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40,
/** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
+28 -4
View File
@@ -183,6 +183,10 @@ pub const ImageData = enum(c_int) {
data_ptr = 7,
data_len = 8,
generation = 9,
encoded_png_ptr = 10,
encoded_png_len = 11,
file_context = 12,
file_identity = 13,
pub fn OutType(comptime self: ImageData) type {
return switch (self) {
@@ -190,9 +194,10 @@ pub const ImageData = enum(c_int) {
.id, .number, .width, .height => u32,
.format => ImageFormat,
.compression => ImageCompression,
.data_ptr => [*]const u8,
.data_len => usize,
.generation => u64,
.data_ptr, .encoded_png_ptr => [*]const u8,
.data_len, .encoded_png_len => usize,
.generation, .file_identity => u64,
.file_context => ?*anyopaque,
};
}
};
@@ -266,7 +271,26 @@ fn imageGetTyped(
// data; the image generation changes whenever the current
// frame does, so generation-keyed caches stay coherent.
.data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr,
.data_len => out.* = image.renderData().len(),
.data_len => out.* = switch (image.renderData()) {
.encoded_png => |png| png.decoded_len,
else => image.renderData().len(),
},
.encoded_png_ptr => out.* = switch (image.data) {
.encoded_png => |png| png.bytes.ptr,
else => return .no_value,
},
.encoded_png_len => out.* = switch (image.data) {
.encoded_png => |png| png.bytes.len,
else => return .no_value,
},
.file_context => out.* = switch (image.data) {
.native_file => |file| file.context,
else => return .no_value,
},
.file_identity => out.* = switch (image.data) {
.native_file => |file| file.identity,
else => return .no_value,
},
.generation => out.* = image.generation,
}
+27
View File
@@ -13,6 +13,7 @@ const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
const apc = @import("../apc.zig");
const kitty = @import("../kitty/key.zig");
const kitty_image = @import("../kitty/graphics_image.zig");
const kitty_gfx_c = @import("kitty_graphics.zig");
const modes = @import("../modes.zig");
const point = @import("../point.zig");
@@ -257,6 +258,7 @@ const Effects = struct {
clipboard_write: ?ClipboardWriteFn = null,
clipboard_read: ?ClipboardReadFn = null,
unknown_sequence: ?UnknownSequenceFn = null,
snapshot_file: ?SnapshotFileFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
/// trampoline converts C feature codes into this buffer and returns
@@ -265,6 +267,14 @@ const Effects = struct {
/// (`reportDeviceAttributes`) reads it before any re-entrant call.
da_features_buf: [64]device_attributes.Primary.Feature = undefined,
pub const SnapshotFileFn = *const fn (Terminal, ?*anyopaque, *const kitty_image.SnapshotFileRequest, *kitty_image.FileBacking) callconv(lib.calling_conv) bool;
fn snapshotFile(context: ?*anyopaque, request: *const kitty_image.SnapshotFileRequest, out: *kitty_image.FileBacking) bool {
const wrapper: *TerminalWrapper = @ptrCast(@alignCast(context.?));
const callback = wrapper.effects.snapshot_file orelse return false;
return callback(@ptrCast(wrapper), wrapper.effects.userdata, request, out);
}
/// C function pointer type for the write_pty callback.
pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void;
@@ -1174,12 +1184,15 @@ pub const Option = enum(c_int) {
terminfo_name = 37,
clipboard_read = 38,
clipboard_write_max_bytes = 39,
kitty_image_preserve_png = 40,
kitty_image_snapshot_file = 41,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.write_pty => ?Effects.WritePtyFn,
.kitty_image_snapshot_file => ?Effects.SnapshotFileFn,
.bell => ?Effects.BellFn,
.color_scheme => ?Effects.ColorSchemeFn,
.desktop_notification => ?Effects.DesktopNotificationFn,
@@ -1197,6 +1210,7 @@ pub const Option = enum(c_int) {
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
.kitty_image_storage_limit => ?*const u64,
.kitty_image_preserve_png,
.kitty_image_medium_file,
.kitty_image_medium_shared_mem,
.glyph_protocol,
@@ -1250,6 +1264,17 @@ fn setTyped(
switch (option) {
.userdata => wrapper.effects.userdata = @constCast(value),
.write_pty => wrapper.effects.write_pty = value,
.kitty_image_snapshot_file => {
if (comptime !build_options.kitty_graphics) return .success;
wrapper.effects.snapshot_file = value;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
entry.value.*.kitty_images.image_limits.snapshot_file = if (value != null)
.{ .context = @ptrCast(wrapper), .callback = &Effects.snapshotFile }
else
null;
}
},
.bell => wrapper.effects.bell = value,
.color_scheme => wrapper.effects.color_scheme = value,
.desktop_notification => wrapper.effects.desktop_notification = value,
@@ -1330,6 +1355,7 @@ fn setTyped(
screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit);
}
},
.kitty_image_preserve_png,
.kitty_image_medium_file,
.kitty_image_medium_shared_mem,
=> {
@@ -1339,6 +1365,7 @@ fn setTyped(
while (it.next()) |entry| {
const screen = entry.value.*;
switch (option) {
.kitty_image_preserve_png => screen.kitty_images.image_limits.preserve_png = val,
.kitty_image_medium_file => screen.kitty_images.image_limits.file = val,
.kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val,
else => unreachable,
+102 -1
View File
@@ -509,6 +509,11 @@ fn transmitAnimationFrame(
result.message = "ENOENT: image not found";
return result;
};
storage.materializeBacking(io, alloc, img) catch |err| {
encodeError(&result, err);
return result;
};
result.id = img.id;
var loading = LoadingImage.init(
@@ -773,6 +778,10 @@ fn controlAnimation(
result.message = "ENOENT: image not found";
return result;
};
storage.materializeBacking(io, alloc, img) catch |err| {
encodeError(&result, err);
return result;
};
const anim = ensureAnimation(alloc, img) catch |err| {
encodeError(&result, err);
@@ -857,6 +866,11 @@ fn composeAnimation(
result.message = "ENOENT: image not found";
return result;
};
storage.materializeBacking(io, alloc, img) catch |err| {
encodeError(&result, err);
return result;
};
result.id = img.id;
if (img.data.bytes() == null) {
result.message = "EINVAL: image data incomplete";
@@ -1058,7 +1072,10 @@ fn loadAndAddImage(
// loading.debugDump() catch unreachable;
// Validate and store our image
var img = try loading.complete(alloc);
var img = if (storage.image_limits.preserve_png)
try loading.completePreservingPng(alloc)
else
try loading.complete(alloc);
errdefer img.deinit(alloc);
try storage.addImage(io, alloc, terminal.screens.active, img);
@@ -3601,3 +3618,87 @@ test "kittygfx animation: control negative gap makes frame gapless" {
const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms);
}
test "kittygfx experimental PNG retention quiet inheritance and transactional materialization" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
const sys = @import("../sys.zig");
const Decoder = struct {
var calls: usize = 0;
var fail: bool = false;
fn decode(a: Allocator, _: []const u8) sys.DecodeError!sys.Image {
calls += 1;
if (fail) return error.InvalidData;
const pixels = try a.alloc(u8, 50 * 76 * 4);
@memset(pixels, 0);
return .{ .width = 50, .height = 76, .data = pixels };
}
};
const original = sys.decode_png;
defer sys.decode_png = original;
sys.decode_png = &Decoder.decode;
Decoder.calls = 0;
Decoder.fail = false;
const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
var cmd: Command = .{
.quiet = .failures,
.control = .{ .transmit = .{ .format = .png, .image_id = 1 } },
.data = @constCast(png),
};
// Default off is the normal decoder path even for q=2.
try testing.expect(!storage.image_limits.preserve_png);
_ = execute(io, alloc, &t, &cmd);
try testing.expectEqual(@as(usize, 1), Decoder.calls);
try testing.expect(storage.imageById(1).?.data == .complete);
storage.image_limits.preserve_png = true;
for ([_]Command.Quiet{ .no, .ok }) |quiet| {
cmd.quiet = quiet;
_ = execute(io, alloc, &t, &cmd);
try testing.expect(storage.imageById(1).?.data == .complete);
}
try testing.expectEqual(@as(usize, 3), Decoder.calls);
cmd.quiet = .failures;
cmd.control = .{ .query = .{ .format = .png, .image_id = 2 } };
_ = execute(io, alloc, &t, &cmd);
try testing.expectEqual(@as(usize, 4), Decoder.calls);
try testing.expect(storage.imageById(2) == null);
cmd.control = .{ .transmit = .{ .format = .png, .image_id = 1, .more_chunks = true } };
cmd.data = @constCast(png[0..33]);
_ = execute(io, alloc, &t, &cmd);
cmd.quiet = .no;
cmd.control = .{ .transmit = .{} };
cmd.data = @constCast(png[33..]);
_ = execute(io, alloc, &t, &cmd);
try testing.expectEqual(@as(usize, 4), Decoder.calls);
const img = storage.imagePtrByIdOrNumber(1, 0).?;
try testing.expectEqualSlices(u8, png, img.data.encoded_png.bytes);
try testing.expect(img.data.bytes() == null);
try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes);
const generation = img.generation;
Decoder.fail = true;
const animate: Command = .{ .control = .{ .control_animation = .{ .image_id = 1 } } };
_ = execute(io, alloc, &t, &animate);
try testing.expectEqual(generation, img.generation);
try testing.expect(img.data == .encoded_png);
try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes);
Decoder.fail = false;
_ = execute(io, alloc, &t, &animate);
try testing.expect(img.generation > generation);
try testing.expect(img.data == .complete);
try testing.expectEqual(@as(usize, 50 * 76 * 4), storage.total_bytes);
// The reservation includes both the encoded and decoded representation.
storage.setLimit(io, alloc, t.screens.active, 50 * 76 * 4);
cmd.quiet = .failures;
cmd.control = .{ .transmit = .{ .format = .png, .image_id = 3 } };
cmd.data = @constCast(png);
const calls = Decoder.calls;
_ = execute(io, alloc, &t, &cmd);
try testing.expectEqual(calls, Decoder.calls);
try testing.expect(storage.imageById(3) == null);
try testing.expect(storage.loading == null);
}
+528 -3
View File
@@ -21,6 +21,24 @@ const max_dimension = 10000;
/// Maximum size in bytes, taken from Kitty.
const max_size = 400 * 1024 * 1024; // 400MB
/// Host-owned immutable raw RGBA snapshot. A successful callback transfers one reference.
pub const FileBacking = extern struct {
context: ?*anyopaque,
identity: u64,
len: usize,
read: ?*const fn (?*anyopaque, [*]u8, usize) callconv(.c) bool,
release: ?*const fn (?*anyopaque) callconv(.c) void,
};
pub const SnapshotFileRequest = extern struct {
size: usize = @sizeOf(SnapshotFileRequest),
fd: i64,
expected_len: u64,
};
pub const SnapshotFileHook = struct {
context: ?*anyopaque,
callback: *const fn (?*anyopaque, *const SnapshotFileRequest, *FileBacking) bool,
};
/// An image that is still being loaded. The image should be initialized
/// using init on the first chunk and then addData for each subsequent
/// chunk. Once all chunks have been added, complete should be called
@@ -29,6 +47,7 @@ pub const LoadingImage = struct {
/// The in-progress image. The first chunk must have all the metadata
/// so this comes from that initially.
image: Image,
snapshot_file: ?SnapshotFileHook = null,
/// The data that is being built up.
data: std.ArrayListUnmanaged(u8) = .empty,
@@ -85,6 +104,8 @@ pub const LoadingImage = struct {
disabled: void,
},
shared_memory: bool,
preserve_png: bool = false,
snapshot_file: ?SnapshotFileHook = null,
/// Enables all filesystem-related image transmission mediums. `path`
/// is the temporary directory to expect files in when files are
@@ -132,6 +153,10 @@ pub const LoadingImage = struct {
.metadata = .{ .transient = t.usage.transient },
},
.snapshot_file = if (cmd.control == .transmit or cmd.control == .transmit_and_display)
limits.snapshot_file
else
null,
.display = cmd.display(),
.quiet = cmd.quiet,
.response = .{
@@ -427,6 +452,16 @@ pub const LoadingImage = struct {
return error.InvalidData;
}
// Only whole, uncompressed base RGBA files qualify. The host must
// verify the descriptor size and create an immutable private snapshot.
if (comptime builtin.os.tag != .windows and builtin.os.tag != .wasi and builtin.os.tag != .freestanding) {
if (self.snapshot_file) |hook| {
if (nativeFileExpectedLen(t)) |expected| {
if (try self.trySnapshotFile(hook, .{ .fd = @intCast(file.handle), .expected_len = expected })) return;
}
}
}
var buf: [4096]u8 = undefined;
var buf_reader = file.reader(io, &buf);
if (t.offset > 0) {
@@ -468,6 +503,32 @@ pub const LoadingImage = struct {
self.data = .{ .items = managed.items, .capacity = managed.capacity };
}
/// Accept only complete callback records. A malformed success falls back
/// to the ordinary reader, releasing the transferred reference if possible.
fn trySnapshotFile(self: *LoadingImage, hook: SnapshotFileHook, request: SnapshotFileRequest) !bool {
var backing: FileBacking = .{ .context = null, .identity = 0, .len = 0, .read = null, .release = null };
if (!hook.callback(hook.context, &request, &backing)) return false;
if (backing.read == null or backing.release == null) {
if (backing.release) |release| release(backing.context);
return false;
}
if (backing.len != request.expected_len) {
backing.release.?(backing.context);
return error.InvalidData;
}
self.image.data = .{ .native_file = backing };
return true;
}
fn nativeFileExpectedLen(t: command.Transmission) ?usize {
if (t.medium != .file or t.format != .rgba or t.compression != .none or
t.more_chunks or t.offset != 0 or t.width == 0 or t.height == 0 or
t.width > max_dimension or t.height > max_dimension) return null;
const len = @as(usize, t.width) * @as(usize, t.height) * 4;
if (len > 16 * 1024 * 1024 or (t.size != 0 and t.size != len)) return null;
return len;
}
/// Returns the canonical path of an open file after applying the file
/// transmission blocklist.
fn validatedFilePath(io: std.Io, file: std.Io.File, buf: []u8) ![]const u8 {
@@ -569,7 +630,7 @@ pub const LoadingImage = struct {
// Data length must be what we expect.
const bpp = command.Transmission.formatBpp(img.format);
const expected_len = img.width * img.height * bpp;
const actual_len = self.data.items.len;
const actual_len = if (img.data == .native_file) img.data.native_file.len else self.data.items.len;
if (self.frame != null) {
// Kitty allows animation frames to exceed their expected length
// and just truncates it. Not sure if thats expected but lets
@@ -592,12 +653,37 @@ pub const LoadingImage = struct {
// Everything looks good, copy the image data over.
var result = self.image;
result.data = .{ .complete = try self.data.toOwnedSlice(alloc) };
if (result.data != .native_file) result.data = .{ .complete = try self.data.toOwnedSlice(alloc) };
errdefer result.deinit(alloc);
self.image = .{};
return result;
}
/// Experimental forwarding path: only invoked for assembled quiet base
/// transmissions. Never used by queries or animation frame loading.
pub fn completePreservingPng(self: *LoadingImage, alloc: Allocator) !Image {
if (self.image.format != .png or self.image.compression != .none or
self.quiet != .failures or self.frame != null) return self.complete(alloc);
const header = inspectPng(self.data.items) catch |err| switch (err) {
// Do not expand the deferred-corruption contract to metadata we
// have not validated. Such PNGs keep the normal decoder semantics.
error.UnsupportedPngRetention => return self.complete(alloc),
error.InvalidData => return error.InvalidData,
error.DimensionsRequired => return error.DimensionsRequired,
error.DimensionsTooLarge => return error.DimensionsTooLarge,
};
var result = self.image;
result.width = header.width;
result.height = header.height;
result.format = .rgba;
result.data = .{ .encoded_png = .{
.bytes = try self.data.toOwnedSlice(alloc),
.decoded_len = header.decoded_len,
} };
self.image = .{};
return result;
}
/// Debug function to write the data to a file. This is useful for
/// capturing some test data for unit tests.
pub fn debugDump(io: std.Io, self: LoadingImage) !void {
@@ -768,11 +854,16 @@ pub const Image = struct {
/// Expected decoded byte length for a payload that has not arrived.
pending: usize,
encoded_png: struct { bytes: []const u8, decoded_len: usize },
native_file: FileBacking,
/// Bytes reserved against the storage limit.
pub fn len(self: Data) usize {
return switch (self) {
.complete => |data| data.len,
.pending => |expected_len| expected_len,
.native_file => |file| file.len,
.encoded_png => |png| png.bytes.len + png.decoded_len,
};
}
@@ -780,7 +871,7 @@ pub const Image = struct {
pub fn bytes(self: Data) ?[]const u8 {
return switch (self) {
.complete => |data| data,
.pending => null,
.pending, .encoded_png, .native_file => null,
};
}
@@ -792,10 +883,43 @@ pub const Image = struct {
switch (self.*) {
.complete => |data| if (data.len > 0) alloc.free(data),
.pending => {},
.encoded_png => |png| alloc.free(png.bytes),
.native_file => |file| if (file.release) |release| release(file.context),
}
}
};
/// Transactional: a failed host read leaves the authoritative backing owned.
pub fn materializeFile(self: *Image, alloc: Allocator) !void {
const file = switch (self.data) {
.native_file => |file| file,
else => return,
};
const read = file.read orelse return error.InvalidData;
const release = file.release orelse return error.InvalidData;
const bytes = try alloc.alloc(u8, file.len);
errdefer alloc.free(bytes);
if (!read(file.context, bytes.ptr, bytes.len)) return error.InvalidData;
self.data = .{ .complete = bytes };
release(file.context);
}
/// Transactional: neither ownership nor metadata changes on failure.
pub fn materializePng(self: *Image, alloc: Allocator) !void {
const png = switch (self.data) {
.encoded_png => |png| png,
else => return,
};
const decode = sys.decode_png orelse return error.UnsupportedFormat;
var limited: LimitedAllocator = .init(alloc, max_size);
const result = try decode(limited.allocator(), png.bytes);
errdefer alloc.free(result.data);
if (result.width != self.width or result.height != self.height or
result.data.len != png.decoded_len) return error.InvalidData;
alloc.free(png.bytes);
self.data = .{ .complete = result.data };
}
pub fn deinit(self: *Image, alloc: Allocator) void {
self.data.deinit(alloc);
if (self.animation) |anim| {
@@ -2159,3 +2283,404 @@ test "limits: temporary file medium allowed by limits" {
);
defer loading.deinit(alloc);
}
const PngHeader = struct { width: u32, height: u32, decoded_len: usize };
/// Bounded structural validation, deliberately without inflating IDAT.
/// CRC-valid but corrupt compressed pixels can fail later in this experiment.
fn inspectPng(bytes: []const u8) !PngHeader {
if (bytes.len > max_size or bytes.len < 33 or
!std.mem.eql(u8, bytes[0..8], "\x89PNG\r\n\x1a\n")) return error.InvalidData;
var offset: usize = 8;
var header: ?PngHeader = null;
var idat = false;
var ended_idat = false;
var palette = false;
var indexed = false;
var color_type: u8 = 0;
var bit_depth: u8 = 0;
var palette_entries: usize = 0;
var transparency = false;
while (offset < bytes.len) {
if (bytes.len - offset < 12) return error.InvalidData;
const len: usize = std.mem.readInt(u32, bytes[offset..][0..4], .big);
if (len > bytes.len - offset - 12) return error.InvalidData;
const kind = bytes[offset + 4 ..][0..4];
for (kind) |ch| if (!std.ascii.isAlphabetic(ch)) return error.InvalidData;
if (kind[2] & 0x20 != 0) return error.InvalidData;
const body = bytes[offset + 8 ..][0..len];
const crc = std.mem.readInt(u32, bytes[offset + 8 + len ..][0..4], .big);
if (std.hash.crc.Crc32IsoHdlc.hash(bytes[offset + 4 ..][0 .. len + 4]) != crc)
return error.InvalidData;
offset += len + 12;
if (std.mem.eql(u8, kind, "IHDR")) {
if (header != null or len != 13) return error.InvalidData;
const width = std.mem.readInt(u32, body[0..4], .big);
const height = std.mem.readInt(u32, body[4..8], .big);
if (width == 0 or height == 0) return error.DimensionsRequired;
if (width > max_dimension or height > max_dimension) return error.DimensionsTooLarge;
const depth = body[8];
const supported = switch (body[9]) {
0 => depth == 1 or depth == 2 or depth == 4 or depth == 8 or depth == 16,
2, 4, 6 => depth == 8 or depth == 16,
3 => depth == 1 or depth == 2 or depth == 4 or depth == 8,
else => false,
};
if (!supported or body[10] != 0 or body[11] != 0 or body[12] > 1)
return error.InvalidData;
color_type = body[9];
bit_depth = depth;
indexed = color_type == 3;
const pixels = std.math.mul(usize, width, height) catch return error.InvalidData;
const decoded_len = std.math.mul(usize, pixels, 4) catch return error.InvalidData;
if (decoded_len > max_size) return error.InvalidData;
header = .{ .width = width, .height = height, .decoded_len = decoded_len };
} else {
if (header == null) return error.InvalidData;
if (std.mem.eql(u8, kind, "IDAT")) {
if (ended_idat or (indexed and !palette)) return error.InvalidData;
idat = true;
} else {
if (idat) ended_idat = true;
if (std.mem.eql(u8, kind, "IEND")) {
if (!idat or len != 0 or offset != bytes.len) return error.InvalidData;
return header.?;
} else if (std.mem.eql(u8, kind, "PLTE")) {
if (palette or idat or transparency or len == 0 or len > 768 or len % 3 != 0 or
color_type == 0 or color_type == 4) return error.InvalidData;
palette_entries = len / 3;
if (indexed and palette_entries > (@as(usize, 1) << @intCast(bit_depth)))
return error.InvalidData;
palette = true;
} else if (std.mem.eql(u8, kind, "tRNS")) {
if (transparency or idat) return error.InvalidData;
switch (color_type) {
0, 2 => {
const expected: usize = if (color_type == 0) 2 else 6;
if (len != expected) return error.InvalidData;
const max_sample: u32 = (@as(u32, 1) << @intCast(bit_depth)) - 1;
var i: usize = 0;
while (i < len) : (i += 2) {
if (std.mem.readInt(u16, body[i..][0..2], .big) > max_sample)
return error.InvalidData;
}
},
3 => if (!palette or len == 0 or len > palette_entries)
return error.InvalidData,
// Alpha-bearing color types must not carry tRNS.
else => return error.InvalidData,
}
transparency = true;
} else if (kind[0] & 0x20 == 0) {
return error.InvalidData;
} else {
// sBIT, bKGD, color profiles, APNG, text, and any other
// ancillary extensions are deliberately not eligible.
// The strict decoder handles their validity/semantics.
return error.UnsupportedPngRetention;
}
}
}
}
return error.InvalidData;
}
test "experimental PNG inspection validates CRC and complete structure" {
const testing = std.testing;
const original = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
const header = try inspectPng(original);
try testing.expectEqual(@as(u32, 50), header.width);
try testing.expectEqual(@as(u32, 76), header.height);
try testing.expectEqual(@as(usize, 50 * 76 * 4), header.decoded_len);
try testing.expectError(error.InvalidData, inspectPng(original[0 .. original.len - 1]));
var corrupt = original.*;
corrupt[29] ^= 1; // IHDR CRC
try testing.expectError(error.InvalidData, inspectPng(&corrupt));
corrupt = original.*;
corrupt[0] = 0;
try testing.expectError(error.InvalidData, inspectPng(&corrupt));
}
test "experimental PNG inspection transparency and palette metadata" {
const testing = std.testing;
const Chunk = struct { kind: *const [4]u8, body: []const u8 };
const Helper = struct {
fn append(out: *std.ArrayList(u8), kind: *const [4]u8, body: []const u8) !void {
var word: [4]u8 = undefined;
std.mem.writeInt(u32, &word, @intCast(body.len), .big);
try out.appendSlice(testing.allocator, &word);
const crc_start = out.items.len;
try out.appendSlice(testing.allocator, kind);
try out.appendSlice(testing.allocator, body);
std.mem.writeInt(u32, &word, std.hash.crc.Crc32IsoHdlc.hash(out.items[crc_start..]), .big);
try out.appendSlice(testing.allocator, &word);
}
fn make(color: u8, depth: u8, chunks: []const Chunk) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(testing.allocator);
try out.appendSlice(testing.allocator, "\x89PNG\r\n\x1a\n");
const ihdr = [_]u8{ 0, 0, 0, 1, 0, 0, 0, 1, depth, color, 0, 0, 0 };
try append(&out, "IHDR", &ihdr);
for (chunks) |chunk| try append(&out, chunk.kind, chunk.body);
try append(&out, "IEND", "");
return out.toOwnedSlice(testing.allocator);
}
fn check(color: u8, depth: u8, chunks: []const Chunk, expected: ?anyerror) !void {
const png = try make(color, depth, chunks);
defer testing.allocator.free(png);
if (expected) |err| {
try testing.expectError(err, inspectPng(png));
} else {
_ = try inspectPng(png);
}
}
};
// IDAT is intentionally opaque; this test only exercises structure.
const idat: Chunk = .{ .kind = "IDAT", .body = "" };
const palette: Chunk = .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 255, 255, 255 } };
const alpha: Chunk = .{ .kind = "tRNS", .body = &.{128} };
const gray: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1 } };
const rgb: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1, 0, 2, 0, 3 } };
try Helper.check(3, 1, &.{ palette, alpha, idat }, null);
try Helper.check(0, 1, &.{ gray, idat }, null);
try Helper.check(2, 8, &.{ rgb, idat }, null);
try Helper.check(2, 8, &.{ palette, rgb, idat }, null);
try Helper.check(3, 1, &.{ alpha, palette, idat }, error.InvalidData);
try Helper.check(3, 1, &.{ palette, idat, alpha }, error.InvalidData);
try Helper.check(3, 1, &.{ palette, alpha, alpha, idat }, error.InvalidData);
try Helper.check(0, 8, &.{ alpha, idat }, error.InvalidData);
try Helper.check(2, 8, &.{ gray, idat }, error.InvalidData);
try Helper.check(4, 8, &.{ gray, idat }, error.InvalidData);
try Helper.check(6, 8, &.{ rgb, idat }, error.InvalidData);
try Helper.check(0, 8, &.{ palette, idat }, error.InvalidData);
try Helper.check(4, 8, &.{ palette, idat }, error.InvalidData);
try Helper.check(2, 8, &.{ rgb, palette, idat }, error.InvalidData);
try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = "" }, idat }, error.InvalidData);
try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = &.{ 1, 2, 3 } }, idat }, error.InvalidData);
try Helper.check(3, 1, &.{ .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 1, 1, 1, 2, 2, 2 } }, idat }, error.InvalidData);
try Helper.check(0, 1, &.{ .{ .kind = "tRNS", .body = &.{ 0, 2 } }, idat }, error.InvalidData);
try Helper.check(2, 8, &.{ .{ .kind = "tRNS", .body = &.{ 1, 0, 0, 0, 0, 0 } }, idat }, error.InvalidData);
try Helper.check(0, 16, &.{ .{ .kind = "tRNS", .body = &.{ 255, 255 } }, idat }, null);
for ([_]*const [4]u8{ "sBIT", "bKGD", "iCCP", "gAMA" }) |kind| {
try Helper.check(2, 8, &.{ .{ .kind = kind, .body = "" }, idat }, error.UnsupportedPngRetention);
}
}
test "experimental PNG unsupported metadata falls back to strict decoding" {
const testing = std.testing;
const Decoder = struct {
var calls: usize = 0;
fn decode(_: Allocator, _: []const u8) sys.DecodeError!sys.Image {
calls += 1;
return error.InvalidData;
}
};
const original_decoder = sys.decode_png;
defer sys.decode_png = original_decoder;
sys.decode_png = &Decoder.decode;
Decoder.calls = 0;
const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
var loading: LoadingImage = .{
.image = .{ .format = .png },
.quiet = .failures,
.temporary_directory = null,
};
defer loading.deinit(testing.allocator);
try loading.addData(testing.allocator, png[0..33]);
// CRC-valid ancillary chunk: inspection must delegate, not retain or
// return UnsupportedPngRetention to the caller.
var chunk = [_]u8{ 0, 0, 0, 1, 's', 'B', 'I', 'T', 8, 0, 0, 0, 0 };
std.mem.writeInt(u32, chunk[9..13], std.hash.crc.Crc32IsoHdlc.hash(chunk[4..9]), .big);
try loading.addData(testing.allocator, &chunk);
try loading.addData(testing.allocator, png[33..]);
try testing.expectError(error.InvalidData, loading.completePreservingPng(testing.allocator));
try testing.expectEqual(@as(usize, 1), Decoder.calls);
}
test "native file completion and transactional lazy materialization" {
const testing = std.testing;
const Fake = struct {
reads: usize = 0,
releases: usize = 0,
fail: bool = false,
fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool {
const self: *@This() = @ptrCast(@alignCast(ctx.?));
self.reads += 1;
if (self.fail) return false;
@memset(dest[0..len], 123);
return true;
}
fn release(ctx: ?*anyopaque) callconv(.c) void {
const self: *@This() = @ptrCast(@alignCast(ctx.?));
self.releases += 1;
}
fn backing(self: *@This(), len: usize) FileBacking {
return .{ .context = self, .identity = 7, .len = len, .read = read, .release = release };
}
};
var fake: Fake = .{};
var loading: LoadingImage = .{
.image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } },
.quiet = .no,
.temporary_directory = null,
};
defer loading.deinit(testing.allocator);
var img = try loading.complete(testing.allocator);
defer img.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), fake.reads);
try testing.expectEqual(@as(usize, 4), img.data.len());
try testing.expect(!img.data.isPending());
try testing.expect(img.data.bytes() == null);
fake.fail = true;
try testing.expectError(error.InvalidData, img.materializeFile(testing.allocator));
try testing.expect(img.data == .native_file);
try testing.expectEqual(@as(usize, 0), fake.releases);
fake.fail = false;
try img.materializeFile(testing.allocator);
try testing.expectEqual(@as(usize, 1), fake.releases);
try testing.expectEqualSlices(u8, &.{ 123, 123, 123, 123 }, img.data.bytes().?);
var invalid: LoadingImage = .{
.image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(3) } },
.quiet = .no,
.temporary_directory = null,
};
try testing.expectError(error.InvalidData, invalid.complete(testing.allocator));
invalid.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), fake.releases);
// q=1 also validates length without reading pixels.
var quiet_one: LoadingImage = .{
.image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } },
.quiet = .ok,
.temporary_directory = null,
};
const reads_before = fake.reads;
var quiet_img = try quiet_one.complete(testing.allocator);
quiet_one.deinit(testing.allocator);
try testing.expectEqual(reads_before, fake.reads);
quiet_img.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 3), fake.releases);
var canceled: LoadingImage = .{
.image = .{ .data = .{ .native_file = fake.backing(4) } },
.quiet = .failures,
.temporary_directory = null,
};
canceled.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 4), fake.releases);
}
test "native file eligibility is whole raw RGBA only" {
const testing = std.testing;
var t: command.Transmission = .{ .medium = .file, .format = .rgba, .width = 1, .height = 2 };
try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t));
t.size = 8;
try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t));
t.size = 7;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.size = 0;
t.more_chunks = true;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.more_chunks = false;
t.offset = 1;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.offset = 0;
t.format = .rgb;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.format = .png;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.format = .rgba;
t.medium = .temporary_file;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.medium = .shared_memory;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.medium = .file;
t.compression = .zlib_deflate;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.compression = .none;
t.width = 0;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.width = 10001;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
t.width = 10000;
t.height = 10000;
try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
}
test "native file snapshot rejects missing callbacks before retaining" {
const testing = std.testing;
const Fake = struct {
reads: usize = 0,
releases: usize = 0,
missing_read: bool = true,
missing_release: bool = false,
fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool {
const self: *@This() = @ptrCast(@alignCast(ctx.?));
self.reads += 1;
@memset(dest[0..len], 42);
return true;
}
fn release(ctx: ?*anyopaque) callconv(.c) void {
const self: *@This() = @ptrCast(@alignCast(ctx.?));
self.releases += 1;
}
fn snapshot(ctx: ?*anyopaque, request: *const SnapshotFileRequest, out: *FileBacking) bool {
const self: *@This() = @ptrCast(@alignCast(ctx.?));
out.* = .{
.context = ctx,
.identity = 1,
.len = @intCast(request.expected_len),
.read = if (self.missing_read) null else &read,
.release = if (self.missing_release) null else &release,
};
return true;
}
};
var fake: Fake = .{};
const hook: SnapshotFileHook = .{ .context = &fake, .callback = &Fake.snapshot };
const request: SnapshotFileRequest = .{ .fd = -1, .expected_len = 4 };
var loading: LoadingImage = .{
.image = .{ .width = 1, .height = 1, .format = .rgba },
.quiet = .no,
.temporary_directory = null,
};
// Missing read: transferred context is immediately released, not stored.
try testing.expect(!try loading.trySnapshotFile(hook, request));
try testing.expect(loading.image.data != .native_file);
try testing.expectEqual(@as(usize, 1), fake.releases);
// Missing release (or both): reject without calling a null pointer.
fake.missing_read = false;
fake.missing_release = true;
try testing.expect(!try loading.trySnapshotFile(hook, request));
try testing.expect(loading.image.data != .native_file);
fake.missing_read = true;
try testing.expect(!try loading.trySnapshotFile(hook, request));
try testing.expect(loading.image.data != .native_file);
try testing.expectEqual(@as(usize, 0), fake.reads);
try testing.expectEqual(@as(usize, 1), fake.releases);
// The ordinary reader can still supply bytes after rejection.
try loading.addData(testing.allocator, &.{ 1, 2, 3, 4 });
var img = try loading.complete(testing.allocator);
loading.deinit(testing.allocator);
try testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4 }, img.data.bytes().?);
img.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), fake.releases);
// Valid callback ownership is unchanged: zero-read acceptance, one release.
fake.missing_read = false;
fake.missing_release = false;
var accepted: LoadingImage = .{
.image = .{ .width = 1, .height = 1, .format = .rgba },
.quiet = .no,
.temporary_directory = null,
};
try testing.expect(try accepted.trySnapshotFile(hook, request));
try testing.expect(accepted.image.data == .native_file);
try testing.expectEqual(@as(usize, 0), fake.reads);
accepted.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), fake.releases);
}
@@ -154,7 +154,7 @@ pub const ImageStorage = struct {
if (img.generation != self.generation) return false;
const expected_len = switch (img.data) {
.complete => return false,
.complete, .encoded_png, .native_file => return false,
.pending => |len| len,
};
if (data.len != expected_len) return false;
@@ -975,6 +975,15 @@ pub const ImageStorage = struct {
img.generation = self.generation;
}
pub fn materializeBacking(self: *ImageStorage, io: std.Io, alloc: Allocator, img: *Image) !void {
if (img.data != .encoded_png and img.data != .native_file) return;
const before = img.data.len();
try img.materializeFile(alloc);
try img.materializePng(alloc);
self.total_bytes -= before - img.data.len();
self.markImageContentChanged(io, img);
}
/// Convert a stored image's base data to RGBA in place, adjusting
/// byte accounting. All animation composition happens in RGBA;
/// this is called before the first composition into an image.
File diff suppressed because it is too large Load Diff