feat!: use runa-debug WebSockets with live image notifications
Replace plain TCP JSON-lines connections with bounded loopback WebSockets using the required runa-debug subprotocol. Keep single-threaded image dispatch and stdio framing unchanged. Broadcast coalesced, non-destructive image_changed invalidations after successful mutations. Update the REPL client and automatically refresh VS Code actor trees and open inspectors, preserving request correlation and connection-session isolation. Validate negotiation, message limits and fragmentation, multi-client notifications, real REPL interoperability, and automatic mailbox/lifecycle/failure inspection in a VS Code extension host. BREAKING CHANGE: the network endpoint now accepts only runa-debug WebSockets at the root path, not plain TCP JSON Lines. Rust network integrations must use WebSocketClient and serve_websocket. Request/response DTOs remain protocol v3; stdio is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
8ce6ca9a2a
commit
72a37b178c
|
|
@ -68,8 +68,8 @@ proposed capabilities as plans, not existing behavior.
|
||||||
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions.
|
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions.
|
||||||
- `crates/runa-image-api/`: trusted in-process services for operating on an image.
|
- `crates/runa-image-api/`: trusted in-process services for operating on an image.
|
||||||
- `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs.
|
- `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs.
|
||||||
- `crates/runa-server/`: protocol dispatch and local JSON-lines stdio transport.
|
- `crates/runa-server/`: protocol dispatch, JSON-lines stdio, and loopback WebSockets.
|
||||||
- `crates/runa-client/`: local child-process client for the JSON-lines protocol.
|
- `crates/runa-client/`: stdio child-process and runa-debug WebSocket clients.
|
||||||
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.
|
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.
|
||||||
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
|
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
|
||||||
- `docs/decisions/`: architectural decisions.
|
- `docs/decisions/`: architectural decisions.
|
||||||
|
|
@ -103,7 +103,7 @@ are required; install the locked extension dependencies with
|
||||||
`npm --prefix vscode ci`.
|
`npm --prefix vscode ci`.
|
||||||
|
|
||||||
The workspace tests cover execution probes, actor runtime invariants, and the
|
The workspace tests cover execution probes, actor runtime invariants, and the
|
||||||
interactive CLI. Extension tests include a real Rust-server TCP round trip.
|
interactive CLI. Extension tests include a real Rust-server WebSocket round trip.
|
||||||
`mise run start` opens the local CLI. The separate `mise run test-extension-host`
|
`mise run start` opens the local CLI. The separate `mise run test-extension-host`
|
||||||
task verifies inspector commands in VS Code and requires a display (or
|
task verifies inspector commands in VS Code and requires a display (or
|
||||||
`xvfb-run -a mise run test-extension-host` on headless Linux).
|
`xvfb-run -a mise run test-extension-host` on headless Linux).
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Non-destructive `image_changed` server notifications with coalesced
|
||||||
|
per-connection revisions and automatic VS Code tree/inspector refresh.
|
||||||
|
- Tests for WebSocket subprotocol negotiation, invalid messages, multi-client
|
||||||
|
notifications, and automatic mailbox/lifecycle/failure inspector updates.
|
||||||
- Initial TypeScript VS Code extension with explicit localhost connection
|
- Initial TypeScript VS Code extension with explicit localhost connection
|
||||||
management, an actor explorer, and read-only state/mailbox/execution snapshots.
|
management, an actor explorer, and read-only state/mailbox/execution snapshots.
|
||||||
- Bounded protocol-v3 extension client with lossless 64-bit JSON integers,
|
- Bounded protocol-v3 extension client with lossless 64-bit JSON integers,
|
||||||
|
|
@ -86,6 +90,9 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Replaced the plain TCP JSON-lines transport with loopback-only WebSockets
|
||||||
|
using the required `runa-debug` subprotocol in the server, REPL, and VS Code.
|
||||||
|
Stdio framing and version 3 request/response shapes are unchanged.
|
||||||
- Renamed the local REPL mise task from `run` to `start`.
|
- Renamed the local REPL mise task from `run` to `start`.
|
||||||
- Protocol v3 and Rune's two-argument `handle(state, message)` replace the
|
- Protocol v3 and Rune's two-argument `handle(state, message)` replace the
|
||||||
temporary integer-only state and kind/value/target_ref message envelope.
|
temporary integer-only state and kind/value/target_ref message envelope.
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,24 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
|
|
@ -107,6 +122,15 @@ version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -122,6 +146,32 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endian-type"
|
name = "endian-type"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
|
@ -167,6 +217,23 @@ version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
|
|
@ -180,6 +247,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
|
|
@ -200,6 +269,16 @@ dependencies = [
|
||||||
"windows-result",
|
"windows-result",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -211,6 +290,18 @@ dependencies = [
|
||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "home"
|
name = "home"
|
||||||
version = "0.5.12"
|
version = "0.5.12"
|
||||||
|
|
@ -220,6 +311,22 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
|
|
@ -278,6 +385,17 @@ version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "musli"
|
name = "musli"
|
||||||
version = "0.0.124"
|
version = "0.0.124"
|
||||||
|
|
@ -316,7 +434,7 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
|
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand",
|
"rand 0.8.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -490,6 +608,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "radix_trie"
|
name = "radix_trie"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -507,8 +631,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -518,7 +652,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -527,7 +671,16 @@ version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -557,6 +710,7 @@ dependencies = [
|
||||||
"runa-server",
|
"runa-server",
|
||||||
"rustyline",
|
"rustyline",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -572,6 +726,7 @@ version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runa-protocol",
|
"runa-protocol",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -634,12 +789,15 @@ dependencies = [
|
||||||
name = "runa-server"
|
name = "runa-server"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
"runa-actor",
|
"runa-actor",
|
||||||
"runa-client",
|
"runa-client",
|
||||||
"runa-code",
|
"runa-code",
|
||||||
"runa-image-api",
|
"runa-image-api",
|
||||||
"runa-protocol",
|
"runa-protocol",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -831,6 +989,17 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -864,6 +1033,16 @@ version = "1.16.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
|
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
|
|
@ -901,6 +1080,26 @@ dependencies = [
|
||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
|
|
@ -910,6 +1109,44 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.54.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -959,12 +1196,35 @@ dependencies = [
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "twox-hash"
|
name = "twox-hash"
|
||||||
version = "2.1.5"
|
version = "2.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
|
|
@ -989,6 +1249,12 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -1013,6 +1279,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.4+wasi-0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
|
|
@ -1119,6 +1394,12 @@ version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.63"
|
version = "0.8.63"
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -22,9 +22,10 @@ registry, and the CLI exposes publishing, history, rollback, safe-boundary
|
||||||
installation, pause/resume, and cancellation through a trusted in-process image
|
installation, pause/resume, and cancellation through a trusted in-process image
|
||||||
API. A versioned transport-neutral protocol contract is now defined, but no
|
API. A versioned transport-neutral protocol contract is now defined, but no
|
||||||
authentication is implemented. The dispatcher is available through local
|
authentication is implemented. The dispatcher is available through local
|
||||||
JSON-lines stdio and an unauthenticated TCP listener restricted to localhost;
|
JSON-lines stdio and an unauthenticated WebSocket listener restricted to localhost;
|
||||||
both use a bounded request size. The interactive CLI connects to the localhost
|
both use a bounded request size. The interactive CLI and VS Code connect with
|
||||||
server over TCP, so multiple clients operate on the same server-owned image.
|
the `runa-debug` WebSocket subprotocol, so clients share one server-owned image.
|
||||||
|
Server change notifications automatically refresh VS Code's actor tree and inspectors.
|
||||||
|
|
||||||
The prototype uses worker-local invocation stacks and cooperative instruction
|
The prototype uses worker-local invocation stacks and cooperative instruction
|
||||||
slices, and actor snapshots report basic active-execution progress. Native
|
slices, and actor snapshots report basic active-execution progress. Native
|
||||||
|
|
@ -70,22 +71,22 @@ For a local child-process protocol endpoint, run `mise run serve` and exchange
|
||||||
one JSON request per line on stdin; responses are written to stdout. Request
|
one JSON request per line on stdin; responses are written to stdout. Request
|
||||||
lines are limited to 1 MiB.
|
lines are limited to 1 MiB.
|
||||||
|
|
||||||
For a TCP endpoint, run `mise run serve-network`. It listens on
|
For a WebSocket endpoint, run `mise run serve-network`. It listens on
|
||||||
`127.0.0.1:3737` and accepts the same JSON-lines protocol, with up to 16
|
`ws://127.0.0.1:3737/` and requires the `runa-debug` subprotocol, with up to 16
|
||||||
simultaneous clients sharing one live image. The listener rejects non-loopback
|
simultaneous clients sharing one live image. The listener rejects non-loopback
|
||||||
addresses and has no authentication; any local process that can connect has
|
addresses and has no authentication; any local process that can connect has
|
||||||
full access to the development API. Use it only on a trusted local machine.
|
full access to the development API. Use it only on a trusted local machine.
|
||||||
|
|
||||||
The `runa-client` crate can launch the stdio process or connect over loopback
|
The `runa-client` crate can launch the stdio process or connect over loopback
|
||||||
TCP and exchange typed protocol requests with request/response correlation.
|
WebSockets and exchange typed protocol requests with request/response correlation.
|
||||||
TCP connection establishment is limited to 5 seconds and reads/writes to 30
|
Network connection establishment is limited to 5 seconds and response waits/writes to 30
|
||||||
seconds. Transport failures disconnect the client and are never automatically
|
seconds. Transport failures disconnect the client and are never automatically
|
||||||
retried: an operation may have committed even if its response was lost.
|
retried: an operation may have committed even if its response was lost.
|
||||||
The VS Code extension connects over the network to this same live image; actors remain in
|
The VS Code extension connects over the network to this same live image; actors remain in
|
||||||
the image regardless of how clients connect. Remote network access requires
|
the image regardless of how clients connect. Remote network access requires
|
||||||
authentication and authorization, which are not implemented.
|
authentication and authorization, which are not implemented.
|
||||||
|
|
||||||
The TCP server publishes the bundled `counter` behavior once at startup;
|
The WebSocket server publishes the bundled `counter` behavior once at startup;
|
||||||
connecting a client does not publish or reset behavior revisions:
|
connecting a client does not publish or reset behavior revisions:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -132,11 +133,12 @@ open this repository and launch **Run Runa Extension** from Run and Debug
|
||||||
(F5). Start `mise run serve-network`, then use **Runa: Connect to Local Image**
|
(F5). Start `mise run serve-network`, then use **Runa: Connect to Local Image**
|
||||||
in the Extension Development Host. The **Runa Actors** view in Explorer lists
|
in the Extension Development Host. The **Runa Actors** view in Explorer lists
|
||||||
actors created through the REPL or another client. Click an actor to inspect
|
actors created through the REPL or another client. Click an actor to inspect
|
||||||
it, and use **Runa: Refresh Actors** to fetch current snapshots.
|
it. Server notifications automatically refresh the tree and open snapshots;
|
||||||
|
**Runa: Refresh Actors** is also available on demand.
|
||||||
|
|
||||||
The extension does not start, tick, or stop the image. Disconnecting leaves
|
The extension does not start, tick, or stop the image. Disconnecting leaves
|
||||||
actors running in the server. Editing, spawn/send controls, and event
|
actors running in the server. Editing, spawn/send controls, and detailed runtime
|
||||||
subscriptions are not implemented yet. See the [extension guide](vscode/README.md)
|
event subscriptions are not implemented yet. See the [extension guide](vscode/README.md)
|
||||||
for connection restrictions, snapshot semantics, and tests.
|
for connection restrictions, snapshot semantics, and tests.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
@ -160,7 +162,7 @@ Tombi formats TOML files offline; rustfmt formats all workspace Rust code;
|
||||||
Prettier formats the extension. Clippy checks all workspace targets with
|
Prettier formats the extension. Clippy checks all workspace targets with
|
||||||
warnings treated as errors. Extension checks include strict TypeScript and
|
warnings treated as errors. Extension checks include strict TypeScript and
|
||||||
Prettier. The test task runs the Rust workspace tests, extension unit tests,
|
Prettier. The test task runs the Rust workspace tests, extension unit tests,
|
||||||
and a TypeScript-to-Rust TCP interoperability test.
|
and a TypeScript-to-Rust WebSocket interoperability test.
|
||||||
`mise run test-extension-host` additionally downloads VS Code 1.96.4 into an
|
`mise run test-extension-host` additionally downloads VS Code 1.96.4 into an
|
||||||
ignored test cache and tests the actual inspector commands in an isolated
|
ignored test cache and tests the actual inspector commands in an isolated
|
||||||
Extension Development Host. It requires a graphical display; on headless Linux,
|
Extension Development Host. It requires a graphical display; on headless Linux,
|
||||||
|
|
@ -181,7 +183,7 @@ use `xvfb-run -a mise run test-extension-host`.
|
||||||
│ ├── runa-image-api/ # Trusted in-process image operations
|
│ ├── runa-image-api/ # Trusted in-process image operations
|
||||||
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
|
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
|
||||||
│ ├── runa-server/ # Protocol dispatcher and local JSON-lines transport
|
│ ├── runa-server/ # Protocol dispatcher and local JSON-lines transport
|
||||||
│ └── runa-client/ # Stdio child-process and loopback TCP clients
|
│ └── runa-client/ # Stdio child-process and loopback WebSocket clients
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── architecture.md
|
│ ├── architecture.md
|
||||||
│ ├── protocol.md
|
│ ├── protocol.md
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ edition = "2024"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
runa-protocol = { path = "../runa-protocol" }
|
runa-protocol = { path = "../runa-protocol" }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tungstenite = "0.28"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
//! Stdio and loopback TCP clients for the versioned JSON-lines protocol.
|
//! Stdio and loopback WebSocket clients for the versioned protocol.
|
||||||
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{self, BufRead, BufReader, BufWriter, Write};
|
use std::io::{self, BufRead, BufReader, BufWriter, Write};
|
||||||
use std::net::{Shutdown, SocketAddr, TcpStream};
|
use std::net::{Shutdown, SocketAddr, TcpStream};
|
||||||
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
|
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use runa_protocol::{Command as ProtocolCommand, PROTOCOL_VERSION, Request, Response};
|
use runa_protocol::{
|
||||||
|
Command as ProtocolCommand, ImageEvent, Notification, PROTOCOL_VERSION, Request, Response,
|
||||||
|
WEBSOCKET_SUBPROTOCOL,
|
||||||
|
};
|
||||||
|
use tungstenite::client::IntoClientRequest;
|
||||||
|
use tungstenite::{Message, WebSocket, protocol::WebSocketConfig};
|
||||||
|
|
||||||
pub const MAX_MESSAGE_LINE_BYTES: usize = 1024 * 1024;
|
pub const MAX_MESSAGE_LINE_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -22,12 +27,14 @@ pub struct ProtocolClient<R, W> {
|
||||||
next_request_id: u64,
|
next_request_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TcpClient {
|
pub struct WebSocketClient {
|
||||||
client: ProtocolClient<BufReader<TcpStream>, BufWriter<TcpStream>>,
|
socket: WebSocket<TcpStream>,
|
||||||
|
next_request_id: u64,
|
||||||
|
revision: u64,
|
||||||
disconnected: bool,
|
disconnected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TcpClient {
|
impl WebSocketClient {
|
||||||
/// Connect only to loopback, without authentication or automatic retries.
|
/// Connect only to loopback, without authentication or automatic retries.
|
||||||
pub fn connect(address: SocketAddr) -> io::Result<Self> {
|
pub fn connect(address: SocketAddr) -> io::Result<Self> {
|
||||||
if !address.ip().is_loopback() {
|
if !address.ip().is_loopback() {
|
||||||
|
|
@ -37,13 +44,43 @@ impl TcpClient {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5))?;
|
let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5))?;
|
||||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||||
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
|
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||||
|
let mut request = format!("ws://{address}/")
|
||||||
|
.into_client_request()
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
request.headers_mut().insert(
|
||||||
|
"sec-websocket-protocol",
|
||||||
|
WEBSOCKET_SUBPROTOCOL.parse().map_err(io::Error::other)?,
|
||||||
|
);
|
||||||
|
let config = WebSocketConfig::default()
|
||||||
|
.max_message_size(Some(MAX_MESSAGE_LINE_BYTES))
|
||||||
|
.max_frame_size(Some(MAX_MESSAGE_LINE_BYTES))
|
||||||
|
.max_write_buffer_size(2 * MAX_MESSAGE_LINE_BYTES);
|
||||||
|
let (socket, response) =
|
||||||
|
tungstenite::client::client_with_config(request, stream, Some(config))
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
if response
|
||||||
|
.headers()
|
||||||
|
.get("sec-websocket-protocol")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
!= Some(WEBSOCKET_SUBPROTOCOL)
|
||||||
|
{
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"server did not negotiate runa-debug",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
socket
|
||||||
|
.get_ref()
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||||
|
socket
|
||||||
|
.get_ref()
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(30)))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client: ProtocolClient::new(
|
socket,
|
||||||
BufReader::new(stream.try_clone()?),
|
next_request_id: 0,
|
||||||
BufWriter::new(stream),
|
revision: 0,
|
||||||
),
|
|
||||||
disconnected: false,
|
disconnected: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -52,19 +89,89 @@ impl TcpClient {
|
||||||
if self.disconnected {
|
if self.disconnected {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::NotConnected,
|
io::ErrorKind::NotConnected,
|
||||||
"TCP client is disconnected; reconnect explicitly",
|
"WebSocket client is disconnected; reconnect explicitly",
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let result = self.client.request(command);
|
let result = self.request_inner(command);
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
self.disconnected = true;
|
self.disconnected = true;
|
||||||
// A lost response may follow a committed operation. Never retry it
|
// A lost response may follow a committed operation. Never retry it
|
||||||
// or reuse a stream whose request/response alignment is uncertain.
|
// or reuse a stream whose request/response alignment is uncertain.
|
||||||
let _ = self.client.writer.get_ref().shutdown(Shutdown::Both);
|
let _ = self.socket.get_ref().shutdown(Shutdown::Both);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_inner(&mut self, command: ProtocolCommand) -> Result<Response, ClientError> {
|
||||||
|
self.next_request_id = self
|
||||||
|
.next_request_id
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ClientError::RequestIdsExhausted)?;
|
||||||
|
let request_id = format!("runa-client-{}", self.next_request_id);
|
||||||
|
let encoded = serde_json::to_string(&Request {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
request_id: request_id.clone(),
|
||||||
|
command,
|
||||||
|
})?;
|
||||||
|
if encoded.len() > MAX_MESSAGE_LINE_BYTES {
|
||||||
|
return Err(ClientError::RequestTooLarge);
|
||||||
|
}
|
||||||
|
self.socket
|
||||||
|
.send(Message::Text(encoded.into()))
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let remaining = deadline
|
||||||
|
.checked_duration_since(Instant::now())
|
||||||
|
.filter(|duration| !duration.is_zero())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::TimedOut, "WebSocket response timed out")
|
||||||
|
})?;
|
||||||
|
self.socket.get_ref().set_read_timeout(Some(remaining))?;
|
||||||
|
match self.socket.read().map_err(io::Error::other)? {
|
||||||
|
Message::Text(text) => {
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&text)?;
|
||||||
|
if value.get("event").is_some() {
|
||||||
|
let event: Notification = serde_json::from_value(value)?;
|
||||||
|
if event.version != PROTOCOL_VERSION {
|
||||||
|
return Err(ClientError::UnsupportedVersion(event.version));
|
||||||
|
}
|
||||||
|
let ImageEvent::ImageChanged { revision } = event.event;
|
||||||
|
if revision <= self.revision {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"non-increasing image notification revision",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
self.revision = revision;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let response: Response = serde_json::from_value(value)?;
|
||||||
|
if response.version != PROTOCOL_VERSION {
|
||||||
|
return Err(ClientError::UnsupportedVersion(response.version));
|
||||||
|
}
|
||||||
|
if response.request_id != request_id {
|
||||||
|
return Err(ClientError::CorrelationMismatch {
|
||||||
|
expected: request_id,
|
||||||
|
actual: response.request_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
Message::Ping(_) => self.socket.flush().map_err(io::Error::other)?,
|
||||||
|
Message::Pong(_) => {}
|
||||||
|
_ => {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"expected a WebSocket text protocol message",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -267,30 +374,115 @@ mod tests {
|
||||||
use runa_protocol::{ProtocolError, Reply};
|
use runa_protocol::{ProtocolError, Reply};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
struct DebugProtocol;
|
||||||
|
impl tungstenite::handshake::server::Callback for DebugProtocol {
|
||||||
|
fn on_request(
|
||||||
|
self,
|
||||||
|
_: &tungstenite::handshake::server::Request,
|
||||||
|
mut response: tungstenite::handshake::server::Response,
|
||||||
|
) -> Result<
|
||||||
|
tungstenite::handshake::server::Response,
|
||||||
|
tungstenite::handshake::server::ErrorResponse,
|
||||||
|
> {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"sec-websocket-protocol",
|
||||||
|
WEBSOCKET_SUBPROTOCOL.parse().unwrap(),
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn websocket_peer(
|
||||||
|
handler: impl FnOnce(WebSocket<TcpStream>, Request) + Send + 'static,
|
||||||
|
) -> (SocketAddr, std::thread::JoinHandle<()>) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let peer = std::thread::spawn(move || {
|
||||||
|
let (stream, _) = listener.accept().unwrap();
|
||||||
|
let mut socket = tungstenite::accept_hdr(stream, DebugProtocol).unwrap();
|
||||||
|
let message = socket.read().unwrap();
|
||||||
|
let request = serde_json::from_str(message.to_text().unwrap()).unwrap();
|
||||||
|
handler(socket, request);
|
||||||
|
});
|
||||||
|
(address, peer)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_client_rejects_non_loopback_addresses() {
|
fn websocket_client_validates_notifications_without_losing_response_correlation() {
|
||||||
let error = TcpClient::connect("192.0.2.1:3737".parse().unwrap())
|
let (address, peer) = websocket_peer(|mut socket, request| {
|
||||||
|
socket
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&Notification {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
event: ImageEvent::ImageChanged { revision: 42 },
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
socket
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&Response {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
request_id: request.request_id,
|
||||||
|
result: Ok(Reply::Actors(Vec::new())),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.request(ProtocolCommand::ListActors).unwrap().result,
|
||||||
|
Ok(Reply::Actors(Vec::new()))
|
||||||
|
);
|
||||||
|
assert_eq!(client.revision, 42);
|
||||||
|
peer.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_client_rejects_bad_versions_correlations_and_notifications() {
|
||||||
|
for message in [
|
||||||
|
serde_json::json!({"version": 2, "request_id": "runa-client-1",
|
||||||
|
"result": {"Ok": {"kind": "actors", "data": []}}}),
|
||||||
|
serde_json::json!({"version": 3, "request_id": "wrong",
|
||||||
|
"result": {"Ok": {"kind": "actors", "data": []}}}),
|
||||||
|
serde_json::json!({"version": 3, "event": {"event": "image_changed", "data": {"revision": 0}}}),
|
||||||
|
serde_json::json!({"version": 3, "event": {"event": "unknown"}}),
|
||||||
|
] {
|
||||||
|
let (address, peer) = websocket_peer(move |mut socket, _| {
|
||||||
|
socket
|
||||||
|
.send(Message::Text(message.to_string().into()))
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
|
assert!(client.request(ProtocolCommand::ListActors).is_err());
|
||||||
|
assert!(matches!(client.request(ProtocolCommand::ListActors),
|
||||||
|
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected));
|
||||||
|
peer.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_client_rejects_non_loopback_addresses() {
|
||||||
|
let error = WebSocketClient::connect("192.0.2.1:3737".parse().unwrap())
|
||||||
.err()
|
.err()
|
||||||
.expect("non-loopback connection must be rejected");
|
.expect("non-loopback connection must be rejected");
|
||||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_client_reports_disconnect_and_does_not_retry() {
|
fn websocket_client_reports_disconnect_and_does_not_retry() {
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
let (address, peer) = websocket_peer(|_socket, request| {
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let peer = std::thread::spawn(move || {
|
|
||||||
let (stream, _) = listener.accept().unwrap();
|
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
let mut line = String::new();
|
|
||||||
reader.read_line(&mut line).unwrap();
|
|
||||||
let request: Request = serde_json::from_str(&line).unwrap();
|
|
||||||
assert_eq!(request.command, ProtocolCommand::ListActors);
|
assert_eq!(request.command, ProtocolCommand::ListActors);
|
||||||
// Close after receiving the request, without sending a response.
|
// Close after receiving the request, without sending a response.
|
||||||
});
|
});
|
||||||
let mut client = TcpClient::connect(address).unwrap();
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
assert!(matches!(client.request(ProtocolCommand::ListActors),
|
assert!(matches!(
|
||||||
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof));
|
client.request(ProtocolCommand::ListActors),
|
||||||
|
Err(ClientError::Io(_))
|
||||||
|
));
|
||||||
assert!(matches!(client.request(ProtocolCommand::ListActors),
|
assert!(matches!(client.request(ProtocolCommand::ListActors),
|
||||||
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected));
|
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected));
|
||||||
peer.join().unwrap();
|
peer.join().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@ pub use runa_core::Value;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const PROTOCOL_VERSION: u16 = 3;
|
pub const PROTOCOL_VERSION: u16 = 3;
|
||||||
|
pub const WEBSOCKET_SUBPROTOCOL: &str = "runa-debug";
|
||||||
|
|
||||||
|
/// Coalescible invalidation, not a drain of the image's runtime event queue.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub version: u16,
|
||||||
|
pub event: ImageEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||||
|
pub enum ImageEvent {
|
||||||
|
ImageChanged { revision: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ version = "0.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
futures-util = { version = "0.3", features = ["sink"] }
|
||||||
runa-actor = { path = "../runa-actor" }
|
runa-actor = { path = "../runa-actor" }
|
||||||
runa-code = { path = "../runa-code" }
|
runa-code = { path = "../runa-code" }
|
||||||
runa-image-api = { path = "../runa-image-api" }
|
runa-image-api = { path = "../runa-image-api" }
|
||||||
runa-protocol = { path = "../runa-protocol" }
|
runa-protocol = { path = "../runa-protocol" }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] }
|
||||||
|
tokio-tungstenite = "0.28"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
runa-client = { path = "../runa-client" }
|
runa-client = { path = "../runa-client" }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
//! Transport-neutral dispatch from protocol requests to image API operations.
|
//! Transport-neutral dispatch from protocol requests to image API operations.
|
||||||
|
|
||||||
use std::io::{self, BufRead, Read, Write};
|
use std::io::{self, BufRead, Write};
|
||||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
use std::net::{SocketAddr, TcpListener};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
mod network;
|
||||||
|
pub use network::serve_websocket;
|
||||||
|
|
||||||
use runa_actor::{ActorRef, Event, Status};
|
use runa_actor::{ActorRef, Event, Status};
|
||||||
use runa_image_api::ImageApi;
|
use runa_image_api::ImageApi;
|
||||||
|
|
@ -14,7 +15,6 @@ use runa_protocol::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
|
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
|
||||||
const MAX_NETWORK_CLIENTS: usize = 16;
|
|
||||||
|
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
image: ImageApi,
|
image: ImageApi,
|
||||||
|
|
@ -44,171 +44,6 @@ pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
|
||||||
TcpListener::bind(address)
|
TcpListener::bind(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve JSON-lines requests from loopback clients using one shared image.
|
|
||||||
///
|
|
||||||
/// The nonblocking event loop keeps the non-`Send` Rune runtime on one thread.
|
|
||||||
/// At most `MAX_NETWORK_CLIENTS` are active.
|
|
||||||
pub fn serve_tcp(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
|
||||||
let address = listener.local_addr()?;
|
|
||||||
if !address.ip().is_loopback() {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"network listener must be bound to a loopback address",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
listener.set_nonblocking(true)?;
|
|
||||||
eprintln!("runa-server: listening on {address}");
|
|
||||||
let mut clients = Vec::new();
|
|
||||||
loop {
|
|
||||||
loop {
|
|
||||||
match listener.accept() {
|
|
||||||
Ok((stream, peer_address)) => {
|
|
||||||
if clients.len() >= MAX_NETWORK_CLIENTS {
|
|
||||||
eprintln!("runa-server: rejecting {peer_address}; client limit reached");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
stream.set_nonblocking(true)?;
|
|
||||||
clients.push(NetworkClient::new(stream, peer_address));
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut index = 0;
|
|
||||||
while index < clients.len() {
|
|
||||||
match clients[index].poll(&mut server) {
|
|
||||||
Ok(true) => index += 1,
|
|
||||||
Ok(false) => {
|
|
||||||
clients.swap_remove(index);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("runa-server: client {}: {error}", clients[index].address);
|
|
||||||
clients.swap_remove(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NetworkClient {
|
|
||||||
stream: TcpStream,
|
|
||||||
address: SocketAddr,
|
|
||||||
input: Vec<u8>,
|
|
||||||
output: Vec<u8>,
|
|
||||||
output_offset: usize,
|
|
||||||
read_closed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetworkClient {
|
|
||||||
fn new(stream: TcpStream, address: SocketAddr) -> Self {
|
|
||||||
Self {
|
|
||||||
stream,
|
|
||||||
address,
|
|
||||||
input: Vec::new(),
|
|
||||||
output: Vec::new(),
|
|
||||||
output_offset: 0,
|
|
||||||
read_closed: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll(&mut self, server: &mut Server) -> io::Result<bool> {
|
|
||||||
if self.output_offset == self.output.len() {
|
|
||||||
self.output.clear();
|
|
||||||
self.output_offset = 0;
|
|
||||||
self.process_pending_requests(server, self.read_closed)?;
|
|
||||||
|
|
||||||
if self.output.is_empty() && !self.read_closed {
|
|
||||||
let mut buffer = [0; 8192];
|
|
||||||
match self.stream.read(&mut buffer) {
|
|
||||||
Ok(0) => {
|
|
||||||
self.read_closed = true;
|
|
||||||
self.process_pending_requests(server, true)?;
|
|
||||||
}
|
|
||||||
Ok(length) => {
|
|
||||||
self.input.extend_from_slice(&buffer[..length]);
|
|
||||||
self.process_pending_requests(server, false)?;
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.output_offset < self.output.len() {
|
|
||||||
match self.stream.write(&self.output[self.output_offset..]) {
|
|
||||||
Ok(0) => {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::WriteZero,
|
|
||||||
"failed to write protocol response",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(length) => {
|
|
||||||
self.output_offset += length;
|
|
||||||
if self.output_offset == self.output.len() {
|
|
||||||
self.output.clear();
|
|
||||||
self.output_offset = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(!self.read_closed || !self.input.is_empty() || self.output_offset < self.output.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_pending_requests(
|
|
||||||
&mut self,
|
|
||||||
server: &mut Server,
|
|
||||||
final_line: bool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let Some(line_end) = self.input.iter().position(|byte| *byte == b'\n') else {
|
|
||||||
if self.input.len() > MAX_REQUEST_LINE_BYTES {
|
|
||||||
return Err(invalid_data(format!(
|
|
||||||
"request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if !final_line || self.input.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let line = self.input.clone();
|
|
||||||
self.input.clear();
|
|
||||||
self.dispatch_line(server, &line)?;
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if line_end > MAX_REQUEST_LINE_BYTES {
|
|
||||||
return Err(invalid_data(format!(
|
|
||||||
"request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let line = self.input[..line_end].to_vec();
|
|
||||||
self.input.drain(..=line_end);
|
|
||||||
self.dispatch_line(server, &line)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dispatch_line(&mut self, server: &mut Server, line: &[u8]) -> io::Result<()> {
|
|
||||||
let request: Request = serde_json::from_slice(line)
|
|
||||||
.map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?;
|
|
||||||
let response = server.dispatch(request);
|
|
||||||
let mut encoded = serde_json::to_vec(&response).map_err(io::Error::other)?;
|
|
||||||
if encoded.len() > MAX_REQUEST_LINE_BYTES {
|
|
||||||
return Err(invalid_data(format!(
|
|
||||||
"response exceeds {MAX_REQUEST_LINE_BYTES} bytes"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
encoded.push(b'\n');
|
|
||||||
self.output.extend_from_slice(&encoded);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalid_data(message: String) -> io::Error {
|
fn invalid_data(message: String) -> io::Error {
|
||||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||||
}
|
}
|
||||||
|
|
@ -555,8 +390,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
use runa_protocol::{Command, PROTOCOL_VERSION};
|
use runa_protocol::{Command, PROTOCOL_VERSION};
|
||||||
use std::io::{BufRead, BufReader, Cursor};
|
use std::io::Cursor;
|
||||||
use std::net::Shutdown;
|
|
||||||
|
|
||||||
fn request(command: Command) -> Request {
|
fn request(command: Command) -> Request {
|
||||||
Request {
|
Request {
|
||||||
|
|
@ -773,39 +607,17 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_transport_dispatches_requests_to_the_shared_server() {
|
fn notification_contract_is_versioned_and_distinct_from_responses() {
|
||||||
let listener = bind_loopback_listener("127.0.0.1:0".parse().unwrap()).unwrap();
|
let notification = runa_protocol::Notification {
|
||||||
let address = listener.local_addr().unwrap();
|
version: PROTOCOL_VERSION,
|
||||||
let mut client = TcpStream::connect(address).unwrap();
|
event: runa_protocol::ImageEvent::ImageChanged { revision: 42 },
|
||||||
let (server_stream, peer_address) = listener.accept().unwrap();
|
};
|
||||||
let mut network_client = NetworkClient::new(server_stream, peer_address);
|
assert_eq!(
|
||||||
|
serde_json::to_value(notification).unwrap(),
|
||||||
let first_request = request(Command::ListActors);
|
serde_json::json!({
|
||||||
let mut second_request = request(Command::ListActors);
|
"version": 3,
|
||||||
second_request.request_id = "second-request".to_owned();
|
"event": {"event": "image_changed", "data": {"revision": 42}}
|
||||||
writeln!(
|
})
|
||||||
client,
|
);
|
||||||
"{}\n{}",
|
|
||||||
serde_json::to_string(&first_request).unwrap(),
|
|
||||||
serde_json::to_string(&second_request).unwrap()
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
client.shutdown(Shutdown::Write).unwrap();
|
|
||||||
|
|
||||||
assert!(network_client.poll(&mut Server::new()).unwrap());
|
|
||||||
let mut response_line = String::new();
|
|
||||||
let mut client_reader = BufReader::new(client);
|
|
||||||
client_reader.read_line(&mut response_line).unwrap();
|
|
||||||
let first_response: Response = serde_json::from_str(&response_line).unwrap();
|
|
||||||
assert_eq!(first_response.request_id, first_request.request_id);
|
|
||||||
|
|
||||||
assert!(network_client.poll(&mut Server::new()).unwrap());
|
|
||||||
response_line.clear();
|
|
||||||
client_reader.read_line(&mut response_line).unwrap();
|
|
||||||
let second_response: Response = serde_json::from_str(&response_line).unwrap();
|
|
||||||
assert_eq!(second_response.request_id, second_request.request_id);
|
|
||||||
assert_eq!(second_response.result, Ok(Reply::Actors(Vec::new())));
|
|
||||||
|
|
||||||
assert!(!network_client.poll(&mut Server::new()).unwrap());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::net::SocketAddr;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use runa_protocol::{Command, PROTOCOL_VERSION, Request};
|
use runa_protocol::{Command, PROTOCOL_VERSION, Request};
|
||||||
use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_tcp};
|
use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_websocket};
|
||||||
|
|
||||||
fn initial_image() -> io::Result<Server> {
|
fn initial_image() -> io::Result<Server> {
|
||||||
let mut server = Server::new();
|
let mut server = Server::new();
|
||||||
|
|
@ -55,7 +55,7 @@ fn run() -> io::Result<()> {
|
||||||
format!("invalid listener address: {error}"),
|
format!("invalid listener address: {error}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
serve_tcp(bind_loopback_listener(address)?, initial_image()?)
|
serve_websocket(bind_loopback_listener(address)?, initial_image()?)
|
||||||
}
|
}
|
||||||
Some(_) => Err(io::Error::new(
|
Some(_) => Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
use std::io;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use runa_protocol::{
|
||||||
|
Command, ImageEvent, Notification, PROTOCOL_VERSION, Request, Response, WEBSOCKET_SUBPROTOCOL,
|
||||||
|
};
|
||||||
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use tokio_tungstenite::tungstenite::handshake::server::{
|
||||||
|
Callback, ErrorResponse, Request as UpgradeRequest, Response as UpgradeResponse,
|
||||||
|
};
|
||||||
|
use tokio_tungstenite::tungstenite::http::{HeaderValue, StatusCode};
|
||||||
|
use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
|
||||||
|
|
||||||
|
use crate::{MAX_REQUEST_LINE_BYTES, Server};
|
||||||
|
|
||||||
|
const MAX_NETWORK_CLIENTS: usize = 16;
|
||||||
|
const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Keep all image dispatch on one thread, with bounded per-client I/O tasks.
|
||||||
|
pub fn serve_websocket(listener: TcpListener, server: Server) -> io::Result<()> {
|
||||||
|
if !listener.local_addr()?.ip().is_loopback() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"network listener must be bound to a loopback address",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
listener.set_nonblocking(true)?;
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?
|
||||||
|
.block_on(serve(listener, server))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Dispatch {
|
||||||
|
request: Request,
|
||||||
|
respond: oneshot::Sender<Response>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
||||||
|
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||||
|
eprintln!("runa-server: listening on {}", listener.local_addr()?);
|
||||||
|
let (requests, mut pending) = mpsc::channel::<Dispatch>(MAX_NETWORK_CLIENTS);
|
||||||
|
let (changes, _) = watch::channel(0_u64);
|
||||||
|
let mut revision = 0_u64;
|
||||||
|
let mut clients = JoinSet::new();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let (stream, address) = accepted?;
|
||||||
|
if clients.len() >= MAX_NETWORK_CLIENTS {
|
||||||
|
eprintln!("runa-server: rejecting {address}; client limit reached");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let requests = requests.clone();
|
||||||
|
let changes = changes.subscribe();
|
||||||
|
clients.spawn(async move {
|
||||||
|
if let Err(error) = client(stream, requests, changes).await {
|
||||||
|
eprintln!("runa-server: client {address}: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(dispatch) = pending.recv() => {
|
||||||
|
let mutates = !matches!(dispatch.request.command,
|
||||||
|
Command::GetBehavior { .. } | Command::GetBehaviors |
|
||||||
|
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } |
|
||||||
|
Command::ListActors | Command::GetEvents);
|
||||||
|
let response = server.dispatch(dispatch.request);
|
||||||
|
let changed = mutates && response.result.is_ok();
|
||||||
|
// Dispatch may commit even if the initiating client has disconnected.
|
||||||
|
let _ = dispatch.respond.send(response);
|
||||||
|
if changed {
|
||||||
|
revision = revision.checked_add(1)
|
||||||
|
.ok_or_else(|| io::Error::other("image notification revisions exhausted"))?;
|
||||||
|
changes.send_replace(revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(result) = clients.join_next(), if !clients.is_empty() => {
|
||||||
|
result.map_err(io::Error::other)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DebugProtocol;
|
||||||
|
|
||||||
|
impl Callback for DebugProtocol {
|
||||||
|
fn on_request(
|
||||||
|
self,
|
||||||
|
request: &UpgradeRequest,
|
||||||
|
mut response: UpgradeResponse,
|
||||||
|
) -> Result<UpgradeResponse, ErrorResponse> {
|
||||||
|
let offered = request
|
||||||
|
.headers()
|
||||||
|
.get("sec-websocket-protocol")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.any(|item| item.trim() == WEBSOCKET_SUBPROTOCOL)
|
||||||
|
});
|
||||||
|
// No browser-origin access is enabled for this unauthenticated local API.
|
||||||
|
if !offered || request.headers().contains_key("origin") || request.uri().path() != "/" {
|
||||||
|
let mut error = ErrorResponse::new(Some(
|
||||||
|
"requires runa-debug subprotocol, root path, and a non-browser local client"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
*error.status_mut() = StatusCode::BAD_REQUEST;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"sec-websocket-protocol",
|
||||||
|
HeaderValue::from_static(WEBSOCKET_SUBPROTOCOL),
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn client(
|
||||||
|
stream: tokio::net::TcpStream,
|
||||||
|
requests: mpsc::Sender<Dispatch>,
|
||||||
|
mut changes: watch::Receiver<u64>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let config = WebSocketConfig::default()
|
||||||
|
.max_message_size(Some(MAX_REQUEST_LINE_BYTES))
|
||||||
|
.max_frame_size(Some(MAX_REQUEST_LINE_BYTES))
|
||||||
|
.max_write_buffer_size(2 * MAX_REQUEST_LINE_BYTES);
|
||||||
|
let mut socket = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
tokio_tungstenite::accept_hdr_async_with_config(stream, DebugProtocol, Some(config)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
message = socket.next() => {
|
||||||
|
match message {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
let request: Request = serde_json::from_str(&text)?;
|
||||||
|
let (respond, response) = oneshot::channel();
|
||||||
|
requests.send(Dispatch { request, respond }).await?;
|
||||||
|
let encoded = serde_json::to_string(&response.await?)?;
|
||||||
|
if encoded.len() > MAX_REQUEST_LINE_BYTES {
|
||||||
|
return Err("WebSocket response exceeds 1 MiB".into());
|
||||||
|
}
|
||||||
|
timeout(WRITE_TIMEOUT, socket.send(Message::Text(encoded.into()))).await??;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Ping(_))) => {
|
||||||
|
timeout(WRITE_TIMEOUT, socket.flush()).await??;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Pong(_))) => {}
|
||||||
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
|
timeout(WRITE_TIMEOUT, socket.flush()).await??;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => return Err("only text protocol messages are accepted".into()),
|
||||||
|
Some(Err(error)) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = changes.changed() => {
|
||||||
|
changed?;
|
||||||
|
// Retain only the latest invalidation for each slow client, not an event log.
|
||||||
|
let revision = *changes.borrow_and_update();
|
||||||
|
let notification = Notification {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
event: ImageEvent::ImageChanged { revision },
|
||||||
|
};
|
||||||
|
let text = serde_json::to_string(¬ification)?;
|
||||||
|
timeout(WRITE_TIMEOUT, socket.send(Message::Text(text.into()))).await??;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,211 @@
|
||||||
use runa_protocol::Value;
|
use runa_protocol::Value;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::net::{Shutdown, SocketAddr, TcpStream};
|
use std::net::SocketAddr;
|
||||||
use std::process::{Child, Command as ProcessCommand, Stdio};
|
use std::process::{Child, Command as ProcessCommand, Stdio};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use runa_client::{LocalClient, TcpClient};
|
use runa_client::{LocalClient, WebSocketClient};
|
||||||
use runa_protocol::{Command, PROTOCOL_VERSION, Reply, Request, Response};
|
use runa_protocol::{Command, Reply};
|
||||||
|
use tokio_tungstenite::tungstenite::{self, Message, client::IntoClientRequest};
|
||||||
|
|
||||||
|
fn start_server() -> (ServerProcess, SocketAddr) {
|
||||||
|
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
||||||
|
.args(["--listen", "127.0.0.1:0"])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let mut server = ServerProcess(child);
|
||||||
|
let mut stderr = BufReader::new(server.0.stderr.take().unwrap());
|
||||||
|
let mut startup = String::new();
|
||||||
|
stderr.read_line(&mut startup).unwrap();
|
||||||
|
let address = startup
|
||||||
|
.strip_prefix("runa-server: listening on ")
|
||||||
|
.expect("server should report its bound address")
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::io::copy(&mut stderr, &mut std::io::sink()).unwrap();
|
||||||
|
});
|
||||||
|
(server, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watcher(address: SocketAddr) -> tungstenite::WebSocket<std::net::TcpStream> {
|
||||||
|
let mut request = format!("ws://{address}/").into_client_request().unwrap();
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert("sec-websocket-protocol", "runa-debug".parse().unwrap());
|
||||||
|
let stream = std::net::TcpStream::connect(address).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
let (socket, response) = tungstenite::client(request, stream).unwrap();
|
||||||
|
assert_eq!(response.headers()["sec-websocket-protocol"], "runa-debug");
|
||||||
|
socket
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revision(socket: &mut tungstenite::WebSocket<std::net::TcpStream>) -> u64 {
|
||||||
|
let message = socket.read().unwrap();
|
||||||
|
let event: runa_protocol::Notification =
|
||||||
|
serde_json::from_str(message.to_text().unwrap()).unwrap();
|
||||||
|
assert_eq!(event.version, runa_protocol::PROTOCOL_VERSION);
|
||||||
|
let runa_protocol::ImageEvent::ImageChanged { revision } = event.event;
|
||||||
|
revision
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_broadcasts_non_destructive_invalidations_for_mailbox_state_and_lifecycle() {
|
||||||
|
let (_server, address) = start_server();
|
||||||
|
let mut first = watcher(address);
|
||||||
|
let mut second = watcher(address);
|
||||||
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
|
let actor_ref = match client
|
||||||
|
.request(Command::Spawn {
|
||||||
|
behavior_name: "counter".to_owned(),
|
||||||
|
state: Value::Integer(1),
|
||||||
|
mailbox_capacity: 4,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.result
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Reply::ActorSpawned { actor_ref } => actor_ref,
|
||||||
|
other => panic!("unexpected spawn: {other:?}"),
|
||||||
|
};
|
||||||
|
let mut previous = 0;
|
||||||
|
for command in [
|
||||||
|
Command::ListActors,
|
||||||
|
Command::Send {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
message: Value::Record(std::collections::BTreeMap::from([
|
||||||
|
("op".to_owned(), Value::String("add".to_owned())),
|
||||||
|
("value".to_owned(), Value::Integer(3)),
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
Command::Tick {
|
||||||
|
ticks: 100,
|
||||||
|
instruction_budget: 10,
|
||||||
|
},
|
||||||
|
Command::PauseActor {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
},
|
||||||
|
Command::ResumeActor {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
},
|
||||||
|
Command::StopActor {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
},
|
||||||
|
Command::RestartActor {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
if !matches!(command, Command::ListActors) {
|
||||||
|
client.request(command).unwrap().result.unwrap();
|
||||||
|
}
|
||||||
|
let current = revision(&mut first);
|
||||||
|
assert!(current > previous);
|
||||||
|
assert_eq!(revision(&mut second), current);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
// Both subscribers have seen notifications, but neither consumed runtime events.
|
||||||
|
let Reply::Events(events) = client.request(Command::GetEvents).unwrap().result.unwrap() else {
|
||||||
|
panic!("expected runtime events")
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, runa_protocol::EventDto::Spawned { .. }))
|
||||||
|
);
|
||||||
|
let Reply::Actor(snapshot) = client
|
||||||
|
.request(Command::InspectActor { actor_ref })
|
||||||
|
.unwrap()
|
||||||
|
.result
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected actor")
|
||||||
|
};
|
||||||
|
assert_eq!(snapshot.state, Value::Integer(4));
|
||||||
|
|
||||||
|
// A read after a failed mutation is a fence: no invalidation should precede its reply.
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.request(Command::Spawn {
|
||||||
|
behavior_name: "missing".to_owned(),
|
||||||
|
state: Value::Null,
|
||||||
|
mailbox_capacity: 1,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.result
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
first
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&runa_protocol::Request {
|
||||||
|
version: 3,
|
||||||
|
request_id: "read-fence".to_owned(),
|
||||||
|
command: Command::ListActors,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let message = first.read().unwrap();
|
||||||
|
let response: runa_protocol::Response =
|
||||||
|
serde_json::from_str(message.to_text().unwrap()).unwrap();
|
||||||
|
assert_eq!(response.request_id, "read-fence");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_requires_subprotocol_and_rejects_browser_origins_without_blocking_other_clients() {
|
||||||
|
let (_server, address) = start_server();
|
||||||
|
// An idle HTTP handshake cannot stall the image or another client's upgrade.
|
||||||
|
let _idle = std::net::TcpStream::connect(address).unwrap();
|
||||||
|
for (protocol, origin) in [
|
||||||
|
(None, false),
|
||||||
|
(Some("debug"), false),
|
||||||
|
(Some("runa-debug"), true),
|
||||||
|
] {
|
||||||
|
let mut request = format!("ws://{address}/").into_client_request().unwrap();
|
||||||
|
if let Some(protocol) = protocol {
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert("sec-websocket-protocol", protocol.parse().unwrap());
|
||||||
|
}
|
||||||
|
if origin {
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert("origin", "https://example.test".parse().unwrap());
|
||||||
|
}
|
||||||
|
let stream = std::net::TcpStream::connect(address).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(tungstenite::client(request, stream).is_err());
|
||||||
|
}
|
||||||
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.request(Command::ListActors).unwrap().result.unwrap(),
|
||||||
|
Reply::Actors(Vec::new())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_rejects_binary_malformed_and_oversized_requests() {
|
||||||
|
let (_server, address) = start_server();
|
||||||
|
for message in [
|
||||||
|
Message::Binary(b"{}".to_vec().into()),
|
||||||
|
Message::Text("{not-json}".into()),
|
||||||
|
Message::Text(" ".repeat(runa_server::MAX_REQUEST_LINE_BYTES + 1).into()),
|
||||||
|
] {
|
||||||
|
let mut socket = watcher(address);
|
||||||
|
// An oversized frame may be rejected as soon as its header arrives,
|
||||||
|
// before the sender finishes writing its payload.
|
||||||
|
let _ = socket.send(message);
|
||||||
|
assert!(socket.read().is_err());
|
||||||
|
}
|
||||||
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
|
assert!(client.request(Command::ListActors).unwrap().result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
struct ServerProcess(Child);
|
struct ServerProcess(Child);
|
||||||
|
|
||||||
|
|
@ -65,7 +265,7 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_server_accepts_local_protocol_requests() {
|
fn websocket_server_accepts_local_protocol_requests() {
|
||||||
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
||||||
.args(["--listen", "127.0.0.1:0"])
|
.args(["--listen", "127.0.0.1:0"])
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
|
|
@ -84,25 +284,12 @@ fn tcp_server_accepts_local_protocol_requests() {
|
||||||
.parse::<SocketAddr>()
|
.parse::<SocketAddr>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mut client = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
|
let mut client = WebSocketClient::connect(address).unwrap();
|
||||||
let request = Request {
|
let response = client.request(Command::ListActors).unwrap();
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
request_id: "tcp-process-test".to_owned(),
|
|
||||||
command: Command::ListActors,
|
|
||||||
};
|
|
||||||
writeln!(client, "{}", serde_json::to_string(&request).unwrap()).unwrap();
|
|
||||||
client.shutdown(Shutdown::Write).unwrap();
|
|
||||||
|
|
||||||
let mut response_line = String::new();
|
|
||||||
BufReader::new(client)
|
|
||||||
.read_line(&mut response_line)
|
|
||||||
.unwrap();
|
|
||||||
let response: Response = serde_json::from_str(&response_line).unwrap();
|
|
||||||
assert_eq!(response.request_id, "tcp-process-test");
|
|
||||||
assert_eq!(response.result, Ok(Reply::Actors(Vec::new())));
|
assert_eq!(response.result, Ok(Reply::Actors(Vec::new())));
|
||||||
|
|
||||||
let mut first = TcpClient::connect(address).unwrap();
|
let mut first = WebSocketClient::connect(address).unwrap();
|
||||||
let mut second = TcpClient::connect(address).unwrap();
|
let mut second = WebSocketClient::connect(address).unwrap();
|
||||||
let actor_ref = match first
|
let actor_ref = match first
|
||||||
.request(Command::Spawn {
|
.request(Command::Spawn {
|
||||||
behavior_name: "counter".to_owned(),
|
behavior_name: "counter".to_owned(),
|
||||||
|
|
@ -133,7 +320,7 @@ fn tcp_server_accepts_local_protocol_requests() {
|
||||||
drop(first);
|
drop(first);
|
||||||
drop(second);
|
drop(second);
|
||||||
|
|
||||||
let mut reconnected = TcpClient::connect(address).unwrap();
|
let mut reconnected = WebSocketClient::connect(address).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
reconnected
|
reconnected
|
||||||
.request(Command::GetBehavior {
|
.request(Command::GetBehavior {
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
runa-server = { path = "../runa-server" }
|
runa-server = { path = "../runa-server" }
|
||||||
|
tungstenite = "0.28"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::io::{self, Write};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
|
|
||||||
use runa_client::TcpClient;
|
use runa_client::WebSocketClient;
|
||||||
use runa_core::ActorRef;
|
use runa_core::ActorRef;
|
||||||
use runa_protocol::{Command, Reply, Value};
|
use runa_protocol::{Command, Reply, Value};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -27,7 +27,7 @@ trait Connection {
|
||||||
fn dispatch(&mut self, command: Command) -> Result<Reply, String>;
|
fn dispatch(&mut self, command: Command) -> Result<Reply, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Connection for TcpClient {
|
impl Connection for WebSocketClient {
|
||||||
fn dispatch(&mut self, command: Command) -> Result<Reply, String> {
|
fn dispatch(&mut self, command: Command) -> Result<Reply, String> {
|
||||||
self.request(command)
|
self.request(command)
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
|
|
@ -192,7 +192,7 @@ impl Helper for ReplHelper {}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let address = parse_address(&std::env::args().skip(1).collect::<Vec<_>>())?;
|
let address = parse_address(&std::env::args().skip(1).collect::<Vec<_>>())?;
|
||||||
let mut server = TcpClient::connect(address).map_err(|error| {
|
let mut server = WebSocketClient::connect(address).map_err(|error| {
|
||||||
format!("cannot connect to {address}: {error}; start `mise run serve-network` first")
|
format!("cannot connect to {address}: {error}; start `mise run serve-network` first")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -910,23 +910,47 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_repl_reconnects_to_the_same_image() -> Result<(), Box<dyn std::error::Error>> {
|
fn websocket_repl_reconnects_to_the_same_image() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||||
let address = listener.local_addr()?;
|
let address = listener.local_addr()?;
|
||||||
let worker = std::thread::spawn(move || {
|
let worker = std::thread::spawn(move || {
|
||||||
let mut image = server();
|
let mut image = server();
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let (stream, _) = listener.accept().unwrap();
|
let (stream, _) = listener.accept().unwrap();
|
||||||
runa_server::serve_json_lines(
|
struct DebugProtocol;
|
||||||
&mut image,
|
impl tungstenite::handshake::server::Callback for DebugProtocol {
|
||||||
io::BufReader::new(stream.try_clone().unwrap()),
|
fn on_request(
|
||||||
stream,
|
self,
|
||||||
)
|
_: &tungstenite::handshake::server::Request,
|
||||||
.unwrap();
|
mut response: tungstenite::handshake::server::Response,
|
||||||
|
) -> Result<
|
||||||
|
tungstenite::handshake::server::Response,
|
||||||
|
tungstenite::handshake::server::ErrorResponse,
|
||||||
|
> {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"sec-websocket-protocol",
|
||||||
|
runa_protocol::WEBSOCKET_SUBPROTOCOL.parse().unwrap(),
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut socket = tungstenite::accept_hdr(stream, DebugProtocol).unwrap();
|
||||||
|
while let Ok(message) = socket.read() {
|
||||||
|
let Some(text) = message.to_text().ok() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let request = serde_json::from_str(text).unwrap();
|
||||||
|
let response = image.dispatch(request);
|
||||||
|
socket
|
||||||
|
.send(tungstenite::Message::Text(
|
||||||
|
serde_json::to_string(&response).unwrap().into(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut client = TcpClient::connect(address)?;
|
let mut client = WebSocketClient::connect(address)?;
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
run_repl(
|
run_repl(
|
||||||
io::Cursor::new(
|
io::Cursor::new(
|
||||||
|
|
@ -943,7 +967,7 @@ mod tests {
|
||||||
assert_eq!(actor_ref.len(), 21);
|
assert_eq!(actor_ref.len(), 21);
|
||||||
drop(client);
|
drop(client);
|
||||||
|
|
||||||
let mut client = TcpClient::connect(address)?;
|
let mut client = WebSocketClient::connect(address)?;
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
run_repl(
|
run_repl(
|
||||||
io::Cursor::new(format!("actors\ninspect @{actor_ref}\nquit\n")),
|
io::Cursor::new(format!("actors\ninspect @{actor_ref}\nquit\n")),
|
||||||
|
|
@ -965,7 +989,7 @@ mod tests {
|
||||||
assert_eq!(snapshot.state, Value::Integer(12));
|
assert_eq!(snapshot.state, Value::Integer(12));
|
||||||
assert_eq!(snapshot.status, runa_protocol::ActorStatusDto::Idle);
|
assert_eq!(snapshot.status, runa_protocol::ActorStatusDto::Idle);
|
||||||
drop(client);
|
drop(client);
|
||||||
worker.join().expect("TCP image worker completed");
|
worker.join().expect("WebSocket image worker completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ Rust supplies a minimal, reliable substrate; increasingly much of the actor fram
|
||||||
+-----------------------------------------------------------+
|
+-----------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The initial TCP transport is unauthenticated and must bind only to loopback; a remote-capable network boundary must authenticate and authorize clients. Neither transport creates a separate actor runtime or image.
|
The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The initial WebSocket transport is unauthenticated and must bind only to loopback; a remote-capable network boundary must authenticate and authorize clients. Neither transport creates a separate actor runtime or image.
|
||||||
|
|
||||||
## 4. Fundamental Actor Model
|
## 4. Fundamental Actor Model
|
||||||
|
|
||||||
|
|
@ -235,19 +235,21 @@ These are desired capabilities, not a requirement to implement every endpoint im
|
||||||
|
|
||||||
### 8.1 Security
|
### 8.1 Security
|
||||||
|
|
||||||
Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The stdio bridge is intended for a trusted parent process. The initial TCP bridge accepts unauthenticated connections only on IPv4 or IPv6 loopback and grants each connection the full development API; any local process may connect. It is suitable only for trusted local development. Never bind this endpoint to a non-loopback address or expose it remotely until authentication and authorization are implemented.
|
Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The stdio bridge is intended for a trusted parent process. The initial WebSocket bridge accepts unauthenticated connections only on IPv4 or IPv6 loopback and grants each connection the full development API; any local process may connect. It is suitable only for trusted local development. Never bind this endpoint to a non-loopback address or expose it remotely until authentication and authorization are implemented.
|
||||||
|
|
||||||
## 9. VS Code Extension
|
## 9. VS Code Extension
|
||||||
|
|
||||||
VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON Lines over TCP, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients.
|
VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON over runa-debug WebSockets, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients.
|
||||||
|
|
||||||
The first extension increment implements explicit connect/disconnect, an actor
|
The first extension increment implements explicit connect/disconnect, an actor
|
||||||
explorer, and read-only state/mailbox/execution snapshots. Refresh and inspection
|
explorer, and read-only state/mailbox/execution snapshots. Server `image_changed`
|
||||||
fetch current data on demand; no scheduler ticks or event drains are performed.
|
notifications automatically refresh the tree and open documents without
|
||||||
|
scheduler ticks or event drains. Revisions and refreshes are coalesced rather
|
||||||
|
than replayed, and changes during a fetch trigger a follow-up refresh.
|
||||||
Cached open snapshots are marked disconnected on connection loss and isolated
|
Cached open snapshots are marked disconnected on connection loss and isolated
|
||||||
by connection session so reconnecting to another image cannot overwrite them.
|
by connection session so reconnecting to another image cannot overwrite them.
|
||||||
The remaining UX below is planned; non-destructive event subscription semantics
|
The remaining UX below is planned; detailed runtime event subscriptions and
|
||||||
still need design.
|
durable replay still need design.
|
||||||
|
|
||||||
Required extension UX:
|
Required extension UX:
|
||||||
|
|
||||||
|
|
@ -325,8 +327,8 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
|
||||||
| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` |
|
| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` |
|
||||||
| `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` |
|
| `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` |
|
||||||
| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` |
|
| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` |
|
||||||
| `runa-server` | Protocol dispatch, JSON-lines stdio, and loopback-only TCP transport | `runa-protocol`, `runa-image-api` |
|
| `runa-server` | Protocol dispatch, JSON-lines stdio, and loopback-only WebSocket transport | `runa-protocol`, `runa-image-api` |
|
||||||
| `runa-client` | Local child-process client for the JSON-lines stdio protocol | `runa-protocol` |
|
| `runa-client` | Local child-process stdio and loopback runa-debug WebSocket clients | `runa-protocol` |
|
||||||
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
|
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
|
||||||
| `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` |
|
| `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` |
|
||||||
| `runa` | Startup, configuration, resource wiring, shutdown | Top-level crates it instantiates |
|
| `runa` | Startup, configuration, resource wiring, shutdown | Top-level crates it instantiates |
|
||||||
|
|
@ -346,8 +348,8 @@ defines versioned, serializable request, response, and event DTOs without
|
||||||
depending on a transport or the runtime implementation. `runa-server` now
|
depending on a transport or the runtime implementation. `runa-server` now
|
||||||
dispatches those commands through the image API and maps service results into
|
dispatches those commands through the image API and maps service results into
|
||||||
protocol replies and typed errors. The dispatcher is exposed through local
|
protocol replies and typed errors. The dispatcher is exposed through local
|
||||||
child-process JSON-lines stdio and an unauthenticated TCP listener restricted
|
child-process JSON-lines stdio and an unauthenticated WebSocket listener restricted
|
||||||
to IPv4 or IPv6 loopback; both enforce a 1 MiB request-line limit. The TCP
|
to IPv4 or IPv6 loopback; both enforce a 1 MiB request limit. The WebSocket
|
||||||
listener shares one image across at most 16 concurrent clients. Every local
|
listener shares one image across at most 16 concurrent clients. Every local
|
||||||
client receives full development API access, so the endpoint is for trusted
|
client receives full development API access, so the endpoint is for trusted
|
||||||
local development only. In-process consumers call `runa-image-api`, and
|
local development only. In-process consumers call `runa-image-api`, and
|
||||||
|
|
@ -464,11 +466,11 @@ mapping, and local-variable inspection remain future work.
|
||||||
The first Phase 5 increments are a trusted in-process application API used by
|
The first Phase 5 increments are a trusted in-process application API used by
|
||||||
the CLI, a transport-neutral versioned protocol contract, and a dispatcher
|
the CLI, a transport-neutral versioned protocol contract, and a dispatcher
|
||||||
exposed through bounded local JSON-lines stdio. The interactive CLI now uses
|
exposed through bounded local JSON-lines stdio. The interactive CLI now uses
|
||||||
`runa-client` to connect over loopback TCP to a server-owned image, rather than
|
`runa-client` to connect over loopback WebSockets to a server-owned image, rather than
|
||||||
embedding a runtime. Multiple clients share actors and behavior revisions;
|
embedding a runtime. Multiple clients share actors and behavior revisions;
|
||||||
client disconnect/reconnect leaves the running image intact. `runa-client`
|
client disconnect/reconnect leaves the running image intact. `runa-client`
|
||||||
also launches the stdio server for external local tools. The server provides an
|
also launches the stdio server for external local tools. The server provides an
|
||||||
unauthenticated TCP JSON-lines endpoint restricted to loopback for local VS
|
unauthenticated runa-debug WebSocket endpoint restricted to loopback for local VS
|
||||||
Code development. Remote access will require authentication and
|
Code development. Remote access will require authentication and
|
||||||
authorization; neither network transport nor client location changes the
|
authorization; neither network transport nor client location changes the
|
||||||
single-image actor model.
|
single-image actor model.
|
||||||
|
|
@ -485,7 +487,9 @@ single-image actor model.
|
||||||
|
|
||||||
Connection management, the actor explorer, and read-only snapshot inspection
|
Connection management, the actor explorer, and read-only snapshot inspection
|
||||||
are implemented in `vscode/`. Spawn/send and lifecycle controls, behavior editing,
|
are implemented in `vscode/`. Spawn/send and lifecycle controls, behavior editing,
|
||||||
diagnostics, hot upgrades, and event subscriptions remain planned.
|
diagnostics, hot upgrades, and detailed runtime event subscriptions remain planned.
|
||||||
|
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||||
|
implemented over the shared `runa-debug` WebSocket transport.
|
||||||
|
|
||||||
### Phase 8 — Persistence and advanced debugger
|
### Phase 8 — Persistence and advanced debugger
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
`runa-protocol` defines versioned request, response, and event data contracts
|
`runa-protocol` defines versioned request, response, and event data contracts
|
||||||
for clients and the `runa-server` dispatcher. It is independent of transport
|
for clients and the `runa-server` dispatcher. It is independent of transport
|
||||||
and runtime implementation. `runa-server` accepts JSON Lines over stdio or,
|
and runtime implementation. `runa-server` accepts JSON Lines over stdio or,
|
||||||
when started with `--listen`, over TCP bound to an IPv4 or IPv6 loopback
|
when started with `--listen`, as WebSocket text messages bound to an IPv4 or
|
||||||
address. The TCP listener is unauthenticated and is intended only for trusted
|
IPv6 loopback address. The listener is unauthenticated and intended only for
|
||||||
local development.
|
trusted local development. Both the REPL and VS Code use WebSockets.
|
||||||
|
|
||||||
## Version 3
|
## Version 3
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ client syntax, not wire encodings.
|
||||||
|
|
||||||
Each state/message permits a maximum nesting depth of 32 (root depth 0),
|
Each state/message permits a maximum nesting depth of 32 (root depth 0),
|
||||||
4,096 nodes, and 1 MiB of UTF-8 string/key bytes. Requests are also subject to
|
4,096 nodes, and 1 MiB of UTF-8 string/key bytes. Requests are also subject to
|
||||||
the transport's 1 MiB total line limit, including tags and envelope.
|
the transport's 1 MiB total message limit, including tags and envelope.
|
||||||
Unsupported value tags, malformed references, floats, and out-of-range integers
|
Unsupported value tags, malformed references, floats, and out-of-range integers
|
||||||
fail decoding; well-formed requests exceeding value limits receive
|
fail decoding; well-formed requests exceeding value limits receive
|
||||||
`invalid_request`. Invalid Rune transition output fails the actor without
|
`invalid_request`. Invalid Rune transition output fails the actor without
|
||||||
|
|
@ -84,38 +84,87 @@ any of its sends.
|
||||||
|
|
||||||
`get_events` drains and returns the events currently accumulated by this
|
`get_events` drains and returns the events currently accumulated by this
|
||||||
single-worker image. It is not a durable subscription or replayable event log.
|
single-worker image. It is not a durable subscription or replayable event log.
|
||||||
Both transports limit each request line to 1 MiB before parsing. Malformed
|
Both transports limit each request to 1 MiB before parsing. Malformed
|
||||||
JSON or an oversized line terminates that stream with an error; no protocol
|
JSON or an oversized message terminates that connection with an error; no protocol
|
||||||
response is emitted when a request cannot be decoded. Stdio EOF terminates the
|
response is emitted when a request cannot be decoded. Stdio EOF terminates the
|
||||||
process, while TCP EOF closes only that client connection. The TCP listener
|
process, while a WebSocket disconnect closes only that client connection. The listener
|
||||||
allows up to 16 simultaneous clients and serializes each dispatch through the
|
allows up to 16 simultaneous clients and serializes each dispatch through the
|
||||||
same image. TCP response payloads are capped at 1 MiB, with at most one
|
same image. WebSocket response payloads are capped at 1 MiB, with one
|
||||||
response queued per client. Every local client can use the full development
|
in-flight dispatch per client and a bounded shared dispatch queue. Every local client can use the full development
|
||||||
API; loopback binding is not authentication or authorization. Remote access
|
API; loopback binding is not authentication or authorization. Remote access
|
||||||
must not be enabled until appropriate authentication and authorization exist.
|
must not be enabled until appropriate authentication and authorization exist.
|
||||||
The `runa-client` crate launches the stdio server locally and validates
|
The `runa-client` crate launches the stdio server locally and validates
|
||||||
response protocol versions and request IDs. Its `TcpClient` also connects to
|
response protocol versions and request IDs. Its `WebSocketClient` connects to
|
||||||
loopback endpoints using the same bounded framing and correlation checks.
|
loopback endpoints using bounded WebSocket messages and correlation checks,
|
||||||
Connection establishment has a 5-second timeout; reads and writes have
|
validating interleaved notifications while waiting for responses.
|
||||||
30-second timeouts. A failed TCP request invalidates that client connection.
|
Connection establishment has a 5-second timeout; WebSocket writes and response
|
||||||
|
waits have 30-second timeouts. A failed network request invalidates that client connection.
|
||||||
No automatic retry is performed: loss of a response does not imply that the
|
No automatic retry is performed: loss of a response does not imply that the
|
||||||
operation failed to commit.
|
operation failed to commit.
|
||||||
|
|
||||||
The interactive REPL uses TCP and never owns the image. Disconnecting clients
|
The interactive REPL uses WebSockets and never owns the image. Disconnecting clients
|
||||||
does not stop actors or reset behavior revisions; reconnecting clients can
|
does not stop actors or reset behavior revisions; reconnecting clients can
|
||||||
retrieve them through `list_actors` and `get_behaviors`. The TCP executable
|
retrieve them through `list_actors` and `get_behaviors`. The network executable
|
||||||
publishes the bundled `counter` behavior on image startup, not on connection.
|
publishes the bundled `counter` behavior on image startup, not on connection.
|
||||||
The stdio executable continues to start with an empty image.
|
The stdio executable continues to start with an empty image.
|
||||||
|
|
||||||
The TypeScript VS Code client currently issues only `list_actors` and
|
The TypeScript VS Code client currently issues only `list_actors` and
|
||||||
`inspect_actor`. It enforces loopback endpoints, bounded UTF-8 response lines,
|
`inspect_actor`. It enforces loopback endpoints, bounded UTF-8 text messages,
|
||||||
version/request-ID/reply-kind checks, and portable snapshot value validation.
|
version/request-ID/reply-kind checks, and portable snapshot value validation.
|
||||||
It decodes JSON integers as `bigint` and prints them losslessly, including
|
It decodes JSON integers as `bigint` and prints them losslessly, including
|
||||||
signed 64-bit state and unsigned 64-bit telemetry. It uses the same 5-second
|
signed 64-bit state and unsigned 64-bit telemetry. It uses the same 5-second
|
||||||
connect and 30-second request timeouts, closes on transport/protocol corruption,
|
connect and 30-second request timeouts, closes on transport/protocol corruption,
|
||||||
and never retries automatically. A well-formed server error is surfaced without
|
and never retries automatically. A well-formed server error is surfaced without
|
||||||
invalidating the stream. Refresh is manual; the extension does not call
|
invalidating the stream. Server notifications trigger automatic refreshes of
|
||||||
`get_events`, because doing so would drain events needed by other clients.
|
the actor tree and open snapshot documents. Manual refresh remains available.
|
||||||
|
|
||||||
|
## WebSocket transport and notifications
|
||||||
|
|
||||||
|
Connect to `ws://127.0.0.1:3737/` (or a configured numeric loopback endpoint)
|
||||||
|
and offer the `runa-debug` WebSocket subprotocol. The server requires and echoes
|
||||||
|
that subprotocol. Plain TCP JSON Lines is no longer accepted by the network
|
||||||
|
listener. The request/response contract remains version 3; stdio is unchanged.
|
||||||
|
Each WebSocket text message contains exactly one JSON request, response, or
|
||||||
|
notification, without newline framing. Binary messages and malformed requests
|
||||||
|
terminate the connection. Fragmented text is supported with a 1 MiB aggregate
|
||||||
|
message limit; individual frames are also limited to 1 MiB. Compression is not
|
||||||
|
enabled. Upgrade handshakes have a 5-second timeout and writes have a
|
||||||
|
30-second timeout so stalled peers do not hold client slots forever.
|
||||||
|
|
||||||
|
The endpoint accepts only the root path and rejects requests carrying an
|
||||||
|
`Origin` header: browser-origin access is not enabled for this unauthenticated
|
||||||
|
local API. This is not authentication; local non-browser clients retain full
|
||||||
|
development privileges.
|
||||||
|
|
||||||
|
Every upgraded connection automatically receives non-destructive invalidations:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"event": {
|
||||||
|
"event": "image_changed",
|
||||||
|
"data": { "revision": 42 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful mutating requests (spawn, send, tick, lifecycle controls,
|
||||||
|
publication/rollback, and installation) increment a server-session revision
|
||||||
|
and notify all connected clients, including the initiator. Read-only requests,
|
||||||
|
failed mutations, and `get_events` do not generate invalidations. A tick
|
||||||
|
invalidates snapshots even when it only advances execution telemetry or causes
|
||||||
|
an actor failure. A successful send invalidates mailbox snapshots before any
|
||||||
|
handler runs. Notifications may appear between requests and their replies;
|
||||||
|
clients must distinguish them from correlated responses.
|
||||||
|
|
||||||
|
Revisions increase within one server session, not across server restarts.
|
||||||
|
Slow subscribers retain only the latest unsent revision; gaps are expected.
|
||||||
|
These notifications are hints to re-fetch authoritative data, not individual
|
||||||
|
runtime events, a durable log, an atomic snapshot revision, or replay.
|
||||||
|
On connection/reconnection, fetch a full listing first. The VS Code client
|
||||||
|
coalesces bursts and schedules another refresh if an invalidation arrives
|
||||||
|
during an in-flight read. It never advances the scheduler or calls `get_events`;
|
||||||
|
the REPL's explicit `events` command still drains the separate runtime queue.
|
||||||
|
|
||||||
The current validation checks the protocol version, request ID, actor-reference
|
The current validation checks the protocol version, request ID, actor-reference
|
||||||
shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox
|
shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Quitting the REPL leaves the image running. Reconnecting clients can list and
|
||||||
inspect the same actors; local bindings and history do not survive a REPL
|
inspect the same actors; local bindings and history do not survive a REPL
|
||||||
restart. Server exit still loses the entire image.
|
restart. Server exit still loses the entire image.
|
||||||
|
|
||||||
The TCP server publishes [counter.rn](../runa-std/actors/counter.rn) as the
|
The WebSocket server publishes [counter.rn](../runa-std/actors/counter.rn) as the
|
||||||
`counter` behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`,
|
`counter` behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`,
|
||||||
`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`,
|
`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`,
|
||||||
`publish`, `history`, `rollback`, and `install`; enter `help` to see usage.
|
`publish`, `history`, `rollback`, and `install`; enter `help` to see usage.
|
||||||
|
|
@ -22,7 +22,10 @@ current revision and a target historical revision. `install` selects a named
|
||||||
behavior's current implementation and fails if the actor is not at an idle
|
behavior's current implementation and fails if the actor is not at an idle
|
||||||
handler boundary. `spawn` requires a behavior name and initial state, in that
|
handler boundary. `spawn` requires a behavior name and initial state, in that
|
||||||
order. All commands operate on the same process-local runtime and code store.
|
order. All commands operate on the same process-local runtime and code store.
|
||||||
The CLI sends versioned protocol requests to the loopback-only TCP listener.
|
The CLI sends versioned requests to the loopback-only WebSocket listener using
|
||||||
|
the `runa-debug` subprotocol. VS Code uses the same protocol and automatically
|
||||||
|
refreshes on non-destructive server change notifications. These notifications
|
||||||
|
do not tick the image or drain the REPL's explicit `events` queue.
|
||||||
The separate stdio endpoint starts an empty image for child-process clients.
|
The separate stdio endpoint starts an empty image for child-process clients.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
@ -78,9 +81,10 @@ also offers references inside a `"$actor"` string.
|
||||||
event DTOs; it does not dispatch requests or access runtime state.
|
event DTOs; it does not dispatch requests or access runtime state.
|
||||||
- `runa-server` dispatches those requests through `runa-image-api` and maps
|
- `runa-server` dispatches those requests through `runa-image-api` and maps
|
||||||
results to protocol replies; its executable accepts newline-delimited JSON
|
results to protocol replies; its executable accepts newline-delimited JSON
|
||||||
over stdin/stdout or TCP bound to loopback, with a 1 MiB request-line limit.
|
over stdin/stdout or JSON text messages over loopback WebSockets, with a
|
||||||
- `runa-client` provides correlated, bounded stdio and loopback TCP clients.
|
1 MiB request limit.
|
||||||
- `runa` provides an interactive CLI using the TCP client, without an embedded
|
- `runa-client` provides correlated, bounded stdio and loopback WebSocket clients.
|
||||||
|
- `runa` provides an interactive CLI using the WebSocket client, without an embedded
|
||||||
runtime. Connection errors are explicit; requests are never auto-retried.
|
runtime. Connection errors are explicit; requests are never auto-retried.
|
||||||
|
|
||||||
The image API is process-local and intended for trusted callers. It has no
|
The image API is process-local and intended for trusted callers. It has no
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ description = "Run the local JSON-lines protocol server"
|
||||||
run = "cargo run -p runa-server"
|
run = "cargo run -p runa-server"
|
||||||
|
|
||||||
[tasks.serve-network]
|
[tasks.serve-network]
|
||||||
description = "Run the protocol server on localhost"
|
description = "Run the runa-debug WebSocket server on localhost"
|
||||||
run = "cargo run -p runa-server -- --listen 127.0.0.1:3737"
|
run = "cargo run -p runa-server -- --listen 127.0.0.1:3737"
|
||||||
|
|
||||||
[tasks.lint]
|
[tasks.lint]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
# Runa VS Code client
|
# Runa VS Code client
|
||||||
|
|
||||||
This is the first, read-only client for a trusted localhost Runa image. It uses
|
This is the first, read-only client for a trusted localhost Runa image. It uses
|
||||||
the [version 3 JSON-lines protocol](../docs/protocol.md), not Rust APIs, and never
|
the [version 3 protocol](../docs/protocol.md) over WebSockets with the `runa-debug`
|
||||||
|
subprotocol, not Rust APIs, and never
|
||||||
hosts actors or starts its own image.
|
hosts actors or starts its own image.
|
||||||
|
|
||||||
## Run from source
|
## Run from source
|
||||||
|
|
@ -24,7 +25,7 @@ In that host:
|
||||||
1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`;
|
1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`;
|
||||||
`runa.address` changes the suggested endpoint.
|
`runa.address` changes the suggested endpoint.
|
||||||
2. Expand **Runa Actors** in Explorer. Use the REPL (`mise run start`) to
|
2. Expand **Runa Actors** in Explorer. Use the REPL (`mise run start`) to
|
||||||
spawn actors in the same server, then run **Runa: Refresh Actors**.
|
spawn actors in the same server. The tree updates automatically.
|
||||||
3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot.
|
3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot.
|
||||||
4. Run **Runa: Disconnect** to close only this client connection.
|
4. Run **Runa: Disconnect** to close only this client connection.
|
||||||
|
|
||||||
|
|
@ -44,7 +45,9 @@ machine running the extension host, not necessarily the desktop.
|
||||||
strings and preserve signed 64-bit integers exactly.
|
strings and preserve signed 64-bit integers exactly.
|
||||||
- Documents are read-only snapshots, not editable actor state or live VM frames.
|
- Documents are read-only snapshots, not editable actor state or live VM frames.
|
||||||
The timestamp records when this client received the data, not a runtime clock.
|
The timestamp records when this client received the data, not a runtime clock.
|
||||||
**Refresh Actors** updates the tree and existing documents for that connection.
|
Server `image_changed` notifications automatically update the tree and open
|
||||||
|
documents. Bursts are coalesced, and changes arriving during a fetch trigger
|
||||||
|
a follow-up fetch. **Refresh Actors** is also available on demand.
|
||||||
- Open documents are retained and marked `connected: false` on disconnect.
|
- Open documents are retained and marked `connected: false` on disconnect.
|
||||||
Each connection gets distinct document URIs; reconnecting never replaces
|
Each connection gets distinct document URIs; reconnecting never replaces
|
||||||
snapshots from an earlier connection or another image. Closing a document
|
snapshots from an earlier connection or another image. Closing a document
|
||||||
|
|
@ -55,6 +58,8 @@ machine running the extension host, not necessarily the desktop.
|
||||||
output channel. Server-reported operation errors leave the connection usable.
|
output channel. Server-reported operation errors leave the connection usable.
|
||||||
- The client does not tick the scheduler, drain `get_events`, spawn/send, or
|
- The client does not tick the scheduler, drain `get_events`, spawn/send, or
|
||||||
change actor lifecycle/behavior. Use the REPL for those operations.
|
change actor lifecycle/behavior. Use the REPL for those operations.
|
||||||
|
Notifications are non-destructive invalidations, not a replayable runtime
|
||||||
|
event stream; reconnect fetches the current image rather than replaying history.
|
||||||
|
|
||||||
For automation, the `runa.connect` command also accepts an explicit endpoint
|
For automation, the `runa.connect` command also accepts an explicit endpoint
|
||||||
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
||||||
|
|
@ -71,18 +76,20 @@ mise run test-extension-host
|
||||||
```
|
```
|
||||||
|
|
||||||
The normal test task covers portable-value validation, framing and UTF-8
|
The normal test task covers portable-value validation, framing and UTF-8
|
||||||
fragmentation, timeout/disconnect behavior, exact 64-bit integer display, and
|
fragmentation, subprotocol negotiation, interleaved notifications, refresh
|
||||||
|
coalescing, timeout/disconnect behavior, exact 64-bit integer display, and
|
||||||
interoperation with the real Rust server on an ephemeral loopback port.
|
interoperation with the real Rust server on an ephemeral loopback port.
|
||||||
|
|
||||||
The separate host test downloads VS Code 1.96.4 to the ignored `.vscode-test/`
|
The separate host test downloads VS Code 1.96.4 to the ignored `.vscode-test/`
|
||||||
cache and runs connection, inspection, refresh, disconnect, and reconnect
|
cache and runs connection, inspection, disconnect, and reconnect commands
|
||||||
commands against a test-owned Rust server. It verifies actual virtual document
|
against a test-owned Rust server. It verifies automatic document updates after
|
||||||
content and session isolation. On headless Linux:
|
another client sends messages, pauses, and advances an actor into failure, as
|
||||||
|
well as session isolation. On headless Linux:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
xvfb-run -a mise run test-extension-host
|
xvfb-run -a mise run test-extension-host
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior/source browsing and editing, spawn/send and lifecycle controls,
|
Behavior/source browsing and editing, spawn/send and lifecycle controls,
|
||||||
compilation diagnostics, non-destructive event subscriptions, packaging for
|
compilation diagnostics, detailed runtime event subscriptions, packaging for
|
||||||
distribution, and debugging remain future work.
|
distribution, and debugging remain future work.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export async function run(): Promise<void> {
|
||||||
assert.match(document.getText(), /9223372036854775807/);
|
assert.match(document.getText(), /9223372036854775807/);
|
||||||
assert.match(document.getText(), /"connected": true/);
|
assert.match(document.getText(), /"connected": true/);
|
||||||
const firstUri = document.uri.toString();
|
const firstUri = document.uri.toString();
|
||||||
|
const refreshed = documentChanged(document, (text) =>
|
||||||
|
text.includes("queued from another client"),
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await control(image.address, {
|
await control(image.address, {
|
||||||
operation: "send",
|
operation: "send",
|
||||||
|
|
@ -26,11 +29,22 @@ export async function run(): Promise<void> {
|
||||||
}),
|
}),
|
||||||
{ version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } },
|
{ version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } },
|
||||||
);
|
);
|
||||||
const refreshed = documentChanged(document);
|
|
||||||
await vscode.commands.executeCommand("runa.refresh");
|
|
||||||
await refreshed;
|
await refreshed;
|
||||||
assert.match(document.getText(), /queued from another client/);
|
assert.match(document.getText(), /queued from another client/);
|
||||||
const disconnected = documentChanged(document);
|
const paused = documentChanged(document, (text) => text.includes('"status": "paused"'));
|
||||||
|
await control(image.address, { operation: "pause_actor", parameters: { actor_ref: actorRef } });
|
||||||
|
await paused;
|
||||||
|
const failed = documentChanged(document, (text) => text.includes('"status": "failed"'));
|
||||||
|
await control(image.address, {
|
||||||
|
operation: "resume_actor",
|
||||||
|
parameters: { actor_ref: actorRef },
|
||||||
|
});
|
||||||
|
await control(image.address, {
|
||||||
|
operation: "tick",
|
||||||
|
parameters: { ticks: 100, instruction_budget: 10 },
|
||||||
|
});
|
||||||
|
await failed;
|
||||||
|
const disconnected = documentChanged(document, (text) => text.includes('"connected": false'));
|
||||||
await vscode.commands.executeCommand("runa.disconnect");
|
await vscode.commands.executeCommand("runa.disconnect");
|
||||||
await disconnected;
|
await disconnected;
|
||||||
assert.match(document.getText(), /"connected": false/);
|
assert.match(document.getText(), /"connected": false/);
|
||||||
|
|
@ -42,17 +56,25 @@ export async function run(): Promise<void> {
|
||||||
assert.match(document.getText(), /queued from another client/);
|
assert.match(document.getText(), /queued from another client/);
|
||||||
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
|
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
|
||||||
assert.match(old.getText(), /"connected": false/);
|
assert.match(old.getText(), /"connected": false/);
|
||||||
console.log("Runa extension host: connect, inspect, refresh, disconnect, reconnect passed");
|
console.log(
|
||||||
|
"Runa extension host: connect, inspect, automatic refresh, disconnect, reconnect passed",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await vscode.commands.executeCommand("runa.disconnect");
|
await vscode.commands.executeCommand("runa.disconnect");
|
||||||
await image.stop();
|
await image.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentChanged(document: vscode.TextDocument): Promise<void> {
|
function documentChanged(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
expected: (text: string) => boolean,
|
||||||
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const subscription = vscode.workspace.onDidChangeTextDocument((event) => {
|
const subscription = vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
if (event.document.uri.toString() === document.uri.toString()) {
|
if (
|
||||||
|
event.document.uri.toString() === document.uri.toString() &&
|
||||||
|
expected(event.document.getText())
|
||||||
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
subscription.dispose();
|
subscription.dispose();
|
||||||
resolve();
|
resolve();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { ImageClient } from "../src/client";
|
||||||
|
import { startImage } from "./server";
|
||||||
|
|
||||||
|
test(
|
||||||
|
"the real REPL mutates the same WebSocket image and notifies the inspection client",
|
||||||
|
{ timeout: 10000 },
|
||||||
|
async (t) => {
|
||||||
|
const image = await startImage();
|
||||||
|
let inspector: ImageClient | undefined;
|
||||||
|
t.after(async () => {
|
||||||
|
inspector?.close();
|
||||||
|
await image.stop();
|
||||||
|
});
|
||||||
|
const revisions: bigint[] = [];
|
||||||
|
let onChanged = () => {};
|
||||||
|
const changed = new Promise<void>((resolve) => {
|
||||||
|
onChanged = resolve;
|
||||||
|
});
|
||||||
|
inspector = await ImageClient.connect(
|
||||||
|
image.address,
|
||||||
|
(error) => assert.equal(error, undefined),
|
||||||
|
(revision) => {
|
||||||
|
revisions.push(revision);
|
||||||
|
onChanged();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const executable = resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../../target/debug",
|
||||||
|
process.platform === "win32" ? "runa.exe" : "runa",
|
||||||
|
);
|
||||||
|
const repl = spawn(executable, ["--connect", image.address], {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
if (repl.exitCode === null && repl.signalCode === null) repl.kill();
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
repl.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
repl.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
const exited = once(repl, "exit");
|
||||||
|
repl.stdin.end(
|
||||||
|
[
|
||||||
|
"let actor = spawn counter 5",
|
||||||
|
'send actor {"op":"add","value":7}',
|
||||||
|
"tick 100",
|
||||||
|
"inspect actor",
|
||||||
|
"quit",
|
||||||
|
].join("\n") + "\n",
|
||||||
|
);
|
||||||
|
const [code] = await exited;
|
||||||
|
assert.equal(code, 0, stderr);
|
||||||
|
assert.doesNotMatch(stdout, /error:/i);
|
||||||
|
await changed;
|
||||||
|
assert.ok(revisions.length > 0);
|
||||||
|
const [actor] = await inspector.listActors();
|
||||||
|
assert.ok(actor);
|
||||||
|
assert.ok(stdout.includes(actor.actor_ref));
|
||||||
|
assert.deepEqual(actor.state, { type: "integer", data: 12n });
|
||||||
|
assert.deepEqual(actor.queued, []);
|
||||||
|
assert.equal(actor.reserved, null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -3,50 +3,76 @@ import test from "node:test";
|
||||||
import { ImageClient } from "../src/client";
|
import { ImageClient } from "../src/client";
|
||||||
import { control, spawnCounter, startImage } from "./server";
|
import { control, spawnCounter, startImage } from "./server";
|
||||||
|
|
||||||
test("TypeScript inspection shares the real Rust image across clients and reconnects", async (t) => {
|
test(
|
||||||
const image = await startImage();
|
"TypeScript inspection shares the real Rust image across clients and reconnects",
|
||||||
t.after(() => image.stop());
|
{ timeout: 10000 },
|
||||||
const actorRef = await spawnCounter(image.address);
|
async (t) => {
|
||||||
const reference = { type: "actor_ref", data: actorRef };
|
const image = await startImage();
|
||||||
const payload = {
|
t.after(() => image.stop());
|
||||||
type: "record",
|
const actorRef = await spawnCounter(image.address);
|
||||||
data: {
|
const reference = { type: "actor_ref", data: actorRef };
|
||||||
nested: {
|
const payload = {
|
||||||
type: "list",
|
type: "record",
|
||||||
data: [reference, { type: "integer", data: -9223372036854775808n }],
|
data: {
|
||||||
|
nested: {
|
||||||
|
type: "list",
|
||||||
|
data: [reference, { type: "integer", data: -9223372036854775808n }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
assert.deepEqual(
|
||||||
assert.deepEqual(
|
await control(image.address, {
|
||||||
await control(image.address, {
|
operation: "send",
|
||||||
operation: "send",
|
parameters: { actor_ref: actorRef, message: payload },
|
||||||
parameters: { actor_ref: actorRef, message: payload },
|
}),
|
||||||
}),
|
{ version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } },
|
||||||
{ version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } },
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const failures: Error[] = [];
|
const failures: Error[] = [];
|
||||||
const disconnected = (error?: Error) => {
|
const disconnected = (error?: Error) => {
|
||||||
if (error) failures.push(error);
|
if (error) failures.push(error);
|
||||||
};
|
};
|
||||||
const first = await ImageClient.connect(image.address, disconnected);
|
const first = await ImageClient.connect(image.address, disconnected);
|
||||||
t.after(() => first.close());
|
t.after(() => first.close());
|
||||||
const [snapshot] = await first.listActors();
|
const [snapshot] = await first.listActors();
|
||||||
assert.ok(snapshot);
|
assert.ok(snapshot);
|
||||||
assert.equal(snapshot.actor_ref, actorRef);
|
assert.equal(snapshot.actor_ref, actorRef);
|
||||||
assert.deepEqual(snapshot.state, { type: "integer", data: 9223372036854775807n });
|
assert.deepEqual(snapshot.state, { type: "integer", data: 9223372036854775807n });
|
||||||
assert.deepEqual(snapshot.queued, [payload]);
|
assert.deepEqual(snapshot.queued, [payload]);
|
||||||
assert.equal(snapshot.reserved, null);
|
assert.equal(snapshot.reserved, null);
|
||||||
first.close();
|
first.close();
|
||||||
const second = await ImageClient.connect(image.address, disconnected);
|
const revisions: bigint[] = [];
|
||||||
t.after(() => second.close());
|
let onChanged = () => {};
|
||||||
assert.deepEqual(await second.inspectActor(actorRef), snapshot);
|
const changed = new Promise<void>((resolve) => {
|
||||||
// Reading from the extension must not drain the shared runtime event queue.
|
onChanged = resolve;
|
||||||
const events = await control(image.address, { operation: "get_events" });
|
});
|
||||||
assert.deepEqual(events, {
|
const second = await ImageClient.connect(image.address, disconnected, (revision) => {
|
||||||
version: 3,
|
revisions.push(revision);
|
||||||
request_id: "integration-control",
|
onChanged();
|
||||||
result: { Ok: { kind: "events", data: [{ event: "spawned", data: { actor_ref: actorRef } }] } },
|
});
|
||||||
});
|
t.after(() => second.close());
|
||||||
assert.deepEqual(failures, []);
|
assert.deepEqual(await second.inspectActor(actorRef), snapshot);
|
||||||
});
|
await control(image.address, { operation: "pause_actor", parameters: { actor_ref: actorRef } });
|
||||||
|
await changed;
|
||||||
|
assert.equal(revisions.length, 1);
|
||||||
|
assert.ok(revisions[0] !== undefined && revisions[0] > 0n);
|
||||||
|
const paused = await second.inspectActor(actorRef);
|
||||||
|
assert.equal(paused.status.status, "paused");
|
||||||
|
// Reading from the extension must not drain the shared runtime event queue.
|
||||||
|
const events = await control(image.address, { operation: "get_events" });
|
||||||
|
assert.deepEqual(events, {
|
||||||
|
version: 3,
|
||||||
|
request_id: "integration-control",
|
||||||
|
result: {
|
||||||
|
Ok: {
|
||||||
|
kind: "events",
|
||||||
|
data: [
|
||||||
|
{ event: "spawned", data: { actor_ref: actorRef } },
|
||||||
|
{ event: "paused", data: { actor_ref: actorRef } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(failures, []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { ChildProcess, spawn } from "node:child_process";
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { createConnection } from "node:net";
|
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { json } from "../test/fixtures";
|
import { json } from "../test/fixtures";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import { decodeNotification, WEBSOCKET_SUBPROTOCOL } from "../src/protocol";
|
||||||
|
|
||||||
export async function startImage(): Promise<{ address: string; stop: () => Promise<void> }> {
|
export async function startImage(): Promise<{ address: string; stop: () => Promise<void> }> {
|
||||||
const executable = resolve(
|
const executable = resolve(
|
||||||
|
|
@ -62,28 +63,30 @@ async function stopImage(child: ChildProcess): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function control(address: string, command: unknown): Promise<unknown> {
|
export async function control(address: string, command: unknown): Promise<unknown> {
|
||||||
const port = Number(address.split(":")[1]);
|
const socket = new WebSocket(`ws://${address}/`, WEBSOCKET_SUBPROTOCOL);
|
||||||
const socket = createConnection({ host: "127.0.0.1", port });
|
const timer = setTimeout(() => socket.terminate(), 5000);
|
||||||
socket.setTimeout(5000, () => socket.destroy(new Error("Control request timed out")));
|
|
||||||
const lines = createInterface({ input: socket });
|
|
||||||
try {
|
try {
|
||||||
await once(socket, "connect");
|
await once(socket, "open");
|
||||||
const response = Promise.race([
|
const response = new Promise<unknown>((resolve, reject) => {
|
||||||
once(lines, "line").then((args) => {
|
socket.on("message", (data, binary) => {
|
||||||
const line: unknown = args[0];
|
try {
|
||||||
if (typeof line !== "string") throw new Error("Missing control response");
|
if (binary) throw new Error("Unexpected binary control reply");
|
||||||
const decoded: unknown = JSON.parse(line);
|
const text = data.toString();
|
||||||
return decoded;
|
if (decodeNotification(text) !== undefined) return;
|
||||||
}),
|
const decoded: unknown = JSON.parse(text);
|
||||||
once(socket, "close").then(() => {
|
resolve(decoded);
|
||||||
throw new Error("Control connection closed");
|
} catch (error) {
|
||||||
}),
|
reject(error);
|
||||||
]);
|
}
|
||||||
socket.write(json({ version: 3, request_id: "integration-control", command }) + "\n");
|
});
|
||||||
|
socket.once("close", () => reject(new Error("Control connection closed")));
|
||||||
|
socket.once("error", reject);
|
||||||
|
});
|
||||||
|
socket.send(json({ version: 3, request_id: "integration-control", command }));
|
||||||
return await response;
|
return await response;
|
||||||
} finally {
|
} finally {
|
||||||
lines.close();
|
clearTimeout(timer);
|
||||||
socket.destroy();
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import test from "node:test";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import { MAX_MESSAGE_BYTES, WEBSOCKET_SUBPROTOCOL } from "../src/protocol";
|
||||||
|
import { json } from "../test/fixtures";
|
||||||
|
import { control, startImage } from "./server";
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Rust server accepts fragmented text at the exact message limit and handles ping",
|
||||||
|
{ timeout: 10000 },
|
||||||
|
async (t) => {
|
||||||
|
const image = await startImage();
|
||||||
|
t.after(() => image.stop());
|
||||||
|
const socket = new WebSocket(`ws://${image.address}/`, WEBSOCKET_SUBPROTOCOL);
|
||||||
|
t.after(() => socket.terminate());
|
||||||
|
await once(socket, "open");
|
||||||
|
assert.equal(socket.protocol, WEBSOCKET_SUBPROTOCOL);
|
||||||
|
const request = json({
|
||||||
|
version: 3,
|
||||||
|
request_id: "fragmented",
|
||||||
|
command: { operation: "list_actors" },
|
||||||
|
});
|
||||||
|
const text = request + " ".repeat(MAX_MESSAGE_BYTES - Buffer.byteLength(request));
|
||||||
|
const reply = once(socket, "message");
|
||||||
|
socket.send(text.slice(0, 100), { fin: false });
|
||||||
|
socket.send(text.slice(100), { fin: true });
|
||||||
|
const [response] = await reply;
|
||||||
|
assert.deepEqual(JSON.parse(String(response)), {
|
||||||
|
version: 3,
|
||||||
|
request_id: "fragmented",
|
||||||
|
result: { Ok: { kind: "actors", data: [] } },
|
||||||
|
});
|
||||||
|
const pong = once(socket, "pong");
|
||||||
|
socket.ping("runa-debug");
|
||||||
|
const [payload] = await pong;
|
||||||
|
assert.equal(String(payload), "runa-debug");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Rust server rejects an oversized fragmented aggregate and keeps the image available",
|
||||||
|
{ timeout: 10000 },
|
||||||
|
async (t) => {
|
||||||
|
const image = await startImage();
|
||||||
|
t.after(() => image.stop());
|
||||||
|
const socket = new WebSocket(`ws://${image.address}/`, WEBSOCKET_SUBPROTOCOL);
|
||||||
|
t.after(() => socket.terminate());
|
||||||
|
await once(socket, "open");
|
||||||
|
const closed = once(socket, "close");
|
||||||
|
socket.send(" ".repeat(MAX_MESSAGE_BYTES), { fin: false });
|
||||||
|
socket.send(" ", { fin: true });
|
||||||
|
await closed;
|
||||||
|
assert.deepEqual(await control(image.address, { operation: "list_actors" }), {
|
||||||
|
version: 3,
|
||||||
|
request_id: "integration-control",
|
||||||
|
result: { Ok: { kind: "actors", data: [] } },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -8,11 +8,13 @@
|
||||||
"name": "runa",
|
"name": "runa",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lossless-json": "^4.3.0"
|
"lossless-json": "^4.3.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/vscode": "~1.96.0",
|
"@types/vscode": "~1.96.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@vscode/test-electron": "^2.5.2",
|
"@vscode/test-electron": "^2.5.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|
@ -39,6 +41,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||||
|
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vscode/test-electron": {
|
"node_modules/@vscode/test-electron": {
|
||||||
"version": "2.5.2",
|
"version": "2.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz",
|
||||||
|
|
@ -547,6 +559,27 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||||
|
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,18 @@
|
||||||
"lint": "tsc -p . --noEmit && prettier --check .",
|
"lint": "tsc -p . --noEmit && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "npm run compile && node --test out/test/*.test.js",
|
"test": "npm run compile && node --test out/test/*.test.js",
|
||||||
"test:integration": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node --test out/integration/*.test.js",
|
"test:integration": "cargo build --manifest-path ../Cargo.toml -p runa-server -p runa && npm run compile && node --test out/integration/*.test.js",
|
||||||
"test:host": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node scripts/test-host.cjs"
|
"test:host": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node scripts/test-host.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lossless-json": "^4.3.0"
|
"lossless-json": "^4.3.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vscode/test-electron": "^2.5.2",
|
"@vscode/test-electron": "^2.5.2",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/vscode": "~1.96.0",
|
"@types/vscode": "~1.96.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { createConnection, isIP, Socket } from "node:net";
|
import { isIP } from "node:net";
|
||||||
import { TextDecoder } from "node:util";
|
import { TextDecoder } from "node:util";
|
||||||
|
import WebSocket, { RawData } from "ws";
|
||||||
import {
|
import {
|
||||||
ActorSnapshot,
|
ActorSnapshot,
|
||||||
|
decodeNotification,
|
||||||
decodeResponse,
|
decodeResponse,
|
||||||
isActorRef,
|
isActorRef,
|
||||||
MAX_LINE_BYTES,
|
MAX_MESSAGE_BYTES,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
ReadCommand,
|
ReadCommand,
|
||||||
ReadReply,
|
ReadReply,
|
||||||
|
WEBSOCKET_SUBPROTOCOL,
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
|
|
||||||
export function parseEndpoint(endpoint: string): { host: string; port: number } {
|
export function parseEndpoint(endpoint: string): { host: string; port: number } {
|
||||||
|
|
@ -22,7 +25,8 @@ export function parseEndpoint(endpoint: string): { host: string; port: number }
|
||||||
throw new Error("Only numeric IPv4 loopback or [::1] endpoints are supported");
|
throw new Error("Only numeric IPv4 loopback or [::1] endpoints are supported");
|
||||||
}
|
}
|
||||||
const port = Number(match[3]);
|
const port = Number(match[3]);
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid TCP port");
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||||
|
throw new Error("Invalid WebSocket port");
|
||||||
return { host, port };
|
return { host, port };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,52 +39,61 @@ interface Pending {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageClient {
|
export class ImageClient {
|
||||||
private buffer = Buffer.alloc(0);
|
|
||||||
private pending?: Pending;
|
private pending?: Pending;
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
private sequence = 0;
|
private sequence = 0;
|
||||||
|
private revision = 0n;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
private ready = false;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly socket: Socket,
|
private readonly socket: WebSocket,
|
||||||
private readonly onDisconnect: (error?: Error) => void,
|
private readonly onDisconnect: (error?: Error) => void,
|
||||||
|
private readonly onImageChanged: (revision: bigint) => void,
|
||||||
private readonly requestTimeout: number,
|
private readonly requestTimeout: number,
|
||||||
) {
|
) {
|
||||||
socket.on("data", (chunk: Buffer) => this.receive(chunk));
|
socket.on("message", (data, binary) => this.receive(data, binary));
|
||||||
socket.on("error", (error) => this.close(error));
|
socket.on("error", (error) => this.close(error));
|
||||||
socket.on("close", () => this.close(new Error("Image connection closed")));
|
socket.on("close", () => this.close(new Error("Image WebSocket closed")));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async connect(
|
static async connect(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
onDisconnect: (error?: Error) => void,
|
onDisconnect: (error?: Error) => void,
|
||||||
|
onImageChanged: (revision: bigint) => void = () => {},
|
||||||
timeouts = { connect: 5000, request: 30000 },
|
timeouts = { connect: 5000, request: 30000 },
|
||||||
): Promise<ImageClient> {
|
): Promise<ImageClient> {
|
||||||
const address = parseEndpoint(endpoint);
|
parseEndpoint(endpoint);
|
||||||
const socket = createConnection(address);
|
const socket = new WebSocket(`ws://${endpoint}/`, WEBSOCKET_SUBPROTOCOL, {
|
||||||
await new Promise<void>((resolve, reject) => {
|
handshakeTimeout: timeouts.connect,
|
||||||
const cleanup = () => {
|
maxPayload: MAX_MESSAGE_BYTES,
|
||||||
clearTimeout(timer);
|
perMessageDeflate: false,
|
||||||
socket.off("error", fail);
|
followRedirects: false,
|
||||||
socket.off("connect", connected);
|
|
||||||
};
|
|
||||||
const fail = (error: Error) => {
|
|
||||||
cleanup();
|
|
||||||
socket.destroy();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
const connected = () => {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
const timer = setTimeout(
|
|
||||||
() => fail(new Error("Image connection timed out")),
|
|
||||||
timeouts.connect,
|
|
||||||
);
|
|
||||||
socket.once("error", fail);
|
|
||||||
socket.once("connect", connected);
|
|
||||||
});
|
});
|
||||||
return new ImageClient(socket, onDisconnect, timeouts.request);
|
const client = new ImageClient(socket, onDisconnect, onImageChanged, timeouts.request);
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
socket.off("open", opened);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const opened = () => {
|
||||||
|
socket.off("error", fail);
|
||||||
|
if (socket.protocol !== WEBSOCKET_SUBPROTOCOL) {
|
||||||
|
reject(new Error("Server did not negotiate runa-debug"));
|
||||||
|
} else {
|
||||||
|
client.ready = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.once("error", fail);
|
||||||
|
socket.once("open", opened);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
client.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActors(): Promise<ActorSnapshot[]> {
|
async listActors(): Promise<ActorSnapshot[]> {
|
||||||
|
|
@ -108,7 +121,7 @@ export class ImageClient {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestId = `runa-vscode-${++this.sequence}`;
|
const requestId = `runa-vscode-${++this.sequence}`;
|
||||||
const line = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
version: PROTOCOL_VERSION,
|
version: PROTOCOL_VERSION,
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
command,
|
command,
|
||||||
|
|
@ -118,12 +131,12 @@ export class ImageClient {
|
||||||
this.requestTimeout,
|
this.requestTimeout,
|
||||||
);
|
);
|
||||||
this.pending = { requestId, command, resolve, reject, timer };
|
this.pending = { requestId, command, resolve, reject, timer };
|
||||||
this.socket.write(line + "\n", (error) => {
|
this.socket.send(message, (error) => {
|
||||||
if (error) this.close(error);
|
if (error) this.close(error);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// A failed request must not prevent later requests; each caller retains its own rejection.
|
// Each caller retains its own rejection, without poisoning subsequent requests.
|
||||||
this.queue = request.then(
|
this.queue = request.then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
() => undefined,
|
() => undefined,
|
||||||
|
|
@ -131,48 +144,49 @@ export class ImageClient {
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private receive(chunk: Buffer): void {
|
private receive(data: RawData, binary: boolean): void {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
let offset = 0;
|
try {
|
||||||
while (offset < chunk.length && !this.closed) {
|
if (binary) throw new Error("Only WebSocket text protocol messages are accepted");
|
||||||
if (!this.pending) {
|
const bytes = Array.isArray(data)
|
||||||
this.close(new Error("Unexpected unsolicited image response"));
|
? Buffer.concat(data)
|
||||||
|
: data instanceof ArrayBuffer
|
||||||
|
? Buffer.from(data)
|
||||||
|
: data;
|
||||||
|
if (bytes.length > MAX_MESSAGE_BYTES) throw new Error("Image response exceeds 1 MiB");
|
||||||
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
const revision = decodeNotification(text);
|
||||||
|
if (revision !== undefined) {
|
||||||
|
if (revision <= this.revision)
|
||||||
|
throw new Error("Non-increasing image notification revision");
|
||||||
|
this.revision = revision;
|
||||||
|
this.onImageChanged(revision);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newline = chunk.indexOf(10, offset);
|
|
||||||
const end = newline === -1 ? chunk.length : newline;
|
|
||||||
if (this.buffer.length + end - offset > MAX_LINE_BYTES) {
|
|
||||||
this.close(new Error("Image response exceeds the 1 MiB line limit"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.buffer = Buffer.concat([this.buffer, chunk.subarray(offset, end)]);
|
|
||||||
if (newline === -1) return;
|
|
||||||
const frame = this.buffer;
|
|
||||||
this.buffer = Buffer.alloc(0);
|
|
||||||
offset = newline + 1;
|
|
||||||
const pending = this.pending;
|
const pending = this.pending;
|
||||||
|
if (!pending) throw new Error("Unexpected unsolicited image response");
|
||||||
|
try {
|
||||||
|
pending.resolve(decodeResponse(text, pending.requestId, pending.command));
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ProtocolError)) throw error;
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
this.pending = undefined;
|
this.pending = undefined;
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
try {
|
} catch (error) {
|
||||||
const line = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
this.close(error instanceof Error ? error : new Error(String(error)));
|
||||||
pending.resolve(decodeResponse(line, pending.requestId, pending.command));
|
|
||||||
} catch (error) {
|
|
||||||
const failure = error instanceof Error ? error : new Error(String(error));
|
|
||||||
pending.reject(failure);
|
|
||||||
if (!(failure instanceof ProtocolError)) this.close(failure);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(error?: Error): void {
|
close(error?: Error): void {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.socket.destroy();
|
this.socket.terminate();
|
||||||
if (this.pending) {
|
if (this.pending) {
|
||||||
clearTimeout(this.pending.timer);
|
clearTimeout(this.pending.timer);
|
||||||
this.pending.reject(error ?? new Error("Disconnected from image"));
|
this.pending.reject(error ?? new Error("Disconnected from image"));
|
||||||
this.pending = undefined;
|
this.pending = undefined;
|
||||||
}
|
}
|
||||||
this.onDisconnect(error);
|
if (this.ready) this.onDisconnect(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { ImageClient, parseEndpoint } from "./client";
|
import { ImageClient, parseEndpoint } from "./client";
|
||||||
import { ActorSnapshot, formatSnapshot } from "./protocol";
|
import { ActorSnapshot, formatSnapshot } from "./protocol";
|
||||||
|
import { RefreshQueue } from "./refresh";
|
||||||
|
|
||||||
class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
|
class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
|
||||||
private readonly changes = new vscode.EventEmitter<void>();
|
private readonly changes = new vscode.EventEmitter<void>();
|
||||||
|
|
@ -123,6 +124,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
let session = 0;
|
let session = 0;
|
||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let automaticRefresh: RefreshQueue | undefined;
|
||||||
|
|
||||||
const report = (error: unknown) => {
|
const report = (error: unknown) => {
|
||||||
const failure = error instanceof Error ? error : new Error(String(error));
|
const failure = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
@ -144,6 +146,8 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
|
automaticRefresh?.dispose();
|
||||||
|
automaticRefresh = undefined;
|
||||||
const previous = client;
|
const previous = client;
|
||||||
client = undefined;
|
client = undefined;
|
||||||
documents.disconnect(session);
|
documents.disconnect(session);
|
||||||
|
|
@ -190,17 +194,27 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
const currentSession = session;
|
const currentSession = session;
|
||||||
view.message = `Connecting to ${address}...`;
|
view.message = `Connecting to ${address}...`;
|
||||||
let connected: ImageClient;
|
let connected: ImageClient;
|
||||||
|
const updates = new RefreshQueue(async () => {
|
||||||
|
if (currentSession === session && client) await refresh();
|
||||||
|
}, report);
|
||||||
|
automaticRefresh = updates;
|
||||||
try {
|
try {
|
||||||
connected = await ImageClient.connect(address, (error) => {
|
connected = await ImageClient.connect(
|
||||||
if (currentSession !== session || disposed) return;
|
address,
|
||||||
client = undefined;
|
(error) => {
|
||||||
tree.replace([]);
|
if (currentSession !== session || disposed) return;
|
||||||
documents.disconnect(currentSession);
|
client = undefined;
|
||||||
connectionStatus(false);
|
updates.dispose();
|
||||||
output.appendLine(`Disconnected from ${address}`);
|
tree.replace([]);
|
||||||
if (error) report(error);
|
documents.disconnect(currentSession);
|
||||||
});
|
connectionStatus(false);
|
||||||
|
output.appendLine(`Disconnected from ${address}`);
|
||||||
|
if (error) report(error);
|
||||||
|
},
|
||||||
|
() => updates.invalidate(),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
updates.dispose();
|
||||||
if (currentSession === session) connectionStatus(false);
|
if (currentSession === session) connectionStatus(false);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -276,6 +290,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
{
|
{
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
automaticRefresh?.dispose();
|
||||||
client?.close();
|
client?.close();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { parse, stringify } from "lossless-json";
|
import { parse, stringify } from "lossless-json";
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 3;
|
export const PROTOCOL_VERSION = 3;
|
||||||
export const MAX_LINE_BYTES = 1024 * 1024;
|
export const MAX_MESSAGE_BYTES = 1024 * 1024;
|
||||||
|
export const WEBSOCKET_SUBPROTOCOL = "runa-debug";
|
||||||
const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/;
|
const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/;
|
||||||
|
|
||||||
export type Value =
|
export type Value =
|
||||||
|
|
@ -104,7 +105,7 @@ export function decodeValue(input: unknown): Value {
|
||||||
case "string": {
|
case "string": {
|
||||||
const data = text(value.data);
|
const data = text(value.data);
|
||||||
bytes += Buffer.byteLength(data);
|
bytes += Buffer.byteLength(data);
|
||||||
if (bytes > MAX_LINE_BYTES) throw new Error("Value exceeds string/key byte limit");
|
if (bytes > MAX_MESSAGE_BYTES) throw new Error("Value exceeds string/key byte limit");
|
||||||
return { type, data };
|
return { type, data };
|
||||||
}
|
}
|
||||||
case "actor_ref":
|
case "actor_ref":
|
||||||
|
|
@ -115,7 +116,7 @@ export function decodeValue(input: unknown): Value {
|
||||||
case "record": {
|
case "record": {
|
||||||
const entries = Object.entries(object(value.data)).map(([key, child]) => {
|
const entries = Object.entries(object(value.data)).map(([key, child]) => {
|
||||||
bytes += Buffer.byteLength(key);
|
bytes += Buffer.byteLength(key);
|
||||||
if (bytes > MAX_LINE_BYTES) throw new Error("Value exceeds string/key byte limit");
|
if (bytes > MAX_MESSAGE_BYTES) throw new Error("Value exceeds string/key byte limit");
|
||||||
return [key, visit(child, depth + 1)];
|
return [key, visit(child, depth + 1)];
|
||||||
});
|
});
|
||||||
return { type, data: Object.fromEntries(entries) };
|
return { type, data: Object.fromEntries(entries) };
|
||||||
|
|
@ -163,14 +164,35 @@ function decodeActor(input: unknown): ActorSnapshot {
|
||||||
export type ReadReply =
|
export type ReadReply =
|
||||||
{ kind: "actors"; data: ActorSnapshot[] } | { kind: "actor"; data: ActorSnapshot };
|
{ kind: "actors"; data: ActorSnapshot[] } | { kind: "actor"; data: ActorSnapshot };
|
||||||
|
|
||||||
export function decodeResponse(line: string, requestId: string, command: ReadCommand): ReadReply {
|
function decodeEnvelope(message: string): Record<string, unknown> {
|
||||||
const response = object(
|
const envelope = object(
|
||||||
parse(line, undefined, (literal) => {
|
parse(message, undefined, (literal) => {
|
||||||
if (!/^-?(0|[1-9]\d*)$/.test(literal)) throw new Error("Non-integer protocol number");
|
if (!/^-?(0|[1-9]\d*)$/.test(literal)) throw new Error("Non-integer protocol number");
|
||||||
return BigInt(literal);
|
return BigInt(literal);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (response.version !== BigInt(PROTOCOL_VERSION)) throw new Error("Protocol version mismatch");
|
if (envelope.version !== BigInt(PROTOCOL_VERSION)) throw new Error("Protocol version mismatch");
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeNotification(message: string): bigint | undefined {
|
||||||
|
const envelope = decodeEnvelope(message);
|
||||||
|
if (!Object.hasOwn(envelope, "event")) return undefined;
|
||||||
|
if (Object.keys(envelope).length !== 2) throw new Error("Invalid notification envelope");
|
||||||
|
const event = object(envelope.event);
|
||||||
|
if (event.event !== "image_changed" || Object.keys(event).length !== 2) {
|
||||||
|
throw new Error("Unsupported server event");
|
||||||
|
}
|
||||||
|
const data = object(event.data);
|
||||||
|
const revision = uint(data.revision);
|
||||||
|
if (Object.keys(data).length !== 1 || revision === 0n) {
|
||||||
|
throw new Error("Invalid image revision");
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeResponse(line: string, requestId: string, command: ReadCommand): ReadReply {
|
||||||
|
const response = decodeEnvelope(line);
|
||||||
if (response.request_id !== requestId) throw new Error("Response request ID mismatch");
|
if (response.request_id !== requestId) throw new Error("Response request ID mismatch");
|
||||||
const result = object(response.result);
|
const result = object(response.result);
|
||||||
if (Object.keys(result).length !== 1) throw new Error("Invalid response result");
|
if (Object.keys(result).length !== 1) throw new Error("Invalid response result");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/** Coalesce invalidations and rerun if a change arrives during an in-flight read. */
|
||||||
|
export class RefreshQueue {
|
||||||
|
private dirty = false;
|
||||||
|
private running = false;
|
||||||
|
private stopped = false;
|
||||||
|
private timer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly refresh: () => Promise<void>,
|
||||||
|
private readonly report: (error: unknown) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.dirty = true;
|
||||||
|
if (this.running || this.timer) return;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
void this.run();
|
||||||
|
}, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(): Promise<void> {
|
||||||
|
this.running = true;
|
||||||
|
this.dirty = false;
|
||||||
|
try {
|
||||||
|
await this.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.stopped) this.report(error);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
if (this.dirty) this.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@ import assert from "node:assert/strict";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { ImageClient, parseEndpoint } from "../src/client";
|
import { ImageClient, parseEndpoint } from "../src/client";
|
||||||
import { MAX_LINE_BYTES, ProtocolError } from "../src/protocol";
|
import { MAX_MESSAGE_BYTES, ProtocolError } from "../src/protocol";
|
||||||
import { actor, fakeImage, json, REF, response } from "./fixtures";
|
import { actor, fakeImage, json, notification, REF, response } from "./fixtures";
|
||||||
|
|
||||||
test("only numeric loopback endpoints and valid ports are accepted", () => {
|
test("only numeric loopback endpoints and valid ports are accepted", () => {
|
||||||
assert.deepEqual(parseEndpoint("127.0.0.1:3737"), { host: "127.0.0.1", port: 3737 });
|
assert.deepEqual(parseEndpoint("127.0.0.1:3737"), { host: "127.0.0.1", port: 3737 });
|
||||||
|
|
@ -36,8 +36,8 @@ test("fragmented UTF-8 replies and simultaneous reads preserve correlation and v
|
||||||
: { kind: "actor", data: actor() };
|
: { kind: "actor", data: actor() };
|
||||||
const bytes = Buffer.from(response(request, reply));
|
const bytes = Buffer.from(response(request, reply));
|
||||||
const split = bytes.indexOf(Buffer.from("雪")) + 1;
|
const split = bytes.indexOf(Buffer.from("雪")) + 1;
|
||||||
socket.write(bytes.subarray(0, split));
|
socket.send(bytes.subarray(0, split), { binary: false, fin: false });
|
||||||
setImmediate(() => socket.write(bytes.subarray(split)));
|
setImmediate(() => socket.send(bytes.subarray(split), { binary: false, fin: true }));
|
||||||
});
|
});
|
||||||
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
|
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
|
||||||
t.after(() => client.close());
|
t.after(() => client.close());
|
||||||
|
|
@ -51,13 +51,13 @@ test("protocol errors preserve the connection and are never retried", async (t)
|
||||||
let requests = 0;
|
let requests = 0;
|
||||||
const address = await fakeImage(t, (request, socket) => {
|
const address = await fakeImage(t, (request, socket) => {
|
||||||
requests++;
|
requests++;
|
||||||
socket.write(
|
socket.send(
|
||||||
requests === 1
|
requests === 1
|
||||||
? json({
|
? json({
|
||||||
version: 3,
|
version: 3,
|
||||||
request_id: request.request_id,
|
request_id: request.request_id,
|
||||||
result: { Err: { code: "not_found", message: "Missing actor" } },
|
result: { Err: { code: "not_found", message: "Missing actor" } },
|
||||||
}) + "\n"
|
})
|
||||||
: response(request, { kind: "actors", data: [] }),
|
: response(request, { kind: "actors", data: [] }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -69,23 +69,37 @@ test("protocol errors preserve the connection and are never retried", async (t)
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bad correlation, malformed JSON/UTF-8, oversize, truncation, and wrong versions close the stream", async (t) => {
|
test("bad correlation, malformed JSON/UTF-8, oversize, truncation, and wrong versions close the stream", async (t) => {
|
||||||
for (const mode of ["correlation", "json", "utf8", "size", "truncated", "version", "kind"]) {
|
for (const mode of [
|
||||||
|
"correlation",
|
||||||
|
"json",
|
||||||
|
"utf8",
|
||||||
|
"size",
|
||||||
|
"truncated",
|
||||||
|
"version",
|
||||||
|
"kind",
|
||||||
|
"binary",
|
||||||
|
"event",
|
||||||
|
]) {
|
||||||
await t.test(mode, async (t) => {
|
await t.test(mode, async (t) => {
|
||||||
let requests = 0;
|
let requests = 0;
|
||||||
const failures: Error[] = [];
|
const failures: Error[] = [];
|
||||||
const address = await fakeImage(t, (request, socket) => {
|
const address = await fakeImage(t, (request, socket) => {
|
||||||
requests++;
|
requests++;
|
||||||
if (mode === "json") socket.write("{broken}\n");
|
if (mode === "json") socket.send("{broken}");
|
||||||
else if (mode === "utf8") socket.write(Buffer.from([0xff, 10]));
|
else if (mode === "utf8") socket.send(Buffer.from([0xff]), { binary: false });
|
||||||
else if (mode === "size") socket.write(Buffer.alloc(MAX_LINE_BYTES + 1, 32));
|
else if (mode === "size") socket.send(" ".repeat(MAX_MESSAGE_BYTES + 1));
|
||||||
else if (mode === "truncated") socket.end('{"version":3');
|
else if (mode === "truncated") {
|
||||||
|
socket.send('{"version":3', { fin: false });
|
||||||
|
socket.terminate();
|
||||||
|
} else if (mode === "binary") socket.send(Buffer.from("{}"));
|
||||||
|
else if (mode === "event") socket.send(json({ version: 3, event: { event: "unknown" } }));
|
||||||
else
|
else
|
||||||
socket.write(
|
socket.send(
|
||||||
json({
|
json({
|
||||||
version: mode === "version" ? 2 : 3,
|
version: mode === "version" ? 2 : 3,
|
||||||
request_id: mode === "correlation" ? "wrong" : request.request_id,
|
request_id: mode === "correlation" ? "wrong" : request.request_id,
|
||||||
result: { Ok: { kind: mode === "kind" ? "acknowledged" : "actors", data: [] } },
|
result: { Ok: { kind: mode === "kind" ? "acknowledged" : "actors", data: [] } },
|
||||||
}) + "\n",
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const client = await ImageClient.connect(address, (error) => {
|
const client = await ImageClient.connect(address, (error) => {
|
||||||
|
|
@ -105,7 +119,12 @@ test("request timeout rejects pending and queued reads without retry", async (t)
|
||||||
const address = await fakeImage(t, () => {
|
const address = await fakeImage(t, () => {
|
||||||
requests++;
|
requests++;
|
||||||
});
|
});
|
||||||
const client = await ImageClient.connect(address, () => {}, { connect: 1000, request: 30 });
|
const client = await ImageClient.connect(
|
||||||
|
address,
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
{ connect: 1000, request: 30 },
|
||||||
|
);
|
||||||
t.after(() => client.close());
|
t.after(() => client.close());
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
assert.rejects(client.listActors(), /timed out/),
|
assert.rejects(client.listActors(), /timed out/),
|
||||||
|
|
@ -116,8 +135,8 @@ test("request timeout rejects pending and queued reads without retry", async (t)
|
||||||
|
|
||||||
test("the 1 MiB response limit is inclusive and framing survives the next request", async (t) => {
|
test("the 1 MiB response limit is inclusive and framing survives the next request", async (t) => {
|
||||||
const address = await fakeImage(t, (request, socket) => {
|
const address = await fakeImage(t, (request, socket) => {
|
||||||
const line = response(request, { kind: "actors", data: [] }).slice(0, -1);
|
const line = response(request, { kind: "actors", data: [] });
|
||||||
socket.write(line + " ".repeat(MAX_LINE_BYTES - Buffer.byteLength(line)) + "\n");
|
socket.send(line + " ".repeat(MAX_MESSAGE_BYTES - Buffer.byteLength(line)));
|
||||||
});
|
});
|
||||||
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
|
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
|
||||||
t.after(() => client.close());
|
t.after(() => client.close());
|
||||||
|
|
@ -125,10 +144,11 @@ test("the 1 MiB response limit is inclusive and framing survives the next reques
|
||||||
assert.deepEqual(await client.listActors(), []);
|
assert.deepEqual(await client.listActors(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unsolicited partial frames invalidate the connection", async (t) => {
|
test("unsolicited responses invalidate the connection", async (t) => {
|
||||||
const failures: Error[] = [];
|
const failures: Error[] = [];
|
||||||
const address = await fakeImage(t, (request, socket) => {
|
const address = await fakeImage(t, (request, socket) => {
|
||||||
socket.write(response(request, { kind: "actors", data: [] }) + "{");
|
socket.send(response(request, { kind: "actors", data: [] }));
|
||||||
|
socket.send(response(request, { kind: "actors", data: [] }));
|
||||||
});
|
});
|
||||||
const client = await ImageClient.connect(address, (error) => {
|
const client = await ImageClient.connect(address, (error) => {
|
||||||
if (error) failures.push(error);
|
if (error) failures.push(error);
|
||||||
|
|
@ -140,7 +160,7 @@ test("unsolicited partial frames invalidate the connection", async (t) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("disconnect rejects pending reads and idle peer close is reported", async (t) => {
|
test("disconnect rejects pending reads and idle peer close is reported", async (t) => {
|
||||||
const address = await fakeImage(t, (_request, socket) => socket.end());
|
const address = await fakeImage(t, (_request, socket) => socket.close());
|
||||||
let disconnected = false;
|
let disconnected = false;
|
||||||
const client = await ImageClient.connect(address, (error) => {
|
const client = await ImageClient.connect(address, (error) => {
|
||||||
assert.match(error?.message ?? "", /closed/);
|
assert.match(error?.message ?? "", /closed/);
|
||||||
|
|
@ -151,8 +171,8 @@ test("disconnect rejects pending reads and idle peer close is reported", async (
|
||||||
assert.equal(disconnected, true);
|
assert.equal(disconnected, true);
|
||||||
|
|
||||||
const idleAddress = await fakeImage(t, (request, socket) => {
|
const idleAddress = await fakeImage(t, (request, socket) => {
|
||||||
socket.write(response(request, { kind: "actors", data: [] }));
|
socket.send(response(request, { kind: "actors", data: [] }));
|
||||||
setImmediate(() => socket.end());
|
setImmediate(() => socket.close());
|
||||||
});
|
});
|
||||||
let onClosed = () => {};
|
let onClosed = () => {};
|
||||||
const closed = new Promise<void>((resolve) => {
|
const closed = new Promise<void>((resolve) => {
|
||||||
|
|
@ -165,6 +185,52 @@ test("disconnect rejects pending reads and idle peer close is reported", async (
|
||||||
await assert.rejects(idle.listActors(), /Not connected/);
|
await assert.rejects(idle.listActors(), /Not connected/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requires the runa-debug WebSocket subprotocol", async (t) => {
|
||||||
|
const address = await fakeImage(t, () => {}, "wrong-protocol");
|
||||||
|
await assert.rejects(
|
||||||
|
ImageClient.connect(address, () => {}),
|
||||||
|
/subprotocol/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notifications interleave with replies and arrive when no request is pending", async (t) => {
|
||||||
|
const revisions: bigint[] = [];
|
||||||
|
let onIdle = () => {};
|
||||||
|
const idle = new Promise<void>((resolve) => {
|
||||||
|
onIdle = resolve;
|
||||||
|
});
|
||||||
|
const address = await fakeImage(t, (request, socket) => {
|
||||||
|
socket.send(notification(1n));
|
||||||
|
socket.send(response(request, { kind: "actors", data: [] }));
|
||||||
|
setImmediate(() => socket.send(notification(5n)));
|
||||||
|
});
|
||||||
|
const client = await ImageClient.connect(
|
||||||
|
address,
|
||||||
|
(error) => assert.equal(error, undefined),
|
||||||
|
(revision) => {
|
||||||
|
revisions.push(revision);
|
||||||
|
if (revision === 5n) onIdle();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
t.after(() => client.close());
|
||||||
|
assert.deepEqual(await client.listActors(), []);
|
||||||
|
await idle;
|
||||||
|
assert.deepEqual(revisions, [1n, 5n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated or backwards revisions close the connection", async (t) => {
|
||||||
|
const failures: Error[] = [];
|
||||||
|
const address = await fakeImage(t, (_request, socket) => {
|
||||||
|
socket.send(notification(5n));
|
||||||
|
socket.send(notification(5n));
|
||||||
|
});
|
||||||
|
const client = await ImageClient.connect(address, (error) => {
|
||||||
|
if (error) failures.push(error);
|
||||||
|
});
|
||||||
|
t.after(() => client.close());
|
||||||
|
await assert.rejects(client.listActors(), /Non-increasing/);
|
||||||
|
assert.equal(failures.length, 1);
|
||||||
|
});
|
||||||
test("failed connection reports the error", async () => {
|
test("failed connection reports the error", async () => {
|
||||||
const { createServer } = await import("node:net");
|
const { createServer } = await import("node:net");
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { createServer, Socket } from "node:net";
|
import { once } from "node:events";
|
||||||
import { createInterface } from "node:readline";
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
import { TestContext } from "node:test";
|
import { TestContext } from "node:test";
|
||||||
import { stringify } from "lossless-json";
|
import { stringify } from "lossless-json";
|
||||||
import { ActorSnapshot } from "../src/protocol";
|
import { ActorSnapshot, WEBSOCKET_SUBPROTOCOL } from "../src/protocol";
|
||||||
|
|
||||||
export const REF = "abcdefghijklmnopqrstu";
|
export const REF = "abcdefghijklmnopqrstu";
|
||||||
|
|
||||||
|
|
@ -42,26 +42,28 @@ export interface TestRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function response(request: TestRequest, reply: unknown): string {
|
export function response(request: TestRequest, reply: unknown): string {
|
||||||
return json({ version: 3, request_id: request.request_id, result: { Ok: reply } }) + "\n";
|
return json({ version: 3, request_id: request.request_id, result: { Ok: reply } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fakeImage(
|
export async function fakeImage(
|
||||||
t: TestContext,
|
t: TestContext,
|
||||||
handle: (request: TestRequest, socket: Socket) => void,
|
handle: (request: TestRequest, socket: WebSocket) => void,
|
||||||
|
protocol = WEBSOCKET_SUBPROTOCOL,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const sockets = new Set<Socket>();
|
const server = new WebSocketServer({
|
||||||
const server = createServer((socket) => {
|
host: "127.0.0.1",
|
||||||
sockets.add(socket);
|
port: 0,
|
||||||
socket.on("close", () => sockets.delete(socket));
|
handleProtocols: (offered) => (offered.has(protocol) ? protocol : false),
|
||||||
|
});
|
||||||
|
server.on("connection", (socket) => {
|
||||||
socket.on("error", () => {
|
socket.on("error", () => {
|
||||||
// Transport-failure tests intentionally close a socket while the fixture is writing.
|
// Transport-failure tests intentionally close a socket while the fixture is writing.
|
||||||
});
|
});
|
||||||
const lines = createInterface({ input: socket });
|
socket.on("message", (data) => handle(JSON.parse(data.toString()), socket));
|
||||||
lines.on("line", (line) => handle(JSON.parse(line), socket));
|
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await once(server, "listening");
|
||||||
t.after(async () => {
|
t.after(async () => {
|
||||||
for (const socket of sockets) socket.destroy();
|
for (const socket of server.clients) socket.terminate();
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
server.close((error) => (error ? reject(error) : resolve())),
|
server.close((error) => (error ? reject(error) : resolve())),
|
||||||
);
|
);
|
||||||
|
|
@ -70,3 +72,7 @@ export async function fakeImage(
|
||||||
if (!address || typeof address === "string") throw new Error("Missing test listener");
|
if (!address || typeof address === "string") throw new Error("Missing test listener");
|
||||||
return `127.0.0.1:${address.port}`;
|
return `127.0.0.1:${address.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notification(revision: bigint): string {
|
||||||
|
return json({ version: 3, event: { event: "image_changed", data: { revision } } });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import test from "node:test";
|
||||||
import { parse } from "lossless-json";
|
import { parse } from "lossless-json";
|
||||||
import {
|
import {
|
||||||
decodeResponse,
|
decodeResponse,
|
||||||
|
decodeNotification,
|
||||||
decodeValue,
|
decodeValue,
|
||||||
formatSnapshot,
|
formatSnapshot,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
|
|
@ -14,6 +15,41 @@ const command: ReadCommand = { operation: "list_actors" };
|
||||||
const envelope = (reply: unknown) =>
|
const envelope = (reply: unknown) =>
|
||||||
json({ version: 3, request_id: "test", result: { Ok: reply } });
|
json({ version: 3, request_id: "test", result: { Ok: reply } });
|
||||||
|
|
||||||
|
test("notification envelopes preserve u64 revisions and reject malformed events", () => {
|
||||||
|
const notification = (data: unknown) =>
|
||||||
|
json({
|
||||||
|
version: 3,
|
||||||
|
event: {
|
||||||
|
event: "image_changed",
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
decodeNotification(notification({ revision: 18446744073709551615n })),
|
||||||
|
18446744073709551615n,
|
||||||
|
);
|
||||||
|
assert.equal(decodeNotification(envelope({ kind: "actors", data: [] })), undefined);
|
||||||
|
for (const data of [
|
||||||
|
{ revision: 0 },
|
||||||
|
{ revision: -1 },
|
||||||
|
{ revision: 1.5 },
|
||||||
|
{ revision: 18446744073709551616n },
|
||||||
|
{ revision: "1" },
|
||||||
|
{ revision: 1, extra: true },
|
||||||
|
{},
|
||||||
|
]) {
|
||||||
|
assert.throws(() => decodeNotification(notification(data)));
|
||||||
|
}
|
||||||
|
assert.throws(() =>
|
||||||
|
decodeNotification(
|
||||||
|
json({
|
||||||
|
version: 2,
|
||||||
|
event: { event: "image_changed", data: { revision: 1 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("v3 snapshots preserve all value variants, i64 limits, u64 telemetry, and mailbox order", () => {
|
test("v3 snapshots preserve all value variants, i64 limits, u64 telemetry, and mailbox order", () => {
|
||||||
const snapshot = actor();
|
const snapshot = actor();
|
||||||
const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command);
|
const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { RefreshQueue } from "../src/refresh";
|
||||||
|
|
||||||
|
test("refreshes coalesce bursts and rerun after in-flight invalidation", async (t) => {
|
||||||
|
const reads: (() => void)[] = [];
|
||||||
|
const queue = new RefreshQueue(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
reads.push(resolve);
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
t.after(() => queue.dispose());
|
||||||
|
for (let i = 0; i < 100; i++) queue.invalidate();
|
||||||
|
await waitFor(() => reads.length === 1);
|
||||||
|
for (let i = 0; i < 100; i++) queue.invalidate();
|
||||||
|
assert.equal(reads.length, 1);
|
||||||
|
reads[0]?.();
|
||||||
|
await waitFor(() => reads.length === 2);
|
||||||
|
reads[1]?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disposed sessions never run queued refreshes and errors are reported", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const queue = new RefreshQueue(
|
||||||
|
async () => {
|
||||||
|
calls++;
|
||||||
|
throw new Error("refresh failed");
|
||||||
|
},
|
||||||
|
(error) => errors.push(error),
|
||||||
|
);
|
||||||
|
queue.invalidate();
|
||||||
|
await waitFor(() => errors.length === 1);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
queue.invalidate();
|
||||||
|
queue.dispose();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitFor(condition: () => boolean): Promise<void> {
|
||||||
|
const deadline = Date.now() + 2000;
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() > deadline) throw new Error("Refresh queue did not make progress");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue