okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnsessions: a daemon restart ends session streams with eof instead of a terminal daemon_unavailable failure53cd699

main
7.3 KiB179 linesraw
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) {
17	for _, failure := range []string{"gap", "wrong cursor", "missing sequence", "malformed later event", "invalid invalidate", "changed generation", "malformed generation", "callback"} {
18		t.Run(failure, func(t *testing.T) {
19			var requests atomic.Int32
20			callbackCause := errors.New("consumer stopped")
21			conn := controlledConnection(t, func(w http.ResponseWriter, r *http.Request) {
22				if r.URL.Path != "/sessions/test" || r.Header.Get("Accept") != "text/event-stream" {
23					t.Errorf("wrong subscription %s %s", r.URL, r.Header.Get("Accept"))
24				}
25				w.Header().Set("Content-Type", "text/event-stream")
26				switch requests.Add(1) {
27				case 1:
28					writeInitialStream(w, "chat")
29				case 2:
30					if r.URL.Query().Get("after_seq") != "1" || r.URL.Query().Get("after_generation") != generationA {
31						t.Errorf("lost resume cursor %s", r.URL)
32					}
33					generation := generationA
34					cursor := int64(2)
35					events := []any{canonicalText(2, "uncommitted")}
36					switch failure {
37					case "invalid invalidate":
38						events = append(events, canonicalEvent("invalidate", 3, map[string]any{"kind": "unknown", "url": "/sessions/test"}))
39						cursor = 3
40					case "gap":
41						events = []any{canonicalText(3, "gap")}
42						cursor = 3
43					case "wrong cursor":
44						cursor = 3
45					case "missing sequence":
46						events = []any{map[string]any{"type": "text", "data": map[string]any{}}}
47					case "malformed later event":
48						events = append(events, canonicalEvent("status", 3, map[string]any{"phase": false, "run_id": nil, "interrupt_requested": false, "blocking_reason": nil}))
49						cursor = 3
50					case "changed generation":
51						generation = generationB
52					case "malformed generation":
53						generation = "short"
54					}
55					writeBatch(w, map[string]any{"generation": generation, "cursor": cursor, "events": events})
56				case 3:
57					if r.URL.Query().Get("after_seq") != "1" {
58						t.Errorf("rejected batch committed cursor %s", r.URL)
59					}
60					writeBatch(w, map[string]any{"generation": generationA, "cursor": 2, "events": []any{canonicalText(2, "replayed")}})
61				}
62			})
63			client := NewChatClient(conn, "test")
64			var texts []string
65			callback := func(event StreamEvent) error {
66				if event.Type == EventText {
67					if failure == "callback" && event.Text == "uncommitted" {
68						return callbackCause
69					}
70					texts = append(texts, event.Text)
71				}
72				return nil
73			}
74			if err := client.Stream(t.Context(), 0, callback); err != nil {
75				t.Fatal(err)
76			}
77			err := client.Stream(t.Context(), 0, callback)
78			if err == nil {
79				t.Fatal("invalid batch delivered")
80			}
81			if failure == "callback" && !errors.Is(err, callbackCause) {
82				t.Fatalf("callback cause lost %v", err)
83			}
84			if err = client.Stream(t.Context(), 0, callback); err != nil {
85				t.Fatal(err)
86			}
87			if strings.Join(texts, ",") != "hello,replayed" {
88				t.Fatalf("partial invalid batch reached client: %v", texts)
89			}
90		})
91	}
92}
93
94func TestSessionUnknownEventsAdvanceCursorAndCancellationClearsIt(t *testing.T) {
95	var requests atomic.Int32
96	conn := controlledConnection(t, func(w http.ResponseWriter, r *http.Request) {
97		w.Header().Set("Content-Type", "text/event-stream")
98		switch requests.Add(1) {
99		case 1:
100			writeInitialStream(w, "chat")
101			writeBatch(w, map[string]any{"generation": generationA, "cursor": 2, "events": []any{canonicalEvent("future", 2, map[string]any{})}})
102		case 2:
103			if r.URL.Query().Get("after_seq") != "2" {
104				t.Errorf("unknown event cursor lost: %s", r.URL)
105			}
106			writeBatch(w, map[string]any{"generation": generationA, "cursor": 3, "events": []any{canonicalText(3, "cancel")}})
107		case 3:
108			if r.URL.Query().Has("after_seq") {
109				t.Errorf("cancelled lifetime resumed: %s", r.URL)
110			}
111			writeInitialStream(w, "chat")
112		}
113	})
114	client := NewChatClient(conn, "test")
115	if err := client.Stream(t.Context(), 0, func(StreamEvent) error { return nil }); err != nil {
116		t.Fatal(err)
117	}
118	ctx, cancel := context.WithCancel(t.Context())
119	_ = client.Stream(ctx, 0, func(StreamEvent) error { cancel(); return context.Canceled })
120	if err := client.Stream(t.Context(), 0, func(StreamEvent) error { return nil }); err != nil {
121		t.Fatal(err)
122	}
123}
124
125func TestResetProgressPrecedesLaterTerminalEvent(t *testing.T) {
126	snapshot := canonicalSession("test", generationA, 0)
127	snapshot["current_progress"] = []any{canonicalProgress("progress-a")}
128	conn := controlledConnection(t, func(w http.ResponseWriter, r *http.Request) {
129		w.Header().Set("Content-Type", "text/event-stream")
130		writeBatch(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	})
132	var progress []bool
133	err := NewChatClient(conn, "test").Stream(t.Context(), 0, func(event StreamEvent) error {
134		if event.Type == EventToolProgress {
135			progress = append(progress, event.Progress != nil)
136		}
137		return nil
138	})
139	if err != nil || fmt.Sprint(progress) != "[true false]" {
140		t.Fatalf("reset restored stale progress: %v %v", progress, err)
141	}
142}
143
144func TestProgressOverflowIsRejectedBeforeCallbacks(t *testing.T) {
145	conn := controlledConnection(t, func(w http.ResponseWriter, r *http.Request) {
146		w.Header().Set("Content-Type", "text/event-stream")
147		events := []any{map[string]any{"type": "reset", "data": map[string]any{"reason": "initial"}}}
148		for i := range 33 {
149			events = append(events, canonicalEvent("tool_progress", int64(i+1), map[string]any{"progress": canonicalProgress(fmt.Sprint(i))}))
150		}
151		events = append(events, canonicalEvent("tool_progress", 34, map[string]any{"progress": nil}))
152		writeBatch(w, map[string]any{"generation": generationA, "cursor": 34, "snapshot": canonicalSession("test", generationA, 0), "events": events})
153	})
154	calls := 0
155	err := NewChatClient(conn, "test").Stream(t.Context(), 0, func(StreamEvent) error { calls++; return nil })
156	if err == nil || calls != 0 {
157		t.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) {
166	for code, want := range map[string]StreamFailureKind{
167		"daemon_unavailable":  StreamTransient,
168		"daemon_stopping":     StreamTransient,
169		"history_unavailable": StreamTerminal,
170	} {
171		client := NewChatClient(NewConnection(ConnectionSnapshot{Port: 1}, nil), "s")
172		wire := `data: {"generation":"abcdefghijklmnopqrstuv","cursor":3,"events":[{"type":"failure","data":{"code":"` + code + `","detail":"gone"}}]}` + "\n\n"
173		err := client.readStream(context.Background(), bufio.NewScanner(strings.NewReader(wire)), nil, func(StreamEvent) error { return nil })
174		var failure *StreamError
175		if !errors.As(err, &failure) || failure.Kind != want {
176			t.Errorf("%s failure batch: %v, want kind %d", code, err, want)
177		}
178	}
179}