Skip to content

feat: expose GHOSTSNP terminal snapshots - #2

Merged
nekohasekai merged 1 commit into
SagerNet:mainfrom
leizhiyuan:feat/ghostsnp-snapshots
Oct 30, 2026
Merged

nekohasekai merged 1 commit into
SagerNet:mainfrom
leizhiyuan:feat/ghostsnp-snapshots

Conversation

@leizhiyuan

@leizhiyuan leizhiyuan commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Wires up libghostty-vt's terminal snapshot support (the GHOSTSNP record stream) so a
session can be persisted and resumed. 把 libghostty-vt 的终端快照(GHOSTSNP 记录流)接出来,
让会话可以持久化并恢复。


English

What

val snapshot: ByteArray? = session.saveSnapshot()
val restored: GhosttyTerminalSession? = GhosttyTerminalSession.restore(context, snapshot)

A snapshot carries cell contents, scrollback, colors, palette, modes, title, working
directory and the unfinished escape sequence the parser is inside. A restored session
keeps rendering and accepting input where the saved one stopped, and can be snapshotted
again.

The pinned ghostty already ships include/ghostty/vt/snapshot.h and all
ghostty_snapshot_* symbols, so this needs no submodule bump. The implementation only uses
entry points that exist in that revision, so it also builds against current
ghostty-org/ghostty main.

Changes

  • GhosttyVt: nativeSnapshotEncode, nativeSnapshotDecode, nativeSize
  • GhosttyTerminalSession: saveSnapshot(), Companion.restore(), plus a private
    constructor that takes snapshot bytes
  • JNI: split resource and option setup out of nativeCreate into createAuxResources /
    attachTerminalHandlers / readXTVersion / applyTerminfoName, so a decoded terminal
    gets the same wiring
  • GhosttySnapshotTest: round trip and invalid input coverage
  • README: usage snippet

Design notes

  • Continuation tracking is enabled in nativeCreate
    (GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES, 8 MiB). Encoding a terminal whose VT
    parser is mid-sequence requires the replayable suffix to have been retained before the
    input was written, so it cannot be turned on lazily at save time. Tracking has no cost
    while the parser is at ground and the buffer only grows while a single sequence is in
    flight; saveSnapshot() returns null if a suffix ever exceeds the cap, instead of writing
    a snapshot that cannot be resumed.
  • Restore does not re-apply snapshot-carried state. Geometry, scrollback limits,
    colors, cursor defaults and screen contents come from the snapshot, so
    Options.columns/rows/maxScrollbackLines/defaultCursorStyle/defaultCursorBlink are
    ignored by restore(); only reportedVersion and terminfoName apply, because they
    describe the host terminal. Documented on the factory.
  • The restore path does not touch the continuation cap. The decoder applies it via
    GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION; changing a live cap can invalidate the
    retained suffix, so nativeSnapshotDecode deliberately skips it.
  • restore() seeds columns, rows, title, workingDirectory and backgroundColor
    from the decoded terminal, and returns null for data that is empty, too short, truncated
    before FINISH, or fails CRC.

Verification

  • :library:compileDebugKotlin, :library:compileDebugAndroidTestKotlin: pass
  • :library:connectedDebugAndroidTest on an arm64-v8a API 36 emulator: 7 tests, 0 failures,
    including GhosttySnapshotTest:
    • roundTripRestoresContentsAndContinuation — OSC title, OSC 7 pwd, colored text, 40
      lines of scrollback and an unfinished CSI 31; asserts equal size, title, pwd,
      screenText() and transcriptText(), feeds the rest of the sequence to both terminals
      and compares again, then snapshots and restores a second time
    • restoreRejectsInvalidSnapshot — empty, non-snapshot bytes, truncated FINISH and a
      single flipped byte all return null
  • Also integrated and exercised end to end in a downstream app:
    Stream a pane from Rex instead of polling its screen leizhiyuan/rexpocket#1

Caveats

  • Kitty graphics pixels are not part of the record stream the pinned ghostty writes, so a
    restored session has the same text and state without images. Noted in the KDoc; it will
    follow a future ghostty bump.
  • Snapshot format version 1 is explicitly not binary-stable upstream; persistence code
    should keep the snapshot and the library version paired.

中文

背景

libghostty-vt 早就能把终端整体序列化成 GHOSTSNP 记录流并还原,但这个封装一直没接,
所以会话无法持久化。这个 PR 补上编解码两端。

val snapshot: ByteArray? = session.saveSnapshot()
val restored: GhosttyTerminalSession? = GhosttyTerminalSession.restore(context, snapshot)

快照内容包含单元格内容、scrollback、颜色、调色板、模式、标题、工作目录,以及 parser
当前停在半截的转义序列
。恢复出来的会话可以直接继续渲染、继续喂 PTY 输入,而且能再次
打快照。

子模块 pin 的 ghostty 版本已经带 include/ghostty/vt/snapshot.h 和全套
ghostty_snapshot_* 符号,因此不需要升子模块;实现只用了该版本已有的入口,所以对当前
ghostty-org/ghostty main 也编得过。

改动

  • GhosttyVt:新增 nativeSnapshotEncode、nativeSnapshotDecode、nativeSize
  • GhosttyTerminalSession:新增 saveSnapshot()、Companion.restore(),构造器改为主/次
    分离,次构造器接收快照字节
  • JNI:把 nativeCreate 里的资源分配和 option 注册拆成 createAuxResources /
    attachTerminalHandlers / readXTVersion / applyTerminfoName,让解码出来的终端复用同一
    套接线
  • GhosttySnapshotTest:往返与非法输入用例
  • README:加使用示例

设计说明

  • continuation tracking 在 nativeCreate 里就打开
    (GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,8 MiB)。当 parser 停在半截序列时想编码,
    可重放的字节后缀必须在写入那批输入之前就开始保留,存盘时再开是来不及的。parser 处于
    ground 时这项没有任何开销,缓冲也只在单条序列未结束时增长;万一后缀超过上限,
    saveSnapshot() 返回 null,而不是写一个恢复不了的快照。
  • 恢复时不覆盖快照已带的状态。几何尺寸、scrollback 上限、颜色、光标默认值、屏幕内容都
    来自快照,所以 restore() 忽略
    Options.columns/rows/maxScrollbackLines/defaultCursorStyle/defaultCursorBlink;只有
    reportedVersion 和 terminfoName 生效,因为它们描述的是宿主终端而不是被保存的状态。
  • 恢复路径不重复设置 continuation 上限。上限由 decoder 通过
    GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION 设好;改动一个正在生效的上限可能让已
    保留的后缀失效,所以 nativeSnapshotDecode 故意跳过这一项。
  • restore() 会从解码出的终端回填 columns、rows、title、workingDirectory、
    backgroundColor;对空数据、长度不足、FINISH 之前截断、CRC 校验失败的数据一律返回 null。

验证

  • :library:compileDebugKotlin、:library:compileDebugAndroidTestKotlin:通过
  • :library:connectedDebugAndroidTest(arm64-v8a / API 36 模拟器):7 个用例 0 失败,其中
    GhosttySnapshotTest:
    • roundTripRestoresContentsAndContinuation:OSC 标题、OSC 7 工作目录、彩色文本、40 行
      scrollback,外加半截 CSI 31;断言尺寸、标题、pwd、screenText()、transcriptText()
      完全一致,再给两边分别喂上序列剩余部分后比较一次,最后对恢复出的终端再存快照并二次恢复
    • restoreRejectsInvalidSnapshot:空字节、非快照字节、截断 FINISH、翻转一个字节,都必须
      返回 null
  • 已在下游应用里完整接入并实测:Stream a pane from Rex instead of polling its screen leizhiyuan/rexpocket#1

注意事项

  • 当前 pin 的 ghostty 写出的记录流不含 kitty 图片像素,恢复后文本与状态完整、图片丢失;
    KDoc 里已注明,等以后升 ghostty 会自然带上。
  • 上游明确说明 snapshot format v1 暂不保证二进制兼容,做持久化的调用方应把快照与库版本配对
    管理。

@leizhiyuan
leizhiyuan force-pushed the feat/ghostsnp-snapshots branch from 10866f8 to c54e7c7 Compare October 30, 2026 09:42
@nekohasekai
nekohasekai force-pushed the feat/ghostsnp-snapshots branch from c54e7c7 to abbab74 Compare October 30, 2026 14:17
@nekohasekai
nekohasekai merged commit 21ab85e into SagerNet:main Oct 30, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants