From 5337bcffc2a4643b7d0b688d96b4bbdc55865416 Mon Sep 17 00:00:00 2001 From: James Yong Date: Sun, 23 Aug 2026 20:13:22 -0700 Subject: [PATCH 1/2] release: 0.11.1 --- CHANGELOG.md | 28 + 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-pipeline-lab/README.md | 43 + .../electron.vite.config.ts | 27 + apps/desktop-pipeline-lab/package.json | 37 + apps/desktop-pipeline-lab/src/main/index.ts | 66 + .../desktop-pipeline-lab/src/preload/index.ts | 24 + .../src/renderer/global.d.ts | 17 + .../src/renderer/index.html | 17 + .../src/renderer/src/App.tsx | 411 ++++++ .../src/renderer/src/gpu.ts | 1315 +++++++++++++++++ .../src/renderer/src/host.ts | 101 ++ .../src/renderer/src/main.tsx | 5 + .../src/renderer/src/pipeline.worker.ts | 86 ++ .../src/renderer/src/styles.css | 409 +++++ .../src/shared/messages.ts | 32 + .../src/shared/model.test.ts | 96 ++ apps/desktop-pipeline-lab/src/shared/model.ts | 415 ++++++ apps/desktop-pipeline-lab/tsconfig.json | 11 + apps/desktop/package.json | 12 +- eslint.config.js | 6 +- native/ghosttea/Cargo.toml | 8 +- .../ghosttea/crates/ghosttea-core/Cargo.toml | 4 +- .../crates/ghosttea-truffle/Cargo.toml | 4 +- native/ghosttea/crates/ghosttea-vt/Cargo.toml | 2 +- native/ghosttead/Cargo.toml | 4 +- package-lock.json | 126 +- package.json | 7 +- 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/package.json | 2 +- packages/ghosttea-react/README.md | 17 + packages/ghosttea-react/package.json | 8 +- packages/ghosttea-react/src/index.ts | 2 + .../ghosttea-react/src/routed-control.test.ts | 138 ++ packages/ghosttea-react/src/routed-control.ts | 26 + .../ghosttea-react/src/routed-runtime.test.ts | 418 ++++++ packages/ghosttea-react/src/runtime.ts | 256 +++- packages/ghosttea/package.json | 4 +- packages/ghosttead-darwin-arm64/package.json | 2 +- packages/ghosttead-win32-x64/package.json | 2 +- packages/ghosttead/package.json | 6 +- 51 files changed, 4241 insertions(+), 269 deletions(-) create mode 100644 apps/desktop-pipeline-lab/README.md create mode 100644 apps/desktop-pipeline-lab/electron.vite.config.ts create mode 100644 apps/desktop-pipeline-lab/package.json create mode 100644 apps/desktop-pipeline-lab/src/main/index.ts create mode 100644 apps/desktop-pipeline-lab/src/preload/index.ts create mode 100644 apps/desktop-pipeline-lab/src/renderer/global.d.ts create mode 100644 apps/desktop-pipeline-lab/src/renderer/index.html create mode 100644 apps/desktop-pipeline-lab/src/renderer/src/App.tsx create mode 100644 apps/desktop-pipeline-lab/src/renderer/src/gpu.ts create mode 100644 apps/desktop-pipeline-lab/src/renderer/src/host.ts create mode 100644 apps/desktop-pipeline-lab/src/renderer/src/main.tsx create mode 100644 apps/desktop-pipeline-lab/src/renderer/src/pipeline.worker.ts create mode 100644 apps/desktop-pipeline-lab/src/renderer/src/styles.css create mode 100644 apps/desktop-pipeline-lab/src/shared/messages.ts create mode 100644 apps/desktop-pipeline-lab/src/shared/model.test.ts create mode 100644 apps/desktop-pipeline-lab/src/shared/model.ts create mode 100644 apps/desktop-pipeline-lab/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f48d4b5..197c07aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ All notable changes to Ghosttea are documented here. The Rust and npm packages share one version. +## 0.11.1 - 2026-08-23 + +### Added + +- Routed React hosts can supply `getSession` for the existing frame-driven + metadata cadence. Routed frame commits now coalesce exact-summary reads at + 200 ms, prevent overlapping reads for a session, and reject stale completions + after a newer event, registration, exit, or removal. +- `@vibecook/ghosttea-react` exports `RoutedSessionEvent` and + `applySessionEvent` for full-summary, activity, exit, and removal projection. + Activity and exit use the same CustomEvent ordering as the port transport, + exit events preserve every termination fact, and removal never terminates or + invents exit evidence. +- Routed hosts can opt into strict inbound extension messages with + `onExtensionMessage`. Only unknown tagged objects received after control-leg + authentication reach the callback, together with authenticated cell and + connection-set identity; malformed, known-invalid, wrong-leg, binary, + oversized, and pre-authentication messages retain their protocol close. +- The repository includes a private Electron compositor pipeline lab for + comparing scene/view architectures and exercising renderer scheduling under + controlled workloads. + +### Compatibility + +- All new routed host members are optional. Omitting them preserves 0.11.0 + behavior, and the existing port-pair transport and Ghosttea wire contracts + are unchanged. + ## 0.11.0 - 2026-08-22 ### Added diff --git a/Cargo.lock b/Cargo.lock index 432dad9f..31594eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,7 +648,7 @@ dependencies = [ [[package]] name = "ghosttea" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "async-trait", @@ -670,7 +670,7 @@ dependencies = [ [[package]] name = "ghosttea-apple-ffi" -version = "0.11.0" +version = "0.11.1" dependencies = [ "ghosttea-ffi", "ghosttea-font-fixture-ffi", @@ -678,7 +678,7 @@ dependencies = [ [[package]] name = "ghosttea-config" -version = "0.11.0" +version = "0.11.1" dependencies = [ "libc", "serde", @@ -689,7 +689,7 @@ dependencies = [ [[package]] name = "ghosttea-core" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "ghosttea-text", @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "ghosttea-ffi" -version = "0.11.0" +version = "0.11.1" dependencies = [ "ghosttea-config", "ghosttea-core", @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "ghosttea-font-fixture-ffi" -version = "0.11.0" +version = "0.11.1" dependencies = [ "ghosttea-text", "serde_json", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "ghosttea-text" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "fontdb", @@ -734,7 +734,7 @@ dependencies = [ [[package]] name = "ghosttea-truffle" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "async-trait", @@ -755,14 +755,14 @@ dependencies = [ [[package]] name = "ghosttea-vt" -version = "0.11.0" +version = "0.11.1" dependencies = [ "ghosttea-vt-sys", ] [[package]] name = "ghosttea-vt-sys" -version = "0.11.0" +version = "0.11.1" dependencies = [ "cc", "serde", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ghosttead" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index 687db68d..28efd588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "0.11.0" +version = "0.11.1" 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 346c5a94..32ddabfa 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.11.0", + "bom-ref": "pkg:npm/ghosttea@0.11.1", "name": "Ghosttea iOS", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/ghosttea@0.11.0", + "purl": "pkg:npm/ghosttea@0.11.1", "properties": [ { "name": "ghosttea:scope", @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "4f90810bca895bbdf2f991d717f1f8b7937734684a0f67a0465c60f99c428030" + "value": "477713035f0a7e626f656342601697e0492ad36ce3e868ec46a74efa42e39065" }, { "name": "ghosttea:rust-target", @@ -160,9 +160,9 @@ "components": [ { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.11.1", "name": "ghosttea-apple-ffi", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -170,7 +170,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-apple-ffi@0.11.0" + "purl": "pkg:cargo/ghosttea-apple-ffi@0.11.1" }, { "type": "library", @@ -1314,9 +1314,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-config@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-config@0.11.1", "name": "ghosttea-config", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1324,7 +1324,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-config@0.11.0", + "purl": "pkg:cargo/ghosttea-config@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1338,9 +1338,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-core@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-core@0.11.1", "name": "ghosttea-core", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1348,7 +1348,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-core@0.11.0", + "purl": "pkg:cargo/ghosttea-core@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1362,9 +1362,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-ffi@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-ffi@0.11.1", "name": "ghosttea-ffi", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1372,7 +1372,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-ffi@0.11.0", + "purl": "pkg:cargo/ghosttea-ffi@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1386,9 +1386,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "name": "ghosttea-font-fixture-ffi", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1396,7 +1396,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1410,9 +1410,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-text@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-text@0.11.1", "name": "ghosttea-text", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1420,7 +1420,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-text@0.11.0", + "purl": "pkg:cargo/ghosttea-text@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1434,9 +1434,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.11.1", "name": "ghosttea-vt-sys", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1444,7 +1444,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "purl": "pkg:cargo/ghosttea-vt-sys@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1458,9 +1458,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-vt@0.11.1", "name": "ghosttea-vt", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1468,7 +1468,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt@0.11.0", + "purl": "pkg:cargo/ghosttea-vt@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -3293,9 +3293,9 @@ ], "dependencies": [ { - "ref": "pkg:npm/ghosttea@0.11.0", + "ref": "pkg:npm/ghosttea@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-apple-ffi@0.11.0", + "pkg:cargo/ghosttea-apple-ffi@0.11.1", "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.11.0", + "ref": "pkg:cargo/ghosttea-apple-ffi@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-ffi@0.11.0", - "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "pkg:cargo/ghosttea-ffi@0.11.1", + "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "pkg:github/ghostty-org/ghostty@f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" ] }, @@ -3542,7 +3542,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-config@0.11.0", + "ref": "pkg:cargo/ghosttea-config@0.11.1", "dependsOn": [ "pkg:cargo/libc@0.2.186", "pkg:cargo/serde@1.0.228", @@ -3552,33 +3552,33 @@ ] }, { - "ref": "pkg:cargo/ghosttea-core@0.11.0", + "ref": "pkg:cargo/ghosttea-core@0.11.1", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", - "pkg:cargo/ghosttea-text@0.11.0", - "pkg:cargo/ghosttea-vt@0.11.0", + "pkg:cargo/ghosttea-text@0.11.1", + "pkg:cargo/ghosttea-vt@0.11.1", "pkg:cargo/serde@1.0.228", "pkg:cargo/smallvec@1.15.2" ] }, { - "ref": "pkg:cargo/ghosttea-ffi@0.11.0", + "ref": "pkg:cargo/ghosttea-ffi@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-config@0.11.0", - "pkg:cargo/ghosttea-core@0.11.0", - "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-config@0.11.1", + "pkg:cargo/ghosttea-core@0.11.1", + "pkg:cargo/ghosttea-text@0.11.1", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-text@0.11.1", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-text@0.11.0", + "ref": "pkg:cargo/ghosttea-text@0.11.1", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", "pkg:cargo/fontdb@0.23.0", @@ -3592,7 +3592,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "ref": "pkg:cargo/ghosttea-vt-sys@0.11.1", "dependsOn": [ "pkg:cargo/cc@1.2.67", "pkg:cargo/serde@1.0.228", @@ -3603,9 +3603,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt@0.11.0", + "ref": "pkg:cargo/ghosttea-vt@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-vt-sys@0.11.0" + "pkg:cargo/ghosttea-vt-sys@0.11.1" ] }, { diff --git a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt index 106f2550..cf47d0c8 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: 31c78aab13813cea43214d59c9bce954c5fe021f14a5b9c70699728f097db861 +CycloneDX SHA-256: ea3a13626dc4b4555bcd64f7df2623d602688fa9088413bd57bc6fa7ae34e176 Components: 108 Unique license documents: 97 COMPONENT INDEX =============== -ghosttea-apple-ffi 0.11.0 +ghosttea-apple-ffi 0.11.1 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.11.0 +ghosttea-config 0.11.1 License: MIT Documents: LICENSE-081 -ghosttea-core 0.11.0 +ghosttea-core 0.11.1 License: MIT Documents: LICENSE-081 -ghosttea-ffi 0.11.0 +ghosttea-ffi 0.11.1 License: MIT Documents: LICENSE-081 -ghosttea-font-fixture-ffi 0.11.0 +ghosttea-font-fixture-ffi 0.11.1 License: MIT Documents: LICENSE-081 -ghosttea-text 0.11.0 +ghosttea-text 0.11.1 License: MIT Documents: LICENSE-081 -ghosttea-vt-sys 0.11.0 +ghosttea-vt-sys 0.11.1 License: MIT Documents: LICENSE-081 -ghosttea-vt 0.11.0 +ghosttea-vt 0.11.1 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.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 +Used by: Truffle Swift 2cf5732bbeda8e3a2b9c3e444f471f303b6b8040, ghosttea-apple-ffi 0.11.1, ghosttea-config 0.11.1, ghosttea-core 0.11.1, ghosttea-ffi 0.11.1, ghosttea-font-fixture-ffi 0.11.1, ghosttea-text 0.11.1, ghosttea-vt 0.11.1, ghosttea-vt-sys 0.11.1 -------------------------------------------------------------------------------- MIT License diff --git a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json index 916108d4..04754d80 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": "31c78aab13813cea43214d59c9bce954c5fe021f14a5b9c70699728f097db861" + "sha256": "ea3a13626dc4b4555bcd64f7df2623d602688fa9088413bd57bc6fa7ae34e176" }, "bundledBom": { "path": "apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json", - "sha256": "31c78aab13813cea43214d59c9bce954c5fe021f14a5b9c70699728f097db861" + "sha256": "ea3a13626dc4b4555bcd64f7df2623d602688fa9088413bd57bc6fa7ae34e176" }, "notices": { "path": "apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt", - "sha256": "069c14d41f3c5c8eacc63197adda1a0c977aad7754f577d5396408da295dfa69", + "sha256": "b17de76ee4ecf8aa1a03fe6af4bb81d4f40575497adbff0161a4f8ff1c63d852", "componentCount": 108, "licenseDocumentCount": 97 } diff --git a/apple/GhostteaKit/Compatibility/ios-release.cdx.json b/apple/GhostteaKit/Compatibility/ios-release.cdx.json index 346c5a94..32ddabfa 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.11.0", + "bom-ref": "pkg:npm/ghosttea@0.11.1", "name": "Ghosttea iOS", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/ghosttea@0.11.0", + "purl": "pkg:npm/ghosttea@0.11.1", "properties": [ { "name": "ghosttea:scope", @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "4f90810bca895bbdf2f991d717f1f8b7937734684a0f67a0465c60f99c428030" + "value": "477713035f0a7e626f656342601697e0492ad36ce3e868ec46a74efa42e39065" }, { "name": "ghosttea:rust-target", @@ -160,9 +160,9 @@ "components": [ { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-apple-ffi@0.11.1", "name": "ghosttea-apple-ffi", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -170,7 +170,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-apple-ffi@0.11.0" + "purl": "pkg:cargo/ghosttea-apple-ffi@0.11.1" }, { "type": "library", @@ -1314,9 +1314,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-config@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-config@0.11.1", "name": "ghosttea-config", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1324,7 +1324,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-config@0.11.0", + "purl": "pkg:cargo/ghosttea-config@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1338,9 +1338,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-core@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-core@0.11.1", "name": "ghosttea-core", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1348,7 +1348,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-core@0.11.0", + "purl": "pkg:cargo/ghosttea-core@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1362,9 +1362,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-ffi@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-ffi@0.11.1", "name": "ghosttea-ffi", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1372,7 +1372,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-ffi@0.11.0", + "purl": "pkg:cargo/ghosttea-ffi@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1386,9 +1386,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "name": "ghosttea-font-fixture-ffi", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1396,7 +1396,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "purl": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1410,9 +1410,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-text@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-text@0.11.1", "name": "ghosttea-text", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1420,7 +1420,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-text@0.11.0", + "purl": "pkg:cargo/ghosttea-text@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1434,9 +1434,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-vt-sys@0.11.1", "name": "ghosttea-vt-sys", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1444,7 +1444,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "purl": "pkg:cargo/ghosttea-vt-sys@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -1458,9 +1458,9 @@ }, { "type": "library", - "bom-ref": "pkg:cargo/ghosttea-vt@0.11.0", + "bom-ref": "pkg:cargo/ghosttea-vt@0.11.1", "name": "ghosttea-vt", - "version": "0.11.0", + "version": "0.11.1", "licenses": [ { "license": { @@ -1468,7 +1468,7 @@ } } ], - "purl": "pkg:cargo/ghosttea-vt@0.11.0", + "purl": "pkg:cargo/ghosttea-vt@0.11.1", "properties": [ { "name": "ghosttea:cargo-source", @@ -3293,9 +3293,9 @@ ], "dependencies": [ { - "ref": "pkg:npm/ghosttea@0.11.0", + "ref": "pkg:npm/ghosttea@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-apple-ffi@0.11.0", + "pkg:cargo/ghosttea-apple-ffi@0.11.1", "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.11.0", + "ref": "pkg:cargo/ghosttea-apple-ffi@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-ffi@0.11.0", - "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "pkg:cargo/ghosttea-ffi@0.11.1", + "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "pkg:github/ghostty-org/ghostty@f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" ] }, @@ -3542,7 +3542,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-config@0.11.0", + "ref": "pkg:cargo/ghosttea-config@0.11.1", "dependsOn": [ "pkg:cargo/libc@0.2.186", "pkg:cargo/serde@1.0.228", @@ -3552,33 +3552,33 @@ ] }, { - "ref": "pkg:cargo/ghosttea-core@0.11.0", + "ref": "pkg:cargo/ghosttea-core@0.11.1", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", - "pkg:cargo/ghosttea-text@0.11.0", - "pkg:cargo/ghosttea-vt@0.11.0", + "pkg:cargo/ghosttea-text@0.11.1", + "pkg:cargo/ghosttea-vt@0.11.1", "pkg:cargo/serde@1.0.228", "pkg:cargo/smallvec@1.15.2" ] }, { - "ref": "pkg:cargo/ghosttea-ffi@0.11.0", + "ref": "pkg:cargo/ghosttea-ffi@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-config@0.11.0", - "pkg:cargo/ghosttea-core@0.11.0", - "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-config@0.11.1", + "pkg:cargo/ghosttea-core@0.11.1", + "pkg:cargo/ghosttea-text@0.11.1", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-text@0.11.1", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-text@0.11.0", + "ref": "pkg:cargo/ghosttea-text@0.11.1", "dependsOn": [ "pkg:cargo/anyhow@1.0.103", "pkg:cargo/fontdb@0.23.0", @@ -3592,7 +3592,7 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "ref": "pkg:cargo/ghosttea-vt-sys@0.11.1", "dependsOn": [ "pkg:cargo/cc@1.2.67", "pkg:cargo/serde@1.0.228", @@ -3603,9 +3603,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt@0.11.0", + "ref": "pkg:cargo/ghosttea-vt@0.11.1", "dependsOn": [ - "pkg:cargo/ghosttea-vt-sys@0.11.0" + "pkg:cargo/ghosttea-vt-sys@0.11.1" ] }, { diff --git a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json index 1e79badd..cb38468f 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.11.0", + "ref": "pkg:cargo/ghosttea-apple-ffi@0.11.1", "name": "ghosttea-apple-ffi", - "version": "0.11.0", + "version": "0.11.1", "dependencies": [ - "pkg:cargo/ghosttea-ffi@0.11.0", - "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0" + "pkg:cargo/ghosttea-ffi@0.11.1", + "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1" ] }, - "cargoLockSha256": "4f90810bca895bbdf2f991d717f1f8b7937734684a0f67a0465c60f99c428030", + "cargoLockSha256": "477713035f0a7e626f656342601697e0492ad36ce3e868ec46a74efa42e39065", "components": [ { "ref": "pkg:cargo/anyhow@1.0.103", @@ -358,9 +358,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-config@0.11.0", + "ref": "pkg:cargo/ghosttea-config@0.11.1", "name": "ghosttea-config", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ @@ -372,47 +372,47 @@ ] }, { - "ref": "pkg:cargo/ghosttea-core@0.11.0", + "ref": "pkg:cargo/ghosttea-core@0.11.1", "name": "ghosttea-core", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ "pkg:cargo/anyhow@1.0.103", - "pkg:cargo/ghosttea-text@0.11.0", - "pkg:cargo/ghosttea-vt@0.11.0", + "pkg:cargo/ghosttea-text@0.11.1", + "pkg:cargo/ghosttea-vt@0.11.1", "pkg:cargo/serde@1.0.228", "pkg:cargo/smallvec@1.15.2" ] }, { - "ref": "pkg:cargo/ghosttea-ffi@0.11.0", + "ref": "pkg:cargo/ghosttea-ffi@0.11.1", "name": "ghosttea-ffi", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ - "pkg:cargo/ghosttea-config@0.11.0", - "pkg:cargo/ghosttea-core@0.11.0", - "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-config@0.11.1", + "pkg:cargo/ghosttea-core@0.11.1", + "pkg:cargo/ghosttea-text@0.11.1", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.0", + "ref": "pkg:cargo/ghosttea-font-fixture-ffi@0.11.1", "name": "ghosttea-font-fixture-ffi", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ - "pkg:cargo/ghosttea-text@0.11.0", + "pkg:cargo/ghosttea-text@0.11.1", "pkg:cargo/serde_json@1.0.150" ] }, { - "ref": "pkg:cargo/ghosttea-text@0.11.0", + "ref": "pkg:cargo/ghosttea-text@0.11.1", "name": "ghosttea-text", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ @@ -428,9 +428,9 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt-sys@0.11.0", + "ref": "pkg:cargo/ghosttea-vt-sys@0.11.1", "name": "ghosttea-vt-sys", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ @@ -443,13 +443,13 @@ ] }, { - "ref": "pkg:cargo/ghosttea-vt@0.11.0", + "ref": "pkg:cargo/ghosttea-vt@0.11.1", "name": "ghosttea-vt", - "version": "0.11.0", + "version": "0.11.1", "source": "workspace", "license": "MIT", "dependencies": [ - "pkg:cargo/ghosttea-vt-sys@0.11.0" + "pkg:cargo/ghosttea-vt-sys@0.11.1" ] }, { diff --git a/apps/desktop-experiment/package.json b/apps/desktop-experiment/package.json index ef8f649e..1adbe3ba 100644 --- a/apps/desktop-experiment/package.json +++ b/apps/desktop-experiment/package.json @@ -1,6 +1,6 @@ { "name": "ghosttea-desktop-experiment", - "version": "0.11.0", + "version": "0.11.1", "description": "An isolated Ghosttea desktop experimentation application.", "author": "James Yong", "license": "MIT", @@ -23,11 +23,11 @@ "test": "vitest run" }, "dependencies": { - "@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", + "@vibecook/ghosttea": "0.11.1", + "@vibecook/ghosttea-electron": "0.11.1", + "@vibecook/ghosttea-frame": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1", + "@vibecook/ghosttea-react": "0.11.1", "react": "19.2.7", "react-dom": "19.2.7" }, diff --git a/apps/desktop-pipeline-lab/README.md b/apps/desktop-pipeline-lab/README.md new file mode 100644 index 00000000..561f447d --- /dev/null +++ b/apps/desktop-pipeline-lab/README.md @@ -0,0 +1,43 @@ +# Ghosttea Pipeline Lab + +An isolated Electron app that races compositor topologies for terminal panes. It does **not** speak to `ghosttead`. The grid is synthetic so a difference in the log is a difference in the Electron/WebGPU end, not in VT or shaping. + +```bash +npm install +npm run dev:pipeline-lab +``` + +## Why this exists + +The production path already decodes TRF1 once per session. What it still duplicates is the thing after that: scene passes, swapchains, and Chromium canvas layers. This lab holds the paint cost constant and changes only that topology. + +## Candidates + +| Gate | Topology | Hypothesis | +| -------- | ------------------------------------------------------------ | ----------------------------------------------------- | +| PER-VIEW | One scene texture and one WebGPU canvas per view | Control. Today’s product. Costs scale with views. | +| SHARED | One scene per session, blit/letterbox to N canvases | The product bet for deck + stage + ICE + thumbnails. | +| WINDOW | One stage canvas; worker composites every view rect | Wins if N swapchains / compositor layers are the tax. | +| BITMAP | Authority stays WebGPU; mirrors take `ImageBitmap` snapshots | Viable only for cold or tiny thumbnails. | + +Toggles that matter: + +- **Scene size** — `view` / `authority` / `grid-native`. Shared + grid-native is the “one scene, N views” design. +- **Effects** — off, on the scene once, or on every view (the remaining full-pane sample). +- **GPUDevice per view** — negative control. Only legal on PER-VIEW. + +## Recipes + +1. **Mirror tax** — 1 session × 8 views, flood. SHARED should drop `scene` from 8 to 1. If `present` stays 8 and CPU barely moves, the swapchain is the leftover cost. +2. **Deck of strangers** — 8 sessions × 1 view. SHARED cannot help. WINDOW should drop `present` to 1. +3. **Stage + tiles** — the product shape, sparse typing. +4. **Present tax** — 16 thumbnails, `repaint` (present every vsync, no content change). +5. **Effect scope** — CRT on the scene vs per view. +6. **Device tax** — should lose. + +Arm records 2.5 s after a 700 ms warmup. Compare `scene`, `present`, `submit`, render CPU p95, upload, bitmap copies, RSS. Do not crown a winner from one run. + +## What this will not tell you + +- Typing latency through the real TRF1 / native text engine path (`bench/render` already owns that). +- Whether CSS rounded corners on a DOM canvas are worth a second swapchain. WINDOW gives that up on purpose. diff --git a/apps/desktop-pipeline-lab/electron.vite.config.ts b/apps/desktop-pipeline-lab/electron.vite.config.ts new file mode 100644 index 00000000..0d672da3 --- /dev/null +++ b/apps/desktop-pipeline-lab/electron.vite.config.ts @@ -0,0 +1,27 @@ +import { resolve } from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: resolve("src/main/index.ts"), + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: resolve("src/preload/index.ts"), + output: { format: "cjs", entryFileNames: "[name].cjs" }, + }, + }, + }, + renderer: { + root: resolve("src/renderer"), + plugins: [react()], + }, +}); diff --git a/apps/desktop-pipeline-lab/package.json b/apps/desktop-pipeline-lab/package.json new file mode 100644 index 00000000..11062f17 --- /dev/null +++ b/apps/desktop-pipeline-lab/package.json @@ -0,0 +1,37 @@ +{ + "name": "ghosttea-desktop-pipeline-lab", + "version": "0.11.1", + "description": "Electron compositor lab: race terminal scene/view architectures.", + "author": "James Yong", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/vibecook-dev/ghosttea.git", + "directory": "apps/desktop-pipeline-lab" + }, + "private": true, + "type": "module", + "main": "./out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "check": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/node": "26.1.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.2.0", + "@webgpu/types": "^0.1.71", + "electron": "43.1.1", + "electron-vite": "5.0.0", + "typescript": "7.0.2", + "vite": "7.3.6", + "vitest": "4.1.10" + } +} diff --git a/apps/desktop-pipeline-lab/src/main/index.ts b/apps/desktop-pipeline-lab/src/main/index.ts new file mode 100644 index 00000000..041c8198 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/main/index.ts @@ -0,0 +1,66 @@ +import { join } from "node:path"; +import { app, BrowserWindow, ipcMain, nativeTheme } from "electron"; + +app.setName("Ghosttea Pipeline Lab"); +nativeTheme.themeSource = "dark"; +if (process.platform === "darwin") app.setActivationPolicy("regular"); + +const ownsProfile = app.requestSingleInstanceLock(); +if (!ownsProfile) app.quit(); + +ipcMain.handle("lab-process-sample", () => { + const samples = app.getAppMetrics().map((metric) => ({ + pid: metric.pid, + type: metric.type, + ...(metric.name ? { name: metric.name } : {}), + cpuPercent: metric.cpu.percentCPUUsage, + workingSetBytes: metric.memory.workingSetSize * 1024, + })); + const renderer = samples.find((sample) => sample.type === "Tab" || sample.type === "GPU"); + const gpu = samples.find((sample) => sample.type === "GPU"); + const cpuPercent = samples.reduce((sum, sample) => sum + sample.cpuPercent, 0); + const workingSetBytes = samples.reduce((sum, sample) => sum + sample.workingSetBytes, 0); + return { + atMs: Date.now(), + cpuPercent, + workingSetBytes, + rendererWorkingSetBytes: renderer?.workingSetBytes ?? 0, + gpuWorkingSetBytes: gpu?.workingSetBytes ?? 0, + processes: samples.length, + }; +}); + +async function createWindow(): Promise { + const window = new BrowserWindow({ + width: 1480, + height: 940, + minWidth: 960, + minHeight: 640, + show: false, + title: "Ghosttea Pipeline Lab", + backgroundColor: "#10110e", + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + ...(process.platform === "darwin" ? { trafficLightPosition: { x: 16, y: 18 } } : {}), + acceptFirstMouse: true, + autoHideMenuBar: process.platform !== "darwin", + webPreferences: { + preload: join(__dirname, "../preload/index.cjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + spellcheck: false, + backgroundThrottling: false, + }, + }); + + window.once("ready-to-show", () => window.show()); + + if (process.env.ELECTRON_RENDERER_URL) { + await window.loadURL(process.env.ELECTRON_RENDERER_URL); + } else { + await window.loadFile(join(__dirname, "../renderer/index.html")); + } +} + +app.whenReady().then(() => void createWindow()); +app.on("window-all-closed", () => app.quit()); diff --git a/apps/desktop-pipeline-lab/src/preload/index.ts b/apps/desktop-pipeline-lab/src/preload/index.ts new file mode 100644 index 00000000..0d1f8d0f --- /dev/null +++ b/apps/desktop-pipeline-lab/src/preload/index.ts @@ -0,0 +1,24 @@ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("pipelineLab", { + platform: process.platform, + sampleProcess: () => ipcRenderer.invoke("lab-process-sample") as Promise, +}); + +interface ProcessSample { + atMs: number; + cpuPercent: number; + workingSetBytes: number; + rendererWorkingSetBytes: number; + gpuWorkingSetBytes: number; + processes: number; +} + +declare global { + interface Window { + pipelineLab: { + platform: string; + sampleProcess: () => Promise; + }; + } +} diff --git a/apps/desktop-pipeline-lab/src/renderer/global.d.ts b/apps/desktop-pipeline-lab/src/renderer/global.d.ts new file mode 100644 index 00000000..031d48af --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/global.d.ts @@ -0,0 +1,17 @@ +export {}; + +declare global { + interface Window { + pipelineLab: { + platform: string; + sampleProcess: () => Promise<{ + atMs: number; + cpuPercent: number; + workingSetBytes: number; + rendererWorkingSetBytes: number; + gpuWorkingSetBytes: number; + processes: number; + }>; + }; + } +} diff --git a/apps/desktop-pipeline-lab/src/renderer/index.html b/apps/desktop-pipeline-lab/src/renderer/index.html new file mode 100644 index 00000000..c9b8abe9 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/index.html @@ -0,0 +1,17 @@ + + + + + + + + Ghosttea Pipeline Lab + + +
+ + + diff --git a/apps/desktop-pipeline-lab/src/renderer/src/App.tsx b/apps/desktop-pipeline-lab/src/renderer/src/App.tsx new file mode 100644 index 00000000..ac22c634 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/src/App.tsx @@ -0,0 +1,411 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import type { SlotLayout } from "../../shared/messages"; +import { + ARCHITECTURE_SPECS, + DEFAULT_LAB_CONFIG, + RECIPES, + WORKLOADS, + clampConfig, + expandViews, + formatBytes, + formatMs, + mean, + percentile, + sessionHue, + usesDomCanvases, + type LabConfig, + type LabRun, + type LayoutId, + type WorkloadId, +} from "../../shared/model"; +import { PipelineHost } from "./host"; + +const host = new PipelineHost(); + +const WARMUP_MS = 700; +const MEASURE_MS = 2500; + +function classNames(...names: Array): string { + return names.filter(Boolean).join(" "); +} + +export function App() { + const [config, setConfig] = useState(DEFAULT_LAB_CONFIG); + const [adapter, setAdapter] = useState("requesting adapter"); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [runs, setRuns] = useState([]); + const [mountKey, setMountKey] = useState(0); + const stageRef = useRef(null); + const stageCanvasRef = useRef(null); + const viewCanvases = useRef(new Map()); + const applied = useRef(0); + + const views = useMemo( + () => expandViews(config.sessions, config.viewsPerSession), + [config.sessions, config.viewsPerSession], + ); + const spec = ARCHITECTURE_SPECS.find((candidate) => candidate.id === config.architecture) ?? ARCHITECTURE_SPECS[0]!; + + const patch = useCallback((partial: Partial) => { + setConfig((current) => clampConfig({ ...current, ...partial })); + setMountKey((value) => value + 1); + }, []); + + useEffect(() => { + host.onError = (message) => setError(message); + return () => { + host.onError = undefined; + }; + }, []); + + const publishLayout = useCallback(() => { + const stage = stageRef.current; + if (!stage) return; + const origin = stageCanvasRef.current ?? stage; + const stageBox = origin.getBoundingClientRect(); + const dpr = window.devicePixelRatio; + const slots: SlotLayout[] = []; + for (const node of stage.querySelectorAll("[data-view-id]")) { + const box = node.getBoundingClientRect(); + const viewId = node.dataset.viewId; + if (!viewId) continue; + slots.push({ + viewId, + x: box.left - stageBox.left, + y: box.top - stageBox.top, + width: box.width, + height: box.height, + dpr, + }); + } + host.layout(slots, stageBox.width, stageBox.height, dpr); + }, []); + + useEffect(() => { + let cancelled = false; + const generation = ++applied.current; + const boot = async (): Promise => { + setError(null); + const name = await host.configure(config); + if (cancelled || generation !== applied.current) return; + setAdapter(name); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (cancelled || generation !== applied.current) return; + const dpr = window.devicePixelRatio; + if (!usesDomCanvases(config.architecture)) { + const canvas = stageCanvasRef.current; + const stage = stageRef.current; + if (!canvas || !stage) return; + const box = stage.getBoundingClientRect(); + host.mountStage(canvas.transferControlToOffscreen(), box.width, box.height, dpr); + publishLayout(); + return; + } + const canvases = new Map(); + const sizes = new Map(); + for (const view of views) { + const node = viewCanvases.current.get(view.viewId); + if (!node) continue; + const box = node.getBoundingClientRect(); + canvases.set(view.viewId, node.transferControlToOffscreen()); + sizes.set(view.viewId, { width: Math.max(1, box.width), height: Math.max(1, box.height), dpr }); + } + host.mountViews(config, canvases, sizes); + publishLayout(); + }; + void boot().catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause))); + return () => { + cancelled = true; + }; + }, [config, mountKey, publishLayout, views]); + + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const observer = new ResizeObserver(() => { + publishLayout(); + if (!usesDomCanvases(config.architecture)) return; + const dpr = window.devicePixelRatio; + for (const [viewId, canvas] of viewCanvases.current) { + const box = canvas.getBoundingClientRect(); + host.resizeView(viewId, box.width, box.height, dpr); + } + }); + observer.observe(stage); + return () => observer.disconnect(); + }, [config.architecture, publishLayout, mountKey, config.layout]); + + const arm = async (): Promise => { + if (busy) return; + setBusy(true); + setError(null); + const processSamples: Array<{ cpuPercent: number; workingSetBytes: number }> = []; + const timer = window.setInterval(() => { + void window.pipelineLab.sampleProcess().then((sample) => { + processSamples.push({ cpuPercent: sample.cpuPercent, workingSetBytes: sample.workingSetBytes }); + }); + }, 200); + try { + await new Promise((resolve) => window.setTimeout(resolve, WARMUP_MS)); + const measured = await host.measure(MEASURE_MS); + const cpu = mean(processSamples.map((sample) => sample.cpuPercent)) ?? 0; + const rss = mean(processSamples.map((sample) => sample.workingSetBytes)) ?? 0; + setRuns((current) => + [ + { + id: `${Date.now().toString(36)}-${current.length}`, + at: Date.now(), + config, + durationMs: measured.durationMs, + counters: measured.counters, + process: { cpuPercent: cpu, workingSetBytes: rss, samples: processSamples.length }, + }, + ...current, + ].slice(0, 12), + ); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + window.clearInterval(timer); + setBusy(false); + } + }; + + return ( +
+
+
+
+

+ Same synthetic grid. Different GPU topologies. The counters below are the argument, not the screenshots. +

+
+ {adapter} + + {views.length} views · {config.sessions} session{config.sessions === 1 ? "" : "s"} + +
+
+ + + +
+

{spec.title}

+

{spec.claim}

+
+
+
Wins when
+
{spec.winsWhen}
+
+
+
Loses when
+
{spec.losesWhen}
+
+
+
+ {RECIPES.map((recipe) => ( + + ))} +
+
event.preventDefault()}> + + + + + + + + +
+ + {error ?

{error}

: null} +
+ +
+ {config.architecture === "window-composite" ? ( + + ) : null} + {views.map((view) => ( +
+ {usesDomCanvases(config.architecture) ? ( + { + if (node) viewCanvases.current.set(view.viewId, node); + else viewCanvases.current.delete(view.viewId); + }} + /> + ) : ( +
+ )} + + {view.sessionId} {view.role === "authority" ? "AUTH" : "MIRROR"} + +
+ ))} +
+ +
+ + + + + + + + + + + + + + + + + + + {runs.length === 0 ? ( + + + + ) : ( + runs.map((run) => ( + + + + + + + + + + + + + + + )) + )} + +
gatetopologyloadsceneblitpresentsubmitcpu p95uploadbitmaprssproc cpu
Arm a run. Warmup is discarded. Compare rows, not feelings.
{run.config.architecture} + {run.config.sessions}×{run.config.viewsPerSession} {run.config.layout} + {run.config.workload}{run.counters.scenePasses}{run.counters.blitPasses}{run.counters.swapchainAcquires}{run.counters.queueSubmits}{formatMs(percentile(run.counters.renderCpuMs, 95))}{formatBytes(run.counters.uploadBytes)}{run.counters.bitmapTransfers}{formatBytes(run.process.workingSetBytes)}{run.process.cpuPercent.toFixed(1)}%
+
+
+ ); +} diff --git a/apps/desktop-pipeline-lab/src/renderer/src/gpu.ts b/apps/desktop-pipeline-lab/src/renderer/src/gpu.ts new file mode 100644 index 00000000..2bf3c2fb --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/src/gpu.ts @@ -0,0 +1,1315 @@ +/// + +import type { SlotLayout, ViewMountSpec } from "../../shared/messages"; +import { + clampConfig, + emptyWorkerCounters, + fitGrid, + fitRect, + gridNativeSize, + type FitMode, + type LabConfig, + type WorkerCounters, +} from "../../shared/model"; + +const ATLAS_COLS = 16; +const ATLAS_ROWS = 8; +const GLYPH_W = 8; +const GLYPH_H = 16; +const ATLAS_W = ATLAS_COLS * GLYPH_W; +const ATLAS_H = ATLAS_ROWS * GLYPH_H; +const INSTANCE_FLOATS = 12; +const UNIFORM_BYTES = 64; + +const CELL_SHADER = /* wgsl */ ` +struct Uniforms { + viewport: vec2f, + cell: vec2f, + origin: vec2f, + cursor: vec2f, + cursor_on: f32, + time: f32, +} + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var atlas: texture_2d; +@group(0) @binding(2) var atlas_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) fg: vec4f, + @location(2) bg: vec4f, +} + +@vertex fn vertex_main( + @builtin(vertex_index) vertex_index: u32, + @location(0) cell: vec2f, + @location(1) uv_bounds: vec4f, + @location(2) fg: vec3f, + @location(3) bg: vec3f, +) -> VertexOutput { + let corners = array( + vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), + vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0), + ); + let corner = corners[vertex_index]; + let pixel = uniforms.origin + (cell + corner) * uniforms.cell; + let clip = vec2f(pixel.x / uniforms.viewport.x * 2.0 - 1.0, 1.0 - pixel.y / uniforms.viewport.y * 2.0); + var output: VertexOutput; + output.position = vec4f(clip, 0.0, 1.0); + output.uv = mix(uv_bounds.xy, uv_bounds.zw, corner); + output.fg = vec4f(fg, 1.0); + output.bg = vec4f(bg, 1.0); + return output; +} + +@fragment fn fragment_main(input: VertexOutput) -> @location(0) vec4f { + let coverage = textureSample(atlas, atlas_sampler, input.uv).r; + let color = mix(input.bg, input.fg, coverage); + return vec4f(color.rgb * color.a, color.a); +} +`; + +const FILL_SHADER = /* wgsl */ ` +struct Fill { + color: vec4f, + rect: vec4f, + viewport: vec2f, + _pad: vec2f, +} + +@group(0) @binding(0) var fill: Fill; + +@vertex fn vertex_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4f { + let corners = array( + vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), + vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0), + ); + let corner = corners[vertex_index]; + let pixel = fill.rect.xy + corner * fill.rect.zw; + return vec4f(pixel.x / fill.viewport.x * 2.0 - 1.0, 1.0 - pixel.y / fill.viewport.y * 2.0, 0.0, 1.0); +} + +@fragment fn fragment_main() -> @location(0) vec4f { + return vec4f(fill.color.rgb * fill.color.a, fill.color.a); +} +`; + +const BLIT_SHADER = /* wgsl */ ` +struct Blit { + rect: vec4f, + viewport: vec2f, + time: f32, + effect: f32, +} + +@group(0) @binding(0) var blit: Blit; +@group(0) @binding(1) var source: texture_2d; +@group(0) @binding(2) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex fn vertex_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let corners = array( + vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), + vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0), + ); + let corner = corners[vertex_index]; + let pixel = blit.rect.xy + corner * blit.rect.zw; + var output: VertexOutput; + output.position = vec4f(pixel.x / blit.viewport.x * 2.0 - 1.0, 1.0 - pixel.y / blit.viewport.y * 2.0, 0.0, 1.0); + output.uv = corner; + return output; +} + +@fragment fn fragment_main(input: VertexOutput) -> @location(0) vec4f { + var color = textureSample(source, source_sampler, input.uv); + if (blit.effect > 0.5) { + let scan = 0.88 + 0.12 * sin(input.uv.y * blit.viewport.y * 3.14159); + let roll = 0.04 * sin(input.uv.y * 40.0 + blit.time * 6.0); + color = vec4f(color.rgb * scan + roll, color.a); + } + return color; +} +`; + +function premultiplied(): GPUBlendState { + return { + color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }, + alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }, + }; +} + +function hsl(h: number, s: number, l: number, a = 1): [number, number, number, number] { + const sat = s / 100; + const lig = l / 100; + const k = (n: number) => (n + h / 30) % 12; + const q = sat * Math.min(lig, 1 - lig); + const f = (n: number) => lig - q * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + return [f(0), f(8), f(4), a]; +} + +const FONT_5X7: readonly string[] = [ + "01110100011001110101110011000101110", + "00100011000010000100001000010001110", + "01110100010000100010001000100011111", + "01110100010000100110000011000101110", + "00010001100101010010111110001000010", + "11111100001111000001000011000101110", + "00110010001000011110100011000101110", + "11111000010001000100001000010000100", + "01110100011000101110100011000101110", + "01110100011000101111000010001001100", + "01110100011111110001100011000110001", + "11110100011111010001100011000111110", + "01110100011000010000100001000101110", + "11110100011000110001100011000111110", + "11111100001110010000100001000011111", + "11111100001110010000100001000010000", + "01110100011000010011100011000101111", + "10001100011111110001100011000110001", + "01110001000010000100001000010001110", + "00111000100001000010000101001001100", + "10001100101010011000101001001010001", + "10000100001000010000100001000011111", + "10001110111010110101100011000110001", + "10001110011100110101100111000110001", + "01110100011000110001100011000101110", + "11110100011000111110100001000010000", + "01110100011000110001101011001001101", + "11110100011000111110101001001010001", + "01111100000111000001000011000111110", + "11111001000010000100001000010000100", + "10001100011000110001100011000101110", + "10001100011000110001100010101000100", + "10001100011000110101101011010101010", + "10001100010101000100010101000110001", + "10001100010101000100001000010000100", + "11111000010001000100010001000011111", +]; + +function paintGlyph(data: Uint8Array, glyph: number, bits: string, fill = false): void { + const originX = (glyph % ATLAS_COLS) * GLYPH_W; + const originY = Math.floor(glyph / ATLAS_COLS) * GLYPH_H; + if (fill) { + for (let y = 1; y < GLYPH_H - 1; y += 1) { + for (let x = 1; x < GLYPH_W - 1; x += 1) data[(originY + y) * ATLAS_W + originX + x] = 255; + } + return; + } + for (let y = 0; y < 7; y += 1) { + for (let x = 0; x < 5; x += 1) { + if (bits[y * 5 + x] === "1") data[(originY + 4 + y) * ATLAS_W + originX + 1 + x] = 255; + } + } +} + +function buildAtlasBytes(): Uint8Array { + const data = new Uint8Array(ATLAS_W * ATLAS_H); + for (let digit = 0; digit < 10; digit += 1) paintGlyph(data, digit, FONT_5X7[digit]!); + for (let letter = 0; letter < 26; letter += 1) paintGlyph(data, 10 + letter, FONT_5X7[10 + letter]!); + paintGlyph(data, 37, "", true); + return data; +} + +function cellGlyph(col: number, row: number, generation: number, sessionIndex: number): number { + if (col === 0) return Math.floor(row / 10) % 10; + if (col === 1) return row % 10; + if (col === 2) return 36; + return 10 + ((col + row * 3 + generation + sessionIndex) % 26); +} + +class GrowBuffer { + buffer: GPUBuffer; + capacity: number; + + constructor( + private readonly device: GPUDevice, + private readonly label: string, + private readonly usage: GPUBufferUsageFlags, + bytes: number, + ) { + this.capacity = Math.max(256, bytes); + this.buffer = device.createBuffer({ label, size: this.capacity, usage }); + } + + write(data: ArrayBufferView): GPUBuffer { + const bytes = data.byteLength; + if (bytes > this.capacity) { + this.buffer.destroy(); + this.capacity = Math.max(bytes, this.capacity * 2); + this.buffer = this.device.createBuffer({ label: this.label, size: this.capacity, usage: this.usage }); + } + if (bytes > 0) this.device.queue.writeBuffer(this.buffer, 0, data); + return this.buffer; + } + + destroy(): void { + this.buffer.destroy(); + } +} + +interface GpuHub { + device: GPUDevice; + format: GPUTextureFormat; + cellPipeline: GPURenderPipeline; + fillPipeline: GPURenderPipeline; + blitPipeline: GPURenderPipeline; + sampler: GPUSampler; + atlas: GPUTexture; + atlasView: GPUTextureView; + cellLayout: GPUBindGroupLayout; + fillLayout: GPUBindGroupLayout; + blitLayout: GPUBindGroupLayout; +} + +interface SessionGpu { + id: string; + index: number; + texture: GPUTexture; + view: GPUTextureView; + width: number; + height: number; + instance: GrowBuffer; + cellUniform: GPUBuffer; + fillUniform: GPUBuffer; + cellBind: GPUBindGroup; + fillBind: GPUBindGroup; + generation: number; + cursor: { x: number; y: number; on: boolean }; + dirtyRows: Set; + full: boolean; + device: GPUDevice; +} + +interface SlotGpu { + blitUniform: GPUBuffer; + fillUniform: GPUBuffer; + blitBind: GPUBindGroup | undefined; + fillBind: GPUBindGroup; +} + +interface ViewGpu { + id: string; + sessionId: string; + role: "authority" | "mirror"; + canvas: OffscreenCanvas; + context: GPUCanvasContext | ImageBitmapRenderingContext; + bitmap: boolean; + width: number; + height: number; + dpr: number; + blitUniform: GPUBuffer | undefined; + blitBind: GPUBindGroup | undefined; + fillUniform: GPUBuffer | undefined; + fillBind: GPUBindGroup | undefined; + hub: GpuHub; + localScene: SessionGpu | undefined; +} + +function createTexture( + device: GPUDevice, + format: GPUTextureFormat, + width: number, + height: number, + label: string, +): GPUTexture { + return device.createTexture({ + label, + size: [Math.max(1, width), Math.max(1, height)], + format, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, + }); +} + +export class PipelineEngine { + config: LabConfig = clampConfig({ + architecture: "shared-scene", + sceneResolution: "grid-native", + effectScope: "off", + devicePerView: false, + sessions: 1, + viewsPerSession: 1, + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + layout: "grid", + workload: "hold", + hz: 60, + }); + counters = emptyWorkerCounters(); + adapterName = "unknown"; + #shared: GpuHub | undefined; + #sessions = new Map(); + #views = new Map(); + #slots = new Map(); + #stage: + { canvas: OffscreenCanvas; context: GPUCanvasContext; width: number; height: number; dpr: number } | undefined; + #scratch: { canvas: OffscreenCanvas; context: GPUCanvasContext; width: number; height: number } | undefined; + #slotGpu = new Map(); + #lastFlushAt = 0; + #tick = 0; + + async init(): Promise { + this.#shared ??= await this.#createHub("shared"); + return this.adapterName; + } + + async configure(config: LabConfig): Promise { + this.config = clampConfig(config); + this.resetSurfaces(); + this.#sessions.clear(); + this.#tick = 0; + } + + resetSurfaces(): void { + for (const session of this.#sessions.values()) this.#destroySession(session); + this.#sessions.clear(); + for (const view of this.#views.values()) this.#destroyView(view); + this.#views.clear(); + for (const slot of this.#slotGpu.values()) { + slot.blitUniform.destroy(); + slot.fillUniform.destroy(); + } + this.#slotGpu.clear(); + this.#stage = undefined; + if (this.#scratch) { + this.#scratch.canvas.width = 1; + this.#scratch = undefined; + } + this.counters.canvasContexts = 0; + this.counters.sceneTextures = 0; + this.counters.devices = this.#shared ? 1 : 0; + } + + mountStage(canvas: OffscreenCanvas, width: number, height: number, dpr: number): void { + const hub = this.#requireHub(); + const context = canvas.getContext("webgpu"); + if (!context) throw new Error("WebGPU stage context unavailable"); + canvas.width = Math.max(1, Math.round(width * dpr)); + canvas.height = Math.max(1, Math.round(height * dpr)); + context.configure({ device: hub.device, format: hub.format, alphaMode: "opaque" }); + this.#stage = { canvas, context, width: canvas.width, height: canvas.height, dpr }; + this.counters.canvasContexts += 1; + } + + async mountViews(specs: readonly ViewMountSpec[], canvases: readonly OffscreenCanvas[]): Promise { + if (specs.length !== canvases.length) throw new Error("view mount length mismatch"); + for (let index = 0; index < specs.length; index += 1) { + const spec = specs[index]!; + const canvas = canvases[index]!; + const hub = + this.config.devicePerView && spec.role === "authority" + ? await this.#createHub(`view-${spec.viewId}`) + : this.config.devicePerView + ? await this.#createHub(`view-${spec.viewId}`) + : this.#requireHub(); + const width = Math.max(1, Math.round(spec.width * spec.dpr)); + const height = Math.max(1, Math.round(spec.height * spec.dpr)); + canvas.width = width; + canvas.height = height; + const bitmap = spec.bitmap && spec.role === "mirror"; + if (bitmap) { + const context = canvas.getContext("bitmaprenderer"); + if (!context) throw new Error("bitmaprenderer unavailable"); + this.#views.set(spec.viewId, { + id: spec.viewId, + sessionId: spec.sessionId, + role: spec.role, + canvas, + context, + bitmap: true, + width, + height, + dpr: spec.dpr, + blitUniform: undefined, + blitBind: undefined, + fillUniform: undefined, + fillBind: undefined, + hub, + localScene: undefined, + }); + } else { + const context = canvas.getContext("webgpu"); + if (!context) throw new Error("WebGPU view context unavailable"); + context.configure({ device: hub.device, format: hub.format, alphaMode: "opaque" }); + const view: ViewGpu = { + id: spec.viewId, + sessionId: spec.sessionId, + role: spec.role, + canvas, + context, + bitmap: false, + width, + height, + dpr: spec.dpr, + blitUniform: hub.device.createBuffer({ + label: `blit ${spec.viewId}`, + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }), + blitBind: undefined, + fillUniform: hub.device.createBuffer({ + label: `clear ${spec.viewId}`, + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }), + fillBind: undefined, + hub, + localScene: undefined, + }; + view.fillBind = hub.device.createBindGroup({ + layout: hub.fillLayout, + entries: [{ binding: 0, resource: { buffer: view.fillUniform! } }], + }); + this.#views.set(spec.viewId, view); + } + this.counters.canvasContexts += 1; + if (this.config.architecture === "per-view-scene" || this.config.devicePerView) { + const sceneSize = + this.config.sceneResolution === "view" + ? { width, height } + : this.#sceneSizeForSession(spec.sessionId, spec.sessionIndex); + const local = this.#createSessionGpu(hub, spec.sessionId, spec.sessionIndex, sceneSize.width, sceneSize.height); + this.#views.get(spec.viewId)!.localScene = local; + } + } + this.#ensureSessions(); + this.#invalidateAll(); + } + + layout(slots: readonly SlotLayout[], stageWidth: number, stageHeight: number, stageDpr: number): void { + this.#slots.clear(); + for (const slot of slots) this.#slots.set(slot.viewId, slot); + if (this.#stage) { + const width = Math.max(1, Math.round(stageWidth * stageDpr)); + const height = Math.max(1, Math.round(stageHeight * stageDpr)); + if (this.#stage.canvas.width !== width || this.#stage.canvas.height !== height) { + this.#stage.canvas.width = width; + this.#stage.canvas.height = height; + this.#stage.context.configure({ + device: this.#requireHub().device, + format: this.#requireHub().format, + alphaMode: "opaque", + }); + } + this.#stage.width = width; + this.#stage.height = height; + this.#stage.dpr = stageDpr; + } + this.#invalidateAll(); + } + + resizeView(viewId: string, width: number, height: number, dpr: number): void { + const view = this.#views.get(viewId); + if (!view) return; + const nextW = Math.max(1, Math.round(width * dpr)); + const nextH = Math.max(1, Math.round(height * dpr)); + if (view.width === nextW && view.height === nextH && view.dpr === dpr) return; + view.width = nextW; + view.height = nextH; + view.dpr = dpr; + view.canvas.width = nextW; + view.canvas.height = nextH; + if (!view.bitmap) { + (view.context as GPUCanvasContext).configure({ + device: view.hub.device, + format: view.hub.format, + alphaMode: "opaque", + }); + } + if (view.localScene && this.config.sceneResolution === "view") { + this.#resizeSession(view.hub, view.localScene, nextW, nextH); + } + const session = this.#sessions.get(view.sessionId); + if (session && view.role === "authority" && this.config.sceneResolution === "authority") { + this.#resizeSession(this.#requireHub(), session, nextW, nextH); + } + this.#invalidateSession(view.sessionId); + } + + tick(now: number): void { + const { workload } = this.config; + this.#tick += 1; + const mutate = (session: SessionGpu, full: boolean, rows: Iterable): void => { + if (full) { + session.full = true; + session.dirtyRows.clear(); + session.generation += 1; + } else { + for (const row of rows) session.dirtyRows.add(row); + } + }; + + const targets = this.#sessionTargets(); + for (const session of targets) { + if (workload === "hold") continue; + if (workload === "repaint") continue; + if (workload === "blink") { + session.cursor.on = Math.floor(now / 500) % 2 === 0; + mutate(session, false, [session.cursor.y]); + continue; + } + if (workload === "sparse") { + session.cursor.x = this.#tick % this.config.cols; + session.cursor.y = Math.floor(this.#tick / 7) % this.config.rows; + session.generation += 1; + mutate(session, false, [session.cursor.y]); + continue; + } + if (workload === "flood") { + session.cursor.x = this.#tick % this.config.cols; + mutate(session, true, []); + continue; + } + session.generation += 1; + session.cursor.y = this.#tick % this.config.rows; + mutate(session, true, []); + } + } + + async flush(now: number): Promise { + if (this.#lastFlushAt > 0) this.counters.flushIntervalsMs.push(now - this.#lastFlushAt); + this.#lastFlushAt = now; + const started = performance.now(); + this.counters.flushes += 1; + + if (this.config.architecture === "window-composite") { + await this.#flushWindow(now); + } else if (this.config.architecture === "bitmap-mirrors") { + await this.#flushBitmap(now); + } else if (this.config.architecture === "per-view-scene") { + this.#flushPerView(now); + } else { + this.#flushShared(now); + } + + this.counters.renderCpuMs.push(performance.now() - started); + if (this.counters.renderCpuMs.length > 4_000) + this.counters.renderCpuMs.splice(0, this.counters.renderCpuMs.length - 2_000); + if (this.counters.flushIntervalsMs.length > 4_000) { + this.counters.flushIntervalsMs.splice(0, this.counters.flushIntervalsMs.length - 2_000); + } + } + + snapshotAndReset(durationMs: number): WorkerCounters { + const snapshot = { + ...this.counters, + renderCpuMs: [...this.counters.renderCpuMs], + flushIntervalsMs: [...this.counters.flushIntervalsMs], + }; + const resources = { + devices: this.counters.devices, + sceneTextures: this.counters.sceneTextures, + canvasContexts: this.counters.canvasContexts, + }; + this.counters = emptyWorkerCounters(); + this.counters.devices = resources.devices; + this.counters.sceneTextures = resources.sceneTextures; + this.counters.canvasContexts = resources.canvasContexts; + void durationMs; + return snapshot; + } + + needsContinuousPresent(): boolean { + return this.config.workload !== "hold" || this.config.effectScope !== "off"; + } + + #sessionTargets(): SessionGpu[] { + if (this.config.architecture === "per-view-scene" || this.config.devicePerView) { + return [...this.#views.values()].flatMap((view) => (view.localScene ? [view.localScene] : [])); + } + return [...this.#sessions.values()]; + } + + #ensureSessions(): void { + if (this.config.architecture === "per-view-scene" || this.config.devicePerView) return; + const hub = this.#requireHub(); + const views = [...this.#views.values()]; + const sessionIds = new Set(views.map((view) => view.sessionId)); + if (this.config.architecture === "window-composite") { + for (const slot of this.#slots.values()) sessionIds.add(slot.viewId.split(":")[0] ?? slot.viewId); + } + const unique = new Set(); + for (const view of views) unique.add(view.sessionId); + if (unique.size === 0) { + const count = this.config.sessions; + for (let index = 0; index < count; index += 1) unique.add(`s${index}`); + } + for (const [index, sessionId] of [...unique].entries()) { + if (this.#sessions.has(sessionId)) continue; + const size = this.#sceneSizeForSession(sessionId, index); + this.#sessions.set(sessionId, this.#createSessionGpu(hub, sessionId, index, size.width, size.height)); + } + this.#rebindViewBlits(); + } + + #sceneSizeForSession(sessionId: string, sessionIndex: number): { width: number; height: number } { + if (this.config.sceneResolution === "grid-native") { + const authority = [...this.#views.values()].find( + (view) => view.sessionId === sessionId && view.role === "authority", + ); + const dpr = authority?.dpr ?? this.#stage?.dpr ?? 2; + return gridNativeSize(this.config, dpr); + } + if (this.config.sceneResolution === "authority") { + const authority = [...this.#views.values()].find( + (view) => view.sessionId === sessionId && view.role === "authority", + ); + if (authority) return { width: authority.width, height: authority.height }; + const slot = [...this.#slots.values()].find((candidate) => candidate.viewId.startsWith(`${sessionId}:`)); + if (slot) + return { + width: Math.max(1, Math.round(slot.width * slot.dpr)), + height: Math.max(1, Math.round(slot.height * slot.dpr)), + }; + } + void sessionIndex; + return gridNativeSize(this.config, 2); + } + + #rebindViewBlits(): void { + for (const view of this.#views.values()) { + if (view.bitmap || !view.blitUniform) continue; + const session = view.localScene ?? this.#sessions.get(view.sessionId); + if (!session) continue; + view.blitBind = view.hub.device.createBindGroup({ + layout: view.hub.blitLayout, + entries: [ + { binding: 0, resource: { buffer: view.blitUniform } }, + { binding: 1, resource: session.view }, + { binding: 2, resource: view.hub.sampler }, + ], + }); + } + } + + #flushPerView(now: number): void { + const submitted = new Set(); + for (const view of this.#views.values()) { + const session = view.localScene; + if (!session || view.bitmap) continue; + const encoder = view.hub.device.createCommandEncoder({ label: `per-view ${view.id}` }); + this.#encodeScene(view.hub, encoder, session, now); + this.#encodePresent( + view.hub, + encoder, + view, + session, + now, + { x: 0, y: 0, width: view.width, height: view.height }, + this.#presentFit(view, session), + ); + view.hub.device.queue.submit([encoder.finish()]); + submitted.add(view.hub.device); + } + this.counters.queueSubmits += submitted.size; + } + + #flushShared(now: number): void { + const hub = this.#requireHub(); + const encoder = hub.device.createCommandEncoder({ label: "shared-scene flush" }); + for (const session of this.#sessions.values()) this.#encodeScene(hub, encoder, session, now); + for (const view of this.#views.values()) { + const session = this.#sessions.get(view.sessionId); + if (!session || view.bitmap) continue; + this.#encodePresent( + hub, + encoder, + view, + session, + now, + { x: 0, y: 0, width: view.width, height: view.height }, + this.#presentFit(view, session), + ); + } + hub.device.queue.submit([encoder.finish()]); + this.counters.queueSubmits += 1; + } + + async #flushWindow(now: number): Promise { + const hub = this.#requireHub(); + const stage = this.#stage; + if (!stage) return; + this.#ensureSessionsFromSlots(); + const encoder = hub.device.createCommandEncoder({ label: "window-composite flush" }); + for (const session of this.#sessions.values()) this.#encodeScene(hub, encoder, session, now); + const target = stage.context.getCurrentTexture().createView(); + this.counters.swapchainAcquires += 1; + this.#encodeFill( + hub, + encoder, + target, + stage.width, + stage.height, + [0.06, 0.065, 0.05, 1], + { + x: 0, + y: 0, + width: stage.width, + height: stage.height, + }, + undefined, + "stage", + ); + for (const slot of this.#slots.values()) { + const sessionId = slot.viewId.split(":")[0] ?? "s0"; + const session = this.#sessions.get(sessionId); + if (!session) continue; + const dest = { + x: slot.x * slot.dpr, + y: slot.y * slot.dpr, + width: slot.width * slot.dpr, + height: slot.height * slot.dpr, + }; + this.#encodeBlitTo( + hub, + encoder, + target, + session, + stage.width, + stage.height, + dest, + "letterbox", + now, + undefined, + slot.viewId, + ); + } + hub.device.queue.submit([encoder.finish()]); + this.counters.queueSubmits += 1; + } + + async #flushBitmap(now: number): Promise { + const hub = this.#requireHub(); + const encoder = hub.device.createCommandEncoder({ label: "bitmap flush" }); + for (const session of this.#sessions.values()) this.#encodeScene(hub, encoder, session, now); + for (const view of this.#views.values()) { + if (view.bitmap) continue; + const session = this.#sessions.get(view.sessionId); + if (!session) continue; + this.#encodePresent( + hub, + encoder, + view, + session, + now, + { x: 0, y: 0, width: view.width, height: view.height }, + "stretch", + ); + } + hub.device.queue.submit([encoder.finish()]); + this.counters.queueSubmits += 1; + await hub.device.queue.onSubmittedWorkDone(); + + for (const view of this.#views.values()) { + if (!view.bitmap) continue; + const session = this.#sessions.get(view.sessionId); + if (!session) continue; + const scratch = this.#ensureScratch(hub, session.width, session.height); + const copy = hub.device.createCommandEncoder({ label: `bitmap copy ${view.id}` }); + this.#encodeBlitTo( + hub, + copy, + scratch.context.getCurrentTexture().createView(), + session, + scratch.width, + scratch.height, + { x: 0, y: 0, width: scratch.width, height: scratch.height }, + "stretch", + now, + undefined, + `scratch:${view.id}`, + ); + this.counters.swapchainAcquires += 1; + hub.device.queue.submit([copy.finish()]); + this.counters.queueSubmits += 1; + await hub.device.queue.onSubmittedWorkDone(); + const bitmap = await createImageBitmap(scratch.canvas); + (view.context as ImageBitmapRenderingContext).transferFromImageBitmap(bitmap); + this.counters.bitmapTransfers += 1; + } + } + + #ensureSessionsFromSlots(): void { + const hub = this.#requireHub(); + const ids = new Set(); + for (const slot of this.#slots.values()) ids.add(slot.viewId.split(":")[0] ?? "s0"); + for (const [index, sessionId] of [...ids].entries()) { + if (this.#sessions.has(sessionId)) continue; + const size = this.#sceneSizeForSession(sessionId, index); + this.#sessions.set(sessionId, this.#createSessionGpu(hub, sessionId, index, size.width, size.height)); + } + } + + #ensureScratch( + hub: GpuHub, + width: number, + height: number, + ): { canvas: OffscreenCanvas; context: GPUCanvasContext; width: number; height: number } { + if (this.#scratch && this.#scratch.width === width && this.#scratch.height === height) return this.#scratch; + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("webgpu"); + if (!context) throw new Error("scratch WebGPU context unavailable"); + context.configure({ device: hub.device, format: hub.format, alphaMode: "opaque" }); + this.#scratch = { canvas, context, width, height }; + return this.#scratch; + } + + #encodeScene(hub: GpuHub, encoder: GPUCommandEncoder, session: SessionGpu, now: number): void { + if (!session.full && session.dirtyRows.size === 0) return; + const instances = this.#packInstances(session); + session.instance.write(instances.buffer); + this.counters.uploadBytes += instances.byteLength + UNIFORM_BYTES; + this.#writeCellUniform(hub, session, now); + const attachment: GPURenderPassColorAttachment = { + view: session.view, + clearValue: { r: 0.05, g: 0.055, b: 0.04, a: 1 }, + loadOp: session.full ? "clear" : "load", + storeOp: "store", + }; + const pass = encoder.beginRenderPass({ label: `scene ${session.id}`, colorAttachments: [attachment] }); + if (instances.count > 0) { + pass.setPipeline(hub.cellPipeline); + pass.setBindGroup(0, session.cellBind); + pass.setVertexBuffer(0, session.instance.buffer); + pass.draw(6, instances.count); + } + pass.end(); + this.counters.scenePasses += 1; + this.counters.cellsDrawn += instances.count; + session.full = false; + session.dirtyRows.clear(); + } + + #encodePresent( + hub: GpuHub, + encoder: GPUCommandEncoder, + view: ViewGpu, + session: SessionGpu, + now: number, + dest: { x: number; y: number; width: number; height: number }, + fit: FitMode, + ): void { + const context = view.context as GPUCanvasContext; + const target = context.getCurrentTexture().createView(); + this.counters.swapchainAcquires += 1; + this.#encodeBlitTo(hub, encoder, target, session, view.width, view.height, dest, fit, now, view); + } + + #encodeBlitTo( + hub: GpuHub, + encoder: GPUCommandEncoder, + target: GPUTextureView, + session: SessionGpu, + viewportW: number, + viewportH: number, + dest: { x: number; y: number; width: number; height: number }, + fit: FitMode, + now: number, + view?: ViewGpu, + resourceId?: string, + ): void { + const bar: [number, number, number, number] = [0.07, 0.075, 0.055, 1]; + const clearFirst = Boolean(view) || resourceId === "stage" || resourceId?.startsWith("scratch:"); + if (!view) { + this.#encodeFill(hub, encoder, target, viewportW, viewportH, bar, dest, view, resourceId); + } + const fitted = fitRect(session.width, session.height, dest.width, dest.height, fit); + const rect = { x: dest.x + fitted.x, y: dest.y + fitted.y, width: fitted.width, height: fitted.height }; + const slot = view ? undefined : this.#slotResources(hub, session, resourceId ?? session.id); + const uniform = view?.blitUniform ?? slot!.blitUniform; + this.#writeBlit(hub.device, uniform, rect, viewportW, viewportH, now, this.config.effectScope !== "off"); + if (view && !view.blitBind && view.blitUniform) { + view.blitBind = hub.device.createBindGroup({ + layout: hub.blitLayout, + entries: [ + { binding: 0, resource: { buffer: view.blitUniform } }, + { binding: 1, resource: session.view }, + { binding: 2, resource: hub.sampler }, + ], + }); + } + const bind = view?.blitBind ?? slot!.blitBind; + if (!bind) return; + const pass = encoder.beginRenderPass({ + label: "blit", + colorAttachments: [ + { + view: target, + clearValue: { r: bar[0], g: bar[1], b: bar[2], a: bar[3] }, + loadOp: clearFirst && view ? "clear" : "load", + storeOp: "store", + }, + ], + }); + pass.setPipeline(hub.blitPipeline); + pass.setBindGroup(0, bind); + pass.draw(6); + pass.end(); + this.counters.blitPasses += 1; + if (this.config.effectScope !== "off") this.counters.effectPasses += 1; + } + + #encodeFill( + hub: GpuHub, + encoder: GPUCommandEncoder, + target: GPUTextureView, + viewportW: number, + viewportH: number, + color: [number, number, number, number], + rect: { x: number; y: number; width: number; height: number }, + view?: ViewGpu, + resourceId?: string, + ): void { + const slot = view ? undefined : this.#slotResources(hub, undefined, resourceId ?? "fill"); + const uniform = view?.fillUniform ?? slot!.fillUniform; + this.#writeFill(hub.device, uniform, color, rect, viewportW, viewportH); + const bind = view?.fillBind ?? slot!.fillBind; + const pass = encoder.beginRenderPass({ + label: "fill", + colorAttachments: [ + { + view: target, + clearValue: { r: color[0], g: color[1], b: color[2], a: color[3] }, + loadOp: resourceId === "stage" || resourceId?.startsWith("scratch:") ? "clear" : "load", + storeOp: "store", + }, + ], + }); + pass.setPipeline(hub.fillPipeline); + pass.setBindGroup(0, bind); + pass.draw(6); + pass.end(); + } + + #slotResources(hub: GpuHub, session: SessionGpu | undefined, id: string): SlotGpu { + let slot = this.#slotGpu.get(id); + if (!slot) { + const blitUniform = hub.device.createBuffer({ + label: `slot blit ${id}`, + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + const fillUniform = hub.device.createBuffer({ + label: `slot fill ${id}`, + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + slot = { + blitUniform, + fillUniform, + blitBind: undefined, + fillBind: hub.device.createBindGroup({ + layout: hub.fillLayout, + entries: [{ binding: 0, resource: { buffer: fillUniform } }], + }), + }; + this.#slotGpu.set(id, slot); + } + if (session && !slot.blitBind) { + slot.blitBind = hub.device.createBindGroup({ + layout: hub.blitLayout, + entries: [ + { binding: 0, resource: { buffer: slot.blitUniform } }, + { binding: 1, resource: session.view }, + { binding: 2, resource: hub.sampler }, + ], + }); + } + return slot; + } + + #presentFit(view: ViewGpu, session: SessionGpu): FitMode { + return session.width === view.width && session.height === view.height ? "stretch" : "letterbox"; + } + + #packInstances(session: SessionGpu): { buffer: Float32Array; byteLength: number; count: number } { + const { cols, rows } = this.config; + const rowsToDraw = session.full + ? Array.from({ length: rows }, (_, row) => row) + : [...session.dirtyRows].sort((left, right) => left - right); + const count = rowsToDraw.length * cols; + const data = new Float32Array(count * INSTANCE_FLOATS); + const hue = (session.index * 47 + 18) % 360; + const fg = hsl(hue, 42, 78); + const bgOdd = hsl(hue, 18, 12); + const bgEven = hsl(hue, 16, 16); + let offset = 0; + for (const row of rowsToDraw) { + const bg = row % 2 === 0 ? bgEven : bgOdd; + for (let col = 0; col < cols; col += 1) { + const cursor = session.cursor.on && session.cursor.x === col && session.cursor.y === row; + const glyph = cursor ? 37 : cellGlyph(col, row, session.generation, session.index); + const u0 = (glyph % ATLAS_COLS) / ATLAS_COLS; + const v0 = Math.floor(glyph / ATLAS_COLS) / ATLAS_ROWS; + data[offset++] = col; + data[offset++] = row; + data[offset++] = u0; + data[offset++] = v0; + data[offset++] = u0 + 1 / ATLAS_COLS; + data[offset++] = v0 + 1 / ATLAS_ROWS; + const color = cursor ? hsl(42, 80, 62) : fg; + data[offset++] = color[0]!; + data[offset++] = color[1]!; + data[offset++] = color[2]!; + data[offset++] = bg[0]!; + data[offset++] = bg[1]!; + data[offset++] = bg[2]!; + } + } + return { buffer: data, byteLength: count * INSTANCE_FLOATS * 4, count }; + } + + #writeCellUniform(_hub: GpuHub, session: SessionGpu, now: number): void { + const grid = fitGrid( + this.config.cols, + this.config.rows, + this.config.cellWidth, + this.config.cellHeight, + session.width, + session.height, + ); + const data = new Float32Array(UNIFORM_BYTES / 4); + data[0] = session.width; + data[1] = session.height; + data[2] = grid.cellW; + data[3] = grid.cellH; + data[4] = grid.x; + data[5] = grid.y; + data[6] = session.cursor.x; + data[7] = session.cursor.y; + data[8] = session.cursor.on ? 1 : 0; + data[9] = now / 1000; + session.device.queue.writeBuffer(session.cellUniform, 0, data); + } + + #writeFill( + device: GPUDevice, + buffer: GPUBuffer, + color: [number, number, number, number], + rect: { x: number; y: number; width: number; height: number }, + viewportW: number, + viewportH: number, + ): void { + const data = new Float32Array(UNIFORM_BYTES / 4); + data.set(color, 0); + data[4] = rect.x; + data[5] = rect.y; + data[6] = rect.width; + data[7] = rect.height; + data[8] = viewportW; + data[9] = viewportH; + device.queue.writeBuffer(buffer, 0, data); + } + + #writeBlit( + device: GPUDevice, + buffer: GPUBuffer, + rect: { x: number; y: number; width: number; height: number }, + viewportW: number, + viewportH: number, + now: number, + effect: boolean, + ): void { + const data = new Float32Array(UNIFORM_BYTES / 4); + data[0] = rect.x; + data[1] = rect.y; + data[2] = rect.width; + data[3] = rect.height; + data[4] = viewportW; + data[5] = viewportH; + data[6] = now / 1000; + data[7] = effect ? 1 : 0; + device.queue.writeBuffer(buffer, 0, data); + } + + #createSessionGpu(hub: GpuHub, id: string, index: number, width: number, height: number): SessionGpu { + const texture = createTexture(hub.device, hub.format, width, height, `scene ${id}`); + const cellUniform = hub.device.createBuffer({ + label: `cell uniform ${id}`, + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + const fillUniform = hub.device.createBuffer({ + label: `fill uniform ${id}`, + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + const session: SessionGpu = { + id, + index, + texture, + view: texture.createView(), + width, + height, + instance: new GrowBuffer( + hub.device, + `instances ${id}`, + GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + this.config.cols * this.config.rows * INSTANCE_FLOATS * 4, + ), + cellUniform, + fillUniform, + cellBind: hub.device.createBindGroup({ + layout: hub.cellLayout, + entries: [ + { binding: 0, resource: { buffer: cellUniform } }, + { binding: 1, resource: hub.atlasView }, + { binding: 2, resource: hub.sampler }, + ], + }), + fillBind: hub.device.createBindGroup({ + layout: hub.fillLayout, + entries: [{ binding: 0, resource: { buffer: fillUniform } }], + }), + generation: 1, + cursor: { x: 0, y: 0, on: true }, + dirtyRows: new Set(), + full: true, + device: hub.device, + }; + this.counters.sceneTextures += 1; + return session; + } + + #resizeSession(hub: GpuHub, session: SessionGpu, width: number, height: number): void { + if (session.width === width && session.height === height) return; + session.texture.destroy(); + session.texture = createTexture(hub.device, hub.format, width, height, `scene ${session.id}`); + session.view = session.texture.createView(); + session.width = width; + session.height = height; + session.cellBind = hub.device.createBindGroup({ + layout: hub.cellLayout, + entries: [ + { binding: 0, resource: { buffer: session.cellUniform } }, + { binding: 1, resource: hub.atlasView }, + { binding: 2, resource: hub.sampler }, + ], + }); + session.full = true; + for (const slot of this.#slotGpu.values()) slot.blitBind = undefined; + this.#rebindViewBlits(); + } + + #destroySession(session: SessionGpu): void { + session.texture.destroy(); + session.instance.destroy(); + session.cellUniform.destroy(); + session.fillUniform.destroy(); + this.counters.sceneTextures = Math.max(0, this.counters.sceneTextures - 1); + } + + #destroyView(view: ViewGpu): void { + view.blitUniform?.destroy(); + view.fillUniform?.destroy(); + if (view.localScene) this.#destroySession(view.localScene); + if (!view.bitmap) { + try { + (view.context as GPUCanvasContext).unconfigure(); + } catch { + // canvas already detached + } + } + } + + #invalidateAll(): void { + for (const session of this.#sessionTargets()) { + session.full = true; + session.dirtyRows.clear(); + } + } + + #invalidateSession(sessionId: string): void { + const session = this.#sessions.get(sessionId); + if (session) session.full = true; + for (const view of this.#views.values()) { + if (view.sessionId === sessionId && view.localScene) view.localScene.full = true; + } + } + + #requireHub(): GpuHub { + if (!this.#shared) throw new Error("WebGPU hub is not ready"); + return this.#shared; + } + + async #createHub(label: string): Promise { + if (!navigator.gpu) throw new Error("WebGPU is unavailable"); + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) throw new Error("No WebGPU adapter"); + this.adapterName = adapter.info?.description || adapter.info?.vendor || label; + const device = await adapter.requestDevice({ label: `pipeline-lab ${label}` }); + const format = navigator.gpu.getPreferredCanvasFormat(); + const cellModule = device.createShaderModule({ label: "cell", code: CELL_SHADER }); + const fillModule = device.createShaderModule({ label: "fill", code: FILL_SHADER }); + const blitModule = device.createShaderModule({ label: "blit", code: BLIT_SHADER }); + const cellPipeline = device.createRenderPipeline({ + label: "cell pipeline", + layout: "auto", + vertex: { + module: cellModule, + entryPoint: "vertex_main", + buffers: [ + { + arrayStride: INSTANCE_FLOATS * 4, + stepMode: "instance", + attributes: [ + { shaderLocation: 0, offset: 0, format: "float32x2" }, + { shaderLocation: 1, offset: 8, format: "float32x4" }, + { shaderLocation: 2, offset: 24, format: "float32x3" }, + { shaderLocation: 3, offset: 36, format: "float32x3" }, + ], + }, + ], + }, + fragment: { + module: cellModule, + entryPoint: "fragment_main", + targets: [{ format, blend: premultiplied() }], + }, + primitive: { topology: "triangle-list" }, + }); + const fillPipeline = device.createRenderPipeline({ + label: "fill pipeline", + layout: "auto", + vertex: { module: fillModule, entryPoint: "vertex_main" }, + fragment: { module: fillModule, entryPoint: "fragment_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + const blitPipeline = device.createRenderPipeline({ + label: "blit pipeline", + layout: "auto", + vertex: { module: blitModule, entryPoint: "vertex_main" }, + fragment: { module: blitModule, entryPoint: "fragment_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + const atlas = device.createTexture({ + label: "glyph atlas", + size: [ATLAS_W, ATLAS_H], + format: "r8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + device.queue.writeTexture({ texture: atlas }, buildAtlasBytes(), { bytesPerRow: ATLAS_W }, [ATLAS_W, ATLAS_H]); + const sampler = device.createSampler({ magFilter: "nearest", minFilter: "nearest" }); + this.counters.devices += 1; + return { + device, + format, + cellPipeline, + fillPipeline, + blitPipeline, + sampler, + atlas, + atlasView: atlas.createView(), + cellLayout: cellPipeline.getBindGroupLayout(0), + fillLayout: fillPipeline.getBindGroupLayout(0), + blitLayout: blitPipeline.getBindGroupLayout(0), + }; + } +} diff --git a/apps/desktop-pipeline-lab/src/renderer/src/host.ts b/apps/desktop-pipeline-lab/src/renderer/src/host.ts new file mode 100644 index 00000000..0ab52825 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/src/host.ts @@ -0,0 +1,101 @@ +import type { HostToWorker, SlotLayout, ViewMountSpec, WorkerToHost } from "../../shared/messages"; +import { + clampConfig, + expandViews, + usesBitmapMirrors, + usesDomCanvases, + type LabConfig, + type WorkerCounters, +} from "../../shared/model"; + +export class PipelineHost { + #worker: Worker; + #ready: Promise; + #readyResolve: (adapter: string) => void = () => undefined; + #measures = new Map void>(); + #requestId = 1; + adapter = "starting"; + onError: ((message: string) => void) | undefined; + + constructor() { + this.#worker = new Worker(new URL("./pipeline.worker.ts", import.meta.url), { type: "module" }); + this.#ready = new Promise((resolve) => { + this.#readyResolve = resolve; + }); + this.#worker.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "ready") { + this.adapter = message.adapter; + this.#readyResolve(message.adapter); + } else if (message.type === "error") { + this.onError?.(message.message); + } else if (message.type === "measure-result") { + this.#measures.get(message.requestId)?.({ durationMs: message.durationMs, counters: message.counters }); + this.#measures.delete(message.requestId); + } + }; + } + + async configure(config: LabConfig): Promise { + this.#ready = new Promise((resolve) => { + this.#readyResolve = resolve; + }); + this.#post({ type: "configure", config: clampConfig(config) }); + return this.#ready; + } + + mountViews( + config: LabConfig, + canvases: Map, + sizes: Map, + ): void { + const views = expandViews(config.sessions, config.viewsPerSession); + const specs: ViewMountSpec[] = []; + const transferred: OffscreenCanvas[] = []; + for (const view of views) { + const canvas = canvases.get(view.viewId); + const size = sizes.get(view.viewId); + if (!canvas || !size) continue; + specs.push({ + ...view, + width: size.width, + height: size.height, + dpr: size.dpr, + bitmap: usesBitmapMirrors(config.architecture) && view.role === "mirror", + }); + transferred.push(canvas); + } + this.#post({ type: "mount-views", views: specs, canvases: transferred }, transferred); + } + + mountStage(canvas: OffscreenCanvas, width: number, height: number, dpr: number): void { + this.#post({ type: "mount-stage", canvas, width, height, dpr }, [canvas]); + } + + layout(slots: SlotLayout[], stageWidth: number, stageHeight: number, stageDpr: number): void { + this.#post({ type: "layout", slots, stageWidth, stageHeight, stageDpr }); + } + + resizeView(viewId: string, width: number, height: number, dpr: number): void { + this.#post({ type: "resize-view", viewId, width, height, dpr }); + } + + usesCanvases(config: LabConfig): boolean { + return usesDomCanvases(config.architecture); + } + + async measure(durationMs: number): Promise<{ durationMs: number; counters: WorkerCounters }> { + const requestId = this.#requestId++; + const result = new Promise<{ durationMs: number; counters: WorkerCounters }>((resolve) => { + this.#measures.set(requestId, resolve); + }); + this.#post({ type: "measure-start", requestId }); + await new Promise((resolve) => window.setTimeout(resolve, durationMs)); + this.#post({ type: "measure-stop", requestId }); + return result; + } + + #post(message: HostToWorker, transfer: Transferable[] = []): void { + this.#worker.postMessage(message, transfer); + } +} diff --git a/apps/desktop-pipeline-lab/src/renderer/src/main.tsx b/apps/desktop-pipeline-lab/src/renderer/src/main.tsx new file mode 100644 index 00000000..bbc4d240 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/desktop-pipeline-lab/src/renderer/src/pipeline.worker.ts b/apps/desktop-pipeline-lab/src/renderer/src/pipeline.worker.ts new file mode 100644 index 00000000..91128a56 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/src/pipeline.worker.ts @@ -0,0 +1,86 @@ +/// +/// + +import type { HostToWorker, WorkerToHost } from "../../shared/messages"; +import { PipelineEngine } from "./gpu"; + +const engine = new PipelineEngine(); +let running = false; +let measureStarted = 0; +let lastWorkloadAt = 0; + +function post(message: WorkerToHost): void { + self.postMessage(message); +} + +function schedule(): void { + if (running) return; + running = true; + const tick = (now: number): void => { + self.requestAnimationFrame(tick); + const interval = 1000 / Math.max(1, engine.config.hz); + if (engine.config.workload !== "hold" && now - lastWorkloadAt >= interval) { + engine.tick(now); + lastWorkloadAt = now; + } + if (engine.needsContinuousPresent() || engine.counters.flushes === 0) { + void engine.flush(now).catch((error: unknown) => { + post({ type: "error", message: error instanceof Error ? error.message : String(error) }); + }); + } + }; + self.requestAnimationFrame(tick); +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + void (async () => { + try { + if (message.type === "configure") { + await engine.init(); + await engine.configure(message.config); + post({ type: "ready", adapter: engine.adapterName }); + return; + } + if (message.type === "mount-stage") { + engine.mountStage(message.canvas, message.width, message.height, message.dpr); + schedule(); + return; + } + if (message.type === "mount-views") { + await engine.mountViews(message.views, message.canvases); + schedule(); + post({ + type: "status", + running: true, + views: message.views.length, + sessions: new Set(message.views.map((view) => view.sessionId)).size, + }); + return; + } + if (message.type === "layout") { + engine.layout(message.slots, message.stageWidth, message.stageHeight, message.stageDpr); + if (engine.config.architecture === "window-composite") schedule(); + return; + } + if (message.type === "resize-view") { + engine.resizeView(message.viewId, message.width, message.height, message.dpr); + return; + } + if (message.type === "measure-start") { + measureStarted = performance.now(); + engine.snapshotAndReset(0); + schedule(); + return; + } + if (message.type === "measure-stop") { + const durationMs = performance.now() - measureStarted; + const counters = engine.snapshotAndReset(durationMs); + post({ type: "measure-result", requestId: message.requestId, durationMs, counters }); + return; + } + } catch (error) { + post({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } + })(); +}; diff --git a/apps/desktop-pipeline-lab/src/renderer/src/styles.css b/apps/desktop-pipeline-lab/src/renderer/src/styles.css new file mode 100644 index 00000000..b3dbb40b --- /dev/null +++ b/apps/desktop-pipeline-lab/src/renderer/src/styles.css @@ -0,0 +1,409 @@ +:root { + color-scheme: dark; + --void: #10110e; + --rail: #181a13; + --gate: #22251b; + --sprocket: #2e3226; + --paper: #ddd6c4; + --mute: #8c8776; + --tungsten: #d89a42; + --aqua: #6fb3ad; + --ember: #c45c48; + --hair: rgba(221, 214, 196, 0.12); + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; + background: var(--void); + color: var(--paper); +} + +body { + font-family: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace; + font-size: 12px; + letter-spacing: 0.01em; +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +.lab { + --mast: 76px; + height: 100%; + display: grid; + grid-template-columns: 168px minmax(280px, 340px) 1fr; + grid-template-rows: var(--mast) 1fr 168px; + grid-template-areas: + "mast mast mast" + "gates brief sheet" + "log log log"; +} + +.mast { + grid-area: mast; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 28px; + align-items: end; + padding: 22px 22px 12px 72px; + border-bottom: 1px solid var(--hair); +} + +.mast-mark { + display: flex; + gap: 14px; + align-items: center; +} + +.sprocket { + width: 18px; + height: 36px; + background: + radial-gradient(circle at 50% 8px, var(--void) 4px, transparent 4.5px), + radial-gradient(circle at 50% 28px, var(--void) 4px, transparent 4.5px), var(--tungsten); + clip-path: inset(0 2px); +} + +.eyebrow { + margin: 0; + color: var(--mute); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 10px; +} + +h1 { + margin: 2px 0 0; + font-family: "Avenir Next Condensed", "DIN Alternate", "Futura", "Avenir Next", sans-serif; + font-weight: 600; + font-size: 28px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mast-claim { + margin: 0; + max-width: 42em; + color: var(--mute); + line-height: 1.45; +} + +.mast-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + color: var(--aqua); +} + +.gates { + grid-area: gates; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 12px 16px 14px; + background: repeating-linear-gradient(to bottom, transparent 0 22px, var(--sprocket) 22px 28px); + border-right: 1px solid var(--hair); +} + +.gate { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 10px 10px 12px; + border: 1px solid var(--hair); + background: var(--rail); + text-align: left; + cursor: pointer; +} + +.gate:hover, +.gate:focus-visible { + border-color: var(--tungsten); + outline: none; +} + +.gate.is-live { + background: #2a2214; + border-color: var(--tungsten); + box-shadow: inset 3px 0 0 var(--tungsten); +} + +.gate-name { + font-family: "Avenir Next Condensed", "DIN Alternate", sans-serif; + letter-spacing: 0.14em; + font-size: 13px; +} + +.gate-title { + color: var(--mute); +} + +.gate-cost { + color: var(--aqua); + font-size: 10px; +} + +.brief { + grid-area: brief; + padding: 16px 18px 20px; + border-right: 1px solid var(--hair); + overflow: auto; +} + +.brief h2 { + margin: 0 0 8px; + font-family: "Avenir Next Condensed", "DIN Alternate", sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 18px; + font-weight: 600; +} + +.brief p, +.brief dd { + margin: 0; + color: var(--mute); + line-height: 1.45; +} + +.brief dl { + margin: 14px 0; + display: grid; + gap: 10px; +} + +.brief dt { + color: var(--tungsten); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 10px; + margin-bottom: 3px; +} + +.recipes { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 14px; +} + +.recipe, +.arm { + border: 1px solid var(--hair); + background: transparent; + padding: 6px 8px; + cursor: pointer; +} + +.recipe:hover, +.arm:hover, +.recipe:focus-visible, +.arm:focus-visible { + border-color: var(--aqua); + outline: none; +} + +.dials { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 12px; + margin-bottom: 16px; +} + +.dials label { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--mute); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 10px; +} + +.dials input, +.dials select { + width: 100%; + background: var(--rail); + border: 1px solid var(--hair); + padding: 5px 6px; + color: var(--paper); +} + +.pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.check { + flex-direction: row !important; + align-items: center; + gap: 8px !important; + text-transform: none; + letter-spacing: 0; + font-size: 12px; + color: var(--paper); +} + +.check input { + width: auto; +} + +.arm { + width: 100%; + background: var(--tungsten); + color: #1a1208; + border-color: var(--tungsten); + letter-spacing: 0.16em; + text-transform: uppercase; + padding: 10px; +} + +.arm:disabled { + opacity: 0.55; + cursor: wait; +} + +.fault { + margin: 10px 0 0; + color: var(--ember); +} + +.sheet { + grid-area: sheet; + position: relative; + display: grid; + gap: 8px; + padding: 14px; + overflow: auto; + background: + linear-gradient(var(--hair) 1px, transparent 1px) 0 0 / 100% 24px, + var(--void); +} + +.sheet[data-layout="grid"] { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-auto-rows: minmax(120px, 1fr); +} + +.sheet[data-layout="thumbnails"] { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-auto-rows: 92px; +} + +.sheet[data-layout="stage-and-tiles"] { + grid-template-columns: 2fr 1fr 1fr; + grid-auto-rows: minmax(110px, 1fr); +} + +.sheet[data-layout="stage-and-tiles"] .frame.is-authority { + grid-column: 1; + grid-row: 1 / span 4; +} + +.stage-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.sheet:has(.stage-canvas) .frame { + background: transparent; + pointer-events: none; +} + +.frame { + position: relative; + z-index: 1; + min-height: 88px; + border: 1px solid hsl(var(--session, 40) 18% 28%); + background: #0c0d0a; + overflow: hidden; +} + +.frame canvas, +.frame-ghost { + display: block; + width: 100%; + height: 100%; +} + +.frame-tag { + position: absolute; + left: 6px; + bottom: 6px; + padding: 2px 5px; + background: rgba(16, 17, 14, 0.78); + color: hsl(var(--session, 40) 40% 72%); + font-size: 10px; + letter-spacing: 0.08em; +} + +.densitometer { + grid-area: log; + overflow: auto; + border-top: 1px solid var(--hair); + background: var(--rail); +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid var(--hair); + white-space: nowrap; +} + +th { + color: var(--mute); + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 10px; +} + +tbody tr:first-child td { + color: var(--aqua); +} + +@media (max-width: 1100px) { + .lab { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr 180px; + grid-template-areas: + "mast" + "gates" + "sheet" + "log"; + } + + .brief { + display: none; + } + + .gates { + flex-direction: row; + overflow: auto; + } +} diff --git a/apps/desktop-pipeline-lab/src/shared/messages.ts b/apps/desktop-pipeline-lab/src/shared/messages.ts new file mode 100644 index 00000000..5877d044 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/shared/messages.ts @@ -0,0 +1,32 @@ +import type { LabConfig, ViewRole, WorkerCounters } from "./model"; + +export interface SlotLayout { + viewId: string; + x: number; + y: number; + width: number; + height: number; + dpr: number; +} + +export interface ViewMountSpec extends ViewRole { + width: number; + height: number; + dpr: number; + bitmap: boolean; +} + +export type HostToWorker = + | { type: "configure"; config: LabConfig } + | { type: "mount-stage"; canvas: OffscreenCanvas; width: number; height: number; dpr: number } + | { type: "mount-views"; views: ViewMountSpec[]; canvases: OffscreenCanvas[] } + | { type: "layout"; slots: SlotLayout[]; stageWidth: number; stageHeight: number; stageDpr: number } + | { type: "resize-view"; viewId: string; width: number; height: number; dpr: number } + | { type: "measure-start"; requestId: number } + | { type: "measure-stop"; requestId: number }; + +export type WorkerToHost = + | { type: "ready"; adapter: string } + | { type: "error"; message: string } + | { type: "status"; running: boolean; views: number; sessions: number } + | { type: "measure-result"; requestId: number; durationMs: number; counters: WorkerCounters }; diff --git a/apps/desktop-pipeline-lab/src/shared/model.test.ts b/apps/desktop-pipeline-lab/src/shared/model.test.ts new file mode 100644 index 00000000..7e23373d --- /dev/null +++ b/apps/desktop-pipeline-lab/src/shared/model.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { clampConfig, DEFAULT_LAB_CONFIG, expandViews, fitGrid, fitRect, gridNativeSize, percentile } from "./model"; + +describe("expandViews", () => { + it("marks the first view of each session as the authority", () => { + const views = expandViews(2, 3); + expect(views.map((view) => `${view.viewId}:${view.role}`)).toEqual([ + "s0:v0:authority", + "s0:v1:mirror", + "s0:v2:mirror", + "s1:v0:authority", + "s1:v1:mirror", + "s1:v2:mirror", + ]); + }); +}); + +describe("fitRect", () => { + it("letterboxes a wide scene into a tall destination", () => { + expect(fitRect(200, 100, 100, 100, "letterbox")).toEqual({ + x: 0, + y: 25, + width: 100, + height: 50, + }); + }); + + it("stretches to fill", () => { + expect(fitRect(200, 100, 50, 80, "stretch")).toEqual({ + x: 0, + y: 0, + width: 50, + height: 80, + }); + }); +}); + +describe("fitGrid", () => { + it("keeps 8×16 cells and integer-zooms when the box is larger than the grid", () => { + const fitted = fitGrid(80, 24, 8, 16, 2000, 2000); + expect(fitted.scale).toBe(3); + expect(fitted.width).toBe(1920); + expect(fitted.height).toBe(1152); + expect(fitted.x).toBe(40); + expect(fitted.y).toBe(424); + }); + + it("shrinks fractionally to fit a thumbnail", () => { + const fitted = fitGrid(80, 24, 8, 16, 160, 90); + expect(fitted.scale).toBeLessThan(1); + expect(fitted.width).toBeLessThanOrEqual(160); + expect(fitted.height).toBeLessThanOrEqual(90); + }); +}); + +describe("gridNativeSize", () => { + it("scales the cell grid by device pixel ratio", () => { + expect(gridNativeSize({ cols: 80, rows: 24, cellWidth: 8, cellHeight: 16 }, 2)).toEqual({ + width: 1280, + height: 768, + }); + }); +}); + +describe("clampConfig", () => { + it("keeps grid-native scene resolution on per-view so cell aspect stays honest", () => { + const clamped = clampConfig({ + ...DEFAULT_LAB_CONFIG, + architecture: "per-view-scene", + sceneResolution: "grid-native", + devicePerView: true, + }); + expect(clamped.sceneResolution).toBe("grid-native"); + expect(clamped.devicePerView).toBe(true); + }); + + it("ignores device-per-view on shared architectures", () => { + const clamped = clampConfig({ + ...DEFAULT_LAB_CONFIG, + architecture: "shared-scene", + devicePerView: true, + }); + expect(clamped.devicePerView).toBe(false); + }); +}); + +describe("percentile", () => { + it("returns null for an empty sample", () => { + expect(percentile([], 99)).toBeNull(); + }); + + it("uses the ceiling rank", () => { + expect(percentile([1, 2, 3, 4], 50)).toBe(2); + expect(percentile([1, 2, 3, 4], 100)).toBe(4); + }); +}); diff --git a/apps/desktop-pipeline-lab/src/shared/model.ts b/apps/desktop-pipeline-lab/src/shared/model.ts new file mode 100644 index 00000000..e33fa420 --- /dev/null +++ b/apps/desktop-pipeline-lab/src/shared/model.ts @@ -0,0 +1,415 @@ +export const ARCHITECTURES = ["per-view-scene", "shared-scene", "window-composite", "bitmap-mirrors"] as const; +export type ArchitectureId = (typeof ARCHITECTURES)[number]; + +export const SCENE_RESOLUTIONS = ["view", "authority", "grid-native"] as const; +export type SceneResolution = (typeof SCENE_RESOLUTIONS)[number]; + +export const LAYOUTS = ["grid", "stage-and-tiles", "thumbnails"] as const; +export type LayoutId = (typeof LAYOUTS)[number]; + +export const WORKLOADS = ["hold", "repaint", "blink", "sparse", "flood", "scroll"] as const; +export type WorkloadId = (typeof WORKLOADS)[number]; + +export const FIT_MODES = ["letterbox", "stretch"] as const; +export type FitMode = (typeof FIT_MODES)[number]; + +export const EFFECT_SCOPES = ["off", "scene", "view"] as const; +export type EffectScope = (typeof EFFECT_SCOPES)[number]; + +export interface LabConfig { + architecture: ArchitectureId; + sceneResolution: SceneResolution; + effectScope: EffectScope; + devicePerView: boolean; + sessions: number; + viewsPerSession: number; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + layout: LayoutId; + workload: WorkloadId; + hz: number; +} + +export const DEFAULT_LAB_CONFIG: LabConfig = { + architecture: "shared-scene", + sceneResolution: "grid-native", + effectScope: "off", + devicePerView: false, + sessions: 1, + viewsPerSession: 4, + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + layout: "stage-and-tiles", + workload: "sparse", + hz: 60, +}; + +export interface ArchitectureSpec { + id: ArchitectureId; + gate: string; + title: string; + claim: string; + winsWhen: string; + losesWhen: string; + scenePasses: string; + presents: string; +} + +export const ARCHITECTURE_SPECS: readonly ArchitectureSpec[] = [ + { + id: "per-view-scene", + gate: "PER-VIEW", + title: "One scene per canvas", + claim: "Today’s Electron path: each view rebuilds the grid into its own scene texture and swapchain.", + winsWhen: + "Every view is a different session, or views are so different in size that a shared texture would just be resampled waste.", + losesWhen: + "The same session is shown more than once — deck tile + stage, ICE widget, thumbnail. Scene cost scales with views, not sessions.", + scenePasses: "sessions × views", + presents: "N canvases", + }, + { + id: "shared-scene", + gate: "SHARED", + title: "One scene, N views", + claim: + "Decode and paint the grid once per session. Each view is a blit, letterbox, or view-local effect onto its own canvas.", + winsWhen: "Mirrors of one session. Authority still drives the grid; mirrors never resize the PTY.", + losesWhen: "Every view is a unique session — then this collapses to per-view plus a blit. N swapchains remain.", + scenePasses: "sessions", + presents: "N canvases", + }, + { + id: "window-composite", + gate: "WINDOW", + title: "One swapchain for the stage", + claim: + "One OffscreenCanvas covers the contact sheet. The worker composites every view rect in WebGPU. Chromium sees one layer.", + winsWhen: "A deck of many tiles, or any layout where CSS-per-canvas layer tax dominates.", + losesWhen: "You need per-tile CSS clips, rounded corners, transforms, or DOM hit testing on the canvas itself.", + scenePasses: "sessions (if shared) or views", + presents: "1 canvas", + }, + { + id: "bitmap-mirrors", + gate: "BITMAP", + title: "ImageBitmap mirrors", + claim: + "Authority stays WebGPU. Mirrors are bitmaprenderer canvases fed snapshots of the scene. Measures the copy hop.", + winsWhen: "Tiny, static, or rarely updated thumbnails where a copy is cheaper than a second WebGPU context.", + losesWhen: "Live 60 Hz mirrors — GPU wait + createImageBitmap per view is the thing we expect to lose.", + scenePasses: "sessions", + presents: "1 WebGPU + N bitmaps", + }, +]; + +export interface Recipe { + id: string; + title: string; + question: string; + patch: Partial; +} + +export const RECIPES: readonly Recipe[] = [ + { + id: "mirror-tax", + title: "Mirror tax", + question: "Does one session × eight views make shared-scene win, or is present still the cost?", + patch: { + sessions: 1, + viewsPerSession: 8, + layout: "grid", + workload: "flood", + sceneResolution: "grid-native", + effectScope: "off", + devicePerView: false, + }, + }, + { + id: "stranger-deck", + title: "Deck of strangers", + question: "Eight different sessions, one view each. Is the Chromium canvas compositor the bottleneck?", + patch: { + sessions: 8, + viewsPerSession: 1, + layout: "grid", + workload: "flood", + sceneResolution: "view", + effectScope: "off", + devicePerView: false, + }, + }, + { + id: "stage-tiles", + title: "Stage + tiles", + question: "The product shape: one authority, many letterboxed mirrors, sparse typing.", + patch: { + sessions: 1, + viewsPerSession: 8, + layout: "stage-and-tiles", + workload: "sparse", + sceneResolution: "grid-native", + effectScope: "off", + devicePerView: false, + }, + }, + { + id: "present-tax", + title: "Present tax", + question: "Sixteen thumbnails, no content change. Does swapping N canvases every vsync show up?", + patch: { + sessions: 16, + viewsPerSession: 1, + layout: "thumbnails", + workload: "repaint", + sceneResolution: "view", + effectScope: "off", + devicePerView: false, + cols: 40, + rows: 12, + }, + }, + { + id: "effect-scope", + title: "Effect scope", + question: "CRT on the scene once, or per view? Shared-scene + view effects still pays N fullscreen samples.", + patch: { + sessions: 1, + viewsPerSession: 6, + layout: "stage-and-tiles", + workload: "repaint", + sceneResolution: "grid-native", + effectScope: "view", + devicePerView: false, + }, + }, + { + id: "device-tax", + title: "Device tax", + question: "Negative control. One GPUDevice per view should lose on memory and compile time.", + patch: { + architecture: "per-view-scene", + sessions: 1, + viewsPerSession: 4, + layout: "grid", + workload: "flood", + sceneResolution: "view", + effectScope: "off", + devicePerView: true, + }, + }, +]; + +export interface ViewRole { + sessionIndex: number; + viewIndex: number; + viewId: string; + sessionId: string; + role: "authority" | "mirror"; +} + +export function expandViews(sessions: number, viewsPerSession: number): ViewRole[] { + const sessionCount = Math.max(1, Math.trunc(sessions)); + const viewCount = Math.max(1, Math.trunc(viewsPerSession)); + const views: ViewRole[] = []; + for (let sessionIndex = 0; sessionIndex < sessionCount; sessionIndex += 1) { + for (let viewIndex = 0; viewIndex < viewCount; viewIndex += 1) { + views.push({ + sessionIndex, + viewIndex, + sessionId: `s${sessionIndex}`, + viewId: `s${sessionIndex}:v${viewIndex}`, + role: viewIndex === 0 ? "authority" : "mirror", + }); + } + } + return views; +} + +export function usesDomCanvases(architecture: ArchitectureId): boolean { + return architecture !== "window-composite"; +} + +export function usesBitmapMirrors(architecture: ArchitectureId): boolean { + return architecture === "bitmap-mirrors"; +} + +export function effectiveSceneResolution(config: LabConfig): SceneResolution { + if (config.architecture === "per-view-scene" && config.sceneResolution === "authority") return "view"; + if (config.architecture !== "per-view-scene" && config.sceneResolution === "view") return "grid-native"; + return config.sceneResolution; +} + +export function effectiveDevicePerView(config: LabConfig): boolean { + return config.architecture === "per-view-scene" && config.devicePerView; +} + +export interface FitRect { + x: number; + y: number; + width: number; + height: number; +} + +export function fitRect( + sourceWidth: number, + sourceHeight: number, + destWidth: number, + destHeight: number, + mode: FitMode, +): FitRect { + const destW = Math.max(0, destWidth); + const destH = Math.max(0, destHeight); + if (mode === "stretch" || sourceWidth <= 0 || sourceHeight <= 0 || destW <= 0 || destH <= 0) { + return { x: 0, y: 0, width: destW, height: destH }; + } + const scale = Math.min(destW / sourceWidth, destH / sourceHeight); + const width = sourceWidth * scale; + const height = sourceHeight * scale; + return { x: (destW - width) / 2, y: (destH - height) / 2, width, height }; +} + +/** Place a cols×rows grid of fixed-aspect cells inside a pixel box. Integer zoom when it fits. */ +export function fitGrid( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + destWidth: number, + destHeight: number, +): FitRect & { scale: number; cellW: number; cellH: number } { + const nativeW = Math.max(1, cols * cellWidth); + const nativeH = Math.max(1, rows * cellHeight); + const destW = Math.max(1, destWidth); + const destH = Math.max(1, destHeight); + let scale = Math.min(destW / nativeW, destH / nativeH); + if (scale >= 1) scale = Math.floor(scale); + const cellW = cellWidth * scale; + const cellH = cellHeight * scale; + const width = cols * cellW; + const height = rows * cellH; + return { + x: (destW - width) / 2, + y: (destH - height) / 2, + width, + height, + scale, + cellW, + cellH, + }; +} + +export function gridNativeSize( + config: Pick, + dpr: number, +): { + width: number; + height: number; +} { + const scale = Math.max(1, dpr); + return { + width: Math.max(1, Math.round(config.cols * config.cellWidth * scale)), + height: Math.max(1, Math.round(config.rows * config.cellHeight * scale)), + }; +} + +export function sessionHue(sessionIndex: number): number { + return (sessionIndex * 47 + 18) % 360; +} + +export interface WorkerCounters { + flushes: number; + scenePasses: number; + blitPasses: number; + effectPasses: number; + swapchainAcquires: number; + queueSubmits: number; + cellsDrawn: number; + uploadBytes: number; + bitmapTransfers: number; + devices: number; + sceneTextures: number; + canvasContexts: number; + renderCpuMs: number[]; + flushIntervalsMs: number[]; +} + +export function emptyWorkerCounters(): WorkerCounters { + return { + flushes: 0, + scenePasses: 0, + blitPasses: 0, + effectPasses: 0, + swapchainAcquires: 0, + queueSubmits: 0, + cellsDrawn: 0, + uploadBytes: 0, + bitmapTransfers: 0, + devices: 0, + sceneTextures: 0, + canvasContexts: 0, + renderCpuMs: [], + flushIntervalsMs: [], + }; +} + +export interface ProcessSample { + atMs: number; + cpuPercent: number; + workingSetBytes: number; +} + +export interface LabRun { + id: string; + at: number; + config: LabConfig; + durationMs: number; + counters: WorkerCounters; + process: { + cpuPercent: number; + workingSetBytes: number; + samples: number; + }; +} + +export function percentile(values: readonly number[], p: number): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[index] ?? null; +} + +export function mean(values: readonly number[]): number | null { + if (values.length === 0) return null; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${Math.round(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} + +export function formatMs(value: number | null): string { + if (value === null) return "—"; + return `${value.toFixed(2)} ms`; +} + +export function clampConfig(config: LabConfig): LabConfig { + return { + ...config, + sessions: Math.min(32, Math.max(1, Math.trunc(config.sessions))), + viewsPerSession: Math.min(16, Math.max(1, Math.trunc(config.viewsPerSession))), + cols: Math.min(240, Math.max(20, Math.trunc(config.cols))), + rows: Math.min(80, Math.max(6, Math.trunc(config.rows))), + cellWidth: Math.min(16, Math.max(6, Math.trunc(config.cellWidth))), + cellHeight: Math.min(24, Math.max(10, Math.trunc(config.cellHeight))), + hz: Math.min(120, Math.max(1, Math.trunc(config.hz))), + devicePerView: effectiveDevicePerView(config), + sceneResolution: effectiveSceneResolution(config), + }; +} diff --git a/apps/desktop-pipeline-lab/tsconfig.json b/apps/desktop-pipeline-lab/tsconfig.json new file mode 100644 index 00000000..5ad2b3a2 --- /dev/null +++ b/apps/desktop-pipeline-lab/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "types": ["node", "vite/client", "@webgpu/types"], + "noEmit": true, + "declaration": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "electron.vite.config.ts"] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f8e95766..2af25863 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "ghosttea-demo", - "version": "0.11.0", + "version": "0.11.1", "description": "The Ghosttea desktop example application.", "author": "James Yong", "license": "MIT", @@ -23,11 +23,11 @@ "test": "vitest run" }, "dependencies": { - "@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", + "@vibecook/ghosttea": "0.11.1", + "@vibecook/ghosttea-electron": "0.11.1", + "@vibecook/ghosttea-frame": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1", + "@vibecook/ghosttea-react": "0.11.1", "react": "19.2.7", "react-dom": "19.2.7" }, diff --git a/eslint.config.js b/eslint.config.js index 9bf54fa4..f48460c5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,11 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, { - files: ["apps/desktop/src/renderer/**/*.{ts,tsx}", "packages/ghosttea-react/src/**/*.{ts,tsx}"], + files: [ + "apps/desktop/src/renderer/**/*.{ts,tsx}", + "apps/desktop-pipeline-lab/src/renderer/**/*.{ts,tsx}", + "packages/ghosttea-react/src/**/*.{ts,tsx}", + ], plugins: reactHooks.configs.flat.recommended.plugins, rules: reactHooks.configs.flat.recommended.rules, }, diff --git a/native/ghosttea/Cargo.toml b/native/ghosttea/Cargo.toml index 0a22e300..207ed819 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.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" } +ghosttea-vt = { version = "0.11.1", path = "crates/ghosttea-vt" } +ghosttea-config = { version = "0.11.1", path = "crates/ghosttea-config" } +ghosttea-core = { version = "0.11.1", path = "crates/ghosttea-core" } +ghosttea-text = { version = "0.11.1", path = "crates/ghosttea-text" } serde.workspace = true serde_json.workspace = true subtle.workspace = true diff --git a/native/ghosttea/crates/ghosttea-core/Cargo.toml b/native/ghosttea/crates/ghosttea-core/Cargo.toml index d68a89a6..e04cc6b0 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.11.0", path = "../ghosttea-text" } -ghosttea-vt = { version = "0.11.0", path = "../ghosttea-vt" } +ghosttea-text = { version = "0.11.1", path = "../ghosttea-text" } +ghosttea-vt = { version = "0.11.1", 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 9a88cec3..e1bf292e 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.11.0", path = "../.." } -ghosttea-text = { version = "0.11.0", path = "../ghosttea-text" } +ghosttea = { version = "0.11.1", path = "../.." } +ghosttea-text = { version = "0.11.1", 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 66d7cfcb..3629aaeb 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.11.0", path = "../ghosttea-vt-sys" } +ghosttea-vt-sys = { version = "0.11.1", path = "../ghosttea-vt-sys" } diff --git a/native/ghosttead/Cargo.toml b/native/ghosttead/Cargo.toml index 9483dc77..87980789 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.11.0", path = "../ghosttea" } -ghosttea-truffle = { version = "0.11.0", path = "../ghosttea/crates/ghosttea-truffle" } +ghosttea = { version = "0.11.1", path = "../ghosttea" } +ghosttea-truffle = { version = "0.11.1", path = "../ghosttea/crates/ghosttea-truffle" } tokio.workspace = true truffle-core.workspace = true diff --git a/package-lock.json b/package-lock.json index de89e208..c069ba52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghosttea", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghosttea", - "version": "0.11.0", + "version": "0.11.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28,14 +28,14 @@ }, "apps/desktop": { "name": "ghosttea-demo", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { - "@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", + "@vibecook/ghosttea": "0.11.1", + "@vibecook/ghosttea-electron": "0.11.1", + "@vibecook/ghosttea-frame": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1", + "@vibecook/ghosttea-react": "0.11.1", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -55,14 +55,14 @@ }, "apps/desktop-experiment": { "name": "ghosttea-desktop-experiment", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { - "@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", + "@vibecook/ghosttea": "0.11.1", + "@vibecook/ghosttea-electron": "0.11.1", + "@vibecook/ghosttea-frame": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1", + "@vibecook/ghosttea-react": "0.11.1", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -115,6 +115,62 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "apps/desktop-pipeline-lab": { + "name": "ghosttea-desktop-pipeline-lab", + "version": "0.11.1", + "license": "MIT", + "dependencies": { + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/node": "26.1.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.2.0", + "@webgpu/types": "^0.1.71", + "electron": "43.1.1", + "electron-vite": "5.0.0", + "typescript": "7.0.2", + "vite": "7.3.6", + "vitest": "4.1.10" + } + }, + "apps/desktop-pipeline-lab/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "apps/desktop/node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -4795,6 +4851,10 @@ "resolved": "apps/desktop-experiment", "link": true }, + "node_modules/ghosttea-desktop-pipeline-lab": { + "resolved": "apps/desktop-pipeline-lab", + "link": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7820,10 +7880,10 @@ }, "packages/ghosttea": { "name": "@vibecook/ghosttea", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { - "@vibecook/ghosttea-protocol": "0.11.0" + "@vibecook/ghosttea-protocol": "0.11.1" }, "devDependencies": { "typescript": "7.0.2", @@ -7832,10 +7892,10 @@ }, "packages/ghosttea-client": { "name": "@vibecook/ghosttea-client", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { - "@vibecook/ghosttea-protocol": "0.11.0" + "@vibecook/ghosttea-protocol": "0.11.1" }, "devDependencies": { "@types/node": "26.1.1", @@ -7883,11 +7943,11 @@ }, "packages/ghosttea-electron": { "name": "@vibecook/ghosttea-electron", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { - "@vibecook/ghosttea-client": "0.11.0", - "@vibecook/ghosttea-protocol": "0.11.0" + "@vibecook/ghosttea-client": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1" }, "devDependencies": { "@types/node": "26.1.1", @@ -7936,7 +7996,7 @@ }, "packages/ghosttea-frame": { "name": "@vibecook/ghosttea-frame", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "devDependencies": { "typescript": "7.0.2", @@ -7980,7 +8040,7 @@ }, "packages/ghosttea-native-tabs": { "name": "@vibecook/ghosttea-native-tabs", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "devDependencies": { "@types/node": "26.1.1", @@ -8025,7 +8085,7 @@ }, "packages/ghosttea-protocol": { "name": "@vibecook/ghosttea-protocol", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "devDependencies": { "typescript": "7.0.2", @@ -8069,12 +8129,12 @@ }, "packages/ghosttea-react": { "name": "@vibecook/ghosttea-react", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { - "@vibecook/ghosttea": "0.11.0", - "@vibecook/ghosttea-frame": "0.11.0", - "@vibecook/ghosttea-protocol": "0.11.0" + "@vibecook/ghosttea": "0.11.1", + "@vibecook/ghosttea-frame": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1" }, "devDependencies": { "@types/react": "19.2.17", @@ -8160,7 +8220,7 @@ }, "packages/ghosttead": { "name": "@vibecook/ghosttead", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "devDependencies": { "@types/node": "26.1.1", @@ -8168,18 +8228,18 @@ "vitest": "4.1.10" }, "optionalDependencies": { - "@vibecook/ghosttead-darwin-arm64": "0.11.0", - "@vibecook/ghosttead-win32-x64": "0.11.0" + "@vibecook/ghosttead-darwin-arm64": "0.11.1", + "@vibecook/ghosttead-win32-x64": "0.11.1" } }, "packages/ghosttead-darwin-arm64": { "name": "@vibecook/ghosttead-darwin-arm64", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT" }, "packages/ghosttead-win32-x64": { "name": "@vibecook/ghosttead-win32-x64", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT" }, "packages/ghosttead/node_modules/typescript": { diff --git a/package.json b/package.json index 4eee6e8a..634fae4b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ghosttea", "private": true, - "version": "0.11.0", + "version": "0.11.1", "description": "Ghostty-powered native terminal runtime for Electron applications.", "license": "MIT", "author": "James Yong", @@ -132,6 +132,7 @@ "check": "npm run format:check && npm run lint && npm run check:workspace-names && npm run check:build-script-inputs && npm run check:ghostty-upgrade && npm run check:bundled-fonts && npm run check:swiftpm:manifests && npm run check:ios-release-bom && npm run check:ios-release-resources && npm run check:ios-app-store && npm run check:ios-beta-matrix && npm run test:ios:beta-matrix && npm run check:ios-instruments && npm run test:ios:instruments-evidence && npm run check:ios-diagnostics && npm run build:sdk && npm run check --workspaces --if-present && cargo check --workspace && cargo clippy --workspace --all-targets --all-features -- -D warnings", "dev": "npm run build:sdk && npm run dev --workspace ghosttea-demo", "dev:experiment": "npm run build:sdk && npm run dev --workspace ghosttea-desktop-experiment", + "dev:pipeline-lab": "npm run dev --workspace ghosttea-desktop-pipeline-lab", "dev:peer": "node scripts/dev-peer.mjs", "dev:peers": "node scripts/dev-peers.mjs", "dist": "npm run dist --workspace ghosttea-demo", @@ -141,8 +142,8 @@ "ghosttead": "cargo run -p ghosttead", "terminald": "npm run ghosttead", "lint": "eslint apps packages scripts tests bench *.js --max-warnings 0", - "format": "prettier --write .github/workflows/*.yml apps/desktop/src apps/desktop-experiment/src packages scripts tests bench *.json *.js *.md && cargo fmt --all", - "format:check": "prettier --check .github/workflows/*.yml apps/desktop/src apps/desktop-experiment/src packages scripts tests bench *.json *.js *.md && cargo fmt --all -- --check" + "format": "prettier --write .github/workflows/*.yml apps/desktop/src apps/desktop-experiment/src apps/desktop-pipeline-lab/src packages scripts tests bench *.json *.js *.md && cargo fmt --all", + "format:check": "prettier --check .github/workflows/*.yml apps/desktop/src apps/desktop-experiment/src apps/desktop-pipeline-lab/src packages scripts tests bench *.json *.js *.md && cargo fmt --all -- --check" }, "engines": { "node": ">=22" diff --git a/packages/ghosttea-client/package.json b/packages/ghosttea-client/package.json index f94ba21d..404fb227 100644 --- a/packages/ghosttea-client/package.json +++ b/packages/ghosttea-client/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-client", - "version": "0.11.0", + "version": "0.11.1", "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.11.0" + "@vibecook/ghosttea-protocol": "0.11.1" }, "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 1cd0e67e..95f60664 100644 --- a/packages/ghosttea-electron/package.json +++ b/packages/ghosttea-electron/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-electron", - "version": "0.11.0", + "version": "0.11.1", "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.11.0", - "@vibecook/ghosttea-protocol": "0.11.0" + "@vibecook/ghosttea-client": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1" }, "peerDependencies": { "electron": ">=35" diff --git a/packages/ghosttea-frame/package.json b/packages/ghosttea-frame/package.json index 156d3f4c..ed683b65 100644 --- a/packages/ghosttea-frame/package.json +++ b/packages/ghosttea-frame/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-frame", - "version": "0.11.0", + "version": "0.11.1", "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 a779ac49..1b6efae2 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.11.0", + "version": "0.11.1", "description": "Prebuilt macOS native window-tab ordering addon for Electron applications.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttea-protocol/package.json b/packages/ghosttea-protocol/package.json index 35f7d7a5..fae8de3a 100644 --- a/packages/ghosttea-protocol/package.json +++ b/packages/ghosttea-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-protocol", - "version": "0.11.0", + "version": "0.11.1", "description": "Typed control protocol for Ghosttea terminal clients and bridges.", "license": "MIT", "author": "James Yong", diff --git a/packages/ghosttea-react/README.md b/packages/ghosttea-react/README.md index aa1de69d..860eb370 100644 --- a/packages/ghosttea-react/README.md +++ b/packages/ghosttea-react/README.md @@ -26,12 +26,29 @@ const runtime = createGhostteaTerminalRuntime({ openTicket: (sessionId, options) => fieldd.openTicket(sessionId, options), renewAttach: (request) => fieldd.renewAttach(request), listSessions: () => fieldd.listSessions(), + getSession: (sessionId) => fieldd.getSession(sessionId), createSession: (options) => fieldd.createSession(options), terminate: (sessionId, source) => fieldd.terminate(sessionId, source), + onExtensionMessage: (message, context) => hostExtensions.receive(message, context), }, }); ``` +When `getSession` is present, each routed `frame-committed` signal arms the +same coalesced 200 ms metadata refresh used by the ports transport. Reads for a +session never overlap, `null` is a no-op, and a late response cannot overwrite +a newer registration or event. Hosts can also project authoritative lifecycle +facts directly with `runtime.applySessionEvent(event)`. The exported +`RoutedSessionEvent` union covers full-summary updates, activity changes, exits +with complete exit facts, and non-terminating removal. + +`onExtensionMessage` is deliberately strict. It receives only an unknown +tagged object from an authenticated control leg, unchanged and with its +`cellBootId`/`connectionSetId` context. Known protocol messages still use the +built-in decoder, and malformed, wrong-leg, oversized, binary, or pre-accept +messages retain their protocol-close behavior. Omitting the callback keeps +unknown tags closed as before. + 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 diff --git a/packages/ghosttea-react/package.json b/packages/ghosttea-react/package.json index 23fb8534..cbcb482d 100644 --- a/packages/ghosttea-react/package.json +++ b/packages/ghosttea-react/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea-react", - "version": "0.11.0", + "version": "0.11.1", "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.11.0", - "@vibecook/ghosttea-frame": "0.11.0", - "@vibecook/ghosttea-protocol": "0.11.0" + "@vibecook/ghosttea": "0.11.1", + "@vibecook/ghosttea-frame": "0.11.1", + "@vibecook/ghosttea-protocol": "0.11.1" }, "peerDependencies": { "react": ">=19" diff --git a/packages/ghosttea-react/src/index.ts b/packages/ghosttea-react/src/index.ts index 156a7482..551b7fe3 100644 --- a/packages/ghosttea-react/src/index.ts +++ b/packages/ghosttea-react/src/index.ts @@ -25,6 +25,8 @@ export { type GhostteaRoutedHost, type GhostteaRoutedTerminalRuntimeOptions, type GhostteaTerminalRuntimeOptions, + type RoutedExtensionMessageContext, + type RoutedSessionEvent, type RoutedTerminalInputContext, type RoutedTerminalInputOperation, sessionIsFrozen, diff --git a/packages/ghosttea-react/src/routed-control.test.ts b/packages/ghosttea-react/src/routed-control.test.ts index 041d0a78..42b8e827 100644 --- a/packages/ghosttea-react/src/routed-control.test.ts +++ b/packages/ghosttea-react/src/routed-control.test.ts @@ -88,6 +88,144 @@ function attach(transport: RoutedControlTransport): void { } describe("main-thread routed control transport", () => { + it("delivers unknown authenticated control messages only when extension ingress is enabled", () => { + const socket = new FakeSocket(); + const events: RoutedControlTransportEvent[] = []; + const transport = new RoutedControlTransport({ + socketFactory: () => socket as unknown as WebSocket, + acceptExtensionMessages: true, + 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"], + }), + ); + const message = { type: "HostSessionNotice", sessionId: "session-a", revision: 3 }; + socket.receive(JSON.stringify(message)); + + expect(events.at(-1)).toEqual({ + type: "extension-message", + message, + context: { cellBootId: "cell-a", connectionSetId: "set-a", channel: "control" }, + }); + expect(socket.readyState).toBe(1); + transport.dispose(); + }); + + it("keeps unknown messages closed before authentication and when extension ingress is disabled", () => { + const preAuthSocket = new FakeSocket(); + const preAuthEvents: RoutedControlTransportEvent[] = []; + const preAuth = new RoutedControlTransport({ + socketFactory: () => preAuthSocket as unknown as WebSocket, + acceptExtensionMessages: true, + emit: (event) => preAuthEvents.push(event), + }); + attach(preAuth); + preAuthSocket.open(); + preAuthSocket.receive(JSON.stringify({ type: "HostSessionNotice" })); + expect(preAuthEvents.at(-1)).toMatchObject({ type: "transport-closed", code: 4003, preAuth: false }); + + const disabledSocket = new FakeSocket(); + const disabledEvents: RoutedControlTransportEvent[] = []; + const disabled = new RoutedControlTransport({ + socketFactory: () => disabledSocket as unknown as WebSocket, + emit: (event) => disabledEvents.push(event), + }); + attach(disabled); + disabledSocket.open(); + disabledSocket.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"], + }), + ); + disabledSocket.receive(JSON.stringify({ type: "HostSessionNotice" })); + expect(disabledEvents.at(-1)).toMatchObject({ type: "transport-closed", code: 4003, preAuth: false }); + + preAuth.dispose(); + disabled.dispose(); + }); + + it("does not reclassify a known wrong-leg message as an extension", () => { + const socket = new FakeSocket(); + const events: RoutedControlTransportEvent[] = []; + const transport = new RoutedControlTransport({ + socketFactory: () => socket as unknown as WebSocket, + acceptExtensionMessages: true, + 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"], + }), + ); + const outboundAttach = socket.sent.at(-1)!; + socket.receive(outboundAttach); + + expect(events.at(-1)).toMatchObject({ type: "transport-closed", code: 4003, preAuth: false }); + expect(events.some((event) => event.type === "extension-message")).toBe(false); + transport.dispose(); + }); + + it("keeps malformed, known-invalid, binary, and oversized ingress closed", () => { + const reject = ( + deliver: (socket: FakeSocket) => void, + maxControlMessageBytes = DEFAULT_ROUTED_PROTOCOL_LIMITS.maxControlMessageBytes, + ) => { + const socket = new FakeSocket(); + const events: RoutedControlTransportEvent[] = []; + const transport = new RoutedControlTransport({ + socketFactory: () => socket as unknown as WebSocket, + acceptExtensionMessages: true, + 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, maxControlMessageBytes }, + capabilities: ["resume"], + }), + ); + deliver(socket); + expect(events.at(-1)).toMatchObject({ type: "transport-closed", code: 4003, preAuth: false }); + expect(events.some((event) => event.type === "extension-message")).toBe(false); + transport.dispose(); + }; + + reject((socket) => socket.receive("{")); + reject((socket) => socket.receive(JSON.stringify({ type: "CellActivationStatus" }))); + reject((socket) => socket.dispatchEvent(new MessageEvent("message", { data: new Uint8Array([1, 2, 3]).buffer }))); + reject((socket) => socket.receive(JSON.stringify({ type: "HostSessionNotice", payload: "x".repeat(128) })), 64); + }); + it("closes a leg whose heartbeat acknowledgements stop", async () => { const socket = new FakeSocket(); const events: RoutedControlTransportEvent[] = []; diff --git a/packages/ghosttea-react/src/routed-control.ts b/packages/ghosttea-react/src/routed-control.ts index dcc79ce3..4e963dca 100644 --- a/packages/ghosttea-react/src/routed-control.ts +++ b/packages/ghosttea-react/src/routed-control.ts @@ -31,6 +31,12 @@ export interface RoutedControlAttachRequest { capabilities?: string[]; } +export interface RoutedExtensionMessageContext { + readonly cellBootId: string; + readonly connectionSetId: string; + readonly channel: "control"; +} + export type RoutedControlTransportEvent = | { type: "transport-ready"; cellBootId: string; accepted: RoutedConnectionAccepted } | { type: "control-attached"; attached: RoutedControlLegAttached } @@ -39,6 +45,11 @@ export type RoutedControlTransportEvent = | { type: "demand-accepted"; activationId?: string; demandSequence: number } | { type: "geometry-committed"; activationId?: string; committed: RoutedGeometryCommitted } | { type: "geometry-refused"; activationId?: string; refused: RoutedGeometryRefused } + | { + type: "extension-message"; + message: Readonly>; + context: RoutedExtensionMessageContext; + } | { type: "transport-closed"; cellBootId: string; @@ -78,6 +89,7 @@ const socketClosing = 2; export class RoutedControlTransport { readonly #socketFactory: (url: string) => WebSocket; readonly #emit: (event: RoutedControlTransportEvent) => void; + readonly #acceptExtensionMessages: boolean; readonly #connections = new Map(); readonly #activations = new Map(); #disposed = false; @@ -85,9 +97,11 @@ export class RoutedControlTransport { constructor(options: { socketFactory?: (url: string) => WebSocket; emit: (event: RoutedControlTransportEvent) => void; + acceptExtensionMessages?: boolean; }) { this.#socketFactory = options.socketFactory ?? ((url) => new WebSocket(url)); this.#emit = options.emit; + this.#acceptExtensionMessages = options.acceptExtensionMessages ?? false; } attach(request: RoutedControlAttachRequest): void { @@ -242,6 +256,18 @@ export class RoutedControlTransport { } const decoded = decodeRoutedMessage(event.data, ROUTED_LEG_OUTBOUND.control); if (!decoded.ok) { + if (decoded.error === "unknown-type" && connection.accepted && this.#acceptExtensionMessages) { + this.#emit({ + type: "extension-message", + message: JSON.parse(event.data) as Readonly>, + context: { + cellBootId: connection.cellBootId, + connectionSetId: connection.accepted.connectionSetId, + channel: "control", + }, + }); + return; + } this.#closeConnection(connection, 4003, `invalid control message: ${decoded.error}`, true); return; } diff --git a/packages/ghosttea-react/src/routed-runtime.test.ts b/packages/ghosttea-react/src/routed-runtime.test.ts index 95668c49..a01aeae0 100644 --- a/packages/ghosttea-react/src/routed-runtime.test.ts +++ b/packages/ghosttea-react/src/routed-runtime.test.ts @@ -140,6 +140,424 @@ async function flush(): Promise { } describe("routed terminal runtime", () => { + it("refreshes registered metadata 200ms after a routed frame commit", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const first = session("session-a", "11"); + const refreshed = { + ...first, + title: "build complete", + cwd: "/workspace/project", + activity: { + ...first.activity, + kind: "shell-idle" as const, + source: "shell-integration" as const, + confidence: "authoritative" as const, + observedAtMs: 9, + }, + }; + const getSession = vi.fn(async () => refreshed); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket: async () => ticket("cell-a", "session-a", "set-a"), getSession }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + workerFactory: () => worker as unknown as Worker, + }); + const metadata = vi.fn(); + runtime.addEventListener("session-metadata", metadata); + runtime.registerSession(first); + + worker.emit({ + type: "frame-committed", + sessionHandle: first.handle, + sessionEpoch: 1n, + frameSequence: 1n, + fullSnapshot: false, + }); + await vi.advanceTimersByTimeAsync(199); + expect(getSession).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await flush(); + + expect(getSession).toHaveBeenCalledOnce(); + expect(getSession).toHaveBeenCalledWith(first.id); + expect(runtime.sessionMetadata(first.handle)).toEqual(refreshed); + expect(metadata).toHaveBeenCalledOnce(); + expect((metadata.mock.calls[0]![0] as CustomEvent).detail).toEqual(refreshed); + + runtime.dispose(); + vi.useRealTimers(); + }); + + it("coalesces routed metadata reads without overlapping them", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const first = session("session-a", "11"); + let resolveFirst!: (value: SessionSummary | null) => void; + const firstRead = new Promise((resolve) => { + resolveFirst = resolve; + }); + const getSession = vi + .fn<() => Promise>() + .mockImplementationOnce(() => firstRead) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error("inventory unavailable")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket: async () => ticket("cell-a", "session-a", "set-a"), getSession }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + workerFactory: () => worker as unknown as Worker, + }); + runtime.registerSession(first); + const commit = (frameSequence: bigint) => + worker.emit({ + type: "frame-committed", + sessionHandle: first.handle, + sessionEpoch: 1n, + frameSequence, + fullSnapshot: false, + }); + + commit(1n); + await vi.advanceTimersByTimeAsync(200); + expect(getSession).toHaveBeenCalledOnce(); + commit(2n); + commit(3n); + await vi.advanceTimersByTimeAsync(1_000); + expect(getSession).toHaveBeenCalledOnce(); + + resolveFirst({ ...first, title: "first read" }); + await flush(); + await vi.advanceTimersByTimeAsync(199); + expect(getSession).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + await flush(); + expect(getSession).toHaveBeenCalledTimes(2); + expect(runtime.sessionMetadata(first.handle)?.title).toBe("first read"); + + commit(4n); + await vi.advanceTimersByTimeAsync(200); + await flush(); + expect(getSession).toHaveBeenCalledTimes(3); + expect(runtime.sessionMetadata(first.handle)?.title).toBe("first read"); + expect(warn).toHaveBeenCalledWith( + "[terminal-runtime] session metadata refresh failed", + expect.objectContaining({ message: "inventory unavailable" }), + ); + + runtime.dispose(); + warn.mockRestore(); + vi.useRealTimers(); + }); + + it("does not let a late routed read resurrect an exited session", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const first = session("session-a", "11"); + let resolveRead!: (value: SessionSummary | null) => void; + const getSession = vi.fn( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket: async () => ticket("cell-a", "session-a", "set-a"), getSession }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + workerFactory: () => worker as unknown as Worker, + }); + runtime.registerSession(first); + worker.emit({ + type: "frame-committed", + sessionHandle: first.handle, + sessionEpoch: 1n, + frameSequence: 1n, + fullSnapshot: false, + }); + await vi.advanceTimersByTimeAsync(200); + expect(getSession).toHaveBeenCalledOnce(); + + runtime.applySessionEvent({ + type: "exited", + sessionId: first.id, + exitCode: null, + exitSignal: "SIGTERM", + requestedTermination: "application", + exitOutcome: "application-terminated", + }); + resolveRead({ ...first, title: "stale live session" }); + await flush(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(runtime.sessionMetadata(first.handle)).toMatchObject({ + exited: true, + title: null, + exitCode: null, + exitSignal: "SIGTERM", + requestedTermination: "application", + exitOutcome: "application-terminated", + }); + expect(getSession).toHaveBeenCalledOnce(); + + runtime.dispose(); + vi.useRealTimers(); + }); + + it("discards routed reads made stale by events, registrations, and removal", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const first = session("session-a", "11"); + const resolvers: Array<(value: SessionSummary | null) => void> = []; + const getSession = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket: async () => ticket("cell-a", "session-a", "set-a"), getSession }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + workerFactory: () => worker as unknown as Worker, + }); + runtime.registerSession(first); + const startRead = async (frameSequence: bigint) => { + worker.emit({ + type: "frame-committed", + sessionHandle: first.handle, + sessionEpoch: 1n, + frameSequence, + fullSnapshot: false, + }); + await vi.advanceTimersByTimeAsync(200); + }; + + await startRead(1n); + runtime.applySessionEvent({ type: "updated", session: { ...first, title: "newer event" } }); + resolvers[0]!({ ...first, title: "stale read one" }); + await flush(); + expect(runtime.sessionMetadata(first.handle)?.title).toBe("newer event"); + + await startRead(2n); + runtime.registerSession({ ...first, title: "newer registration" }); + resolvers[1]!({ ...first, title: "stale read two" }); + await flush(); + expect(runtime.sessionMetadata(first.handle)?.title).toBe("newer registration"); + + await startRead(3n); + runtime.applySessionEvent({ type: "removed", sessionId: first.id }); + resolvers[2]!({ ...first, title: "removed session" }); + await flush(); + expect(runtime.sessionMetadata(first.handle)).toBeUndefined(); + expect(getSession).toHaveBeenCalledTimes(3); + + runtime.dispose(); + vi.useRealTimers(); + }); + + it("applies routed session events with ports-compatible activity and exit ordering", () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const terminate = vi.fn(); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { openTicket: async () => ticket("cell-a", "session-a", "set-a"), terminate }, + platform: { + writeClipboard: () => undefined, + forceCanvasFallback: () => false, + setForceCanvasFallback: () => undefined, + reload: () => undefined, + }, + workerFactory: () => worker as unknown as Worker, + }); + const first = session("session-a", "11"); + runtime.registerSession(first); + const order: string[] = []; + const activities: unknown[] = []; + const exits: unknown[] = []; + runtime.addEventListener("session-activity", (event) => { + order.push("activity"); + activities.push((event as CustomEvent).detail); + }); + runtime.addEventListener("session-metadata", () => order.push("metadata")); + runtime.addEventListener("session-exited", (event) => { + order.push("exited"); + exits.push((event as CustomEvent).detail); + }); + + runtime.applySessionEvent({ type: "updated", session: { ...first, title: "host title" } }); + const activity = { + kind: "foreground-job" as const, + source: "process-group" as const, + confidence: "authoritative" as const, + rootProcessGroupId: 10, + foregroundProcessGroupId: 20, + observedAtMs: 30, + }; + runtime.applySessionEvent({ type: "activity-changed", sessionId: first.id, activity }); + runtime.applySessionEvent({ + type: "exited", + sessionId: first.id, + exitCode: 7, + exitSignal: null, + requestedTermination: null, + exitOutcome: "crashed", + }); + + expect(order).toEqual(["metadata", "activity", "metadata", "metadata", "exited"]); + expect(activities).toEqual([{ requestId: 0, type: "session-activity-changed", sessionId: first.id, activity }]); + expect(exits).toEqual([ + { + requestId: 0, + type: "session-exited", + sessionId: first.id, + exitCode: 7, + exitSignal: null, + requestedTermination: null, + exitOutcome: "crashed", + }, + ]); + expect(runtime.sessionMetadata(first.handle)).toMatchObject({ + title: "host title", + activity, + exited: true, + exitCode: 7, + exitOutcome: "crashed", + }); + + runtime.applySessionEvent({ type: "updated", session: { ...first, title: "resurrected" } }); + runtime.applySessionEvent({ + type: "exited", + sessionId: first.id, + exitCode: 0, + exitSignal: null, + requestedTermination: null, + exitOutcome: "completed", + }); + expect(order).toHaveLength(5); + runtime.applySessionEvent({ type: "removed", sessionId: first.id }); + expect(runtime.sessionMetadata(first.handle)).toBeUndefined(); + expect(terminate).not.toHaveBeenCalled(); + + runtime.dispose(); + }); + + it("emits the first explicit exit even when a full summary observed the exit first", () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + 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, + }, + workerFactory: () => worker as unknown as Worker, + }); + const first = session("session-a", "11"); + runtime.registerSession(first); + const order: string[] = []; + runtime.addEventListener("session-metadata", () => order.push("metadata")); + runtime.addEventListener("session-exited", () => order.push("exited")); + const exit = { + exitCode: 0, + exitSignal: null, + requestedTermination: null, + exitOutcome: "completed" as const, + }; + + runtime.applySessionEvent({ type: "updated", session: { ...first, exited: true, ...exit } }); + expect(order).toEqual(["metadata"]); + runtime.applySessionEvent({ type: "exited", sessionId: first.id, ...exit }); + expect(order).toEqual(["metadata", "metadata", "exited"]); + runtime.applySessionEvent({ type: "exited", sessionId: first.id, ...exit }); + expect(order).toEqual(["metadata", "metadata", "exited"]); + + runtime.dispose(); + }); + + it("forwards authenticated host extension messages with their routed identity", async () => { + vi.stubGlobal("window", globalThis); + const worker = new FakeWorker(); + const sockets: FakeSocket[] = []; + const onExtensionMessage = vi.fn(); + const runtime = new GhostteaTerminalRuntime({ + transport: "routed", + host: { + openTicket: async () => ticket("cell-a", "session-a", "set-a"), + onExtensionMessage, + }, + 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("ConnectionAccepted", { + selectedProtocolVersion: { major: 1, minor: 0 }, + connectionSetId: "set-a", + channel: "control", + legGeneration: 1, + heartbeatTtlMs: 15_000, + protocolLimits: DEFAULT_ROUTED_PROTOCOL_LIMITS, + capabilities: ["resume"], + }), + ); + const message = { type: "HostSessionNotice", sessionId: first.id, revision: 4 }; + sockets[0]!.receive(JSON.stringify(message)); + + expect(onExtensionMessage).toHaveBeenCalledOnce(); + expect(onExtensionMessage).toHaveBeenCalledWith(message, { + cellBootId: "cell-a", + connectionSetId: "set-a", + channel: "control", + }); + expect(sockets[0]!.readyState).toBe(1); + + runtime.dispose(); + }); + it("keeps routed input closed when T1 has no negotiated input encoder", async () => { vi.stubGlobal("window", globalThis); const worker = new FakeWorker(); diff --git a/packages/ghosttea-react/src/runtime.ts b/packages/ghosttea-react/src/runtime.ts index 36b6d59f..0c97ba26 100644 --- a/packages/ghosttea-react/src/runtime.ts +++ b/packages/ghosttea-react/src/runtime.ts @@ -10,6 +10,7 @@ import { isValidScrollbackBytes, type ConfigSnapshot, type CreateSessionOptions, + type ExitOutcome, type RemoteControllerInfo, type RemoteHostSummary, type RemoteSessionLifecycle, @@ -38,9 +39,15 @@ import { type RoutedActivationEvent, type RoutedActivationState, } from "./routed-activation.js"; -import { RoutedControlTransport, type RoutedControlTransportEvent } from "./routed-control.js"; +import { + RoutedControlTransport, + type RoutedControlTransportEvent, + type RoutedExtensionMessageContext, +} from "./routed-control.js"; import type { RoutedFramesTransportEvent } from "./routed-frames.js"; +export type { RoutedExtensionMessageContext } from "./routed-control.js"; + export interface GhostteaRendererPorts { control: MessagePort; frames: MessagePort; @@ -84,6 +91,19 @@ export interface RoutedTerminalInputContext { operation: RoutedTerminalInputOperation; } +export type RoutedSessionEvent = + | { type: "updated"; session: SessionSummary } + | { type: "activity-changed"; sessionId: string; activity: SessionActivity } + | { + type: "exited"; + sessionId: string; + exitCode: number | null; + exitSignal: string | null; + requestedTermination: TerminationSource | null; + exitOutcome: ExitOutcome; + } + | { type: "removed"; sessionId: string }; + export interface GhostteaRoutedHost { openTicket( sessionId: string, @@ -95,6 +115,7 @@ export interface GhostteaRoutedHost { requestId: string; }): Promise<{ attachGrant: RoutedSessionAttachGrant }>; listSessions?(): Promise; + getSession?(sessionId: string): Promise; createSession?(options: CreateSessionOptions): Promise; terminate?(sessionId: string, source: TerminationSource): void | Promise; /** @@ -102,6 +123,8 @@ export interface GhostteaRoutedHost { * no terminal-input tag, so routed input stays closed when this is absent. */ encodeInput?(context: RoutedTerminalInputContext): Readonly> | null; + /** Receives negotiated host-owned messages from an authenticated routed control leg. */ + onExtensionMessage?(message: Readonly>, context: RoutedExtensionMessageContext): void; } export interface GhostteaRoutedTerminalRuntimeOptions extends GhostteaTerminalRuntimeBaseOptions { @@ -326,6 +349,11 @@ export class GhostteaTerminalRuntime extends EventTarget { #configSnapshot: ConfigSnapshot | undefined; #configProtocolSupported = false; readonly #metadataTimers = new Map(); + readonly #metadataRefreshes = new Map>(); + readonly #metadataRefreshPending = new Set(); + readonly #sessionGenerationByHandle = new Map(); + readonly #appliedRoutedExitEvents = new Set(); + #sessionGeneration = 0; readonly #resync: FrameResyncController; #performanceRequestId = 1; readonly #performanceRequests = new Map< @@ -355,6 +383,7 @@ export class GhostteaTerminalRuntime extends EventTarget { options.transport === "routed" ? new RoutedControlTransport({ ...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }), + acceptExtensionMessages: options.host.onExtensionMessage !== undefined, emit: (event) => this.#handleRoutedControlEvent(event), }) : undefined; @@ -400,6 +429,7 @@ export class GhostteaTerminalRuntime extends EventTarget { } else if (data.type === "frame-resync-complete") { this.#resync.complete(data.sessionHandle); } else if (data.type === "frame-committed") { + if (this.#routedHost?.getSession) this.#scheduleMetadataRefresh(data.sessionHandle); this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot); } else if (data.type === "catalog-pressure") { console.warn( @@ -528,21 +558,7 @@ export class GhostteaTerminalRuntime extends EventTarget { this.#control = new ControlClient(ports.control); this.#control.addEventListener("session-exited", (event) => { const detail = (event as CustomEvent>).detail; - const handle = this.#handleBySessionId.get(detail.sessionId); - const session = handle ? this.#sessionByHandle.get(handle) : undefined; - if (!handle || !session) return; - this.#cancelMetadataRefresh(handle); - const exited = { - ...session, - exited: true, - exitCode: detail.exitCode, - exitSignal: detail.exitSignal, - requestedTermination: detail.requestedTermination, - exitOutcome: detail.exitOutcome, - }; - this.#sessionByHandle.set(handle, exited); - this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited })); - this.dispatchEvent(new CustomEvent("session-exited", { detail })); + this.#applySessionExited(detail); }); this.#control.addEventListener("events-lost", () => { // The daemon dropped events faster than this client drained them. Any @@ -552,13 +568,7 @@ export class GhostteaTerminalRuntime extends EventTarget { }); this.#control.addEventListener("session-activity-changed", (event) => { const detail = (event as CustomEvent>).detail; - const handle = this.#handleBySessionId.get(detail.sessionId); - const session = handle ? this.#sessionByHandle.get(handle) : undefined; - if (!handle || !session || session.exited) return; - const updated = { ...session, activity: detail.activity }; - this.#sessionByHandle.set(handle, updated); - this.dispatchEvent(new CustomEvent("session-activity", { detail })); - this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated })); + this.#applySessionActivity(detail); }); this.#control.addEventListener("control-changed", (event) => { const detail = (event as CustomEvent>).detail; @@ -723,43 +733,143 @@ export class GhostteaTerminalRuntime extends EventTarget { if (this.#frameFlowControlEnabled) this.#frames?.postMessage({ type: "frame-credit", bytes }); } + #bumpSessionGeneration(sessionHandle: string): number { + this.#sessionGeneration += 1; + this.#sessionGenerationByHandle.set(sessionHandle, this.#sessionGeneration); + return this.#sessionGeneration; + } + + #applySessionExited(detail: Extract): void { + const handle = this.#handleBySessionId.get(detail.sessionId); + const session = handle ? this.#sessionByHandle.get(handle) : undefined; + if (!handle || !session) return; + this.#cancelMetadataRefresh(handle); + const exited = { + ...session, + exited: true, + exitCode: detail.exitCode, + exitSignal: detail.exitSignal, + requestedTermination: detail.requestedTermination, + exitOutcome: detail.exitOutcome, + }; + this.#sessionByHandle.set(handle, exited); + this.#bumpSessionGeneration(handle); + this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited })); + this.dispatchEvent(new CustomEvent("session-exited", { detail })); + } + + #applySessionActivity(detail: Extract): void { + const handle = this.#handleBySessionId.get(detail.sessionId); + const session = handle ? this.#sessionByHandle.get(handle) : undefined; + if (!handle || !session || session.exited) return; + const updated = { ...session, activity: detail.activity }; + this.#sessionByHandle.set(handle, updated); + this.#bumpSessionGeneration(handle); + this.dispatchEvent(new CustomEvent("session-activity", { detail })); + this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated })); + } + + #applyRoutedSessionSummary(sessionHandle: string, next: SessionSummary, expectedGeneration?: number): boolean { + const previous = this.#sessionByHandle.get(sessionHandle); + if ( + !previous || + previous.exited || + previous.id !== next.id || + next.handle !== sessionHandle || + this.#handleBySessionId.get(next.id) !== sessionHandle || + (expectedGeneration !== undefined && this.#sessionGenerationByHandle.get(sessionHandle) !== expectedGeneration) + ) { + return false; + } + this.#sessionByHandle.set(sessionHandle, next); + this.#bumpSessionGeneration(sessionHandle); + if ( + previous.title !== next.title || + previous.cwd !== next.cwd || + previous.exited !== next.exited || + !sameSessionActivity(previous.activity, next.activity) + ) { + this.dispatchEvent(new CustomEvent("session-metadata", { detail: next })); + } + return true; + } + #scheduleMetadataRefresh(sessionHandle: string): void { const scheduledSession = this.#sessionByHandle.get(sessionHandle); if (!scheduledSession || scheduledSession.exited) return; + if (this.#routedHost?.getSession && this.#metadataRefreshes.has(sessionHandle)) { + this.#metadataRefreshPending.add(sessionHandle); + return; + } if (this.#metadataTimers.has(sessionHandle)) return; const timer = window.setTimeout(() => { this.#metadataTimers.delete(sessionHandle); const session = this.#sessionByHandle.get(sessionHandle); - if (!session || session.exited || !this.#control) return; - void this.#control - .request({ type: "get-session", sessionId: session.id }) - .then((response) => { - if (response.type !== "session") return; - const previous = this.#sessionByHandle.get(sessionHandle); - if (!previous || previous.id !== response.session.id || previous.exited) return; - this.#sessionByHandle.set(sessionHandle, response.session); - if ( - previous.title !== response.session.title || - previous.cwd !== response.session.cwd || - previous.exited !== response.session.exited || - !sameSessionActivity(previous.activity, response.session.activity) - ) { - this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session })); - } - }) - .catch((error) => { - const current = this.#sessionByHandle.get(sessionHandle); - if (!current || current.exited || this.#disposed) return; - console.warn("[terminal-runtime] session metadata refresh failed", error); - }); + if (!session || session.exited) return; + if (this.#control) { + void this.#control + .request({ type: "get-session", sessionId: session.id }) + .then((response) => { + if (response.type !== "session") return; + const previous = this.#sessionByHandle.get(sessionHandle); + if (!previous || previous.id !== response.session.id || previous.exited) return; + this.#sessionByHandle.set(sessionHandle, response.session); + if ( + previous.title !== response.session.title || + previous.cwd !== response.session.cwd || + previous.exited !== response.session.exited || + !sameSessionActivity(previous.activity, response.session.activity) + ) { + this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session })); + } + }) + .catch((error) => { + const current = this.#sessionByHandle.get(sessionHandle); + if (!current || current.exited || this.#disposed) return; + console.warn("[terminal-runtime] session metadata refresh failed", error); + }); + return; + } + if (this.#routedHost?.getSession) this.#startRoutedMetadataRefresh(sessionHandle, session); }, 200); this.#metadataTimers.set(sessionHandle, timer); } + #startRoutedMetadataRefresh(sessionHandle: string, session: SessionSummary): void { + const host = this.#routedHost; + const getSession = host?.getSession; + if (!host || !getSession || this.#disposed) return; + if (this.#metadataRefreshes.has(sessionHandle)) { + this.#metadataRefreshPending.add(sessionHandle); + return; + } + const expectedGeneration = this.#sessionGenerationByHandle.get(sessionHandle); + const refresh = Promise.resolve() + .then(() => getSession.call(host, session.id)) + .then((next) => { + if (next === null) return; + this.#applyRoutedSessionSummary(sessionHandle, next, expectedGeneration); + }) + .catch((error) => { + const current = this.#sessionByHandle.get(sessionHandle); + if (!current || current.exited || this.#disposed) return; + console.warn("[terminal-runtime] session metadata refresh failed", error); + }) + .finally(() => { + if (this.#metadataRefreshes.get(sessionHandle) !== refresh) return; + this.#metadataRefreshes.delete(sessionHandle); + if (!this.#metadataRefreshPending.delete(sessionHandle) || this.#disposed) return; + const current = this.#sessionByHandle.get(sessionHandle); + if (current && !current.exited) this.#scheduleMetadataRefresh(sessionHandle); + }); + this.#metadataRefreshes.set(sessionHandle, refresh); + } + #cancelMetadataRefresh(sessionHandle: string): void { const timer = this.#metadataTimers.get(sessionHandle); if (timer !== undefined) window.clearTimeout(timer); this.#metadataTimers.delete(sessionHandle); + this.#metadataRefreshPending.delete(sessionHandle); } sessionMetadata(sessionHandle: string): SessionSummary | undefined { @@ -767,8 +877,48 @@ export class GhostteaTerminalRuntime extends EventTarget { } registerSession(session: SessionSummary): void { + const previous = this.#sessionByHandle.get(session.handle); + if (!previous || previous.id !== session.id || !previous.exited || !session.exited) { + this.#appliedRoutedExitEvents.delete(session.handle); + } this.#sessionByHandle.set(session.handle, session); this.#handleBySessionId.set(session.id, session.handle); + this.#bumpSessionGeneration(session.handle); + } + + applySessionEvent(event: RoutedSessionEvent): void { + if (this.#disposed) return; + if (event.type === "updated") { + const handle = this.#handleBySessionId.get(event.session.id); + if (handle) this.#applyRoutedSessionSummary(handle, event.session); + return; + } + if (event.type === "activity-changed") { + this.#applySessionActivity({ + requestId: 0, + type: "session-activity-changed", + sessionId: event.sessionId, + activity: event.activity, + }); + return; + } + if (event.type === "exited") { + const handle = this.#handleBySessionId.get(event.sessionId); + const current = handle ? this.#sessionByHandle.get(handle) : undefined; + if (!handle || !current || this.#appliedRoutedExitEvents.has(handle)) return; + this.#appliedRoutedExitEvents.add(handle); + this.#applySessionExited({ + requestId: 0, + type: "session-exited", + sessionId: event.sessionId, + exitCode: event.exitCode, + exitSignal: event.exitSignal, + requestedTermination: event.requestedTermination, + exitOutcome: event.exitOutcome, + }); + return; + } + this.unregisterSession(event.sessionId); } #queueFrameSubscriptionSync(): Promise { @@ -1468,6 +1618,16 @@ export class GhostteaTerminalRuntime extends EventTarget { #handleRoutedControlEvent(event: RoutedControlTransportEvent): void { if (this.#disposed) return; + if (event.type === "extension-message") { + const host = this.#routedHost; + if (!host?.onExtensionMessage) return; + try { + host.onExtensionMessage(event.message, event.context); + } catch (error) { + console.error("[terminal-runtime] routed extension handler failed", error); + } + return; + } if (event.type === "transport-ready") { const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs; this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline); @@ -2716,6 +2876,8 @@ export class GhostteaTerminalRuntime extends EventTarget { } } this.#sessionByHandle.delete(handle); + this.#sessionGenerationByHandle.delete(handle); + this.#appliedRoutedExitEvents.delete(handle); this.#handleBySessionId.delete(sessionId); this.#remoteSessions.delete(sessionId); this.#controlBySession.delete(sessionId); @@ -2792,6 +2954,10 @@ export class GhostteaTerminalRuntime extends EventTarget { this.#frameSubscriptionRequests.clear(); for (const timer of this.#metadataTimers.values()) window.clearTimeout(timer); this.#metadataTimers.clear(); + this.#metadataRefreshPending.clear(); + this.#metadataRefreshes.clear(); + this.#sessionGenerationByHandle.clear(); + this.#appliedRoutedExitEvents.clear(); this.#resync.dispose(); for (const request of this.#performanceRequests.values()) { window.clearTimeout(request.timer); diff --git a/packages/ghosttea/package.json b/packages/ghosttea/package.json index c896fe32..065823ad 100644 --- a/packages/ghosttea/package.json +++ b/packages/ghosttea/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttea", - "version": "0.11.0", + "version": "0.11.1", "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.11.0" + "@vibecook/ghosttea-protocol": "0.11.1" }, "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 f25ef7d5..8ddb72ea 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.11.0", + "version": "0.11.1", "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 f96edec4..9bb4dcce 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.11.0", + "version": "0.11.1", "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 60bc2930..a383cc84 100644 --- a/packages/ghosttead/package.json +++ b/packages/ghosttead/package.json @@ -1,6 +1,6 @@ { "name": "@vibecook/ghosttead", - "version": "0.11.0", + "version": "0.11.1", "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.11.0", - "@vibecook/ghosttead-win32-x64": "0.11.0" + "@vibecook/ghosttead-darwin-arm64": "0.11.1", + "@vibecook/ghosttead-win32-x64": "0.11.1" }, "devDependencies": { "@types/node": "26.1.1", From 4b7dcad2d18639614c052ba2ddd2660643acfd5f Mon Sep 17 00:00:00 2001 From: James Yong Date: Sun, 23 Aug 2026 20:36:43 -0700 Subject: [PATCH 2/2] release: pin 0.11.1 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 47c1994b..2a497749 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-882149c68985/GhostteaAppleNative.xcframework.zip" -let appleNativeChecksum = "9cff2c6a921d1ace47e36cd182b124692cdd8aaa785fab89cac5bc28788cd1ad" + "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-3f583406767d/GhostteaAppleNative.xcframework.zip" +let appleNativeChecksum = "a455b785444a9c92aa9e98d43fca274e61e4cf7b00a38ed78872e39408d91e98" // 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 cb811148..abba3f85 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-882149c68985", + "tag": "ghosttea-apple-native-3f583406767d", "filename": "GhostteaAppleNative.xcframework.zip", - "url": "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-882149c68985/GhostteaAppleNative.xcframework.zip", - "checksum": "9cff2c6a921d1ace47e36cd182b124692cdd8aaa785fab89cac5bc28788cd1ad", - "size": 45183838, - "contentDigest": "882149c68985683dac91ba81da13ed6b4af47e946fbd3dee4a27868d3a983804", - "sourceDigest": "6e1a5ffa1ee341065880304771ee0ac1e2a953681691b6e66a0f53a0b9094a40", + "url": "https://github.com/vibecook-dev/ghosttea/releases/download/ghosttea-apple-native-3f583406767d/GhostteaAppleNative.xcframework.zip", + "checksum": "a455b785444a9c92aa9e98d43fca274e61e4cf7b00a38ed78872e39408d91e98", + "size": 45183980, + "contentDigest": "3f583406767dcce51416022990a3a2227041864e4faa93f0067250b3687d6d9b", + "sourceDigest": "4452541602c83ee99d649e153a116b5bd2ba834051dd71693ce741ac3514ce67", "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-22.", + "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-23.", "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-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)." + "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-23, 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-882149c68985, the 0.11.0 artifact. The 0.11.1 workspace version bump moves the native source digest through Cargo.toml and Cargo.lock, 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/32686016483 from clean source commit 5337bcffc2a4643b7d0b688d96b4bbdc55865416 (release/0.11.1), promoted through the protected release environment. Attestation: https://github.com/vibecook-dev/ghosttea/attestations/42489535 (subject sha256:a455b785444a9c92aa9e98d43fca274e61e4cf7b00a38ed78872e39408d91e98)." }, "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": "6759c92935316ece6d8e1c1e2d180ccd3d48f893c904e4287ad3438cf4c0bb7c", - "ios-arm64/libghosttea-apple-native.a": "ed6dcd4397b006292f29d3a3db38d663c282f3ca8d9aa8f7a60beeaf95719a7d", - "macos-arm64/libghosttea-apple-native.a": "2cb042a37adaa31b4e1485158862215701b88c60ce64ef0d65cd76c2554b8914" + "ios-arm64-simulator/libghosttea-apple-native.a": "fc561cf1f7316478661fa4a362faeb7b85bb8c37a1bcebe86bb0b7b2a3eb368d", + "ios-arm64/libghosttea-apple-native.a": "6b77fe2958634bf2701b291189efdf88e0bf4525a656e5bba71403146a343864", + "macos-arm64/libghosttea-apple-native.a": "944707b7e725d1234dec40b576b52843a4401f9bb451c8709aec3e2c40218c32" } }