Skip to content

Commit 47477c2

Browse files
RobinBiallyclaude
andcommitted
fix(ws): narrow ambiguous same-path sessions by leading client frames
Sessions identical in host, path, query and opening client frame were handed out by recorded order, so parallel test workers each got a lottery ticket and whoever drew a foreign session died with 1011 on the first differing frame. Narrowing now happens after the upgrade and only reads while every remaining candidate expects a client frame next — the deadlock that ruled out content matching (sessions opening with a server frame, e.g. OpenAI realtime session.created) can't be hit that way. The rotating cursor stays as the tie-break for candidates that are genuinely content-equal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6f105e9 commit 47477c2

2 files changed

Lines changed: 164 additions & 21 deletions

File tree

‎internal/proxy/websocket.go‎

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,9 @@ func RecordWSHandler(target *url.URL, rec *Recorder) http.Handler {
184184
})
185185
}
186186

187-
// ReplayWSHandler serves a WebSocket session from the cassette. It uses the
188-
// next un-consumed websocket interaction (sessions are matched by order of
189-
// appearance, not by request shape — the upgrade request rarely contains
190-
// stable per-test info worth matching on).
187+
// ReplayWSHandler serves a WebSocket session from the cassette, matched by
188+
// host+path, then query, then leading client frames; recorded order only breaks
189+
// ties between candidates that are indistinguishable by all three.
191190
//
192191
// Strict mode: every client-to-server frame must match the recorded frame
193192
// at the corresponding position. On drift, the proxy closes the connection
@@ -259,25 +258,24 @@ func (r *wsReplayer) candidatesForPath(host, path string) []*cassette.WSSession
259258
return fallback
260259
}
261260

262-
// takeForPath returns the next session for host/path, advancing a per-key
263-
// cursor that wraps modulo the candidate count. Within one run, repeated
264-
// connections to a path with N recorded sessions walk them in recorded order;
265-
// the cursor wraps rather than exhausting, so the same WS workload replayed
266-
// again against the same running buffr starts a fresh cycle and matches
267-
// identically — idempotent across runs, the same property the HTTP matcher has.
261+
// takeForPath returns the sessions servable for host/path in the order the
262+
// connection should prefer them, advancing a per-key cursor that rotates the
263+
// list. Within one run, repeated connections to a path with N recorded sessions
264+
// walk them in recorded order; the cursor wraps rather than exhausting, so the
265+
// same WS workload replayed again against the same running buffr starts a fresh
266+
// cycle and matches identically — idempotent across runs, the same property the
267+
// HTTP matcher has.
268268
//
269269
// Tie-breaker: a WebSocket handshake carries no request body, so when a path
270270
// has more than one recorded session the query string is the closest analogue
271271
// of HTTP's body-aware match key for telling otherwise-identical paths apart
272272
// (e.g. wss://…/v1/realtime?model=A vs ?model=B). When the live query uniquely
273273
// narrows the path's sessions, that subset is served; when it matches none
274274
// (e.g. it carries per-run noise), matching falls back to path-only cycling so
275-
// it is never stricter than before — at worst the recorded-order tie-break the
276-
// deterministic-suite contract already relies on. Frame content is deliberately
277-
// not used: a recorded session may open with a server→client frame (OpenAI
278-
// realtime greets with session.created), so buffering a first client frame to
279-
// match on would deadlock those sessions.
280-
func (r *wsReplayer) takeForPath(host, path, query string) *cassette.WSSession {
275+
// it is never stricter than before. Anything still ambiguous after that is left
276+
// to the caller to narrow by leading client frames (see narrowByClientFrames),
277+
// with this rotation as the final tie-break among content-equal candidates.
278+
func (r *wsReplayer) takeForPath(host, path, query string) []*cassette.WSSession {
281279
r.mu.Lock()
282280
defer r.mu.Unlock()
283281
pathCands := r.candidatesForPath(host, path)
@@ -290,9 +288,11 @@ func (r *wsReplayer) takeForPath(host, path, query string) *cassette.WSSession {
290288
cands, key = q, host+"\x00"+path+"\x00"+query
291289
}
292290
}
293-
s := cands[r.cursor[key]%len(cands)]
291+
offset := r.cursor[key] % len(cands)
294292
r.cursor[key]++
295-
return s
293+
rotated := make([]*cassette.WSSession, 0, len(cands))
294+
rotated = append(rotated, cands[offset:]...)
295+
return append(rotated, cands[:offset]...)
296296
}
297297

298298
// filterByQuery returns the sessions whose recorded handshake query equals the
@@ -313,14 +313,52 @@ func filterByQuery(sessions []*cassette.WSSession, query string) []*cassette.WSS
313313
return out
314314
}
315315

316+
// narrowByClientFrames picks one session out of several that path+query could
317+
// not tell apart, by reading the connection's leading client frames: while the
318+
// next expected frame is client_to_server in *every* remaining candidate, read
319+
// it and keep only the candidates it matches; as soon as one candidate is due a
320+
// server frame (or only one candidate is left) commit to the first survivor.
321+
// Reading is safe exactly under that condition — the recordings themselves say
322+
// the client speaks next, so no session that opens with a server frame (OpenAI
323+
// realtime greets with session.created) is ever waited on.
324+
//
325+
// Returns the chosen session and how many of its frames the narrowing already
326+
// consumed. A zero survivor count means the client's frames match no recording:
327+
// genuine drift, reported against the first candidate for a useful diff.
328+
func narrowByClientFrames(conn *websocket.Conn, cands []*cassette.WSSession) (*cassette.WSSession, int, error) {
329+
cursor := 0
330+
for len(cands) > 1 {
331+
for _, s := range cands {
332+
if cursor >= len(s.Frames) || s.Frames[cursor].Direction != cassette.DirClientToServer {
333+
return cands[0], cursor, nil
334+
}
335+
}
336+
msgType, payload, err := conn.ReadMessage()
337+
if err != nil {
338+
return cands[0], cursor, nil // let the replay loop report the early close
339+
}
340+
var kept []*cassette.WSSession
341+
for _, s := range cands {
342+
if validateFrame(s.Frames[cursor], msgType, payload) == nil {
343+
kept = append(kept, s)
344+
}
345+
}
346+
if len(kept) == 0 {
347+
return cands[0], cursor, validateFrame(cands[0].Frames[cursor], msgType, payload)
348+
}
349+
cands, cursor = kept, cursor+1
350+
}
351+
return cands[0], cursor, nil
352+
}
353+
316354
// ReplayWSHandler returns a Handler that serves the next recorded session per
317355
// WebSocket connection. If the cassette has no more WS sessions, the upgrade
318356
// is rejected with a 599 — same convention as the HTTP replay miss.
319357
func ReplayWSHandler(rep *wsReplayer) http.Handler {
320358
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
321359
start := time.Now()
322-
session := rep.takeForPath(matchHost(r), r.URL.Path, r.URL.RawQuery)
323-
if session == nil {
360+
cands := rep.takeForPath(matchHost(r), r.URL.Path, r.URL.RawQuery)
361+
if len(cands) == 0 {
324362
slog.Warn("WS "+r.URL.Path, "src", "miss")
325363
http.Error(w, "buffr: no cassette ws session for "+r.URL.Path, 599)
326364
return
@@ -331,6 +369,17 @@ func ReplayWSHandler(rep *wsReplayer) http.Handler {
331369
}
332370
defer conn.Close()
333371

372+
session, cursor, err := narrowByClientFrames(conn, cands)
373+
if err != nil {
374+
slog.Warn("WS cassette drift", "path", r.URL.Path, "frame", cursor, "err", err)
375+
_ = conn.WriteControl(
376+
websocket.CloseMessage,
377+
websocket.FormatCloseMessage(1011, "buffr: cassette drift"),
378+
time.Now().Add(time.Second),
379+
)
380+
return
381+
}
382+
334383
// Two passes over the frames in order: server-to-client are written
335384
// to the client (honoring delay), client-to-server are read and
336385
// validated. When the next recorded frame is c→s, block on reading
@@ -339,7 +388,6 @@ func ReplayWSHandler(rep *wsReplayer) http.Handler {
339388
"frames", len(session.Frames),
340389
"dur", fmtDur(time.Since(start)),
341390
"src", "cassette")
342-
cursor := 0
343391
for cursor < len(session.Frames) {
344392
f := session.Frames[cursor]
345393
switch f.Direction {

‎internal/proxy/websocket_test.go‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,101 @@ func TestReplayWSSamePathSameQueryKeepsOrder(t *testing.T) {
311311
}
312312
}
313313

314+
// TestReplayWSNarrowsSamePathByLeadingClientFrames guards the TTS case: several
315+
// sessions share host, path, query *and* their first client frame (a
316+
// session.config that carries no per-test info); only the second client frame —
317+
// the text to synthesize — tells them apart. Recorded order can't decide, since
318+
// parallel test workers connect in arbitrary order.
319+
func TestReplayWSNarrowsSamePathByLeadingClientFrames(t *testing.T) {
320+
config := cassette.WSFrame{
321+
Direction: cassette.DirClientToServer,
322+
Opcode: cassette.OpText,
323+
Data: `{"type":"session.config","voice":"heidi"}`,
324+
}
325+
session := func(text, audio string) cassette.Interaction {
326+
return cassette.Interaction{Type: "websocket", WebSocket: &cassette.WSSession{
327+
Request: cassette.WSRequest{Path: "/v1/audio/speech/stream", Query: "model=VoxCPM2"},
328+
Frames: []cassette.WSFrame{
329+
config,
330+
{Direction: cassette.DirClientToServer, Opcode: cassette.OpText, Data: text},
331+
{Direction: cassette.DirServerToClient, Opcode: cassette.OpText, Data: audio},
332+
},
333+
}}
334+
}
335+
c := &cassette.Cassette{Interactions: []cassette.Interaction{
336+
session("short", "audio-short"),
337+
session("a much longer sentence", "audio-long"),
338+
}}
339+
rep := NewWSReplayer(c)
340+
srv := httptest.NewServer(ReplayWSHandler(rep))
341+
defer srv.Close()
342+
343+
synthesize := func(text string) string {
344+
conn, _, err := websocket.DefaultDialer.Dial(
345+
wsURL(srv.URL)+"/v1/audio/speech/stream?model=VoxCPM2", nil)
346+
if err != nil {
347+
t.Fatalf("dial %q: %v", text, err)
348+
}
349+
defer conn.Close()
350+
for _, frame := range []string{config.Data, text} {
351+
if err := conn.WriteMessage(websocket.TextMessage, []byte(frame)); err != nil {
352+
t.Fatalf("write %q: %v", text, err)
353+
}
354+
}
355+
_, msg, err := conn.ReadMessage()
356+
if err != nil {
357+
t.Fatalf("read %q: %v", text, err)
358+
}
359+
return string(msg)
360+
}
361+
362+
for run := 1; run <= 3; run++ {
363+
// Ask for the second recording first — arrival order must not decide.
364+
if got := synthesize("a much longer sentence"); got != "audio-long" {
365+
t.Fatalf("run %d long: got %q, want audio-long", run, got)
366+
}
367+
if got := synthesize("short"); got != "audio-short" {
368+
t.Fatalf("run %d short: got %q, want audio-short", run, got)
369+
}
370+
}
371+
}
372+
373+
// TestReplayWSNarrowingDriftClosesConnection covers the other exit of frame
374+
// narrowing: a client frame that matches none of the still-ambiguous candidates
375+
// is drift, not a session pick, and must close the connection loudly. Drift hits
376+
// on the *second* frame here — after narrowing already accepted the shared first
377+
// one — so only the narrowing path can reach it.
378+
func TestReplayWSNarrowingDriftClosesConnection(t *testing.T) {
379+
session := func(text string) cassette.Interaction {
380+
return cassette.Interaction{Type: "websocket", WebSocket: &cassette.WSSession{
381+
Request: cassette.WSRequest{Path: "/stream"},
382+
Frames: []cassette.WSFrame{
383+
{Direction: cassette.DirClientToServer, Opcode: cassette.OpText, Data: "config"},
384+
{Direction: cassette.DirClientToServer, Opcode: cassette.OpText, Data: text},
385+
{Direction: cassette.DirServerToClient, Opcode: cassette.OpText, Data: "ok"},
386+
},
387+
}}
388+
}
389+
c := &cassette.Cassette{Interactions: []cassette.Interaction{session("a"), session("b")}}
390+
rep := NewWSReplayer(c)
391+
srv := httptest.NewServer(ReplayWSHandler(rep))
392+
defer srv.Close()
393+
394+
conn, _, err := websocket.DefaultDialer.Dial(wsURL(srv.URL)+"/stream", nil)
395+
if err != nil {
396+
t.Fatalf("dial: %v", err)
397+
}
398+
defer conn.Close()
399+
for _, frame := range []string{"config", "c"} {
400+
if err := conn.WriteMessage(websocket.TextMessage, []byte(frame)); err != nil {
401+
t.Fatalf("write %q: %v", frame, err)
402+
}
403+
}
404+
if _, _, err = conn.ReadMessage(); err == nil {
405+
t.Fatalf("expected connection close on cassette drift, got no error")
406+
}
407+
}
408+
314409
func TestReplayWSMissReturns599(t *testing.T) {
315410
rep := NewWSReplayer(&cassette.Cassette{})
316411
srv := httptest.NewServer(ReplayWSHandler(rep))

0 commit comments

Comments
 (0)