fix: preserve session layouts across shutdown and restore failures (#4400)

* fix: preserve session layouts across shutdown and restore failures

refs #4320

* test: update subscription fixture for pane restore errors

---------

Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
Can Celik
2026-09-21 00:29:45 +03:00
committed by GitHub
co-authored by JJ Liebig
parent f090ce95d0
commit 0ff0f27e22
31 changed files with 1722 additions and 72 deletions
Generated
+335 -4
View File
@@ -35,6 +35,40 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "atomic"
version = "0.6.1"
@@ -425,6 +459,33 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -450,6 +511,26 @@ dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -460,6 +541,12 @@ dependencies = [
"regex",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -568,6 +655,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.33"
@@ -680,6 +780,7 @@ dependencies = [
"clap_complete",
"crossterm",
"ctrlc",
"futures-util",
"interprocess",
"jsonc-parser",
"libc",
@@ -702,6 +803,7 @@ dependencies = [
"widestring",
"windows-sys",
"wmi",
"zbus",
]
[[package]]
@@ -1072,6 +1174,22 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1250,6 +1368,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.15+spec-1.1.0",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1579,6 +1706,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
@@ -1680,6 +1818,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -1735,6 +1883,30 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "terminfo"
version = "0.9.0"
@@ -1891,7 +2063,9 @@ dependencies = [
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys",
]
@@ -1914,8 +2088,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
@@ -1927,6 +2101,15 @@ dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -1936,9 +2119,30 @@ dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
[[package]]
@@ -2020,6 +2224,17 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2076,6 +2291,7 @@ dependencies = [
"atomic",
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -2437,6 +2653,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
@@ -2548,8 +2773,114 @@ dependencies = [
"windows-core",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.6",
"winnow 1.0.4",
]
+5
View File
@@ -51,6 +51,11 @@ schemars = { version = "1.2.1", features = ["derive"] }
[patch.crates-io]
portable-pty = { path = "vendor/portable-pty" }
[target.'cfg(target_os = "linux")'.dependencies]
# logind's delay inhibitor lets the server save before host shutdown kills panes.
zbus = { version = "5.19", default-features = false, features = ["tokio"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
[target.'cfg(windows)'.dependencies]
# UTF-16 SDDL input for interprocess security descriptors.
widestring = "1.2"
+12
View File
@@ -808,6 +808,12 @@
"pane_id": {
"type": "string"
},
"restore_error": {
"type": [
"string",
"null"
]
},
"revision": {
"format": "uint64",
"minimum": 0,
@@ -8005,6 +8011,12 @@
"pane_id": {
"type": "string"
},
"restore_error": {
"type": [
"string",
"null"
]
},
"revision": {
"format": "uint64",
"minimum": 0,
@@ -34,10 +34,29 @@ Herdr サーバーが停止して再起動すると、元のペインのプロ
スナップショット復元は、動作中のシェル、サーバー、テスト、その他任意のプロセスを保存しません。より強力な復元経路が使えないペインは、保存されたディレクトリで新しいシェルとして戻ってきます。
`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。正常に読み込めた場合は復旧用コピーを作成しません
保存されたディレクトリが使えない場合やシェルを起動できない場合、ペインは消えたりホームディレクトリへ移動したりせず、エラーを表示してレイアウトに残ります。保存されたディレクトリとエージェントのセッション参照は保持されます。ディレクトリやシェル設定を修正してサーバーを再起動すると再試行できます。不要なペインは明示的に閉じて削除できます
保存時には、OS が取得をサポートしていれば、実行中のシェルのディレクトリを優先します。シェル終了後も最後に確認したディレクトリを保持します。それ以外の環境ではシェルが報告したディレクトリを使用します。
`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。これらの読み込み失敗時の復旧用コピーは、通常のスナップショット履歴とは別に管理します。
復旧用コピーは最新の 3 個を保持し、新しいコピーの安全な保存が完了してから古いコピーを削除します。コピーに失敗すると、自動保存時も終了時も元のファイルを変更せず、失敗をログに記録し、次の保存要求時に再試行します。コピーが自動的に復元されることはありません。復旧するには、対象のサーバーを停止し、復旧用ファイルをそのサーバーの `session.json` にコピーしてから再起動します。復旧用コピーにペイン画面履歴は含まれません。
## シャットダウンとスナップショットからの復旧
systemd-logind を使う Linux では、Herdr はホストのシャットダウン予告を受け取り、保存して停止するまで短い遅延を要求します。その後 logind がシャットダウンを進めます。遅延には OS による上限があり、無期限にはブロックしません。logind がない環境、強制終了、電源断、予告前にプロセスが終了した場合には、この保護は使えません。
全プラットフォームで、`session.json` の隣の `session-snapshots/` に最大 48 個のレイアウトスナップショットを保持します。最初に保存したレイアウトは直ちにコピーします。その後の保存や削除では、直前の保存済みレイアウトを最大 15 分に 1 回コピーします。同じ内容は重複保存しません。この間隔はサーバーの再起動後も維持されるため、連続したペイン終了や再起動で古いコピーがすべて押し出されることはありません。直近の変更がコピーに含まれない場合があります。
通常のペイン終了は引き続き `session.json` に反映されます。復旧を促す通知や、古いスナップショットの自動復元はありません。手動で復旧するには:
1. `herdr session list --json` で対象のセッションディレクトリを確認します。
2. 対象サーバーを停止します。デフォルトは `herdr server stop`、名前付きセッションは `herdr session stop <name>` です。
3. 現在の `session.json` を別にコピーしてから、`session-snapshots/` 内の選んだファイルを `session.json` にコピーします。ファイルの更新日時で保存時刻を確認できます。
4. セッションを再起動します。
コピーにはレイアウトとエージェントのセッション参照が含まれますが、実行中のプロセスやペイン画面履歴は含まれません。非公開のセッションデータとして扱ってください。コピーの書き込みに失敗するとログに記録しますが、通常のセッション保存は続行します。
## ペイン画面履歴のリプレイ
ペイン画面履歴は、サーバーの完全な再起動後に直近のターミナル内容を復元します。復元されるのは Herdr が表示できるものであって、元のプロセスではありません。
@@ -51,6 +70,8 @@ pane_history = true
有効にすると、Herdr は保存したペイン履歴を `session.json` の隣の `session-history.json` に保存します。Herdr の設定/セッションディレクトリはターミナル履歴と同じ感覚で扱ってください。
履歴は、保存されたレイアウトと完全に一致する場合だけ再生されます。スナップショットを手動で復元した場合など、異なるレイアウトの履歴は無視されます。レイアウトを検証できない旧形式の履歴も無視されますが、新たに保存された履歴は次回の再起動で再生できます。
## エージェントネイティブのセッション復元
一部のエージェントは自分の会話セッションを resume できます。Herdr は、公式インテグレーションが報告したセッション参照を使って、Herdr サーバーの再起動後に対応エージェントのペインを再起動できます。
@@ -32,10 +32,29 @@ If the Herdr server stops and starts again, the original pane processes are gone
Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories.
If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. Healthy restores do not create recovery copies.
If a saved directory is unavailable or the shell cannot start, the pane stays in the layout with an error instead of disappearing or moving to your home directory. Its saved directory and agent session reference remain intact. Fix the directory or shell configuration and restart the server to retry, or explicitly close the pane to remove it.
When saving, Herdr prefers the live shell's directory where the operating system exposes it, and retains the last confirmed directory after the shell exits. Shell-reported directories remain the fallback on other platforms.
If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. These failed-load recovery copies are separate from normal snapshot history.
Herdr retains the three most recent recovery copies, pruning older copies only after a new copy is safely written. If preservation fails, autosave and shutdown leave the original untouched and log the failure; preservation is retried on the next save request. Copies are never restored automatically. To recover one, stop the affected server, copy the recovery file over its `session.json`, then restart. Recovery copies do not include pane screen history.
## Shutdown and snapshot recovery
On Linux systems with systemd-logind, Herdr listens for the host shutdown warning and requests a short delay while it saves and stops. This happens before logind proceeds with shutdown. The operating system limits the delay; Herdr does not block shutdown indefinitely. This protection is unavailable without logind and does not cover forced shutdown, power loss, or processes killed before the warning arrives.
Herdr also keeps up to 48 layout snapshots in `session-snapshots/` beside `session.json`, on all platforms. The first saved layout is copied immediately. Later saves or clears can add a snapshot of the previous saved layout, at most once every 15 minutes. Unchanged snapshots are not duplicated. The interval survives server restarts, so a burst of pane exits or restarts cannot rotate away all older layouts. Recent changes may not be in the recovery snapshots.
Normal pane closures still update `session.json`. There are no recovery prompts and no automatic restoration of older snapshots. To recover a layout:
1. Find the affected session directory with `herdr session list --json`.
2. Stop that server (`herdr server stop` for the default session, or `herdr session stop <name>` for a named session).
3. Keep a copy of its current `session.json`, then copy the chosen file from `session-snapshots/` over `session.json`. File modification times identify when snapshots were taken.
4. Start the session again.
Snapshots contain layout and agent session references, not running processes or pane screen history. Treat them as private session data. A snapshot-write failure is logged but does not prevent the primary session from saving.
## Pane screen history replay
Pane screen history restores recent terminal contents after a full server restart without restoring the old process.
@@ -49,6 +68,8 @@ pane_history = true
When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`. Treat the Herdr config/session directory like terminal history.
History is replayed only when it matches the exact saved layout. History from a different layout, including after manual snapshot recovery, is ignored. Older history files without layout verification are also ignored; newly saved history can be replayed on the next restart.
## Native agent session restore
Some agents can resume their own conversation sessions. Herdr can use official integration-reported session references to restart supported agent panes after a Herdr server restart.
@@ -34,10 +34,29 @@ herdr
快照恢复不会保留运行中的 shell、服务器、测试或任意进程。无法使用更强恢复路径的窗格,会在各自保存的目录中作为新 shell 回来。
如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。正常加载的会话不会创建恢复副本
如果保存的目录不可用或 shell 无法启动,窗格会留在布局中并显示错误,而不是消失或切换到主目录。保存的目录和智能体会话引用仍会保留。修复目录或 shell 配置后,重启服务器即可重试;也可以明确关闭窗格将其移除
保存时,如果操作系统支持查询,Herdr 会优先使用正在运行的 shell 的目录,并在 shell 退出后保留最后确认的目录。在其他平台上,则使用 shell 报告的目录。
如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。这些加载失败时的恢复副本与常规快照历史分开管理。
Herdr 保留最新的三个恢复副本,只有在新副本安全写入后才删除旧副本。如果无法保存副本,自动保存和退出保存都不会修改原始文件,并会记录错误,在下一次保存请求时重试。副本不会自动恢复。如需恢复,请停止对应的服务器,将恢复文件复制到该服务器的 `session.json`,然后重启。恢复副本不包含窗格屏幕历史。
## 关机与快照恢复
在使用 systemd-logind 的 Linux 系统上,Herdr 会监听主机关机预告,并请求短暂延迟,以便保存并停止服务器,随后 logind 才继续关机。延迟由操作系统限制,不会无限阻止关机。此保护不适用于没有 logind 的系统、强制关机、断电,或进程在预告到达前已被终止的情况。
在所有平台上,Herdr 还会在 `session.json` 旁的 `session-snapshots/` 中保留最多 48 个布局快照。首次保存的布局会立即复制。之后的保存或清除操作会复制之前保存的布局,最多每 15 分钟一次,不重复保存相同内容。此间隔在服务器重启后仍然有效,因此连续的窗格退出或重启不会挤掉所有旧快照。最近的更改可能尚未包含在快照中。
正常关闭窗格仍会更新 `session.json`,不会出现恢复提示,也不会自动恢复旧布局。如需手动恢复:
1. 用 `herdr session list --json` 找到对应的会话目录。
2. 停止对应的服务器:默认会话用 `herdr server stop`,命名会话用 `herdr session stop <name>`。
3. 先另存当前的 `session.json`,再将 `session-snapshots/` 中选定的文件复制到 `session.json`。文件修改时间表示快照的保存时间。
4. 重新启动该会话。
快照包含布局和代理会话引用,不包含运行中的进程或窗格屏幕历史。请将其视为私密会话数据。快照写入失败会记录到日志,但不会阻止主会话文件的正常保存。
## 窗格屏幕历史回放
窗格屏幕历史在服务器完全重启后恢复最近的终端内容。它恢复的是 Herdr 能展示的内容,而不是原来的进程。
@@ -51,6 +70,8 @@ pane_history = true
开启后,Herdr 把保存的窗格历史存放在 `session.json` 旁边的 `session-history.json` 中。请像对待终端历史一样对待 Herdr 的配置/会话目录。
只有与保存的布局完全匹配的历史才会回放。来自其他布局的历史,包括手动恢复快照后不再匹配的历史,都会被忽略。无法验证布局的旧版历史文件也会被忽略;新保存的历史可在下次重启时回放。
## 智能体原生会话恢复
一些智能体可以恢复它们自己的对话会话。Herdr 可以使用官方集成上报的会话引用,在 Herdr 服务器重启后重新启动受支持的智能体窗格。
+2
View File
@@ -535,6 +535,8 @@ pub struct PaneInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground_cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restore_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
+2
View File
@@ -825,6 +825,7 @@ fn worktree_request_and_response_round_trip() {
focused: true,
cwd: Some("/worktrees/herdr/worktree-api".into()),
foreground_cwd: None,
restore_error: None,
label: None,
agent: None,
title: None,
@@ -1253,6 +1254,7 @@ fn create_response_round_trips_with_root_pane() {
focused: false,
cwd: Some("/tmp/review".into()),
foreground_cwd: None,
restore_error: None,
label: None,
agent: None,
title: None,
+1
View File
@@ -1037,6 +1037,7 @@ mod tests {
focused: true,
cwd: None,
foreground_cwd: None,
restore_error: None,
label: None,
agent: Some("pi".into()),
title: None,
@@ -224,6 +224,7 @@ fn reply_to_probe(request: ApiRequestMessage) {
focused: true,
cwd: None,
foreground_cwd: None,
restore_error: None,
label: None,
agent: Some("pi".into()),
title: None,
+1
View File
@@ -701,6 +701,7 @@ mod tests {
focused: true,
cwd: None,
foreground_cwd: None,
restore_error: None,
label: None,
agent: None,
title: None,
+56 -2
View File
@@ -233,6 +233,15 @@ impl App {
return false;
};
if !cwd.is_dir() {
if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
terminal.pending_agent_resume_plan = None;
terminal.restore_error = Some("Saved directory is unavailable. Restore the directory and restart this session.".into());
terminal.revision = terminal.revision.saturating_add(1);
}
return true;
}
let runtime = match crate::terminal::TerminalRuntime::spawn(
pane_id,
rows,
@@ -257,9 +266,11 @@ impl App {
"failed to start shell for deferred agent resume"
);
if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
terminal.clear_agent_runtime_identity_after_respawn();
terminal.pending_agent_resume_plan = None;
terminal.restore_error = Some(format!("Could not start the saved shell: {err}. Fix the shell configuration and restart this session."));
terminal.revision = terminal.revision.saturating_add(1);
}
return false;
return true;
}
};
@@ -378,6 +389,49 @@ mod tests {
]
}
#[cfg(unix)]
#[tokio::test]
async fn failed_deferred_restore_keeps_session_reference_without_retrying_elsewhere() {
for missing_shell in [false, true] {
let mut app = test_app();
let workspace = crate::workspace::Workspace::test_new("unavailable");
let pane_id = workspace.tabs[0].root_pane;
let terminal_id = workspace.terminal_id(pane_id).unwrap().clone();
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.ensure_test_terminals();
if missing_shell {
app.state.default_shell = "__herdr_missing_resume_shell__".into();
}
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
if !missing_shell {
terminal.cwd = std::env::current_dir()
.unwrap()
.join("__herdr_missing_resume_cwd__");
assert!(!terminal.cwd.exists());
}
let session = crate::agent_resume::PersistedAgentSession {
source: "herdr:codex".into(),
agent: "codex".into(),
session_ref: crate::agent_resume::AgentSessionRef::id("resume-test").unwrap(),
};
terminal.persisted_agent_session = Some(session.clone());
terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
argv: long_running_test_argv(),
dedupe_key: "resume-test".into(),
});
app.start_pending_agent_resume_for_terminal(&terminal_id, 24, 80, true);
assert!(app.terminal_runtimes.get(&terminal_id).is_none());
let terminal = &app.state.terminals[&terminal_id];
assert!(terminal.pending_agent_resume_plan.is_none());
assert_eq!(terminal.persisted_agent_session.as_ref(), Some(&session));
assert!(terminal.restore_error.is_some());
assert!(!app.has_pending_agent_resumes());
assert!(!app.start_pending_agent_resume_for_terminal(&terminal_id, 24, 80, true));
}
}
#[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_waits_for_host_theme_before_launch() {
+1
View File
@@ -340,6 +340,7 @@ impl App {
foreground_cwd: ws.tabs[tab_idx]
.foreground_cwd_for_pane(pane_id, &self.terminal_runtimes)
.map(|cwd| cwd.display().to_string()),
restore_error: terminal.restore_error.clone(),
label: terminal.manual_label.clone(),
agent: terminal.effective_agent_label().map(str::to_string),
title: presentation.title,
+5 -1
View File
@@ -49,7 +49,11 @@ impl App {
self.state.selected,
);
let history = self.persist_pane_history.then(|| {
crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes)
crate::persist::capture_history(
&snapshot,
&self.state.workspaces,
&self.terminal_runtimes,
)
});
SessionSaveJob::Save { snapshot, history }
}
+1
View File
@@ -183,6 +183,7 @@ fn pane_scroll_result(
focused: true,
cwd: None,
foreground_cwd: None,
restore_error: None,
label: None,
agent: None,
title: None,
+53
View File
@@ -1255,6 +1255,8 @@ pub struct PaneRuntime {
current_size: Cell<(u16, u16, u32, u32)>,
child_pid: Arc<AtomicU32>,
reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
persistence_cwd: Mutex<Option<std::path::PathBuf>>,
cwd_process_exited: Arc<AtomicBool>,
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
content_seq: Arc<AtomicU64>,
@@ -2304,6 +2306,7 @@ impl PaneRuntime {
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let child_pid = Arc::new(AtomicU32::new(child_pid));
let reported_cwd = Arc::new(Mutex::new(None));
let cwd_process_exited = Arc::new(AtomicBool::new(false));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
let content_seq = Arc::new(AtomicU64::new(0));
let content_write_lock = Arc::new(Mutex::new(()));
@@ -2370,7 +2373,9 @@ impl PaneRuntime {
}
});
let exit_events = events.clone();
let cwd_process_exited = cwd_process_exited.clone();
let on_reader_exit = Box::new(move || {
cwd_process_exited.store(true, Ordering::Release);
// Imported handoff panes have no child wait handle, so their exit cause is
// unknowable. Checkpoint conservatively; normal autosave settles clean exits.
let _ = rt.block_on(exit_events.send(AppEvent::PaneDied {
@@ -2405,6 +2410,8 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
child_pid,
reported_cwd,
persistence_cwd: Mutex::new(None),
cwd_process_exited,
child_wait_completed: None,
kitty_keyboard_flags,
content_seq,
@@ -2981,6 +2988,8 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols, 0, 0)),
child_pid,
reported_cwd,
persistence_cwd: Mutex::new(None),
cwd_process_exited: child_wait_completed.clone(),
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
content_seq,
@@ -3491,6 +3500,27 @@ impl PaneRuntime {
crate::platform::process_cwd(pid)
}
pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> {
let pid = self.child_pid.load(Ordering::Acquire);
let exited = self.cwd_process_exited.load(Ordering::Acquire);
if let Some(cwd) = (!exited)
.then(|| crate::platform::process_cwd(pid))
.flatten()
.filter(|cwd| cwd.is_absolute())
{
// Persistence observations must not change OSC authority or follow-cwd behavior.
if let Ok(mut known) = self.persistence_cwd.lock() {
*known = Some(cwd.clone());
}
return Some(cwd);
}
self.persistence_cwd
.lock()
.ok()
.and_then(|cwd| cwd.clone())
.or_else(|| self.reported_cwd.lock().ok().and_then(|cwd| cwd.clone()))
}
pub fn child_pid(&self) -> Option<u32> {
let pid = self.child_pid.load(Ordering::Acquire);
(pid > 0).then_some(pid)
@@ -3661,6 +3691,8 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
reported_cwd: Arc::new(Mutex::new(None)),
persistence_cwd: Mutex::new(None),
cwd_process_exited: Arc::new(AtomicBool::new(false)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
content_seq: Arc::new(AtomicU64::new(0)),
@@ -4592,6 +4624,23 @@ mod tests {
assert!(runtime.handoff_history_ansi().is_none());
}
#[cfg(unix)]
#[tokio::test]
async fn ended_handoff_keeps_persistence_cwd_when_pid_is_reused() {
let (runtime, _rx) = PaneRuntime::test_with_channel(80, 24);
assert!(runtime.child_wait_completed.is_none());
let saved = std::env::temp_dir().join("saved-handoff-cwd");
*runtime.persistence_cwd.lock().unwrap() = Some(saved.clone());
// A different live process now owns the imported shell's numeric PID.
runtime
.child_pid
.store(std::process::id(), Ordering::Release);
runtime.cwd_process_exited.store(true, Ordering::Release);
assert_eq!(runtime.cwd_for_persistence(), Some(saved));
*runtime.persistence_cwd.lock().unwrap() = None;
assert_eq!(runtime.cwd_for_persistence(), None);
}
#[cfg(unix)]
#[tokio::test]
async fn handoff_runtime_state_captures_terminal_input_and_title_state() {
@@ -4749,6 +4798,8 @@ mod tests {
));
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let runtime = PaneRuntime {
cwd_process_exited: Arc::new(AtomicBool::new(false)),
persistence_cwd: Mutex::new(None),
pane_id,
terminal,
io: PaneRuntimeIo::TestChannel {
@@ -4786,6 +4837,8 @@ mod tests {
));
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let runtime = PaneRuntime {
cwd_process_exited: Arc::new(AtomicBool::new(false)),
persistence_cwd: Mutex::new(None),
pane_id,
terminal,
io: PaneRuntimeIo::TestChannel {
+1
View File
@@ -198,6 +198,7 @@ mod tests {
fn history_snapshot(secret: &str) -> SessionHistorySnapshot {
SessionHistorySnapshot {
version: SNAPSHOT_VERSION,
layout_fingerprint: None,
workspaces: vec![WorkspaceHistorySnapshot {
tabs: vec![TabHistorySnapshot {
panes: std::collections::HashMap::from([(
+188 -17
View File
@@ -267,6 +267,14 @@ fn restore_with_imports_and_failures(
render_notify: Arc<Notify>,
render_dirty: Arc<RenderSignal>,
) -> RestoreFailures<RestoredSession> {
let history = history.filter(|history| {
let matches = history.layout_fingerprint.is_some()
&& history.layout_fingerprint == super::snapshot::layout_fingerprint(snapshot);
if !matches {
tracing::warn!("Ignoring pane history without a matching session layout");
}
matches
});
let mut workspaces = Vec::new();
let mut terminals = HashMap::new();
let mut terminal_runtimes = HashMap::new();
@@ -433,6 +441,34 @@ fn restore_workspace(
)
}
fn unavailable_restored_terminal(
pane: Option<&super::snapshot::PaneSnapshot>,
cwd: PathBuf,
reason: String,
) -> TerminalState {
warn!(cwd = %cwd.display(), reason = %reason, "preserving unavailable restored pane");
let mut terminal = TerminalState::new(TerminalId::alloc(), cwd);
terminal.restore_error = Some(reason);
if let Some(pane) = pane {
terminal.manual_label = pane.label.clone();
terminal.launch_argv = pane.launch_argv.clone();
if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) {
terminal.set_persisted_agent_session(session);
}
match (
pane.agent_name.as_ref(),
pane.managed_agent_kind
.as_deref()
.and_then(crate::detect::parse_canonical_agent_label),
) {
(Some(name), Some(agent)) => terminal.restore_managed_agent(name.clone(), agent),
(Some(name), None) => terminal.set_agent_name(name.clone()),
_ => {}
}
}
terminal
}
fn restored_worktree_space_membership(
space: Option<crate::workspace::WorktreeSpaceMembership>,
) -> Option<crate::workspace::WorktreeSpaceMembership> {
@@ -473,22 +509,19 @@ fn restore_tab(
.map(|p| p.cwd.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let cwd = if saved_cwd.exists() {
saved_cwd
} else {
warn!(
cwd = %saved_cwd.display(),
"saved pane cwd does not exist, falling back to HOME"
let cwd = saved_cwd;
let has_import = old_id.is_some_and(|old_id| imported_panes.contains_key(old_id));
if !has_import && !cwd.is_dir() {
let terminal = unavailable_restored_terminal(
saved_pane,
cwd,
"Saved directory is unavailable. Restore the directory and restart this session."
.into(),
);
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/"));
if home.exists() {
home
} else {
PathBuf::from("/")
}
};
panes.insert(*id, PaneState::new(terminal.id.clone()));
terminals.push(terminal);
continue;
}
let saved_label = saved_pane.and_then(|p| p.label.clone());
let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone());
@@ -681,8 +714,16 @@ fn restore_tab(
tab = ?snap.custom_name,
pane_id = id.raw(),
err = %e,
"failed to restore pane, skipping"
"failed to restore pane"
);
if !was_imported {
let terminal = unavailable_restored_terminal(
saved_pane, cwd,
format!("Could not start the saved shell: {e}. Fix the shell configuration and restart this session."),
);
panes.insert(*id, PaneState::new(terminal.id.clone()));
terminals.push(terminal);
}
}
}
}
@@ -1167,6 +1208,92 @@ mod tests {
assert!(restored_terminal_agent_session(Some(&session), true).is_none());
}
#[tokio::test]
async fn failed_cold_restore_preserves_panes_and_saved_directories() {
for missing_shell in [false, true] {
let mut snapshot: SessionSnapshot = serde_json::from_str(include_str!(
"../../tests/fixtures/session/current-herdr-session.json"
))
.unwrap();
let cwd = std::env::current_dir().unwrap();
let missing = cwd.join("__herdr_missing_restore_directory__");
assert!(!missing.exists());
for workspace in &mut snapshot.workspaces {
workspace.identity_cwd = cwd.clone();
for tab in &mut workspace.tabs {
for pane in tab.panes.values_mut() {
pane.cwd = cwd.clone();
}
}
}
let failed = snapshot.workspaces[0].tabs[0].panes.get_mut(&1).unwrap();
failed.cwd = missing.clone();
failed.label = Some("keep my pane".into());
failed.agent_session = Some(super::super::snapshot::PaneAgentSessionSnapshot {
source: "herdr:opencode".into(),
agent: "opencode".into(),
kind: crate::agent_resume::AgentSessionRefKind::Id,
value: "keep-my-session".into(),
});
let (events, _rx) = mpsc::channel(32);
let (workspaces, terminals, runtimes) = restore(
&snapshot,
None,
24,
80,
0,
if missing_shell {
"__herdr_missing_restore_shell__"
} else {
test_restore_shell()
},
crate::config::ShellModeConfig::NonLogin,
false,
events,
Arc::new(Notify::new()),
Arc::new(RenderSignal::new()),
);
let runtimes = crate::terminal::TerminalRuntimeRegistry::from(runtimes);
let captured = crate::persist::capture(&workspaces, &terminals, &runtimes, Some(0), 0);
assert_eq!(
captured.workspaces.len(),
2,
"a launch failure must not delete a workspace"
);
assert_eq!(captured.workspaces[0].tabs.len(), 2);
let pane = captured.workspaces[0].tabs[0]
.panes
.values()
.next()
.unwrap();
assert_eq!(
pane.cwd, missing,
"fallback cwd must not replace saved intent"
);
assert_eq!(pane.label.as_deref(), Some("keep my pane"));
assert_eq!(
pane.agent_session.as_ref().unwrap().value,
"keep-my-session"
);
let root = workspaces[0].tabs[0].root_pane;
let terminal_id = workspaces[0].tabs[0].terminal_id(root).unwrap();
assert!(
runtimes.get(terminal_id).is_none(),
"do not open a replacement shell elsewhere"
);
let healthy = workspaces[1].tabs[0]
.terminal_id(workspaces[1].tabs[0].root_pane)
.unwrap();
assert_eq!(runtimes.get(healthy).is_some(), !missing_shell);
assert!(terminals[terminal_id].restore_error.is_some());
let mut state = crate::app::AppState::test_new();
state.workspaces = workspaces;
state.terminals = terminals;
state.active = Some(0);
state.assert_invariants_for_test();
}
}
#[tokio::test]
async fn restore_carries_persisted_agent_session_metadata() {
let cwd = std::env::current_dir().unwrap();
@@ -1658,6 +1785,48 @@ mod tests {
let _ = runtime.try_send_bytes(bytes::Bytes::from_static(b"exit\n"));
}
#[tokio::test]
async fn restore_rejects_history_from_another_layout_or_without_provenance() {
for legacy in [false, true] {
let (mut snapshot, history) = snapshot_with_saved_pane_history();
let mut value = serde_json::to_value(history).unwrap();
if legacy {
value.as_object_mut().unwrap().remove("layout_fingerprint");
} else {
snapshot.workspaces[0].tabs[0]
.panes
.get_mut(&0)
.unwrap()
.cwd = std::env::temp_dir();
}
let history = serde_json::from_value(value).unwrap();
let (events, _rx) = mpsc::channel(8);
let (_, _, runtimes) = restore(
&snapshot,
Some(&history),
5,
80,
4096,
test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
false,
events,
Arc::new(Notify::new()),
Arc::new(RenderSignal::new()),
);
let runtime = runtimes.values().next().unwrap();
assert!(
!runtime
.recent_unwrapped_text(10)
.contains("RESTORED_HISTORY"),
"screen history must belong to the exact saved layout"
);
for (_, runtime) in runtimes {
runtime.shutdown();
}
}
}
fn snapshot_with_saved_pane_history() -> (SessionSnapshot, SessionHistorySnapshot) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let mut panes = HashMap::new();
@@ -1672,8 +1841,9 @@ mod tests {
launch_argv: None,
},
);
let history = SessionHistorySnapshot {
let mut history = SessionHistorySnapshot {
version: super::super::snapshot::SNAPSHOT_VERSION,
layout_fingerprint: None,
workspaces: vec![WorkspaceHistorySnapshot {
tabs: vec![super::super::snapshot::TabHistorySnapshot {
panes: HashMap::from([(
@@ -1717,6 +1887,7 @@ mod tests {
sidebar_section_split: Some(0.5),
collapsed_space_keys: Default::default(),
};
history.layout_fingerprint = super::super::snapshot::layout_fingerprint(&snapshot);
(snapshot, history)
}
}
+157 -16
View File
@@ -33,6 +33,8 @@ pub struct SessionHistorySnapshot {
/// Format version follows the matching session snapshot version.
#[serde(default)]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout_fingerprint: Option<String>,
pub workspaces: Vec<WorkspaceHistorySnapshot>,
}
@@ -281,12 +283,20 @@ fn capture_workspace(
>,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> WorkspaceSnapshot {
let tabs: Vec<_> = ws
.tabs
.iter()
.map(|tab| capture_tab(tab, terminals, terminal_runtimes))
.collect();
let identity_cwd = tabs
.first()
.and_then(|tab| tab.root_pane.and_then(|id| tab.panes.get(&id)))
.map(|pane| pane.cwd.clone())
.unwrap_or_else(|| ws.identity_cwd.clone());
WorkspaceSnapshot {
id: Some(ws.id.clone()),
custom_name: ws.custom_name.clone(),
identity_cwd: ws
.resolved_identity_cwd_from(terminals, terminal_runtimes)
.unwrap_or_else(|| ws.identity_cwd.clone()),
identity_cwd,
worktree_space: ws.worktree_space.clone(),
public_pane_numbers: ws
.public_pane_numbers
@@ -296,11 +306,7 @@ fn capture_workspace(
next_public_pane_number: ws.next_public_pane_number,
public_tab_numbers: ws.tabs.iter().map(|tab| tab.number).collect(),
next_public_tab_number: ws.next_public_tab_number,
tabs: ws
.tabs
.iter()
.map(|tab| capture_tab(tab, terminals, terminal_runtimes))
.collect(),
tabs,
active_tab: ws.active_tab,
}
}
@@ -315,13 +321,13 @@ fn capture_tab(
) -> TabSnapshot {
let mut panes = HashMap::new();
for id in tab.panes.keys() {
let cwd = tab
.cwd_for_pane(*id, terminals, terminal_runtimes)
let terminal_id = tab.terminal_id(*id);
let terminal = terminal_id.and_then(|id| terminals.get(id));
let cwd = terminal_id
.and_then(|id| terminal_runtimes.get(id))
.and_then(|runtime| runtime.cwd_for_persistence())
.or_else(|| terminal.map(|terminal| terminal.cwd.clone()))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let terminal = tab
.panes
.get(id)
.and_then(|pane| terminals.get(&pane.attached_terminal_id));
let label = terminal.and_then(|terminal| terminal.manual_label.clone());
let (agent_name, managed_agent_kind) = terminal
.filter(|terminal| !terminal.managed_agent_launch_pending())
@@ -378,13 +384,27 @@ fn capture_tab(
}
}
pub(super) fn layout_fingerprint(snapshot: &SessionSnapshot) -> Option<String> {
use sha2::{Digest, Sha256};
let mut value = serde_json::to_value(snapshot).ok()?;
// Sets serialize as arrays; normalize their order as well as JSON object keys.
let mut collapsed: Vec<_> = snapshot.collapsed_space_keys.iter().collect();
collapsed.sort_unstable();
value["collapsed_space_keys"] = serde_json::to_value(collapsed).ok()?;
let bytes = serde_json::to_vec(&value).ok()?;
Some(format!("{:x}", Sha256::digest(bytes)))
}
/// Capture pane screen history separately from the structural session snapshot.
pub fn capture_history(
snapshot: &SessionSnapshot,
workspaces: &[Workspace],
terminal_runtimes: &TerminalRuntimeRegistry,
) -> SessionHistorySnapshot {
SessionHistorySnapshot {
version: SNAPSHOT_VERSION,
layout_fingerprint: layout_fingerprint(snapshot),
workspaces: workspaces
.iter()
.map(|workspace| WorkspaceHistorySnapshot {
@@ -546,7 +566,8 @@ mod tests {
state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> SessionHistorySnapshot {
capture_history(&state.workspaces, terminal_runtimes)
let snapshot = capture_from_state_with_runtimes(state, terminal_runtimes);
capture_history(&snapshot, &state.workspaces, terminal_runtimes)
}
fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> {
@@ -593,6 +614,28 @@ mod tests {
assert_eq!(active_pane.managed_agent_kind.as_deref(), Some("pi"));
}
#[test]
fn layout_fingerprint_survives_json_round_trip() {
let mut snapshot = parse_snapshot(include_str!(
"../../tests/fixtures/session/current-herdr-session.json"
))
.unwrap();
snapshot.collapsed_space_keys = ["z", "a", "m"].map(String::from).into();
let expected = layout_fingerprint(&snapshot).unwrap();
for _ in 0..16 {
snapshot = parse_snapshot(&serde_json::to_string(&snapshot).unwrap()).unwrap();
assert_eq!(
layout_fingerprint(&snapshot).as_deref(),
Some(expected.as_str())
);
}
snapshot.workspaces.swap(0, 1);
assert_ne!(
layout_fingerprint(&snapshot).as_deref(),
Some(expected.as_str())
);
}
#[test]
fn round_trip_empty_session() {
let snap = SessionSnapshot {
@@ -1002,6 +1045,104 @@ mod tests {
assert_eq!(workspace.next_public_tab_number, 3);
}
#[cfg(unix)]
#[tokio::test]
async fn capture_prefers_live_shell_cwd_and_keeps_it_after_exit() {
let old = std::env::current_dir().unwrap();
let new = std::env::temp_dir().join(format!(
"herdr-persist-cwd-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir(&new).unwrap();
let new = std::fs::canonicalize(new).unwrap();
let mut state = AppState::test_new();
state.workspaces = vec![Workspace::test_new("cwd-source")];
state.workspaces[0].identity_cwd = old.clone();
state.active = Some(0);
state.ensure_test_terminals();
let pane_id = state.workspaces[0].tabs[0].root_pane;
let terminal_id = state.workspaces[0].terminal_id(pane_id).unwrap().clone();
let (events, _rx) = tokio::sync::mpsc::channel(32);
let runtime = crate::terminal::TerminalRuntime::spawn(
pane_id,
24,
80,
old.clone(),
0,
Default::default(),
None,
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
&crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
)
.unwrap();
let pid = runtime.child_pid().unwrap();
runtime
.try_send_bytes(bytes::Bytes::from(format!(
"cd '{}'; printf '\\033]7;file://{}\\007'; exec sleep 30\n",
new.display(),
old.display()
)))
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while (crate::platform::process_cwd(pid).as_ref() != Some(&new)
|| runtime.cwd().as_ref() != Some(&old))
&& std::time::Instant::now() < deadline
{
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(crate::platform::process_cwd(pid), Some(new.clone()));
assert_eq!(
runtime.cwd(),
Some(old.clone()),
"existing reported-cwd accessor is unchanged"
);
let mut runtimes = TerminalRuntimeRegistry::new();
runtimes.insert(terminal_id, runtime);
let before = capture_from_state_with_runtimes(&state, &runtimes);
assert_eq!(
before.workspaces[0].tabs[0]
.panes
.values()
.next()
.unwrap()
.cwd,
new
);
assert_eq!(before.workspaces[0].identity_cwd, new);
assert_eq!(runtimes.values().next().unwrap().cwd(), Some(old.clone()));
crate::platform::signal_processes(&[pid], crate::platform::Signal::Kill);
let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while crate::platform::process_cwd(pid).is_some()
&& std::time::Instant::now() < exit_deadline
{
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(crate::platform::process_cwd(pid).is_none());
let after = capture_from_state_with_runtimes(&state, &runtimes);
assert_eq!(
after.workspaces[0].tabs[0]
.panes
.values()
.next()
.unwrap()
.cwd,
new
);
assert_eq!(after.workspaces[0].identity_cwd, new);
assert_eq!(runtimes.values().next().unwrap().cwd(), Some(old));
for (_, runtime) in runtimes.drain() {
runtime.shutdown();
}
std::fs::remove_dir(new).unwrap();
}
#[test]
fn capture_contract_tracks_workspace_identity_and_pane_cwds() {
let mut state = state_with_workspaces(&["one"]);
@@ -1199,7 +1340,7 @@ mod tests {
}
#[test]
fn restore_falls_back_to_home_when_cwd_missing() {
fn snapshot_parsing_preserves_missing_cwd() {
let mut panes = HashMap::new();
panes.insert(
0,
+227 -12
View File
@@ -26,20 +26,31 @@ impl SessionWriter {
Ok(())
}
fn preserve_snapshot_history(&self) {
if let Err(err) = preserve_snapshot_history(&self.path) {
tracing::warn!(
event = "persist.snapshot", outcome = "error", path = %self.path.display(),
err = %err, "failed to preserve session snapshot"
);
}
}
pub(crate) fn save(
&mut self,
snapshot: &SessionSnapshot,
history: Option<&SessionHistorySnapshot>,
) {
let result = self
.preserve_unloaded()
.and_then(|()| super::io::save_to_path(&self.path, snapshot));
let result = self.preserve_unloaded().and_then(|()| {
self.preserve_snapshot_history();
super::io::save_to_path(&self.path, snapshot)
});
if let Err(err) = result {
crate::logging::session_save_failed(&self.path, &err.to_string());
return;
}
// Optional history failure must not reclassify our committed layout as unloaded.
self.protect_unloaded = false;
self.preserve_snapshot_history();
let history_path = self.path.with_file_name("session-history.json");
if let Err(err) = super::io::save_history_to_path(&history_path, history) {
crate::logging::session_save_failed(&history_path, &err.to_string());
@@ -48,9 +59,10 @@ impl SessionWriter {
}
pub(crate) fn clear(&mut self) {
let result = self
.preserve_unloaded()
.and_then(|()| super::io::clear_path(&self.path));
let result = self.preserve_unloaded().and_then(|()| {
self.preserve_snapshot_history();
super::io::clear_path(&self.path)
});
if let Err(err) = result {
crate::logging::session_clear_failed(&self.path, &err.to_string());
return;
@@ -63,7 +75,55 @@ impl SessionWriter {
}
}
const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
const SNAPSHOT_LIMIT: usize = 48;
fn preserve_snapshot_history(path: &Path) -> io::Result<()> {
let directory = path.with_file_name("session-snapshots");
let existing = match recovery_files(&directory) {
Ok(files) => files,
Err(err) if err.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(err) => return Err(err),
};
if let Some((_, latest)) = existing.last() {
let modified = std::fs::metadata(latest)?.modified()?;
if SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age < SNAPSHOT_INTERVAL)
{
return Ok(());
}
}
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
let Ok(snapshot) = serde_json::from_slice::<SessionSnapshot>(&bytes) else {
return Ok(());
};
if snapshot.version > super::snapshot::SNAPSHOT_VERSION || snapshot.workspaces.is_empty() {
return Ok(());
}
if let Some((_, latest)) = existing.last() {
let previous_bytes = std::fs::read(latest)?;
if let Ok(previous) = serde_json::from_slice::<SessionSnapshot>(&previous_bytes) {
if super::snapshot::layout_fingerprint(&snapshot).is_some_and(|fingerprint| {
super::snapshot::layout_fingerprint(&previous).as_ref() == Some(&fingerprint)
}) {
return Ok(());
}
}
}
preserve_existing_in(path, "session-snapshots", SNAPSHOT_LIMIT)?;
Ok(())
}
fn preserve_existing(path: &Path) -> io::Result<bool> {
preserve_existing_in(path, "session-backups", 3)
}
fn preserve_existing_in(path: &Path, directory_name: &str, keep: usize) -> io::Result<bool> {
let mut source = match File::open(path) {
Ok(file) => file,
// Recheck on the next mutation until a fresh session is actually saved.
@@ -73,7 +133,7 @@ fn preserve_existing(path: &Path) -> io::Result<bool> {
if !source.metadata()?.is_file() {
return Err(io::Error::other("session path is not a regular file"));
}
let directory = path.with_file_name("session-backups");
let directory = path.with_file_name(directory_name);
std::fs::create_dir_all(&directory)?;
let older = recovery_files(&directory)?;
let now = SystemTime::now()
@@ -105,9 +165,13 @@ fn preserve_existing(path: &Path) -> io::Result<bool> {
outcome = "ok",
path = %path.display(),
backup_path = %backup.display(),
"preserved unloaded session before replacement"
"preserved session recovery copy"
);
if let Err(err) = prune_backups(&older) {
if let Err(err) = prune_backups(&older, keep) {
if directory_name == "session-snapshots" {
std::fs::remove_file(&backup)?;
return Err(err);
}
tracing::warn!(
event = "persist.backup", subsystem = "persist", outcome = "prune_error",
path = %directory.display(), err = %err, "failed to prune session recovery copies"
@@ -168,10 +232,23 @@ fn recovery_files(directory: &Path) -> io::Result<Vec<(u128, PathBuf)>> {
Ok(files)
}
fn prune_backups(older: &[(u128, PathBuf)]) -> io::Result<()> {
fn prune_backups(older: &[(u128, PathBuf)], keep: usize) -> io::Result<()> {
// The new copy is durable before any of the previous copies are removed.
for (_, path) in older.iter().take(older.len().saturating_sub(2)) {
std::fs::remove_file(path)?;
let mut remaining = older.len().saturating_sub(keep.saturating_sub(1));
let mut failure = None;
for (_, path) in older {
if remaining == 0 {
return Ok(());
}
match std::fs::remove_file(path) {
Ok(()) => remaining -= 1,
Err(err) => failure = Some(err),
}
}
if remaining > 0 {
if let Some(err) = failure {
return Err(err);
}
}
Ok(())
}
@@ -234,6 +311,144 @@ mod tests {
.collect()
}
fn snapshots(writer: &SessionWriter) -> Vec<(u128, PathBuf)> {
recovery_files(&writer.path.with_file_name("session-snapshots")).unwrap()
}
#[test]
fn snapshot_survives_exit_bursts_clears_and_writer_restarts() {
let mut writer = writer(false);
let original = snapshot();
writer.save(&original, None);
let files = snapshots(&writer);
assert_eq!(files.len(), 1);
let saved = std::fs::read(&files[0].1).unwrap();
for i in 0..100 {
let mut shrinking = snapshot();
shrinking.workspaces[0].custom_name = Some(format!("remaining pane {i}"));
writer.save(&shrinking, None);
writer = SessionWriter {
path: writer.path.clone(),
protect_unloaded: false,
};
}
writer.clear();
assert!(
!writer.path.exists(),
"intentional clear must still persist"
);
assert_eq!(snapshots(&writer), files);
assert_eq!(std::fs::read(&files[0].1).unwrap(), saved);
assert!(backups(&writer).is_empty());
std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
}
#[test]
fn snapshot_history_is_bounded_and_does_not_rotate_identical_layouts() {
let mut writer = writer(false);
let directory = writer.path.with_file_name("session-snapshots");
std::fs::create_dir(&directory).unwrap();
let manual = directory.join("my-layout.json");
std::fs::write(&manual, b"manual").unwrap();
for i in 0..SNAPSHOT_LIMIT {
std::fs::write(
directory.join(format!("session-{i:039}-1-0.json")),
b"old snapshot",
)
.unwrap();
}
for (_, path) in snapshots(&writer) {
File::options()
.write(true)
.open(path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(UNIX_EPOCH))
.unwrap();
}
writer.save(&snapshot(), None);
assert_eq!(snapshots(&writer).len(), SNAPSHOT_LIMIT);
assert!(!directory
.join(format!("session-{:039}-1-0.json", 0))
.exists());
assert!(manual.exists());
for (_, path) in snapshots(&writer) {
std::fs::remove_file(path).unwrap();
}
let old = directory.join(format!("session-{:039}-1-0.json", 1));
let saved = std::fs::read(&writer.path).unwrap();
let reordered: serde_json::Value = serde_json::from_slice(&saved).unwrap();
let equivalent = serde_json::to_vec(&reordered).unwrap();
assert_ne!(saved, equivalent);
std::fs::write(&old, equivalent).unwrap();
File::options()
.write(true)
.open(&old)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(UNIX_EPOCH))
.unwrap();
writer.save(&snapshot(), None);
assert_eq!(snapshots(&writer), vec![(1, old)]);
std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
}
#[test]
fn snapshot_cadence_recovers_after_clock_rollback_and_restart() {
let mut writer = writer(false);
writer.save(&snapshot(), None);
let file = snapshots(&writer).pop().unwrap().1;
File::options()
.write(true)
.open(&file)
.unwrap()
.set_times(
std::fs::FileTimes::new()
.set_modified(SystemTime::now() + std::time::Duration::from_secs(86400)),
)
.unwrap();
let mut changed = snapshot();
changed.workspaces[0].custom_name = Some("after clock rollback".into());
writer.save(&changed, None);
assert_eq!(snapshots(&writer).len(), 2);
writer = SessionWriter {
path: writer.path.clone(),
protect_unloaded: false,
};
changed.workspaces[0].custom_name = Some("after restart".into());
writer.save(&changed, None);
assert_eq!(
snapshots(&writer).len(),
2,
"new mtime restores cadence across restart"
);
std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
}
#[test]
fn pruning_continues_past_an_undeletable_entry() {
let writer = writer(false);
let locked = writer.path.with_file_name("undeletable");
std::fs::create_dir(&locked).unwrap();
let removable = writer.path.with_file_name("removable");
std::fs::write(&removable, b"old").unwrap();
assert!(prune_backups(&[(1, locked.clone()), (2, removable.clone())], 2).is_ok());
assert!(locked.exists());
assert!(!removable.exists());
assert!(prune_backups(&[(1, locked)], 1).is_err());
std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
}
#[test]
fn snapshot_failure_does_not_block_primary_save_and_clear() {
let mut writer = writer(false);
std::fs::write(writer.path.with_file_name("session-snapshots"), b"blocked").unwrap();
writer.save(&snapshot(), None);
assert!(writer.path.exists());
writer.clear();
assert!(!writer.path.exists());
std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap();
}
#[test]
fn healthy_and_fresh_sessions_save_and_clear_without_backups() {
for protect_unloaded in [false, true] {
+3
View File
@@ -26,6 +26,9 @@ pub(crate) use super::unix_common::{
#[cfg(test)]
mod config_file_tests;
mod shutdown;
pub(crate) use shutdown::monitor_host_shutdown;
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION";
const CHILD_GROUPS_SCAN_LIMIT: usize = 64;
+208
View File
@@ -0,0 +1,208 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
pub(crate) fn monitor_host_shutdown(
requested: Arc<AtomicBool>,
wake: impl Fn() + Send + Sync + 'static,
) -> Option<tokio::task::JoinHandle<()>> {
Some(tokio::spawn(async move {
let mut retry = Duration::from_secs(1);
loop {
match watch_shutdown(&requested, &wake).await {
Ok(()) => retry = Duration::from_secs(1),
Err(err) => {
tracing::debug!(err = %err, retry_seconds = retry.as_secs(), "host shutdown notification unavailable");
tokio::time::sleep(retry).await;
retry = (retry * 2).min(Duration::from_secs(60));
}
}
}
}))
}
async fn watch_shutdown(
requested: &AtomicBool,
wake: &(impl Fn() + Send + Sync),
) -> zbus::Result<()> {
let connection = zbus::Connection::system().await?;
watch_connection(connection, requested, wake).await
}
async fn watch_connection(
connection: zbus::Connection,
requested: &AtomicBool,
wake: &(impl Fn() + Send + Sync),
) -> zbus::Result<()> {
let manager = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.await?;
let mut owners = manager.receive_owner_changed().await?;
let mut signals = manager.receive_signal("PrepareForShutdown").await?;
let inhibitor: zbus::zvariant::OwnedFd = manager
.call(
"Inhibit",
&(
"shutdown",
"Herdr",
"Save terminal workspace layout",
"delay",
),
)
.await?;
tracing::debug!("host shutdown notification ready");
let mut preparing: bool = manager.get_property("PreparingForShutdown").await?;
loop {
if preparing {
tracing::info!("host shutdown requested; preserving session before pane termination");
requested.store(true, Ordering::Release);
wake();
// Keep the delay lock until the server has saved and drops its monitor.
// logind caps the delay even if the server gets stuck.
std::future::pending::<()>().await;
}
tokio::select! {
biased;
_ = owners.next() => break,
signal = signals.next() => {
let Some(signal) = signal else { break };
preparing = signal.body().deserialize()?;
}
}
}
drop(inhibitor);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, Read};
use std::os::unix::net::UnixStream;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
struct PrivateBus(Child);
impl Drop for PrivateBus {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct LoginManager {
preparing: bool,
peer: Arc<Mutex<Option<UnixStream>>>,
}
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
impl LoginManager {
fn inhibit(
&self,
what: &str,
_who: &str,
_why: &str,
mode: &str,
) -> zbus::fdo::Result<zbus::zvariant::OwnedFd> {
assert_eq!((what, mode), ("shutdown", "delay"));
let (lock, peer) = UnixStream::pair().unwrap();
peer.set_nonblocking(true).unwrap();
*self.peer.lock().unwrap() = Some(peer);
Ok(std::os::fd::OwnedFd::from(lock).into())
}
#[zbus(property)]
fn preparing_for_shutdown(&self) -> bool {
self.preparing
}
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never requests host shutdown"]
async fn shutdown_warning_holds_inhibitor_until_monitor_is_dropped() {
for already_preparing in [false, true] {
let mut bus = PrivateBus(
Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.unwrap(),
);
let mut address = String::new();
std::io::BufReader::new(bus.0.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
let peer = Arc::new(Mutex::new(None));
let service = zbus::connection::Builder::address(address.trim())
.unwrap()
.name("org.freedesktop.login1")
.unwrap()
.serve_at(
"/org/freedesktop/login1",
LoginManager {
preparing: already_preparing,
peer: peer.clone(),
},
)
.unwrap()
.build()
.await
.unwrap();
let client = zbus::connection::Builder::address(address.trim())
.unwrap()
.build()
.await
.unwrap();
let requested = Arc::new(AtomicBool::new(false));
let wake = Arc::new(tokio::sync::Notify::new());
let task = tokio::spawn({
let requested = requested.clone();
let wake = wake.clone();
async move {
watch_connection(client, &requested, &move || wake.notify_one())
.await
.unwrap();
}
});
tokio::time::timeout(Duration::from_secs(5), async {
while peer.lock().unwrap().is_none() {
tokio::task::yield_now().await;
}
if !already_preparing {
assert!(!requested.load(Ordering::Acquire));
service
.emit_signal(
None::<&str>,
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
"PrepareForShutdown",
&true,
)
.await
.unwrap();
}
wake.notified().await;
})
.await
.unwrap();
assert!(requested.load(Ordering::Acquire));
let mut peer = peer.lock().unwrap().take().unwrap();
assert_eq!(
peer.read(&mut [0]).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock,
"shutdown must remain inhibited while the server saves"
);
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert_eq!(peer.read(&mut [0]).unwrap(), 0);
}
}
}
+30
View File
@@ -3,6 +3,36 @@
//! Centralizes OS-dependent behavior behind a clean boundary so core
//! modules don't scatter `#[cfg]` branches through product logic.
pub(crate) struct HostShutdownMonitor {
task: Option<tokio::task::JoinHandle<()>>,
}
impl HostShutdownMonitor {
pub(crate) fn start(
requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
wake: impl Fn() + Send + Sync + 'static,
) -> Self {
let task = monitor_host_shutdown(requested, wake);
Self { task }
}
}
impl Drop for HostShutdownMonitor {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
}
}
#[cfg(not(target_os = "linux"))]
fn monitor_host_shutdown(
_requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
_wake: impl Fn() + Send + Sync + 'static,
) -> Option<tokio::task::JoinHandle<()>> {
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundProcess {
pub pid: u32,
+19 -1
View File
@@ -250,6 +250,7 @@ pub struct HeadlessServer {
pending_handoff_repaint_nudge: bool,
/// Flag set by Ctrl+C or `server stop` signal.
should_quit: Arc<AtomicBool>,
host_shutdown_requested: Arc<AtomicBool>,
/// Channel for receiving server events from client connection threads.
server_event_rx: mpsc::Receiver<ServerEvent>,
/// Sender for server events (cloned for each client thread).
@@ -373,6 +374,7 @@ impl HeadlessServer {
headless_size,
effective_size: headless_size,
shutting_down: false,
host_shutdown_requested: Arc::new(AtomicBool::new(false)),
handoff_in_progress: false,
#[cfg(unix)]
pending_handoff_repaint_nudge: false,
@@ -398,6 +400,13 @@ impl HeadlessServer {
let should_quit = self.should_quit.clone();
let quit_notify = self.server_event_tx.clone();
ctrlc_handler(should_quit, quit_notify);
let quit_notify = self.server_event_tx.clone();
let _host_shutdown = crate::platform::HostShutdownMonitor::start(
self.host_shutdown_requested.clone(),
move || {
let _ = quit_notify.try_send(ServerEvent::QuitSignal);
},
);
let mut needs_render = true;
let mut needs_full_render = true;
@@ -414,6 +423,13 @@ impl HeadlessServer {
break;
}
// A host shutdown warning precedes process termination. Do not drain pane
// deaths here: logind's delay lock stays held until the final session save.
if self.host_shutdown_requested.load(Ordering::Acquire) {
self.initiate_shutdown();
continue;
}
// Check if we should start shutting down.
if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) {
self.drain_internal_events_with_forwarding_up_to(
@@ -638,7 +654,9 @@ impl HeadlessServer {
}
};
if self.should_quit.load(Ordering::Acquire) {
if self.should_quit.load(Ordering::Acquire)
|| self.host_shutdown_requested.load(Ordering::Acquire)
{
match event {
LoopEvent::Internal(ev) => {
self.handle_internal_event_with_forwarding(ev);
+6
View File
@@ -321,6 +321,9 @@ impl HeadlessServer {
///
/// Returns true if the event changed visual state (requiring a re-render).
pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool {
if self.host_shutdown_requested.load(Ordering::Acquire) {
return false;
}
let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev {
result
.api_request
@@ -752,6 +755,9 @@ impl HeadlessServer {
let mut had_event = false;
let mut changed = false;
for _ in 0..limit {
if self.host_shutdown_requested.load(Ordering::Acquire) {
break;
}
let Ok(ev) = self.app.event_rx.try_recv() else {
break;
};
+32
View File
@@ -96,6 +96,7 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ
headless_size,
effective_size: headless_size,
shutting_down: false,
host_shutdown_requested: Arc::new(AtomicBool::new(false)),
handoff_in_progress: false,
#[cfg(unix)]
pending_handoff_repaint_nudge: false,
@@ -4185,6 +4186,37 @@ fn changed_git_refresh_requests_headless_render() {
assert!(changed);
}
#[tokio::test]
async fn host_shutdown_preserves_panes_from_queued_and_selected_death_events() {
let mut server = test_headless_server();
let workspace = crate::workspace::Workspace::test_new("host-shutdown");
let pane_id = workspace.tabs[0].root_pane;
server.app.state.workspaces = vec![workspace];
server.app.state.ensure_test_terminals();
server.app.state.active = Some(0);
let event = || AppEvent::PaneDied {
pane_id,
exit_reason: crate::platform::ChildExitReason::Exited,
};
server.app.event_tx.try_send(event()).unwrap();
server
.host_shutdown_requested
.store(true, Ordering::Release);
assert_eq!(
server.drain_internal_events_with_forwarding_up_to(16),
(false, false)
);
assert!(!server.handle_internal_event_with_forwarding(event()));
assert!(server.app.find_pane(pane_id).is_some());
assert!(server.app.event_rx.try_recv().is_ok());
server
.host_shutdown_requested
.store(false, Ordering::Release);
assert!(server.handle_internal_event_with_forwarding(event()));
assert!(server.app.find_pane(pane_id).is_none());
shutdown_test_runtimes(&mut server);
}
#[tokio::test]
async fn pane_death_reconciles_each_client_view_and_focus() {
let mut server = test_headless_server();
+4
View File
@@ -562,6 +562,10 @@ impl TerminalRuntime {
self.0.cwd()
}
pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> {
self.0.cwd_for_persistence()
}
pub fn follow_cwd(&self) -> Option<std::path::PathBuf> {
self.0.follow_cwd()
}
+2
View File
@@ -148,6 +148,7 @@ pub struct TerminalState {
recent_agent_process_exit: Option<RecentAgentProcessExit>,
agent_process_acquisition_pending: bool,
pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>,
pub restore_error: Option<String>,
}
impl TerminalState {
@@ -183,6 +184,7 @@ impl TerminalState {
recent_agent_process_exit: None,
agent_process_acquisition_pending: false,
pending_agent_resume_plan: None,
restore_error: None,
}
}
+40 -2
View File
@@ -2,7 +2,7 @@ use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
@@ -392,9 +392,10 @@ pub(super) fn render_panes(
pane_infos: &[PaneInfo],
split_borders: &[crate::layout::SplitBorder],
) {
let Some(ws_idx) = target.map(|target| target.workspace_index) else {
let Some(target) = target else {
return;
};
let ws_idx = target.workspace_index;
let Some(ws) = app.workspaces.get(ws_idx) else {
return;
};
@@ -406,6 +407,17 @@ pub(super) fn render_panes(
&& app.pane_exposes_host_cursor(ws_idx, info.id);
rt.render(frame, info.inner_rect, show_cursor);
render_pane_scrollbar(app, frame, info, rt);
} else if let Some(reason) = ws
.tabs
.get(target.tab_index)
.and_then(|tab| tab.terminal_id(info.id))
.and_then(|id| app.terminals.get(id))
.and_then(|terminal| terminal.restore_error.as_deref())
{
frame.render_widget(
Paragraph::new(reason).wrap(Wrap { trim: false }),
info.inner_rect,
);
}
}
@@ -847,6 +859,32 @@ mod tests {
render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame);
}
#[test]
fn unavailable_pane_renders_restore_failure_without_a_runtime() {
let mut app = AppState::test_new();
app.workspaces = vec![Workspace::test_new("unavailable")];
app.active = Some(0);
app.ensure_test_terminals();
let pane_id = app.workspaces[0].tabs[0].root_pane;
let terminal_id = app.workspaces[0].terminal_id(pane_id).unwrap().clone();
app.terminals.get_mut(&terminal_id).unwrap().restore_error =
Some("Saved directory is unavailable. Restart to retry.".into());
let (buffer, cursor, _, _) = crate::server::render_stream::render_tab_surface_virtual(
&app,
&TerminalRuntimeRegistry::new(),
Some(crate::ui::TabSurfaceTarget {
workspace_index: 0,
tab_index: 0,
}),
Rect::new(0, 0, 80, 24),
false,
Default::default(),
);
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(text.contains("Saved directory is unavailable."));
assert!(cursor.is_none_or(|cursor| !cursor.visible));
}
#[test]
fn pane_border_title_trims_and_truncates() {
assert_eq!(
+43 -14
View File
@@ -1796,10 +1796,7 @@ fn client_receives_pane_surface_after_pane_output() {
}
#[test]
fn pane_spawn_cwd_fallback_in_server() {
// Pane spawn failure cwd fallback in server context.
// This test verifies that the server can start even with invalid
// session data pointing to non-existent directories.
fn unavailable_restored_pane_keeps_saved_cwd_in_server() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
@@ -1854,12 +1851,11 @@ fn pane_spawn_cwd_fallback_in_server() {
assert_eq!(pane["result"]["pane"]["workspace_id"], workspace_id);
let cwd = pane["result"]["pane"]["cwd"]
.as_str()
.expect("restored pane should report fallback cwd");
assert_ne!(cwd, missing_cwd);
assert!(
std::path::Path::new(cwd).exists(),
"fallback cwd should exist: {cwd}"
);
.expect("restored pane should retain saved cwd");
assert_eq!(cwd, missing_cwd);
assert!(pane["result"]["pane"]["restore_error"]
.as_str()
.is_some_and(|error| error.contains("directory")));
let client_shell = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket);
let output = spawn_pty_drain(
@@ -1872,14 +1868,47 @@ fn pane_spawn_cwd_fallback_in_server() {
);
assert!(
wait_until(Duration::from_secs(8), Duration::from_millis(20), || {
read_output(&output).contains("missing-cwd")
let screen = read_output(&output);
screen.contains("missing-cwd") && screen.contains("unavailable")
}),
"client shell should render the restored session; output: {:?}",
"client shell should render the unavailable pane; output: {:?}",
read_output(&output)
);
drop(client_shell);
let stopped = send_json_request(
&api_socket,
r#"{"id":"stop","method":"server.stop","params":{}}"#,
);
assert!(stopped.get("error").is_none(), "{stopped}");
let mut spawned = spawned;
assert!(wait_until(
Duration::from_secs(10),
Duration::from_millis(20),
|| { spawned.child.try_wait().unwrap().is_some() }
));
drop(spawned);
cleanup_spawned_herdr(client_shell, base);
fs::create_dir(missing_cwd).unwrap();
let restarted = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
wait_for_socket(&api_socket, Duration::from_secs(10));
let recovered = send_json_request(
&api_socket,
&format!(r#"{{"id":"recovered","method":"pane.get","params":{{"pane_id":"{pane_id}"}}}}"#),
);
assert_eq!(
std::fs::canonicalize(recovered["result"]["pane"]["cwd"].as_str().unwrap()).unwrap(),
std::fs::canonicalize(missing_cwd).unwrap()
);
assert!(recovered["result"]["pane"]["restore_error"].is_null());
let sent = send_json_request(
&api_socket,
&serde_json::json!({"id": "type", "method": "pane.send_text", "params": {
"pane_id": pane_id, "text": "printf 'RESTORE_RETRY_OK\\n'\n"
}})
.to_string(),
);
assert!(sent.get("error").is_none(), "{sent}");
cleanup_spawned_herdr(restarted, base);
}
#[test]
+221
View File
@@ -0,0 +1,221 @@
#![cfg(target_os = "linux")]
use std::io::{BufRead, Read, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct LoginManager {
peer: Arc<Mutex<Option<UnixStream>>>,
}
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
impl LoginManager {
fn inhibit(
&self,
what: &str,
_who: &str,
_why: &str,
mode: &str,
) -> zbus::fdo::Result<zbus::zvariant::OwnedFd> {
assert_eq!((what, mode), ("shutdown", "delay"));
let (lock, peer) = UnixStream::pair().unwrap();
peer.set_nonblocking(true).unwrap();
*self.peer.lock().unwrap() = Some(peer);
Ok(std::os::fd::OwnedFd::from(lock).into())
}
#[zbus(property)]
fn preparing_for_shutdown(&self) -> bool {
false
}
}
fn private_bus(address: &str) -> ChildGuard {
let mut child = ChildGuard(
Command::new("dbus-daemon")
.args([
"--session",
"--nofork",
"--print-address=1",
"--address",
address,
])
.stdout(Stdio::piped())
.spawn()
.unwrap(),
);
let mut ready = String::new();
std::io::BufReader::new(child.0.stdout.take().unwrap())
.read_line(&mut ready)
.unwrap();
child
}
async fn login_service(address: &str, peer: Arc<Mutex<Option<UnixStream>>>) -> zbus::Connection {
zbus::connection::Builder::address(address)
.unwrap()
.name("org.freedesktop.login1")
.unwrap()
.serve_at("/org/freedesktop/login1", LoginManager { peer })
.unwrap()
.build()
.await
.unwrap()
}
async fn wait_for_inhibitor(peer: &Mutex<Option<UnixStream>>) {
tokio::time::timeout(Duration::from_secs(10), async {
while peer.lock().unwrap().is_none() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
}
fn api(socket: &Path, method: &str, params: serde_json::Value) -> serde_json::Value {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
writeln!(
stream,
"{}",
serde_json::json!({"id":"test", "method":method, "params":params})
)
.unwrap();
let mut line = String::new();
std::io::BufReader::new(stream)
.read_line(&mut line)
.unwrap();
let response: serde_json::Value = serde_json::from_str(&line).unwrap();
assert!(response.get("error").is_none(), "{response}");
response
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires dbus-daemon; exercises a private bus and disposable named server, not host shutdown"]
async fn host_shutdown_saves_layout_before_releasing_delay_lock() {
let base = std::path::PathBuf::from(format!(
"/var/tmp/hhs-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&base).unwrap();
let address = format!("unix:path={}", base.join("bus").display());
let mut bus = private_bus(&address);
let peer = Arc::new(Mutex::new(None));
let mut service = login_service(&address, peer.clone()).await;
let socket = base.join("herdr-dev/sessions/shutdown/herdr.sock");
let config = base.join("config.toml");
std::fs::write(&config, "onboarding = false\n[experimental]\nallow_nested = true\n[terminal]\ndefault_shell = \"/bin/sh\"\n").unwrap();
let mut server = ChildGuard(
Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["--session", "shutdown", "server"])
.env("XDG_CONFIG_HOME", &base)
.env("XDG_STATE_HOME", &base)
.env("XDG_RUNTIME_DIR", &base)
.env("HERDR_CONFIG_PATH", &config)
.env_remove("HERDR_SOCKET_PATH")
.env("DBUS_SYSTEM_BUS_ADDRESS", address.trim())
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_SESSION")
.env_remove("HERDR_WORKSPACE_ID")
.env_remove("HERDR_TAB_ID")
.env_remove("HERDR_PANE_ID")
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.unwrap(),
);
tokio::time::timeout(Duration::from_secs(10), async {
while !socket.exists() || peer.lock().unwrap().is_none() {
assert!(
server.0.try_wait().unwrap().is_none(),
"server exited during startup"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
// Neither a login1 owner change nor a bus restart may leave a stale inhibitor.
peer.lock().unwrap().take();
service
.release_name("org.freedesktop.login1")
.await
.unwrap();
drop(service);
service = login_service(&address, peer.clone()).await;
wait_for_inhibitor(&peer).await;
peer.lock().unwrap().take();
drop(service);
drop(bus);
if base.join("bus").exists() {
std::fs::remove_file(base.join("bus")).unwrap();
}
bus = private_bus(&address);
service = login_service(&address, peer.clone()).await;
wait_for_inhibitor(&peer).await;
for label in ["one", "two", "three"] {
api(
&socket,
"workspace.create",
serde_json::json!({"cwd":base,"label":label,"focus":true}),
);
}
service
.emit_signal(
None::<&str>,
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
"PrepareForShutdown",
&true,
)
.await
.unwrap();
let mut peer = peer.lock().unwrap().take().unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
loop {
match peer.read(&mut [0]) {
Ok(0) => break,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {}
other => panic!("unexpected inhibitor state: {other:?}"),
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
let saved = base.join("herdr-dev/sessions/shutdown/session.json");
let layout: serde_json::Value = serde_json::from_slice(&std::fs::read(saved).unwrap()).unwrap();
assert_eq!(layout["workspaces"].as_array().unwrap().len(), 3);
assert_eq!(layout["workspaces"][2]["custom_name"], "three");
tokio::time::timeout(Duration::from_secs(5), async {
while server.0.try_wait().unwrap().is_none() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
drop(server);
drop(service);
drop(bus);
std::fs::remove_dir_all(base).unwrap();
}