@@ -37,6 +37,25 @@ type WsReader = SplitStream<WsStream>;
3737/// exposing a memory-bomb surface to a misbehaving server.
3838const 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+
4059mod 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) ]
398498mod 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