okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
53cd699
main
1// Malformed replay batches must leave callbacks and cursors untouched. 2// A controlled peer can inject these faults and unknown events into one stream. 3package daemon 4 5import ( 6"bufio" 7"context" 8"errors" 9"fmt" 10"net/http" 11"strings" 12"sync/atomic" 13"testing" 14) 15 16func TestSessionReplayRejectsWholeBatchAndRetainsCursor (t * testing.T ) { 17for _ ,failure := range []string {"gap" ,"wrong cursor" ,"missing sequence" ,"malformed later event" ,"invalid invalidate" ,"changed generation" ,"malformed generation" ,"callback" } { 18t .Run (failure ,func (t * testing.T ) { 19var requests atomic.Int32 20callbackCause := errors .New ("consumer stopped" ) 21conn := controlledConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 22if r .URL .Path != "/sessions/test" || r .Header .Get ("Accept" )!= "text/event-stream" { 23t .Errorf ("wrong subscription %s %s" ,r .URL ,r .Header .Get ("Accept" )) 24 } 25w .Header ().Set ("Content-Type" ,"text/event-stream" ) 26switch requests .Add (1 ) { 27case 1 : 28writeInitialStream (w ,"chat" ) 29case 2 : 30if r .URL .Query ().Get ("after_seq" )!= "1" || r .URL .Query ().Get ("after_generation" )!= generationA { 31t .Errorf ("lost resume cursor %s" ,r .URL ) 32 } 33generation := generationA 34cursor := int64 (2 ) 35events := []any {canonicalText (2 ,"uncommitted" )} 36switch failure { 37case "invalid invalidate" : 38events = append (events ,canonicalEvent ("invalidate" ,3 ,map [string ]any {"kind" :"unknown" ,"url" :"/sessions/test" })) 39cursor = 3 40case "gap" : 41events = []any {canonicalText (3 ,"gap" )} 42cursor = 3 43case "wrong cursor" : 44cursor = 3 45case "missing sequence" : 46events = []any {map [string ]any {"type" :"text" ,"data" :map [string ]any {}}} 47case "malformed later event" : 48events = append (events ,canonicalEvent ("status" ,3 ,map [string ]any {"phase" :false ,"run_id" :nil ,"interrupt_requested" :false ,"blocking_reason" :nil })) 49cursor = 3 50case "changed generation" : 51generation = generationB 52case "malformed generation" : 53generation = "short" 54 } 55writeBatch (w ,map [string ]any {"generation" :generation ,"cursor" :cursor ,"events" :events }) 56case 3 : 57if r .URL .Query ().Get ("after_seq" )!= "1" { 58t .Errorf ("rejected batch committed cursor %s" ,r .URL ) 59 } 60writeBatch (w ,map [string ]any {"generation" :generationA ,"cursor" :2 ,"events" : []any {canonicalText (2 ,"replayed" )}}) 61 } 62 }) 63client := NewChatClient (conn ,"test" ) 64var texts []string 65callback := func (event StreamEvent )error { 66if event .Type == EventText { 67if failure == "callback" && event .Text == "uncommitted" { 68return callbackCause 69 } 70texts = append (texts ,event .Text ) 71 } 72return nil 73 } 74if err := client .Stream (t .Context (),0 ,callback );err != nil { 75t .Fatal (err ) 76 } 77err := client .Stream (t .Context (),0 ,callback ) 78if err == nil { 79t .Fatal ("invalid batch delivered" ) 80 } 81if failure == "callback" && ! errors .Is (err ,callbackCause ) { 82t .Fatalf ("callback cause lost %v" ,err ) 83 } 84if err = client .Stream (t .Context (),0 ,callback );err != nil { 85t .Fatal (err ) 86 } 87if strings .Join (texts ,"," )!= "hello,replayed" { 88t .Fatalf ("partial invalid batch reached client: %v" ,texts ) 89 } 90 }) 91 } 92} 93 94func TestSessionUnknownEventsAdvanceCursorAndCancellationClearsIt (t * testing.T ) { 95var requests atomic.Int32 96conn := controlledConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 97w .Header ().Set ("Content-Type" ,"text/event-stream" ) 98switch requests .Add (1 ) { 99case 1 : 100writeInitialStream (w ,"chat" ) 101writeBatch (w ,map [string ]any {"generation" :generationA ,"cursor" :2 ,"events" : []any {canonicalEvent ("future" ,2 ,map [string ]any {})}}) 102case 2 : 103if r .URL .Query ().Get ("after_seq" )!= "2" { 104t .Errorf ("unknown event cursor lost: %s" ,r .URL ) 105 } 106writeBatch (w ,map [string ]any {"generation" :generationA ,"cursor" :3 ,"events" : []any {canonicalText (3 ,"cancel" )}}) 107case 3 : 108if r .URL .Query ().Has ("after_seq" ) { 109t .Errorf ("cancelled lifetime resumed: %s" ,r .URL ) 110 } 111writeInitialStream (w ,"chat" ) 112 } 113 }) 114client := NewChatClient (conn ,"test" ) 115if err := client .Stream (t .Context (),0 ,func (StreamEvent )error {return nil });err != nil { 116t .Fatal (err ) 117 } 118ctx ,cancel := context .WithCancel (t .Context ()) 119_ = client .Stream (ctx ,0 ,func (StreamEvent )error {cancel ();return context .Canceled }) 120if err := client .Stream (t .Context (),0 ,func (StreamEvent )error {return nil });err != nil { 121t .Fatal (err ) 122 } 123} 124 125func TestResetProgressPrecedesLaterTerminalEvent (t * testing.T ) { 126snapshot := canonicalSession ("test" ,generationA ,0 ) 127snapshot ["current_progress" ]= []any {canonicalProgress ("progress-a" )} 128conn := controlledConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 129w .Header ().Set ("Content-Type" ,"text/event-stream" ) 130writeBatch (w ,map [string ]any {"generation" :generationA ,"cursor" :1 ,"snapshot" :snapshot ,"events" : []any {map [string ]any {"type" :"reset" ,"data" :map [string ]any {"reason" :"initial" }},canonicalEvent ("tool_progress" ,1 ,map [string ]any {"progress" :nil })}}) 131 }) 132var progress []bool 133err := NewChatClient (conn ,"test" ).Stream (t .Context (),0 ,func (event StreamEvent )error { 134if event .Type == EventToolProgress { 135progress = append (progress ,event .Progress != nil ) 136 } 137return nil 138 }) 139if err != nil || fmt .Sprint (progress )!= "[true false]" { 140t .Fatalf ("reset restored stale progress: %v %v" ,progress ,err ) 141 } 142} 143 144func TestProgressOverflowIsRejectedBeforeCallbacks (t * testing.T ) { 145conn := controlledConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 146w .Header ().Set ("Content-Type" ,"text/event-stream" ) 147events := []any {map [string ]any {"type" :"reset" ,"data" :map [string ]any {"reason" :"initial" }}} 148for i := range 33 { 149events = append (events ,canonicalEvent ("tool_progress" ,int64 (i + 1 ),map [string ]any {"progress" :canonicalProgress (fmt .Sprint (i ))})) 150 } 151events = append (events ,canonicalEvent ("tool_progress" ,34 ,map [string ]any {"progress" :nil })) 152writeBatch (w ,map [string ]any {"generation" :generationA ,"cursor" :34 ,"snapshot" :canonicalSession ("test" ,generationA ,0 ),"events" :events }) 153 }) 154calls := 0 155err := NewChatClient (conn ,"test" ).Stream (t .Context (),0 ,func (StreamEvent )error {calls ++ ;return nil }) 156if err == nil || calls != 0 { 157t .Fatalf ("intermediate overflow delivered: %v calls=%d" ,err ,calls ) 158 } 159} 160 161// The daemon that restarts under a client is often an older build than the 162// client, and older daemons end a stream with a daemon_unavailable failure 163// batch while they shut down. The e2e suites run one build on both sides, so 164// only this test sends that batch. 165func TestFailureBatchFromStoppingDaemonIsTransient (t * testing.T ) { 166for code ,want := range map [string ]StreamFailureKind { 167"daemon_unavailable" :StreamTransient , 168"daemon_stopping" :StreamTransient , 169"history_unavailable" :StreamTerminal , 170 } { 171client := NewChatClient (NewConnection (ConnectionSnapshot {Port :1 },nil ),"s" ) 172wire := `data: {"generation":"abcdefghijklmnopqrstuv","cursor":3,"events":[{"type":"failure","data":{"code":"` + code + `","detail":"gone"}}]}` + "\n\n" 173err := client .readStream (context .Background (),bufio .NewScanner (strings .NewReader (wire )),nil ,func (StreamEvent )error {return nil }) 174var failure * StreamError 175if ! errors .As (err ,& failure )|| failure .Kind != want { 176t .Errorf ("%s failure batch: %v, want kind %d" ,code ,err ,want ) 177 } 178 } 179}