@@ -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.
319357func 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 {
0 commit comments