feat(console): drive resident timers and TCP streams

Add a revision-scoped ResidentSupervisor that serializes Host events through ResidentSession while Tokio tasks own timers, TCP listeners, and stream sockets.

Enforce deny-by-default endpoint policy, bounded event and stream queues, startup rollback, deterministic service shutdown, stale Actor replacement, and deployment restoration across Runtime restarts.

Add timer and real TCP integration coverage for port conflicts, multi-chunk echo traffic, service restart, and full Runtime recovery.
This commit is contained in:
Maofeng
2026-07-31 10:04:36 +08:00
parent ef2eddc31f
commit 7939285e9f
7 changed files with 1728 additions and 35 deletions
+245 -1
View File
@@ -1,9 +1,12 @@
use std::{
collections::BTreeMap,
fs,
io::Cursor,
net::{SocketAddr, TcpListener as StdTcpListener},
path::{Path, PathBuf},
process::Command,
sync::{Arc, OnceLock},
time::Duration,
};
use axum::{
@@ -14,8 +17,15 @@ use axum::{
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde_json::{Value, json};
use tempfile::TempDir;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tower::ServiceExt;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
use wasmeld_console::{
Console, ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
ResidentTcpListenerConfig, ResidentTimerConfig, app, gateway_app,
};
use wasmeld_package::{
module::{ModuleLock, sync_dependencies},
wit_package::build_wit_package,
@@ -90,6 +100,192 @@ async fn manages_a_resident_component_over_http() {
assert!(events["events"].as_array().unwrap().len() >= 5);
}
#[tokio::test]
async fn drives_resident_timers_across_service_and_runtime_restarts() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let mut resident_services = BTreeMap::new();
resident_services.insert(
"resident-probe".to_owned(),
ResidentServiceConfig {
timers: vec![ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 5,
interval_ms: None,
}],
..ResidentServiceConfig::default()
},
);
let console = Arc::new(
Console::new(ConsoleConfig {
artifact_dir: artifact_dir.path().to_path_buf(),
wit_registry_dir: artifact_dir.path().join("wit-packages"),
database_path: artifact_dir.path().join("console.db"),
resident_services,
..ConsoleConfig::default()
})
.await
.expect("console should start"),
);
let application = app(console, Vec::new());
let component = fs::read(component_artifact("resident_probe_component.wasm"))
.expect("resident probe component should be readable");
let response = application
.clone()
.oneshot(package_request_with_world(
"resident-probe",
"0.1.0",
"component:resident-probe/resident-probe-component@0.1.0",
&component,
))
.await
.expect("resident package registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"configured timer should reach the resident Component"
);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
.await
.expect("resident service stop should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
.await
.expect("resident service restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"service restart should create a new timer session"
);
let response = application
.clone()
.oneshot(empty_post("/api/v1/runtime/restart"))
.await
.expect("Runtime restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"deployment restore should recreate the timer supervisor"
);
}
#[tokio::test]
async fn drives_tcp_streams_across_service_and_runtime_restarts() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let occupied_listener =
StdTcpListener::bind("127.0.0.1:0").expect("occupied test listener should bind");
let address = occupied_listener
.local_addr()
.expect("occupied test listener should have an address");
let mut resident_services = BTreeMap::new();
resident_services.insert(
"resident-probe".to_owned(),
ResidentServiceConfig {
policy: ResidentPolicy {
tcp_listen: NetworkScope::Loopback,
..ResidentPolicy::default()
},
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "echo".to_owned(),
bind: address,
}],
..ResidentServiceConfig::default()
},
);
let console = Arc::new(
Console::new(ConsoleConfig {
artifact_dir: artifact_dir.path().to_path_buf(),
wit_registry_dir: artifact_dir.path().join("wit-packages"),
database_path: artifact_dir.path().join("console.db"),
resident_services,
..ConsoleConfig::default()
})
.await
.expect("console should start"),
);
let application = app(console, Vec::new());
let component = fs::read(component_artifact("resident_probe_component.wasm"))
.expect("resident probe component should be readable");
let response = application
.clone()
.oneshot(package_request_with_world(
"resident-probe",
"0.1.0",
"component:resident-probe/resident-probe-component@0.1.0",
&component,
))
.await
.expect("resident package registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("conflicting resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
// Driver startup failure must stop the Actor and leave deployment state
// untouched. Once the external conflict is gone, the same revision can
// start normally without repairing Runtime state manually.
drop(occupied_listener);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::OK);
let large_payload = vec![0xA5; 2 * 64 * 1024 + 17];
assert_tcp_echo(address, &large_payload).await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
.await
.expect("resident service stop should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
.await
.expect("resident service restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_tcp_echo(address, b"service restart").await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/runtime/restart"))
.await
.expect("Runtime restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_tcp_echo(address, b"runtime restart").await;
}
#[tokio::test]
async fn reports_exact_component_host_capabilities() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -999,6 +1195,53 @@ async fn invoke_service(application: &Router, id: &str, revision: &str, input: &
.unwrap()
}
async fn wait_for_resident_count(application: &Router) -> u64 {
for _ in 0..100 {
let output = invoke_service(application, "resident-probe", "0.1.0", &[]).await;
let count = u64::from_le_bytes(
output
.try_into()
.expect("resident probe response should be a u64"),
);
if count > 0 {
return count;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
0
}
async fn assert_tcp_echo(address: SocketAddr, payload: &[u8]) {
let mut stream = connect_tcp(address).await;
stream
.write_all(payload)
.await
.expect("TCP request should be written");
let mut echoed = vec![0_u8; payload.len()];
tokio::time::timeout(Duration::from_secs(2), stream.read_exact(&mut echoed))
.await
.expect("resident TCP echo should not time out")
.expect("resident TCP echo should be readable");
assert_eq!(echoed, payload);
}
async fn connect_tcp(address: SocketAddr) -> TcpStream {
let mut last_error = None;
for _ in 0..100 {
match TcpStream::connect(address).await {
Ok(stream) => return stream,
Err(error) => {
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
panic!(
"resident TCP listener {address} did not accept connections: {}",
last_error.expect("at least one connection should have been attempted")
);
}
fn echo_component() -> PathBuf {
component_artifact("echo_component.wasm")
}
@@ -1010,6 +1253,7 @@ fn component_artifact(name: &str) -> PathBuf {
"components/counter/Cargo.toml",
"components/clock-probe/Cargo.toml",
"components/kv-probe/Cargo.toml",
"components/resident-probe/Cargo.toml",
] {
let status = Command::new("rustup")
.args([