From e485b3ab9da346244ee897653c9cf6c7e1f8af35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 18:09:30 +0000 Subject: [PATCH] fix(jellycompat): stop binding NUL advisory lock keys #529 stripped NUL from durable playback-session JSON, but replaceUnstartedNegotiation still joined token/device/item with U+0000 and bound that string as PostgreSQL text. Postgres rejects NUL in text (SQLSTATE 22021), so every negotiated compat session with a DeviceId failed to persist and stayed cache-only. Join the lock key with chr(31) inside PostgreSQL, matching the marker contribution lock, so the bind parameters stay valid text. Co-authored-by: Quick --- .../jellycompat/playback_sessions_postgres.go | 17 +++++++++++++++-- .../playback_sessions_postgres_test.go | 10 ++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/jellycompat/playback_sessions_postgres.go b/internal/jellycompat/playback_sessions_postgres.go index 99599521..92318176 100644 --- a/internal/jellycompat/playback_sessions_postgres.go +++ b/internal/jellycompat/playback_sessions_postgres.go @@ -42,6 +42,13 @@ var ( var jsonNULCodePoint = []byte(`\u0000`) +// compatNegotiationLockSQL takes token, device ID, and route item ID as +// separate text parameters and joins them with chr(31) inside PostgreSQL. +const compatNegotiationLockSQL = ` + SELECT pg_advisory_xact_lock( + hashtextextended($1 || chr(31) || $2 || chr(31) || $3, 0) + )` + // marshalPlaybackSession removes NUL code points from every nested string in // the JSON document. PostgreSQL JSONB rejects U+0000 even when Go's encoder // represents it as \u0000. Escaped literal text (\\u0000) remains unchanged. @@ -218,8 +225,14 @@ func (d *DurableCompatPlaybackStore) replaceUnstartedNegotiation( var removed []string if session.CompatToken != "" && session.ClientDeviceID != "" && session.RouteItemID != "" { - scope := session.CompatToken + "\x00" + session.ClientDeviceID + "\x00" + session.RouteItemID - if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, scope); err != nil { + // Join the lock key inside PostgreSQL. A Go-side NUL delimiter cannot + // be bound as text: PostgreSQL rejects U+0000 (SQLSTATE 22021), the + // transaction aborts, and the negotiated session stays cache-only. + if _, err := tx.Exec(ctx, compatNegotiationLockSQL, + stripCompatNUL(session.CompatToken), + stripCompatNUL(session.ClientDeviceID), + stripCompatNUL(session.RouteItemID), + ); err != nil { return nil, err } rows, err := tx.Query(ctx, ` diff --git a/internal/jellycompat/playback_sessions_postgres_test.go b/internal/jellycompat/playback_sessions_postgres_test.go index 265d2efe..dbd0fcb5 100644 --- a/internal/jellycompat/playback_sessions_postgres_test.go +++ b/internal/jellycompat/playback_sessions_postgres_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "testing" "time" @@ -13,6 +14,15 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +func TestCompatNegotiationLockSQLDoesNotBindNULDelimiter(t *testing.T) { + if strings.Contains(compatNegotiationLockSQL, "\x00") { + t.Fatal("advisory lock SQL must not embed a NUL delimiter") + } + if !strings.Contains(compatNegotiationLockSQL, "chr(31)") { + t.Fatal("advisory lock SQL should join key parts with chr(31) inside PostgreSQL") + } +} + func TestMarshalPlaybackSessionStripsNestedNUL(t *testing.T) { wantLiteral := `literal\u0000text` data, err := marshalPlaybackSession(PlaybackSession{