aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 02:45:20 +0300
committerGitHub <noreply@github.com>2026-09-02 02:45:20 +0300
commitcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch)
tree4d37c760099070f1a1488b61c7b7271875aafef8 /tests
parent18e69891dca486d669a584facd80644bb51f54a2 (diff)
feat: add stable client endpoint compatibility (#3509)
* feat: add stable client endpoint compatibility * fix: keep portable-pty patch checks composable * ci: run maintenance checks in pull requests * fix: reject endpoint errors from terminal clients * feat: keep compatible servers running across updates
Diffstat (limited to 'tests')
-rw-r--r--tests/cli/sessions.rs28
-rw-r--r--tests/client_mode.rs5
-rw-r--r--tests/cross_area.rs7
-rw-r--r--tests/detach_reattach.rs2
-rw-r--r--tests/fixtures/endpoint-hello-v1.json25
-rw-r--r--tests/fixtures/endpoint-method-shapes-v1.json39
-rw-r--r--tests/fixtures/endpoint-snapshot-v1.json106
-rw-r--r--tests/fixtures/endpoint-welcome-v1.json11
-rw-r--r--tests/live_handoff.rs71
-rw-r--r--tests/multi_client.rs3
-rw-r--r--tests/support/mod.rs97
11 files changed, 332 insertions, 62 deletions
diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs
index cc8474eb..8e5fc48f 100644
--- a/tests/cli/sessions.rs
+++ b/tests/cli/sessions.rs
@@ -398,7 +398,11 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" compatible: yes"),
+ full_stdout.contains(" private_protocol_compatible: yes"),
+ "stdout: {full_stdout}"
+ );
+ assert!(
+ full_stdout.contains(" endpoint_compatible: yes"),
"stdout: {full_stdout}"
);
assert!(
@@ -406,6 +410,10 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
+ full_stdout.contains(" server_binary_stale: no"),
+ "stdout: {full_stdout}"
+ );
+ assert!(
full_stdout.contains(&socket_path.display().to_string()),
"stdout: {full_stdout}"
);
@@ -422,7 +430,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 22"),
+ server_stdout.contains("private_protocol: 22"),
"stdout: {server_stdout}"
);
@@ -438,6 +446,10 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
+ client_stdout.contains("endpoint_protocol_generation: 1"),
+ "stdout: {client_stdout}"
+ );
+ assert!(
client_stdout.contains("binary: "),
"stdout: {client_stdout}"
);
@@ -445,25 +457,31 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(full_json["client"]["protocol"], 22);
+ assert_eq!(full_json["client"]["endpoint_protocol_generation"], 1);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
+ assert_eq!(full_json["server"]["endpoint_compatible"], true);
assert_eq!(
full_json["server"]["socket"],
socket_path.display().to_string()
);
assert_eq!(full_json["server"]["restart_needed"], false);
+ assert_eq!(full_json["server"]["server_binary_stale"], false);
assert_eq!(full_json["update"]["restart_needed"], false);
+ assert_eq!(full_json["update"]["server_binary_stale"], false);
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(server_json["protocol"], 22);
assert_eq!(server_json["compatible"], true);
+ assert_eq!(server_json["endpoint_compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(client_json["protocol"], 22);
+ assert_eq!(client_json["endpoint_protocol_generation"], 1);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));
@@ -485,6 +503,10 @@ fn status_reports_not_running_when_server_socket_is_missing() {
assert!(stdout.contains(" status: not running"), "stdout: {stdout}");
assert!(stdout.contains(" restart_needed: no"), "stdout: {stdout}");
assert!(
+ stdout.contains(" server_binary_stale: no"),
+ "stdout: {stdout}"
+ );
+ assert!(
stdout.contains(&socket_path.display().to_string()),
"stdout: {stdout}"
);
@@ -497,7 +519,9 @@ fn status_reports_not_running_when_server_socket_is_missing() {
socket_path.display().to_string()
);
assert_eq!(status_json["server"]["restart_needed"], false);
+ assert_eq!(status_json["server"]["server_binary_stale"], false);
assert_eq!(status_json["update"]["restart_needed"], false);
+ assert_eq!(status_json["update"]["server_binary_stale"], false);
cleanup_test_base(&base);
}
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index c6c76c34..351c5fa2 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -18,8 +18,9 @@ use support::{
cleanup_test_base, client_shell_handshake, read_server_message, register_runtime_dir,
register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap,
wait_for_message_variant, wait_for_message_variants, wait_for_socket, wait_until,
- CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
- SERVER_MESSAGE_SEMANTIC_NOTIFICATION, SERVER_MESSAGE_SERVER_SHUTDOWN,
+ CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE,
+ SERVER_MESSAGE_PANE_SURFACE_PATCH, SERVER_MESSAGE_SEMANTIC_NOTIFICATION,
+ SERVER_MESSAGE_SERVER_SHUTDOWN,
};
fn unique_test_dir() -> PathBuf {
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 9b856cb8..91c17800 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -14,8 +14,9 @@ use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}
use serde_json::{json, Value};
use support::{
cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid, CURRENT_PROTOCOL, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
- SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
+ unregister_spawned_herdr_pid, CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL,
+ SERVER_MESSAGE_ENDPOINT_CONTROL, SERVER_MESSAGE_PANE_SURFACE,
+ SERVER_MESSAGE_PANE_SURFACE_PATCH,
};
fn unique_test_dir() -> PathBuf {
@@ -444,7 +445,7 @@ fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool {
let slice = deadline.saturating_duration_since(Instant::now());
match read_server_variant(stream, slice) {
Ok(SERVER_MESSAGE_PANE_SURFACE | SERVER_MESSAGE_PANE_SURFACE_PATCH) => return true,
- Ok(SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT) => {}
+ Ok(SERVER_MESSAGE_ENDPOINT_CONTROL) => {}
Ok(_) => {}
Err(err) if is_timeout(&err) => {}
Err(_) => return false,
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index f570e979..cdf0736c 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -16,7 +16,7 @@ use serde_json::Value;
use support::{
cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir,
register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, wait_for_disconnect,
- wait_for_socket, wait_until, CURRENT_PROTOCOL,
+ wait_for_socket, wait_until, CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL,
};
const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false
diff --git a/tests/fixtures/endpoint-hello-v1.json b/tests/fixtures/endpoint-hello-v1.json
new file mode 100644
index 00000000..dfe7ebb3
--- /dev/null
+++ b/tests/fixtures/endpoint-hello-v1.json
@@ -0,0 +1,25 @@
+{
+ "generation": 1,
+ "cell_width_px": 8,
+ "cell_height_px": 16,
+ "surface_size": {
+ "cols": 80,
+ "rows": 24
+ },
+ "pixel_mouse": true,
+ "direct_graphics": false,
+ "endpoint_keybindings": false,
+ "mouse_capture": true,
+ "snapshot_codecs": [
+ "shell.snapshot.v1"
+ ],
+ "surface_codecs": [
+ "shell.surface.v1"
+ ],
+ "input_codecs": [
+ "shell.input.semantic.v1"
+ ],
+ "blob_codecs": [
+ "shell.blob.v1"
+ ]
+}
diff --git a/tests/fixtures/endpoint-method-shapes-v1.json b/tests/fixtures/endpoint-method-shapes-v1.json
new file mode 100644
index 00000000..b473869f
--- /dev/null
+++ b/tests/fixtures/endpoint-method-shapes-v1.json
@@ -0,0 +1,39 @@
+{
+ "command.invoke": "b16817652442b1ea2405e60a0443826a55baca461e938f524e79b16a675c5899",
+ "integration.install": "913055b838166650fee6713332c1cc436efd4f8fae9a2064d8f9b6616a7a66f8",
+ "integration.list": "60c57189adf9c281958062f0e11680bda0ece83307c506fa18bff8b01ebfb4e8",
+ "layout.set_split_ratio": "13ef71d865f49c4f512fdfa8de64e1bdc0d4299675f78daf61bd5cdc10174d2f",
+ "pane.close": "dcc277a6a4f9b8fc3438d8fa61de2688f4338b0d2d60100798e4103690e0b316",
+ "pane.copy_motion": "57e690b444d5dbb408ac3e0d40ff307f77d6e79dccd3c026cd19580e1bd78983",
+ "pane.copy_search": "5391364ee0a0a12776a5ba3c59e7ca2a879448ec79ff357c8157b0c01e3cbfc1",
+ "pane.edit_scrollback": "c9b4722bb38e280f97915264205ba19171d76027002e7eeefae19141a44a91de",
+ "pane.focus": "680c1dffa1466d7725550f91cb58af6e1d45dcbde2bf5d4765e5dc5fc9c982d5",
+ "pane.focus_direction": "4e34a76f69213378d51b397927edc43e098c9cd24660fdcbf8b5001b6e226e05",
+ "pane.input.set": "4eb8914f4cd40417ba5631d4e9058a04cde11191c58c08e66553f43e4ca0ffd8",
+ "pane.link.activate": "42bf791ae426cb56f3fdad63e861bfe01432cb8e81e046960a9245d89dc442ff",
+ "pane.rename": "cc3df3226f618ec4b9a175eca3450a66a9f2bea9fa39843e1a5900a1b45918c3",
+ "pane.resize": "9fbcc70b8908c43f47b0ff85d0a9040ce18c9179a029ed7d8f2616a05d8c301d",
+ "pane.scroll": "3229d55230b89730e5231a43d87cefa98cfda24acaaf6365f7c6a3548cc42db1",
+ "pane.selection.read": "2f7b0fdf4f0fc1f1fe9ab4d962c833dd39a1e9bd866d77bbf0031f8b2c485534",
+ "pane.split": "5d740322cac5287070194fb522c8135795a6f877b44652ea4959c63b4c67cefd",
+ "pane.swap": "3146d499d8174d73cfdfa3b6ca44f5d5d0951ab1cdd1808f94a3cf8846dc8057",
+ "pane.zoom": "0521e444ea892abb1f73d16dc0e10a5667c8a752284eae4cbc0961cd6d96bbfe",
+ "product_announcement.dismiss": "7ce099f799f396d4733c77ddfa0ac7e2fd597e49c52e2739af3b883624a834d5",
+ "release_notes.dismiss": "75e096dc234e7879b401b934097ecc3a4a91b78d89a909d47f6dd9f49cfc7bdb",
+ "server.reload_config": "6def1206dab9748df80e6c98ab6fbee3d45c1e5019e889dd8c9abd22a3dadac8",
+ "tab.close": "29e03e6723e4eeef762161db2f6dd23306432b9ecbb8c879efe1e7b1fe697312",
+ "tab.create": "c2477e9bada16b329303fc552d66b900ad5c38ae9ed4ea0884f4b3de8ff01820",
+ "tab.focus": "666eff9e6ae83ae1dd8fd10c456cc5d216466a667070ee3f9fdca7a4422611e6",
+ "tab.move": "63c354ed4684d3216fc7f712c3073989f757135293a5ee37f49a2734841f4187",
+ "tab.rename": "bd84404b569286748a8cfa68720aacdbbe549fc5d2b009529d26e8cd455ef071",
+ "workspace.close": "e4e762f28e3e2fb96be974f3cd9be7420ec7c57cf1b6f55c19d7c67853197618",
+ "workspace.create": "b9dc2df43776d7d73bea576ef31b25276604c42a1c91e66e5bde8715119936ac",
+ "workspace.focus": "39675f05cfe6110ece18aa2789c26f0905303b2114af801712857d0c021df150",
+ "workspace.move": "1d8d308b3a6f878ef36232c7776a6d78e9ffb05f0ff3bdb52476d3a71a98208a",
+ "workspace.move_block": "1c1fdf3cd9387152e09d676830998f961cf80143704940da4dc88b5c7cb639a9",
+ "workspace.rename": "0e00bc779c367ef3e7a154c63b9bbbf9b9bcaa9b198450f5c479f9b9956fa7cb",
+ "worktree.create": "e5cca6546ace513fa2f6ca48c2ec7bc3eebcd589feeb6b97b9d16fd415037399",
+ "worktree.list": "b0ee8d77a954fdd418f7719074b07484294ee1395cb4dc6e38ab831a5ffac000",
+ "worktree.open": "5ea3b41d74b095c12bf342cd5cde3f23e2e1b9c71281dfb7138e929484b26fae",
+ "worktree.remove": "49425bdf418edd0c7f95fc80f7060210e390d5f5f659efc9f47db749f3d9716b"
+}
diff --git a/tests/fixtures/endpoint-snapshot-v1.json b/tests/fixtures/endpoint-snapshot-v1.json
new file mode 100644
index 00000000..001229a3
--- /dev/null
+++ b/tests/fixtures/endpoint-snapshot-v1.json
@@ -0,0 +1,106 @@
+{
+ "boot_id": "boot-v1",
+ "revision": 7,
+ "config_diagnostic": "endpoint warning",
+ "product_announcement": {
+ "version": "1.0.0",
+ "id": "announcement-v1",
+ "title": "Announcement",
+ "body": "Body",
+ "preview": false
+ },
+ "update_available": "1.0.1",
+ "update_install_command": "herdr update",
+ "server_keybindings_toml": "[keys]",
+ "latest_release_notes_available": true,
+ "integration_updates_available": true,
+ "worktree_directory": "/worktrees",
+ "release_notes": {
+ "version": "1.0.1",
+ "body": "Notes",
+ "preview": true
+ },
+ "focused_workspace_id": "w1",
+ "focused_tab_id": "w1:t1",
+ "focused_pane_id": "w1:p1",
+ "tab_bar_right": [
+ {
+ "text": "host",
+ "accent": true
+ }
+ ],
+ "tab_bar_right_separator": " | ",
+ "agent_view_label": "focus",
+ "agent_order": ["w1:p1"],
+ "workspaces": [
+ {
+ "workspace_id": "w1",
+ "active_tab_id": "w1:t1",
+ "new_workspace_cwd": "/repo",
+ "number": 1,
+ "label": "repo",
+ "custom_label": false,
+ "branch": "main",
+ "git_ahead_behind": [1, 2],
+ "tokens": [["model", "opus"]],
+ "worktree": {
+ "key": "repo/main",
+ "label": "main",
+ "is_linked_worktree": true
+ },
+ "focused": true,
+ "agent_status": "future_status_from_new_server"
+ }
+ ],
+ "tabs": [
+ {
+ "tab_id": "w1:t1",
+ "workspace_id": "w1",
+ "number": 1,
+ "label": "main",
+ "custom_label": false,
+ "zoomed": false,
+ "focused": true,
+ "agent_status": "working"
+ }
+ ],
+ "panes": [
+ {
+ "pane_id": "w1:p1",
+ "workspace_id": "w1",
+ "tab_id": "w1:t1",
+ "label": "shell",
+ "cwd": "/repo",
+ "foreground_cwd": "/repo",
+ "focused": true,
+ "right_click_passthrough": false
+ }
+ ],
+ "agents": [
+ {
+ "pane_id": "w1:p1",
+ "workspace_id": "w1",
+ "tab_id": "w1:t1",
+ "name": "reviewer",
+ "display_agent": "Claude",
+ "agent": "claude",
+ "title": "Review",
+ "terminal_title": "Claude Review",
+ "terminal_title_stripped": "Claude Review",
+ "agent_status": "blocked",
+ "state_change_seq": 9,
+ "state_labels": [["blocked", "waiting"]],
+ "tokens": [["task", "review"]],
+ "focused": true
+ }
+ ],
+ "commands": [
+ {
+ "command_id": "command-v1",
+ "binding_label": "prefix+x",
+ "binding_labels": ["prefix+x"],
+ "action": "Shell",
+ "description": "Command"
+ }
+ ]
+}
diff --git a/tests/fixtures/endpoint-welcome-v1.json b/tests/fixtures/endpoint-welcome-v1.json
new file mode 100644
index 00000000..3d153dfa
--- /dev/null
+++ b/tests/fixtures/endpoint-welcome-v1.json
@@ -0,0 +1,11 @@
+{
+ "generation": 1,
+ "server_version": "0.8.2",
+ "snapshot_codec": "shell.snapshot.v1",
+ "surface_codec": "shell.surface.v1",
+ "input_codec": "shell.input.semantic.v1",
+ "blob_codec": "shell.blob.v1",
+ "methods": [
+ "pane.focus"
+ ]
+}
diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs
index d58fb301..50b39698 100644
--- a/tests/live_handoff.rs
+++ b/tests/live_handoff.rs
@@ -14,7 +14,7 @@ use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}
use support::{
cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid,
send_client_shell_shift_enter, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap,
- wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
+ wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_ENDPOINT_CONTROL,
SERVER_MESSAGE_SERVER_SHUTDOWN,
};
@@ -863,22 +863,17 @@ fn live_handoff_preserves_pane_process_io() {
assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0);
assert_eq!(unsafe { libc::kill(second_child_pid as libc::pid_t, 0) }, 0);
- let protocol = request(
- &api_socket,
- serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
- )["result"]["protocol"]
- .as_u64()
- .unwrap() as u32;
+ let endpoint_generation = support::CURRENT_ENDPOINT_PROTOCOL_GENERATION;
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) =
- client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
- assert_eq!(server_protocol, protocol);
+ let (server_generation, error) =
+ client_shell_handshake(&mut client_stream, endpoint_generation, 54, 23).unwrap();
+ assert_eq!(server_generation, endpoint_generation);
assert!(error.is_none(), "client shell handshake failed: {error:?}");
assert!(
wait_for_message_variant(
&mut client_stream,
Duration::from_secs(5),
- SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
+ SERVER_MESSAGE_ENDPOINT_CONTROL,
)
.unwrap(),
"client shell should receive a complete snapshot before handoff"
@@ -953,9 +948,17 @@ fn live_handoff_preserves_pane_process_io() {
wait_for_output(&api_socket, &second_pane_id, "second:after-handoff-sec");
let mut reattached_shell = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) =
- client_shell_handshake(&mut reattached_shell, protocol, 54, 23).unwrap();
- assert_eq!(server_protocol, protocol);
+ let (server_generation, error) = client_shell_handshake(
+ &mut reattached_shell,
+ support::CURRENT_ENDPOINT_PROTOCOL_GENERATION,
+ 54,
+ 23,
+ )
+ .unwrap();
+ assert_eq!(
+ server_generation,
+ support::CURRENT_ENDPOINT_PROTOCOL_GENERATION
+ );
assert!(error.is_none(), "reattached client shell failed: {error:?}");
wait_for_client_shell_bootstrap(&mut reattached_shell, Duration::from_secs(5))
.expect("fresh client shell should receive restored snapshot before pane content");
@@ -1030,12 +1033,6 @@ pathlib.Path({received:?}).write_text(data.hex())
));
support::wait_for_file(&ready_marker, Duration::from_secs(5));
- let protocol = request(
- &api_socket,
- serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
- )["result"]["protocol"]
- .as_u64()
- .unwrap() as u32;
assert_ok(request(
&api_socket,
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
@@ -1045,9 +1042,17 @@ pathlib.Path({received:?}).write_text(data.hex())
wait_for_socket(&client_socket, Duration::from_secs(5));
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) =
- client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
- assert_eq!(server_protocol, protocol);
+ let (server_generation, error) = client_shell_handshake(
+ &mut client_stream,
+ support::CURRENT_ENDPOINT_PROTOCOL_GENERATION,
+ 54,
+ 23,
+ )
+ .unwrap();
+ assert_eq!(
+ server_generation,
+ support::CURRENT_ENDPOINT_PROTOCOL_GENERATION
+ );
assert!(error.is_none(), "client shell handshake failed: {error:?}");
wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5))
.expect("client shell should receive restored state before sending input");
@@ -1124,12 +1129,6 @@ pathlib.Path({received:?}).write_text(data.hex())
));
support::wait_for_file(&ready_marker, Duration::from_secs(5));
- let protocol = request(
- &api_socket,
- serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
- )["result"]["protocol"]
- .as_u64()
- .unwrap() as u32;
assert_ok(request(
&api_socket,
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
@@ -1139,9 +1138,17 @@ pathlib.Path({received:?}).write_text(data.hex())
wait_for_socket(&client_socket, Duration::from_secs(5));
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) =
- client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
- assert_eq!(server_protocol, protocol);
+ let (server_generation, error) = client_shell_handshake(
+ &mut client_stream,
+ support::CURRENT_ENDPOINT_PROTOCOL_GENERATION,
+ 54,
+ 23,
+ )
+ .unwrap();
+ assert_eq!(
+ server_generation,
+ support::CURRENT_ENDPOINT_PROTOCOL_GENERATION
+ );
assert!(error.is_none(), "client shell handshake failed: {error:?}");
wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5))
.expect("client shell should receive restored state before sending input");
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index 83fb0b00..ae642a22 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -16,7 +16,8 @@ use support::{
cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir,
register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid,
wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_message_variants,
- CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
+ CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE,
+ SERVER_MESSAGE_PANE_SURFACE_PATCH,
};
fn unique_test_dir() -> PathBuf {
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 2c863fbe..68337ade 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -14,13 +14,14 @@ static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
pub const CURRENT_PROTOCOL: u32 = 22;
+pub const CURRENT_ENDPOINT_PROTOCOL_GENERATION: u32 = 1;
pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3;
-pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 12;
+pub const SERVER_MESSAGE_ENDPOINT_CONTROL: u32 = 20;
pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13;
pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14;
pub const SERVER_MESSAGE_PANE_SURFACE_PATCH: u32 = 19;
-const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 11;
const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13;
+const CLIENT_MESSAGE_ENDPOINT_CONTROL: u32 = 20;
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
let Some(pid) = pid else {
@@ -185,6 +186,25 @@ fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
buf
}
+fn encode_string(value: &str) -> Vec<u8> {
+ let mut encoded = encode_varint_u32(value.len() as u32);
+ encoded.extend_from_slice(value.as_bytes());
+ encoded
+}
+
+fn decode_string(payload: &[u8], offset: &mut usize) -> Result<String, String> {
+ let (len, consumed) = decode_varint_u32(payload, *offset)?;
+ *offset += consumed;
+ let len = len as usize;
+ if *offset + len > payload.len() {
+ return Err("payload too short for string content".into());
+ }
+ let value = String::from_utf8(payload[*offset..*offset + len].to_vec())
+ .map_err(|err| err.to_string())?;
+ *offset += len;
+ Ok(value)
+}
+
fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
let mut offset = 0;
let (variant, consumed) = decode_varint_u32(payload, offset)?;
@@ -225,10 +245,10 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
Ok((version, error))
}
-fn finish_handshake(
+fn read_handshake_response(
stream: &mut UnixStream,
hello_payload: &[u8],
-) -> Result<(u32, Option<String>), String> {
+) -> Result<Vec<u8>, String> {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.map_err(|e| e.to_string())?;
@@ -245,7 +265,7 @@ fn finish_handshake(
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
- decode_welcome(&payload)
+ Ok(payload)
}
pub fn client_handshake(
@@ -265,30 +285,60 @@ pub fn client_handshake(
&[0], // pixel_mouse = false
],
);
- finish_handshake(stream, &hello_payload)
+ let response = read_handshake_response(stream, &hello_payload)?;
+ decode_welcome(&response)
}
pub fn client_shell_handshake(
stream: &mut UnixStream,
- version: u32,
+ endpoint_generation: u32,
surface_cols: u16,
surface_rows: u16,
) -> Result<(u32, Option<String>), String> {
+ let data = serde_json::json!({
+ "generation": endpoint_generation,
+ "cell_width_px": 8,
+ "cell_height_px": 16,
+ "surface_size": {"cols": surface_cols, "rows": surface_rows},
+ "pixel_mouse": false,
+ "direct_graphics": false,
+ "endpoint_keybindings": false,
+ "mouse_capture": false,
+ "snapshot_codecs": ["shell.snapshot.v1"],
+ "surface_codecs": ["shell.surface.v1"],
+ "input_codecs": ["shell.input.semantic.v1"],
+ "blob_codecs": ["shell.blob.v1"]
+ })
+ .to_string();
let hello_payload = encode_varint_enum(
- CLIENT_MESSAGE_CLIENT_SHELL_HELLO,
- &[
- &encode_varint_u32(version),
- &encode_varint_u32(8),
- &encode_varint_u32(16),
- &encode_varint_u16(surface_cols),
- &encode_varint_u16(surface_rows),
- &[0], // pixel mouse disabled
- &[0], // direct graphics disabled
- &[0], // client-owned keybindings
- &[0], // mouse capture disabled
- ],
+ CLIENT_MESSAGE_ENDPOINT_CONTROL,
+ &[&encode_string("endpoint.hello.v1"), &encode_string(&data)],
);
- finish_handshake(stream, &hello_payload)
+ let response = read_handshake_response(stream, &hello_payload)?;
+ let mut offset = 0;
+ let (variant, consumed) = decode_varint_u32(&response, offset)?;
+ offset += consumed;
+ if variant != SERVER_MESSAGE_ENDPOINT_CONTROL {
+ return Err(format!(
+ "expected EndpointControl (variant {SERVER_MESSAGE_ENDPOINT_CONTROL}), got variant {variant}"
+ ));
+ }
+ let kind = decode_string(&response, &mut offset)?;
+ if kind != "endpoint.welcome.v1" {
+ return Err(format!("expected endpoint.welcome.v1, got {kind}"));
+ }
+ let data = decode_string(&response, &mut offset)?;
+ let value: serde_json::Value = serde_json::from_str(&data).map_err(|err| err.to_string())?;
+ let generation = value["generation"]
+ .as_u64()
+ .ok_or_else(|| "endpoint welcome omitted generation".to_owned())?
+ as u32;
+ let error = value["error"]
+ .as_object()
+ .and_then(|error| error.get("message"))
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_owned);
+ Ok((generation, error))
}
pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), String> {
@@ -407,7 +457,12 @@ pub fn wait_for_client_shell_bootstrap(
let mut saw_snapshot = false;
while Instant::now() < deadline {
match read_server_message(stream) {
- Ok((SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, _)) => saw_snapshot = true,
+ Ok((SERVER_MESSAGE_ENDPOINT_CONTROL, payload)) => {
+ let mut offset = 0;
+ if decode_string(&payload, &mut offset).as_deref() == Ok("shell.snapshot.v1") {
+ saw_snapshot = true;
+ }
+ }
Ok((SERVER_MESSAGE_PANE_SURFACE, _)) if saw_snapshot => return Ok(()),
Ok((SERVER_MESSAGE_PANE_SURFACE, _)) => {
return Err("client shell pane surface arrived before its snapshot".into());