Skip to content

Commit 6286ca7

Browse files
I-am-nothingclaude
andcommitted
feat: send the SDK manifest hash on the tunnel heartbeat
Each 30s heartbeat now GETs the local module manifest and forwards the SDK's X-MS-Manifest-Hash response header (read verbatim — the CLI never computes a hash, keeping it byte-consistent with what the platform reads) on the ping. The platform uses it to skip re-fetching the manifest unless it changed. - The ping payload carries {manifest_hash} when the local read succeeds; a failed or timed-out fetch sends a payload-less ping so the tunnel keeps beating. - The fetch is timeout-guarded (500ms) so a wedged module never stalls ping/pong. - RegisterPayload gains manifest_hash (skip-if-none; None at register since the module isn't up yet when open_tunnels runs). - The dev proxy already relays response headers verbatim (raw io::copy), so the platform's own manifest fetch sees the same X-MS-Manifest-Hash — no proxy change needed. Additive + wire-compatible: old dispatch ignores the new field. Part of the 3-repo hash-beat (app-module-sdk + api-platform). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 283af89 commit 6286ca7

2 files changed

Lines changed: 167 additions & 3 deletions

File tree

src/commands/dev/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,10 @@ fn open_tunnels(
781781
local_url: &module_local_url,
782782
version: env!("CARGO_PKG_VERSION"),
783783
internal_secret: Some(internal_secret),
784+
// The module isn't up yet at register (open_tunnels precedes
785+
// `docker compose up`); the platform seeds the hash from its
786+
// own fetch and the first heartbeat carries it.
787+
manifest_hash: None,
784788
},
785789
)
786790
.await

src/commands/dev/tunnel.rs

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ type WsReader = SplitStream<WsStream>;
3737
/// exposing a memory-bomb surface to a misbehaving server.
3838
const MAX_INBOUND_FRAME_BYTES: usize = 1 << 20;
3939

40+
/// Local Internal-scope endpoint the SDK serves the module manifest on. The
41+
/// heartbeat GETs this (through the CLI's own dev proxy) purely to read the
42+
/// hash response header — it is NOT the platform's `/v1/tunnel/manifest` route.
43+
const MODULE_MANIFEST_PATH: &str = "/__mirrorstack/platform/manifest";
44+
45+
/// Response header the SDK stamps with its own manifest hash. The CLI forwards
46+
/// this value verbatim on the heartbeat; it never computes a hash itself, so
47+
/// the ping stays byte-consistent with what the platform reads on its fetch.
48+
const MANIFEST_HASH_HEADER: &str = "X-MS-Manifest-Hash";
49+
50+
/// Hard cap on the heartbeat's manifest fetch. A wedged module must never
51+
/// stall ping/pong, so the GET is abandoned well inside the 30s beat.
52+
// Kept deliberately short: the manifest fetch is awaited inside the heartbeat
53+
// select! arm, so this bounds how long inbound-frame handling (server ping/pong)
54+
// can be delayed each beat. The endpoint is loopback-local (~tens of ms healthy;
55+
// a mid-restart module refuses the connection and fails fast), so 500ms is a
56+
// generous ceiling that keeps the beat responsive.
57+
const MANIFEST_FETCH_TIMEOUT: Duration = Duration::from_millis(500);
58+
4059
mod uuid_lite {
4160
//! Tiny v4 UUID generator. We don't pull `uuid` for one call site — the
4261
//! envelope just wants a unique ID to correlate frames, not anything
@@ -133,6 +152,13 @@ pub(super) struct RegisterPayload<'a> {
133152
/// the register frame.
134153
#[serde(skip_serializing_if = "Option::is_none")]
135154
pub internal_secret: Option<&'a str>,
155+
/// The module's current manifest hash — the SDK's `X-MS-Manifest-Hash`
156+
/// value, read verbatim off a local manifest fetch. None at register time
157+
/// (the module isn't up yet when `open_tunnels` runs); the platform seeds
158+
/// its stored hash from its own fetch and the first heartbeat. Serialized
159+
/// only when present so older dispatch builds keep round-tripping register.
160+
#[serde(skip_serializing_if = "Option::is_none")]
161+
pub manifest_hash: Option<String>,
136162
}
137163

138164
#[derive(Debug, Deserialize)]
@@ -253,7 +279,21 @@ pub(super) async fn open(
253279
.context("dev: register_ack timeout")??;
254280

255281
let shutdown = Arc::new(Notify::new());
256-
tokio::spawn(run_tunnel_loop(sink, stream, shutdown.clone()));
282+
// Async client for the heartbeat's local manifest fetch. Client-level
283+
// timeout guards every GET so a wedged module can't stall the ping loop.
284+
let http = reqwest::Client::builder()
285+
.timeout(MANIFEST_FETCH_TIMEOUT)
286+
.build()
287+
.context("dev: build manifest-hash http client")?;
288+
tokio::spawn(run_tunnel_loop(
289+
sink,
290+
stream,
291+
shutdown.clone(),
292+
http,
293+
register.local_url.to_string(),
294+
ack.service_token.clone(),
295+
register.internal_secret.map(str::to_string),
296+
));
257297

258298
Ok(TunnelHandle {
259299
session_id: ack.session_id,
@@ -339,7 +379,15 @@ async fn await_register_ack(
339379
/// so a silently-dying tunnel doesn't leave the user wondering why
340380
/// inbound calls stop arriving. (See issue #29 for upgrading this to a
341381
/// liveness signal callers can poll.)
342-
async fn run_tunnel_loop(mut sink: WsSink, mut stream: WsReader, shutdown: Arc<Notify>) {
382+
async fn run_tunnel_loop(
383+
mut sink: WsSink,
384+
mut stream: WsReader,
385+
shutdown: Arc<Notify>,
386+
http: reqwest::Client,
387+
local_url: String,
388+
service_token: String,
389+
internal_secret: Option<String>,
390+
) {
343391
let mut interval = tokio::time::interval(Duration::from_secs(30));
344392
// The first tick fires immediately — consume it so the first ping is at
345393
// t=30s, not t=0. Delay the missed-tick behavior so a paused runtime
@@ -357,7 +405,20 @@ async fn run_tunnel_loop(mut sink: WsSink, mut stream: WsReader, shutdown: Arc<N
357405
return;
358406
}
359407
_ = interval.tick() => {
360-
let ping = Frame::new(FrameType::Ping, None);
408+
// Read the module's current manifest hash off its local
409+
// endpoint, timeout-guarded so a wedged module can never stall
410+
// the beat. A successful read sends the SDK's hash verbatim; any
411+
// error/timeout sends a payload-less ping so the tunnel keeps
412+
// beating. Re-sending an unchanged hash is a no-op on the
413+
// platform, so fetching every beat is cheap and safe.
414+
let hash = fetch_manifest_hash(
415+
&http,
416+
&local_url,
417+
&service_token,
418+
internal_secret.as_deref(),
419+
)
420+
.await;
421+
let ping = Frame::new(FrameType::Ping, ping_payload(hash.as_deref()));
361422
if let Ok(body) = serde_json::to_string(&ping) {
362423
if let Err(e) = sink.send(Message::Text(body)).await {
363424
eprintln!("{} tunnel: ping send failed ({e}); closing tunnel", warn_prefix());
@@ -394,6 +455,45 @@ async fn run_tunnel_loop(mut sink: WsSink, mut stream: WsReader, shutdown: Arc<N
394455
}
395456
}
396457

458+
/// Build a ping frame payload for a (possibly absent) manifest hash.
459+
/// `Some(hash)` → `{"manifest_hash": hash}`; `None` → payload-less ping
460+
/// (the frame's `payload` field stays absent).
461+
fn ping_payload(hash: Option<&str>) -> Option<serde_json::Value> {
462+
hash.map(|h| serde_json::json!({ "manifest_hash": h }))
463+
}
464+
465+
/// GET the module's local manifest endpoint and return the SDK's
466+
/// `X-MS-Manifest-Hash` RESPONSE header verbatim. The CLI never computes a
467+
/// hash — reading the SDK's own header keeps it single-producer-consistent
468+
/// with the platform, which reads the same header on its fetch.
469+
///
470+
/// The GET carries the platform token + internal secret the module's Internal
471+
/// scope requires, and is capped by [`MANIFEST_FETCH_TIMEOUT`]. Any failure —
472+
/// connect refused, timeout, missing/empty header — yields `None` so the caller
473+
/// sends a payload-less ping instead of stalling or dropping the tunnel.
474+
async fn fetch_manifest_hash(
475+
client: &reqwest::Client,
476+
local_url: &str,
477+
service_token: &str,
478+
internal_secret: Option<&str>,
479+
) -> Option<String> {
480+
let mut req = client.get(format!("{local_url}{MODULE_MANIFEST_PATH}"));
481+
if !service_token.is_empty() {
482+
req = req.header("X-MS-Platform-Token", service_token);
483+
}
484+
if let Some(secret) = internal_secret {
485+
req = req.header("X-MS-Internal-Secret", secret);
486+
}
487+
let resp = req.send().await.ok()?;
488+
let hash = resp
489+
.headers()
490+
.get(MANIFEST_HASH_HEADER)?
491+
.to_str()
492+
.ok()?
493+
.trim();
494+
(!hash.is_empty()).then(|| hash.to_string())
495+
}
496+
397497
#[cfg(test)]
398498
mod tests {
399499
use super::*;
@@ -439,12 +539,15 @@ mod tests {
439539
local_url: "http://localhost:8080",
440540
version: "0.1.0",
441541
internal_secret: None,
542+
manifest_hash: None,
442543
};
443544
let s = serde_json::to_string(&p).unwrap();
444545
assert!(s.contains("\"module_id\":\"m_abc\""));
445546
assert!(s.contains("\"local_url\":\"http://localhost:8080\""));
446547
// Skip-if-none keeps the field absent for older dispatch builds.
447548
assert!(!s.contains("internal_secret"));
549+
// manifest_hash is None at register time — also skipped.
550+
assert!(!s.contains("manifest_hash"));
448551
}
449552

450553
#[test]
@@ -454,11 +557,68 @@ mod tests {
454557
local_url: "http://localhost:8080",
455558
version: "0.1.0",
456559
internal_secret: Some("s3cret"),
560+
manifest_hash: None,
457561
};
458562
let s = serde_json::to_string(&p).unwrap();
459563
assert!(s.contains("\"internal_secret\":\"s3cret\""));
460564
}
461565

566+
#[test]
567+
fn ping_payload_wraps_hash_when_present() {
568+
let p = ping_payload(Some("sha256:abc")).unwrap();
569+
assert_eq!(p, serde_json::json!({ "manifest_hash": "sha256:abc" }));
570+
}
571+
572+
#[test]
573+
fn ping_payload_none_stays_payload_less() {
574+
assert!(ping_payload(None).is_none());
575+
}
576+
577+
#[tokio::test]
578+
async fn fetch_manifest_hash_reads_response_header() {
579+
let mut server = mockito::Server::new_async().await;
580+
let m = server
581+
.mock("GET", "/__mirrorstack/platform/manifest")
582+
.match_header("x-ms-platform-token", "ptok")
583+
.match_header("x-ms-internal-secret", "sec")
584+
.with_status(200)
585+
.with_header(MANIFEST_HASH_HEADER, "sha256:abc")
586+
.with_body("{}")
587+
.create_async()
588+
.await;
589+
let client = reqwest::Client::new();
590+
let got = fetch_manifest_hash(&client, &server.url(), "ptok", Some("sec")).await;
591+
assert_eq!(got.as_deref(), Some("sha256:abc"));
592+
m.assert_async().await;
593+
}
594+
595+
#[tokio::test]
596+
async fn fetch_manifest_hash_absent_header_is_none() {
597+
let mut server = mockito::Server::new_async().await;
598+
let _m = server
599+
.mock("GET", "/__mirrorstack/platform/manifest")
600+
.with_status(200)
601+
.with_body("{}")
602+
.create_async()
603+
.await;
604+
let client = reqwest::Client::new();
605+
assert_eq!(
606+
fetch_manifest_hash(&client, &server.url(), "", None).await,
607+
None
608+
);
609+
}
610+
611+
#[tokio::test]
612+
async fn fetch_manifest_hash_connect_error_is_none() {
613+
// Nothing listening on this port → connect refused → None, never a stall.
614+
let client = reqwest::Client::builder()
615+
.timeout(MANIFEST_FETCH_TIMEOUT)
616+
.build()
617+
.unwrap();
618+
let got = fetch_manifest_hash(&client, "http://127.0.0.1:1", "", None).await;
619+
assert_eq!(got, None);
620+
}
621+
462622
#[test]
463623
fn with_token_param_appends_when_no_query() {
464624
let got = with_token_param("wss://api.example/ws", "ttok_abc").unwrap();

0 commit comments

Comments
 (0)