@@ -10,7 +10,6 @@ use futures::StreamExt;
1010use serde:: Deserialize ;
1111use std:: collections:: HashMap ;
1212use std:: path:: { Path as FsPath , PathBuf } ;
13- use tokio_stream:: wrappers:: BroadcastStream ;
1413use uuid:: Uuid ;
1514
1615use crate :: daemon:: buffer:: { LogEntry , Stream } ;
@@ -327,44 +326,57 @@ pub async fn get_logs(
327326 . ok_or_else ( || AppError :: NotFound ( "session not found" . into ( ) ) ) ?;
328327
329328 // Streaming follow mode
330- // Both stream_name and format are owned Strings — safe to move into the closure
331- let stream_name_clone = stream_name. clone ( ) ;
332329 let format_clone = format. clone ( ) ;
333- let stream_mode = stream_name. clone ( ) ;
334- let stream = BroadcastStream :: new ( rx) . flat_map ( move |msg| {
335- let line = match msg {
336- Ok ( entry) => {
337- if entry. seq < snapshot_next_seq {
338- return futures:: stream:: empty ( ) . left_stream ( ) ;
339- }
340- // Filter: for blended, show all; for stdout/stderr, show only that stream; for unknown, show nothing
341- let should_include = match stream_name_clone. as_str ( ) {
342- "blended" => true ,
343- "stdout" => entry. stream == Stream :: Stdout ,
344- "stderr" => entry. stream == Stream :: Stderr ,
345- _ => false , // unknown stream name → no entries
346- } ;
347- if !should_include {
348- return futures:: stream:: empty ( ) . left_stream ( ) ;
349- }
350- format_entry ( & entry, & stream_mode, & format_clone)
351- }
352- Err ( tokio_stream:: wrappers:: errors:: BroadcastStreamRecvError :: Lagged ( n) ) => {
353- if format_clone == "text" {
354- format ! ( "[korun: dropped {n} lines]\n " )
355- } else {
356- let e = serde_json:: json!( {
357- "seq" : 0u64 ,
358- "ts" : Utc :: now( ) . to_rfc3339( ) ,
359- "stream" : "system" ,
360- "line" : format!( "dropped {n} lines" ) ,
361- } ) ;
362- format ! ( "{e}\n " )
330+ let mgr_for_follow = mgr. clone ( ) ;
331+ let stream = futures:: stream:: unfold (
332+ FollowState {
333+ rx,
334+ mgr : mgr_for_follow,
335+ session_id : id,
336+ stream_name : stream_name. clone ( ) ,
337+ format : format_clone,
338+ cutoff_seq : snapshot_next_seq,
339+ last_seen_seq : snapshot_next_seq,
340+ } ,
341+ |mut state| async move {
342+ loop {
343+ tokio:: select! {
344+ msg = state. rx. recv( ) => {
345+ match msg {
346+ Ok ( entry) => {
347+ if entry. seq < state. cutoff_seq {
348+ continue ;
349+ }
350+ if !should_include_stream( & state. stream_name, entry. stream) {
351+ continue ;
352+ }
353+ state. last_seen_seq = state. last_seen_seq. max( entry. seq. saturating_add( 1 ) ) ;
354+ let line = format_entry( & entry, & state. stream_name, & state. format) ;
355+ return Some ( ( Ok :: <_, std:: convert:: Infallible >( line) , state) ) ;
356+ }
357+ Err ( tokio:: sync:: broadcast:: error:: RecvError :: Lagged ( n) ) => {
358+ let line = format_lag_notice( n, & state. format) ;
359+ return Some ( ( Ok :: <_, std:: convert:: Infallible >( line) , state) ) ;
360+ }
361+ Err ( tokio:: sync:: broadcast:: error:: RecvError :: Closed ) => return None ,
362+ }
363+ }
364+ _ = tokio:: time:: sleep( std:: time:: Duration :: from_millis( 100 ) ) => {
365+ let ( is_terminal, next_seq) = match state
366+ . mgr
367+ . with( & state. session_id, |s| ( matches!( s. state, SessionState :: Exited | SessionState :: Failed ) , s. next_seq( ) ) )
368+ {
369+ Some ( values) => values,
370+ None => return None ,
371+ } ;
372+ if is_terminal && next_seq <= state. last_seen_seq {
373+ return None ;
374+ }
375+ }
363376 }
364377 }
365- } ;
366- futures:: stream:: once ( async move { Ok :: < _ , std:: convert:: Infallible > ( line) } ) . right_stream ( )
367- } ) ;
378+ } ,
379+ ) ;
368380
369381 // First send buffered entries, then stream new ones.
370382 // Use into_iter() + move to avoid capturing &format (which would prevent 'static bound).
@@ -454,6 +466,39 @@ fn format_entry(entry: &LogEntry, requested_stream: &str, format: &str) -> Strin
454466 }
455467}
456468
469+ fn should_include_stream ( stream_name : & str , stream : Stream ) -> bool {
470+ match stream_name {
471+ "blended" => true ,
472+ "stdout" => stream == Stream :: Stdout ,
473+ "stderr" => stream == Stream :: Stderr ,
474+ _ => false ,
475+ }
476+ }
477+
478+ fn format_lag_notice ( n : u64 , format : & str ) -> String {
479+ if format == "text" {
480+ format ! ( "[korun: dropped {n} lines]\n " )
481+ } else {
482+ let e = serde_json:: json!( {
483+ "seq" : 0u64 ,
484+ "ts" : Utc :: now( ) . to_rfc3339( ) ,
485+ "stream" : "system" ,
486+ "line" : format!( "dropped {n} lines" ) ,
487+ } ) ;
488+ format ! ( "{e}\n " )
489+ }
490+ }
491+
492+ struct FollowState {
493+ rx : tokio:: sync:: broadcast:: Receiver < LogEntry > ,
494+ mgr : AppState ,
495+ session_id : Uuid ,
496+ stream_name : String ,
497+ format : String ,
498+ cutoff_seq : u64 ,
499+ last_seen_seq : u64 ,
500+ }
501+
457502fn validate_create_session_request (
458503 command : & [ String ] ,
459504 cwd : & str ,
0 commit comments