From 69fe965c5f53c28e92834868a0386285876d822a Mon Sep 17 00:00:00 2001 From: James Yong Date: Sat, 22 Aug 2026 23:38:30 -0700 Subject: [PATCH 1/3] release: 0.11.0 --- CHANGELOG.md | 42 + Cargo.lock | 22 +- Cargo.toml | 2 +- .../Resources/Ghosttea-iOS.cdx.json | 94 +- .../Resources/THIRD-PARTY-NOTICES.txt | 20 +- .../ios-release-resources.lock.json | 6 +- .../Compatibility/ios-release.cdx.json | 94 +- .../ios-rust-components.lock.json | 52 +- apps/desktop-experiment/package.json | 12 +- apps/desktop/package.json | 12 +- native/ghosttea/Cargo.toml | 8 +- native/ghosttea/README.md | 43 +- .../ghosttea/crates/ghosttea-core/Cargo.toml | 4 +- .../crates/ghosttea-truffle/Cargo.toml | 4 +- native/ghosttea/crates/ghosttea-vt/Cargo.toml | 2 +- native/ghosttea/src/lib.rs | 13 +- native/ghosttea/src/service.rs | 997 ++++++++++---- native/ghosttea/src/session.rs | 10 +- native/ghosttead/Cargo.toml | 4 +- package-lock.json | 66 +- package.json | 2 +- packages/ghosttea-client/package.json | 4 +- packages/ghosttea-electron/package.json | 6 +- packages/ghosttea-frame/package.json | 2 +- packages/ghosttea-native-tabs/package.json | 2 +- packages/ghosttea-protocol/README.md | 20 + packages/ghosttea-protocol/package.json | 2 +- .../scripts/verify-vibefield-vectors.mjs | 152 +++ packages/ghosttea-protocol/src/index.ts | 2 + packages/ghosttea-protocol/src/routed.test.ts | 120 ++ packages/ghosttea-protocol/src/routed.ts | 1187 ++++++++++++++++ packages/ghosttea-react/README.md | 50 + packages/ghosttea-react/package.json | 8 +- .../ghosttea-react/src/TerminalSurface.tsx | 30 +- packages/ghosttea-react/src/index.ts | 27 +- packages/ghosttea-react/src/performance.ts | 35 + .../src/routed-activation.test.ts | 219 +++ .../ghosttea-react/src/routed-activation.ts | 373 +++++ .../ghosttea-react/src/routed-control.test.ts | 253 ++++ packages/ghosttea-react/src/routed-control.ts | 469 +++++++ .../ghosttea-react/src/routed-frames.test.ts | 575 ++++++++ packages/ghosttea-react/src/routed-frames.ts | 873 ++++++++++++ .../ghosttea-react/src/routed-runtime.test.ts | 522 +++++++ packages/ghosttea-react/src/runtime.test.ts | 53 +- packages/ghosttea-react/src/runtime.ts | 1211 ++++++++++++++++- .../src/terminal-render.worker.test.ts | 360 +++++ .../src/terminal-render.worker.ts | 242 +++- .../ghosttea-react/src/worker-messages.ts | 10 +- .../src/workspace/Workspace.tsx | 126 +- .../ghosttea-react/src/workspace/index.ts | 2 + packages/ghosttea/package.json | 4 +- packages/ghosttead-darwin-arm64/package.json | 2 +- packages/ghosttead-win32-x64/package.json | 2 +- packages/ghosttead/package.json | 6 +- 54 files changed, 7829 insertions(+), 629 deletions(-) create mode 100644 packages/ghosttea-protocol/scripts/verify-vibefield-vectors.mjs create mode 100644 packages/ghosttea-protocol/src/routed.test.ts create mode 100644 packages/ghosttea-protocol/src/routed.ts create mode 100644 packages/ghosttea-react/src/routed-activation.test.ts create mode 100644 packages/ghosttea-react/src/routed-activation.ts create mode 100644 packages/ghosttea-react/src/routed-control.test.ts create mode 100644 packages/ghosttea-react/src/routed-control.ts create mode 100644 packages/ghosttea-react/src/routed-frames.test.ts create mode 100644 packages/ghosttea-react/src/routed-frames.ts create mode 100644 packages/ghosttea-react/src/routed-runtime.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5afc0675..0f48d4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,48 @@ All notable changes to Ghosttea are documented here. The Rust and npm packages share one version. +## 0.11.0 - 2026-08-22 + +### Added + +- `ghosttea::ServiceHandle::sessions` now yields a cloneable in-process + `ServiceSessions` capability over the exact registry and frame hub served by + the legacy sockets. It supports stable session snapshots and lookup, + policy-identical spawning, and typed created/exited/removed lifecycle events. + `Session::spawn_with_private_env_prefixes` is public for hosts that must + construct a session outside the service policy path. +- `@vibecook/ghosttea-protocol` exports the TPv3 routed-terminal contract: + grants and tickets, direction-checked tagged messages, RFC 8785 canonical + grant input, close classification, scene ordering, CRC-32C, and binary + presentation-envelope framing. A compatibility verifier covers the complete + published VibeField TP fixture corpus. +- `@vibecook/ghosttea-react` gains an additive `transport: "routed"` mode. + Main owns activation authority, the pooled control WebSockets, grant renewal, + independent presentation/input leases, recovery, demand, and geometry CAS; + the render worker owns pooled frames WebSockets, envelope-before-apply and + inner-TRF identity validation, cumulative byte credits, bounded seed/catch-up + staging, atomic swaps, and negotiated resume. +- Terminal surfaces can remove input locally with `inputPolicy="read-only"` or + `readWrite={false}`, apply themes per view, and read monotonic production + rendering counters without opening a sample window or draining the GPU. + Workspace divider drags now expose a throttled live-update/final-commit seam, + and pane zoom preserves mounted canvases and activation identity. + +### Fixed + +- Focus is now scheduling state only. It cannot claim resize authority, the + runtime refuses resize calls without an explicit controller request, and a + `controlsResize={false}` surface skips observer-driven resize calls as well. + +### Compatibility + +- The existing UDS control/frame wire and port-pair renderer transport are + unchanged; port-pair remains the default rollback path. +- The published TPv3 T1 contract has no terminal-input message tag. Routed + input therefore remains closed unless a host supplies an encoder for an + extension negotiated with its cell, instead of Ghosttea inventing an + incompatible wire verb. + ## 0.10.1 - 2026-08-18 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index da413159..432dad9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,7 +648,7 @@ dependencies = [ [[package]] name = "ghosttea" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "async-trait", @@ -670,7 +670,7 @@ dependencies = [ [[package]] name = "ghosttea-apple-ffi" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ghosttea-ffi", "ghosttea-font-fixture-ffi", @@ -678,7 +678,7 @@ dependencies = [ [[package]] name = "ghosttea-config" -version = "0.10.1" +version = "0.11.0" dependencies = [ "libc", "serde", @@ -689,7 +689,7 @@ dependencies = [ [[package]] name = "ghosttea-core" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "ghosttea-text", @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "ghosttea-ffi" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ghosttea-config", "ghosttea-core", @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "ghosttea-font-fixture-ffi" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ghosttea-text", "serde_json", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "ghosttea-text" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "fontdb", @@ -734,7 +734,7 @@ dependencies = [ [[package]] name = "ghosttea-truffle" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "async-trait", @@ -755,14 +755,14 @@ dependencies = [ [[package]] name = "ghosttea-vt" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ghosttea-vt-sys", ] [[package]] name = "ghosttea-vt-sys" -version = "0.10.1" +version = "0.11.0" dependencies = [ "cc", "serde", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ghosttead" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index d9cfe5c7..687db68d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "0.10.1" +version = "0.11.0" edition = "2024" rust-version = "1.88" authors = ["James Yong"] diff --git a/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json b/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json index 22c401d1..346c5a94 100644 --- a/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json +++ b/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json @@ -8,9 +8,9 @@ "timestamp": "2026-07-18T00:00:00Z", "component": { "type": "application", - "bom-ref": "pkg:npm/ghosttea@0.10.1", + "bom-ref": "pkg:npm/ghosttea@0.11.0", "name": "Ghosttea iOS", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/ghosttea@0.10.1", + "purl": "pkg:npm/ghosttea@0.11.0", "properties": [ { "name": "ghosttea:scope", @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "0cdd2436f10568e113115d0d4bf6fd04bad2cc36a9942c5e43ef76d40834ed69" + "value": "4f90810bca895bbdf2f991d717f1f8b7937734684a0f67a0465c60f99c428030" }, { "name": "ghosttea:rust-target", @@ -160,9 +160,9 @@ "components": [ { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", "name": "ghosttea-apple-ffi", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -170,7 +170,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-apple-ffi@0.10.1" + "purl": "pkg:cargo/ghosttea-apple-ffi@0.11.0" }, { "type": "library", @@ -1314,9 +1314,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-config@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-config@0.11.0", "name": "ghosttea-config", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1324,7 +1324,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-config@0.10.1", + "purl": "pkg:cargo/ghosttea-config@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1338,9 +1338,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-core@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-core@0.11.0", "name": "ghosttea-core", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1348,7 +1348,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-core@0.10.1", + "purl": "pkg:cargo/ghosttea-core@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1362,9 +1362,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-ffi@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-ffi@0.11.0", "name": "ghosttea-ffi", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1372,7 +1372,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-ffi@0.10.1", + "purl": "pkg:cargo/ghosttea-ffi@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1386,9 +1386,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "name": "ghosttea-font-fixture-ffi", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1396,7 +1396,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1410,9 +1410,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-text@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-text@0.11.0", "name": "ghosttea-text", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1420,7 +1420,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-text@0.10.1", + "purl": "pkg:cargo/ghosttea-text@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1434,9 +1434,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", "name": "ghosttea-vt-sys", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1444,7 +1444,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "purl": "pkg:cargo/ghosttea-vt-sys@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1458,9 +1458,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-vt@0.11.0", "name": "ghosttea-vt", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1468,7 +1468,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt@0.10.1", + "purl": "pkg:cargo/ghosttea-vt@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -3293,9 +3293,9 @@ ], "dependencies": [ { - "ref": "pkg:npm/ghosttea@0.10.1", + "ref": "pkg:npm/ghosttea@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "pkg:cargo/ghosttea-apple-ffi@0.11.0", "pkg:github/openssl/openssl@8cf17aaeb4599f8af87fefd810b5b5fee90fe69e", "pkg:github/libssh2/libssh2@a312b43325e3383c865a87bb1d26cb52e3292641", "pkg:github/vibecook-dev/truffle@2cf5732bbeda8e3a2b9c3e444f471f303b6b8040", @@ -3312,10 +3312,10 @@ ] }, { - "ref": "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-ffi@0.10.1", - "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "pkg:cargo/ghosttea-ffi@0.11.0", + "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "pkg:github/ghostty-org/ghostty@f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" ] }, @@ -3542,7 +3542,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-config@0.10.1", + "ref": "pkg:cargo/ghosttea-config@0.11.0", "dependsOn": [ "pkg:cargo/libc@0.2.186", "pkg:cargo/serde@1.0.228", @@ -3552,33 +3552,33 @@ ] }, { - "ref": "pkg:cargo/ghosttea-core@0.10.1", + "ref": "pkg:cargo/ghosttea-core@0.11.0", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", - "pkg:cargo/ghosttea-text@0.10.1", - "pkg:cargo/ghosttea-vt@0.10.1", + "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-vt@0.11.0", "pkg:cargo/serde@1.0.228", "pkg:cargo/smallvec@1.15.2" ] }, { - "ref": "pkg:cargo/ghosttea-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-ffi@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-config@0.10.1", - "pkg:cargo/ghosttea-core@0.10.1", - "pkg:cargo/ghosttea-text@0.10.1", + "pkg:cargo/ghosttea-config@0.11.0", + "pkg:cargo/ghosttea-core@0.11.0", + "pkg:cargo/ghosttea-text@0.11.0", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-text@0.10.1", + "pkg:cargo/ghosttea-text@0.11.0", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-text@0.10.1", + "ref": "pkg:cargo/ghosttea-text@0.11.0", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", "pkg:cargo/fontdb@0.23.0", @@ -3592,7 +3592,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", "dependsOn": [ "pkg:cargo/cc@1.2.67", "pkg:cargo/serde@1.0.228", @@ -3603,9 +3603,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt@0.10.1", + "ref": "pkg:cargo/ghosttea-vt@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-vt-sys@0.10.1" + "pkg:cargo/ghosttea-vt-sys@0.11.0" ] }, { diff --git a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt index ee59c179..106f2550 100644 --- a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt +++ b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt @@ -2,14 +2,14 @@ GHOSTTEA iOS THIRD-PARTY NOTICES ================================ This file is generated from the reviewed iOS release dependency graph. -CycloneDX SHA-256: c2588aaf2134bebb42d19a4084ad3d1f8d1b216c1259ffb6e074892d7b1da56a +CycloneDX SHA-256: 31c78aab13813cea43214d59c9bce954c5fe021f14a5b9c70699728f097db861 Components: 108 Unique license documents: 97 COMPONENT INDEX =============== -ghosttea-apple-ffi 0.10.1 +ghosttea-apple-ffi 0.11.0 License: MIT Documents: LICENSE-081 @@ -165,31 +165,31 @@ getrandom 0.3.4 License: MIT OR Apache-2.0 Documents: LICENSE-015, LICENSE-066 -ghosttea-config 0.10.1 +ghosttea-config 0.11.0 License: MIT Documents: LICENSE-081 -ghosttea-core 0.10.1 +ghosttea-core 0.11.0 License: MIT Documents: LICENSE-081 -ghosttea-ffi 0.10.1 +ghosttea-ffi 0.11.0 License: MIT Documents: LICENSE-081 -ghosttea-font-fixture-ffi 0.10.1 +ghosttea-font-fixture-ffi 0.11.0 License: MIT Documents: LICENSE-081 -ghosttea-text 0.10.1 +ghosttea-text 0.11.0 License: MIT Documents: LICENSE-081 -ghosttea-vt-sys 0.10.1 +ghosttea-vt-sys 0.11.0 License: MIT Documents: LICENSE-081 -ghosttea-vt 0.10.1 +ghosttea-vt 0.11.0 License: MIT Documents: LICENSE-081 @@ -5645,7 +5645,7 @@ in this Software without prior written authorization of the copyright holder. ================================================================================ LICENSE-081 · LICENSE, TRUFFLE-LICENSE SHA-256: d5988fd09ab1f97d46de9b2678a4fd2950286601f0a5995e739ac926c5a8eae0 -Used by: Truffle Swift 2cf5732bbeda8e3a2b9c3e444f471f303b6b8040, ghosttea-apple-ffi 0.10.1, ghosttea-config 0.10.1, ghosttea-core 0.10.1, ghosttea-ffi 0.10.1, ghosttea-font-fixture-ffi 0.10.1, ghosttea-text 0.10.1, ghosttea-vt 0.10.1, ghosttea-vt-sys 0.10.1 +Used by: Truffle Swift 2cf5732bbeda8e3a2b9c3e444f471f303b6b8040, ghosttea-apple-ffi 0.11.0, ghosttea-config 0.11.0, ghosttea-core 0.11.0, ghosttea-ffi 0.11.0, ghosttea-font-fixture-ffi 0.11.0, ghosttea-text 0.11.0, ghosttea-vt 0.11.0, ghosttea-vt-sys 0.11.0 -------------------------------------------------------------------------------- MIT License diff --git a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json index 6ef81c07..916108d4 100644 --- a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json +++ b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "sourceBom": { "path": "apple/GhostteaKit/Compatibility/ios-release.cdx.json", - "sha256": "c2588aaf2134bebb42d19a4084ad3d1f8d1b216c1259ffb6e074892d7b1da56a" + "sha256": "31c78aab13813cea43214d59c9bce954c5fe021f14a5b9c70699728f097db861" }, "bundledBom": { "path": "apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json", - "sha256": "c2588aaf2134bebb42d19a4084ad3d1f8d1b216c1259ffb6e074892d7b1da56a" + "sha256": "31c78aab13813cea43214d59c9bce954c5fe021f14a5b9c70699728f097db861" }, "notices": { "path": "apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt", - "sha256": "b85351f091264e7f435ab64e462285563c5fa0a680360d6d81817876338a0ed5", + "sha256": "069c14d41f3c5c8eacc63197adda1a0c977aad7754f577d5396408da295dfa69", "componentCount": 108, "licenseDocumentCount": 97 } diff --git a/apple/GhostteaKit/Compatibility/ios-release.cdx.json b/apple/GhostteaKit/Compatibility/ios-release.cdx.json index 22c401d1..346c5a94 100644 --- a/apple/GhostteaKit/Compatibility/ios-release.cdx.json +++ b/apple/GhostteaKit/Compatibility/ios-release.cdx.json @@ -8,9 +8,9 @@ "timestamp": "2026-07-18T00:00:00Z", "component": { "type": "application", - "bom-ref": "pkg:npm/ghosttea@0.10.1", + "bom-ref": "pkg:npm/ghosttea@0.11.0", "name": "Ghosttea iOS", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/ghosttea@0.10.1", + "purl": "pkg:npm/ghosttea@0.11.0", "properties": [ { "name": "ghosttea:scope", @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "0cdd2436f10568e113115d0d4bf6fd04bad2cc36a9942c5e43ef76d40834ed69" + "value": "4f90810bca895bbdf2f991d717f1f8b7937734684a0f67a0465c60f99c428030" }, { "name": "ghosttea:rust-target", @@ -160,9 +160,9 @@ "components": [ { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", "name": "ghosttea-apple-ffi", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -170,7 +170,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-apple-ffi@0.10.1" + "purl": "pkg:cargo/ghosttea-apple-ffi@0.11.0" }, { "type": "library", @@ -1314,9 +1314,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-config@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-config@0.11.0", "name": "ghosttea-config", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1324,7 +1324,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-config@0.10.1", + "purl": "pkg:cargo/ghosttea-config@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1338,9 +1338,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-core@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-core@0.11.0", "name": "ghosttea-core", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1348,7 +1348,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-core@0.10.1", + "purl": "pkg:cargo/ghosttea-core@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1362,9 +1362,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-ffi@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-ffi@0.11.0", "name": "ghosttea-ffi", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1372,7 +1372,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-ffi@0.10.1", + "purl": "pkg:cargo/ghosttea-ffi@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1386,9 +1386,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "name": "ghosttea-font-fixture-ffi", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1396,7 +1396,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1410,9 +1410,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-text@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-text@0.11.0", "name": "ghosttea-text", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1420,7 +1420,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-text@0.10.1", + "purl": "pkg:cargo/ghosttea-text@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1434,9 +1434,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", "name": "ghosttea-vt-sys", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1444,7 +1444,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "purl": "pkg:cargo/ghosttea-vt-sys@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -1458,9 +1458,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt@0.10.1", + "bom-ref": "pkg:cargo/ghosttea-vt@0.11.0", "name": "ghosttea-vt", - "version": "0.10.1", + "version": "0.11.0", "licenses": [ { "license": { @@ -1468,7 +1468,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt@0.10.1", + "purl": "pkg:cargo/ghosttea-vt@0.11.0", "properties": [ { "name": "ghosttea:cargo-source", @@ -3293,9 +3293,9 @@ ], "dependencies": [ { - "ref": "pkg:npm/ghosttea@0.10.1", + "ref": "pkg:npm/ghosttea@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "pkg:cargo/ghosttea-apple-ffi@0.11.0", "pkg:github/openssl/openssl@8cf17aaeb4599f8af87fefd810b5b5fee90fe69e", "pkg:github/libssh2/libssh2@a312b43325e3383c865a87bb1d26cb52e3292641", "pkg:github/vibecook-dev/truffle@2cf5732bbeda8e3a2b9c3e444f471f303b6b8040", @@ -3312,10 +3312,10 @@ ] }, { - "ref": "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-ffi@0.10.1", - "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "pkg:cargo/ghosttea-ffi@0.11.0", + "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "pkg:github/ghostty-org/ghostty@f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" ] }, @@ -3542,7 +3542,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-config@0.10.1", + "ref": "pkg:cargo/ghosttea-config@0.11.0", "dependsOn": [ "pkg:cargo/libc@0.2.186", "pkg:cargo/serde@1.0.228", @@ -3552,33 +3552,33 @@ ] }, { - "ref": "pkg:cargo/ghosttea-core@0.10.1", + "ref": "pkg:cargo/ghosttea-core@0.11.0", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", - "pkg:cargo/ghosttea-text@0.10.1", - "pkg:cargo/ghosttea-vt@0.10.1", + "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-vt@0.11.0", "pkg:cargo/serde@1.0.228", "pkg:cargo/smallvec@1.15.2" ] }, { - "ref": "pkg:cargo/ghosttea-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-ffi@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-config@0.10.1", - "pkg:cargo/ghosttea-core@0.10.1", - "pkg:cargo/ghosttea-text@0.10.1", + "pkg:cargo/ghosttea-config@0.11.0", + "pkg:cargo/ghosttea-core@0.11.0", + "pkg:cargo/ghosttea-text@0.11.0", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-text@0.10.1", + "pkg:cargo/ghosttea-text@0.11.0", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-text@0.10.1", + "ref": "pkg:cargo/ghosttea-text@0.11.0", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", "pkg:cargo/fontdb@0.23.0", @@ -3592,7 +3592,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", "dependsOn": [ "pkg:cargo/cc@1.2.67", "pkg:cargo/serde@1.0.228", @@ -3603,9 +3603,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt@0.10.1", + "ref": "pkg:cargo/ghosttea-vt@0.11.0", "dependsOn": [ - "pkg:cargo/ghosttea-vt-sys@0.10.1" + "pkg:cargo/ghosttea-vt-sys@0.11.0" ] }, { diff --git a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json index 40366100..1e79badd 100644 --- a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json +++ b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "target": "aarch64-apple-ios", "root": { - "ref": "pkg:cargo/ghosttea-apple-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", "name": "ghosttea-apple-ffi", - "version": "0.10.1", + "version": "0.11.0", "dependencies": [ - "pkg:cargo/ghosttea-ffi@0.10.1", - "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1" + "pkg:cargo/ghosttea-ffi@0.11.0", + "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0" ] }, - "cargoLockSha256": "0cdd2436f10568e113115d0d4bf6fd04bad2cc36a9942c5e43ef76d40834ed69", + "cargoLockSha256": "4f90810bca895bbdf2f991d717f1f8b7937734684a0f67a0465c60f99c428030", "components": [ { "ref": "pkg:cargo/anyhow@1.0.103", @@ -358,9 +358,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-config@0.10.1", + "ref": "pkg:cargo/ghosttea-config@0.11.0", "name": "ghosttea-config", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ @@ -372,47 +372,47 @@ ] }, { - "ref": "pkg:cargo/ghosttea-core@0.10.1", + "ref": "pkg:cargo/ghosttea-core@0.11.0", "name": "ghosttea-core", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ "pkg:cargo/anyhow@1.0.103", - "pkg:cargo/ghosttea-text@0.10.1", - "pkg:cargo/ghosttea-vt@0.10.1", + "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-vt@0.11.0", "pkg:cargo/serde@1.0.228", "pkg:cargo/smallvec@1.15.2" ] }, { - "ref": "pkg:cargo/ghosttea-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-ffi@0.11.0", "name": "ghosttea-ffi", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ - "pkg:cargo/ghosttea-config@0.10.1", - "pkg:cargo/ghosttea-core@0.10.1", - "pkg:cargo/ghosttea-text@0.10.1", + "pkg:cargo/ghosttea-config@0.11.0", + "pkg:cargo/ghosttea-core@0.11.0", + "pkg:cargo/ghosttea-text@0.11.0", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.10.1", + "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", "name": "ghosttea-font-fixture-ffi", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ - "pkg:cargo/ghosttea-text@0.10.1", + "pkg:cargo/ghosttea-text@0.11.0", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-text@0.10.1", + "ref": "pkg:cargo/ghosttea-text@0.11.0", "name": "ghosttea-text", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ @@ -428,9 +428,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt-sys@0.10.1", + "ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", "name": "ghosttea-vt-sys", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ @@ -443,13 +443,13 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt@0.10.1", + "ref": "pkg:cargo/ghosttea-vt@0.11.0", "name": "ghosttea-vt", - "version": "0.10.1", + "version": "0.11.0", "source": "workspace", "license": "MIT", "dependencies": [ - "pkg:cargo/ghosttea-vt-sys@0.10.1" + "pkg:cargo/ghosttea-vt-sys@0.11.0" ] }, { diff --git a/apps/desktop-experiment/package.json b/apps/desktop-experiment/package.json index 8a3a3ff4..ef8f649e 100644 --- a/apps/desktop-experiment/package.json +++ b/apps/desktop-experiment/package.json @@ -1,6 +1,6 @@ { "name": "ghosttea-desktop-experiment", - "version": "0.10.1", + "version": "0.11.0", "description": "An isolated Ghosttea desktop experimentation application.", "author": "James Yong", "license": "MIT", @@ -23,11 +23,11 @@ "test": "vitest run" }, "dependencies": { - "@vibecook/ghosttea": "0.10.1", - "@vibecook/ghosttea-electron": "0.10.1", - "@vibecook/ghosttea-frame": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1", - "@vibecook/ghosttea-react": "0.10.1", + "@vibecook/ghosttea": "0.11.0", + "@vibecook/ghosttea-electron": "0.11.0", + "@vibecook/ghosttea-frame": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0", + "@vibecook/ghosttea-react": "0.11.0", "react": "19.2.7", "react-dom": "19.2.7" }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 228344fb..f8e95766 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "ghosttea-demo", - "version": "0.10.1", + "version": "0.11.0", "description": "The Ghosttea desktop example application.", "author": "James Yong", "license": "MIT", @@ -23,11 +23,11 @@ "test": "vitest run" }, "dependencies": { - "@vibecook/ghosttea": "0.10.1", - "@vibecook/ghosttea-electron": "0.10.1", - "@vibecook/ghosttea-frame": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1", - "@vibecook/ghosttea-react": "0.10.1", + "@vibecook/ghosttea": "0.11.0", + "@vibecook/ghosttea-electron": "0.11.0", + "@vibecook/ghosttea-frame": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0", + "@vibecook/ghosttea-react": "0.11.0", "react": "19.2.7", "react-dom": "19.2.7" }, diff --git a/native/ghosttea/Cargo.toml b/native/ghosttea/Cargo.toml index ffc62917..0a22e300 100644 --- a/native/ghosttea/Cargo.toml +++ b/native/ghosttea/Cargo.toml @@ -14,10 +14,10 @@ anyhow.workspace = true async-trait.workspace = true bytes.workspace = true portable-pty = "0.9" -ghosttea-vt = { version = "0.10.1", path = "crates/ghosttea-vt" } -ghosttea-config = { version = "0.10.1", path = "crates/ghosttea-config" } -ghosttea-core = { version = "0.10.1", path = "crates/ghosttea-core" } -ghosttea-text = { version = "0.10.1", path = "crates/ghosttea-text" } +ghosttea-vt = { version = "0.11.0", path = "crates/ghosttea-vt" } +ghosttea-config = { version = "0.11.0", path = "crates/ghosttea-config" } +ghosttea-core = { version = "0.11.0", path = "crates/ghosttea-core" } +ghosttea-text = { version = "0.11.0", path = "crates/ghosttea-text" } serde.workspace = true serde_json.workspace = true subtle.workspace = true diff --git a/native/ghosttea/README.md b/native/ghosttea/README.md index fb3116ed..d3cf94c0 100644 --- a/native/ghosttea/README.md +++ b/native/ghosttea/README.md @@ -59,12 +59,49 @@ the `serve()` future stops accepting traffic and aborts the terminal mesh task, but it is not a graceful session-drain API. A host that needs classified shutdown events should terminate its sessions before cancellation. +### In-process session access + +An embedding host that serves an additional transport beside Ghosttea's local +sockets can use `serve_managed` to reach the exact same registry and frame hub: + +```rust,ignore +let (service_handle, serving) = service.serve_managed(listeners); +let serving = tokio::spawn(serving); + +// The serving future must be polled before readiness can complete. +let sessions = service_handle.sessions().await?; +let mut lifecycle = sessions.subscribe_lifecycle(); + +let session = sessions.spawn(spawn_options).await?; +let same_session = sessions.session(&session.id()).expect("registered session"); +assert!(std::sync::Arc::ptr_eq(&session, &same_session)); + +let (mut frames, baseline_ordinal) = sessions.frames().subscribe(); +session.attach_view("door-view", "door-client")?; +let packet = frames.recv().await?; +assert!(packet.ordinal > baseline_ordinal); + +service_handle.shutdown(shutdown_timeout).await?; +serving.await??; +``` + +`ServiceSessions::spawn` is the service policy path, not a parallel registry: +it applies configured private-environment stripping, scrollback and appearance, +shutdown and owner fencing, persistence, tombstones, socket-visible events, and +typed lifecycle events. `Created`, `Exited`, and `Removed` are independent +facts and may arrive in either exit/removal order. A lagged lifecycle receiver +should reconcile with `sessions()`; `snapshot_and_subscribe()` provides the +subscribe-before-snapshot pattern for that fold. + +Use public `Session::spawn_with_private_env_prefixes` only when the embedder +deliberately owns those service policies itself. + ## Local endpoints by platform -| Platform | Control | Frames | -| --- | --- | --- | +| Platform | Control | Frames | +| ------------ | ------------------ | ------------------------- | | macOS, Linux | Unix-domain socket | second Unix-domain socket | -| Windows | named pipe | second named pipe | +| Windows | named pipe | second named pipe | Windows pipe names share one flat, machine-wide namespace instead of sitting under a private directory, so each name carries an instance suffix and the diff --git a/native/ghosttea/crates/ghosttea-core/Cargo.toml b/native/ghosttea/crates/ghosttea-core/Cargo.toml index 95cdac50..d68a89a6 100644 --- a/native/ghosttea/crates/ghosttea-core/Cargo.toml +++ b/native/ghosttea/crates/ghosttea-core/Cargo.toml @@ -11,8 +11,8 @@ readme = "README.md" [dependencies] anyhow.workspace = true -ghosttea-text = { version = "0.10.1", path = "../ghosttea-text" } -ghosttea-vt = { version = "0.10.1", path = "../ghosttea-vt" } +ghosttea-text = { version = "0.11.0", path = "../ghosttea-text" } +ghosttea-vt = { version = "0.11.0", path = "../ghosttea-vt" } serde.workspace = true smallvec.workspace = true diff --git a/native/ghosttea/crates/ghosttea-truffle/Cargo.toml b/native/ghosttea/crates/ghosttea-truffle/Cargo.toml index 665814bb..9a88cec3 100644 --- a/native/ghosttea/crates/ghosttea-truffle/Cargo.toml +++ b/native/ghosttea/crates/ghosttea-truffle/Cargo.toml @@ -22,8 +22,8 @@ required-features = ["interop-fixture"] [dependencies] anyhow.workspace = true async-trait.workspace = true -ghosttea = { version = "0.10.1", path = "../.." } -ghosttea-text = { version = "0.10.1", path = "../ghosttea-text" } +ghosttea = { version = "0.11.0", path = "../.." } +ghosttea-text = { version = "0.11.0", path = "../ghosttea-text" } rand.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/native/ghosttea/crates/ghosttea-vt/Cargo.toml b/native/ghosttea/crates/ghosttea-vt/Cargo.toml index 0540d43c..66d7cfcb 100644 --- a/native/ghosttea/crates/ghosttea-vt/Cargo.toml +++ b/native/ghosttea/crates/ghosttea-vt/Cargo.toml @@ -10,4 +10,4 @@ description = "Safe Rust adapter for Ghosttea's Ghostty VT core" readme = "README.md" [dependencies] -ghosttea-vt-sys = { version = "0.10.1", path = "../ghosttea-vt-sys" } +ghosttea-vt-sys = { version = "0.11.0", path = "../ghosttea-vt-sys" } diff --git a/native/ghosttea/src/lib.rs b/native/ghosttea/src/lib.rs index e9a3e5f8..1580976e 100644 --- a/native/ghosttea/src/lib.rs +++ b/native/ghosttea/src/lib.rs @@ -36,12 +36,13 @@ pub use mesh::{ pub use replica::RemoteReplica; pub use service::Registry as SessionRegistry; pub use service::{ - DrainReport, ReadyInfo, ServiceHandle, TerminalService, TerminalServiceConfig, - TerminalServiceListeners, + DrainReport, ReadyInfo, ServiceHandle, ServiceSessions, SessionLifecycleEvent, TerminalService, + TerminalServiceConfig, TerminalServiceListeners, }; pub use session::{ - AutomationInputOperation, AutomationInputResult, ExitOutcome, Session, SessionActivity, - SessionActivityConfidence, SessionActivityKind, SessionActivitySource, SessionEndCause, - SessionEndEvidence, SessionEnvironment, SessionExit, SessionProgramKind, SessionStatus, - SessionSummary, SessionTombstone, SessionTombstones, TerminationSource, TombstoneClock, + AutomationInputOperation, AutomationInputResult, ExitCallback, ExitOutcome, Persistence, + Session, SessionActivity, SessionActivityConfidence, SessionActivityKind, + SessionActivitySource, SessionEndCause, SessionEndEvidence, SessionEnvironment, SessionExit, + SessionProgramKind, SessionStatus, SessionSummary, SessionTombstone, SessionTombstones, + SpawnOptions, TerminationSource, TombstoneClock, }; diff --git a/native/ghosttea/src/service.rs b/native/ghosttea/src/service.rs index bd7fc5aa..4e3d60be 100644 --- a/native/ghosttea/src/service.rs +++ b/native/ghosttea/src/service.rs @@ -905,6 +905,27 @@ impl From<&mesh::RemoteViewRecord> for ViewStateRecord { pub type Registry = Arc>>>; +/// A local session's lifecycle as observed by an embedding host. +/// +/// Registry membership and process lifetime are deliberately separate: an +/// explicitly closed session can be removed while its process ladder is still +/// running, while a `keep-until-explicit-close` session remains registered +/// after it exits. Consumers must therefore handle `Removed` and `Exited` as +/// independent facts rather than assuming one fixed ordering between them. +#[derive(Clone)] +pub enum SessionLifecycleEvent { + Created { + session: Arc, + }, + Exited { + session_id: String, + exit: session::SessionExit, + }, + Removed { + session_id: String, + }, +} + /// What the host answers when a resuming viewer asks after a session id. /// /// Only the verdict crosses the seam — live, ended with a cause, or unknown. @@ -927,6 +948,7 @@ struct ControlContext { registry: Registry, frames: FrameHub, event_tx: broadcast::Sender, + lifecycle_tx: broadcast::Sender, text_engine: Arc>, mesh_runtime: Arc, /// Why each session left, for the viewers that were not watching when it @@ -944,6 +966,138 @@ struct ControlContext { shutdown: Arc, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ServiceSessionsStatus { + Starting, + Ready, + Stopped, +} + +struct ServiceSessionsShared { + status: watch::Sender, + context: Mutex>>, +} + +impl ServiceSessionsShared { + fn new() -> Arc { + let (status, _) = watch::channel(ServiceSessionsStatus::Starting); + Arc::new(Self { + status, + context: Mutex::new(None), + }) + } + + fn install(&self, context: Arc) { + *self.context.lock().unwrap() = Some(context); + self.status.send_replace(ServiceSessionsStatus::Ready); + } + + fn stop(&self) { + self.status.send_replace(ServiceSessionsStatus::Stopped); + } + + fn is_ready(&self) -> bool { + *self.status.borrow() == ServiceSessionsStatus::Ready + } +} + +struct ServiceSessionsRunGuard(Arc); + +impl Drop for ServiceSessionsRunGuard { + fn drop(&mut self) { + self.0.stop(); + } +} + +/// Ready, cloneable in-process access to the exact local session set served by +/// [`TerminalService`]. +/// +/// This is intentionally narrower than [`Registry`]: callers can inspect +/// sessions but cannot insert or remove entries behind the service's shutdown, +/// tombstone, ownership, configuration, and lifecycle policy. +#[derive(Clone)] +pub struct ServiceSessions { + context: Arc, + shared: Arc, +} + +impl ServiceSessions { + /// Look up a currently registered local session by its stable session id. + pub fn session(&self, id: &str) -> Option> { + self.context.registry.read().unwrap().get(id).cloned() + } + + /// Snapshot the currently registered local sessions in stable id order. + /// + /// An `Arc` is a capability, not proof of continuing registry + /// membership; a returned session can be removed immediately afterward. + pub fn sessions(&self) -> Vec> { + let mut sessions = self + .context + .registry + .read() + .unwrap() + .values() + .cloned() + .collect::>(); + sessions.sort_by_key(|session| session.id()); + sessions + } + + /// The service-wide frame hub used by every local session and by the frame + /// socket. Packets carry `session_handle`; consumers select the sessions + /// they need while retaining the same global ordinal stream as the socket. + pub fn frames(&self) -> FrameHub { + self.context.frames.clone() + } + + /// Return the service-wide hub only when `id` is currently registered. + /// This convenience shape lets per-session source traits preserve their + /// `Option` contract without pretending the service owns one hub per + /// session. + pub fn frames_for(&self, id: &str) -> Option { + self.context + .registry + .read() + .unwrap() + .contains_key(id) + .then(|| self.context.frames.clone()) + } + + /// Spawn through the service's complete policy path. + /// + /// Private-environment stripping, live configuration, admission/shutdown + /// fencing, tombstones, registry insertion, and lifecycle announcements are + /// identical to a `create-session` received over the control socket. + pub async fn spawn(&self, options: SpawnOptions) -> Result> { + if !self.shared.is_ready() { + bail!("terminal service is no longer serving"); + } + create_local_session(&self.context, options).await + } + + /// Subscribe to typed local lifecycle events. + /// + /// The channel is bounded and may report `Lagged`; callers should then + /// reconcile with [`ServiceSessions::sessions`]. + pub fn subscribe_lifecycle(&self) -> broadcast::Receiver { + self.context.lifecycle_tx.subscribe() + } + + /// Subscribe first, then snapshot the registry, so no birth or removal can + /// fall between the two operations. An event may also describe a session in + /// the snapshot; consumers should reconcile idempotently by session id. + pub fn snapshot_and_subscribe( + &self, + ) -> ( + Vec>, + broadcast::Receiver, + ) { + let events = self.subscribe_lifecycle(); + (self.sessions(), events) + } +} + /// Sessions that have been told to end but are no longer registry-resident. /// /// The invariant this exists for: **a session whose ladder has started and @@ -1216,12 +1370,24 @@ impl TerminalService { ) -> (ServiceHandle, impl Future>) { let (requests, shutdown_rx) = tokio::sync::mpsc::channel(1); let shutdown: Arc = Arc::default(); + let sessions = ServiceSessionsShared::new(); + let serving_sessions = Arc::clone(&sessions); + let serving_shutdown = Arc::clone(&shutdown); + // Constructed outside the async body so dropping an unpolled serving + // future still wakes `ServiceHandle::sessions` with `Stopped`. + let run_guard = ServiceSessionsRunGuard(Arc::clone(&serving_sessions)); + let serving = async move { + let _run_guard = run_guard; + self.serve_until_shutdown(listeners, shutdown_rx, serving_shutdown, serving_sessions) + .await + }; ( ServiceHandle { requests, shutdown: Arc::clone(&shutdown), + sessions, }, - self.serve_until_shutdown(listeners, shutdown_rx, shutdown), + serving, ) } @@ -1230,6 +1396,7 @@ impl TerminalService { listeners: TerminalServiceListeners, mut shutdown_rx: tokio::sync::mpsc::Receiver, shutdown: Arc, + service_sessions: Arc, ) -> Result<()> { let configured_text_engine = self.text_engine; let config = ConfigManager::load(self.config_load_options); @@ -1240,6 +1407,7 @@ impl TerminalService { let TerminalServiceListeners { control, frames } = listeners; let frame_hub = FrameHub::new(32); let (event_tx, _) = broadcast::channel::(EVENT_CHANNEL_CAPACITY); + let (lifecycle_tx, _) = broadcast::channel::(EVENT_CHANNEL_CAPACITY); let registry: Registry = Arc::new(RwLock::new(HashMap::new())); let activity_registry = Arc::clone(®istry); let _activity_sampler_task = TaskGuard(tokio::spawn(async move { @@ -1373,10 +1541,11 @@ impl TerminalService { } })) }); - let context = ControlContext { + let context = Arc::new(ControlContext { registry: Arc::clone(®istry), frames: frame_hub.clone(), event_tx, + lifecycle_tx, text_engine, mesh_runtime, tombstones, @@ -1389,13 +1558,19 @@ impl TerminalService { config_update_lock: Arc::new(Mutex::new(())), host_config_tx, shutdown: Arc::clone(&shutdown), - }; + }); + // Publish in-process access before either listener begins accepting, so + // a host that awaited `ServiceHandle::sessions` cannot miss a birth. + service_sessions.install(Arc::clone(&context)); // Spawned rather than joined in place: the drain has to run *while* // both keep serving, so observers can watch `terminate`, `list-sessions` // and events for the whole of it. A select!-cancel shape would silence // exactly the connections that need to see the drain happen. - let mut control_task = - tokio::spawn(serve_control(control, auth_token.clone(), context.clone())); + let mut control_task = tokio::spawn(serve_control( + control, + auth_token.clone(), + context.as_ref().clone(), + )); let mut frame_task = tokio::spawn(serve_frames(frames, auth_token, frame_hub)); let finish = |result: std::result::Result, tokio::task::JoinError>| match result { @@ -1460,9 +1635,44 @@ struct ShutdownRequest { pub struct ServiceHandle { requests: tokio::sync::mpsc::Sender, shutdown: Arc, + sessions: Arc, } impl ServiceHandle { + /// Wait until the managed service has initialized its session registry, + /// frame hub, text engine, configuration, and lifecycle bus. + /// + /// `serve_managed` returns an inert future; the caller must poll or spawn + /// that future before awaiting this method. If serving stops before it + /// becomes ready, this returns an error instead of hanging indefinitely. + pub async fn sessions(&self) -> Result { + let mut status = self.sessions.status.subscribe(); + loop { + let current = *status.borrow_and_update(); + match current { + ServiceSessionsStatus::Starting => { + status + .changed() + .await + .context("terminal service stopped before session access became ready")?; + } + ServiceSessionsStatus::Ready => { + let context = + self.sessions.context.lock().unwrap().clone().context( + "terminal service published readiness without session access", + )?; + return Ok(ServiceSessions { + context, + shared: Arc::clone(&self.sessions), + }); + } + ServiceSessionsStatus::Stopped => { + bail!("terminal service stopped before session access became ready"); + } + } + } + } + /// How many terminated-but-unregistered sessions are currently tracked. /// /// How many tracked sessions are still *alive* — the number that would @@ -1951,6 +2161,248 @@ fn live_sessions(context: &ControlContext) -> Vec> { live.into_values().collect() } +/// The one local-session creation path for both the control socket and an +/// in-process [`ServiceSessions`] caller. +async fn create_local_session( + context: &ControlContext, + options: SpawnOptions, +) -> Result> { + validate_grid(options.cols, options.rows)?; + validate_owner(options.owner_id.as_deref())?; + // Count first, then check — the order is the guarantee. A create that + // reads the flag as open before being descheduled has already made itself + // visible, so a shutdown that observes zero in-flight creates knows none + // can still appear. + let _in_flight = CreateInFlight::enter(&context.shutdown); + if context.shutdown.admissions_closed() { + bail!("service is shutting down"); + } + if let Some(owner) = options.owner_id.as_deref() + && context.closed_owners.lock().await.contains(owner) + { + bail!("session owner is already closed"); + } + + let registry_on_exit = Arc::clone(&context.registry); + let tombstones_on_exit = Arc::clone(&context.tombstones); + let events_on_exit = context.event_tx.clone(); + let lifecycle_on_exit = context.lifecycle_tx.clone(); + let (session_exit_tx, mut control_exit) = watch::channel(false); + let mut activity_exit = session_exit_tx.subscribe(); + let on_exit: ExitCallback = Arc::new(move |session_id, exit| { + session_exit_tx.send_replace(true); + let removed = { + // Read the class under the same lock the removal takes, and the + // same one `set-persistence` writes under: a set that returned + // success before this point is the value that decides retention. + let mut registry = registry_on_exit.write().unwrap(); + let retain = registry + .get(&session_id) + .and_then(|session| session.persistence()) + .is_some_and(|persistence| persistence == Persistence::KeepUntilExplicitClose); + !retain + && tombstones_on_exit + .remove_with_cause_locked(&mut registry, &session_id) + .is_some() + }; + let _ = events_on_exit.send(json!({ + "requestId": 0, + "type": "session-exited", + "sessionId": session_id, + "exitCode": exit.exit_code, + "exitSignal": exit.exit_signal, + "requestedTermination": exit.requested_termination, + "exitOutcome": exit.exit_outcome, + })); + let _ = lifecycle_on_exit.send(SessionLifecycleEvent::Exited { + session_id: session_id.clone(), + exit, + }); + if removed { + let _ = lifecycle_on_exit.send(SessionLifecycleEvent::Removed { session_id }); + } + }); + + // openpty plus fork/exec is blocking work; keep it off the async workers so + // concurrent creates do not stall the runtime. + let session = { + let frames = context.frames.clone(); + let text_engine = Arc::clone(&context.text_engine); + let private_env_prefixes = Arc::clone(&context.private_env_prefixes); + let terminal_config = context.config.snapshot().terminal.clone(); + let effective_scrollback_bytes = options + .scrollback_bytes + .unwrap_or(terminal_config.scrollback_bytes); + let scrollback_bytes = usize::try_from(effective_scrollback_bytes) + .context("scrollbackBytes does not fit this platform")?; + tokio::task::spawn_blocking(move || { + let session = Session::spawn_configured( + options, + frames, + text_engine, + &private_env_prefixes, + scrollback_bytes, + on_exit, + )?; + let palette = terminal_config + .palette + .iter() + .map(|entry| (entry.index, entry.color)) + .collect::>(); + session.set_appearance( + terminal_config.foreground, + terminal_config.background, + terminal_config.cursor, + &palette, + )?; + Ok::<_, anyhow::Error>(session) + }) + .await + .context("session spawn task stopped")?? + }; + + let summary = session.summary(); + let mut controls = session.subscribe_control(); + let mut activities = session.subscribe_activity(); + let control_session_id = summary.id.clone(); + let activity_session_id = summary.id.clone(); + let control_events = context.event_tx.clone(); + let activity_events = context.event_tx.clone(); + let control_session = Arc::downgrade(&session); + let activity_session = Arc::downgrade(&session); + tokio::spawn(async move { + loop { + tokio::select! { + changed = control_exit.changed() => { + if changed.is_err() || *control_exit.borrow() { + break; + } + } + changed = controls.recv() => match changed { + Ok(changed) => { + let _ = control_events.send(json!({ + "requestId": 0, + "type": "control-changed", + "sessionId": control_session_id, + "controllerViewId": changed.controller.view_id, + "controlEpoch": changed.controller.control_epoch, + "cols": changed.cols, + "rows": changed.rows, + "layoutEpoch": changed.layout_epoch, + })); + } + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(session) = control_session.upgrade() else { + break; + }; + let (controller, cols, rows, layout_epoch) = session.control_state(); + let Some(controller) = controller else { + continue; + }; + let _ = control_events.send(json!({ + "requestId": 0, + "type": "control-changed", + "sessionId": control_session_id, + "controllerViewId": controller.view_id, + "controlEpoch": controller.control_epoch, + "cols": cols, + "rows": rows, + "layoutEpoch": layout_epoch, + })); + } + Err(broadcast::error::RecvError::Closed) => break, + }, + } + } + }); + tokio::spawn(async move { + loop { + let activity = tokio::select! { + changed = activity_exit.changed() => { + if changed.is_err() || *activity_exit.borrow() { + break; + } + continue; + } + activity = activities.recv() => match activity { + Ok(activity) => activity, + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(session) = activity_session.upgrade() else { + break; + }; + session.summary().activity + } + Err(broadcast::error::RecvError::Closed) => break, + }, + }; + let _ = activity_events.send(json!({ + "requestId": 0, + "type": "session-activity-changed", + "sessionId": activity_session_id, + "activity": activity, + })); + } + }); + + // Re-check owner closure while holding the transaction lock across insert. + { + let owner_lifecycle = context.closed_owners.lock().await; + if summary + .owner_id + .as_deref() + .is_some_and(|owner| owner_lifecycle.contains(owner)) + { + drop(owner_lifecycle); + let _ = context + .shutdown + .terminate_tracked(&session, TerminationSource::User, None); + bail!("session owner is already closed"); + } + if context.shutdown.admissions_closed() { + let deadline = *context.shutdown.deadline.lock().unwrap(); + drop(owner_lifecycle); + let _ = context.shutdown.terminate_tracked( + &session, + TerminationSource::ServiceShutdown, + deadline, + ); + bail!("service is shutting down"); + } + context + .registry + .write() + .unwrap() + .insert(summary.id.clone(), Arc::clone(&session)); + } + + // A child that died during spawn leaves nothing to keep unless its class + // says otherwise. Read that class under the same lock as the removal. + let retained = { + let mut registry = context.registry.write().unwrap(); + let retain = !session.has_exited() + || session.persistence() == Some(Persistence::KeepUntilExplicitClose); + if !retain { + let removed = context + .tombstones + .remove_with_cause_locked(&mut registry, &summary.id) + .is_some(); + if removed { + let _ = context.lifecycle_tx.send(SessionLifecycleEvent::Removed { + session_id: summary.id.clone(), + }); + } + } + retain + }; + if retained { + announce_session_created(&context.event_tx, &summary); + let _ = context.lifecycle_tx.send(SessionLifecycleEvent::Created { + session: Arc::clone(&session), + }); + } + Ok(session) +} + async fn handle_command( command: Envelope, client_id: &str, @@ -1959,7 +2411,6 @@ async fn handle_command( ) -> ResponseEnvelope { let request_id = command.request_id; let registry = &context.registry; - let event_tx = &context.event_tx; let result: Result = async { match command.command { Command::Hello { @@ -2029,244 +2480,8 @@ async fn handle_command( } } Command::CreateSession { options } => { - validate_grid(options.cols, options.rows)?; - validate_owner(options.owner_id.as_deref())?; - // Count first, then check — the order is the guarantee. A - // create that reads the flag as open before being descheduled - // has already made itself visible, so a shutdown that observes - // zero in-flight creates knows none can still appear. Checking - // first would let a create slip between the read and the count - // and fork a child after the drain had already resolved. - let _in_flight = CreateInFlight::enter(&context.shutdown); - if context.shutdown.admissions_closed() { - bail!("service is shutting down"); - } - if let Some(owner) = options.owner_id.as_deref() - && context.closed_owners.lock().await.contains(owner) - { - bail!("session owner is already closed"); - } - let registry_on_exit = Arc::clone(registry); - let tombstones_on_exit = Arc::clone(&context.tombstones); - let events_on_exit = event_tx.clone(); - let (session_exit_tx, mut control_exit) = watch::channel(false); - let mut activity_exit = session_exit_tx.subscribe(); - let on_exit: ExitCallback = Arc::new(move |session_id, exit| { - session_exit_tx.send_replace(true); - { - // Read the class under the same lock the removal takes, - // and the same one `set-persistence` writes under: a set - // that returned success before this point is the value - // that decides retention, never one sampled earlier. - let mut registry = registry_on_exit.write().unwrap(); - let retain = registry - .get(&session_id) - .and_then(|session| session.persistence()) - .is_some_and(|persistence| { - persistence == Persistence::KeepUntilExplicitClose - }); - if !retain { - // Through the choke point, holding the guard we - // already have: the unlocked variant would take it - // again and deadlock here. - tombstones_on_exit - .remove_with_cause_locked(&mut registry, &session_id); - } - } - let _ = events_on_exit.send(json!({ - "requestId": 0, - "type": "session-exited", - "sessionId": session_id, - "exitCode": exit.exit_code, - "exitSignal": exit.exit_signal, - "requestedTermination": exit.requested_termination, - "exitOutcome": exit.exit_outcome, - })); - }); - // openpty plus fork/exec is blocking work; keep it off the - // async workers so concurrent creates don't stall the runtime. - let session = { - let frames = context.frames.clone(); - let text_engine = Arc::clone(&context.text_engine); - let private_env_prefixes = Arc::clone(&context.private_env_prefixes); - let terminal_config = context.config.snapshot().terminal.clone(); - let effective_scrollback_bytes = options - .scrollback_bytes - .unwrap_or(terminal_config.scrollback_bytes); - let scrollback_bytes = usize::try_from(effective_scrollback_bytes) - .context("scrollbackBytes does not fit this platform")?; - tokio::task::spawn_blocking(move || { - let session = Session::spawn_configured( - options, - frames, - text_engine, - &private_env_prefixes, - scrollback_bytes, - on_exit, - )?; - let palette = terminal_config - .palette - .iter() - .map(|entry| (entry.index, entry.color)) - .collect::>(); - session.set_appearance( - terminal_config.foreground, - terminal_config.background, - terminal_config.cursor, - &palette, - )?; - Ok::<_, anyhow::Error>(session) - }) - .await - .context("session spawn task stopped")?? - }; + let session = create_local_session(context, options).await?; let summary = session.summary(); - let mut controls = session.subscribe_control(); - let mut activities = session.subscribe_activity(); - let control_session_id = summary.id.clone(); - let activity_session_id = summary.id.clone(); - let control_events = event_tx.clone(); - let activity_events = event_tx.clone(); - let control_session = Arc::downgrade(&session); - let activity_session = Arc::downgrade(&session); - tokio::spawn(async move { - loop { - tokio::select! { - changed = control_exit.changed() => { - if changed.is_err() || *control_exit.borrow() { - break; - } - } - changed = controls.recv() => match changed { - Ok(changed) => { - let _ = control_events.send(json!({ - "requestId": 0, - "type": "control-changed", - "sessionId": control_session_id, - "controllerViewId": changed.controller.view_id, - "controlEpoch": changed.controller.control_epoch, - "cols": changed.cols, - "rows": changed.rows, - "layoutEpoch": changed.layout_epoch, - })); - } - Err(broadcast::error::RecvError::Lagged(_)) => { - // Skipped intermediates don't matter as - // long as the client ends on the current - // controller and size. - let Some(session) = control_session.upgrade() else { - break; - }; - let (controller, cols, rows, layout_epoch) = - session.control_state(); - let Some(controller) = controller else { - continue; - }; - let _ = control_events.send(json!({ - "requestId": 0, - "type": "control-changed", - "sessionId": control_session_id, - "controllerViewId": controller.view_id, - "controlEpoch": controller.control_epoch, - "cols": cols, - "rows": rows, - "layoutEpoch": layout_epoch, - })); - } - Err(broadcast::error::RecvError::Closed) => break, - }, - } - } - }); - tokio::spawn(async move { - loop { - let activity = tokio::select! { - changed = activity_exit.changed() => { - if changed.is_err() || *activity_exit.borrow() { - break; - } - continue; - } - activity = activities.recv() => match activity { - Ok(activity) => activity, - Err(broadcast::error::RecvError::Lagged(_)) => { - let Some(session) = activity_session.upgrade() else { - break; - }; - session.summary().activity - } - Err(broadcast::error::RecvError::Closed) => break, - }, - }; - let _ = activity_events.send(json!({ - "requestId": 0, - "type": "session-activity-changed", - "sessionId": activity_session_id, - "activity": activity, - })); - } - }); - // Re-check the tombstone while holding the lock across the - // insert: a concurrent close-session-owner either finds this - // session in the registry and sweeps it, or its tombstone is - // already visible here and the create fails. - { - let owner_lifecycle = context.closed_owners.lock().await; - if summary - .owner_id - .as_deref() - .is_some_and(|owner| owner_lifecycle.contains(owner)) - { - drop(owner_lifecycle); - // Never in the registry, but its ladder is running, so - // the invariant applies: track it or lose it. - let _ = context.shutdown.terminate_tracked( - &session, - TerminationSource::User, - None, - ); - bail!("session owner is already closed"); - } - // The shutdown re-check that matters: the entry check ran - // before `spawn_blocking`, and a shutdown could have begun - // during the fork. The barrier takes this same lock, so a - // create either inserts before the drain's snapshot or is - // refused here — the registry cannot grow behind the drain. - if context.shutdown.admissions_closed() { - let deadline = *context.shutdown.deadline.lock().unwrap(); - drop(owner_lifecycle); - // Already forked: refusing the request does not unmake - // the child, so hand it to the drain rather than - // dropping it and leaking a PTY the snapshot never saw. - let _ = context.shutdown.terminate_tracked( - &session, - TerminationSource::ServiceShutdown, - deadline, - ); - bail!("service is shutting down"); - } - registry - .write() - .unwrap() - .insert(summary.id.clone(), Arc::clone(&session)); - } - // A child that died during spawn leaves nothing to keep unless - // its class says otherwise. Read that class under the same lock - // as the removal, exactly as the exit path does. - let retained = { - let mut registry = registry.write().unwrap(); - let retain = !session.has_exited() - || session.persistence() == Some(Persistence::KeepUntilExplicitClose); - if !retain { - context - .tombstones - .remove_with_cause_locked(&mut registry, &summary.id); - } - retain - }; - if retained { - announce_session_created(event_tx, &summary); - } Ok(ResponseBody::SessionCreated { session: summary }) } Command::ListSessions => { @@ -3055,7 +3270,15 @@ async fn handle_command( // the entry disappears while the child is still dying. context.shutdown.terminate_tracked(&session, source, None)?; remove_session_view_owners(context, &session_id); - context.tombstones.remove_with_cause(registry, &session_id); + if context + .tombstones + .remove_with_cause(registry, &session_id) + .is_some() + { + let _ = context + .lifecycle_tx + .send(SessionLifecycleEvent::Removed { session_id }); + } } else { if !context.mesh_runtime.close_session(&session_id).await { bail!("unknown session"); @@ -3158,7 +3381,16 @@ async fn handle_command( ); } remove_session_view_owners(context, &session.id()); - context.tombstones.remove_with_cause(registry, &session.id()); + let session_id = session.id(); + if context + .tombstones + .remove_with_cause(registry, &session_id) + .is_some() + { + let _ = context + .lifecycle_tx + .send(SessionLifecycleEvent::Removed { session_id }); + } } for session_id in remote_session_ids { context.mesh_runtime.close_session(&session_id).await; @@ -3711,6 +3943,7 @@ mod protocol_tests { handle: ServiceHandle, serving: tokio::task::JoinHandle>, control: String, + frame: String, token: String, _control_endpoint: Endpoint, _frame_endpoint: Endpoint, @@ -3739,6 +3972,7 @@ mod protocol_tests { handle, serving: tokio::spawn(serving), control: control_endpoint.name.clone(), + frame: frame_endpoint.name.clone(), token, _control_endpoint: control_endpoint, _frame_endpoint: frame_endpoint, @@ -3789,6 +4023,279 @@ mod protocol_tests { stream } + async fn connect_frames(service: &TestService) -> ControlStream { + let mut stream = dial_control(&service.frame).await; + write_packet(&mut stream, service.token.as_bytes()) + .await + .unwrap(); + let acknowledgement = read_packet(&mut stream, 64).await.unwrap(); + assert_eq!(acknowledgement, b"ok"); + stream + } + + fn long_running_spawn_options() -> SpawnOptions { + #[cfg(unix)] + let (executable, args) = ( + "/bin/sh".to_owned(), + vec!["-c".to_owned(), "sleep 30".to_owned()], + ); + #[cfg(windows)] + let (executable, args) = ( + windows_shell(), + vec![ + "/d".to_owned(), + "/c".to_owned(), + "ping".to_owned(), + "-n".to_owned(), + "60".to_owned(), + "127.0.0.1".to_owned(), + ], + ); + SpawnOptions { + executable, + args, + cwd: None, + env: HashMap::new(), + environment: None, + cols: 40, + rows: 10, + persistence: Persistence::TerminateWithApp, + program_kind: session::SessionProgramKind::Application, + owner_id: None, + scrollback_bytes: None, + } + } + + #[tokio::test] + async fn dropping_an_unpolled_managed_service_refuses_session_access() { + let control = unique_endpoint("unpolled-access-control"); + let frames = unique_endpoint("unpolled-access-frames"); + let service = TerminalService::new(TerminalServiceConfig { + control_socket: control.name.clone(), + frame_socket: frames.name.clone(), + auth_token: "secret".to_owned(), + }) + .with_text_engine(TextEngine::discover().unwrap()); + let listeners = service.bind().unwrap(); + let (handle, serving) = service.serve_managed(listeners); + + drop(serving); + + let access = tokio::time::timeout(Duration::from_secs(1), handle.sessions()) + .await + .expect("session readiness hung after the serving future was dropped"); + assert!(access.is_err()); + } + + #[tokio::test] + async fn in_process_access_shares_registry_frames_spawn_and_lifecycle() { + let service = start_test_service("in-process-session-access"); + let access = tokio::time::timeout(Duration::from_secs(10), service.handle.sessions()) + .await + .expect("session access did not become ready") + .unwrap(); + let mut lifecycle = access.subscribe_lifecycle(); + + // Handle-born sessions take the exact service path and immediately + // appear to a legacy control client. + let spawned = access.spawn(long_running_spawn_options()).await.unwrap(); + let spawned_id = spawned.id(); + let spawned_summary = spawned.summary(); + assert_eq!( + spawned_summary.persistence, + Some(Persistence::TerminateWithApp) + ); + assert!( + spawned_summary + .scrollback_bytes + .is_some_and(|bytes| bytes > 0) + ); + let created = tokio::time::timeout(Duration::from_secs(5), lifecycle.recv()) + .await + .unwrap() + .unwrap(); + match created { + SessionLifecycleEvent::Created { session } => { + assert!(Arc::ptr_eq(&session, &spawned)); + } + _ => panic!("expected a created lifecycle event"), + } + assert!(Arc::ptr_eq(&access.session(&spawned_id).unwrap(), &spawned)); + + let mut client = connect_control(&service).await; + let listed = request( + &mut client, + json!({ "requestId": 1, "type": "list-sessions" }), + ) + .await; + assert!( + listed["sessions"] + .as_array() + .unwrap() + .iter() + .any(|session| session["id"] == spawned_id) + ); + + // A UDS-born session is the same Arc the in-process host observes. + let created_over_uds = + request(&mut client, owned_long_running_session(2, "uds-owner")).await; + let uds_id = created_over_uds["session"]["id"] + .as_str() + .unwrap() + .to_owned(); + let uds_session = access + .session(&uds_id) + .expect("UDS session missing from handle"); + let uds_created = tokio::time::timeout(Duration::from_secs(5), lifecycle.recv()) + .await + .unwrap() + .unwrap(); + match uds_created { + SessionLifecycleEvent::Created { session } => { + assert!(Arc::ptr_eq(&session, &uds_session)); + } + _ => panic!("expected the UDS session's created lifecycle event"), + } + + // Both the direct subscriber and the legacy frame socket consume the + // same hub publication; the socket strips only the hub metadata. + let mut frame_client = connect_frames(&service).await; + write_packet( + &mut frame_client, + &serde_json::to_vec(&json!({ + "type": "subscribe", + "requestId": 3, + "sessionHandles": [uds_session.summary().handle], + })) + .unwrap(), + ) + .await + .unwrap(); + let acknowledgement = read_packet(&mut frame_client, MAX_FRAME_SUBSCRIPTION_BYTES) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&acknowledgement).unwrap()["type"], + "subscription-ack" + ); + let (mut frames, baseline) = access.frames().subscribe(); + uds_session.attach_view("host-view", "host-client").unwrap(); + let packet = tokio::time::timeout(Duration::from_secs(5), frames.recv()) + .await + .unwrap() + .unwrap(); + assert!(packet.ordinal > baseline); + assert_eq!( + packet.session_handle.to_string(), + uds_session.summary().handle + ); + let socket_packet = tokio::time::timeout( + Duration::from_secs(5), + read_packet(&mut frame_client, MAX_FRAME_BYTES), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(&socket_packet[..], &packet[..]); + + request( + &mut client, + json!({ + "requestId": 4, + "type": "terminate", + "sessionId": spawned_id, + "source": "user", + }), + ) + .await; + let mut saw_removed = false; + let mut saw_exited = false; + let deadline = Instant::now() + Duration::from_secs(10); + while !(saw_removed && saw_exited) && Instant::now() < deadline { + let event = tokio::time::timeout(Duration::from_secs(2), lifecycle.recv()) + .await + .unwrap() + .unwrap(); + match event { + SessionLifecycleEvent::Removed { session_id } if session_id == spawned_id => { + saw_removed = true; + } + SessionLifecycleEvent::Exited { session_id, .. } if session_id == spawned_id => { + saw_exited = true; + } + _ => {} + } + } + assert!(saw_removed && saw_exited); + + let _ = request( + &mut client, + json!({ + "requestId": 5, + "type": "terminate", + "sessionId": uds_id, + "source": "user", + }), + ) + .await; + service + .handle + .shutdown(Duration::from_secs(5)) + .await + .unwrap(); + service.serving.await.unwrap().unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn in_process_spawn_applies_the_services_private_environment_strip() { + let control_endpoint = unique_endpoint("in-process-private-env-control"); + let frame_endpoint = unique_endpoint("in-process-private-env-frames"); + let token = "private-env-test-token".to_owned(); + let service = TerminalService::new(TerminalServiceConfig { + control_socket: control_endpoint.name.clone(), + frame_socket: frame_endpoint.name.clone(), + auth_token: token.clone(), + }) + .with_text_engine(TextEngine::discover().unwrap()) + // PATH is a harmless inherited variable whose absence the child can + // report without mutating this test process's global environment. + .with_private_env_prefixes(["PATH"]) + .unwrap(); + let listeners = service.bind().unwrap(); + let (handle, serving) = service.serve_managed(listeners); + let serving = tokio::spawn(serving); + let access = handle.sessions().await.unwrap(); + let mut lifecycle = access.subscribe_lifecycle(); + + let mut options = long_running_spawn_options(); + assert!(std::path::Path::new("/usr/bin/printenv").exists()); + options.executable = "/usr/bin/printenv".to_owned(); + options.args = vec!["PATH".to_owned()]; + options.persistence = Persistence::KeepUntilExplicitClose; + let spawned = access.spawn(options).await.unwrap(); + + let exit = loop { + let event = tokio::time::timeout(Duration::from_secs(5), lifecycle.recv()) + .await + .expect("child environment probe did not exit") + .unwrap(); + if let SessionLifecycleEvent::Exited { session_id, exit } = event + && session_id == spawned.id() + { + break exit; + } + }; + assert_eq!( + exit.exit_code, + Some(1), + "printenv found PATH even though the service configured it as private" + ); + + handle.shutdown(Duration::from_secs(5)).await.unwrap(); + serving.await.unwrap().unwrap(); + } + /// Send a command and return its response, skipping pushed events. async fn request(stream: &mut ControlStream, command: Value) -> Value { write_packet(stream, &serde_json::to_vec(&command).unwrap()) diff --git a/native/ghosttea/src/session.rs b/native/ghosttea/src/session.rs index 3a876b12..0902ce5c 100644 --- a/native/ghosttea/src/session.rs +++ b/native/ghosttea/src/session.rs @@ -1234,7 +1234,15 @@ impl Session { Self::spawn_with_private_env_prefixes(options, frames, text_engine, &[], on_exit) } - pub(crate) fn spawn_with_private_env_prefixes( + /// Spawn a standalone session while stripping additional host-private + /// environment prefixes from inherited child state. + /// + /// Embedders that also run [`crate::TerminalService`] should normally use + /// [`crate::ServiceSessions::spawn`], which additionally applies the live + /// service configuration, registry, shutdown, tombstone, ownership, and + /// lifecycle policy. This lower-level constructor exists for hosts that + /// deliberately own those policies themselves. + pub fn spawn_with_private_env_prefixes( options: SpawnOptions, frames: FrameHub, text_engine: Arc>, diff --git a/native/ghosttead/Cargo.toml b/native/ghosttead/Cargo.toml index 86eb138e..9483dc77 100644 --- a/native/ghosttead/Cargo.toml +++ b/native/ghosttead/Cargo.toml @@ -12,7 +12,7 @@ publish = false [dependencies] anyhow.workspace = true dotenvy.workspace = true -ghosttea = { version = "0.10.1", path = "../ghosttea" } -ghosttea-truffle = { version = "0.10.1", path = "../ghosttea/crates/ghosttea-truffle" } +ghosttea = { version = "0.11.0", path = "../ghosttea" } +ghosttea-truffle = { version = "0.11.0", path = "../ghosttea/crates/ghosttea-truffle" } tokio.workspace = true truffle-core.workspace = true diff --git a/package-lock.json b/package-lock.json index 15932947..de89e208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghosttea", - "version": "0.10.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghosttea", - "version": "0.10.1", + "version": "0.11.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28,14 +28,14 @@ }, "apps/desktop": { "name": "ghosttea-demo", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@vibecook/ghosttea": "0.10.1", - "@vibecook/ghosttea-electron": "0.10.1", - "@vibecook/ghosttea-frame": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1", - "@vibecook/ghosttea-react": "0.10.1", + "@vibecook/ghosttea": "0.11.0", + "@vibecook/ghosttea-electron": "0.11.0", + "@vibecook/ghosttea-frame": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0", + "@vibecook/ghosttea-react": "0.11.0", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -55,14 +55,14 @@ }, "apps/desktop-experiment": { "name": "ghosttea-desktop-experiment", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@vibecook/ghosttea": "0.10.1", - "@vibecook/ghosttea-electron": "0.10.1", - "@vibecook/ghosttea-frame": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1", - "@vibecook/ghosttea-react": "0.10.1", + "@vibecook/ghosttea": "0.11.0", + "@vibecook/ghosttea-electron": "0.11.0", + "@vibecook/ghosttea-frame": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0", + "@vibecook/ghosttea-react": "0.11.0", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -7820,10 +7820,10 @@ }, "packages/ghosttea": { "name": "@vibecook/ghosttea", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea-protocol": "0.11.0" }, "devDependencies": { "typescript": "7.0.2", @@ -7832,10 +7832,10 @@ }, "packages/ghosttea-client": { "name": "@vibecook/ghosttea-client", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea-protocol": "0.11.0" }, "devDependencies": { "@types/node": "26.1.1", @@ -7883,11 +7883,11 @@ }, "packages/ghosttea-electron": { "name": "@vibecook/ghosttea-electron", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@vibecook/ghosttea-client": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea-client": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0" }, "devDependencies": { "@types/node": "26.1.1", @@ -7936,7 +7936,7 @@ }, "packages/ghosttea-frame": { "name": "@vibecook/ghosttea-frame", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "devDependencies": { "typescript": "7.0.2", @@ -7980,7 +7980,7 @@ }, "packages/ghosttea-native-tabs": { "name": "@vibecook/ghosttea-native-tabs", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "devDependencies": { "@types/node": "26.1.1", @@ -8025,7 +8025,7 @@ }, "packages/ghosttea-protocol": { "name": "@vibecook/ghosttea-protocol", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "devDependencies": { "typescript": "7.0.2", @@ -8069,12 +8069,12 @@ }, "packages/ghosttea-react": { "name": "@vibecook/ghosttea-react", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@vibecook/ghosttea": "0.10.1", - "@vibecook/ghosttea-frame": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea": "0.11.0", + "@vibecook/ghosttea-frame": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0" }, "devDependencies": { "@types/react": "19.2.17", @@ -8160,7 +8160,7 @@ }, "packages/ghosttead": { "name": "@vibecook/ghosttead", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "devDependencies": { "@types/node": "26.1.1", @@ -8168,18 +8168,18 @@ "vitest": "4.1.10" }, "optionalDependencies": { - "@vibecook/ghosttead-darwin-arm64": "0.10.1", - "@vibecook/ghosttead-win32-x64": "0.10.1" + "@vibecook/ghosttead-darwin-arm64": "0.11.0", + "@vibecook/ghosttead-win32-x64": "0.11.0" } }, "packages/ghosttead-darwin-arm64": { "name": "@vibecook/ghosttead-darwin-arm64", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT" }, "packages/ghosttead-win32-x64": { "name": "@vibecook/ghosttead-win32-x64", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT" }, "packages/ghosttead/node_modules/typescript": { diff --git a/package.json b/package.json index 79ec1758..4eee6e8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ghosttea", "private": true, - "version": "0.10.1", + "version": "0.11.0", "description": "Ghostty-powered native terminal runtime for Electron applications.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttea-client/package.json b/packages/ghosttea-client/package.json index cb03c1d0..f94ba21d 100644 --- a/packages/ghosttea-client/package.json +++ b/packages/ghosttea-client/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-client", - "version": "0.10.1", + "version": "0.11.0", "description": "Electron-free Node control client for the Ghosttea terminal service.", "license": "MIT", "author": "James Yong", @@ -41,7 +41,7 @@ "node": ">=22" }, "dependencies": { - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea-protocol": "0.11.0" }, "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", diff --git a/packages/ghosttea-electron/package.json b/packages/ghosttea-electron/package.json index 559b180f..1cd0e67e 100644 --- a/packages/ghosttea-electron/package.json +++ b/packages/ghosttea-electron/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-electron", - "version": "0.10.1", + "version": "0.11.0", "description": "Electron lifecycle and direct renderer transport for Ghosttea.", "license": "MIT", "author": "James Yong", @@ -64,8 +64,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@vibecook/ghosttea-client": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea-client": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0" }, "peerDependencies": { "electron": ">=35" diff --git a/packages/ghosttea-frame/package.json b/packages/ghosttea-frame/package.json index 65d27634..156d3f4c 100644 --- a/packages/ghosttea-frame/package.json +++ b/packages/ghosttea-frame/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-frame", - "version": "0.10.1", + "version": "0.11.0", "description": "Binary frame decoder and shared rendering types for Ghosttea.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttea-native-tabs/package.json b/packages/ghosttea-native-tabs/package.json index 1f4897b6..a779ac49 100644 --- a/packages/ghosttea-native-tabs/package.json +++ b/packages/ghosttea-native-tabs/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-native-tabs", - "version": "0.10.1", + "version": "0.11.0", "description": "Prebuilt macOS native window-tab ordering addon for Electron applications.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttea-protocol/README.md b/packages/ghosttea-protocol/README.md index ddc41bbe..bc9c6a41 100644 --- a/packages/ghosttea-protocol/README.md +++ b/packages/ghosttea-protocol/README.md @@ -11,4 +11,24 @@ responses instead of asking clients to serialize a projection back to disk. allowlist; privileged document commands and future unreviewed command families are rejected by the Electron renderer bridge. +## TPv3 routed transport contract + +The package root also exports the additive routed terminal contract: grant and +ticket guards, tagged control/frames message codecs, RFC 8785 canonical grant +input, close classification, scene-stamp ordering, CRC-32C, and the binary +presentation-envelope codec. Readers tolerate unknown object fields and +capability strings while enforcing known tags, direction allowlists, scalar +constraints, grant shapes, loopback-only ticket endpoints, and envelope +kind-specific invariants. + +After building this package, its compatibility helper can check the complete +published VibeField fixture corpus and fails if a new `tp-*.json` vector is not +classified: + +```sh +npm run build --workspace @vibecook/ghosttea-protocol +node packages/ghosttea-protocol/scripts/verify-vibefield-vectors.mjs \ + /path/to/vibe-field/packages/contracts/fixtures +``` + Ghosttea is developed at . diff --git a/packages/ghosttea-protocol/package.json b/packages/ghosttea-protocol/package.json index 88d1ef82..35f7d7a5 100644 --- a/packages/ghosttea-protocol/package.json +++ b/packages/ghosttea-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-protocol", - "version": "0.10.1", + "version": "0.11.0", "description": "Typed control protocol for Ghosttea terminal clients and bridges.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttea-protocol/scripts/verify-vibefield-vectors.mjs b/packages/ghosttea-protocol/scripts/verify-vibefield-vectors.mjs new file mode 100644 index 00000000..6682f865 --- /dev/null +++ b/packages/ghosttea-protocol/scripts/verify-vibefield-vectors.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { + DEFAULT_ROUTED_PROTOCOL_LIMITS, + ROUTED_MESSAGE_TYPES, + canonicalRoutedJson, + decodeRoutedMessage, + decodeRoutedPresentationEnvelope, + encodeRoutedMessage, + encodeRoutedPresentationEnvelope, + isRoutedCellTransportGrant, + isRoutedProtocolLimits, + isRoutedSessionAttachGrant, + isRoutedTerminalOpenTicket, + routedGrantSigningInput, +} from "../dist/index.js"; + +const fixtureDirectory = process.argv[2]; +if (!fixtureDirectory) { + throw new Error("usage: node scripts/verify-vibefield-vectors.mjs <@vibefield/contracts/fixtures>"); +} + +const bodyTypes = new Map([ + ["tp-attach-control-leg.valid.json", "AttachControlLeg"], + ["tp-attach-frames-leg.resume.json", "AttachFramesLeg"], + ["tp-attach-refused.grant-invalid.json", "AttachRefused"], + ["tp-cell-activation-status.lagging.json", "CellActivationStatus"], + ["tp-cell-activation-status.presenting-allowed.json", "CellActivationStatus"], + ["tp-cell-activation-status.revoked.json", "CellActivationStatus"], + ["tp-claim-geometry.valid.json", "ClaimGeometry"], + ["tp-connection-accepted.frames.json", "ConnectionAccepted"], + ["tp-connection-hello.frames.json", "ConnectionHello"], + ["tp-connection-refused.valid.json", "ConnectionRefused"], + ["tp-control-leg-attached.valid.json", "ControlLegAttached"], + ["tp-declare-demand.release.json", "DeclareDemand"], + ["tp-frames-leg-attached.resume.json", "FramesLegAttached"], + ["tp-frames-leg-attached.seed.json", "FramesLegAttached"], + ["tp-geometry-committed.valid.json", "GeometryCommitted"], + ["tp-scene-applied.valid.json", "SceneApplied"], + ["tp-transfer-geometry.valid.json", "TransferGeometry"], + ["tp-transport-credit.valid.json", "TransportCredit"], +]); + +const envelopeHeaders = new Set([ + "tp-envelope-header.chunk.json", + "tp-envelope-header.seed-begin.json", + "tp-envelope-header.trf1.json", +]); +const ticketVectors = new Set([ + "tp-create-open.s1.json", + "tp-open-ticket.s1-no-endpoints.json", + "tp-open-ticket.s1.json", +]); +const structuralVectors = new Set([ + "tp-machines.vector.json", + "tp-presentation-status.valid.json", + "tp-renew-attach-params.valid.json", + "tp-roster-item.valid.json", +]); + +let wireCodecs = 0; +let structural = 0; +const files = readdirSync(fixtureDirectory) + .filter((name) => /^tp-.*\.json$/.test(name)) + .sort(); + +for (const name of files) { + const value = JSON.parse(readFileSync(path.join(fixtureDirectory, name), "utf8")); + const bodyType = bodyTypes.get(name); + if (bodyType) { + const wire = encodeRoutedMessage(bodyType, value); + const decoded = decodeRoutedMessage(wire, ROUTED_MESSAGE_TYPES); + assert.equal(decoded.ok, true, `${name}: body did not decode`); + assert.deepEqual(decoded.message, { type: bodyType, ...value }, `${name}: body round-trip changed`); + wireCodecs += 1; + continue; + } + if (name.startsWith("tp-tagged-message.")) { + const decoded = decodeRoutedMessage(JSON.stringify(value), ROUTED_MESSAGE_TYPES); + assert.equal(decoded.ok, true, `${name}: tagged message did not decode`); + assert.deepEqual(decoded.message, value, `${name}: tagged message round-trip changed`); + wireCodecs += 1; + continue; + } + if (envelopeHeaders.has(name)) { + const encoded = encodeRoutedPresentationEnvelope(value, new Uint8Array()); + const decoded = decodeRoutedPresentationEnvelope(encoded); + assert.equal(decoded.ok, true, `${name}: envelope header did not decode`); + assert.deepEqual(decoded.envelope.header, value, `${name}: envelope header round-trip changed`); + wireCodecs += 1; + continue; + } + if (name === "tp-envelope.vector.json") { + const wire = Uint8Array.from(Buffer.from(value.wireBase64, "base64")); + const decoded = decodeRoutedPresentationEnvelope(wire); + assert.equal(decoded.ok, true, `${name}: golden wire did not decode`); + assert.deepEqual(decoded.envelope.header, value.header); + assert.equal(Buffer.from(decoded.envelope.payload).toString("hex"), value.payloadHex); + assert.deepEqual(encodeRoutedPresentationEnvelope(value.header, decoded.envelope.payload), wire); + wireCodecs += 1; + continue; + } + if (name === "tp-jcs.vector.json") { + assert.equal(canonicalRoutedJson(value.input), value.canonical); + structural += 1; + continue; + } + if (name === "tp-grant-mac.vector.json") { + for (const grant of [value.transport, value.attach]) { + assert.equal(routedGrantSigningInput(grant.protected, grant.claims), grant.signingInput); + assert.equal( + createHmac("sha256", Buffer.from(value.keyHex, "hex")).update(grant.signingInput).digest("base64url"), + grant.mac, + ); + } + structural += 1; + continue; + } + if (name === "tp-transport-grant.valid.json") { + assert.equal(isRoutedCellTransportGrant(value), true); + structural += 1; + continue; + } + if (name === "tp-attach-grant.valid.json") { + assert.equal(isRoutedSessionAttachGrant(value), true); + structural += 1; + continue; + } + if (ticketVectors.has(name)) { + assert.equal(isRoutedTerminalOpenTicket(value), true, `${name}: ticket guard refused the vector`); + structural += 1; + continue; + } + if (name === "tp-protocol-limits.defaults.json") { + assert.equal(isRoutedProtocolLimits(value), true); + assert.deepEqual(value, DEFAULT_ROUTED_PROTOCOL_LIMITS); + structural += 1; + continue; + } + if (structuralVectors.has(name)) { + assert.deepEqual(JSON.parse(canonicalRoutedJson(value)), value, `${name}: tolerant JSON round-trip changed`); + structural += 1; + continue; + } + throw new Error(`unclassified VibeField terminal-pipeline vector: ${name}`); +} + +console.log(`verified ${files.length} TP vectors (${wireCodecs} wire codecs, ${structural} structural vectors)`); diff --git a/packages/ghosttea-protocol/src/index.ts b/packages/ghosttea-protocol/src/index.ts index 06cca436..aa94e08a 100644 --- a/packages/ghosttea-protocol/src/index.ts +++ b/packages/ghosttea-protocol/src/index.ts @@ -5,6 +5,8 @@ export const STRUCTURED_ERROR_PROTOCOL_MINOR = 16; export const CONFIG_SCHEMA_VERSION = 1; export const CONFIG_DOCUMENT_SCHEMA_VERSION = 1; +export * from "./routed.js"; + export type ConfigDiagnosticSeverity = "info" | "warning" | "error"; export type ConfigSupport = "applied" | "parsed" | "unsupported"; export type ConfigSourceKind = "ghostty-default" | "included" | "ghosttea-overlay"; diff --git a/packages/ghosttea-protocol/src/routed.test.ts b/packages/ghosttea-protocol/src/routed.test.ts new file mode 100644 index 00000000..4482e689 --- /dev/null +++ b/packages/ghosttea-protocol/src/routed.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + ROUTED_LEG_INBOUND, + canonicalRoutedJson, + decodeRoutedMessage, + decodeRoutedPresentationEnvelope, + encodeRoutedMessage, + encodeRoutedPresentationEnvelope, + routedCrc32c, + routedGrantSigningInput, + type RoutedCellTransportGrant, + type RoutedPresentationEnvelopeHeader, +} from "./routed"; + +const transportGrant: RoutedCellTransportGrant = { + protected: { + v: 1, + typ: "CellTransportGrant", + iss: "fieldd", + alg: "HS256", + kid: { cellBootId: "cb-7f3a9c1e-2026", keyGeneration: 1 }, + }, + claims: { + audienceCellBootId: "cb-7f3a9c1e-2026", + clientId: "win-3c4d-incarnation-2", + connectionSetId: "cs-win-3c4d-2-cb-7f3a9c1e", + allowedChannels: ["control", "frames"], + transportGrantGeneration: 3, + issuedAt: 1_787_788_800_000, + expiresAt: 1_787_788_860_000, + nonce: "Qk9PVC1OT05DRS0wMDE", + }, + mac: "C7-X5g4Km1-LHGW4sOvq-rI7ddkjZCDGzA7Mbi24bzo", +}; + +describe("TPv3 routed protocol", () => { + it("matches the RFC 8785 canonical JSON golden vector", () => { + const input = { + numbers: [333333333.3333333, 1e30, 4.5, 0.002, 1e-27], + string: '€$\u000f\nA\'B"\\\\"/', + literals: [null, true, false], + }; + expect(canonicalRoutedJson(input)).toBe( + '{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\\u000f\\nA\'B\\"\\\\\\\\\\"/"}', + ); + }); + + it("matches the grant MAC signing-input golden vector", () => { + expect(routedGrantSigningInput(transportGrant.protected, transportGrant.claims)).toBe( + '{"claims":{"allowedChannels":["control","frames"],"audienceCellBootId":"cb-7f3a9c1e-2026","clientId":"win-3c4d-incarnation-2","connectionSetId":"cs-win-3c4d-2-cb-7f3a9c1e","expiresAt":1787788860000,"issuedAt":1787788800000,"nonce":"Qk9PVC1OT05DRS0wMDE","transportGrantGeneration":3},"protected":{"alg":"HS256","iss":"fieldd","kid":{"cellBootId":"cb-7f3a9c1e-2026","keyGeneration":1},"typ":"CellTransportGrant","v":1}}', + ); + }); + + it("round-trips tagged messages while enforcing the leg allow-list", () => { + const wire = encodeRoutedMessage("ConnectionHello", { + protocolMajor: 1, + protocolMinor: 0, + channel: "control", + transportGrant, + capabilities: ["resume", "x-future-capability"], + }); + const decoded = decodeRoutedMessage(wire, ROUTED_LEG_INBOUND.control); + expect(decoded).toEqual({ ok: true, message: JSON.parse(wire) }); + expect(decodeRoutedMessage(wire, ["AttachFramesLeg"])).toEqual({ ok: false, error: "not-allowed-here" }); + }); + + it("does not let an extension field replace the encoded message tag", () => { + expect(() => + encodeRoutedMessage("ConnectionHello", { + protocolMajor: 1, + protocolMinor: 0, + channel: "control", + transportGrant, + capabilities: [], + type: "AttachFramesLeg", + }), + ).toThrow("invalid routed ConnectionHello message"); + }); + + it("matches the presentation-envelope binary golden vector", () => { + const header: RoutedPresentationEnvelopeHeader = { + creditEpoch: 1, + activationSequence: 88, + sessionId: "sess-01J8Z3K9", + activationId: "act-01", + leaseEpoch: 4, + kind: "trf1-frame", + baseContent: { + sceneEpoch: { cellBootId: "cb-7f3a9c1e-2026", modelGeneration: 0 }, + sceneRevision: 4416, + }, + resultContent: { + sceneEpoch: { cellBootId: "cb-7f3a9c1e-2026", modelGeneration: 0 }, + sceneRevision: 4417, + }, + }; + const payload = Uint8Array.from([0x54, 0x52, 0x46, 0x31, 0, 1, 2, 3, 0xff, 0x10]); + const encoded = encodeRoutedPresentationEnvelope(header, payload); + const expected = Uint8Array.from( + atob( + "VFABAAAAAVJ7ImNyZWRpdEVwb2NoIjoxLCJhY3RpdmF0aW9uU2VxdWVuY2UiOjg4LCJzZXNzaW9uSWQiOiJzZXNzLTAxSjhaM0s5IiwiYWN0aXZhdGlvbklkIjoiYWN0LTAxIiwibGVhc2VFcG9jaCI6NCwia2luZCI6InRyZjEtZnJhbWUiLCJiYXNlQ29udGVudCI6eyJzY2VuZUVwb2NoIjp7ImNlbGxCb290SWQiOiJjYi03ZjNhOWMxZS0yMDI2IiwibW9kZWxHZW5lcmF0aW9uIjowfSwic2NlbmVSZXZpc2lvbiI6NDQxNn0sInJlc3VsdENvbnRlbnQiOnsic2NlbmVFcG9jaCI6eyJjZWxsQm9vdElkIjoiY2ItN2YzYTljMWUtMjAyNiIsIm1vZGVsR2VuZXJhdGlvbiI6MH0sInNjZW5lUmV2aXNpb24iOjQ0MTd9fVRSRjEAAQID/xA=", + ), + (character) => character.charCodeAt(0), + ); + expect(encoded).toEqual(expected); + const decoded = decodeRoutedPresentationEnvelope(encoded); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.chargedBytes).toBe(356); + expect(decoded.envelope.header).toEqual(header); + expect(decoded.envelope.payload).toEqual(payload); + }); + + it("rejects reserved bits and computes the standard CRC-32C vector", () => { + const bytes = new Uint8Array(8); + bytes.set([0x54, 0x50, 1, 1]); + expect(decodeRoutedPresentationEnvelope(bytes)).toEqual({ ok: false, error: "bad-reserved" }); + expect(routedCrc32c(new TextEncoder().encode("123456789"))).toBe(0xe306_9283); + }); +}); diff --git a/packages/ghosttea-protocol/src/routed.ts b/packages/ghosttea-protocol/src/routed.ts new file mode 100644 index 00000000..db5f4ea6 --- /dev/null +++ b/packages/ghosttea-protocol/src/routed.ts @@ -0,0 +1,1187 @@ +/** + * Public client-side contract for the TPv3 routed terminal transport. + * + * The authority which mints and verifies grants lives outside Ghosttea. Grants + * are therefore represented structurally and are transported unchanged. The + * helpers in this module validate the renderer-facing wire invariants without + * taking a dependency on an authority-specific schema package. + */ + +export type RoutedTransportChannel = "control" | "frames"; +export type RoutedCapability = "resume" | "snapshot-demand" | "profiling-envelope" | (string & {}); +export type RoutedSessionRight = "geometry" | "geometryAdmin" | "input" | "read" | (string & {}); + +export interface RoutedCellEndpoints { + controlUrl: string; + framesUrl: string; + [key: string]: unknown; +} + +export interface RoutedRouteBinding { + cellBootId: string; + routeRevision: number; + leaseEpoch?: number; + [key: string]: unknown; +} + +export interface RoutedGrantProtectedHeader { + v: 1; + typ: "CellTransportGrant" | "SessionAttachGrant"; + iss: "fieldd"; + alg: "HS256"; + kid: { + cellBootId: string; + keyGeneration: number; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface RoutedCellTransportGrantClaims { + audienceCellBootId: string; + clientId: string; + connectionSetId: string; + allowedChannels: RoutedTransportChannel[]; + transportGrantGeneration: number; + issuedAt: number; + expiresAt: number; + nonce: string; + [key: string]: unknown; +} + +export interface RoutedSessionAttachGrantClaims { + audienceCellBootId: string; + clientId: string; + sessionId: string; + leaseEpoch?: number; + routeRevision: number; + grantGeneration: number; + rights: RoutedSessionRight[]; + issuedAt: number; + expiresAt: number; + [key: string]: unknown; +} + +export interface RoutedAuthenticatedGrant { + protected: RoutedGrantProtectedHeader & { typ: TType }; + claims: TClaims; + mac: string; + [key: string]: unknown; +} + +export type RoutedCellTransportGrant = RoutedAuthenticatedGrant<"CellTransportGrant", RoutedCellTransportGrantClaims>; +export type RoutedSessionAttachGrant = RoutedAuthenticatedGrant<"SessionAttachGrant", RoutedSessionAttachGrantClaims>; + +export interface RoutedTerminalOpenTicket { + route: RoutedRouteBinding; + endpoints?: RoutedCellEndpoints; + transportGrant: RoutedCellTransportGrant; + attachGrant: RoutedSessionAttachGrant; + [key: string]: unknown; +} + +export interface RoutedReceiverCapacities { + connectionCreditBytes: number; + perActivationCreditBytes: number; + stagingBytesPerSession: number; + stagingBytesTotal: number; + maxConcurrentActivations: number; + maxConcurrentSeeds: number; + [key: string]: unknown; +} + +export interface RoutedProtocolLimits { + maxControlMessageBytes: number; + maxPresentationChunkBytes: number; + maxBatchLatencyMs: number; + maxCreditReturnDelayMs: number; + maxSceneAppliedDelayMs: number; + sceneAppliedRefreshMs: number; + presentationStatusRefreshMs: number; + activationAttachDeadlineMs: number; + maxActivationCatchupMs: number; + maxCatchupBytes: number; + creditAccountDrainTtlMs: number; + urgentReserveBytes: number; + maxBulkBytesAdmittedAhead: number; + maxUrgentPresentationUnitBytes: number; + [key: string]: unknown; +} + +export const ROUTED_PROTOCOL_VERSION = { major: 1, minor: 0 } as const; + +/** Conservative browser-worker capacities from the TPv3 golden HELLO. */ +export const DEFAULT_ROUTED_RECEIVER_CAPACITIES: RoutedReceiverCapacities = { + connectionCreditBytes: 8_388_608, + perActivationCreditBytes: 2_097_152, + stagingBytesPerSession: 16_777_216, + stagingBytesTotal: 67_108_864, + maxConcurrentActivations: 128, + maxConcurrentSeeds: 4, +}; + +/** Ratified T1 limits. The cell repeats its authoritative values on accept. */ +export const DEFAULT_ROUTED_PROTOCOL_LIMITS: RoutedProtocolLimits = { + maxControlMessageBytes: 262_144, + maxPresentationChunkBytes: 1_048_576, + maxBatchLatencyMs: 4, + maxCreditReturnDelayMs: 16, + maxSceneAppliedDelayMs: 1_000, + sceneAppliedRefreshMs: 2_000, + presentationStatusRefreshMs: 2_000, + activationAttachDeadlineMs: 5_000, + maxActivationCatchupMs: 3_000, + maxCatchupBytes: 8_388_608, + creditAccountDrainTtlMs: 5_000, + urgentReserveBytes: 2_097_152, + maxBulkBytesAdmittedAhead: 4_194_304, + maxUrgentPresentationUnitBytes: 262_144, +}; + +export const ROUTED_DOOR_LIMITS = { + helloDeadlineMs: 5_000, + preAuthMaxBytes: 65_536, + preAuthConnectionCap: 64, + maxConnectionSets: 256, + heartbeatIntervalMs: 5_000, + heartbeatTtlMs: 15_000, +} as const; + +export const ROUTED_CLOSE_CODES = { + GOING_AWAY: 1001, + POLICY_PRE_AUTH: 1008, + SERVER_ERROR: 1011, + STALE_ROUTE: 4000, + FENCED: 4001, + SUPERSEDED: 4002, + PROTOCOL: 4003, + LEG_TIMEOUT: 4004, +} as const; + +export type RoutedCloseReason = + | "going-away" + | "pre-auth" + | "server-error" + | "stale-route" + | "fenced" + | "superseded" + | "protocol" + | "leg-timeout" + | "unknown"; + +export function classifyRoutedClose(code: number): RoutedCloseReason { + switch (code) { + case ROUTED_CLOSE_CODES.GOING_AWAY: + return "going-away"; + case ROUTED_CLOSE_CODES.POLICY_PRE_AUTH: + return "pre-auth"; + case ROUTED_CLOSE_CODES.SERVER_ERROR: + return "server-error"; + case ROUTED_CLOSE_CODES.STALE_ROUTE: + return "stale-route"; + case ROUTED_CLOSE_CODES.FENCED: + return "fenced"; + case ROUTED_CLOSE_CODES.SUPERSEDED: + return "superseded"; + case ROUTED_CLOSE_CODES.PROTOCOL: + return "protocol"; + case ROUTED_CLOSE_CODES.LEG_TIMEOUT: + return "leg-timeout"; + default: + return "unknown"; + } +} + +export interface RoutedSceneEpoch { + cellBootId: string; + modelGeneration: number; + [key: string]: unknown; +} + +export interface RoutedSceneContentStamp { + sceneEpoch: RoutedSceneEpoch; + sceneRevision: number; + [key: string]: unknown; +} + +export function compareRoutedSceneContent( + left: RoutedSceneContentStamp, + right: RoutedSceneContentStamp, +): -1 | 0 | 1 | null { + if ( + left.sceneEpoch.cellBootId !== right.sceneEpoch.cellBootId || + left.sceneEpoch.modelGeneration !== right.sceneEpoch.modelGeneration + ) { + return null; + } + if (left.sceneRevision === right.sceneRevision) return 0; + return left.sceneRevision < right.sceneRevision ? -1 : 1; +} + +export interface RoutedSourceDemand { + mode: "none" | "snapshot" | "live"; + cadenceClass?: "low" | "normal" | "high"; + urgency?: "background" | "normal" | "urgent"; + [key: string]: unknown; +} + +export interface RoutedConnectionHello { + protocolMajor: number; + protocolMinor: number; + channel: RoutedTransportChannel; + transportGrant: RoutedCellTransportGrant; + receiverCapacities?: RoutedReceiverCapacities; + capabilities: string[]; + [key: string]: unknown; +} + +export interface RoutedConnectionAccepted { + selectedProtocolVersion: { major: number; minor: number; [key: string]: unknown }; + connectionSetId: string; + channel: RoutedTransportChannel; + legGeneration: number; + heartbeatTtlMs: number; + creditEpoch?: number; + initialWindows?: RoutedReceiverCapacities; + protocolLimits: RoutedProtocolLimits; + capabilities: string[]; + [key: string]: unknown; +} + +export interface RoutedConnectionRefused { + code: string; + retryable: boolean; + [key: string]: unknown; +} + +export interface RoutedLegHeartbeat { + connectionSetId: string; + channel: RoutedTransportChannel; + legGeneration: number; + sequence: number; + [key: string]: unknown; +} + +export interface RoutedLegHeartbeatAck { + sequence: number; + [key: string]: unknown; +} + +export interface RoutedAttachControlLeg { + activationId: string; + attachGrant: RoutedSessionAttachGrant; + replacesActivationId?: string; + initialDemand: RoutedSourceDemand; + [key: string]: unknown; +} + +export interface RoutedControlLegAttached { + sessionId: string; + activationId: string; + grantGenerationAccepted: number; + rights: RoutedSessionRight[]; + [key: string]: unknown; +} + +export interface RoutedAttachRefused { + activationId?: string; + code: string; + retryable: boolean; + [key: string]: unknown; +} + +export interface RoutedResumeRequest { + resumeToken: string; + from: RoutedSceneContentStamp; + [key: string]: unknown; +} + +export interface RoutedAttachFramesLeg { + activationId: string; + attachGrant: RoutedSessionAttachGrant; + resume?: RoutedResumeRequest; + [key: string]: unknown; +} + +export interface RoutedTrfIdentity { + sessionHandle: string; + viewHandle: string; + [key: string]: unknown; +} + +export interface RoutedFramesAttachOutcome { + kind: "resume-accepted" | "seed-required"; + from?: RoutedSceneContentStamp; + newestAvailable?: RoutedSceneContentStamp; + reason?: string; + [key: string]: unknown; +} + +export interface RoutedFramesLegAttached { + sessionId: string; + activationId: string; + resumeToken: string; + trfIdentity: RoutedTrfIdentity; + outcome: RoutedFramesAttachOutcome; + [key: string]: unknown; +} + +export interface RoutedDeclareDemand { + sessionId: string; + activationId: string; + leaseEpoch: number; + demandSequence: number; + demand: RoutedSourceDemand; + [key: string]: unknown; +} + +export interface RoutedDemandAccepted { + demandSequence: number; + [key: string]: unknown; +} + +export interface RoutedPresentationDimension { + state: "presenting" | "stopped" | "revoked"; + reason?: string; + [key: string]: unknown; +} + +export interface RoutedInputDimension { + state: "allowed" | "suspended" | "revoked"; + reason?: string; + [key: string]: unknown; +} + +export interface RoutedCellActivationStatus { + sessionId: string; + activationId: string; + cellStatusSequence: number; + leaseTtlMs: number; + acceptedContent?: RoutedSceneContentStamp; + presentation: RoutedPresentationDimension; + input: RoutedInputDimension; + [key: string]: unknown; +} + +export interface RoutedPresentationStatus { + activationId: string; + workerStatusSequence: number; + state: "connecting" | "seeding" | "active" | "recovering"; + sceneContent?: RoutedSceneContentStamp; + leaseTtlMs: number; + [key: string]: unknown; +} + +export interface RoutedFramesLegState { + activationId: string; + state: "attaching" | "resuming" | "seeding" | "applying" | "active" | "failed"; + resumeToken?: string; + appliedContent?: RoutedSceneContentStamp; + [key: string]: unknown; +} + +export interface RoutedGeometryClaimant { + clientId: string; + viewId: string; + [key: string]: unknown; +} + +export interface RoutedGeometryHolder extends RoutedGeometryClaimant { + holderGeneration: number; +} + +interface RoutedActivationTriple { + sessionId: string; + activationId: string; + leaseEpoch: number; +} + +export interface RoutedClaimGeometry extends RoutedActivationTriple { + claimant: RoutedGeometryClaimant; + cols: number; + rows: number; + expectRevision: number; + [key: string]: unknown; +} + +export interface RoutedReleaseGeometry extends RoutedActivationTriple { + holder: RoutedGeometryHolder; + [key: string]: unknown; +} + +export interface RoutedTransferGeometry extends RoutedActivationTriple { + from: RoutedGeometryHolder; + to: RoutedGeometryClaimant; + expectRevision: number; + cols: number; + rows: number; + [key: string]: unknown; +} + +export interface RoutedGeometryCommitted { + holder: RoutedGeometryHolder; + geometryRevision: number; + cols: number; + rows: number; + [key: string]: unknown; +} + +export interface RoutedGeometryRefused { + code: string; + currentHolder?: RoutedGeometryHolder; + geometryRevision?: number; + [key: string]: unknown; +} + +export interface RoutedTransportCredit { + creditEpoch: number; + creditSequence: number; + connectionBytesReturned: number; + accounts: Array<{ activationId: string; bytesReturned: number; [key: string]: unknown }>; + [key: string]: unknown; +} + +export interface RoutedSceneApplied { + sessionId: string; + activationId: string; + leaseEpoch: number; + appliedContent: RoutedSceneContentStamp; + [key: string]: unknown; +} + +export interface RoutedCalibrationPing { + sequence: number; + t0: number; + [key: string]: unknown; +} + +export type RoutedMessageBodyMap = { + ConnectionHello: RoutedConnectionHello; + ConnectionAccepted: RoutedConnectionAccepted; + ConnectionRefused: RoutedConnectionRefused; + LegHeartbeat: RoutedLegHeartbeat; + LegHeartbeatAck: RoutedLegHeartbeatAck; + AttachControlLeg: RoutedAttachControlLeg; + ControlLegAttached: RoutedControlLegAttached; + AttachRefused: RoutedAttachRefused; + DeclareDemand: RoutedDeclareDemand; + DemandAccepted: RoutedDemandAccepted; + CellActivationStatus: RoutedCellActivationStatus; + ClaimGeometry: RoutedClaimGeometry; + ReleaseGeometry: RoutedReleaseGeometry; + TransferGeometry: RoutedTransferGeometry; + GeometryCommitted: RoutedGeometryCommitted; + GeometryRefused: RoutedGeometryRefused; + AttachFramesLeg: RoutedAttachFramesLeg; + FramesLegAttached: RoutedFramesLegAttached; + TransportCredit: RoutedTransportCredit; + SceneApplied: RoutedSceneApplied; + CalibrationPing: RoutedCalibrationPing; +}; + +export type RoutedMessageType = keyof RoutedMessageBodyMap; +export type RoutedTaggedMessage = T extends RoutedMessageType + ? { type: T } & RoutedMessageBodyMap[T] + : never; + +export const ROUTED_MESSAGE_TYPES = [ + "ConnectionHello", + "ConnectionAccepted", + "ConnectionRefused", + "LegHeartbeat", + "LegHeartbeatAck", + "AttachControlLeg", + "ControlLegAttached", + "AttachRefused", + "DeclareDemand", + "DemandAccepted", + "CellActivationStatus", + "ClaimGeometry", + "ReleaseGeometry", + "TransferGeometry", + "GeometryCommitted", + "GeometryRefused", + "AttachFramesLeg", + "FramesLegAttached", + "TransportCredit", + "SceneApplied", + "CalibrationPing", +] as const satisfies readonly RoutedMessageType[]; + +export const ROUTED_LEG_INBOUND = { + control: [ + "ConnectionHello", + "LegHeartbeat", + "AttachControlLeg", + "DeclareDemand", + "ClaimGeometry", + "ReleaseGeometry", + "TransferGeometry", + ], + frames: ["ConnectionHello", "LegHeartbeat", "AttachFramesLeg", "TransportCredit", "SceneApplied", "CalibrationPing"], +} as const satisfies Readonly>; + +export const ROUTED_LEG_OUTBOUND = { + control: [ + "ConnectionAccepted", + "ConnectionRefused", + "LegHeartbeatAck", + "ControlLegAttached", + "AttachRefused", + "DemandAccepted", + "CellActivationStatus", + "GeometryCommitted", + "GeometryRefused", + ], + frames: ["ConnectionAccepted", "ConnectionRefused", "LegHeartbeatAck", "FramesLegAttached", "AttachRefused"], +} as const satisfies Readonly>; + +const routedMessageTypeSet = new Set(ROUTED_MESSAGE_TYPES); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCounter(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function isPositiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isSortedUniqueStringArray(value: unknown, allowed?: ReadonlySet): value is string[] { + return ( + isStringArray(value) && + value.every( + (item, index) => (allowed === undefined || allowed.has(item)) && (index === 0 || String(value[index - 1]) < item), + ) + ); +} + +function hasOptionalCounter(record: Record, key: string): boolean { + return record[key] === undefined || isCounter(record[key]); +} + +function hasOptionalString(record: Record, key: string): boolean { + return record[key] === undefined || typeof record[key] === "string"; +} + +function isStamp(value: unknown): value is RoutedSceneContentStamp { + if (!isRecord(value) || !isRecord(value.sceneEpoch)) return false; + return ( + isNonEmptyString(value.sceneEpoch.cellBootId) && + isCounter(value.sceneEpoch.modelGeneration) && + isCounter(value.sceneRevision) + ); +} + +const transportChannels = new Set(["control", "frames"]); +const sessionRights = new Set(["geometry", "geometryAdmin", "input", "read"]); +const loopbackWebSocket = /^wss?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:\d{1,5})?(\/[^?#]*)?$/; + +function isGrantProtectedHeader(value: unknown, type: RoutedGrantProtectedHeader["typ"]): boolean { + return ( + isRecord(value) && + value.v === 1 && + value.typ === type && + value.iss === "fieldd" && + value.alg === "HS256" && + isRecord(value.kid) && + isNonEmptyString(value.kid.cellBootId) && + isCounter(value.kid.keyGeneration) + ); +} + +/** Runtime guard for a cell transport grant. MAC verification remains the cell's responsibility. */ +export function isRoutedCellTransportGrant(value: unknown): value is RoutedCellTransportGrant { + if (!isRecord(value) || !isGrantProtectedHeader(value.protected, "CellTransportGrant")) return false; + const claims = value.claims; + return ( + isRecord(claims) && + isNonEmptyString(claims.audienceCellBootId) && + isNonEmptyString(claims.clientId) && + isNonEmptyString(claims.connectionSetId) && + isSortedUniqueStringArray(claims.allowedChannels, transportChannels) && + claims.allowedChannels.length > 0 && + isCounter(claims.transportGrantGeneration) && + isCounter(claims.issuedAt) && + isCounter(claims.expiresAt) && + isNonEmptyString(claims.nonce) && + isNonEmptyString(value.mac) + ); +} + +/** Runtime guard for an attach grant. MAC verification remains the cell's responsibility. */ +export function isRoutedSessionAttachGrant(value: unknown): value is RoutedSessionAttachGrant { + if (!isRecord(value) || !isGrantProtectedHeader(value.protected, "SessionAttachGrant")) return false; + const claims = value.claims; + return ( + isRecord(claims) && + isNonEmptyString(claims.audienceCellBootId) && + isNonEmptyString(claims.clientId) && + isNonEmptyString(claims.sessionId) && + hasOptionalCounter(claims, "leaseEpoch") && + isCounter(claims.routeRevision) && + isCounter(claims.grantGeneration) && + isSortedUniqueStringArray(claims.rights, sessionRights) && + isCounter(claims.issuedAt) && + isCounter(claims.expiresAt) && + isNonEmptyString(value.mac) + ); +} + +export function isRoutedReceiverCapacities(value: unknown): value is RoutedReceiverCapacities { + return ( + isRecord(value) && + isCounter(value.connectionCreditBytes) && + isCounter(value.perActivationCreditBytes) && + isCounter(value.stagingBytesPerSession) && + isCounter(value.stagingBytesTotal) && + isCounter(value.maxConcurrentActivations) && + isCounter(value.maxConcurrentSeeds) + ); +} + +export function isRoutedProtocolLimits(value: unknown): value is RoutedProtocolLimits { + return ( + isRecord(value) && + isCounter(value.maxControlMessageBytes) && + isCounter(value.maxPresentationChunkBytes) && + isCounter(value.maxBatchLatencyMs) && + isCounter(value.maxCreditReturnDelayMs) && + isCounter(value.maxSceneAppliedDelayMs) && + isCounter(value.sceneAppliedRefreshMs) && + isCounter(value.presentationStatusRefreshMs) && + isCounter(value.activationAttachDeadlineMs) && + isCounter(value.maxActivationCatchupMs) && + isCounter(value.maxCatchupBytes) && + isCounter(value.creditAccountDrainTtlMs) && + isCounter(value.urgentReserveBytes) && + isCounter(value.maxBulkBytesAdmittedAhead) && + isCounter(value.maxUrgentPresentationUnitBytes) + ); +} + +function isSourceDemand(value: unknown): value is RoutedSourceDemand { + return ( + isRecord(value) && + (value.mode === "none" || value.mode === "snapshot" || value.mode === "live") && + (value.cadenceClass === undefined || + value.cadenceClass === "low" || + value.cadenceClass === "normal" || + value.cadenceClass === "high") && + (value.urgency === undefined || + value.urgency === "background" || + value.urgency === "normal" || + value.urgency === "urgent") + ); +} + +function isTrfIdentity(value: unknown): value is RoutedTrfIdentity { + return ( + isRecord(value) && + typeof value.sessionHandle === "string" && + /^(0|[1-9][0-9]*)$/.test(value.sessionHandle) && + typeof value.viewHandle === "string" && + /^(0|[1-9][0-9]*)$/.test(value.viewHandle) + ); +} + +function isFramesAttachOutcome(value: unknown): value is RoutedFramesAttachOutcome { + if (!isRecord(value) || (value.kind !== "resume-accepted" && value.kind !== "seed-required")) return false; + if (!hasOptionalString(value, "reason")) return false; + if (value.kind === "resume-accepted") return isStamp(value.from) && isStamp(value.newestAvailable); + return ( + (value.from === undefined || isStamp(value.from)) && + (value.newestAvailable === undefined || isStamp(value.newestAvailable)) + ); +} + +function isGeometryClaimant(value: unknown): value is RoutedGeometryClaimant { + return isRecord(value) && isNonEmptyString(value.clientId) && isNonEmptyString(value.viewId); +} + +function isGeometryHolder(value: unknown): value is RoutedGeometryHolder { + return isGeometryClaimant(value) && isCounter(value.holderGeneration); +} + +/** Validate the transport-private ticket before any endpoint is dialled. */ +export function isRoutedTerminalOpenTicket(value: unknown): value is RoutedTerminalOpenTicket { + if (!isRecord(value) || !isRecord(value.route)) return false; + if ( + !isNonEmptyString(value.route.cellBootId) || + !isCounter(value.route.routeRevision) || + !hasOptionalCounter(value.route, "leaseEpoch") || + !isRoutedCellTransportGrant(value.transportGrant) || + !isRoutedSessionAttachGrant(value.attachGrant) + ) { + return false; + } + return ( + value.endpoints === undefined || + (isRecord(value.endpoints) && + typeof value.endpoints.controlUrl === "string" && + loopbackWebSocket.test(value.endpoints.controlUrl) && + typeof value.endpoints.framesUrl === "string" && + loopbackWebSocket.test(value.endpoints.framesUrl)) + ); +} + +function hasActivation(record: Record): boolean { + return isNonEmptyString(record.activationId); +} + +function validateRoutedBody(type: RoutedMessageType, body: Record): boolean { + switch (type) { + case "ConnectionHello": + return ( + isCounter(body.protocolMajor) && + isCounter(body.protocolMinor) && + (body.channel === "control" || body.channel === "frames") && + isRoutedCellTransportGrant(body.transportGrant) && + (body.receiverCapacities === undefined || isRoutedReceiverCapacities(body.receiverCapacities)) && + isStringArray(body.capabilities) + ); + case "ConnectionAccepted": + return ( + isRecord(body.selectedProtocolVersion) && + isCounter(body.selectedProtocolVersion.major) && + isCounter(body.selectedProtocolVersion.minor) && + isNonEmptyString(body.connectionSetId) && + (body.channel === "control" || body.channel === "frames") && + isCounter(body.legGeneration) && + isCounter(body.heartbeatTtlMs) && + hasOptionalCounter(body, "creditEpoch") && + (body.initialWindows === undefined || isRoutedReceiverCapacities(body.initialWindows)) && + isRoutedProtocolLimits(body.protocolLimits) && + isStringArray(body.capabilities) + ); + case "ConnectionRefused": + return typeof body.code === "string" && typeof body.retryable === "boolean"; + case "LegHeartbeat": + return ( + isNonEmptyString(body.connectionSetId) && + (body.channel === "control" || body.channel === "frames") && + isCounter(body.legGeneration) && + isCounter(body.sequence) + ); + case "LegHeartbeatAck": + return isCounter(body.sequence); + case "AttachControlLeg": + return ( + hasActivation(body) && + isRoutedSessionAttachGrant(body.attachGrant) && + (body.replacesActivationId === undefined || isNonEmptyString(body.replacesActivationId)) && + isSourceDemand(body.initialDemand) + ); + case "ControlLegAttached": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isCounter(body.grantGenerationAccepted) && + isSortedUniqueStringArray(body.rights, sessionRights) + ); + case "AttachRefused": + return ( + (body.activationId === undefined || isNonEmptyString(body.activationId)) && + typeof body.code === "string" && + typeof body.retryable === "boolean" + ); + case "DeclareDemand": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isCounter(body.leaseEpoch) && + isCounter(body.demandSequence) && + isSourceDemand(body.demand) + ); + case "DemandAccepted": + return isCounter(body.demandSequence); + case "CellActivationStatus": + if ( + !isNonEmptyString(body.sessionId) || + !hasActivation(body) || + !isCounter(body.cellStatusSequence) || + !isCounter(body.leaseTtlMs) || + (body.acceptedContent !== undefined && !isStamp(body.acceptedContent)) || + !isRecord(body.presentation) || + !["presenting", "stopped", "revoked"].includes(String(body.presentation.state)) || + !hasOptionalString(body.presentation, "reason") || + !isRecord(body.input) || + !["allowed", "suspended", "revoked"].includes(String(body.input.state)) || + !hasOptionalString(body.input, "reason") + ) { + return false; + } + return body.input.state !== "allowed" || body.presentation.state === "presenting"; + case "ClaimGeometry": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isCounter(body.leaseEpoch) && + isGeometryClaimant(body.claimant) && + isPositiveInteger(body.cols) && + isPositiveInteger(body.rows) && + isCounter(body.expectRevision) + ); + case "ReleaseGeometry": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isCounter(body.leaseEpoch) && + isGeometryHolder(body.holder) + ); + case "TransferGeometry": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isCounter(body.leaseEpoch) && + isGeometryHolder(body.from) && + isGeometryClaimant(body.to) && + isCounter(body.expectRevision) && + isPositiveInteger(body.cols) && + isPositiveInteger(body.rows) + ); + case "GeometryCommitted": + return ( + isGeometryHolder(body.holder) && + isCounter(body.geometryRevision) && + isPositiveInteger(body.cols) && + isPositiveInteger(body.rows) + ); + case "GeometryRefused": + return ( + typeof body.code === "string" && + (body.currentHolder === undefined || isGeometryHolder(body.currentHolder)) && + hasOptionalCounter(body, "geometryRevision") + ); + case "AttachFramesLeg": + return ( + hasActivation(body) && + isRoutedSessionAttachGrant(body.attachGrant) && + (body.resume === undefined || + (isRecord(body.resume) && isNonEmptyString(body.resume.resumeToken) && isStamp(body.resume.from))) + ); + case "FramesLegAttached": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isNonEmptyString(body.resumeToken) && + isTrfIdentity(body.trfIdentity) && + isFramesAttachOutcome(body.outcome) + ); + case "TransportCredit": + return ( + isCounter(body.creditEpoch) && + isCounter(body.creditSequence) && + isCounter(body.connectionBytesReturned) && + Array.isArray(body.accounts) && + body.accounts.every( + (account) => isRecord(account) && isNonEmptyString(account.activationId) && isCounter(account.bytesReturned), + ) + ); + case "SceneApplied": + return ( + isNonEmptyString(body.sessionId) && + hasActivation(body) && + isCounter(body.leaseEpoch) && + isStamp(body.appliedContent) + ); + case "CalibrationPing": + return isCounter(body.sequence) && typeof body.t0 === "number" && Number.isFinite(body.t0); + } +} + +export type RoutedMessageDecodeError = + "not-json" | "not-an-object" | "missing-type" | "unknown-type" | "not-allowed-here" | "invalid"; + +export type RoutedMessageDecodeResult = + { ok: true; message: RoutedTaggedMessage } | { ok: false; error: RoutedMessageDecodeError }; + +/** Decode a text message and enforce the caller-provided leg/direction tag set. */ +export function decodeRoutedMessage( + raw: string | unknown, + allowed: readonly RoutedMessageType[] = ROUTED_MESSAGE_TYPES, +): RoutedMessageDecodeResult { + let value: unknown = raw; + if (typeof raw === "string") { + try { + value = JSON.parse(raw); + } catch { + return { ok: false, error: "not-json" }; + } + } + if (!isRecord(value)) return { ok: false, error: "not-an-object" }; + if (typeof value.type !== "string") return { ok: false, error: "missing-type" }; + if (!routedMessageTypeSet.has(value.type)) return { ok: false, error: "unknown-type" }; + const type = value.type as RoutedMessageType; + if (!allowed.includes(type)) return { ok: false, error: "not-allowed-here" }; + const body = { ...value }; + delete body.type; + if (!validateRoutedBody(type, body)) return { ok: false, error: "invalid" }; + return { ok: true, message: value as RoutedTaggedMessage }; +} + +export function encodeRoutedMessage(type: T, body: RoutedMessageBodyMap[T]): string { + if (!isRecord(body) || Object.hasOwn(body, "type") || !validateRoutedBody(type, body)) { + throw new TypeError(`invalid routed ${type} message`); + } + return JSON.stringify({ type, ...body }); +} + +/** RFC 8785 JSON canonicalization for authenticated-grant MAC input. */ +export function canonicalRoutedJson(value: unknown): string { + if (value === null || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("canonicalRoutedJson: non-finite number"); + return JSON.stringify(value); + } + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value + .map((item) => { + if (item === undefined) throw new TypeError("canonicalRoutedJson: undefined array element"); + return canonicalRoutedJson(item); + }) + .join(",")}]`; + } + if (isRecord(value)) { + const entries = Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalRoutedJson(value[key])}`); + return `{${entries.join(",")}}`; + } + throw new TypeError(`canonicalRoutedJson: unsupported value of type ${typeof value}`); +} + +export function routedGrantSigningInput(protectedHeader: RoutedGrantProtectedHeader, claims: unknown): string { + return canonicalRoutedJson({ protected: protectedHeader, claims }); +} + +export type RoutedPresentationEnvelopeKind = + "trf1-frame" | "transfer-begin" | "transfer-chunk" | "transfer-end" | "calibration"; +export type RoutedTransferKind = "seed" | "catchup"; + +export interface RoutedTransferHeader { + transferId: string; + kind?: RoutedTransferKind; + totalBytes?: number; + chunkCount?: number; + targetLayout?: { cols: number; rows: number; scrollbackRows: number; [key: string]: unknown }; + checksum?: { alg: "crc32c"; value: number; [key: string]: unknown }; + chunkIndex?: number; + byteOffset?: number; + [key: string]: unknown; +} + +export interface RoutedPresentationEnvelopeHeader { + creditEpoch: number; + activationSequence: number; + sessionId: string; + activationId: string; + leaseEpoch: number; + kind: RoutedPresentationEnvelopeKind; + baseContent?: RoutedSceneContentStamp | null; + resultContent?: RoutedSceneContentStamp; + transfer?: RoutedTransferHeader; + calibration?: { sequence: number; t0: number; t1: number; t2: number; [key: string]: unknown }; + profiling?: { + damageFirstTs: number; + damageLastTs: number; + encodeTs: number; + probeId?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface RoutedPresentationEnvelope { + header: RoutedPresentationEnvelopeHeader; + /** A zero-copy view into the received message. Copy it before retaining. */ + payload: Uint8Array; +} + +export const ROUTED_PRESENTATION_ENVELOPE_MAGIC = [0x54, 0x50] as const; +export const ROUTED_PRESENTATION_ENVELOPE_VERSION = 1 as const; +export const ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES = 8 as const; +export const ROUTED_PRESENTATION_ENVELOPE_MAX_HEADER_BYTES = 4096 as const; + +const routedUtf8Encoder = new TextEncoder(); +const routedUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isTransferLayout(value: unknown): boolean { + return ( + isRecord(value) && isPositiveInteger(value.cols) && isPositiveInteger(value.rows) && isCounter(value.scrollbackRows) + ); +} + +function isTransferChecksum(value: unknown): boolean { + return isRecord(value) && value.alg === "crc32c" && isCounter(value.value); +} + +function isCalibrationEcho(value: unknown): boolean { + return ( + isRecord(value) && + isCounter(value.sequence) && + isFiniteNumber(value.t0) && + isFiniteNumber(value.t1) && + isFiniteNumber(value.t2) + ); +} + +function isProfilingEnvelope(value: unknown): boolean { + return ( + isRecord(value) && + isFiniteNumber(value.damageFirstTs) && + isFiniteNumber(value.damageLastTs) && + isFiniteNumber(value.encodeTs) && + (value.probeId === undefined || typeof value.probeId === "string") + ); +} + +function validateEnvelopeHeader(value: unknown): value is RoutedPresentationEnvelopeHeader { + if (!isRecord(value)) return false; + if ( + !isCounter(value.creditEpoch) || + !isCounter(value.activationSequence) || + !isNonEmptyString(value.sessionId) || + !isNonEmptyString(value.activationId) || + !isCounter(value.leaseEpoch) || + !["trf1-frame", "transfer-begin", "transfer-chunk", "transfer-end", "calibration"].includes(String(value.kind)) + ) { + return false; + } + const base = value.baseContent; + const result = value.resultContent; + if (base !== undefined && base !== null && !isStamp(base)) return false; + if (result !== undefined && !isStamp(result)) return false; + if (value.profiling !== undefined && !isProfilingEnvelope(value.profiling)) return false; + if (isStamp(base) && isStamp(result) && compareRoutedSceneContent(base, result) === null) return false; + switch (value.kind) { + case "trf1-frame": + return Object.hasOwn(value, "baseContent") && isStamp(result); + case "transfer-begin": { + if (!isRecord(value.transfer) || !isNonEmptyString(value.transfer.transferId)) return false; + if (value.transfer.kind !== "seed" && value.transfer.kind !== "catchup") return false; + if (!isCounter(value.transfer.totalBytes) || !isCounter(value.transfer.chunkCount)) return false; + if ( + !isTransferLayout(value.transfer.targetLayout) || + !isTransferChecksum(value.transfer.checksum) || + !isStamp(result) + ) { + return false; + } + if (value.transfer.kind === "seed") return base === null; + return isStamp(base); + } + case "transfer-chunk": + return ( + isRecord(value.transfer) && + isNonEmptyString(value.transfer.transferId) && + isCounter(value.transfer.chunkIndex) && + isCounter(value.transfer.byteOffset) + ); + case "transfer-end": + return isRecord(value.transfer) && isNonEmptyString(value.transfer.transferId); + case "calibration": + return isCalibrationEcho(value.calibration); + default: + return false; + } +} + +export function encodeRoutedPresentationEnvelope( + header: RoutedPresentationEnvelopeHeader, + payload: Uint8Array, +): Uint8Array { + if (!validateEnvelopeHeader(header)) throw new TypeError("invalid routed presentation envelope header"); + const headerBytes = routedUtf8Encoder.encode(JSON.stringify(header)); + if (headerBytes.byteLength > ROUTED_PRESENTATION_ENVELOPE_MAX_HEADER_BYTES) { + throw new RangeError(`routed presentation envelope header too large: ${headerBytes.byteLength}`); + } + const output = new Uint8Array( + ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES + headerBytes.byteLength + payload.byteLength, + ); + output[0] = ROUTED_PRESENTATION_ENVELOPE_MAGIC[0]; + output[1] = ROUTED_PRESENTATION_ENVELOPE_MAGIC[1]; + output[2] = ROUTED_PRESENTATION_ENVELOPE_VERSION; + output[3] = 0; + new DataView(output.buffer, output.byteOffset, output.byteLength).setUint32(4, headerBytes.byteLength, false); + output.set(headerBytes, ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES); + output.set(payload, ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES + headerBytes.byteLength); + return output; +} + +export type RoutedPresentationEnvelopeDecodeError = + | "short" + | "bad-magic" + | "bad-version" + | "bad-reserved" + | "header-too-large" + | "header-truncated" + | "header-not-utf8" + | "header-not-json" + | "header-invalid"; + +export type RoutedPresentationEnvelopeDecodeResult = + | { ok: true; envelope: RoutedPresentationEnvelope; chargedBytes: number } + | { ok: false; error: RoutedPresentationEnvelopeDecodeError }; + +export function decodeRoutedPresentationEnvelope(bytes: Uint8Array): RoutedPresentationEnvelopeDecodeResult { + if (bytes.byteLength < ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES) return { ok: false, error: "short" }; + if (bytes[0] !== ROUTED_PRESENTATION_ENVELOPE_MAGIC[0] || bytes[1] !== ROUTED_PRESENTATION_ENVELOPE_MAGIC[1]) { + return { ok: false, error: "bad-magic" }; + } + if (bytes[2] !== ROUTED_PRESENTATION_ENVELOPE_VERSION) return { ok: false, error: "bad-version" }; + if (bytes[3] !== 0) return { ok: false, error: "bad-reserved" }; + const headerLength = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(4, false); + if (headerLength > ROUTED_PRESENTATION_ENVELOPE_MAX_HEADER_BYTES) { + return { ok: false, error: "header-too-large" }; + } + const headerEnd = ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES + headerLength; + if (headerEnd > bytes.byteLength) return { ok: false, error: "header-truncated" }; + let text: string; + try { + text = routedUtf8Decoder.decode(bytes.subarray(ROUTED_PRESENTATION_ENVELOPE_PREFIX_BYTES, headerEnd)); + } catch { + return { ok: false, error: "header-not-utf8" }; + } + let header: unknown; + try { + header = JSON.parse(text); + } catch { + return { ok: false, error: "header-not-json" }; + } + if (!validateEnvelopeHeader(header)) return { ok: false, error: "header-invalid" }; + return { + ok: true, + envelope: { header, payload: bytes.subarray(headerEnd) }, + chargedBytes: bytes.byteLength, + }; +} + +/** CRC-32C (Castagnoli), returned as an unsigned 32-bit integer. */ +export function routedCrc32c(bytes: Uint8Array, seed = 0): number { + let crc = (seed ^ 0xffff_ffff) >>> 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = ((crc >>> 1) ^ (0x82f6_3b78 & -(crc & 1))) >>> 0; + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} diff --git a/packages/ghosttea-react/README.md b/packages/ghosttea-react/README.md index 0305ddf1..aa1de69d 100644 --- a/packages/ghosttea-react/README.md +++ b/packages/ghosttea-react/README.md @@ -9,6 +9,56 @@ paths. Create one runtime per renderer window, provide it through Import `@vibecook/ghosttea-react/styles.css` once in the renderer entrypoint. +## Routed terminal transport + +The additive `transport: "routed"` mode connects one control WebSocket and one +frames WebSocket per `cellBootId`, then multiplexes session activations over +each connection set. Main owns activation IDs, leases, replacement and +recovery. The render worker owns the frames socket, presentation-envelope +validation, byte credits, bounded transfer staging, TRF1 identity checks, and +scene apply. Presentation bytes are never relayed through main. + +```ts +const runtime = createGhostteaTerminalRuntime({ + transport: "routed", + platform, + host: { + openTicket: (sessionId, options) => fieldd.openTicket(sessionId, options), + renewAttach: (request) => fieldd.renewAttach(request), + listSessions: () => fieldd.listSessions(), + createSession: (options) => fieldd.createSession(options), + terminate: (sessionId, source) => fieldd.terminate(sessionId, source), + }, +}); +``` + +The ticket guard accepts only loopback `ws://`/`wss://` endpoints without a +query or fragment, and checks route/grant/session binding before dialing. A +frames-leg recovery asks `openTicket` for a fresh transport grant before using +the negotiated `resume` capability; consumed grant nonces are never replayed. +The worker uses its own global `WebSocket`. The optional `websocketFactory` +customizes the main-thread control leg for tests or host instrumentation. + +Read `runtime.routedActivation(sessionId)` or listen for +`routed-activation-state` and `routed-view-readiness`. `PresentationReady` and +`InputAllowed` are deliberately separate: the local scene must cover the +cell's accepted content, both local status leases must be live, and input also +requires the cell's input dimension, a current input right, and a writable +client view. `TerminalSurface` accepts `inputPolicy="read-only"` (or +`readWrite={false}`) to remove input locally without affecting presentation. + +The published VibeField T1 contract currently has no terminal-input wire tag. +Accordingly, routed input stays closed unless the host supplies `encodeInput` +for an extension it has negotiated with its cell; Ghosttea does not invent an +incompatible message. When supplied, the encoded keystroke is sent directly +on the main-thread control socket with no worker `postMessage` hop. + +Focus changes only rendering priority and demand urgency. Geometry changes +require an explicit `controlsResize`/`claimResizeControl` path, so a focused +mirror cannot resize the PTY. Production counters are available through +`readPerformanceCounters()` without opening a measurement window or draining +the GPU. The existing port-pair transport remains the default rollback path. + For applications that want the complete Ghostty-style desktop experience, use `GhostteaWorkspace` from `@vibecook/ghosttea-react/workspace` and import `@vibecook/ghosttea-react/workspace.css`. It owns the titlebar, persisted pane diff --git a/packages/ghosttea-react/package.json b/packages/ghosttea-react/package.json index a5c9aa24..23fb8534 100644 --- a/packages/ghosttea-react/package.json +++ b/packages/ghosttea-react/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-react", - "version": "0.10.1", + "version": "0.11.0", "description": "React terminal surface and worker renderer for Ghosttea.", "license": "MIT", "author": "James Yong", @@ -58,9 +58,9 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@vibecook/ghosttea": "0.10.1", - "@vibecook/ghosttea-frame": "0.10.1", - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea": "0.11.0", + "@vibecook/ghosttea-frame": "0.11.0", + "@vibecook/ghosttea-protocol": "0.11.0" }, "peerDependencies": { "react": ">=19" diff --git a/packages/ghosttea-react/src/TerminalSurface.tsx b/packages/ghosttea-react/src/TerminalSurface.tsx index 6b3efb9d..82d34eca 100644 --- a/packages/ghosttea-react/src/TerminalSurface.tsx +++ b/packages/ghosttea-react/src/TerminalSurface.tsx @@ -20,6 +20,7 @@ import { accumulateWheelRows, wheelDeltaPixels } from "./scroll-input.js"; import { adjustSelectionFocus, usesLocalSelection } from "./selection-input.js"; export type TerminalMenuAction = "copy" | "paste" | "select-all" | "clear-screen"; +export type TerminalInputPolicy = "inherit" | "read-only"; export interface TerminalSurfaceProps { session: SessionSummary; @@ -32,6 +33,10 @@ export interface TerminalSurfaceProps { /** Whether the surface is currently visible enough to spend GPU work painting it. */ visible?: boolean; controlsResize?: boolean; + /** Viewer-local input fence. It can make a writable session read-only but never grants server rights. */ + inputPolicy?: TerminalInputPolicy; + /** Convenience alias for `inputPolicy="read-only"` when false. */ + readWrite?: boolean; onActivate?: () => void; readClipboard?: () => string | Promise; onCopyAvailabilityChange?: (canCopy: boolean) => void; @@ -81,6 +86,8 @@ function TerminalSurfaceSession({ platform, visible = true, controlsResize = active, + inputPolicy = "inherit", + readWrite = true, onActivate, readClipboard, onCopyAvailabilityChange, @@ -91,10 +98,13 @@ function TerminalSurfaceSession({ // Fullscreen is owned by Workspace platform routing (toggle_fullscreen). void _onToggleFullscreen; const terminalRuntime = useGhostteaRuntime(); - const interactive = session.readWrite; + const clientReadWrite = readWrite && inputPolicy !== "read-only"; + const interactive = session.readWrite && clientReadWrite; const canvasRef = useRef(null); const inputRef = useRef(null); const gridRef = useRef({ cols: session.cols, rows: session.rows }); + const controlsResizeRef = useRef(controlsResize); + const clientReadWriteRef = useRef(clientReadWrite); const [viewId] = useState(() => crypto.randomUUID()); const [inputFocused, setInputFocused] = useState(false); const selectionAnchorRef = useRef(null); @@ -129,6 +139,11 @@ function TerminalSurfaceSession({ const scrollbarRef = useRef(scrollbar); const [scrollbarVisible, setScrollbarVisible] = useState(false); + useEffect(() => { + controlsResizeRef.current = controlsResize; + clientReadWriteRef.current = clientReadWrite; + }, [clientReadWrite, controlsResize]); + const setLocalSelection = useCallback( (selection: { anchor: CellPoint; focus: CellPoint } | null, selectAll = false): void => { selectionRef.current = selection; @@ -308,6 +323,7 @@ function TerminalSurfaceSession({ const canvas = canvasRef.current; if (!canvas) return; const handle = terminalRuntime.mount(session.id, session.handle, viewId, canvas); + terminalRuntime.setViewInputPolicy(viewId, clientReadWriteRef.current); let { cols, rows } = gridRef.current; const observer = new ResizeObserver(([entry]) => { if (!entry) return; @@ -318,7 +334,7 @@ function TerminalSurfaceSession({ cols = nextCols; rows = nextRows; gridRef.current = { cols, rows }; - terminalRuntime.resize(session.id, viewId, cols, rows); + if (controlsResizeRef.current) terminalRuntime.resize(session.id, viewId, cols, rows); } }); observer.observe(canvas); @@ -328,6 +344,10 @@ function TerminalSurfaceSession({ }; }, [session.handle, session.id, terminalRuntime, viewId]); + useEffect(() => { + terminalRuntime.setViewInputPolicy(viewId, clientReadWrite); + }, [clientReadWrite, terminalRuntime, viewId]); + useEffect(() => { terminalRuntime.setVisible(session.handle, visible, viewId); }, [session.handle, terminalRuntime, viewId, visible]); @@ -371,9 +391,13 @@ function TerminalSurfaceSession({ ); useEffect(() => { - if (!controlsResize || !interactive) return; + if (!controlsResize || !interactive) { + terminalRuntime.releaseResizeControl(viewId); + return; + } const { cols, rows } = gridRef.current; terminalRuntime.claimResizeControl(session.handle, viewId, cols, rows); + return () => terminalRuntime.releaseResizeControl(viewId); }, [controlsResize, interactive, session.handle, terminalRuntime, viewId]); useEffect(() => { diff --git a/packages/ghosttea-react/src/index.ts b/packages/ghosttea-react/src/index.ts index f1efcf46..156a7482 100644 --- a/packages/ghosttea-react/src/index.ts +++ b/packages/ghosttea-react/src/index.ts @@ -1,5 +1,19 @@ export { GhostteaProvider, useGhostteaRuntime, type GhostteaProviderProps } from "./context.js"; -export { TerminalSurface, type TerminalMenuAction, type TerminalSurfaceProps } from "./TerminalSurface.js"; +export { + TerminalSurface, + type TerminalInputPolicy, + type TerminalMenuAction, + type TerminalSurfaceProps, +} from "./TerminalSurface.js"; +export { + expireRoutedActivationLeases, + initialRoutedActivation, + reduceRoutedActivation, + type RoutedActivationEvent, + type RoutedActivationPhase, + type RoutedActivationState, + type RoutedInputPolicy, +} from "./routed-activation.js"; export { rendererTheme, terminalEffectsFromConfig, terminalThemeFromConfig } from "./config.js"; export { GhostteaTerminalRuntime, @@ -7,7 +21,12 @@ export { waitForGhostteaRendererPorts, type GhostteaRendererPlatform, type GhostteaRendererPorts, + type GhostteaPortTerminalRuntimeOptions, + type GhostteaRoutedHost, + type GhostteaRoutedTerminalRuntimeOptions, type GhostteaTerminalRuntimeOptions, + type RoutedTerminalInputContext, + type RoutedTerminalInputOperation, sessionIsFrozen, showsStaleScreen, type RemoteInputSuppression, @@ -24,4 +43,8 @@ export { type TerminalShaderEffect, type TerminalTheme, } from "./renderers/types.js"; -export type { TerminalRenderMetrics, TerminalRenderPerformanceSnapshot } from "./performance.js"; +export type { + TerminalRenderCounterSnapshot, + TerminalRenderMetrics, + TerminalRenderPerformanceSnapshot, +} from "./performance.js"; diff --git a/packages/ghosttea-react/src/performance.ts b/packages/ghosttea-react/src/performance.ts index 912cd4e9..0bd30ead 100644 --- a/packages/ghosttea-react/src/performance.ts +++ b/packages/ghosttea-react/src/performance.ts @@ -64,6 +64,41 @@ export interface TerminalRenderPerformanceSnapshot { }; } +/** Cheap, monotonic production counters. Reading them never waits for GPU idle. */ +export interface TerminalRenderCounterSnapshot { + backend: string; + durationMs: number; + frames: { + received: number; + bytes: number; + full: number; + incremental: number; + stale: number; + decodes: number; + applies: number; + }; + renderer: { + queueSubmits: number; + presents: number; + }; + flow: { + creditBytesReturned: number; + creditBatchesReturned: number; + }; + sessions: Record< + string, + { + received: number; + bytes: number; + full: number; + incremental: number; + stale: number; + decodes: number; + applies: number; + } + >; +} + export function emptyRenderMetrics(): TerminalRenderMetrics { return { queueSubmits: 0, diff --git a/packages/ghosttea-react/src/routed-activation.test.ts b/packages/ghosttea-react/src/routed-activation.test.ts new file mode 100644 index 00000000..f044fa0c --- /dev/null +++ b/packages/ghosttea-react/src/routed-activation.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import type { RoutedCellActivationStatus, RoutedPresentationStatus } from "@vibecook/ghosttea-protocol"; +import { initialRoutedActivation, reduceRoutedActivation } from "./routed-activation"; + +const stamp = { + sceneEpoch: { cellBootId: "cell-a", modelGeneration: 1 }, + sceneRevision: 4, +}; + +function attached() { + let state = initialRoutedActivation("session", "activation"); + state = reduceRoutedActivation(state, { type: "ticket-minted", endpointsPresent: true }); + state = reduceRoutedActivation(state, { type: "transport-ready" }); + state = reduceRoutedActivation(state, { type: "control-attached", grantGeneration: 2, rights: ["input", "read"] }); + state = reduceRoutedActivation(state, { + type: "frames-attached", + outcome: { kind: "seed-required", reason: "no-cursor" }, + resumeToken: "resume-1", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + }); + return state; +} + +function workerStatus(sequence = 1): RoutedPresentationStatus { + return { + activationId: "activation", + workerStatusSequence: sequence, + state: "active", + sceneContent: stamp, + leaseTtlMs: 2_000, + }; +} + +function cellStatus( + presentation: "presenting" | "stopped" | "revoked" = "presenting", + input: "allowed" | "suspended" | "revoked" = "allowed", + sequence = 1, + reason?: string, +): RoutedCellActivationStatus { + return { + sessionId: "session", + activationId: "activation", + cellStatusSequence: sequence, + leaseTtlMs: 6_000, + acceptedContent: stamp, + presentation: { state: presentation, ...(reason === undefined ? {} : { reason }) }, + input: { state: input, ...(reason === undefined ? {} : { reason }) }, + }; +} + +describe("routed activation authority", () => { + it("abandons an activation on attach-deadline and preserves the replacement CAS", () => { + let state = initialRoutedActivation("session", "activation"); + state = reduceRoutedActivation(state, { type: "ticket-minted", endpointsPresent: true }); + state = reduceRoutedActivation(state, { type: "transport-ready" }); + state = reduceRoutedActivation(state, { type: "attach-deadline" }); + expect(state.phase).toBe("recovering"); + expect(state.replacesActivationId).toBe("activation"); + }); + + it("keeps presentation ready while lag suspends input", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + expect(state.phase).toBe("presenting"); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(true); + + state = reduceRoutedActivation(state, { + type: "cell-status", + status: cellStatus("presenting", "suspended", 2, "lagging"), + now: 200, + }); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(false); + }); + + it("becomes ready when the cell lease arrives before the legs and scene commit", () => { + let state = initialRoutedActivation("session", "activation"); + state = reduceRoutedActivation(state, { type: "ticket-minted", endpointsPresent: true }); + state = reduceRoutedActivation(state, { type: "transport-ready" }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + state = reduceRoutedActivation(state, { + type: "control-attached", + grantGeneration: 2, + rights: ["input", "read"], + }); + state = reduceRoutedActivation(state, { + type: "frames-attached", + outcome: { kind: "seed-required", reason: "no-cursor" }, + resumeToken: "resume-1", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + }); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + expect(state.phase).toBe("seeding"); + expect(state.presentationReady).toBe(false); + + state = reduceRoutedActivation(state, { type: "sync-complete", appliedContent: stamp }); + expect(state.phase).toBe("presenting"); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(true); + }); + + it("moves a presenting activation to stalled when presentation stops", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + state = reduceRoutedActivation(state, { + type: "cell-status", + status: cellStatus("stopped", "suspended", 2, "overload"), + now: 200, + }); + expect(state.phase).toBe("stalled"); + expect(state.presentationReady).toBe(false); + expect(state.inputAllowed).toBe(false); + }); + + it("waits until the local scene covers the cell's accepted content", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { + type: "cell-status", + status: { ...cellStatus(), acceptedContent: { ...stamp, sceneRevision: 5 } }, + now: 100, + }); + expect(state.phase).toBe("presenting"); + expect(state.presentationReady).toBe(false); + expect(state.inputAllowed).toBe(false); + + state = reduceRoutedActivation(state, { + type: "presentation-status", + status: { ...workerStatus(2), sceneContent: { ...stamp, sceneRevision: 5 } }, + now: 200, + }); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(true); + }); + + it("recovers on leg loss and fences route-stale input immediately", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "leg-lost", channel: "frames", resumeCapable: true }); + expect(state.phase).toBe("recovering"); + expect(state.replacesActivationId).toBeUndefined(); + + state = reduceRoutedActivation(state, { type: "route-stale" }); + expect(state.phase).toBe("recovering"); + expect(state.replacesActivationId).toBe("activation"); + expect(state.inputAllowed).toBe(false); + }); + + it("surfaces a persistent protocol failure with the failure-matrix reason", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "leg-lost", channel: "frames", resumeCapable: false }); + state = reduceRoutedActivation(state, { + type: "transport-failed", + recoveryExhausted: true, + reason: "protocol", + }); + expect(state.phase).toBe("unavailable"); + expect(state.unavailableReason).toBe("protocol"); + }); + + it("never opens input for a client-declared read-only view", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "input-policy", policy: "read-only" }); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(false); + }); + + it("applies a renewal rights downgrade without interrupting presentation", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "control-attached", grantGeneration: 3, rights: ["read"] }); + expect(state.phase).toBe("presenting"); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(false); + expect(state.grantGeneration).toBe(3); + }); + + it("closes input at the local renewal margin until a newer grant is accepted", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + expect(state.inputAllowed).toBe(true); + + state = reduceRoutedActivation(state, { type: "grant-expiring" }); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(false); + + state = reduceRoutedActivation(state, { + type: "control-attached", + grantGeneration: 3, + rights: ["input", "read"], + }); + expect(state.presentationReady).toBe(true); + expect(state.inputAllowed).toBe(true); + }); + + it("recovers when the cell refuses a renewal without reopening input", () => { + let state = attached(); + state = reduceRoutedActivation(state, { type: "presentation-status", status: workerStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "cell-status", status: cellStatus(), now: 100 }); + state = reduceRoutedActivation(state, { type: "grant-expiring" }); + state = reduceRoutedActivation(state, { + type: "attach-refused", + code: "GRANT_INVALID", + retryable: false, + }); + + expect(state.phase).toBe("recovering"); + expect(state.presentationReady).toBe(false); + expect(state.inputAllowed).toBe(false); + expect(state.grantInputValid).toBe(false); + expect(state.replacesActivationId).toBe("activation"); + }); +}); diff --git a/packages/ghosttea-react/src/routed-activation.ts b/packages/ghosttea-react/src/routed-activation.ts new file mode 100644 index 00000000..cbc6a95e --- /dev/null +++ b/packages/ghosttea-react/src/routed-activation.ts @@ -0,0 +1,373 @@ +import { + compareRoutedSceneContent, + type RoutedCellActivationStatus, + type RoutedFramesAttachOutcome, + type RoutedFramesLegState, + type RoutedPresentationStatus, + type RoutedSceneContentStamp, + type RoutedSessionRight, + type RoutedTrfIdentity, +} from "@vibecook/ghosttea-protocol"; + +export type RoutedActivationPhase = + | "unresolved" + | "connecting" + | "attaching" + | "seeding" + | "resuming" + | "presenting" + | "stalled" + | "recovering" + | "unavailable" + | "ended"; + +export type RoutedInputPolicy = "read-write" | "read-only"; + +export interface RoutedActivationState { + sessionId: string; + activationId: string; + phase: RoutedActivationPhase; + inputPolicy: RoutedInputPolicy; + unavailableReason?: string; + replacesActivationId?: string; + controlAttached: boolean; + framesAttached: boolean; + framesOutcome?: RoutedFramesAttachOutcome; + trfIdentity?: RoutedTrfIdentity; + resumeToken?: string; + appliedContent?: RoutedSceneContentStamp; + rights: readonly RoutedSessionRight[]; + grantGeneration: number; + grantInputValid: boolean; + lastCellStatusSequence: number; + lastWorkerStatusSequence: number; + cellStatus?: RoutedCellActivationStatus; + framesState?: RoutedFramesLegState; + presentationStatus?: RoutedPresentationStatus; + cellLeaseDeadline: number; + workerLeaseDeadline: number; + presentationReady: boolean; + inputAllowed: boolean; + preAuthRemintUsed: boolean; +} + +export type RoutedActivationEvent = + | { type: "ticket-minted"; endpointsPresent: boolean } + | { type: "no-route"; reason: string } + | { type: "transport-ready" } + | { + type: "transport-failed"; + preAuth?: boolean; + retryable?: boolean; + recoveryExhausted?: boolean; + reason?: string; + } + | { type: "control-attached"; grantGeneration: number; rights: readonly RoutedSessionRight[] } + | { + type: "frames-attached"; + outcome: RoutedFramesAttachOutcome; + trfIdentity: RoutedTrfIdentity; + resumeToken: string; + } + | { type: "attach-refused"; code: string; retryable: boolean } + | { type: "attach-deadline" } + | { type: "sync-complete"; appliedContent: RoutedSceneContentStamp } + | { type: "sync-failed" } + | { type: "cell-status"; status: RoutedCellActivationStatus; now: number } + | { type: "presentation-status"; status: RoutedPresentationStatus; now: number } + | { type: "frames-state"; state: RoutedFramesLegState } + | { type: "cell-lease-expired" } + | { type: "worker-lease-expired" } + | { type: "leg-lost"; channel: "control" | "frames"; resumeCapable: boolean } + | { type: "route-stale" } + | { type: "grant-expiring" } + | { type: "renew-failed" } + | { type: "renewed"; grantGeneration: number; rights: readonly RoutedSessionRight[] } + | { type: "input-policy"; policy: RoutedInputPolicy } + | { type: "retry" } + | { type: "detach" | "replaced" | "runtime-destroyed" }; + +export function initialRoutedActivation( + sessionId: string, + activationId: string, + inputPolicy: RoutedInputPolicy = "read-write", +): RoutedActivationState { + return { + sessionId, + activationId, + phase: "unresolved", + inputPolicy, + controlAttached: false, + framesAttached: false, + rights: [], + grantGeneration: 0, + grantInputValid: true, + lastCellStatusSequence: -1, + lastWorkerStatusSequence: -1, + cellLeaseDeadline: 0, + workerLeaseDeadline: 0, + presentationReady: false, + inputAllowed: false, + preAuthRemintUsed: false, + }; +} + +function withReadiness(state: RoutedActivationState): RoutedActivationState { + const acceptedContent = state.cellStatus?.acceptedContent; + const sceneComparison = + acceptedContent === undefined || state.appliedContent === undefined + ? undefined + : compareRoutedSceneContent(state.appliedContent, acceptedContent); + const sceneCoversAccepted = acceptedContent === undefined || sceneComparison === 0 || sceneComparison === 1; + const presentationReady = + state.phase === "presenting" && + state.controlAttached && + state.framesAttached && + state.cellLeaseDeadline > 0 && + state.workerLeaseDeadline > 0 && + state.cellStatus?.presentation.state === "presenting" && + state.presentationStatus?.state === "active" && + sceneCoversAccepted; + const inputAllowed = + presentationReady && + state.inputPolicy === "read-write" && + state.grantInputValid && + state.rights.includes("input") && + state.cellStatus?.input.state === "allowed"; + if (state.presentationReady === presentationReady && state.inputAllowed === inputAllowed) return state; + return { ...state, presentationReady, inputAllowed }; +} + +function recovering(state: RoutedActivationState, unavailableReason?: string): RoutedActivationState { + return withReadiness({ + ...state, + phase: "recovering", + ...(unavailableReason === undefined ? {} : { unavailableReason }), + inputAllowed: false, + presentationReady: false, + }); +} + +function ended(state: RoutedActivationState): RoutedActivationState { + return { + ...state, + phase: "ended", + inputAllowed: false, + presentationReady: false, + cellLeaseDeadline: 0, + workerLeaseDeadline: 0, + }; +} + +function phaseAfterBothLegs(state: RoutedActivationState): RoutedActivationPhase { + if (!state.controlAttached || !state.framesAttached) return "attaching"; + return state.framesOutcome?.kind === "resume-accepted" ? "resuming" : "seeding"; +} + +/** + * Executable activation table for the main-thread authority. Unknown or stale + * events are ignored, which lets delayed WebSocket and worker messages arrive + * without resurrecting an abandoned activation. + */ +export function reduceRoutedActivation( + state: RoutedActivationState, + event: RoutedActivationEvent, +): RoutedActivationState { + if (state.phase === "ended") return state; + if (event.type === "detach" || event.type === "replaced" || event.type === "runtime-destroyed") { + return ended(state); + } + if (event.type === "input-policy") return withReadiness({ ...state, inputPolicy: event.policy }); + + switch (event.type) { + case "ticket-minted": + if (!["unresolved", "recovering", "unavailable"].includes(state.phase)) return state; + if (!event.endpointsPresent) { + return { + ...state, + phase: "unavailable", + unavailableReason: "transport-not-landed", + presentationReady: false, + inputAllowed: false, + }; + } + { + const available = { ...state }; + delete available.unavailableReason; + return { ...available, phase: "connecting" }; + } + case "no-route": + if (state.phase !== "unresolved" && state.phase !== "recovering") return state; + return { + ...state, + phase: "unavailable", + unavailableReason: event.reason, + presentationReady: false, + inputAllowed: false, + }; + case "transport-ready": + if (state.phase !== "connecting" && state.phase !== "recovering") return state; + { + const reset = { ...state }; + delete reset.framesOutcome; + delete reset.trfIdentity; + delete reset.cellStatus; + delete reset.presentationStatus; + return { + ...reset, + phase: "attaching", + controlAttached: false, + framesAttached: false, + cellLeaseDeadline: 0, + workerLeaseDeadline: 0, + presentationReady: false, + inputAllowed: false, + }; + } + case "transport-failed": + if (state.phase !== "connecting" && state.phase !== "attaching" && state.phase !== "recovering") return state; + if (event.recoveryExhausted || (event.preAuth && state.preAuthRemintUsed) || event.retryable === false) { + return { + ...state, + phase: "unavailable", + unavailableReason: event.reason ?? (event.recoveryExhausted ? "recovery-exhausted" : "transport-refused"), + presentationReady: false, + inputAllowed: false, + }; + } + return recovering({ ...state, preAuthRemintUsed: state.preAuthRemintUsed || event.preAuth === true }); + case "control-attached": + if (state.phase !== "attaching" && !state.controlAttached) return state; + return withReadiness({ + ...state, + controlAttached: true, + grantGeneration: Math.max(state.grantGeneration, event.grantGeneration), + rights: [...event.rights], + grantInputValid: true, + phase: state.phase === "attaching" ? phaseAfterBothLegs({ ...state, controlAttached: true }) : state.phase, + }); + case "frames-attached": + if (state.phase !== "attaching" && !(state.phase === "recovering" && state.controlAttached)) return state; + return withReadiness({ + ...state, + framesAttached: true, + framesOutcome: event.outcome, + trfIdentity: event.trfIdentity, + resumeToken: event.resumeToken, + phase: phaseAfterBothLegs({ ...state, framesAttached: true, framesOutcome: event.outcome }), + }); + case "attach-refused": + if (state.phase === "unresolved" || state.phase === "connecting" || state.phase === "unavailable") return state; + if (["SESSION_UNKNOWN", "CAPACITY"].includes(event.code) && !event.retryable) { + return { + ...state, + phase: "unavailable", + unavailableReason: "attach-refused", + presentationReady: false, + inputAllowed: false, + }; + } + return recovering({ ...state, replacesActivationId: state.activationId }); + case "attach-deadline": + return state.phase === "attaching" ? recovering({ ...state, replacesActivationId: state.activationId }) : state; + case "sync-complete": + if (state.phase !== "seeding" && state.phase !== "resuming") return state; + return withReadiness({ + ...state, + appliedContent: event.appliedContent, + phase: state.cellStatus?.presentation.state === "presenting" ? "presenting" : state.phase, + }); + case "sync-failed": + return state.phase === "seeding" || state.phase === "resuming" + ? recovering({ ...state, replacesActivationId: state.activationId }) + : state; + case "cell-status": { + if (event.status.activationId !== state.activationId) return state; + if (event.status.cellStatusSequence <= state.lastCellStatusSequence) return state; + const base: RoutedActivationState = { + ...state, + cellStatus: event.status, + lastCellStatusSequence: event.status.cellStatusSequence, + cellLeaseDeadline: event.now + event.status.leaseTtlMs, + }; + if (event.status.presentation.state === "revoked") { + const reason = event.status.presentation.reason; + return reason === "leg-dead" || reason === "stale-route" ? recovering(base) : ended(base); + } + if (event.status.presentation.state === "stopped") { + return withReadiness({ ...base, phase: "stalled", presentationReady: false, inputAllowed: false }); + } + if (["seeding", "resuming", "presenting", "stalled"].includes(state.phase)) { + return withReadiness({ ...base, phase: "presenting" }); + } + return withReadiness(base); + } + case "presentation-status": + if (event.status.activationId !== state.activationId) return state; + if (event.status.workerStatusSequence <= state.lastWorkerStatusSequence) return state; + return withReadiness({ + ...state, + presentationStatus: event.status, + lastWorkerStatusSequence: event.status.workerStatusSequence, + workerLeaseDeadline: event.now + event.status.leaseTtlMs, + ...(event.status.sceneContent === undefined ? {} : { appliedContent: event.status.sceneContent }), + }); + case "frames-state": + if (event.state.activationId !== state.activationId) return state; + return withReadiness({ + ...state, + framesState: event.state, + ...(event.state.resumeToken === undefined ? {} : { resumeToken: event.state.resumeToken }), + ...(event.state.appliedContent === undefined ? {} : { appliedContent: event.state.appliedContent }), + }); + case "cell-lease-expired": + return state.phase === "presenting" || state.phase === "stalled" + ? withReadiness({ ...state, phase: "stalled", cellLeaseDeadline: 0 }) + : state; + case "worker-lease-expired": + return state.phase === "presenting" || state.phase === "stalled" + ? withReadiness({ ...state, phase: "stalled", workerLeaseDeadline: 0 }) + : state; + case "leg-lost": + return recovering({ + ...state, + ...(event.channel === "control" ? { controlAttached: false } : { framesAttached: false }), + ...(event.channel === "frames" && event.resumeCapable && state.resumeToken + ? {} + : { replacesActivationId: state.activationId }), + }); + case "route-stale": + return recovering({ ...state, replacesActivationId: state.activationId }, "stale-route"); + case "grant-expiring": + // Close input at the local renewal margin. Presentation is independent + // and remains live while the host obtains a higher grant generation. + return withReadiness({ ...state, grantInputValid: false }); + case "renew-failed": + return withReadiness({ ...state, grantInputValid: false }); + case "renewed": + return withReadiness({ + ...state, + grantGeneration: Math.max(state.grantGeneration, event.grantGeneration), + rights: [...event.rights], + grantInputValid: true, + }); + case "retry": + if (state.phase !== "unavailable") return state; + { + const retrying = { ...state }; + delete retrying.unavailableReason; + return recovering(retrying); + } + } +} + +export function expireRoutedActivationLeases(state: RoutedActivationState, now: number): RoutedActivationState { + let next = state; + if (next.cellLeaseDeadline > 0 && now >= next.cellLeaseDeadline) { + next = reduceRoutedActivation(next, { type: "cell-lease-expired" }); + } + if (next.workerLeaseDeadline > 0 && now >= next.workerLeaseDeadline) { + next = reduceRoutedActivation(next, { type: "worker-lease-expired" }); + } + return next; +} diff --git a/packages/ghosttea-react/src/routed-control.test.ts b/packages/ghosttea-react/src/routed-control.test.ts new file mode 100644 index 00000000..041d0a78 --- /dev/null +++ b/packages/ghosttea-react/src/routed-control.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ROUTED_PROTOCOL_LIMITS, + encodeRoutedMessage, + type RoutedCellTransportGrant, + type RoutedSessionAttachGrant, +} from "@vibecook/ghosttea-protocol"; +import { RoutedControlTransport, type RoutedControlTransportEvent } from "./routed-control"; + +class FakeSocket extends EventTarget { + readyState = 0; + readonly sent: string[] = []; + + send(data: string): void { + this.sent.push(data); + } + + open(): void { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + } + + receive(data: string): void { + this.dispatchEvent(new MessageEvent("message", { data })); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + const event = new Event("close") as Event & { code: number; reason: string }; + Object.assign(event, { code, reason }); + this.dispatchEvent(event); + } +} + +const cellBootId = "cell-a"; +const transportGrant: RoutedCellTransportGrant = { + protected: { + v: 1, + typ: "CellTransportGrant", + iss: "fieldd", + alg: "HS256", + kid: { cellBootId, keyGeneration: 1 }, + }, + claims: { + audienceCellBootId: cellBootId, + clientId: "client-a", + connectionSetId: "set-a", + allowedChannels: ["control", "frames"], + transportGrantGeneration: 1, + issuedAt: 1, + expiresAt: 2, + nonce: "nonce-a", + }, + mac: "opaque", +}; +const attachGrant: RoutedSessionAttachGrant = { + protected: { + v: 1, + typ: "SessionAttachGrant", + iss: "fieldd", + alg: "HS256", + kid: { cellBootId, keyGeneration: 1 }, + }, + claims: { + audienceCellBootId: cellBootId, + clientId: "client-a", + sessionId: "session-a", + leaseEpoch: 1, + routeRevision: 1, + grantGeneration: 1, + rights: ["input", "read"], + issuedAt: 1, + expiresAt: 2, + }, + mac: "opaque", +}; + +function attach(transport: RoutedControlTransport): void { + transport.attach({ + cellBootId, + controlUrl: "ws://127.0.0.1/control", + transportGrant, + attachGrant, + activationId: "activation-a", + initialDemand: { mode: "live" }, + }); +} + +describe("main-thread routed control transport", () => { + it("closes a leg whose heartbeat acknowledgements stop", async () => { + const socket = new FakeSocket(); + const events: RoutedControlTransportEvent[] = []; + const transport = new RoutedControlTransport({ + socketFactory: () => socket as unknown as WebSocket, + emit: (event) => events.push(event), + }); + attach(transport); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "control", + legGeneration: 1, + heartbeatTtlMs: 10, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(events).toContainEqual({ + type: "transport-closed", + cellBootId, + code: 4004, + reason: "heartbeat timeout", + activationIds: ["activation-a"], + preAuth: false, + }); + transport.dispose(); + }); + + it("preserves structured refusals and distinguishes a silent pre-auth close", () => { + const refusalSocket = new FakeSocket(); + const refusalEvents: RoutedControlTransportEvent[] = []; + const refused = new RoutedControlTransport({ + socketFactory: () => refusalSocket as unknown as WebSocket, + emit: (event) => refusalEvents.push(event), + }); + attach(refused); + refusalSocket.open(); + refusalSocket.receive(encodeRoutedMessage("ConnectionRefused", { code: "SET_CHANNEL_BUSY", retryable: true })); + expect(refusalEvents.at(-1)).toMatchObject({ + type: "transport-closed", + preAuth: false, + refusal: { code: "SET_CHANNEL_BUSY", retryable: true }, + }); + + const preAuthSocket = new FakeSocket(); + const preAuthEvents: RoutedControlTransportEvent[] = []; + const preAuth = new RoutedControlTransport({ + socketFactory: () => preAuthSocket as unknown as WebSocket, + emit: (event) => preAuthEvents.push(event), + }); + attach(preAuth); + preAuthSocket.open(); + preAuthSocket.close(1008, ""); + expect(preAuthEvents.at(-1)).toMatchObject({ type: "transport-closed", code: 1008, preAuth: true }); + refused.dispose(); + preAuth.dispose(); + }); + + it("rejects control attach acknowledgements that exceed the bound grant", () => { + const socket = new FakeSocket(); + const events: RoutedControlTransportEvent[] = []; + const transport = new RoutedControlTransport({ + socketFactory: () => socket as unknown as WebSocket, + emit: (event) => events.push(event), + }); + attach(transport); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "control", + legGeneration: 1, + heartbeatTtlMs: 15_000, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + socket.receive( + encodeRoutedMessage("ControlLegAttached", { + sessionId: "session-a", + activationId: "activation-a", + grantGenerationAccepted: 1, + rights: ["geometry", "input", "read"], + }), + ); + + expect(events).toContainEqual( + expect.objectContaining({ type: "transport-closed", code: 4003, activationIds: ["activation-a"] }), + ); + expect(events.some((event) => event.type === "control-attached")).toBe(false); + transport.dispose(); + }); + + it("does not let a fire-and-forget release consume another activation's geometry reply", () => { + const socket = new FakeSocket(); + const events: RoutedControlTransportEvent[] = []; + const transport = new RoutedControlTransport({ + socketFactory: () => socket as unknown as WebSocket, + emit: (event) => events.push(event), + }); + attach(transport); + const activationB = "activation-b"; + transport.attach({ + cellBootId, + controlUrl: "ws://127.0.0.1/control", + transportGrant, + attachGrant: { + ...attachGrant, + claims: { ...attachGrant.claims, sessionId: "session-b" }, + }, + activationId: activationB, + initialDemand: { mode: "live" }, + }); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "control", + legGeneration: 1, + heartbeatTtlMs: 15_000, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + expect( + transport.releaseGeometry("activation-a", { + sessionId: "session-a", + activationId: "activation-a", + leaseEpoch: 1, + holder: { clientId: "client-a", viewId: "view-a", holderGeneration: 1 }, + }), + ).toBe(true); + expect( + transport.claimGeometry(activationB, { + sessionId: "session-b", + activationId: activationB, + leaseEpoch: 1, + claimant: { clientId: "client-a", viewId: "view-b" }, + cols: 80, + rows: 24, + expectRevision: 0, + }), + ).toBe(true); + socket.receive( + encodeRoutedMessage("GeometryCommitted", { + holder: { clientId: "client-a", viewId: "view-b", holderGeneration: 1 }, + geometryRevision: 1, + cols: 80, + rows: 24, + }), + ); + + expect(events.at(-1)).toMatchObject({ type: "geometry-committed", activationId: activationB }); + transport.dispose(); + }); +}); diff --git a/packages/ghosttea-react/src/routed-control.ts b/packages/ghosttea-react/src/routed-control.ts new file mode 100644 index 00000000..dcc79ce3 --- /dev/null +++ b/packages/ghosttea-react/src/routed-control.ts @@ -0,0 +1,469 @@ +import { + ROUTED_CLOSE_CODES, + ROUTED_DOOR_LIMITS, + ROUTED_LEG_OUTBOUND, + ROUTED_PROTOCOL_VERSION, + decodeRoutedMessage, + encodeRoutedMessage, + type RoutedCellActivationStatus, + type RoutedCellTransportGrant, + type RoutedClaimGeometry, + type RoutedConnectionAccepted, + type RoutedConnectionRefused, + type RoutedControlLegAttached, + type RoutedDeclareDemand, + type RoutedGeometryCommitted, + type RoutedGeometryRefused, + type RoutedReleaseGeometry, + type RoutedSessionAttachGrant, + type RoutedSourceDemand, + type RoutedTransferGeometry, +} from "@vibecook/ghosttea-protocol"; + +export interface RoutedControlAttachRequest { + cellBootId: string; + controlUrl: string; + transportGrant: RoutedCellTransportGrant; + attachGrant: RoutedSessionAttachGrant; + activationId: string; + replacesActivationId?: string; + initialDemand: RoutedSourceDemand; + capabilities?: string[]; +} + +export type RoutedControlTransportEvent = + | { type: "transport-ready"; cellBootId: string; accepted: RoutedConnectionAccepted } + | { type: "control-attached"; attached: RoutedControlLegAttached } + | { type: "attach-refused"; activationId?: string; code: string; retryable: boolean } + | { type: "cell-status"; status: RoutedCellActivationStatus } + | { type: "demand-accepted"; activationId?: string; demandSequence: number } + | { type: "geometry-committed"; activationId?: string; committed: RoutedGeometryCommitted } + | { type: "geometry-refused"; activationId?: string; refused: RoutedGeometryRefused } + | { + type: "transport-closed"; + cellBootId: string; + code: number; + reason: string; + activationIds: string[]; + preAuth: boolean; + refusal?: RoutedConnectionRefused; + }; + +interface ControlActivation { + request: RoutedControlAttachRequest; + demandSequence: number; + lastDemand: RoutedSourceDemand; +} + +interface ControlConnection { + cellBootId: string; + controlUrl: string; + transportGrant: RoutedCellTransportGrant; + capabilities: string[]; + socket: WebSocket; + accepted?: RoutedConnectionAccepted; + activationIds: Set; + pendingGeometry: string[]; + heartbeatSequence: number; + heartbeatAckSequence: number; + heartbeatTimer?: number; + heartbeatDeadlineTimer?: number; + refusal?: RoutedConnectionRefused; +} + +const socketOpen = 1; +const socketClosing = 2; + +/** Main/input-thread T1 control connection pool. */ +export class RoutedControlTransport { + readonly #socketFactory: (url: string) => WebSocket; + readonly #emit: (event: RoutedControlTransportEvent) => void; + readonly #connections = new Map(); + readonly #activations = new Map(); + #disposed = false; + + constructor(options: { + socketFactory?: (url: string) => WebSocket; + emit: (event: RoutedControlTransportEvent) => void; + }) { + this.#socketFactory = options.socketFactory ?? ((url) => new WebSocket(url)); + this.#emit = options.emit; + } + + attach(request: RoutedControlAttachRequest): void { + if (this.#disposed) return; + const claims = request.attachGrant.claims; + if ( + claims.sessionId.length === 0 || + request.activationId.length === 0 || + claims.audienceCellBootId !== request.cellBootId || + request.transportGrant.claims.audienceCellBootId !== request.cellBootId + ) { + throw new Error("Routed control attach grant does not match the requested cell"); + } + const existing = this.#activations.get(request.activationId); + if (existing) this.detach(request.activationId); + const activation: ControlActivation = { request, demandSequence: 0, lastDemand: request.initialDemand }; + this.#activations.set(request.activationId, activation); + const connection = this.#connection(request); + connection.activationIds.add(request.activationId); + if (connection.accepted) this.#sendAttach(connection, activation); + } + + renew(activationId: string, attachGrant: RoutedSessionAttachGrant): void { + const activation = this.#activations.get(activationId); + if (!activation) return; + const request = { ...activation.request }; + delete request.replacesActivationId; + activation.request = { ...request, attachGrant }; + const connection = this.#connections.get(activation.request.cellBootId); + if (connection?.accepted) this.#sendAttach(connection, activation); + } + + declareDemand(activationId: string, demand: RoutedSourceDemand): number | undefined { + const activation = this.#activations.get(activationId); + if (!activation) return undefined; + const connection = this.#connections.get(activation.request.cellBootId); + if (!connection?.accepted) return undefined; + activation.demandSequence += 1; + activation.lastDemand = demand; + const body: RoutedDeclareDemand = { + sessionId: activation.request.attachGrant.claims.sessionId, + activationId, + leaseEpoch: activation.request.attachGrant.claims.leaseEpoch ?? 0, + demandSequence: activation.demandSequence, + demand, + }; + this.#send(connection, encodeRoutedMessage("DeclareDemand", body)); + return activation.demandSequence; + } + + claimGeometry(activationId: string, claim: RoutedClaimGeometry): boolean { + if (claim.activationId !== activationId) return false; + return this.#sendGeometry(activationId, encodeRoutedMessage("ClaimGeometry", claim)); + } + + releaseGeometry(activationId: string, release: RoutedReleaseGeometry): boolean { + if (release.activationId !== activationId) return false; + // A successful release is intentionally fire-and-forget on T1; only a + // refusal produces a response. Do not leave an unanswerable FIFO entry + // that would steal the next claim/transfer acknowledgement. + return this.#sendActivation(activationId, encodeRoutedMessage("ReleaseGeometry", release)); + } + + transferGeometry(activationId: string, transfer: RoutedTransferGeometry): boolean { + if (transfer.activationId !== activationId) return false; + return this.#sendGeometry(activationId, encodeRoutedMessage("TransferGeometry", transfer)); + } + + /** + * Extension seam for a future terminal-input wire verb. The current T1 + * contract publishes no such tag, so Ghosttea never invents one itself. + */ + sendExtension(activationId: string, message: Readonly>): boolean { + if (typeof message.type !== "string" || message.type.length === 0) return false; + return this.#sendActivation(activationId, JSON.stringify(message)); + } + + detach(activationId: string): void { + const activation = this.#activations.get(activationId); + if (!activation) return; + this.declareDemand(activationId, { mode: "none" }); + this.#activations.delete(activationId); + this.#connections.get(activation.request.cellBootId)?.activationIds.delete(activationId); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const activationId of [...this.#activations.keys()]) this.detach(activationId); + for (const connection of this.#connections.values()) { + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + if (connection.socket.readyState < socketClosing) connection.socket.close(1000, "runtime-destroyed"); + } + this.#connections.clear(); + } + + #connection(request: RoutedControlAttachRequest): ControlConnection { + const existing = this.#connections.get(request.cellBootId); + if ( + existing && + existing.controlUrl === request.controlUrl && + existing.transportGrant.claims.connectionSetId === request.transportGrant.claims.connectionSetId && + existing.socket.readyState < socketClosing + ) { + return existing; + } + if (existing) this.#closeConnection(existing, 1000, "connection-replaced", true); + const socket = this.#socketFactory(request.controlUrl); + const connection: ControlConnection = { + cellBootId: request.cellBootId, + controlUrl: request.controlUrl, + transportGrant: request.transportGrant, + capabilities: request.capabilities ?? ["resume"], + socket, + activationIds: new Set(), + pendingGeometry: [], + heartbeatSequence: 0, + heartbeatAckSequence: 0, + }; + this.#connections.set(request.cellBootId, connection); + socket.addEventListener("open", () => this.#hello(connection)); + socket.addEventListener("message", (event) => this.#message(connection, event)); + socket.addEventListener("close", (event) => this.#closed(connection, event.code, event.reason)); + socket.addEventListener("error", () => undefined); + return connection; + } + + #hello(connection: ControlConnection): void { + this.#send( + connection, + encodeRoutedMessage("ConnectionHello", { + protocolMajor: ROUTED_PROTOCOL_VERSION.major, + protocolMinor: ROUTED_PROTOCOL_VERSION.minor, + channel: "control", + transportGrant: connection.transportGrant, + capabilities: connection.capabilities, + }), + ); + } + + #message(connection: ControlConnection, event: MessageEvent): void { + if (this.#connections.get(connection.cellBootId) !== connection) return; + if (typeof event.data !== "string") { + this.#closeConnection(connection, 4003, "binary on control leg", true); + return; + } + const maxBytes = connection.accepted?.protocolLimits.maxControlMessageBytes ?? 262_144; + if (new TextEncoder().encode(event.data).byteLength > maxBytes) { + this.#closeConnection(connection, 4003, "control message too large", true); + return; + } + const decoded = decodeRoutedMessage(event.data, ROUTED_LEG_OUTBOUND.control); + if (!decoded.ok) { + this.#closeConnection(connection, 4003, `invalid control message: ${decoded.error}`, true); + return; + } + const message = decoded.message; + if (message.type === "ConnectionAccepted") { + if ( + connection.accepted || + message.channel !== "control" || + message.connectionSetId !== connection.transportGrant.claims.connectionSetId || + message.selectedProtocolVersion.major !== ROUTED_PROTOCOL_VERSION.major || + message.selectedProtocolVersion.minor > ROUTED_PROTOCOL_VERSION.minor || + message.capabilities.some((capability) => !connection.capabilities.includes(capability)) + ) { + this.#closeConnection(connection, 4003, "invalid control acceptance", true); + return; + } + connection.accepted = message; + this.#emit({ type: "transport-ready", cellBootId: connection.cellBootId, accepted: message }); + this.#armHeartbeatDeadline(connection); + this.#scheduleHeartbeat(connection); + for (const activationId of connection.activationIds) { + const activation = this.#activations.get(activationId); + if (activation) this.#sendAttach(connection, activation); + } + return; + } + if (message.type === "ConnectionRefused") { + connection.refusal = message; + this.#closeConnection(connection, 1000, message.code, true); + return; + } + if (!connection.accepted) { + this.#closeConnection(connection, 4003, "message before ConnectionAccepted", true); + return; + } + if (message.type === "LegHeartbeatAck") { + if (message.sequence > connection.heartbeatSequence) { + this.#closeConnection(connection, ROUTED_CLOSE_CODES.PROTOCOL, "heartbeat ack is ahead", true); + return; + } + if (message.sequence > connection.heartbeatAckSequence) { + connection.heartbeatAckSequence = message.sequence; + this.#armHeartbeatDeadline(connection); + } + return; + } + if (message.type === "ControlLegAttached") { + const activation = this.#activations.get(message.activationId); + if (!connection.activationIds.has(message.activationId) && !activation) return; + if ( + !activation || + !connection.activationIds.has(message.activationId) || + message.sessionId !== activation.request.attachGrant.claims.sessionId || + message.grantGenerationAccepted !== activation.request.attachGrant.claims.grantGeneration || + message.rights.some((right) => !activation.request.attachGrant.claims.rights.includes(right)) + ) { + this.#closeConnection(connection, ROUTED_CLOSE_CODES.PROTOCOL, "control attach binding mismatch", true); + return; + } + this.#emit({ type: "control-attached", attached: message }); + return; + } + if (message.type === "AttachRefused") { + if (message.activationId !== undefined && !connection.activationIds.has(message.activationId)) { + if (!this.#activations.has(message.activationId)) return; + this.#closeConnection(connection, ROUTED_CLOSE_CODES.PROTOCOL, "attach refusal binding mismatch", true); + return; + } + this.#emit({ + type: "attach-refused", + ...(message.activationId === undefined ? {} : { activationId: message.activationId }), + code: message.code, + retryable: message.retryable, + }); + return; + } + if (message.type === "CellActivationStatus") { + const activation = this.#activations.get(message.activationId); + if (!connection.activationIds.has(message.activationId) && !activation) return; + if ( + !activation || + !connection.activationIds.has(message.activationId) || + message.sessionId !== activation.request.attachGrant.claims.sessionId + ) { + this.#closeConnection(connection, ROUTED_CLOSE_CODES.PROTOCOL, "cell status binding mismatch", true); + return; + } + this.#emit({ type: "cell-status", status: message }); + return; + } + if (message.type === "DemandAccepted") { + const activationId = this.#activationForDemand(connection, message.demandSequence); + this.#emit({ + type: "demand-accepted", + ...(activationId === undefined ? {} : { activationId }), + demandSequence: message.demandSequence, + }); + return; + } + if (message.type === "GeometryCommitted") { + const activationId = connection.pendingGeometry.shift(); + this.#emit({ + type: "geometry-committed", + ...(activationId === undefined ? {} : { activationId }), + committed: message, + }); + return; + } + if (message.type === "GeometryRefused") { + const activationId = connection.pendingGeometry.shift(); + this.#emit({ + type: "geometry-refused", + ...(activationId === undefined ? {} : { activationId }), + refused: message, + }); + return; + } + this.#closeConnection(connection, 4003, `unexpected control message ${message.type}`, true); + } + + #activationForDemand(connection: ControlConnection, sequence: number): string | undefined { + for (const activationId of connection.activationIds) { + if (this.#activations.get(activationId)?.demandSequence === sequence) return activationId; + } + return undefined; + } + + #sendAttach(connection: ControlConnection, activation: ControlActivation): void { + this.#send( + connection, + encodeRoutedMessage("AttachControlLeg", { + activationId: activation.request.activationId, + attachGrant: activation.request.attachGrant, + ...(activation.request.replacesActivationId === undefined + ? {} + : { replacesActivationId: activation.request.replacesActivationId }), + initialDemand: activation.lastDemand, + }), + ); + } + + #sendActivation(activationId: string, text: string): boolean { + const activation = this.#activations.get(activationId); + if (!activation) return false; + const connection = this.#connections.get(activation.request.cellBootId); + if (!connection?.accepted) return false; + const bytes = new TextEncoder().encode(text).byteLength; + if (bytes > connection.accepted.protocolLimits.maxControlMessageBytes) return false; + this.#send(connection, text); + return true; + } + + #sendGeometry(activationId: string, text: string): boolean { + const activation = this.#activations.get(activationId); + if (!activation) return false; + const connection = this.#connections.get(activation.request.cellBootId); + if (!connection || !this.#sendActivation(activationId, text)) return false; + connection.pendingGeometry.push(activationId); + return true; + } + + #scheduleHeartbeat(connection: ControlConnection): void { + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + connection.heartbeatTimer = setTimeout( + () => { + if (!connection.accepted || connection.socket.readyState !== socketOpen) return; + connection.heartbeatSequence += 1; + this.#send( + connection, + encodeRoutedMessage("LegHeartbeat", { + connectionSetId: connection.accepted.connectionSetId, + channel: "control", + legGeneration: connection.accepted.legGeneration, + sequence: connection.heartbeatSequence, + }), + ); + this.#scheduleHeartbeat(connection); + }, + Math.max( + 100, + Math.min( + ROUTED_DOOR_LIMITS.heartbeatIntervalMs, + Math.floor((connection.accepted?.heartbeatTtlMs ?? ROUTED_DOOR_LIMITS.heartbeatTtlMs) / 3), + ), + ), + ); + } + + #armHeartbeatDeadline(connection: ControlConnection): void { + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + const ttl = connection.accepted?.heartbeatTtlMs ?? ROUTED_DOOR_LIMITS.heartbeatTtlMs; + connection.heartbeatDeadlineTimer = setTimeout(() => { + if (this.#connections.get(connection.cellBootId) !== connection) return; + this.#closeConnection(connection, ROUTED_CLOSE_CODES.LEG_TIMEOUT, "heartbeat timeout", true); + }, ttl); + } + + #send(connection: ControlConnection, text: string): void { + if (connection.socket.readyState === socketOpen) connection.socket.send(text); + } + + #closeConnection(connection: ControlConnection, code: number, reason: string, closeSocket: boolean): void { + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + if (closeSocket && connection.socket.readyState < socketClosing) + connection.socket.close(code, reason.slice(0, 123)); + this.#closed(connection, code, reason); + } + + #closed(connection: ControlConnection, code: number, reason: string): void { + if (this.#connections.get(connection.cellBootId) !== connection) return; + this.#connections.delete(connection.cellBootId); + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + this.#emit({ + type: "transport-closed", + cellBootId: connection.cellBootId, + code, + reason, + activationIds: [...connection.activationIds], + preAuth: connection.refusal === undefined && connection.accepted === undefined && code === 1008, + ...(connection.refusal === undefined ? {} : { refusal: connection.refusal }), + }); + } +} diff --git a/packages/ghosttea-react/src/routed-frames.test.ts b/packages/ghosttea-react/src/routed-frames.test.ts new file mode 100644 index 00000000..fa619f2b --- /dev/null +++ b/packages/ghosttea-react/src/routed-frames.test.ts @@ -0,0 +1,575 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ROUTED_PROTOCOL_LIMITS, + DEFAULT_ROUTED_RECEIVER_CAPACITIES, + encodeRoutedMessage, + encodeRoutedPresentationEnvelope, + routedCrc32c, + type RoutedCellTransportGrant, + type RoutedPresentationEnvelopeHeader, + type RoutedSessionAttachGrant, +} from "@vibecook/ghosttea-protocol"; +import { RoutedFramesTransport, type RoutedFramesTransportEvent } from "./routed-frames"; + +class FakeSocket extends EventTarget { + readyState = 0; + binaryType = "blob"; + readonly sent: Array = []; + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + this.sent.push(data); + } + + open(): void { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + } + + receive(data: string | ArrayBuffer): void { + this.dispatchEvent(new MessageEvent("message", { data })); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + const event = new Event("close") as Event & { code: number; reason: string }; + Object.assign(event, { code, reason }); + this.dispatchEvent(event); + } +} + +const cellBootId = "cell-a"; +const sessionId = "session-a"; +const activationId = "activation-a"; + +const transportGrant: RoutedCellTransportGrant = { + protected: { + v: 1, + typ: "CellTransportGrant", + iss: "fieldd", + alg: "HS256", + kid: { cellBootId, keyGeneration: 1 }, + }, + claims: { + audienceCellBootId: cellBootId, + clientId: "window", + connectionSetId: "set-a", + allowedChannels: ["control", "frames"], + transportGrantGeneration: 1, + issuedAt: 1, + expiresAt: 2, + nonce: "nonce", + }, + mac: "opaque", +}; + +const attachGrant: RoutedSessionAttachGrant = { + protected: { + v: 1, + typ: "SessionAttachGrant", + iss: "fieldd", + alg: "HS256", + kid: { cellBootId, keyGeneration: 1 }, + }, + claims: { + audienceCellBootId: cellBootId, + clientId: "window", + sessionId, + leaseEpoch: 4, + routeRevision: 1, + grantGeneration: 1, + rights: ["input", "read"], + issuedAt: 1, + expiresAt: 2, + }, + mac: "opaque", +}; + +function stamp(revision: number) { + return { sceneEpoch: { cellBootId, modelGeneration: 1 }, sceneRevision: revision }; +} + +describe("worker-owned routed frames transport", () => { + it("uses calibration pings as frames-leg liveness and accepts the bounded echo", async () => { + const socket = new FakeSocket(); + const events: RoutedFramesTransportEvent[] = []; + const transport = new RoutedFramesTransport({ + socketFactory: () => socket as unknown as WebSocket, + applyFrame: () => ({ sessionHandle: "11", viewHandle: "12", cols: 80, rows: 24 }), + emit: (event) => events.push(event), + }); + transport.attach({ + cellBootId, + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/frames", + transportGrant, + attachGrant, + activationId, + }); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 300, + creditEpoch: 1, + initialWindows: DEFAULT_ROUTED_RECEIVER_CAPACITIES, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 120)); + const ping = socket.sent + .filter((item): item is string => typeof item === "string") + .map((item) => JSON.parse(item) as { type: string; sequence?: number; t0?: number }) + .find((item) => item.type === "CalibrationPing"); + expect(ping).toMatchObject({ type: "CalibrationPing", sequence: 1, t0: expect.any(Number) }); + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 0, + sessionId: "-", + activationId: "-", + leaseEpoch: 0, + kind: "calibration", + calibration: { sequence: ping!.sequence!, t0: ping!.t0!, t1: Date.now(), t2: Date.now() }, + }, + new Uint8Array(), + ).buffer as ArrayBuffer, + ); + expect(events.some((event) => event.type === "transport-closed")).toBe(false); + transport.dispose(); + }); + + it("rejects windows larger than the worker advertised", () => { + const socket = new FakeSocket(); + const events: RoutedFramesTransportEvent[] = []; + const transport = new RoutedFramesTransport({ + socketFactory: () => socket as unknown as WebSocket, + applyFrame: () => ({ sessionHandle: "11", viewHandle: "12", cols: 80, rows: 24 }), + emit: (event) => events.push(event), + }); + transport.attach({ + cellBootId, + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/frames", + transportGrant, + attachGrant, + activationId, + }); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 15_000, + creditEpoch: 1, + initialWindows: { + ...DEFAULT_ROUTED_RECEIVER_CAPACITIES, + connectionCreditBytes: DEFAULT_ROUTED_RECEIVER_CAPACITIES.connectionCreditBytes + 1, + }, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + + expect(events).toContainEqual( + expect.objectContaining({ type: "transport-closed", code: 4003, activationIds: [activationId] }), + ); + expect( + socket.sent + .filter((item): item is string => typeof item === "string") + .map((item) => JSON.parse(item) as { type: string }) + .some((item) => item.type === "AttachFramesLeg"), + ).toBe(false); + transport.dispose(); + }); + + it("omits resume when the frames connection did not negotiate it", () => { + const socket = new FakeSocket(); + const events: RoutedFramesTransportEvent[] = []; + const transport = new RoutedFramesTransport({ + socketFactory: () => socket as unknown as WebSocket, + applyFrame: () => ({ sessionHandle: "11", viewHandle: "12", cols: 80, rows: 24 }), + emit: (event) => events.push(event), + }); + transport.attach({ + cellBootId, + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/frames", + transportGrant, + attachGrant, + activationId, + resume: { resumeToken: "resume-a", from: stamp(1) }, + capabilities: ["resume"], + }); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 15_000, + creditEpoch: 1, + initialWindows: DEFAULT_ROUTED_RECEIVER_CAPACITIES, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: [], + }), + ); + + const sent = JSON.parse(socket.sent.at(-1) as string) as Record; + expect(sent.type).toBe("AttachFramesLeg"); + expect(sent).not.toHaveProperty("resume"); + socket.receive( + encodeRoutedMessage("FramesLegAttached", { + sessionId, + activationId, + resumeToken: "resume-b", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + outcome: { kind: "seed-required", reason: "no-resume-capability" }, + }), + ); + expect(events.some((event) => event.type === "transport-closed")).toBe(false); + transport.dispose(); + }); + + it("applies envelopes without a main-thread relay and stages catch-up atomically", async () => { + const socket = new FakeSocket(); + const events: RoutedFramesTransportEvent[] = []; + const applies: Uint8Array[] = []; + const transport = new RoutedFramesTransport({ + socketFactory: () => socket as unknown as WebSocket, + applyFrame: (packet, identity, expectedLayout) => { + expect(identity).toEqual({ sessionHandle: "11", viewHandle: "12" }); + if (expectedLayout) expect(expectedLayout).toEqual({ cols: 80, rows: 24, scrollbackRows: 0 }); + applies.push(new Uint8Array(packet).slice()); + return { sessionHandle: "11", viewHandle: "12", cols: 80, rows: 24 }; + }, + emit: (event) => events.push(event), + }); + transport.attach({ + cellBootId, + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/frames", + transportGrant, + attachGrant, + activationId, + capabilities: ["resume"], + }); + socket.open(); + expect(JSON.parse(socket.sent[0] as string)).toMatchObject({ type: "ConnectionHello", channel: "frames" }); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 15_000, + creditEpoch: 1, + initialWindows: DEFAULT_ROUTED_RECEIVER_CAPACITIES, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + expect(JSON.parse(socket.sent.at(-1) as string)).toMatchObject({ type: "AttachFramesLeg", activationId }); + socket.receive( + encodeRoutedMessage("FramesLegAttached", { + sessionId, + activationId, + resumeToken: "resume-a", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + outcome: { kind: "seed-required", reason: "no-cursor" }, + }), + ); + + const fullHeader: RoutedPresentationEnvelopeHeader = { + creditEpoch: 1, + activationSequence: 1, + sessionId, + activationId, + leaseEpoch: 4, + kind: "trf1-frame", + baseContent: null, + resultContent: stamp(1), + }; + socket.receive(encodeRoutedPresentationEnvelope(fullHeader, Uint8Array.of(9, 8, 7)).buffer as ArrayBuffer); + expect(applies).toEqual([Uint8Array.of(9, 8, 7)]); + expect(events).toContainEqual( + expect.objectContaining({ type: "frames-state", activationId, state: "active", appliedContent: stamp(1) }), + ); + expect(socket.sent.map((item) => (typeof item === "string" ? JSON.parse(item).type : "binary"))).toContain( + "SceneApplied", + ); + + const staged = Uint8Array.of(1, 2, 3, 4); + const begin: RoutedPresentationEnvelopeHeader = { + creditEpoch: 1, + activationSequence: 2, + sessionId, + activationId, + leaseEpoch: 4, + kind: "transfer-begin", + baseContent: stamp(1), + resultContent: stamp(2), + transfer: { + transferId: "catchup-1", + kind: "catchup", + totalBytes: staged.byteLength, + chunkCount: 2, + targetLayout: { cols: 80, rows: 24, scrollbackRows: 0 }, + checksum: { alg: "crc32c", value: routedCrc32c(staged) }, + }, + }; + socket.receive(encodeRoutedPresentationEnvelope(begin, new Uint8Array()).buffer as ArrayBuffer); + for (const [index, payload] of [staged.subarray(0, 2), staged.subarray(2)].entries()) { + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 3 + index, + sessionId, + activationId, + leaseEpoch: 4, + kind: "transfer-chunk", + transfer: { transferId: "catchup-1", chunkIndex: index, byteOffset: index * 2 }, + }, + payload, + ).buffer as ArrayBuffer, + ); + } + expect(applies).toHaveLength(1); + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 5, + sessionId, + activationId, + leaseEpoch: 4, + kind: "transfer-end", + transfer: { transferId: "catchup-1" }, + }, + new Uint8Array(), + ).buffer as ArrayBuffer, + ); + expect(applies).toEqual([Uint8Array.of(9, 8, 7), staged]); + + await new Promise((resolve) => setTimeout(resolve, 25)); + const credits = socket.sent + .filter((item): item is string => typeof item === "string") + .map((item) => JSON.parse(item) as Record) + .filter((item) => item.type === "TransportCredit"); + expect(credits.at(-1)).toMatchObject({ + creditEpoch: 1, + connectionBytesReturned: expect.any(Number), + accounts: [expect.objectContaining({ activationId, bytesReturned: expect.any(Number) })], + }); + transport.dispose(); + }); + + it("preserves scene and status continuity when the same activation resumes", () => { + const socket = new FakeSocket(); + const events: RoutedFramesTransportEvent[] = []; + const applies: Uint8Array[] = []; + const transport = new RoutedFramesTransport({ + socketFactory: () => socket as unknown as WebSocket, + applyFrame: (packet) => { + applies.push(new Uint8Array(packet).slice()); + return { sessionHandle: "11", viewHandle: "12", cols: 80, rows: 24 }; + }, + emit: (event) => events.push(event), + }); + const request = { + cellBootId, + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/frames", + transportGrant, + attachGrant, + activationId, + capabilities: ["resume"], + }; + transport.attach(request); + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 15_000, + creditEpoch: 1, + initialWindows: DEFAULT_ROUTED_RECEIVER_CAPACITIES, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + socket.receive( + encodeRoutedMessage("FramesLegAttached", { + sessionId, + activationId, + resumeToken: "resume-a", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + outcome: { kind: "seed-required", reason: "no-cursor" }, + }), + ); + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 1, + sessionId, + activationId, + leaseEpoch: 4, + kind: "trf1-frame", + baseContent: null, + resultContent: stamp(1), + }, + Uint8Array.of(1), + ).buffer as ArrayBuffer, + ); + + transport.attach({ ...request, resume: { resumeToken: "resume-a", from: stamp(1) } }); + expect(JSON.parse(socket.sent.at(-1) as string)).toMatchObject({ + type: "AttachFramesLeg", + activationId, + resume: { resumeToken: "resume-a", from: stamp(1) }, + }); + socket.receive( + encodeRoutedMessage("FramesLegAttached", { + sessionId, + activationId, + resumeToken: "resume-a", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + outcome: { kind: "resume-accepted", from: stamp(1), newestAvailable: stamp(2) }, + }), + ); + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 2, + sessionId, + activationId, + leaseEpoch: 4, + kind: "trf1-frame", + baseContent: stamp(1), + resultContent: stamp(2), + }, + Uint8Array.of(2), + ).buffer as ArrayBuffer, + ); + + expect(applies).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + expect( + events + .filter( + (event): event is Extract => + event.type === "presentation-status", + ) + .map((event) => event.status.workerStatusSequence), + ).toEqual([1, 2, 3, 4]); + expect(events.some((event) => event.type === "transport-closed")).toBe(false); + transport.dispose(); + }); + + it("protocol-closes a frames connection that names another cell's activation", () => { + const sockets = [new FakeSocket(), new FakeSocket()]; + const events: RoutedFramesTransportEvent[] = []; + const cellB = "cell-b"; + const activationB = "activation-b"; + const transportGrantB: RoutedCellTransportGrant = { + ...transportGrant, + protected: { + ...transportGrant.protected, + kid: { ...transportGrant.protected.kid, cellBootId: cellB }, + }, + claims: { + ...transportGrant.claims, + audienceCellBootId: cellB, + connectionSetId: "set-b", + nonce: "nonce-b", + }, + }; + const attachGrantB: RoutedSessionAttachGrant = { + ...attachGrant, + protected: { + ...attachGrant.protected, + kid: { ...attachGrant.protected.kid, cellBootId: cellB }, + }, + claims: { + ...attachGrant.claims, + audienceCellBootId: cellB, + sessionId: "session-b", + }, + }; + const transport = new RoutedFramesTransport({ + socketFactory: () => sockets.shift()! as unknown as WebSocket, + applyFrame: () => ({ sessionHandle: "11", viewHandle: "12", cols: 80, rows: 24 }), + emit: (event) => events.push(event), + }); + const socketA = sockets[0]!; + const socketB = sockets[1]!; + transport.attach({ + cellBootId, + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/cell-a/frames", + transportGrant, + attachGrant, + activationId, + }); + transport.attach({ + cellBootId: cellB, + sessionHandle: "21", + framesUrl: "ws://127.0.0.1/cell-b/frames", + transportGrant: transportGrantB, + attachGrant: attachGrantB, + activationId: activationB, + }); + for (const [socket, setId] of [ + [socketA, "set-a"], + [socketB, "set-b"], + ] as const) { + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: setId, + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 15_000, + creditEpoch: 1, + initialWindows: DEFAULT_ROUTED_RECEIVER_CAPACITIES, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + } + + socketA.receive( + encodeRoutedMessage("AttachRefused", { + activationId: activationB, + code: "GRANT_INVALID", + retryable: false, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "transport-closed", + cellBootId, + code: 4003, + activationIds: [activationId], + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ type: "frames-state", activationId: activationB, state: "failed" }), + ); + transport.dispose(); + }); +}); diff --git a/packages/ghosttea-react/src/routed-frames.ts b/packages/ghosttea-react/src/routed-frames.ts new file mode 100644 index 00000000..b869b1fa --- /dev/null +++ b/packages/ghosttea-react/src/routed-frames.ts @@ -0,0 +1,873 @@ +import { + DEFAULT_ROUTED_RECEIVER_CAPACITIES, + ROUTED_CLOSE_CODES, + ROUTED_DOOR_LIMITS, + ROUTED_LEG_OUTBOUND, + ROUTED_PROTOCOL_VERSION, + compareRoutedSceneContent, + decodeRoutedMessage, + decodeRoutedPresentationEnvelope, + encodeRoutedMessage, + routedCrc32c, + type RoutedAttachFramesLeg, + type RoutedCellTransportGrant, + type RoutedConnectionAccepted, + type RoutedConnectionRefused, + type RoutedFramesLegAttached, + type RoutedPresentationEnvelopeHeader, + type RoutedPresentationStatus, + type RoutedProtocolLimits, + type RoutedReceiverCapacities, + type RoutedResumeRequest, + type RoutedSceneContentStamp, + type RoutedSessionAttachGrant, + type RoutedTrfIdentity, +} from "@vibecook/ghosttea-protocol"; + +export interface RoutedFramesAttachRequest { + cellBootId: string; + /** Scene key already used by the mounted surfaces for this session. */ + sessionHandle: string; + framesUrl: string; + transportGrant: RoutedCellTransportGrant; + attachGrant: RoutedSessionAttachGrant; + activationId: string; + replacesActivationId?: string; + resume?: RoutedResumeRequest; + receiverCapacities?: RoutedReceiverCapacities; + capabilities?: string[]; +} + +export type RoutedFramesTransportEvent = + | { type: "frames-attached"; attached: RoutedFramesLegAttached } + | { type: "attach-refused"; activationId?: string; code: string; retryable: boolean } + | { + type: "frames-state"; + activationId: string; + state: "attaching" | "resuming" | "seeding" | "applying" | "active" | "failed"; + resumeToken?: string; + appliedContent?: RoutedSceneContentStamp; + reason?: string; + } + | { type: "presentation-status"; status: RoutedPresentationStatus } + | { + type: "transport-closed"; + cellBootId: string; + code: number; + reason: string; + activationIds: string[]; + preAuth: boolean; + refusal?: RoutedConnectionRefused; + }; + +export interface RoutedAppliedFrame { + sessionHandle: string; + viewHandle: string; + cols: number; + rows: number; +} + +export interface RoutedExpectedLayout { + cols: number; + rows: number; + scrollbackRows: number; +} + +export interface RoutedFramesTransportOptions { + socketFactory?: (url: string) => WebSocket; + applyFrame: ( + packet: ArrayBuffer, + identity: RoutedTrfIdentity, + expectedLayout?: RoutedExpectedLayout, + ) => RoutedAppliedFrame; + emit: (event: RoutedFramesTransportEvent) => void; + creditReturned?: (bytes: number) => void; +} + +interface StagedTransfer { + transferId: string; + kind: "seed" | "catchup"; + bytes: Uint8Array; + chunkCount: number; + chunks: Set; + ranges: Array<{ start: number; end: number }>; + checksum: number; + targetLayout: { cols: number; rows: number; scrollbackRows: number }; + baseContent: RoutedSceneContentStamp | null; + resultContent: RoutedSceneContentStamp; +} + +interface FramesActivation { + request: RoutedFramesAttachRequest; + sessionId: string; + leaseEpoch: number; + identity?: RoutedTrfIdentity; + resumeToken?: string; + appliedContent?: RoutedSceneContentStamp; + lastActivationSequence: number; + workerStatusSequence: number; + transfer?: StagedTransfer; + sceneRefreshTimer?: number; + presentationRefreshTimer?: number; +} + +interface FramesConnection { + cellBootId: string; + framesUrl: string; + transportGrant: RoutedCellTransportGrant; + receiverCapacities: RoutedReceiverCapacities; + capabilities: string[]; + socket: WebSocket; + accepted?: RoutedConnectionAccepted; + activationIds: Set; + heartbeatSequence: number; + heartbeatAckSequence: number; + heartbeatTimer?: number; + heartbeatDeadlineTimer?: number; + creditSequence: number; + connectionBytesReturned: number; + connectionBytesReported: number; + accountBytesReturned: Map; + accountDrainTimers: Map; + creditTimer?: number; + refusal?: RoutedConnectionRefused; +} + +const socketOpen = 1; +const socketClosing = 2; + +function exactStamp(left: RoutedSceneContentStamp | undefined, right: RoutedSceneContentStamp): boolean { + return left !== undefined && compareRoutedSceneContent(left, right) === 0; +} + +function copyArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = bytes.slice(); + return copy.buffer as ArrayBuffer; +} + +function capacitiesFitWithin(accepted: RoutedReceiverCapacities, advertised: RoutedReceiverCapacities): boolean { + return ( + accepted.connectionCreditBytes <= advertised.connectionCreditBytes && + accepted.perActivationCreditBytes <= advertised.perActivationCreditBytes && + accepted.stagingBytesPerSession <= advertised.stagingBytesPerSession && + accepted.stagingBytesTotal <= advertised.stagingBytesTotal && + accepted.maxConcurrentActivations <= advertised.maxConcurrentActivations && + accepted.maxConcurrentSeeds <= advertised.maxConcurrentSeeds + ); +} + +/** Worker-owned T1 frames connection pool. It never posts presentation bytes to main. */ +export class RoutedFramesTransport { + readonly #socketFactory: (url: string) => WebSocket; + readonly #applyFrame: RoutedFramesTransportOptions["applyFrame"]; + readonly #emit: RoutedFramesTransportOptions["emit"]; + readonly #creditReturned: ((bytes: number) => void) | undefined; + readonly #connections = new Map(); + readonly #activations = new Map(); + #stagingBytes = 0; + #disposed = false; + + constructor(options: RoutedFramesTransportOptions) { + this.#socketFactory = options.socketFactory ?? ((url) => new WebSocket(url)); + this.#applyFrame = options.applyFrame; + this.#emit = options.emit; + this.#creditReturned = options.creditReturned; + } + + attach(request: RoutedFramesAttachRequest): void { + if (this.#disposed) return; + const claims = request.attachGrant.claims; + if ( + claims.sessionId.length === 0 || + request.activationId.length === 0 || + !/^(0|[1-9][0-9]*)$/.test(request.sessionHandle) || + claims.audienceCellBootId !== request.cellBootId || + request.transportGrant.claims.audienceCellBootId !== request.cellBootId + ) { + throw new Error("Routed frames attach grant does not match the requested cell"); + } + const previous = this.#activations.get(request.activationId); + if ( + previous?.appliedContent !== undefined && + request.resume !== undefined && + !exactStamp(previous.appliedContent, request.resume.from) + ) { + throw new Error("Routed frames resume does not name the worker's applied scene"); + } + const appliedContent = request.resume?.from ?? previous?.appliedContent; + const lastActivationSequence = previous?.lastActivationSequence ?? -1; + const workerStatusSequence = previous?.workerStatusSequence ?? 0; + if (previous) this.detach(request.activationId); + const activation: FramesActivation = { + request, + sessionId: claims.sessionId, + leaseEpoch: claims.leaseEpoch ?? 0, + ...(appliedContent === undefined ? {} : { appliedContent }), + lastActivationSequence, + workerStatusSequence, + }; + this.#activations.set(request.activationId, activation); + this.#emit({ type: "frames-state", activationId: request.activationId, state: "attaching" }); + const connection = this.#connection(request); + connection.activationIds.add(request.activationId); + if (connection.accepted) this.#sendAttach(connection, activation); + } + + detach(activationId: string): void { + const activation = this.#activations.get(activationId); + if (!activation) return; + this.#releaseTransfer(activation); + if (activation.sceneRefreshTimer !== undefined) clearTimeout(activation.sceneRefreshTimer); + if (activation.presentationRefreshTimer !== undefined) clearTimeout(activation.presentationRefreshTimer); + this.#activations.delete(activationId); + const connection = this.#connections.get(activation.request.cellBootId); + if (connection) { + connection.activationIds.delete(activationId); + this.#scheduleAccountDrain(connection, activationId); + } + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const activationId of [...this.#activations.keys()]) this.detach(activationId); + for (const connection of this.#connections.values()) { + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + if (connection.creditTimer !== undefined) clearTimeout(connection.creditTimer); + for (const timer of connection.accountDrainTimers.values()) clearTimeout(timer); + if (connection.socket.readyState < socketClosing) connection.socket.close(1000, "runtime-destroyed"); + } + this.#connections.clear(); + } + + #connection(request: RoutedFramesAttachRequest): FramesConnection { + const existing = this.#connections.get(request.cellBootId); + if ( + existing && + existing.framesUrl === request.framesUrl && + existing.transportGrant.claims.connectionSetId === request.transportGrant.claims.connectionSetId && + existing.socket.readyState < socketClosing + ) { + return existing; + } + if (existing) this.#closeConnection(existing, 1000, "connection-replaced", true); + const socket = this.#socketFactory(request.framesUrl); + socket.binaryType = "arraybuffer"; + const connection: FramesConnection = { + cellBootId: request.cellBootId, + framesUrl: request.framesUrl, + transportGrant: request.transportGrant, + receiverCapacities: request.receiverCapacities ?? DEFAULT_ROUTED_RECEIVER_CAPACITIES, + capabilities: request.capabilities ?? ["resume"], + socket, + activationIds: new Set(), + heartbeatSequence: 0, + heartbeatAckSequence: 0, + creditSequence: 0, + connectionBytesReturned: 0, + connectionBytesReported: 0, + accountBytesReturned: new Map(), + accountDrainTimers: new Map(), + }; + this.#connections.set(request.cellBootId, connection); + socket.addEventListener("open", () => this.#hello(connection)); + socket.addEventListener("message", (event) => void this.#message(connection, event)); + socket.addEventListener("close", (event) => this.#closed(connection, event.code, event.reason)); + socket.addEventListener("error", () => { + // The close event carries the protocol-classifying code. Browsers expose + // no useful error detail here and logging a grant-bearing request is forbidden. + }); + return connection; + } + + #hello(connection: FramesConnection): void { + this.#send( + connection, + encodeRoutedMessage("ConnectionHello", { + protocolMajor: ROUTED_PROTOCOL_VERSION.major, + protocolMinor: ROUTED_PROTOCOL_VERSION.minor, + channel: "frames", + transportGrant: connection.transportGrant, + receiverCapacities: connection.receiverCapacities, + capabilities: connection.capabilities, + }), + ); + } + + async #message(connection: FramesConnection, event: MessageEvent): Promise { + if (this.#connections.get(connection.cellBootId) !== connection) return; + if (typeof event.data === "string") { + this.#text(connection, event.data); + return; + } + let bytes: Uint8Array; + if (event.data instanceof ArrayBuffer) bytes = new Uint8Array(event.data); + else if (ArrayBuffer.isView(event.data)) { + bytes = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + } else if (event.data instanceof Blob) { + bytes = new Uint8Array(await event.data.arrayBuffer()); + } else { + this.#protocolFailure(connection, undefined, 0, "unsupported binary message"); + return; + } + this.#binary(connection, bytes); + } + + #text(connection: FramesConnection, text: string): void { + const maxBytes = connection.accepted?.protocolLimits.maxControlMessageBytes ?? 262_144; + if (new TextEncoder().encode(text).byteLength > maxBytes) { + this.#protocolFailure(connection, undefined, 0, "frames control message too large"); + return; + } + const decoded = decodeRoutedMessage(text, ROUTED_LEG_OUTBOUND.frames); + if (!decoded.ok) { + this.#protocolFailure(connection, undefined, 0, `invalid frames message: ${decoded.error}`); + return; + } + const message = decoded.message; + if (message.type === "ConnectionAccepted") { + if ( + connection.accepted || + message.channel !== "frames" || + message.connectionSetId !== connection.transportGrant.claims.connectionSetId || + message.selectedProtocolVersion.major !== ROUTED_PROTOCOL_VERSION.major || + message.selectedProtocolVersion.minor > ROUTED_PROTOCOL_VERSION.minor || + message.capabilities.some((capability) => !connection.capabilities.includes(capability)) || + message.creditEpoch === undefined || + message.initialWindows === undefined || + !capacitiesFitWithin(message.initialWindows, connection.receiverCapacities) + ) { + this.#protocolFailure(connection, undefined, 0, "invalid frames acceptance"); + return; + } + connection.accepted = message; + this.#armHeartbeatDeadline(connection); + this.#scheduleHeartbeat(connection); + for (const activationId of connection.activationIds) { + const activation = this.#activations.get(activationId); + if (activation) this.#sendAttach(connection, activation); + } + return; + } + if (message.type === "ConnectionRefused") { + connection.refusal = message; + this.#closeConnection(connection, 1000, message.code, true); + return; + } + if (!connection.accepted) { + this.#protocolFailure(connection, undefined, 0, "message before ConnectionAccepted"); + return; + } + if (message.type === "LegHeartbeatAck") { + if (message.sequence > connection.heartbeatSequence) { + this.#protocolFailure(connection, undefined, 0, "heartbeat ack is ahead"); + return; + } + if (message.sequence > connection.heartbeatAckSequence) { + connection.heartbeatAckSequence = message.sequence; + this.#armHeartbeatDeadline(connection); + } + return; + } + if (message.type === "AttachRefused") { + if (message.activationId !== undefined && !connection.activationIds.has(message.activationId)) { + if (!this.#activations.has(message.activationId)) return; + this.#protocolFailure(connection, undefined, 0, "attach refusal binding mismatch"); + return; + } + this.#emit({ + type: "attach-refused", + ...(message.activationId === undefined ? {} : { activationId: message.activationId }), + code: message.code, + retryable: message.retryable, + }); + return; + } + if (message.type === "FramesLegAttached") { + const activation = this.#activations.get(message.activationId); + if (!connection.activationIds.has(message.activationId) && !activation) return; + const duplicateIdentity = [...connection.activationIds].some((activationId) => { + if (activationId === message.activationId) return false; + const identity = this.#activations.get(activationId)?.identity; + return ( + identity?.sessionHandle === message.trfIdentity.sessionHandle && + identity.viewHandle === message.trfIdentity.viewHandle + ); + }); + if ( + !activation || + !connection.activationIds.has(message.activationId) || + message.sessionId !== activation.sessionId || + message.trfIdentity.sessionHandle !== activation.request.sessionHandle || + duplicateIdentity || + (message.outcome.kind === "resume-accepted" && + (!connection.accepted.capabilities.includes("resume") || + activation.request.resume === undefined || + message.outcome.from === undefined || + !exactStamp(activation.request.resume.from, message.outcome.from))) + ) { + this.#protocolFailure(connection, message.activationId, 0, "frames attach identity mismatch"); + return; + } + activation.identity = message.trfIdentity; + activation.resumeToken = message.resumeToken; + this.#emit({ type: "frames-attached", attached: message }); + this.#emit({ + type: "frames-state", + activationId: message.activationId, + state: message.outcome.kind === "resume-accepted" ? "resuming" : "seeding", + resumeToken: message.resumeToken, + }); + this.#reportPresentation( + connection, + activation, + message.outcome.kind === "resume-accepted" ? "recovering" : "seeding", + ); + return; + } + this.#protocolFailure(connection, undefined, 0, `unexpected frames message ${message.type}`); + } + + #sendAttach(connection: FramesConnection, activation: FramesActivation): void { + const resume = connection.accepted?.capabilities.includes("resume") ? activation.request.resume : undefined; + const body: RoutedAttachFramesLeg = { + activationId: activation.request.activationId, + attachGrant: activation.request.attachGrant, + ...(resume === undefined ? {} : { resume }), + }; + this.#send(connection, encodeRoutedMessage("AttachFramesLeg", body)); + } + + #binary(connection: FramesConnection, bytes: Uint8Array): void { + if (!connection.accepted) { + this.#protocolFailure(connection, undefined, bytes.byteLength, "binary message before acceptance"); + return; + } + const decoded = decodeRoutedPresentationEnvelope(bytes); + if (!decoded.ok) { + this.#protocolFailure(connection, undefined, bytes.byteLength, `bad envelope: ${decoded.error}`); + return; + } + const { header, payload } = decoded.envelope; + if (header.creditEpoch !== connection.accepted.creditEpoch) { + this.#protocolFailure(connection, header.activationId, decoded.chargedBytes, "credit epoch mismatch"); + return; + } + if (header.profiling !== undefined && !connection.accepted.capabilities.includes("profiling-envelope")) { + this.#protocolFailure(connection, header.activationId, decoded.chargedBytes, "unnegotiated profiling envelope"); + return; + } + if (header.kind === "calibration") { + if (payload.byteLength !== 0) { + this.#protocolFailure(connection, undefined, decoded.chargedBytes, "calibration payload is not empty"); + return; + } + const sequence = header.calibration!.sequence; + if (sequence > connection.heartbeatSequence) { + this.#protocolFailure(connection, undefined, decoded.chargedBytes, "calibration echo is ahead"); + return; + } + if (sequence > connection.heartbeatAckSequence) { + connection.heartbeatAckSequence = sequence; + this.#armHeartbeatDeadline(connection); + } + this.#returnCredit(connection, undefined, decoded.chargedBytes); + return; + } + const activation = this.#activations.get(header.activationId); + if (!activation || !connection.activationIds.has(header.activationId) || !activation.identity) { + this.#returnCredit(connection, header.activationId, decoded.chargedBytes); + if (activation) + this.#protocolFailure(connection, header.activationId, 0, "presentation activation binding mismatch"); + return; + } + if ( + header.sessionId !== activation.sessionId || + header.leaseEpoch !== activation.leaseEpoch || + header.activationSequence <= activation.lastActivationSequence + ) { + this.#protocolFailure( + connection, + header.activationId, + decoded.chargedBytes, + "presentation identity or sequence mismatch", + ); + return; + } + activation.lastActivationSequence = header.activationSequence; + if ((header.kind === "transfer-begin" || header.kind === "transfer-end") && payload.byteLength !== 0) { + this.#protocolFailure(connection, header.activationId, decoded.chargedBytes, "transfer metadata carries payload"); + return; + } + switch (header.kind) { + case "trf1-frame": + this.#incremental(connection, activation, header, payload, decoded.chargedBytes); + break; + case "transfer-begin": + this.#beginTransfer(connection, activation, header, decoded.chargedBytes); + break; + case "transfer-chunk": + this.#transferChunk(connection, activation, header, payload, decoded.chargedBytes); + break; + case "transfer-end": + this.#endTransfer(connection, activation, header, decoded.chargedBytes); + break; + } + } + + #incremental( + connection: FramesConnection, + activation: FramesActivation, + header: RoutedPresentationEnvelopeHeader, + payload: Uint8Array, + chargedBytes: number, + ): void { + if ( + payload.byteLength > connection.accepted!.protocolLimits.maxPresentationChunkBytes || + !header.resultContent || + header.baseContent === undefined || + (header.baseContent !== null && !exactStamp(activation.appliedContent, header.baseContent)) + ) { + this.#protocolFailure(connection, header.activationId, chargedBytes, "incremental base mismatch"); + return; + } + try { + this.#emit({ type: "frames-state", activationId: header.activationId, state: "applying" }); + this.#applyFrame(copyArrayBuffer(payload), activation.identity!); + activation.appliedContent = header.resultContent; + this.#returnCredit(connection, header.activationId, chargedBytes); + this.#sceneApplied(connection, activation); + } catch (error) { + this.#protocolFailure(connection, header.activationId, chargedBytes, String(error)); + } + } + + #beginTransfer( + connection: FramesConnection, + activation: FramesActivation, + header: RoutedPresentationEnvelopeHeader, + chargedBytes: number, + ): void { + const transfer = header.transfer; + const limits = connection.accepted!.protocolLimits; + const windows = connection.accepted!.initialWindows!; + if ( + activation.transfer || + !transfer?.kind || + transfer.totalBytes === undefined || + transfer.chunkCount === undefined || + !transfer.targetLayout || + !transfer.checksum || + !header.resultContent || + transfer.totalBytes > windows.stagingBytesPerSession || + this.#stagingBytes + transfer.totalBytes > windows.stagingBytesTotal || + (transfer.kind === "catchup" && transfer.totalBytes > limits.maxCatchupBytes) || + (transfer.kind === "catchup" && + (header.baseContent == null || !exactStamp(activation.appliedContent, header.baseContent))) + ) { + this.#protocolFailure(connection, header.activationId, chargedBytes, "transfer budget or header invalid"); + return; + } + const activeSeeds = [...this.#activations.values()].filter( + (candidate) => candidate.transfer?.kind === "seed", + ).length; + if (transfer.kind === "seed" && activeSeeds >= windows.maxConcurrentSeeds) { + this.#protocolFailure(connection, header.activationId, chargedBytes, "seed concurrency exceeded"); + return; + } + activation.transfer = { + transferId: transfer.transferId, + kind: transfer.kind, + bytes: new Uint8Array(transfer.totalBytes), + chunkCount: transfer.chunkCount, + chunks: new Set(), + ranges: [], + checksum: transfer.checksum.value, + targetLayout: transfer.targetLayout, + baseContent: header.baseContent ?? null, + resultContent: header.resultContent, + }; + this.#stagingBytes += transfer.totalBytes; + this.#returnCredit(connection, header.activationId, chargedBytes); + this.#emit({ type: "frames-state", activationId: header.activationId, state: "seeding" }); + } + + #transferChunk( + connection: FramesConnection, + activation: FramesActivation, + header: RoutedPresentationEnvelopeHeader, + payload: Uint8Array, + chargedBytes: number, + ): void { + const staging = activation.transfer; + const transfer = header.transfer; + const chunkIndex = transfer?.chunkIndex; + const byteOffset = transfer?.byteOffset; + if ( + !staging || + transfer?.transferId !== staging.transferId || + chunkIndex === undefined || + byteOffset === undefined || + chunkIndex >= staging.chunkCount || + staging.chunks.has(chunkIndex) || + payload.byteLength > connection.accepted!.protocolLimits.maxPresentationChunkBytes || + byteOffset + payload.byteLength > staging.bytes.byteLength + ) { + this.#protocolFailure(connection, header.activationId, chargedBytes, "transfer chunk invalid"); + return; + } + const end = byteOffset + payload.byteLength; + if (staging.ranges.some((range) => byteOffset < range.end && end > range.start)) { + this.#protocolFailure(connection, header.activationId, chargedBytes, "transfer chunk overlap"); + return; + } + staging.bytes.set(payload, byteOffset); + staging.chunks.add(chunkIndex); + staging.ranges.push({ start: byteOffset, end }); + // Transfer bytes become reclaimable as soon as the staging copy completes. + this.#returnCredit(connection, header.activationId, chargedBytes); + } + + #endTransfer( + connection: FramesConnection, + activation: FramesActivation, + header: RoutedPresentationEnvelopeHeader, + chargedBytes: number, + ): void { + const staging = activation.transfer; + if (!staging || header.transfer?.transferId !== staging.transferId) { + this.#protocolFailure(connection, header.activationId, chargedBytes, "transfer end without begin"); + return; + } + this.#returnCredit(connection, header.activationId, chargedBytes); + const ranges = [...staging.ranges].sort((left, right) => left.start - right.start); + let cursor = 0; + for (const range of ranges) { + if (range.start !== cursor) { + this.#protocolFailure(connection, header.activationId, 0, "transfer has a range gap"); + return; + } + cursor = range.end; + } + if ( + staging.chunks.size !== staging.chunkCount || + cursor !== staging.bytes.byteLength || + routedCrc32c(staging.bytes) !== staging.checksum || + (staging.kind === "catchup" && + (staging.baseContent === null || !exactStamp(activation.appliedContent, staging.baseContent))) + ) { + this.#protocolFailure(connection, header.activationId, 0, "transfer validation failed"); + return; + } + try { + this.#emit({ type: "frames-state", activationId: header.activationId, state: "applying" }); + this.#applyFrame(staging.bytes.buffer as ArrayBuffer, activation.identity!, staging.targetLayout); + activation.appliedContent = staging.resultContent; + this.#releaseTransfer(activation); + this.#sceneApplied(connection, activation); + } catch (error) { + this.#protocolFailure(connection, header.activationId, 0, String(error)); + } + } + + #releaseTransfer(activation: FramesActivation): void { + if (!activation.transfer) return; + this.#stagingBytes = Math.max(0, this.#stagingBytes - activation.transfer.bytes.byteLength); + delete activation.transfer; + } + + #sceneApplied(connection: FramesConnection, activation: FramesActivation): void { + const appliedContent = activation.appliedContent; + if (!appliedContent) return; + this.#send( + connection, + encodeRoutedMessage("SceneApplied", { + sessionId: activation.sessionId, + activationId: activation.request.activationId, + leaseEpoch: activation.leaseEpoch, + appliedContent, + }), + ); + this.#emit({ + type: "frames-state", + activationId: activation.request.activationId, + state: "active", + ...(activation.resumeToken === undefined ? {} : { resumeToken: activation.resumeToken }), + appliedContent, + }); + this.#reportPresentation(connection, activation, "active"); + if (activation.sceneRefreshTimer !== undefined) clearTimeout(activation.sceneRefreshTimer); + activation.sceneRefreshTimer = setTimeout( + () => this.#sceneApplied(connection, activation), + connection.accepted!.protocolLimits.sceneAppliedRefreshMs, + ); + } + + #reportPresentation( + connection: FramesConnection, + activation: FramesActivation, + state: RoutedPresentationStatus["state"], + ): void { + activation.workerStatusSequence += 1; + const status: RoutedPresentationStatus = { + activationId: activation.request.activationId, + workerStatusSequence: activation.workerStatusSequence, + state, + ...(activation.appliedContent === undefined ? {} : { sceneContent: activation.appliedContent }), + leaseTtlMs: Math.max( + connection.accepted?.protocolLimits.presentationStatusRefreshMs ?? 2_000, + connection.accepted?.heartbeatTtlMs ?? ROUTED_DOOR_LIMITS.heartbeatTtlMs, + ), + }; + this.#emit({ type: "presentation-status", status }); + if (activation.presentationRefreshTimer !== undefined) clearTimeout(activation.presentationRefreshTimer); + const refreshMs = connection.accepted?.protocolLimits.presentationStatusRefreshMs ?? 2_000; + activation.presentationRefreshTimer = setTimeout( + () => this.#reportPresentation(connection, activation, state), + refreshMs, + ); + } + + #returnCredit(connection: FramesConnection, activationId: string | undefined, bytes: number): void { + if (bytes <= 0) return; + connection.connectionBytesReturned += bytes; + if (activationId) { + connection.accountBytesReturned.set( + activationId, + (connection.accountBytesReturned.get(activationId) ?? 0) + bytes, + ); + if (!connection.activationIds.has(activationId)) this.#scheduleAccountDrain(connection, activationId); + } + const limits = connection.accepted?.protocolLimits; + if (connection.creditTimer !== undefined) return; + connection.creditTimer = setTimeout(() => this.#flushCredit(connection), limits?.maxCreditReturnDelayMs ?? 16); + } + + #flushCredit(connection: FramesConnection): void { + if (connection.creditTimer !== undefined) clearTimeout(connection.creditTimer); + delete connection.creditTimer; + if (!connection.accepted || connection.connectionBytesReturned === 0) return; + connection.creditSequence += 1; + const newlyReturned = connection.connectionBytesReturned - connection.connectionBytesReported; + connection.connectionBytesReported = connection.connectionBytesReturned; + this.#send( + connection, + encodeRoutedMessage("TransportCredit", { + creditEpoch: connection.accepted.creditEpoch!, + creditSequence: connection.creditSequence, + connectionBytesReturned: connection.connectionBytesReturned, + accounts: [...connection.accountBytesReturned].map(([activationId, bytesReturned]) => ({ + activationId, + bytesReturned, + })), + }), + ); + this.#creditReturned?.(newlyReturned); + } + + #scheduleAccountDrain(connection: FramesConnection, activationId: string): void { + const previous = connection.accountDrainTimers.get(activationId); + if (previous !== undefined) clearTimeout(previous); + const ttl = connection.accepted?.protocolLimits.creditAccountDrainTtlMs ?? 5_000; + connection.accountDrainTimers.set( + activationId, + setTimeout(() => { + connection.accountDrainTimers.delete(activationId); + if (!connection.activationIds.has(activationId)) connection.accountBytesReturned.delete(activationId); + }, ttl), + ); + } + + #scheduleHeartbeat(connection: FramesConnection): void { + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + connection.heartbeatTimer = setTimeout( + () => { + if (!connection.accepted || connection.socket.readyState !== socketOpen) return; + connection.heartbeatSequence += 1; + this.#send( + connection, + encodeRoutedMessage("CalibrationPing", { + sequence: connection.heartbeatSequence, + t0: Date.now(), + }), + ); + this.#scheduleHeartbeat(connection); + }, + Math.max( + 100, + Math.min( + ROUTED_DOOR_LIMITS.heartbeatIntervalMs, + Math.floor((connection.accepted?.heartbeatTtlMs ?? ROUTED_DOOR_LIMITS.heartbeatTtlMs) / 3), + ), + ), + ); + } + + #armHeartbeatDeadline(connection: FramesConnection): void { + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + const ttl = connection.accepted?.heartbeatTtlMs ?? ROUTED_DOOR_LIMITS.heartbeatTtlMs; + connection.heartbeatDeadlineTimer = setTimeout(() => { + if (this.#connections.get(connection.cellBootId) !== connection) return; + this.#closeConnection(connection, ROUTED_CLOSE_CODES.LEG_TIMEOUT, "heartbeat timeout", true); + }, ttl); + } + + #protocolFailure( + connection: FramesConnection, + activationId: string | undefined, + chargedBytes: number, + reason: string, + ): void { + this.#returnCredit(connection, activationId, chargedBytes); + this.#flushCredit(connection); + if (activationId) this.#emit({ type: "frames-state", activationId, state: "failed", reason: "PROTOCOL" }); + this.#closeConnection(connection, ROUTED_CLOSE_CODES.PROTOCOL, reason, true); + } + + #send(connection: FramesConnection, text: string): void { + if (connection.socket.readyState === socketOpen) connection.socket.send(text); + } + + #closeConnection(connection: FramesConnection, code: number, reason: string, closeSocket: boolean): void { + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + if (connection.creditTimer !== undefined) clearTimeout(connection.creditTimer); + for (const timer of connection.accountDrainTimers.values()) clearTimeout(timer); + if (closeSocket && connection.socket.readyState < socketClosing) + connection.socket.close(code, reason.slice(0, 123)); + this.#closed(connection, code, reason); + } + + #closed(connection: FramesConnection, code: number, reason: string): void { + if (this.#connections.get(connection.cellBootId) !== connection) return; + this.#connections.delete(connection.cellBootId); + if (connection.heartbeatTimer !== undefined) clearTimeout(connection.heartbeatTimer); + if (connection.heartbeatDeadlineTimer !== undefined) clearTimeout(connection.heartbeatDeadlineTimer); + if (connection.creditTimer !== undefined) clearTimeout(connection.creditTimer); + for (const timer of connection.accountDrainTimers.values()) clearTimeout(timer); + connection.accountDrainTimers.clear(); + const activationIds = [...connection.activationIds]; + for (const activationId of activationIds) { + const activation = this.#activations.get(activationId); + if (!activation) continue; + this.#releaseTransfer(activation); + if (activation.sceneRefreshTimer !== undefined) clearTimeout(activation.sceneRefreshTimer); + if (activation.presentationRefreshTimer !== undefined) clearTimeout(activation.presentationRefreshTimer); + } + this.#emit({ + type: "transport-closed", + cellBootId: connection.cellBootId, + code, + reason, + activationIds, + preAuth: connection.refusal === undefined && connection.accepted === undefined && code === 1008, + ...(connection.refusal === undefined ? {} : { refusal: connection.refusal }), + }); + } +} + +/** Kept local to avoid a second source of protocol defaults in the worker. */ +export function routedCreditDelay(limits: RoutedProtocolLimits | undefined): number { + return limits?.maxCreditReturnDelayMs ?? 16; +} diff --git a/packages/ghosttea-react/src/routed-runtime.test.ts b/packages/ghosttea-react/src/routed-runtime.test.ts new file mode 100644 index 00000000..95668c49 --- /dev/null +++ b/packages/ghosttea-react/src/routed-runtime.test.ts @@ -0,0 +1,522 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_ROUTED_PROTOCOL_LIMITS, + encodeRoutedMessage, + unknownSessionActivity, + type RoutedCellTransportGrant, + type RoutedSessionAttachGrant, + type RoutedTerminalOpenTicket, + type SessionSummary, +} from "@vibecook/ghosttea-protocol"; +import { GhostteaTerminalRuntime, type GhostteaRoutedHost } from "./runtime"; + +class FakeWorker extends EventTarget { + readonly messages: unknown[] = []; + terminated = false; + + postMessage(message: unknown): void { + this.messages.push(message); + } + + emit(data: unknown): void { + this.dispatchEvent(new MessageEvent("message", { data })); + } + + terminate(): void { + this.terminated = true; + } +} + +class FakeSocket extends EventTarget { + readyState = 0; + binaryType = "blob"; + readonly sent: string[] = []; + closeCode: number | undefined; + + send(data: string): void { + this.sent.push(data); + } + + open(): void { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + } + + receive(data: string): void { + this.dispatchEvent(new MessageEvent("message", { data })); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.closeCode = code; + const event = new Event("close") as Event & { code: number; reason: string }; + Object.assign(event, { code, reason }); + this.dispatchEvent(event); + } +} + +function grantPair(cellBootId: string, sessionId: string, connectionSetId: string) { + const protectedBase = { + v: 1 as const, + iss: "fieldd" as const, + alg: "HS256" as const, + kid: { cellBootId, keyGeneration: 1 }, + }; + const transportGrant: RoutedCellTransportGrant = { + protected: { ...protectedBase, typ: "CellTransportGrant" }, + claims: { + audienceCellBootId: cellBootId, + clientId: "window-1", + connectionSetId, + allowedChannels: ["control", "frames"], + transportGrantGeneration: 1, + issuedAt: 1, + expiresAt: 999_999_999_999_999, + nonce: `nonce-${cellBootId}`, + }, + mac: "opaque", + }; + const attachGrant: RoutedSessionAttachGrant = { + protected: { ...protectedBase, typ: "SessionAttachGrant" }, + claims: { + audienceCellBootId: cellBootId, + clientId: "window-1", + sessionId, + leaseEpoch: 4, + routeRevision: 2, + grantGeneration: 1, + rights: ["geometry", "input", "read"], + issuedAt: 1, + expiresAt: 999_999_999_999_999, + }, + mac: "opaque", + }; + return { transportGrant, attachGrant }; +} + +function ticket(cellBootId: string, sessionId: string, connectionSetId: string): RoutedTerminalOpenTicket { + const grants = grantPair(cellBootId, sessionId, connectionSetId); + return { + route: { cellBootId, routeRevision: 2, leaseEpoch: 4 }, + endpoints: { + controlUrl: `ws://127.0.0.1/${cellBootId}/control`, + framesUrl: `ws://127.0.0.1/${cellBootId}/frames`, + }, + ...grants, + }; +} + +function session(id: string, handle: string): SessionSummary { + return { + id, + handle, + executable: "/bin/zsh", + cols: 80, + rows: 24, + exited: false, + readWrite: true, + title: null, + cwd: null, + bellCount: 0, + pid: 1, + createdAtMs: 1, + exitCode: null, + exitSignal: null, + requestedTermination: null, + exitOutcome: null, + ownerId: null, + persistence: null, + activity: unknownSessionActivity(), + }; +} + +function canvas(): HTMLCanvasElement { + return { transferControlToOffscreen: () => ({}) as OffscreenCanvas } as HTMLCanvasElement; +} + +async function flush(): Promise { + for (let index = 0; index < 12; index += 1) await Promise.resolve(); +} + +describe("routed terminal runtime", () => { + it("keeps routed input closed when T1 has no negotiated input encoder", async () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const sockets: FakeSocket[] = []; + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket: async () => ticket("cell-a", "session-a", "set-a") }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + websocketFactory: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + workerFactory: () => worker as unknown as Worker, + }); + const first = session("session-a", "11"); + runtime.registerSession(first); + runtime.mount(first.id, first.handle, "view-a", canvas()); + await flush(); + + expect(runtime.routedActivation(first.id)?.inputPolicy).toBe("read-only"); + expect(runtime.routedViewInputAllowed("view-a")).toBe(false); + const suppressed = vi.fn(); + runtime.addEventListener("routed-input-suppressed", suppressed); + const workerMessagesBeforeInput = worker.messages.length; + runtime.sendText(first.id, "view-a", "blocked"); + expect(worker.messages).toHaveLength(workerMessagesBeforeInput); + expect(suppressed).toHaveBeenCalledOnce(); + expect((suppressed.mock.calls[0]![0] as CustomEvent).detail.reason).toBe("wire-verb-unavailable"); + + runtime.dispose(); + }); + + it("re-mints after a terminal grant refusal instead of retrying the refused grant", async () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const sockets: FakeSocket[] = []; + let generation = 0; + const openTicket = vi.fn(async () => { + const next = ticket("cell-a", "session-a", "set-a"); + generation += 1; + next.transportGrant.claims.transportGrantGeneration = generation; + next.transportGrant.claims.nonce = `nonce-${generation}`; + return next; + }); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + websocketFactory: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + workerFactory: () => worker as unknown as Worker, + }); + const first = session("session-a", "11"); + runtime.registerSession(first); + runtime.mount(first.id, first.handle, "view-a", canvas()); + await flush(); + sockets[0]!.open(); + sockets[0]!.receive( + encodeRoutedMessage("ConnectionRefused", { + code: "GRANT_NONCE_REPLAYED", + retryable: false, + }), + ); + + expect(runtime.routedActivation(first.id)?.phase).toBe("recovering"); + await new Promise((resolve) => setTimeout(resolve, 120)); + await flush(); + expect(openTicket).toHaveBeenCalledTimes(2); + expect(sockets).toHaveLength(2); + expect(generation).toBe(2); + + runtime.dispose(); + }); + + it("re-dials one protocol failure, then makes a persistent failure unavailable", async () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const openTicket = vi.fn(async () => ticket("cell-a", "session-a", "set-a")); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + websocketFactory: () => new FakeSocket() as unknown as WebSocket, + workerFactory: () => worker as unknown as Worker, + }); + const protocolError = vi.fn(); + runtime.addEventListener("routed-protocol-error", protocolError); + const first = session("session-a", "11"); + runtime.registerSession(first); + runtime.mount(first.id, first.handle, "view-a", canvas()); + await flush(); + const firstActivationId = runtime.routedActivation(first.id)!.activationId; + + worker.emit({ + type: "routed-frames-event", + event: { + type: "transport-closed", + cellBootId: "cell-a", + code: 4003, + reason: "first malformed unit", + activationIds: [firstActivationId], + preAuth: false, + }, + }); + expect(runtime.routedActivation(first.id)?.phase).toBe("recovering"); + await new Promise((resolve) => setTimeout(resolve, 120)); + await flush(); + const secondActivationId = runtime.routedActivation(first.id)!.activationId; + expect(secondActivationId).not.toBe(firstActivationId); + expect(openTicket).toHaveBeenCalledTimes(2); + + worker.emit({ + type: "routed-frames-event", + event: { + type: "transport-closed", + cellBootId: "cell-a", + code: 4003, + reason: "second malformed unit", + activationIds: [secondActivationId], + preAuth: false, + }, + }); + expect(runtime.routedActivation(first.id)).toMatchObject({ + phase: "unavailable", + unavailableReason: "protocol", + }); + expect(protocolError).toHaveBeenCalledOnce(); + expect((protocolError.mock.calls[0]![0] as CustomEvent).detail).toEqual({ + sessionId: first.id, + channel: "frames", + reason: "second malformed unit", + }); + expect(worker.messages.at(-1)).toEqual({ + type: "routed-frames-detach", + activationId: secondActivationId, + }); + + runtime.dispose(); + }); + + it("owns two cell connection sets and sends allowed input directly on control", async () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const sockets: FakeSocket[] = []; + const tickets = new Map([ + ["session-a", ticket("cell-a", "session-a", "set-a")], + ["session-b", ticket("cell-b", "session-b", "set-b")], + ]); + const ticketGenerations = new Map(); + const openTicket = vi.fn(async (sessionId: string) => { + const base = tickets.get(sessionId)!; + const generation = (ticketGenerations.get(sessionId) ?? 0) + 1; + ticketGenerations.set(sessionId, generation); + return { + ...base, + transportGrant: { + ...base.transportGrant, + claims: { + ...base.transportGrant.claims, + transportGrantGeneration: generation, + nonce: `nonce-${sessionId}-${generation}`, + }, + }, + }; + }); + const host: GhostteaRoutedHost = { + openTicket, + encodeInput: (context) => ({ + type: "TerminalInput", + activationId: context.activationId, + leaseEpoch: context.leaseEpoch, + inputSequence: context.inputSequence, + operation: context.operation, + }), + }; + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + websocketFactory: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + workerFactory: () => worker as unknown as Worker, + }); + const first = session("session-a", "11"); + const second = session("session-b", "21"); + runtime.registerSession(first); + runtime.registerSession(second); + runtime.mount(first.id, first.handle, "view-a", canvas()); + runtime.mount(second.id, second.handle, "view-b", canvas()); + runtime.claimResizeControl(first.handle, "view-a", 80, 24); + await flush(); + + expect(sockets).toHaveLength(2); + expect( + worker.messages + .filter( + (message): message is { type: "routed-frames-attach"; request: { cellBootId: string } } => + typeof message === "object" && + message !== null && + (message as { type?: string }).type === "routed-frames-attach", + ) + .map((message) => message.request.cellBootId), + ).toEqual(["cell-a", "cell-b"]); + for (const socket of sockets) socket.open(); + const helloA = JSON.parse(sockets[0]!.sent[0]!) as Record; + const helloB = JSON.parse(sockets[1]!.sent[0]!) as Record; + expect([helloA.type, helloB.type]).toEqual(["ConnectionHello", "ConnectionHello"]); + expect(new Set([helloA.channel, helloB.channel])).toEqual(new Set(["control"])); + + for (let index = 0; index < sockets.length; index += 1) { + const setId = index === 0 ? "set-a" : "set-b"; + sockets[index]!.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: setId, + channel: "control", + legGeneration: 1, + heartbeatTtlMs: 15_000, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + } + const attachA = JSON.parse(sockets[0]!.sent.at(-1)!) as { activationId: string; type: string }; + expect(attachA.type).toBe("AttachControlLeg"); + sockets[0]!.receive( + encodeRoutedMessage("ControlLegAttached", { + sessionId: first.id, + activationId: attachA.activationId, + grantGenerationAccepted: 1, + rights: ["geometry", "input", "read"], + }), + ); + expect(JSON.parse(sockets[0]!.sent.at(-1)!)).toMatchObject({ + type: "ClaimGeometry", + activationId: attachA.activationId, + claimant: { viewId: "view-a" }, + cols: 80, + rows: 24, + }); + worker.emit({ + type: "routed-frames-event", + event: { + type: "frames-attached", + attached: { + sessionId: first.id, + activationId: attachA.activationId, + resumeToken: "resume-a", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + outcome: { kind: "seed-required", reason: "no-cursor" }, + }, + }, + }); + const stamp = { sceneEpoch: { cellBootId: "cell-a", modelGeneration: 1 }, sceneRevision: 1 }; + worker.emit({ + type: "routed-frames-event", + event: { + type: "frames-state", + activationId: attachA.activationId, + state: "active", + appliedContent: stamp, + }, + }); + worker.emit({ + type: "routed-frames-event", + event: { + type: "presentation-status", + status: { + activationId: attachA.activationId, + workerStatusSequence: 1, + state: "active", + sceneContent: stamp, + leaseTtlMs: 10_000, + }, + }, + }); + sockets[0]!.receive( + encodeRoutedMessage("CellActivationStatus", { + sessionId: first.id, + activationId: attachA.activationId, + cellStatusSequence: 1, + leaseTtlMs: 10_000, + acceptedContent: stamp, + presentation: { state: "presenting" }, + input: { state: "allowed" }, + }), + ); + expect(runtime.routedActivation(first.id)?.presentationReady).toBe(true); + expect(runtime.routedViewInputAllowed("view-a")).toBe(true); + + const workerMessagesBeforeInput = worker.messages.length; + const socketMessagesBeforeInput = sockets[0]!.sent.length; + runtime.sendKey(first.id, "view-a", { + type: "down", + key: "a", + code: "KeyA", + location: 0, + repeat: false, + shift: false, + control: false, + alt: false, + meta: false, + timestamp: 1, + }); + expect(worker.messages).toHaveLength(workerMessagesBeforeInput); + expect(sockets[0]!.sent).toHaveLength(socketMessagesBeforeInput + 1); + expect(JSON.parse(sockets[0]!.sent.at(-1)!)).toMatchObject({ + type: "TerminalInput", + activationId: attachA.activationId, + operation: { kind: "key" }, + }); + + sockets[0]!.receive( + encodeRoutedMessage("CellActivationStatus", { + sessionId: first.id, + activationId: attachA.activationId, + cellStatusSequence: 2, + leaseTtlMs: 10_000, + acceptedContent: stamp, + presentation: { state: "presenting" }, + input: { state: "suspended", reason: "lagging" }, + }), + ); + expect(runtime.routedActivation(first.id)?.presentationReady).toBe(true); + expect(runtime.routedViewInputAllowed("view-a")).toBe(false); + const afterSuspension = sockets[0]!.sent.length; + runtime.sendText(first.id, "view-a", "blocked"); + expect(sockets[0]!.sent).toHaveLength(afterSuspension); + + worker.emit({ + type: "routed-frames-event", + event: { + type: "transport-closed", + cellBootId: "cell-a", + code: 4004, + reason: "heartbeat timeout", + activationIds: [attachA.activationId], + preAuth: false, + }, + }); + await flush(); + expect(openTicket).toHaveBeenCalledTimes(3); + expect(worker.messages.at(-1)).toMatchObject({ + type: "routed-frames-attach", + request: { + activationId: attachA.activationId, + transportGrant: { claims: { transportGrantGeneration: 2 } }, + resume: { resumeToken: "resume-a", from: stamp }, + }, + }); + runtime.dispose(); + }); +}); diff --git a/packages/ghosttea-react/src/runtime.test.ts b/packages/ghosttea-react/src/runtime.test.ts index 032905a7..7b3e743c 100644 --- a/packages/ghosttea-react/src/runtime.test.ts +++ b/packages/ghosttea-react/src/runtime.test.ts @@ -1288,7 +1288,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { runtime.dispose(); }); - it("claims resize control once per attachment epoch through one funnel", async () => { + it("claims resize control once per attachment epoch only after an explicit request", async () => { vi.stubGlobal("window", globalThis); const control = new FakePort(); const runtime = new GhostteaTerminalRuntime({ @@ -1308,12 +1308,10 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); - // Focus arrives before any geometry, so there is nothing to claim with yet. - runtime.setFocused(session.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); expect(claims()).toHaveLength(1); - // The focus setter suppresses repeat `true` updates; the claim is scoped to - // an epoch, not to a focus transition, so it must not re-fire either. + // Focus is scheduling state only and cannot add another geometry claim. runtime.setFocused(session.handle, "view-1", true, 80, 24); runtime.resize(session.id, "view-1", 100, 30); expect(claims()).toHaveLength(1); @@ -1325,6 +1323,34 @@ describe("GhostteaTerminalRuntime mount ownership", () => { runtime.dispose(); }); + it("never turns focus into a geometry claim", async () => { + vi.stubGlobal("window", globalThis); + const control = new FakePort(); + const runtime = new GhostteaTerminalRuntime({ + ports: { control: control as unknown as MessagePort, frames: new FakePort() as unknown as MessagePort }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + workerFactory: () => new FakeWorker() as unknown as Worker, + }); + await runtime.connect(); + runtime.registerSession(session); + runtime.mount(session.id, session.handle, "mirror", canvas()); + await flushMicrotasks(); + + for (let index = 0; index < 100; index += 1) { + runtime.setFocused(session.handle, "mirror", index % 2 === 0, 80, 24); + } + runtime.resize(session.id, "mirror", 120, 40); + + expect(control.messages.filter((message) => message.type === "focus-and-resize")).toHaveLength(0); + expect(control.messages.filter((message) => message.type === "resize")).toHaveLength(0); + runtime.dispose(); + }); + it("thaws an idle pane whose recovery frame committed before the live event", async () => { vi.stubGlobal("window", globalThis); const worker = new FakeWorker(); @@ -1441,7 +1467,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const remote = await runtime.openRemoteSession("device", "remote", 80, 24, "studio-mac"); runtime.mount(remote.id, remote.handle, "view-1", canvas()); await flushMicrotasks(); - runtime.setFocused(remote.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(remote.handle, "view-1", 80, 24); const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); const before = claims().length; @@ -1516,7 +1542,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); - runtime.setFocused(session.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); expect(claims()).toHaveLength(1); // A host with no revisions has reported none, and 0 is the "unknown" @@ -1571,7 +1597,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); - runtime.setFocused(session.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); control.dispatchEvent( new MessageEvent("message", { data: { @@ -1663,7 +1689,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); - runtime.setFocused(session.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); const before = claims().length; announce({ viewId: "other-pane", controlEpoch: 4 }, 31); @@ -1728,7 +1754,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); - runtime.setFocused(session.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); announce(null, 9); expect(claims()).toHaveLength(2); expect(claims().at(-1)).toMatchObject({ expectedControlRevision: 9 }); @@ -1778,8 +1804,8 @@ describe("GhostteaTerminalRuntime mount ownership", () => { const claims = (): Record[] => control.messages.filter((message) => message.type === "focus-and-resize"); - // Taking focus is a deliberate claim and stays last-write-wins. - runtime.setFocused(session.handle, "view-1", true, 80, 24); + // The host explicitly requests the geometry seat. + runtime.claimResizeControl(session.handle, "view-1", 80, 24); expect(claims()).toHaveLength(1); // Automatic reclaim is the part that must not fight: a fresh epoch while @@ -1976,6 +2002,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { runtime.registerSession(session); runtime.mount(session.id, session.handle, "view-1", canvas()); await flushMicrotasks(); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); control.dispatchEvent( new MessageEvent("message", { @@ -2277,7 +2304,7 @@ describe("GhostteaTerminalRuntime mount ownership", () => { runtime.mount(session.id, session.handle, "view-1", canvas()); await flushMicrotasks(); - runtime.setFocused(session.handle, "view-1", true, 80, 24); + runtime.claimResizeControl(session.handle, "view-1", 80, 24); runtime.resize(session.id, "view-1", 110, 31); controlChanged(control, "view-1", 80, 24); diff --git a/packages/ghosttea-react/src/runtime.ts b/packages/ghosttea-react/src/runtime.ts index 97a6f186..36b6d59f 100644 --- a/packages/ghosttea-react/src/runtime.ts +++ b/packages/ghosttea-react/src/runtime.ts @@ -1,9 +1,12 @@ import { ControlClient } from "@vibecook/ghosttea"; import { + DEFAULT_ROUTED_PROTOCOL_LIMITS, PROTOCOL_MAJOR, PROTOCOL_MINOR, SESSION_SCROLLBACK_PROTOCOL_MINOR, STRUCTURED_ERROR_PROTOCOL_MINOR, + isRoutedSessionAttachGrant, + isRoutedTerminalOpenTicket, isValidScrollbackBytes, type ConfigSnapshot, type CreateSessionOptions, @@ -11,6 +14,9 @@ import { type RemoteHostSummary, type RemoteSessionLifecycle, type RemoteViewRecord, + type RoutedReceiverCapacities, + type RoutedSessionAttachGrant, + type RoutedTerminalOpenTicket, type SelectionScopeKind, type SessionActivity, type ServerEvent, @@ -23,9 +29,17 @@ import { } from "@vibecook/ghosttea-protocol"; import { FRAME_MAGIC, FrameFlag } from "@vibecook/ghosttea-frame"; import type { CellSelection, TerminalEffects, TerminalTheme } from "./renderers/types.js"; -import type { TerminalRenderPerformanceSnapshot } from "./performance.js"; +import type { TerminalRenderCounterSnapshot, TerminalRenderPerformanceSnapshot } from "./performance.js"; import { FrameResyncController } from "./frame-resync.js"; import type { RendererToWorkerMessage, WorkerToRendererMessage } from "./worker-messages.js"; +import { + initialRoutedActivation, + reduceRoutedActivation, + type RoutedActivationEvent, + type RoutedActivationState, +} from "./routed-activation.js"; +import { RoutedControlTransport, type RoutedControlTransportEvent } from "./routed-control.js"; +import type { RoutedFramesTransportEvent } from "./routed-frames.js"; export interface GhostteaRendererPorts { control: MessagePort; @@ -39,8 +53,7 @@ export interface GhostteaRendererPlatform { reload(): void; } -export interface GhostteaTerminalRuntimeOptions { - ports: GhostteaRendererPorts | Promise; +interface GhostteaTerminalRuntimeBaseOptions { platform: GhostteaRendererPlatform; workerFactory?: () => Worker; clientBuild?: string; @@ -48,11 +61,70 @@ export interface GhostteaTerminalRuntimeOptions { frameSubscriptionGraceMs?: number; } +export interface GhostteaPortTerminalRuntimeOptions extends GhostteaTerminalRuntimeBaseOptions { + transport?: "ports"; + ports: GhostteaRendererPorts | Promise; +} + +export type RoutedTerminalInputOperation = + | { kind: "text"; text: string } + | { kind: "paste"; text: string } + | { kind: "key"; event: TerminalKeyEvent } + | { kind: "mouse"; event: TerminalMouseEvent } + | { kind: "scroll"; rows: number } + | { kind: "scroll-to"; row: number } + | { kind: "interrupt" }; + +export interface RoutedTerminalInputContext { + sessionId: string; + viewId: string; + activationId: string; + leaseEpoch: number; + inputSequence: number; + operation: RoutedTerminalInputOperation; +} + +export interface GhostteaRoutedHost { + openTicket( + sessionId: string, + options?: { reason?: "mount" | "retry" | "route-stale" | "pre-auth" }, + ): Promise; + renewAttach?(params: { + sessionId: string; + expectGeneration: number; + requestId: string; + }): Promise<{ attachGrant: RoutedSessionAttachGrant }>; + listSessions?(): Promise; + createSession?(options: CreateSessionOptions): Promise; + terminate?(sessionId: string, source: TerminationSource): void | Promise; + /** + * Encodes the host's negotiated input extension. TPv3 T1 currently defines + * no terminal-input tag, so routed input stays closed when this is absent. + */ + encodeInput?(context: RoutedTerminalInputContext): Readonly> | null; +} + +export interface GhostteaRoutedTerminalRuntimeOptions extends GhostteaTerminalRuntimeBaseOptions { + transport: "routed"; + host: GhostteaRoutedHost; + websocketFactory?: (url: string) => WebSocket; + receiverCapacities?: RoutedReceiverCapacities; + capabilities?: string[]; +} + +export type GhostteaTerminalRuntimeOptions = GhostteaPortTerminalRuntimeOptions | GhostteaRoutedTerminalRuntimeOptions; + export type TerminalMount = { resize: (width: number, height: number, dpr: number) => void; dispose: () => void; }; +const MAX_BROWSER_TIMEOUT_MS = 2_147_483_647; + +function routedConnectionRefusalIsRecoverable(refusal: { code: string; retryable: boolean }): boolean { + return refusal.retryable || refusal.code === "GRANT_GENERATION_ROLLBACK" || refusal.code === "GRANT_NONCE_REPLAYED"; +} + function sameSessionActivity(left: SessionActivity, right: SessionActivity): boolean { return ( left.kind === right.kind && @@ -80,6 +152,11 @@ interface ViewRuntimeState { sessionHandle: string; attachmentEpoch?: number | undefined; readWrite?: boolean; + /** Viewer-local policy; it can remove input but never add a server right. */ + clientReadWrite: boolean; + /** Only an explicit host/component request may claim the geometry seat. */ + resizeControlRequested: boolean; + visible: boolean; inputSequence: number; resizeSequence: number; controlEpoch: number | undefined; @@ -144,6 +221,30 @@ type FrameChannelMessage = | { type: "frame-gap"; skipped: number; sessionHandles?: string[]; historyComplete?: boolean } | { type: "bridge-capabilities"; requestId: number; protocolVersion: number; frameCredits: boolean }; +interface RoutedActivationRuntime { + sessionId: string; + sessionHandle: string; + state: RoutedActivationState; + ticket?: RoutedTerminalOpenTicket; + replacesActivationId?: string; + viewIds: Set; + start?: Promise; + attachTimer?: number; + renewalTimer?: number; + framesResume?: Promise; + recoveryAttempts: number; + preAuthRemints: { control: number; frames: number }; + protocolFailures: { control: number; frames: number }; +} + +interface RoutedGeometryState { + holderViewId?: string; + holderGeneration?: number; + revision: number; + cols?: number; + rows?: number; +} + const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000; const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7; const FRAME_BRIDGE_CAPABILITY_VERSION = 1; @@ -173,7 +274,15 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000): Promise; + readonly #ports: Promise | undefined; + readonly #routedHost: GhostteaRoutedHost | undefined; + readonly #routedReceiverCapacities: RoutedReceiverCapacities | undefined; + readonly #routedCapabilities: string[]; + readonly #routedControl: RoutedControlTransport | undefined; + readonly #routedBySession = new Map(); + readonly #routedByActivation = new Map(); + readonly #routedGeometry = new Map(); + readonly #routedAttachDeadlineByCell = new Map(); readonly #platform: GhostteaRendererPlatform; readonly #clientBuild: string; readonly #sessionOwnerId: string | undefined; @@ -227,6 +336,10 @@ export class GhostteaTerminalRuntime extends EventTarget { timer: number; } >(); + readonly #counterRequests = new Map< + number, + { resolve: (value: TerminalRenderCounterSnapshot) => void; reject: (error: Error) => void; timer: number } + >(); #disposed = false; constructor(options: GhostteaTerminalRuntimeOptions) { @@ -234,7 +347,17 @@ export class GhostteaTerminalRuntime extends EventTarget { this.#worker = options.workerFactory?.() ?? new Worker(new URL("./terminal-render.worker.js", import.meta.url), { type: "module" }); - this.#ports = Promise.resolve(options.ports); + this.#ports = options.transport === "routed" ? undefined : Promise.resolve(options.ports); + this.#routedHost = options.transport === "routed" ? options.host : undefined; + this.#routedReceiverCapacities = options.transport === "routed" ? options.receiverCapacities : undefined; + this.#routedCapabilities = options.transport === "routed" ? (options.capabilities ?? ["resume"]) : []; + this.#routedControl = + options.transport === "routed" + ? new RoutedControlTransport({ + ...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }), + emit: (event) => this.#handleRoutedControlEvent(event), + }) + : undefined; this.#platform = options.platform; this.#clientBuild = options.clientBuild ?? "ghosttea-react"; this.#sessionOwnerId = options.sessionOwnerId; @@ -289,6 +412,14 @@ export class GhostteaTerminalRuntime extends EventTarget { this.#resolvePerformanceRequest(data.requestId, undefined); } else if (data.type === "performance-result") { this.#resolvePerformanceRequest(data.requestId, data.snapshot); + } else if (data.type === "performance-counters") { + const pending = this.#counterRequests.get(data.requestId); + if (!pending) return; + window.clearTimeout(pending.timer); + this.#counterRequests.delete(data.requestId); + pending.resolve(data.snapshot); + } else if (data.type === "routed-frames-event") { + this.#handleRoutedFramesEvent(data.event); } else if (data.type === "renderer-reload-required") { console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`); this.#platform.setForceCanvasFallback(true); @@ -310,6 +441,17 @@ export class GhostteaTerminalRuntime extends EventTarget { return this.#rendererBackend; } + /** Current main-authority state for a routed session. */ + routedActivation(sessionId: string): RoutedActivationState | undefined { + return this.#routedBySession.get(sessionId)?.state; + } + + routedViewInputAllowed(viewId: string): boolean { + const view = this.#views.get(viewId); + if (!view?.clientReadWrite || view.readWrite === false) return false; + return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false; + } + #resolvePerformanceRequest(requestId: number, value: TerminalRenderPerformanceSnapshot | undefined): void { const pending = this.#performanceRequests.get(requestId); if (!pending) return; @@ -354,6 +496,20 @@ export class GhostteaTerminalRuntime extends EventTarget { return result; } + /** Reads monotonic production counters without starting a sample window or draining the GPU. */ + readPerformanceCounters(timeoutMs = 2_000): Promise { + if (this.#disposed) return Promise.reject(new Error("Terminal runtime is disposed")); + const requestId = this.#performanceRequestId++; + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + this.#counterRequests.delete(requestId); + reject(new Error(`Terminal render counter request ${requestId} timed out`)); + }, timeoutMs); + this.#counterRequests.set(requestId, { resolve, reject, timer }); + this.#postWorker({ type: "performance-counters", requestId }); + }); + } + connect(): Promise { if (this.#disposed) return Promise.reject(new Error("Terminal runtime is disposed")); this.#ready ??= this.#connect(); @@ -361,7 +517,8 @@ export class GhostteaTerminalRuntime extends EventTarget { } async #connect(): Promise { - const ports = await this.#ports; + if (this.#routedHost) return; + const ports = await this.#ports!; if (this.#disposed) { ports.control.close(); ports.frames.close(); @@ -497,6 +654,7 @@ export class GhostteaTerminalRuntime extends EventTarget { async reloadConfig(): Promise { await this.connect(); + if (this.#routedHost) throw new Error("Configuration reload is not part of the routed host contract"); const response = await this.#control!.request({ type: "reload-config" }); if (response.type !== "config") throw new Error("ghosttead returned an unexpected configuration response"); this.#installConfig(response.config); @@ -781,6 +939,12 @@ export class GhostteaTerminalRuntime extends EventTarget { async createSession(options: CreateSessionOptions): Promise { await this.connect(); + if (this.#routedHost) { + if (!this.#routedHost.createSession) throw new Error("The routed host does not provide session creation"); + const session = await this.#routedHost.createSession(options); + this.registerSession(session); + return session; + } if (options.scrollbackBytes !== undefined) { if (!isValidScrollbackBytes(options.scrollbackBytes)) { throw new RangeError("scrollbackBytes must be a non-negative safe integer"); @@ -803,6 +967,13 @@ export class GhostteaTerminalRuntime extends EventTarget { async listSessions(): Promise { await this.connect(); + if (this.#routedHost) { + const sessions = this.#routedHost.listSessions + ? await this.#routedHost.listSessions() + : [...this.#sessionByHandle.values()]; + for (const session of sessions) this.registerSession(session); + return sessions; + } const response = await this.#control!.request({ type: "list-sessions" }); if (response.type !== "sessions") throw new Error("ghosttead returned an unexpected response"); for (const session of response.sessions) { @@ -880,6 +1051,7 @@ export class GhostteaTerminalRuntime extends EventTarget { async listRemoteHosts(): Promise { await this.connect(); + if (this.#routedHost) throw new Error("Remote-host discovery is not part of the routed host contract"); const response = await this.#control!.request({ type: "list-remote-hosts" }); if (response.type !== "remote-hosts") throw new Error("ghosttead returned an unexpected response"); return response.hosts; @@ -887,6 +1059,7 @@ export class GhostteaTerminalRuntime extends EventTarget { async listRemoteSessions(deviceId: string): Promise { await this.connect(); + if (this.#routedHost) throw new Error("Remote-session discovery is not part of the routed host contract"); const response = await this.#control!.request({ type: "list-remote-sessions", deviceId }, 35_000); if (response.type !== "remote-sessions" || response.deviceId !== deviceId) throw new Error("ghosttead returned an unexpected response"); @@ -901,6 +1074,7 @@ export class GhostteaTerminalRuntime extends EventTarget { deviceName = deviceId, ): Promise { await this.connect(); + if (this.#routedHost) throw new Error("Remote-session opening is not part of the routed host contract"); const response = await this.#control!.request({ type: "open-remote-session", deviceId, @@ -948,6 +1122,7 @@ export class GhostteaTerminalRuntime extends EventTarget { mount(sessionId: string, sessionHandle: string, viewId: string, canvas: HTMLCanvasElement): TerminalMount { if (this.#disposed) throw new Error("Cannot mount a disposed terminal runtime"); + if (this.#routedHost) return this.#mountRouted(sessionId, sessionHandle, viewId, canvas); const mounted = this.#mountedCanvases.get(canvas); if (mounted) { if (!mounted.active) throw new Error("A released terminal canvas cannot be remounted"); @@ -982,6 +1157,9 @@ export class GhostteaTerminalRuntime extends EventTarget { const view: ViewRuntimeState = { sessionId, sessionHandle, + clientReadWrite: true, + resizeControlRequested: false, + visible: true, inputSequence: 0, resizeSequence: 0, controlEpoch: undefined, @@ -1049,6 +1227,737 @@ export class GhostteaTerminalRuntime extends EventTarget { return this.#createMountLease(entry); } + #mountRouted(sessionId: string, sessionHandle: string, viewId: string, canvas: HTMLCanvasElement): TerminalMount { + const mounted = this.#mountedCanvases.get(canvas); + if (mounted) { + if (!mounted.active) throw new Error("A released terminal canvas cannot be remounted"); + if (mounted.sessionHandle !== sessionHandle) { + throw new Error("A terminal canvas cannot be reassigned to another session"); + } + mounted.references += 1; + if (mounted.disposeTimer !== undefined) { + window.clearTimeout(mounted.disposeTimer); + mounted.disposeTimer = undefined; + } + return this.#createMountLease(mounted); + } + const offscreen = canvas.transferControlToOffscreen(); + const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1; + this.#mountGenerationBySurface.set(viewId, generation); + this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]); + const entry: MountedCanvas = { + canvas, + sessionHandle, + sessionId, + viewId, + generation, + references: 1, + disposeTimer: undefined, + active: true, + }; + this.#mountedCanvases.set(canvas, entry); + this.#mountedEntries.add(entry); + const session = this.#sessionByHandle.get(sessionHandle); + this.#views.set(viewId, { + sessionId, + sessionHandle, + ...(session === undefined ? {} : { readWrite: session.readWrite }), + clientReadWrite: true, + resizeControlRequested: false, + visible: true, + inputSequence: 0, + resizeSequence: 0, + controlEpoch: undefined, + desiredCols: undefined, + desiredRows: undefined, + pendingInput: [], + lastViewStateSeq: undefined, + lastAttachmentEpoch: undefined, + claimedEpoch: undefined, + claimedRevision: 0, + }); + const activation = this.#routedBySession.get(sessionId); + if (activation) activation.viewIds.add(viewId); + void this.#ensureRoutedActivation(sessionId, sessionHandle, viewId).catch((error: unknown) => { + if (!this.#disposed) console.error(`[terminal-runtime] routed activation failed for ${sessionId}`, error); + }); + return this.#createMountLease(entry); + } + + async #ensureRoutedActivation(sessionId: string, sessionHandle: string, viewId: string): Promise { + const existing = this.#routedBySession.get(sessionId); + if (existing) { + existing.viewIds.add(viewId); + if (existing.start) await existing.start; + const anyWritable = + this.#routedHost?.encodeInput !== undefined && + [...existing.viewIds].some((candidate) => { + const view = this.#views.get(candidate); + return view?.clientReadWrite === true && view.readWrite !== false; + }); + this.#transitionRouted(existing, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" }); + this.#declareRoutedDemand(existing); + return; + } + const activationId = crypto.randomUUID(); + const inputPolicy = + this.#routedHost?.encodeInput !== undefined && + this.#views.get(viewId)?.clientReadWrite !== false && + this.#views.get(viewId)?.readWrite !== false + ? "read-write" + : "read-only"; + const entry: RoutedActivationRuntime = { + sessionId, + sessionHandle, + state: initialRoutedActivation(sessionId, activationId, inputPolicy), + viewIds: new Set([viewId]), + recoveryAttempts: 0, + preAuthRemints: { control: 0, frames: 0 }, + protocolFailures: { control: 0, frames: 0 }, + }; + this.#routedBySession.set(sessionId, entry); + this.#routedByActivation.set(activationId, entry); + entry.start = this.#startRoutedActivation(entry, "mount"); + try { + await entry.start; + } finally { + delete entry.start; + } + } + + #routedTicketMatches(entry: RoutedActivationRuntime, ticket: unknown): ticket is RoutedTerminalOpenTicket { + return ( + isRoutedTerminalOpenTicket(ticket) && + ticket.route.cellBootId === ticket.transportGrant.claims.audienceCellBootId && + ticket.route.cellBootId === ticket.attachGrant.claims.audienceCellBootId && + ticket.route.cellBootId === ticket.transportGrant.protected.kid.cellBootId && + ticket.route.cellBootId === ticket.attachGrant.protected.kid.cellBootId && + ticket.transportGrant.claims.clientId === ticket.attachGrant.claims.clientId && + ticket.transportGrant.claims.allowedChannels.includes("control") && + ticket.transportGrant.claims.allowedChannels.includes("frames") && + ticket.attachGrant.claims.sessionId === entry.sessionId && + ticket.attachGrant.claims.routeRevision === ticket.route.routeRevision && + (ticket.route.leaseEpoch === undefined || + ticket.attachGrant.claims.leaseEpoch === undefined || + ticket.route.leaseEpoch === ticket.attachGrant.claims.leaseEpoch) + ); + } + + #routedRenewalMatches( + entry: RoutedActivationRuntime, + value: unknown, + previousGeneration: number, + ): value is RoutedSessionAttachGrant { + const ticket = entry.ticket; + return ( + ticket !== undefined && + isRoutedSessionAttachGrant(value) && + value.protected.kid.cellBootId === ticket.route.cellBootId && + value.claims.audienceCellBootId === ticket.route.cellBootId && + value.claims.clientId === ticket.attachGrant.claims.clientId && + value.claims.sessionId === entry.sessionId && + value.claims.routeRevision === ticket.route.routeRevision && + value.claims.leaseEpoch === ticket.attachGrant.claims.leaseEpoch && + value.claims.grantGeneration > previousGeneration + ); + } + + async #startRoutedActivation( + entry: RoutedActivationRuntime, + reason: "mount" | "retry" | "route-stale" | "pre-auth", + ): Promise { + const host = this.#routedHost; + if (!host || this.#disposed || entry.viewIds.size === 0) return; + let ticket: RoutedTerminalOpenTicket; + try { + ticket = await host.openTicket(entry.sessionId, { reason }); + } catch (error) { + this.#transitionRouted(entry, { type: "no-route", reason: String(error) }); + return; + } + if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed) return; + if (!this.#routedTicketMatches(entry, ticket)) { + this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" }); + return; + } + this.#transitionRouted(entry, { type: "ticket-minted", endpointsPresent: ticket.endpoints !== undefined }); + if (!ticket.endpoints) return; + entry.ticket = ticket; + this.#transitionRouted(entry, { type: "transport-ready" }); + this.#routedControl!.attach({ + cellBootId: ticket.route.cellBootId, + controlUrl: ticket.endpoints.controlUrl, + transportGrant: ticket.transportGrant, + attachGrant: ticket.attachGrant, + activationId: entry.state.activationId, + ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }), + initialDemand: this.#routedDemand(entry), + capabilities: this.#routedCapabilities, + }); + this.#postWorker({ + type: "routed-frames-attach", + request: { + cellBootId: ticket.route.cellBootId, + sessionHandle: entry.sessionHandle, + framesUrl: ticket.endpoints.framesUrl, + transportGrant: ticket.transportGrant, + attachGrant: ticket.attachGrant, + activationId: entry.state.activationId, + ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }), + ...(this.#routedReceiverCapacities === undefined ? {} : { receiverCapacities: this.#routedReceiverCapacities }), + capabilities: this.#routedCapabilities, + }, + }); + this.#armRoutedAttachDeadline( + entry, + this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ?? + DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs, + ); + this.#scheduleRoutedRenewal(entry); + } + + #stopRoutedActivation(entry: RoutedActivationRuntime): void { + this.#routedControl?.detach(entry.state.activationId); + this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId }); + if (entry.attachTimer !== undefined) window.clearTimeout(entry.attachTimer); + delete entry.attachTimer; + if (entry.renewalTimer !== undefined) window.clearTimeout(entry.renewalTimer); + delete entry.renewalTimer; + delete entry.ticket; + } + + #transitionRouted(entry: RoutedActivationRuntime, event: RoutedActivationEvent): void { + const previous = entry.state; + const next = reduceRoutedActivation(previous, event); + if (next === previous) return; + entry.state = next; + if (next.phase !== "attaching" && entry.attachTimer !== undefined) { + window.clearTimeout(entry.attachTimer); + delete entry.attachTimer; + } + if ((next.phase === "unavailable" || next.phase === "ended") && next.phase !== previous.phase) { + this.#stopRoutedActivation(entry); + } + if (!previous.presentationReady && next.presentationReady) { + entry.recoveryAttempts = 0; + entry.protocolFailures.control = 0; + entry.protocolFailures.frames = 0; + } + this.dispatchEvent( + new CustomEvent("routed-activation-state", { + detail: { sessionId: entry.sessionId, previous, current: next }, + }), + ); + if (previous.presentationReady !== next.presentationReady || previous.inputAllowed !== next.inputAllowed) { + for (const viewId of entry.viewIds) { + const view = this.#views.get(viewId); + this.dispatchEvent( + new CustomEvent("routed-view-readiness", { + detail: { + sessionId: entry.sessionId, + viewId, + presentationReady: next.presentationReady, + inputAllowed: next.inputAllowed && view?.clientReadWrite === true && view.readWrite !== false, + phase: next.phase, + }, + }), + ); + } + } + } + + #handleRoutedControlEvent(event: RoutedControlTransportEvent): void { + if (this.#disposed) return; + if (event.type === "transport-ready") { + const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs; + this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline); + for (const entry of this.#routedBySession.values()) { + if (entry.ticket?.route.cellBootId === event.cellBootId && entry.state.phase === "attaching") { + this.#armRoutedAttachDeadline(entry, deadline); + } + } + return; + } + if (event.type === "control-attached") { + const entry = this.#routedByActivation.get(event.attached.activationId); + if (!entry || event.attached.sessionId !== entry.sessionId) return; + entry.preAuthRemints.control = 0; + this.#transitionRouted(entry, { + type: "control-attached", + grantGeneration: event.attached.grantGenerationAccepted, + rights: event.attached.rights, + }); + const previousGeometry = this.#routedGeometry.get(entry.sessionId); + if ( + !event.attached.rights.includes("geometry") && + previousGeometry?.holderViewId !== undefined && + entry.viewIds.has(previousGeometry.holderViewId) + ) { + // The cell auto-releases a holder when renewal drops the geometry + // right. Preserve its next CAS revision without retaining authority. + this.#routedGeometry.set(entry.sessionId, { + revision: previousGeometry.revision + 1, + ...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }), + ...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }), + }); + } + const geometry = this.#routedGeometry.get(entry.sessionId); + for (const viewId of entry.viewIds) { + const view = this.#views.get(viewId); + if ( + view?.resizeControlRequested && + geometry?.holderViewId !== viewId && + view.desiredCols !== undefined && + view.desiredRows !== undefined + ) { + this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows); + } + } + return; + } + if (event.type === "attach-refused") { + const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined; + if (!entry) return; + this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable }); + if (entry.state.phase === "recovering") { + this.#recoverRoutedActivation( + entry, + event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry", + ); + } + return; + } + if (event.type === "cell-status") { + const entry = this.#routedByActivation.get(event.status.activationId); + if (!entry || event.status.sessionId !== entry.sessionId) return; + this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() }); + const sequence = entry.state.lastCellStatusSequence; + window.setTimeout(() => { + if (entry.state.lastCellStatusSequence !== sequence) return; + this.#transitionRouted(entry, { type: "cell-lease-expired" }); + }, event.status.leaseTtlMs); + if ( + event.status.presentation.state === "revoked" && + (event.status.presentation.reason === "leg-dead" || event.status.presentation.reason === "stale-route") + ) { + this.#recoverRoutedActivation( + entry, + event.status.presentation.reason === "stale-route" ? "route-stale" : "retry", + ); + } + return; + } + if (event.type === "geometry-committed") { + const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined; + if (!entry) return; + this.#routedGeometry.set(entry.sessionId, { + holderViewId: event.committed.holder.viewId, + holderGeneration: event.committed.holder.holderGeneration, + revision: event.committed.geometryRevision, + cols: event.committed.cols, + rows: event.committed.rows, + }); + this.dispatchEvent(new CustomEvent("routed-geometry", { detail: { sessionId: entry.sessionId, ...event } })); + return; + } + if (event.type === "geometry-refused") { + const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined; + if (entry && event.refused.geometryRevision !== undefined) { + const previous = this.#routedGeometry.get(entry.sessionId); + const holder = event.refused.currentHolder; + this.#routedGeometry.set(entry.sessionId, { + revision: event.refused.geometryRevision, + ...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }), + ...(previous?.cols === undefined ? {} : { cols: previous.cols }), + ...(previous?.rows === undefined ? {} : { rows: previous.rows }), + }); + } + this.dispatchEvent(new CustomEvent("routed-geometry-refused", { detail: event })); + return; + } + if (event.type === "transport-closed") { + this.#routedAttachDeadlineByCell.delete(event.cellBootId); + for (const activationId of event.activationIds) { + const entry = this.#routedByActivation.get(activationId); + if (!entry) continue; + if (event.preAuth || event.refusal) { + const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true; + this.#transitionRouted(entry, { + type: "transport-failed", + ...(event.preAuth ? { preAuth: true } : {}), + ...(event.refusal === undefined ? {} : { retryable: recoverable }), + }); + if (entry.state.phase === "unavailable") continue; + this.#recoverRoutedActivation(entry, event.preAuth ? "pre-auth" : "retry", "control"); + continue; + } + const routeStale = event.code === 4000 || event.code === 4001; + if (event.code === 4002) { + this.#transitionRouted(entry, { type: "replaced" }); + continue; + } + if (event.code === 4003) { + if (entry.protocolFailures.control >= 1) { + this.#transitionRouted(entry, { type: "leg-lost", channel: "control", resumeCapable: false }); + this.#transitionRouted(entry, { + type: "transport-failed", + recoveryExhausted: true, + reason: "protocol", + }); + this.dispatchEvent( + new CustomEvent("routed-protocol-error", { + detail: { sessionId: entry.sessionId, channel: "control", reason: event.reason }, + }), + ); + continue; + } + entry.protocolFailures.control += 1; + } + this.#transitionRouted(entry, { + type: routeStale ? "route-stale" : "leg-lost", + channel: "control", + resumeCapable: false, + }); + this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "control"); + } + } + } + + #handleRoutedFramesEvent(event: RoutedFramesTransportEvent): void { + if (event.type === "frames-attached") { + const entry = this.#routedByActivation.get(event.attached.activationId); + if (!entry || event.attached.sessionId !== entry.sessionId) return; + entry.preAuthRemints.frames = 0; + this.#transitionRouted(entry, { + type: "frames-attached", + outcome: event.attached.outcome, + trfIdentity: event.attached.trfIdentity, + resumeToken: event.attached.resumeToken, + }); + return; + } + if (event.type === "attach-refused") { + const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined; + if (!entry) return; + this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable }); + if (entry.state.phase === "recovering") { + this.#recoverRoutedActivation( + entry, + event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry", + "frames", + ); + } + return; + } + if (event.type === "frames-state") { + const entry = this.#routedByActivation.get(event.activationId); + if (!entry) return; + this.#transitionRouted(entry, { + type: "frames-state", + state: { + activationId: event.activationId, + state: event.state, + ...(event.resumeToken === undefined ? {} : { resumeToken: event.resumeToken }), + ...(event.appliedContent === undefined ? {} : { appliedContent: event.appliedContent }), + }, + }); + if (event.state === "active" && event.appliedContent) { + this.#transitionRouted(entry, { type: "sync-complete", appliedContent: event.appliedContent }); + } else if (event.state === "failed") { + this.#transitionRouted(entry, { type: "sync-failed" }); + } + return; + } + if (event.type === "presentation-status") { + const entry = this.#routedByActivation.get(event.status.activationId); + if (!entry) return; + this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() }); + const sequence = entry.state.lastWorkerStatusSequence; + window.setTimeout(() => { + if (entry.state.lastWorkerStatusSequence !== sequence) return; + this.#transitionRouted(entry, { type: "worker-lease-expired" }); + }, event.status.leaseTtlMs); + return; + } + for (const activationId of event.activationIds) { + const entry = this.#routedByActivation.get(activationId); + if (!entry) continue; + if (event.preAuth || event.refusal) { + const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true; + this.#transitionRouted(entry, { + type: "transport-failed", + ...(event.preAuth ? { preAuth: true } : {}), + ...(event.refusal === undefined ? {} : { retryable: recoverable }), + }); + if (entry.state.phase === "unavailable") continue; + this.#resumeRoutedFrames(entry, event.preAuth ? "pre-auth" : "retry"); + continue; + } + const routeStale = event.code === 4000 || event.code === 4001; + if (event.code === 4002) { + this.#transitionRouted(entry, { type: "replaced" }); + continue; + } + if (event.code === 4003) { + if (entry.protocolFailures.frames >= 1) { + this.#transitionRouted(entry, { type: "leg-lost", channel: "frames", resumeCapable: false }); + this.#transitionRouted(entry, { + type: "transport-failed", + recoveryExhausted: true, + reason: "protocol", + }); + this.dispatchEvent( + new CustomEvent("routed-protocol-error", { + detail: { sessionId: entry.sessionId, channel: "frames", reason: event.reason }, + }), + ); + continue; + } + entry.protocolFailures.frames += 1; + } + const activationFailed = event.code === 4002 || event.code === 4003; + const canResume = + !routeStale && + !activationFailed && + this.#routedCapabilities.includes("resume") && + entry.state.resumeToken !== undefined && + entry.state.appliedContent !== undefined && + entry.ticket?.endpoints !== undefined; + this.#transitionRouted(entry, { + type: routeStale ? "route-stale" : "leg-lost", + channel: "frames", + resumeCapable: canResume, + }); + if (canResume) { + this.#resumeRoutedFrames(entry, "retry"); + } else { + this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "frames"); + } + } + } + + #resumeRoutedFrames(entry: RoutedActivationRuntime, reason: "retry" | "pre-auth"): void { + if (entry.framesResume || this.#disposed) return; + const task = (async () => { + const host = this.#routedHost; + const previousTicket = entry.ticket; + const activationId = entry.state.activationId; + const resumeToken = entry.state.resumeToken; + const appliedContent = entry.state.appliedContent; + if (!host || !previousTicket?.endpoints || !resumeToken || !appliedContent) { + this.#recoverRoutedActivation(entry, reason, "frames"); + return; + } + if (reason === "pre-auth") { + if (entry.preAuthRemints.frames >= 1) { + this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true }); + return; + } + entry.preAuthRemints.frames += 1; + } + entry.recoveryAttempts += 1; + if (entry.recoveryAttempts > 5) { + this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true }); + return; + } + let ticket: RoutedTerminalOpenTicket; + try { + ticket = await host.openTicket(entry.sessionId, { reason }); + } catch { + this.#recoverRoutedActivation(entry, "retry", "frames"); + return; + } + if ( + this.#disposed || + this.#routedByActivation.get(activationId) !== entry || + entry.state.activationId !== activationId + ) { + return; + } + if (!this.#routedTicketMatches(entry, ticket) || !ticket.endpoints) { + this.#recoverRoutedActivation(entry, "route-stale", "frames"); + return; + } + const sameRoute = + ticket.route.cellBootId === previousTicket.route.cellBootId && + ticket.route.routeRevision === previousTicket.route.routeRevision && + ticket.route.leaseEpoch === previousTicket.route.leaseEpoch; + if (!sameRoute) { + this.#transitionRouted(entry, { type: "route-stale" }); + this.#recoverRoutedActivation(entry, "route-stale", "frames"); + return; + } + entry.ticket = ticket; + this.#routedControl?.renew(activationId, ticket.attachGrant); + this.#scheduleRoutedRenewal(entry); + this.#postWorker({ + type: "routed-frames-attach", + request: { + cellBootId: ticket.route.cellBootId, + sessionHandle: entry.sessionHandle, + framesUrl: ticket.endpoints.framesUrl, + transportGrant: ticket.transportGrant, + attachGrant: ticket.attachGrant, + activationId, + resume: { resumeToken, from: appliedContent }, + ...(this.#routedReceiverCapacities === undefined + ? {} + : { receiverCapacities: this.#routedReceiverCapacities }), + capabilities: this.#routedCapabilities, + }, + }); + })(); + entry.framesResume = task; + void task.finally(() => { + if (entry.framesResume === task) delete entry.framesResume; + }); + } + + #recoverRoutedActivation( + entry: RoutedActivationRuntime, + reason: "retry" | "route-stale" | "pre-auth", + failedChannel?: "control" | "frames", + ): void { + if (this.#disposed || entry.viewIds.size === 0 || entry.state.phase === "ended") return; + if (reason === "pre-auth") { + const channel = failedChannel ?? "control"; + if (entry.preAuthRemints[channel] >= 1) { + this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true }); + return; + } + entry.preAuthRemints[channel] += 1; + } + entry.recoveryAttempts += 1; + if (entry.recoveryAttempts > 5) { + this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true }); + return; + } + // Geometry belongs to the cell-side attach/client/view scope. Preserve it + // across a same-route leg replacement, but never carry its revision or + // holder generation to a newly routed cell. + if (reason === "route-stale") this.#routedGeometry.delete(entry.sessionId); + const previousActivationId = entry.state.activationId; + this.#routedControl?.detach(previousActivationId); + this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId }); + this.#routedByActivation.delete(previousActivationId); + const nextActivationId = crypto.randomUUID(); + entry.replacesActivationId = previousActivationId; + const inputPolicy = + this.#routedHost?.encodeInput !== undefined && + [...entry.viewIds].some((viewId) => { + const view = this.#views.get(viewId); + return view?.clientReadWrite === true && view.readWrite !== false; + }) + ? "read-write" + : "read-only"; + entry.state = { + ...initialRoutedActivation(entry.sessionId, nextActivationId, inputPolicy), + phase: "recovering", + replacesActivationId: previousActivationId, + preAuthRemintUsed: entry.preAuthRemints.control > 0 || entry.preAuthRemints.frames > 0, + }; + this.#routedByActivation.set(nextActivationId, entry); + if (entry.attachTimer !== undefined) window.clearTimeout(entry.attachTimer); + delete entry.attachTimer; + if (entry.renewalTimer !== undefined) window.clearTimeout(entry.renewalTimer); + delete entry.renewalTimer; + delete entry.ticket; + const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1)); + window.setTimeout(() => { + if (this.#routedByActivation.get(nextActivationId) !== entry) return; + entry.start = this.#startRoutedActivation(entry, reason); + void entry.start.finally(() => delete entry.start); + }, delay); + } + + #armRoutedAttachDeadline(entry: RoutedActivationRuntime, delayMs: number): void { + if (entry.attachTimer !== undefined) window.clearTimeout(entry.attachTimer); + const activationId = entry.state.activationId; + entry.attachTimer = window.setTimeout( + () => { + if (this.#routedByActivation.get(activationId) !== entry || entry.state.phase !== "attaching") return; + this.#transitionRouted(entry, { type: "attach-deadline" }); + this.#recoverRoutedActivation(entry, "retry"); + }, + Math.max(0, delayMs), + ); + } + + #scheduleRoutedRenewal(entry: RoutedActivationRuntime): void { + const ticket = entry.ticket; + const host = this.#routedHost; + if (!ticket || !host) return; + if (entry.renewalTimer !== undefined) window.clearTimeout(entry.renewalTimer); + const generation = ticket.attachGrant.claims.grantGeneration; + const delay = Math.max(0, ticket.attachGrant.claims.expiresAt - Date.now() - 60_000); + if (delay > MAX_BROWSER_TIMEOUT_MS) { + entry.renewalTimer = window.setTimeout(() => { + if (entry.ticket !== ticket || this.#disposed) return; + this.#scheduleRoutedRenewal(entry); + }, MAX_BROWSER_TIMEOUT_MS); + return; + } + entry.renewalTimer = window.setTimeout(() => { + this.#transitionRouted(entry, { type: "grant-expiring" }); + if (!host.renewAttach) { + this.#transitionRouted(entry, { type: "renew-failed" }); + return; + } + const requestId = crypto.randomUUID(); + void host + .renewAttach({ sessionId: entry.sessionId, expectGeneration: generation, requestId }) + .then(({ attachGrant }) => { + if (entry.ticket !== ticket || !this.#routedRenewalMatches(entry, attachGrant, generation)) { + this.#transitionRouted(entry, { type: "renew-failed" }); + return; + } + entry.ticket = { ...ticket, attachGrant }; + this.#routedControl?.renew(entry.state.activationId, attachGrant); + this.#scheduleRoutedRenewal(entry); + }) + .catch(() => this.#transitionRouted(entry, { type: "renew-failed" })); + }, delay); + } + + #routedDemand(entry: RoutedActivationRuntime) { + let live = false; + let urgent = false; + for (const viewId of entry.viewIds) { + const view = this.#views.get(viewId); + live ||= view?.visible === true; + urgent ||= view?.visible === true && this.#focusByView.get(viewId) === true; + } + return { + mode: live ? ("live" as const) : ("none" as const), + urgency: urgent ? ("urgent" as const) : ("normal" as const), + }; + } + + #declareRoutedDemand(entry: RoutedActivationRuntime): void { + this.#routedControl?.declareDemand(entry.state.activationId, this.#routedDemand(entry)); + } + + #releaseRoutedView(sessionId: string, viewId: string): void { + const entry = this.#routedBySession.get(sessionId); + if (!entry) return; + entry.viewIds.delete(viewId); + if (entry.viewIds.size > 0) { + const anyWritable = + this.#routedHost?.encodeInput !== undefined && + [...entry.viewIds].some((candidate) => { + const view = this.#views.get(candidate); + return view?.clientReadWrite === true && view.readWrite !== false; + }); + this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" }); + this.#declareRoutedDemand(entry); + return; + } + this.#transitionRouted(entry, { type: "detach" }); + this.#routedBySession.delete(sessionId); + this.#routedByActivation.delete(entry.state.activationId); + this.#routedGeometry.delete(sessionId); + } + #createMountLease(mounted: MountedCanvas): TerminalMount { let disposed = false; return { @@ -1068,13 +1977,15 @@ export class GhostteaTerminalRuntime extends EventTarget { if (ownsWorkerSurface) { this.#postWorker({ type: "unmount", surfaceId: mounted.viewId }); this.#mountGenerationBySurface.delete(mounted.viewId); - this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId }); + if (this.#routedHost) this.#releaseRoutedView(mounted.sessionId, mounted.viewId); + else + this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId }); this.#views.delete(mounted.viewId); this.#focusByView.delete(mounted.viewId); } this.#mountedCanvases.delete(mounted.canvas); this.#mountedEntries.delete(mounted); - this.#releaseFrameSubscription(mounted.sessionHandle); + if (!this.#routedHost) this.#releaseFrameSubscription(mounted.sessionHandle); }, 0); }, }; @@ -1138,8 +2049,8 @@ export class GhostteaTerminalRuntime extends EventTarget { this.#sendResize(viewId, view, view.desiredCols, view.desiredRows); } } - // A cleared controller is the one case worth re-evaluating: the pane that - // still holds focus may now take control back. + // A cleared controller is the one case worth re-evaluating: a view with an + // outstanding explicit resize-control request may now take the seat. for (const viewId of this.#viewIdsForSession(sessionId)) this.#maybeReclaim(viewId); } @@ -1151,10 +2062,9 @@ export class GhostteaTerminalRuntime extends EventTarget { /** * The single funnel for taking resize control (§4.2.3). Every condition that - * gates a claim re-enters here when it changes, because no one event is - * enough: recovery marks a view attached before its session reaches live, and - * the focus setter suppresses repeat `true` updates, so a claim keyed on - * either alone would be skipped and never retried. + * gates an explicit claim re-enters here when it changes, because no one + * event is enough: recovery can mark a view attached before its session + * reaches live, while the resize-control request already exists. * * At most one claim per attachment epoch, plus one more each time the * controller is cleared at a newer revision. @@ -1168,11 +2078,10 @@ export class GhostteaTerminalRuntime extends EventTarget { */ #maybeReclaim(viewId: string): void { const view = this.#views.get(viewId); - if (!view || view.readWrite === false) return; + if (!view || view.readWrite === false || !view.clientReadWrite || !view.resizeControlRequested) return; const attachmentEpoch = view.attachmentEpoch; if (attachmentEpoch === undefined) return; if (view.desiredCols === undefined || view.desiredRows === undefined) return; - if (this.#focusByView.get(viewId) !== true) return; const remote = this.#remoteSessions.get(view.sessionId); if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame)) return; const control = this.#controlBySession.get(view.sessionId); @@ -1379,7 +2288,7 @@ export class GhostteaTerminalRuntime extends EventTarget { silent = false, ): void { const view = this.#views.get(viewId); - if (!view || view.readWrite === false) return; + if (!view || view.readWrite === false || !view.clientReadWrite) return; const remote = this.#remoteSessions.get(view.sessionId); const attachmentEpoch = view.attachmentEpoch; // Input for a remote session is dropped with feedback rather than queued: @@ -1405,7 +2314,63 @@ export class GhostteaTerminalRuntime extends EventTarget { ); } + #sendRoutedInput( + sessionId: string, + viewId: string, + operation: RoutedTerminalInputOperation, + silent = false, + ): boolean { + const host = this.#routedHost; + const view = this.#views.get(viewId); + const activation = this.#routedBySession.get(sessionId); + if ( + !host || + !view || + view.sessionId !== sessionId || + view.readWrite === false || + !view.clientReadWrite || + !activation?.state.inputAllowed + ) { + if (!silent) { + this.dispatchEvent( + new CustomEvent("routed-input-suppressed", { + detail: { + sessionId, + viewId, + reason: !host?.encodeInput ? "wire-verb-unavailable" : "input-not-allowed", + }, + }), + ); + } + return false; + } + if (!host.encodeInput) { + if (!silent) { + this.dispatchEvent( + new CustomEvent("routed-input-suppressed", { + detail: { sessionId, viewId, reason: "wire-verb-unavailable" }, + }), + ); + } + return false; + } + view.inputSequence += 1; + const message = host.encodeInput({ + sessionId, + viewId, + activationId: activation.state.activationId, + leaseEpoch: activation.ticket?.attachGrant.claims.leaseEpoch ?? 0, + inputSequence: view.inputSequence, + operation, + }); + return message !== null && this.#routedControl!.sendExtension(activation.state.activationId, message); + } + sendText(sessionId: string, viewId: string, text: string): void { + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "text", text }); + return; + } this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }), ); @@ -1414,6 +2379,10 @@ export class GhostteaTerminalRuntime extends EventTarget { } paste(sessionId: string, viewId: string, text: string): void { + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text }); + return; + } this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "paste", sessionId, viewId, attachmentEpoch, inputSequence, text }), ); @@ -1422,6 +2391,10 @@ export class GhostteaTerminalRuntime extends EventTarget { } sendKey(sessionId: string, viewId: string, event: TerminalKeyEvent): void { + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "key", event }); + return; + } this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-key", sessionId, viewId, attachmentEpoch, inputSequence, event }), ); @@ -1430,6 +2403,10 @@ export class GhostteaTerminalRuntime extends EventTarget { } sendMouse(sessionId: string, viewId: string, event: TerminalMouseEvent): void { + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "mouse", event }, true); + return; + } this.#sendViewInput( viewId, (attachmentEpoch, inputSequence) => @@ -1440,6 +2417,10 @@ export class GhostteaTerminalRuntime extends EventTarget { scroll(sessionId: string, viewId: string, rows: number): void { if (rows === 0) return; + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "scroll", rows }, true); + return; + } // Scrolling is host-side input, so it is simply inert while frozen. this.#sendViewInput( viewId, @@ -1451,6 +2432,10 @@ export class GhostteaTerminalRuntime extends EventTarget { scrollTo(sessionId: string, viewId: string, row: number): void { if (!Number.isSafeInteger(row) || row < 0) return; + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "scroll-to", row }, true); + return; + } this.#sendViewInput( viewId, (attachmentEpoch, inputSequence) => @@ -1469,6 +2454,9 @@ export class GhostteaTerminalRuntime extends EventTarget { setTheme(sessionHandle: string, theme: TerminalTheme, surfaceId?: string): void { this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme }); + // A surface-scoped theme is renderer-local. Updating the daemon's + // session-wide palette here would repaint every mirrored viewer. + if (surfaceId) return; const session = this.#sessionByHandle.get(sessionHandle); if (!session) return; const rgb = (color: TerminalTheme["foreground"]): [number, number, number] => [ @@ -1495,6 +2483,20 @@ export class GhostteaTerminalRuntime extends EventTarget { setVisible(sessionHandle: string, visible: boolean, surfaceId?: string): void { this.#postWorker({ type: "visibility", sessionHandle, ...(surfaceId ? { surfaceId } : {}), visible }); + if (this.#routedHost) { + const session = this.#sessionByHandle.get(sessionHandle); + if (!session) return; + if (surfaceId) { + const view = this.#views.get(surfaceId); + if (view) view.visible = visible; + } else { + for (const view of this.#views.values()) { + if (view.sessionId === session.id) view.visible = visible; + } + } + const entry = this.#routedBySession.get(session.id); + if (entry) this.#declareRoutedDemand(entry); + } } forceFullRedraw(sessionHandle: string): void { @@ -1509,31 +2511,90 @@ export class GhostteaTerminalRuntime extends EventTarget { this.#postWorker({ type: "partial-rendering", enabled }); } + #claimRoutedGeometry(entry: RoutedActivationRuntime, viewId: string, cols: number, rows: number): void { + const ticket = entry.ticket; + const view = this.#views.get(viewId); + if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry")) return; + const geometry = this.#routedGeometry.get(entry.sessionId); + this.#routedControl?.claimGeometry(entry.state.activationId, { + sessionId: entry.sessionId, + activationId: entry.state.activationId, + leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0, + claimant: { clientId: ticket.attachGrant.claims.clientId, viewId }, + cols, + rows, + expectRevision: geometry?.revision ?? 0, + }); + } + claimResizeControl(sessionHandle: string, viewId: string, cols: number, rows: number): void { const view = this.#views.get(viewId); if (view) { view.desiredCols = cols; view.desiredRows = rows; - // An explicit claim is the funnel's outcome, not a competing path. - view.claimedEpoch = view.attachmentEpoch; - view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0; + view.resizeControlRequested = true; } - const session = this.#sessionByHandle.get(sessionHandle); - if (!session) return; - this.#sendViewInput( - viewId, - (attachmentEpoch) => { - this.#control?.notify({ - type: "focus-and-resize", - sessionId: session.id, - viewId, - attachmentEpoch, - cols, - rows, + if (this.#routedHost) { + const session = this.#sessionByHandle.get(sessionHandle); + const entry = session ? this.#routedBySession.get(session.id) : undefined; + if (entry) this.#claimRoutedGeometry(entry, viewId, cols, rows); + return; + } + if (!this.#sessionByHandle.has(sessionHandle)) return; + this.#maybeReclaim(viewId); + } + + releaseResizeControl(viewId: string): void { + const view = this.#views.get(viewId); + if (!view) return; + view.resizeControlRequested = false; + if (this.#routedHost) { + const entry = this.#routedBySession.get(view.sessionId); + const geometry = this.#routedGeometry.get(view.sessionId); + const ticket = entry?.ticket; + if (entry && ticket && geometry?.holderViewId === viewId && geometry.holderGeneration !== undefined) { + const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, { + sessionId: view.sessionId, + activationId: entry.state.activationId, + leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0, + holder: { + clientId: ticket.attachGrant.claims.clientId, + viewId, + holderGeneration: geometry.holderGeneration, + }, }); - }, - true, - ); + if (sent) { + // T1 sends no success body for release. The ordered control leg and + // cell state machine make the next revision deterministic. + this.#routedGeometry.set(view.sessionId, { + revision: geometry.revision + 1, + ...(geometry.cols === undefined ? {} : { cols: geometry.cols }), + ...(geometry.rows === undefined ? {} : { rows: geometry.rows }), + }); + } + } + return; + } + // The legacy protocol has no release verb. Clearing the local epoch still + // closes every resize path immediately; a later explicit claim can renew it. + view.controlEpoch = undefined; + } + + setViewInputPolicy(viewId: string, readWrite: boolean): void { + const view = this.#views.get(viewId); + if (!view) return; + view.clientReadWrite = readWrite; + if (!readWrite) view.pendingInput.length = 0; + const entry = this.#routedBySession.get(view.sessionId); + if (entry) { + const anyWritable = + this.#routedHost?.encodeInput !== undefined && + [...entry.viewIds].some((candidate) => { + const candidateView = this.#views.get(candidate); + return candidateView?.clientReadWrite === true && candidateView.readWrite !== false; + }); + this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" }); + } } setFocused(sessionHandle: string, viewId: string, focused: boolean, cols: number, rows: number): void { @@ -1543,16 +2604,17 @@ export class GhostteaTerminalRuntime extends EventTarget { view.desiredRows = rows; } if (this.#focusByView.get(viewId) === focused) { - // Focus has not moved, so the claim below will not run — but an epoch or - // controller change since the last update may have made one possible, - // and nothing else would ever retry it after a resume. - this.#maybeReclaim(viewId); return; } this.#focusByView.set(viewId, focused); this.#postWorker({ type: "focus", surfaceId: viewId, sessionHandle, focused }); const session = this.#sessionByHandle.get(sessionHandle); if (!session) return; + if (this.#routedHost) { + const entry = this.#routedBySession.get(session.id); + if (entry) this.#declareRoutedDemand(entry); + return; + } this.#sendViewInput( viewId, (attachmentEpoch, inputSequence) => { @@ -1564,21 +2626,6 @@ export class GhostteaTerminalRuntime extends EventTarget { inputSequence, focused, }); - if (focused) { - // Taking focus is a deliberate claim, and counts as this epoch's. - if (view) { - view.claimedEpoch = attachmentEpoch; - view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0; - } - this.#control?.notify({ - type: "focus-and-resize", - sessionId: session.id, - viewId, - attachmentEpoch, - cols, - rows, - }); - } }, true, ); @@ -1586,6 +2633,7 @@ export class GhostteaTerminalRuntime extends EventTarget { async copySelection(sessionId: string, viewId: string, selection: CellSelection, selectAll = false): Promise { await this.connect(); + if (this.#routedHost) return ""; const view = this.#views.get(viewId); if (!view || view.sessionId !== sessionId) return ""; // A frozen replica stays copyable: offline the daemon answers from the @@ -1619,6 +2667,10 @@ export class GhostteaTerminalRuntime extends EventTarget { } interrupt(sessionId: string, viewId: string): void { + if (this.#routedHost) { + this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" }); + return; + } this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "interrupt", sessionId, viewId, attachmentEpoch, inputSequence }), ); @@ -1677,10 +2729,23 @@ export class GhostteaTerminalRuntime extends EventTarget { } unregisterSession(sessionId: string): void { + const routed = this.#routedBySession.get(sessionId); + if (routed) { + for (const viewId of [...routed.viewIds]) this.#releaseRoutedView(sessionId, viewId); + } this.#removeRegisteredSession(sessionId, true); } terminate(sessionId: string, source: TerminationSource = "user"): void { + if (this.#routedHost) { + void this.#routedHost.terminate?.(sessionId, source); + const entry = this.#routedBySession.get(sessionId); + if (entry) { + for (const viewId of [...entry.viewIds]) this.#releaseRoutedView(sessionId, viewId); + } + this.#removeRegisteredSession(sessionId, false); + return; + } this.#control?.notify({ type: "terminate", sessionId, source }); this.#removeRegisteredSession(sessionId, false); } @@ -1690,6 +2755,12 @@ export class GhostteaTerminalRuntime extends EventTarget { if (!view || view.sessionId !== sessionId) return; view.desiredCols = cols; view.desiredRows = rows; + if (!view.resizeControlRequested) return; + if (this.#routedHost) { + const entry = this.#routedBySession.get(sessionId); + if (entry) this.#claimRoutedGeometry(entry, viewId, cols, rows); + return; + } if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) { // Dimensions are one of the funnel's conditions: a pane that measured // itself while uncontrolled may now be able to take control. @@ -1727,6 +2798,11 @@ export class GhostteaTerminalRuntime extends EventTarget { request.reject(new Error("Terminal runtime was disposed during a performance request")); } this.#performanceRequests.clear(); + for (const request of this.#counterRequests.values()) { + window.clearTimeout(request.timer); + request.reject(new Error("Terminal runtime was disposed during a counter request")); + } + this.#counterRequests.clear(); this.#views.clear(); this.#remoteSessions.clear(); this.#controlBySession.clear(); @@ -1747,19 +2823,30 @@ export class GhostteaTerminalRuntime extends EventTarget { } this.#control?.dispose(); this.#control = undefined; + this.#routedControl?.dispose(); + for (const entry of this.#routedBySession.values()) { + if (entry.attachTimer !== undefined) window.clearTimeout(entry.attachTimer); + if (entry.renewalTimer !== undefined) window.clearTimeout(entry.renewalTimer); + } + this.#routedBySession.clear(); + this.#routedByActivation.clear(); + this.#routedGeometry.clear(); + this.#routedAttachDeadlineByCell.clear(); this.#serverProtocolMinor = 0; this.#worker.terminate(); - void this.#ports.then( - (ports) => { - ports.control.close(); - ports.frames.close(); - }, - () => undefined, - ); + if (this.#ports) { + void this.#ports.then( + (ports) => { + ports.control.close(); + ports.frames.close(); + }, + () => undefined, + ); + } } #sendResize(viewId: string, view: ViewRuntimeState, cols: number, rows: number): void { - if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) return; + if (!view.resizeControlRequested || view.attachmentEpoch === undefined || view.controlEpoch === undefined) return; view.resizeSequence += 1; this.#control?.notify({ type: "resize", diff --git a/packages/ghosttea-react/src/terminal-render.worker.test.ts b/packages/ghosttea-react/src/terminal-render.worker.test.ts index 39aad1b1..945e1c72 100644 --- a/packages/ghosttea-react/src/terminal-render.worker.test.ts +++ b/packages/ghosttea-react/src/terminal-render.worker.test.ts @@ -1,4 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_ROUTED_PROTOCOL_LIMITS, + DEFAULT_ROUTED_RECEIVER_CAPACITIES, + encodeRoutedMessage, + encodeRoutedPresentationEnvelope, + routedCrc32c, +} from "@vibecook/ghosttea-protocol"; +import { + FRAME_HEADER_BYTES, + FRAME_MAGIC, + FRAME_PROTOCOL_VERSION, + FrameFlag, + SectionKind, +} from "@vibecook/ghosttea-frame"; const renderer = vi.hoisted(() => ({ mount: vi.fn(), @@ -10,6 +24,87 @@ const renderer = vi.hoisted(() => ({ const webgpu = vi.hoisted(() => ({ enabled: false })); +class FakeRoutedSocket extends EventTarget { + readyState = 0; + binaryType = "blob"; + readonly sent: string[] = []; + + send(data: string): void { + this.sent.push(data); + } + + open(): void { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + } + + receive(data: string | ArrayBuffer): void { + this.dispatchEvent(new MessageEvent("message", { data })); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + const event = new Event("close") as Event & { code: number; reason: string }; + Object.assign(event, { code, reason }); + this.dispatchEvent(event); + } +} + +function identityOnlyFrame(sessionHandle: bigint, viewHandle: bigint): ArrayBuffer { + const buffer = new ArrayBuffer(FRAME_HEADER_BYTES); + const view = new DataView(buffer); + view.setUint32(0, FRAME_MAGIC, true); + view.setUint16(4, FRAME_PROTOCOL_VERSION, true); + view.setBigUint64(8, sessionHandle, true); + view.setBigUint64(16, viewHandle, true); + view.setBigUint64(24, 1n, true); + view.setBigUint64(32, 1n, true); + view.setBigUint64(40, 1n, true); + view.setBigUint64(48, 1n, true); + view.setUint16(56, 80, true); + view.setUint16(58, 24, true); + return buffer; +} + +function minimalFullFrame(sessionHandle: bigint, viewHandle: bigint): ArrayBuffer { + const rowOffset = FRAME_HEADER_BYTES + 3 * 16; + const cursorOffset = rowOffset + 2; + const scrollbarOffset = cursorOffset + 8; + const buffer = new ArrayBuffer(scrollbarOffset + 24); + const view = new DataView(buffer); + view.setUint32(0, FRAME_MAGIC, true); + view.setUint16(4, FRAME_PROTOCOL_VERSION, true); + view.setUint16(6, FrameFlag.FullSnapshot, true); + view.setBigUint64(8, sessionHandle, true); + view.setBigUint64(16, viewHandle, true); + view.setBigUint64(24, 1n, true); + view.setBigUint64(32, 1n, true); + view.setBigUint64(40, 1n, true); + view.setBigUint64(48, 1n, true); + view.setUint16(56, 80, true); + view.setUint16(58, 24, true); + view.setUint16(60, 3, true); + view.setUint16(FRAME_HEADER_BYTES, SectionKind.RowReplacements, true); + view.setUint16(FRAME_HEADER_BYTES + 2, 1, true); + view.setUint32(FRAME_HEADER_BYTES + 4, rowOffset, true); + view.setUint32(FRAME_HEADER_BYTES + 8, 2, true); + view.setUint32(FRAME_HEADER_BYTES + 12, 0, true); + view.setUint16(FRAME_HEADER_BYTES + 16, SectionKind.CursorState, true); + view.setUint32(FRAME_HEADER_BYTES + 20, cursorOffset, true); + view.setUint32(FRAME_HEADER_BYTES + 24, 8, true); + view.setUint32(FRAME_HEADER_BYTES + 28, 1, true); + view.setUint16(FRAME_HEADER_BYTES + 32, SectionKind.ScrollbarState, true); + view.setUint32(FRAME_HEADER_BYTES + 36, scrollbarOffset, true); + view.setUint32(FRAME_HEADER_BYTES + 40, 24, true); + view.setUint32(FRAME_HEADER_BYTES + 44, 1, true); + view.setUint16(rowOffset, 0, true); + view.setBigUint64(scrollbarOffset, 24n, true); + view.setBigUint64(scrollbarOffset + 8, 0n, true); + view.setBigUint64(scrollbarOffset + 16, 24n, true); + return buffer; +} + vi.mock("./renderers/canvas-renderer.js", () => ({ CanvasTerminalRenderer: class { readonly kind = "canvas2d" as const; @@ -228,4 +323,269 @@ describe("terminal render worker surfaces", () => { expect(workerScope.postMessage).toHaveBeenCalledWith({ type: "frame-credit", bytes: 7 }); error.mockRestore(); }); + + it.each([ + { + label: "mismatched TRF1 identity", + transfer: false, + full: false, + targetScrollbackRows: 0, + rejected: true, + expectedDecodes: 0, + }, + { + label: "non-full transfer snapshot", + transfer: true, + full: false, + targetScrollbackRows: 0, + rejected: true, + expectedDecodes: 0, + }, + { + label: "mismatched transfer scrollback layout", + transfer: true, + full: true, + targetScrollbackRows: 1, + rejected: true, + expectedDecodes: 1, + }, + { + label: "full transfer snapshot", + transfer: true, + full: true, + targetScrollbackRows: 0, + rejected: false, + expectedDecodes: 1, + }, + ])( + "validates a routed $label before mutating a scene", + async ({ transfer, full, targetScrollbackRows, rejected, expectedDecodes }) => { + const workerScope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: vi.fn(), + requestAnimationFrame: (_callback: FrameRequestCallback): number => 1, + }; + const sockets: FakeRoutedSocket[] = []; + vi.stubGlobal("self", workerScope); + vi.stubGlobal( + "WebSocket", + class extends FakeRoutedSocket { + constructor(_url: string) { + super(); + sockets.push(this); + } + }, + ); + await import("./terminal-render.worker.js"); + const dispatch = (data: unknown): void => workerScope.onmessage?.({ data } as MessageEvent); + const protectedBase = { + v: 1 as const, + iss: "fieldd" as const, + alg: "HS256" as const, + kid: { cellBootId: "cell-a", keyGeneration: 1 }, + }; + const transportGrant = { + protected: { ...protectedBase, typ: "CellTransportGrant" as const }, + claims: { + audienceCellBootId: "cell-a", + clientId: "window-a", + connectionSetId: "set-a", + allowedChannels: ["control", "frames"] as const, + transportGrantGeneration: 1, + issuedAt: 1, + expiresAt: 2, + nonce: "nonce-a", + }, + mac: "opaque", + }; + const attachGrant = { + protected: { ...protectedBase, typ: "SessionAttachGrant" as const }, + claims: { + audienceCellBootId: "cell-a", + clientId: "window-a", + sessionId: "session-a", + leaseEpoch: 4, + routeRevision: 1, + grantGeneration: 1, + rights: ["input", "read"] as const, + issuedAt: 1, + expiresAt: 2, + }, + mac: "opaque", + }; + + dispatch({ + type: "routed-frames-attach", + request: { + cellBootId: "cell-a", + sessionHandle: "11", + framesUrl: "ws://127.0.0.1/frames", + transportGrant, + attachGrant, + activationId: "activation-a", + capabilities: ["resume"], + }, + }); + const socket = sockets[0]!; + socket.open(); + socket.receive( + encodeRoutedMessage("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "frames", + legGeneration: 1, + heartbeatTtlMs: 15_000, + creditEpoch: 1, + initialWindows: DEFAULT_ROUTED_RECEIVER_CAPACITIES, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + socket.receive( + encodeRoutedMessage("FramesLegAttached", { + sessionId: "session-a", + activationId: "activation-a", + resumeToken: "resume-a", + trfIdentity: { sessionHandle: "11", viewHandle: "12" }, + outcome: { kind: "seed-required", reason: "no-cursor" }, + }), + ); + const resultContent = { + sceneEpoch: { cellBootId: "cell-a", modelGeneration: 1 }, + sceneRevision: 1, + }; + const packet = new Uint8Array(full ? minimalFullFrame(11n, 12n) : identityOnlyFrame(transfer ? 11n : 99n, 12n)); + if (transfer) { + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 1, + sessionId: "session-a", + activationId: "activation-a", + leaseEpoch: 4, + kind: "transfer-begin", + baseContent: null, + resultContent, + transfer: { + transferId: "seed-a", + kind: "seed", + totalBytes: packet.byteLength, + chunkCount: 1, + targetLayout: { cols: 80, rows: 24, scrollbackRows: targetScrollbackRows }, + checksum: { alg: "crc32c", value: routedCrc32c(packet) }, + }, + }, + new Uint8Array(), + ).buffer as ArrayBuffer, + ); + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 2, + sessionId: "session-a", + activationId: "activation-a", + leaseEpoch: 4, + kind: "transfer-chunk", + transfer: { transferId: "seed-a", chunkIndex: 0, byteOffset: 0 }, + }, + packet, + ).buffer as ArrayBuffer, + ); + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 3, + sessionId: "session-a", + activationId: "activation-a", + leaseEpoch: 4, + kind: "transfer-end", + transfer: { transferId: "seed-a" }, + }, + new Uint8Array(), + ).buffer as ArrayBuffer, + ); + } else { + socket.receive( + encodeRoutedPresentationEnvelope( + { + creditEpoch: 1, + activationSequence: 1, + sessionId: "session-a", + activationId: "activation-a", + leaseEpoch: 4, + kind: "trf1-frame", + baseContent: null, + resultContent, + }, + packet, + ).buffer as ArrayBuffer, + ); + } + await Promise.resolve(); + + dispatch({ type: "performance-counters", requestId: 8 }); + if (rejected) { + expect(workerScope.postMessage).toHaveBeenCalledWith({ + type: "routed-frames-event", + event: expect.objectContaining({ + type: "frames-state", + activationId: "activation-a", + state: "failed", + reason: "PROTOCOL", + }), + }); + expect(workerScope.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "frame-committed" })); + expect(workerScope.postMessage).toHaveBeenCalledWith({ + type: "performance-counters", + requestId: 8, + snapshot: expect.objectContaining({ + frames: expect.objectContaining({ received: 1, decodes: expectedDecodes, applies: 0 }), + sessions: + expectedDecodes === 0 ? {} : { "11": expect.objectContaining({ received: 1, decodes: 1, applies: 0 }) }, + }), + }); + } else { + expect(workerScope.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "frame-committed", sessionHandle: "11", fullSnapshot: true }), + ); + expect(workerScope.postMessage).toHaveBeenCalledWith({ + type: "performance-counters", + requestId: 8, + snapshot: expect.objectContaining({ + frames: expect.objectContaining({ received: 1, full: 1, decodes: 1, applies: 1 }), + sessions: { "11": expect.objectContaining({ received: 1, full: 1, decodes: 1, applies: 1 }) }, + }), + }); + } + }, + ); + + it("reads monotonic production counters without opening a sample window or settling the GPU", async () => { + const workerScope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: vi.fn(), + requestAnimationFrame: (_callback: FrameRequestCallback): number => 1, + }; + vi.stubGlobal("self", workerScope); + await import("./terminal-render.worker.js"); + for (let requestId = 0; requestId < 10; requestId += 1) { + workerScope.onmessage?.({ data: { type: "performance-counters", requestId } } as MessageEvent); + expect(workerScope.postMessage).toHaveBeenCalledWith({ + type: "performance-counters", + requestId, + snapshot: expect.objectContaining({ + backend: "starting", + frames: expect.objectContaining({ received: 0, decodes: 0, applies: 0 }), + renderer: { queueSubmits: 0, presents: 0 }, + flow: { creditBytesReturned: 0, creditBatchesReturned: 0 }, + }), + }); + } + expect(workerScope.postMessage).toHaveBeenCalledTimes(10); + expect(renderer.render).not.toHaveBeenCalled(); + expect(renderer.renderBatch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ghosttea-react/src/terminal-render.worker.ts b/packages/ghosttea-react/src/terminal-render.worker.ts index 418ab74a..6c0608cc 100644 --- a/packages/ghosttea-react/src/terminal-render.worker.ts +++ b/packages/ghosttea-react/src/terminal-render.worker.ts @@ -17,7 +17,7 @@ import { type StyleRun, SectionKind, } from "@vibecook/ghosttea-frame"; -import type { TerminalScrollbarState } from "@vibecook/ghosttea-protocol"; +import type { RoutedTrfIdentity, TerminalScrollbarState } from "@vibecook/ghosttea-protocol"; import { CanvasTerminalRenderer } from "./renderers/canvas-renderer.js"; import { DEFAULT_EFFECTS, @@ -34,9 +34,14 @@ import { import { WebGpuTerminalRenderer } from "./renderers/webgpu-renderer.js"; import { classifyFrame } from "./frame-sequence.js"; import { cursorActivityChangesPixels } from "./cursor-invalidation.js"; -import { emptyRenderMetrics, type TerminalRenderPerformanceSnapshot } from "./performance.js"; +import { + emptyRenderMetrics, + type TerminalRenderCounterSnapshot, + type TerminalRenderPerformanceSnapshot, +} from "./performance.js"; import type { RendererToWorkerMessage, WorkerToRendererMessage } from "./worker-messages.js"; import { catalogAdmission, definitionCatalogFits, glyphCatalogFits } from "./catalog-budget.js"; +import { RoutedFramesTransport, type RoutedAppliedFrame, type RoutedExpectedLayout } from "./routed-frames.js"; interface SessionSnapshot { rows: string[]; @@ -140,6 +145,41 @@ interface ActivePerformanceMeasurement { let performanceMeasurement: ActivePerformanceMeasurement | undefined; +type SessionCounters = TerminalRenderCounterSnapshot["sessions"][string]; +const lifetimeStartedAt = performance.now(); +const lifetimeFrames: SessionCounters = { + received: 0, + bytes: 0, + full: 0, + incremental: 0, + stale: 0, + decodes: 0, + applies: 0, +}; +const lifetimeSessions = new Map(); +const lifetimeRenderer = { queueSubmits: 0, presents: 0 }; +const lifetimeFlow = { creditBytesReturned: 0, creditBatchesReturned: 0 }; + +function sessionCounters(sessionHandle: string): SessionCounters { + let counters = lifetimeSessions.get(sessionHandle); + if (!counters) { + counters = { received: 0, bytes: 0, full: 0, incremental: 0, stale: 0, decodes: 0, applies: 0 }; + lifetimeSessions.set(sessionHandle, counters); + } + return counters; +} + +function counterSnapshot(): TerminalRenderCounterSnapshot { + return { + backend: renderer?.kind ?? "starting", + durationMs: performance.now() - lifetimeStartedAt, + frames: { ...lifetimeFrames }, + renderer: { ...lifetimeRenderer }, + flow: { ...lifetimeFlow }, + sessions: Object.fromEntries([...lifetimeSessions].map(([id, counters]) => [id, { ...counters }])), + }; +} + function appendPerformanceSample(samples: number[], value: number): void { if (samples.length < MAX_PERFORMANCE_SAMPLES) samples.push(value); } @@ -150,6 +190,8 @@ function flushFrameCredit(): void { if (pendingFrameCreditBytes === 0) return; const bytes = pendingFrameCreditBytes; pendingFrameCreditBytes = 0; + lifetimeFlow.creditBytesReturned += bytes; + lifetimeFlow.creditBatchesReturned += 1; postToRenderer({ type: "frame-credit", bytes }); } @@ -228,6 +270,11 @@ function recordRenderMetrics(metrics: ReturnType): vo target.atlasUploadCalls += metrics.atlasUploadCalls; } +function recordLifetimeRenderMetrics(metrics: ReturnType): void { + lifetimeRenderer.queueSubmits += metrics.queueSubmits; + lifetimeRenderer.presents += metrics.fullRenders + metrics.partialRenders; +} + async function finishPerformanceMeasurement(requestId: number, quietMs: number, timeoutMs: number): Promise { const active = performanceMeasurement; if (!active) throw new Error("No terminal render performance measurement is active"); @@ -278,25 +325,29 @@ function postToRenderer(message: WorkerToRendererMessage): void { self.postMessage(message); } +function emptySessionSnapshot(): SessionSnapshot { + return { + rows: [], + nativeRows: [], + nativeStyleRows: [], + glyphDefinitions: new Map(), + glyphPixelBytes: 0, + styleDefinitions: new Map(), + rowRevisions: [], + cursor: hiddenCursor, + layoutEpoch: 0n, + sessionEpoch: 0n, + sequence: 0n, + awaitingResync: false, + catalogFallback: false, + scrollbar: null, + }; +} + function snapshot(sessionHandle: string): SessionSnapshot { let value = snapshots.get(sessionHandle); if (!value) { - value = { - rows: [], - nativeRows: [], - nativeStyleRows: [], - glyphDefinitions: new Map(), - glyphPixelBytes: 0, - styleDefinitions: new Map(), - rowRevisions: [], - cursor: hiddenCursor, - layoutEpoch: 0n, - sessionEpoch: 0n, - sequence: 0n, - awaitingResync: false, - catalogFallback: false, - scrollbar: null, - }; + value = emptySessionSnapshot(); snapshots.set(sessionHandle, value); } return value; @@ -577,6 +628,7 @@ async function flush(): Promise { const metrics = backend.renderBatch ? backend.renderBatch(entries) : entries.map(({ id, view }) => backend.render(id, view)); + for (const metric of metrics) recordLifetimeRenderMetrics(metric ?? emptyRenderMetrics()); const renderedAt = active ? performance.now() : 0; for (const { id } of entries) { const damage = surfaces.get(id)?.damage; @@ -667,9 +719,15 @@ async function mount(surfaceId: string, sessionHandle: string, canvas: Offscreen scheduleShaderAnimation(); } -function applyFrame(packet: ArrayBuffer): void { +function applyFrame( + packet: ArrayBuffer, + expectedIdentity?: RoutedTrfIdentity, + expectedLayout?: RoutedExpectedLayout, +): RoutedAppliedFrame | undefined { const active = performanceMeasurement; const applyStarted = active ? performance.now() : 0; + lifetimeFrames.received += 1; + lifetimeFrames.bytes += packet.byteLength; if (active) { active.frames.received += 1; active.frames.bytes += packet.byteLength; @@ -677,15 +735,42 @@ function applyFrame(packet: ArrayBuffer): void { } const frame = decodeFrame(packet); const id = frame.sessionHandle.toString(); + if ( + expectedIdentity && + (id !== expectedIdentity.sessionHandle || frame.viewHandle.toString() !== expectedIdentity.viewHandle) + ) { + throw new Error("TRF1 identity does not match the routed activation binding"); + } + if (expectedLayout && (frame.cols !== expectedLayout.cols || frame.rows !== expectedLayout.rows)) { + throw new Error("transfer layout does not match TRF1"); + } + if (expectedLayout && (frame.flags & FrameFlag.FullSnapshot) === 0) { + throw new Error("routed transfer is not a full TRF1 snapshot"); + } + const counters = sessionCounters(id); + lifetimeFrames.decodes += 1; + counters.received += 1; + counters.bytes += packet.byteLength; + counters.decodes += 1; + if ((frame.flags & FrameFlag.FullSnapshot) !== 0) { + lifetimeFrames.full += 1; + counters.full += 1; + } else { + lifetimeFrames.incremental += 1; + counters.incremental += 1; + } if (active) { active.lastFrameAt.set(id, applyStarted); if ((frame.flags & FrameFlag.FullSnapshot) !== 0) active.frames.full += 1; else active.frames.incremental += 1; } - const previous = snapshot(id); + const replacingScene = expectedLayout !== undefined; + const installedScene = replacingScene ? snapshots.get(id) : undefined; + const previous = replacingScene ? emptySessionSnapshot() : snapshot(id); const fullFrame = (frame.flags & FrameFlag.FullSnapshot) !== 0; const catalogReset = (frame.flags & FrameFlag.CatalogReset) !== 0; if (catalogReset && !fullFrame) { + if (expectedIdentity) throw new Error("catalog reset without a full routed frame"); if (active) { active.frames.resyncRequested += 1; appendPerformanceSample(active.samples.frameApplyMs, performance.now() - applyStarted); @@ -701,21 +786,25 @@ function applyFrame(packet: ArrayBuffer): void { full: fullFrame, }); if (classification === "stale") { + lifetimeFrames.stale += 1; + counters.stale += 1; if (active) { active.frames.stale += 1; appendPerformanceSample(active.samples.frameApplyMs, performance.now() - applyStarted); } - return; + if (expectedIdentity) throw new Error("stale routed TRF1 frame"); + return undefined; } const changedSession = previous.sessionEpoch !== 0n && frame.sessionEpoch !== previous.sessionEpoch; if (classification === "resync") { + if (expectedIdentity) throw new Error("routed TRF1 continuity requires a transfer"); if (active) { active.frames.resyncRequested += 1; appendPerformanceSample(active.samples.frameApplyMs, performance.now() - applyStarted); } previous.awaitingResync = true; postToRenderer({ type: "frame-resync-needed", sessionHandle: id }); - return; + return undefined; } const completingResync = previous.awaitingResync; const rowSection = frame.sections.find((candidate) => candidate.kind === SectionKind.RowReplacements); @@ -725,14 +814,49 @@ function applyFrame(packet: ArrayBuffer): void { const clipboardSection = frame.sections.find((candidate) => candidate.kind === SectionKind.ClipboardWrite); const scrollbarSection = frame.sections.find((candidate) => candidate.kind === SectionKind.ScrollbarState); if (!rowSection || !cursorSection) { + if (expectedIdentity) throw new Error("routed TRF1 frame is missing required sections"); if (active) appendPerformanceSample(active.samples.frameApplyMs, performance.now() - applyStarted); - return; + return undefined; + } + const fullRows = (rowSection.flags & 1) !== 0; + if (replacingScene && !fullRows) { + throw new Error("routed transfer row section is not a full snapshot"); } const glyphDefinitions = glyphSection ? decodeGlyphDefinitions(glyphSection) : NO_GLYPH_DEFINITIONS; const styleDefinitions = styleSection ? decodeStyleDefinitions(styleSection) : NO_STYLE_DEFINITIONS; + const replacements = decodeRowReplacements(rowSection); + for (const replacement of replacements) { + if (replacement.row >= frame.rows) throw new RangeError("Row replacement exceeds viewport"); + } + const nextCursor = decodeCursorState(cursorSection); + const clipboardText = clipboardSection ? decodeClipboardWrite(clipboardSection) : undefined; + let scrollbar: TerminalScrollbarState | undefined; + if (scrollbarSection) { + const decoded = decodeScrollbarState(scrollbarSection); + scrollbar = { + total: Number(decoded.total), + offset: Number(decoded.offset), + length: Number(decoded.length), + }; + if ( + !Number.isSafeInteger(scrollbar.total) || + !Number.isSafeInteger(scrollbar.offset) || + !Number.isSafeInteger(scrollbar.length) + ) { + throw new RangeError("Scrollbar state exceeds JavaScript's safe integer range"); + } + } + if ( + expectedLayout && + (scrollbar === undefined || + scrollbar.length !== expectedLayout.rows || + scrollbar.total - scrollbar.length !== expectedLayout.scrollbackRows) + ) { + throw new Error("transfer scrollback layout does not match TRF1"); + } if (active) active.frames.glyphDefinitions += glyphDefinitions.length; - const resetsCatalog = changedSession || completingResync || catalogReset; + const resetsCatalog = replacingScene || changedSession || completingResync || catalogReset; const wasCatalogFallback = previous.catalogFallback; if (resetsCatalog) { previous.rows = []; @@ -787,13 +911,14 @@ function applyFrame(packet: ArrayBuffer): void { ); const admission = catalogAdmission(false, false, catalogFits); if (admission === "request-full") { + if (expectedIdentity) throw new Error("routed catalog pressure requires a fresh transfer"); if (active) { active.frames.resyncRequested += 1; appendPerformanceSample(active.samples.frameApplyMs, performance.now() - applyStarted); } previous.awaitingResync = true; postToRenderer({ type: "frame-resync-needed", sessionHandle: id }); - return; + return undefined; } if (admission === "install") { installGlyphDefinitions(previous, glyphDefinitions); @@ -804,22 +929,8 @@ function applyFrame(packet: ArrayBuffer): void { nativeTextAnnounced = true; postToRenderer({ type: "renderer-status", backend: renderer?.kind ?? "starting", textEngine: "native" }); } - if (clipboardSection) { - postToRenderer({ type: "clipboard-write", text: decodeClipboardWrite(clipboardSection) }); - } - if (scrollbarSection) { - const decoded = decodeScrollbarState(scrollbarSection); - const scrollbar = { - total: Number(decoded.total), - offset: Number(decoded.offset), - length: Number(decoded.length), - }; - if ( - !Number.isSafeInteger(scrollbar.total) || - !Number.isSafeInteger(scrollbar.offset) || - !Number.isSafeInteger(scrollbar.length) - ) - throw new RangeError("Scrollbar state exceeds JavaScript's safe integer range"); + let scrollbarChanged = false; + if (scrollbar) { if ( !previous.scrollbar || previous.scrollbar.total !== scrollbar.total || @@ -827,25 +938,22 @@ function applyFrame(packet: ArrayBuffer): void { previous.scrollbar.length !== scrollbar.length ) { previous.scrollbar = scrollbar; - postToRenderer({ type: "scrollbar-state", sessionHandle: id, scrollbar }); + scrollbarChanged = true; } } - const full = (rowSection.flags & 1) !== 0; const useNativeCatalog = !previous.catalogFallback; - const rows = full ? Array(frame.rows).fill("") : previous.rows.slice(); - const nativeRows = full + const rows = fullRows ? Array(frame.rows).fill("") : previous.rows.slice(); + const nativeRows = fullRows ? Array.from({ length: frame.rows }, () => [] as GlyphInstance[]) : previous.nativeRows.slice(); - const nativeStyleRows = full + const nativeStyleRows = fullRows ? Array.from({ length: frame.rows }, () => [] as StyleRun[]) : previous.nativeStyleRows.slice(); - const rowRevisions = full ? Array(frame.rows).fill(0n) : previous.rowRevisions.slice(); - const replacements = decodeRowReplacements(rowSection); + const rowRevisions = fullRows ? Array(frame.rows).fill(0n) : previous.rowRevisions.slice(); const damagedRows: number[] = []; if (active) active.frames.rowsDecoded += replacements.length; for (const replacement of replacements) { - if (replacement.row >= frame.rows) throw new RangeError("Row replacement exceeds viewport"); if (replacement.revision < (rowRevisions[replacement.row] ?? 0n)) continue; rows[replacement.row] = replacement.text; nativeRows[replacement.row] = useNativeCatalog ? replacement.glyphs : []; @@ -859,7 +967,6 @@ function applyFrame(packet: ArrayBuffer): void { previous.rowRevisions = rowRevisions; const geometryChanged = damagedRows.length > 0; const previousCursor = previous.cursor; - const nextCursor = decodeCursorState(cursorSection); const cursorChanged = nextCursor.x !== previousCursor.x || nextCursor.y !== previousCursor.y || @@ -871,8 +978,14 @@ function applyFrame(packet: ArrayBuffer): void { previous.sessionEpoch = frame.sessionEpoch; previous.sequence = frame.frameSequence; previous.awaitingResync = false; + if (replacingScene) { + snapshots.set(id, previous); + if (installedScene) clearSessionCatalog(installedScene); + } + if (clipboardText !== undefined) postToRenderer({ type: "clipboard-write", text: clipboardText }); + if (scrollbarChanged && scrollbar) postToRenderer({ type: "scrollbar-state", sessionHandle: id, scrollbar }); if (completingResync) postToRenderer({ type: "frame-resync-complete", sessionHandle: id }); - const requiresFullRedraw = full || changedSession || completingResync; + const requiresFullRedraw = fullRows || changedSession || completingResync; if (!requiresFullRedraw && cursorChanged) damagedRows.push(previousCursor.y, nextCursor.y); const hasRowDamage = damagedRows.length > 0; for (const surfaceId of surfaceIdsForSession(id)) { @@ -892,9 +1005,30 @@ function applyFrame(packet: ArrayBuffer): void { // recovered screen apart from a partial update of the stale one. fullSnapshot: fullFrame, }); + lifetimeFrames.applies += 1; + counters.applies += 1; if (active) appendPerformanceSample(active.samples.frameApplyMs, performance.now() - applyStarted); + return { + sessionHandle: id, + viewHandle: frame.viewHandle.toString(), + cols: frame.cols, + rows: frame.rows, + }; } +const routedFrames = new RoutedFramesTransport({ + applyFrame: (packet, identity, expectedLayout) => { + const applied = applyFrame(packet, identity, expectedLayout); + if (!applied) throw new Error("routed TRF1 frame was not applied"); + return applied; + }, + emit: (event) => postToRenderer({ type: "routed-frames-event", event }), + creditReturned: (bytes) => { + lifetimeFlow.creditBytesReturned += bytes; + lifetimeFlow.creditBatchesReturned += 1; + }, +}); + self.onmessage = (event: MessageEvent) => { const message = event.data; try { @@ -1057,6 +1191,12 @@ self.onmessage = (event: MessageEvent) => { void finishPerformanceMeasurement(message.requestId, message.quietMs, message.timeoutMs).catch((error) => { console.error("[terminal-renderer] failed to finish performance measurement", error); }); + } else if (message.type === "performance-counters") { + postToRenderer({ type: "performance-counters", requestId: message.requestId, snapshot: counterSnapshot() }); + } else if (message.type === "routed-frames-attach") { + routedFrames.attach(message.request); + } else if (message.type === "routed-frames-detach") { + routedFrames.detach(message.activationId); } } catch (error) { console.error("[terminal-renderer] rejected worker message", error); diff --git a/packages/ghosttea-react/src/worker-messages.ts b/packages/ghosttea-react/src/worker-messages.ts index ecc0a6a5..59194e6e 100644 --- a/packages/ghosttea-react/src/worker-messages.ts +++ b/packages/ghosttea-react/src/worker-messages.ts @@ -1,6 +1,7 @@ import type { CellSelection, TerminalEffects, TerminalTheme } from "./renderers/types.js"; import type { TerminalScrollbarState } from "@vibecook/ghosttea-protocol"; -import type { TerminalRenderPerformanceSnapshot } from "./performance.js"; +import type { TerminalRenderCounterSnapshot, TerminalRenderPerformanceSnapshot } from "./performance.js"; +import type { RoutedFramesAttachRequest, RoutedFramesTransportEvent } from "./routed-frames.js"; export type RendererToWorkerMessage = | { type: "renderer-config"; forceCanvasFallback: boolean } @@ -23,7 +24,10 @@ export type RendererToWorkerMessage = | { type: "force-full-redraw"; sessionHandle: string } | { type: "force-row-redraw"; sessionHandle: string; row: number } | { type: "performance-start"; requestId: number } - | { type: "performance-finish"; requestId: number; quietMs: number; timeoutMs: number }; + | { type: "performance-finish"; requestId: number; quietMs: number; timeoutMs: number } + | { type: "performance-counters"; requestId: number } + | { type: "routed-frames-attach"; request: RoutedFramesAttachRequest } + | { type: "routed-frames-detach"; activationId: string }; export type WorkerToRendererMessage = | { type: "renderer-status"; backend: string; textEngine?: string; recovered?: boolean } @@ -49,4 +53,6 @@ export type WorkerToRendererMessage = | { type: "frame-credit"; bytes: number } | { type: "performance-started"; requestId: number } | { type: "performance-result"; requestId: number; snapshot: TerminalRenderPerformanceSnapshot } + | { type: "performance-counters"; requestId: number; snapshot: TerminalRenderCounterSnapshot } + | { type: "routed-frames-event"; event: RoutedFramesTransportEvent } | { type: "renderer-reload-required"; reason: string }; diff --git a/packages/ghosttea-react/src/workspace/Workspace.tsx b/packages/ghosttea-react/src/workspace/Workspace.tsx index 29ff9030..74feddf2 100644 --- a/packages/ghosttea-react/src/workspace/Workspace.tsx +++ b/packages/ghosttea-react/src/workspace/Workspace.tsx @@ -127,6 +127,18 @@ export interface GhostteaPaneClose { remainingSessionIds: readonly string[]; } +export interface GhostteaResizeCommit { + splitId: string; + ratio: number; +} + +export interface GhostteaResizeCommitPolicy { + /** Maximum live-reflow cadence. Defaults to 33 ms (about 30 Hz). */ + liveIntervalMs?: number; + /** Called after the final ratio has been committed at gesture end. */ + onCommit?: (commit: GhostteaResizeCommit) => void; +} + export interface GhostteaWorkspaceProps { platform: GhostteaWorkspacePlatform; storageKey?: string; @@ -165,6 +177,7 @@ export interface GhostteaWorkspaceProps { claimExistingSessions?: boolean; initialCwd?: string; onSessionsChange?: (sessionIds: readonly string[]) => void; + resizeCommitPolicy?: GhostteaResizeCommitPolicy; } interface InitialWorkspace { @@ -300,6 +313,7 @@ interface WorkspacePaneProps { session: SessionSummary; active: boolean; zoomed: boolean; + hiddenByZoom: boolean; workspaceActive: boolean; platform: GhostteaWorkspacePlatform; theme: TerminalTheme; @@ -316,6 +330,7 @@ function WorkspacePane({ session, active, zoomed, + hiddenByZoom, workspaceActive, platform, theme, @@ -375,8 +390,8 @@ function WorkspacePane({ bindings={bindings} active={active} {...(platform.platform ? { platform: platform.platform } : {})} - visible={workspaceActive} - controlsResize + visible={workspaceActive && !hiddenByZoom} + controlsResize={!hiddenByZoom} onActivate={onActivate} readClipboard={platform.readClipboard} {...(active && platform.setCanCopy ? { onCopyAvailabilityChange: platform.setCanCopy } : {})} @@ -403,7 +418,8 @@ interface SplitViewProps { onClosePane: (paneId: string) => void; onBrowseDevice: (deviceId: string) => void; decoratePane?: ((session: SessionSummary, paneId: string) => GhostteaWorkspacePaneDecoration | undefined) | undefined; - onRatio: (splitId: string, ratio: number) => void; + resizeLiveIntervalMs: number; + onRatio: (splitId: string, ratio: number, commit: boolean) => void; } function SplitView({ @@ -419,10 +435,24 @@ function SplitView({ onClosePane, onBrowseDevice, decoratePane, + resizeLiveIntervalMs, onRatio, }: SplitViewProps) { const splitRef = useRef(null); - const dragRef = useRef<{ pointerId: number } | null>(null); + const dragRef = useRef<{ + pointerId: number; + lastSentAt: number; + pendingRatio: number | undefined; + timer: number | undefined; + } | null>(null); + + useEffect( + () => () => { + const timer = dragRef.current?.timer; + if (timer !== undefined) window.clearTimeout(timer); + }, + [], + ); if (node.kind === "pane") { return ( @@ -431,6 +461,7 @@ function SplitView({ session={node.session} active={workspaceActive && node.id === activePaneId} zoomed={node.id === zoomedPaneId} + hiddenByZoom={zoomedPaneId !== null && node.id !== zoomedPaneId} workspaceActive={workspaceActive} platform={platform} theme={theme} @@ -444,19 +475,63 @@ function SplitView({ ); } + const zoomedInFirst = zoomedPaneId !== null && containsPane(node.first, zoomedPaneId); + const zoomedInSecond = zoomedPaneId !== null && containsPane(node.second, zoomedPaneId); const style = node.axis === "horizontal" - ? { gridTemplateColumns: `${node.ratio}fr 1px ${1 - node.ratio}fr` } - : { gridTemplateRows: `${node.ratio}fr 1px ${1 - node.ratio}fr` }; + ? { + gridTemplateColumns: zoomedInFirst + ? "1fr 0 0" + : zoomedInSecond + ? "0 0 1fr" + : `${node.ratio}fr 1px ${1 - node.ratio}fr`, + } + : { + gridTemplateRows: zoomedInFirst + ? "1fr 0 0" + : zoomedInSecond + ? "0 0 1fr" + : `${node.ratio}fr 1px ${1 - node.ratio}fr`, + }; - const updateRatio = (event: ReactPointerEvent): void => { - if (dragRef.current?.pointerId !== event.pointerId || !splitRef.current) return; + const updateRatio = (event: ReactPointerEvent, commit = false): void => { + const drag = dragRef.current; + if (drag?.pointerId !== event.pointerId || !splitRef.current) return; const bounds = splitRef.current.getBoundingClientRect(); const raw = node.axis === "horizontal" ? (event.clientX - bounds.left) / Math.max(1, bounds.width) : (event.clientY - bounds.top) / Math.max(1, bounds.height); - onRatio(node.id, Math.max(0.1, Math.min(0.9, raw))); + const ratio = Math.max(0.1, Math.min(0.9, raw)); + const now = performance.now(); + if (commit) { + if (drag.timer !== undefined) window.clearTimeout(drag.timer); + drag.timer = undefined; + drag.pendingRatio = undefined; + drag.lastSentAt = now; + onRatio(node.id, ratio, true); + return; + } + const interval = Math.max(0, resizeLiveIntervalMs); + if (interval === 0 || now - drag.lastSentAt >= interval) { + drag.lastSentAt = now; + onRatio(node.id, ratio, false); + return; + } + drag.pendingRatio = ratio; + if (drag.timer !== undefined) return; + drag.timer = window.setTimeout( + () => { + const current = dragRef.current; + if (current !== drag || current.pendingRatio === undefined) return; + const pending = current.pendingRatio; + current.pendingRatio = undefined; + current.timer = undefined; + current.lastSentAt = performance.now(); + onRatio(node.id, pending, false); + }, + Math.max(0, interval - (now - drag.lastSentAt)), + ); }; return ( @@ -476,23 +551,32 @@ function SplitView({ onClosePane, onBrowseDevice, decoratePane, + resizeLiveIntervalMs, onRatio, }} />
{ - dragRef.current = { pointerId: event.pointerId }; + dragRef.current = { + pointerId: event.pointerId, + lastSentAt: -Infinity, + pendingRatio: undefined, + timer: undefined, + }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); }} - onPointerMove={updateRatio} + onPointerMove={(event) => updateRatio(event)} onPointerUp={(event) => { + updateRatio(event, true); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); dragRef.current = null; }} onPointerCancel={() => { + const timer = dragRef.current?.timer; + if (timer !== undefined) window.clearTimeout(timer); dragRef.current = null; }} /> @@ -511,6 +595,7 @@ function SplitView({ onClosePane, onBrowseDevice, decoratePane, + resizeLiveIntervalMs, onRatio, }} /> @@ -536,6 +621,7 @@ export function GhostteaWorkspace({ claimExistingSessions = true, initialCwd, onSessionsChange, + resizeCommitPolicy, }: GhostteaWorkspaceProps) { const Sidebar = sidebar; const terminalRuntime = useGhostteaRuntime(); @@ -914,8 +1000,6 @@ export function GhostteaWorkspace({ [addSession, terminalRuntime], ); - const displayedLayout = zoomedPaneId ? leaves(layout).find((candidate) => candidate.id === zoomedPaneId) : layout; - const executeWorkspaceCommand = useCallback( (command: WorkspaceEffect): void => { if (command.type === "new-tab") { @@ -1102,13 +1186,13 @@ export function GhostteaWorkspace({
{error}
- ) : displayedLayout && activePaneId ? ( + ) : layout && activePaneId ? ( + resizeLiveIntervalMs={resizeCommitPolicy?.liveIntervalMs ?? 33} + onRatio={(splitId, ratio, commit) => { setLayout((current) => current ? updateSplit(current, splitId, (split) => ({ ...split, ratio })) : current, - ) - } + ); + if (commit) resizeCommitPolicy?.onCommit?.({ splitId, ratio }); + }} /> ) : null} {operationError ? ( diff --git a/packages/ghosttea-react/src/workspace/index.ts b/packages/ghosttea-react/src/workspace/index.ts index 91ef764c..a770334b 100644 --- a/packages/ghosttea-react/src/workspace/index.ts +++ b/packages/ghosttea-react/src/workspace/index.ts @@ -2,6 +2,8 @@ export { GhostteaWorkspace, type GhostteaPaneClose, type GhostteaPaneRehydration, + type GhostteaResizeCommit, + type GhostteaResizeCommitPolicy, type GhostteaWorkspaceContext, type GhostteaWorkspacePane, type GhostteaWorkspacePaneDecoration, diff --git a/packages/ghosttea/package.json b/packages/ghosttea/package.json index 28ca4cbc..c896fe32 100644 --- a/packages/ghosttea/package.json +++ b/packages/ghosttea/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea", - "version": "0.10.1", + "version": "0.11.0", "description": "Typed browser client for the Ghosttea terminal service.", "license": "MIT", "author": "James Yong", @@ -38,7 +38,7 @@ "provenance": true }, "dependencies": { - "@vibecook/ghosttea-protocol": "0.10.1" + "@vibecook/ghosttea-protocol": "0.11.0" }, "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", diff --git a/packages/ghosttead-darwin-arm64/package.json b/packages/ghosttead-darwin-arm64/package.json index 55c87210..f25ef7d5 100644 --- a/packages/ghosttead-darwin-arm64/package.json +++ b/packages/ghosttead-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttead-darwin-arm64", - "version": "0.10.1", + "version": "0.11.0", "description": "Ghosttea terminal daemon binary for macOS on Apple silicon.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttead-win32-x64/package.json b/packages/ghosttead-win32-x64/package.json index eb98849f..f96edec4 100644 --- a/packages/ghosttead-win32-x64/package.json +++ b/packages/ghosttead-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttead-win32-x64", - "version": "0.10.1", + "version": "0.11.0", "description": "Ghosttea terminal daemon binary for Windows on x64.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttead/package.json b/packages/ghosttead/package.json index e1b0326f..60bc2930 100644 --- a/packages/ghosttead/package.json +++ b/packages/ghosttead/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttead", - "version": "0.10.1", + "version": "0.11.0", "description": "Prebuilt Ghosttea terminal daemon, resolved to the binary for the current platform.", "license": "MIT", "author": "James Yong", @@ -48,8 +48,8 @@ "prepublishOnly": "npm run build" }, "optionalDependencies": { - "@vibecook/ghosttead-darwin-arm64": "0.10.1", - "@vibecook/ghosttead-win32-x64": "0.10.1" + "@vibecook/ghosttead-darwin-arm64": "0.11.0", + "@vibecook/ghosttead-win32-x64": "0.11.0" }, "devDependencies": { "@types/node": "26.1.1", From dc25c4fd837dbc2de1c91086e2b98d4c90fa3b92 Mon Sep 17 00:00:00 2001 From: James Yong Date: Sat, 22 Aug 2026 23:56:35 -0700 Subject: [PATCH 2/3] release: pin 0.11.0 Apple native artifact --- Package.swift | 4 +-- .../apple-native-artifact.lock.json | 26 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Package.swift b/Package.swift index 88ab3be3..47c1994b 100644 --- a/Package.swift +++ b/Package.swift @@ -44,8 +44,8 @@ import PackageDescription let appleNativeURL = - "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-881e968585c0/GhostteaAppleNative.xcframework.zip" -let appleNativeChecksum = "aebe69e03ce2c41cc3e95e4e0998b2203473618a3f2c7b3afa38447a08f61411" + "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-882149c68985/GhostteaAppleNative.xcframework.zip" +let appleNativeChecksum = "9cff2c6a921d1ace47e36cd182b124692cdd8aaa785fab89cac5bc28788cd1ad" // Truffle is consumed from its published repository, pinned to an exact version // rather than a bare revision. diff --git a/apple/GhostteaKit/Compatibility/apple-native-artifact.lock.json b/apple/GhostteaKit/Compatibility/apple-native-artifact.lock.json index a1ef8ad7..cb811148 100644 --- a/apple/GhostteaKit/Compatibility/apple-native-artifact.lock.json +++ b/apple/GhostteaKit/Compatibility/apple-native-artifact.lock.json @@ -3,22 +3,22 @@ "binaryTarget": "GhostteaAppleNative", "bundleName": "GhostteaAppleNative.xcframework", "repository": "https://github.com/vibecook-dev/ghosttea", - "tag": "ghosttea-apple-native-881e968585c0", + "tag": "ghosttea-apple-native-882149c68985", "filename": "GhostteaAppleNative.xcframework.zip", - "url": "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-881e968585c0/GhostteaAppleNative.xcframework.zip", - "checksum": "aebe69e03ce2c41cc3e95e4e0998b2203473618a3f2c7b3afa38447a08f61411", - "size": 45186219, - "contentDigest": "881e968585c0b7fb1577edd53db1c2f27f7a70827c7551e9e6344cad80f1744c", - "sourceDigest": "248f5f92c169224ab35a0a429a7c6a7eb6b54004d490c80bf9e82f21ff969d5c", + "url": "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-882149c68985/GhostteaAppleNative.xcframework.zip", + "checksum": "9cff2c6a921d1ace47e36cd182b124692cdd8aaa785fab89cac5bc28788cd1ad", + "size": 45183838, + "contentDigest": "882149c68985683dac91ba81da13ed6b4af47e946fbd3dee4a27868d3a983804", + "sourceDigest": "6e1a5ffa1ee341065880304771ee0ac1e2a953681691b6e66a0f53a0b9094a40", "entries": 137, "notes": { - "checksum": "The SHA-256 of the archive file, which is exactly what `swift package compute-checksum` reports and what SwiftPM enforces before unpacking a URL binary target. Verified equal on 2026-08-18.", + "checksum": "The SHA-256 of the archive file, which is exactly what `swift package compute-checksum` reports and what SwiftPM enforces before unpacking a URL binary target. Verified equal on 2026-08-22.", "contentDigest": "A digest of paths, modes, and file bytes, independent of how the tree is archived. The archive's own checksum also depends on zlib, so this is the value a rebuild can honestly re-derive on another machine.", "tag": "Content-addressed rather than keyed to a ghosttea release. `.binaryTarget(url:checksum:)` needs a checksum already valid at the commit SwiftPM resolves, but release assets are built after their release commit — so a per-release artifact could never carry a valid checksum at its own tag.", "sourceDigest": "A digest of the sources the artifact was built from: the crates compiled into it, the pins fixing its vendored inputs, and the scripts that compile and compose them. The digests above answer which bytes are published; only this answers whether those bytes contain the source being shipped, because a digest taken from a stale build agrees with a lock written from that same stale build. A mismatch means republish, not merely a field update — the Apple build is not byte-reproducible, measured rather than assumed: recomposing identical sources on one machine and toolchain moved the archive by two bytes. `scripts/ghosttea-apple-native-artifact.mjs` owns the input list and explains what it deliberately omits.", - "verification": "There is no hand-set `published` flag. `check:apple-native-artifact --release` fetches this URL, checks the archive's size and SHA-256 against the fields above, unpacks it, and re-derives the content digest and every slice digest from the bytes that are actually served. It replaced a boolean a human set, which stayed true across a release that changed the artifact's contents. Last confirmed 2026-08-18, together with the complete local-artifact SwiftPM suite and an external consumer linking every public product; the promotion job then re-downloaded and independently verified the served release bytes. These bytes are never replaced in place: every consumer's SwiftPM checksum is pinned to them, so a changed artifact takes a new content digest and a new tag.", - "supersedes": "ghosttea-apple-native-196b4cc0f2c1, the 0.10.0 artifact. The 0.10.1 workspace version bump and ghosttea-core predicate cleanup move the native source digest through Cargo.toml, Cargo.lock, and native/ghosttea/**, so the exact release sources require a new immutable publication rather than a field-only update.", - "provenance": "Built, qualified, attested, and published by ghosttea-apple-native-artifact.yml run https://github.com/vibecook-dev/ghosttea/actions/runs/32163596503 from clean source commit 52f430a6bca18cb43cde136aa576414b0fdb6ae9 (release/0.10.1), promoted through the protected release environment. Attestation: https://github.com/vibecook-dev/ghosttea/attestations/41418636 (subject sha256:aebe69e03ce2c41cc3e95e4e0998b2203473618a3f2c7b3afa38447a08f61411)." + "verification": "There is no hand-set `published` flag. `check:apple-native-artifact --release` fetches this URL, checks the archive's size and SHA-256 against the fields above, unpacks it, and re-derives the content digest and every slice digest from the bytes that are actually served. It replaced a boolean a human set, which stayed true across a release that changed the artifact's contents. Last confirmed 2026-08-22, together with the complete local-artifact SwiftPM suite and an external consumer linking every public product; the promotion job then re-downloaded and independently verified the served release bytes. These bytes are never replaced in place: every consumer's SwiftPM checksum is pinned to them, so a changed artifact takes a new content digest and a new tag.", + "supersedes": "ghosttea-apple-native-881e968585c0, the 0.10.1 artifact. The 0.11.0 workspace version bump and ServiceSessions native additions move the native source digest through Cargo.toml, Cargo.lock, and native/ghosttea/**, so the exact release sources require a new immutable publication rather than a field-only update.", + "provenance": "Built, qualified, attested, and published by ghosttea-apple-native-artifact.yml run https://github.com/vibecook-dev/ghosttea/actions/runs/32623484695 from clean source commit 69fe965c5f53c28e92834868a0386285876d822a (release/0.11.0), promoted through the protected release environment. Attestation: https://github.com/vibecook-dev/ghosttea/attestations/42397543 (subject sha256:9cff2c6a921d1ace47e36cd182b124692cdd8aaa785fab89cac5bc28788cd1ad)." }, "producedBy": { "composer": "scripts/build-ghosttea-apple-native.mjs", @@ -26,8 +26,8 @@ "sourceLocks": ["native/ghostty.lock.json", "native/ssh.lock.json", "native/fonts.lock.json"] }, "slices": { - "ios-arm64-simulator/libghosttea-apple-native.a": "97f036f38fdc8823d6bbbde2bc978a32a12c4c57d0340fa8b1386fe3b9505499", - "ios-arm64/libghosttea-apple-native.a": "9a98ee699cf350c669a14354945171b66287e5cdf86703a952f54db31af22e24", - "macos-arm64/libghosttea-apple-native.a": "e075a4d5057d4c57cae327954e5da778940c43f0a35dd88dc100f86e5a5325cc" + "ios-arm64-simulator/libghosttea-apple-native.a": "6759c92935316ece6d8e1c1e2d180ccd3d48f893c904e4287ad3438cf4c0bb7c", + "ios-arm64/libghosttea-apple-native.a": "ed6dcd4397b006292f29d3a3db38d663c282f3ca8d9aa8f7a60beeaf95719a7d", + "macos-arm64/libghosttea-apple-native.a": "2cb042a37adaa31b4e1485158862215701b88c60ce64ef0d65cd76c2554b8914" } } From e596d9257a13ac767846eda118928bfb86d6a94e Mon Sep 17 00:00:00 2001 From: James Yong Date: Sun, 23 Aug 2026 00:09:44 -0700 Subject: [PATCH 3/3] test: avoid lifecycle grace boundary race --- native/ghosttea/src/service.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/native/ghosttea/src/service.rs b/native/ghosttea/src/service.rs index 4e3d60be..7d53462b 100644 --- a/native/ghosttea/src/service.rs +++ b/native/ghosttea/src/service.rs @@ -4212,10 +4212,13 @@ mod protocol_tests { let mut saw_exited = false; let deadline = Instant::now() + Duration::from_secs(10); while !(saw_removed && saw_exited) && Instant::now() < deadline { - let event = tokio::time::timeout(Duration::from_secs(2), lifecycle.recv()) - .await - .unwrap() - .unwrap(); + let Ok(event) = + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), lifecycle.recv()) + .await + else { + break; + }; + let event = event.unwrap(); match event { SessionLifecycleEvent::Removed { session_id } if session_id == spawned_id => { saw_removed = true; @@ -4226,7 +4229,10 @@ mod protocol_tests { _ => {} } } - assert!(saw_removed && saw_exited); + assert!( + saw_removed && saw_exited, + "missing lifecycle event(s): removed={saw_removed}, exited={saw_exited}" + ); let _ = request( &mut client,