Files
pad/internal/server/bug3586_patch_timeout_test.go
T
xarmian ae52c96d02 fix(collab): a patch whose applier times out is not re-sent to another tab (BUG-3586) (#2059)
A designated applier that did not ack in time made electAndApply re-send
the same request to the next tab. For a markdown body that converges; for
a patch it does not: if the first tab applied it with its ack late, the
next tab's document already holds the change, and an insert-shaped edit
(new contains old) keeps its anchor there and is inserted twice. A patch
that reached a peer and timed out now returns the unknown outcome instead.
Re-election remains only where it is durably known not to have landed.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 08:33:52 -04:00

110 lines
3.6 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/collab"
"github.com/gorilla/websocket"
)
// BUG-3586: a patch whose applier times out must NOT be re-sent to another
// tab. Tab A applies it but acks after the server's timeout; tab B already
// holds A's change (synced), so a re-sent insert-shaped edit (new contains
// old) keeps its anchor there and would be inserted twice. The answer is
// the unknown outcome, and B receives nothing.
func TestBUG3586_TimedOutPatchIsNotResentToAnotherTab(t *testing.T) {
restore := collab.SetApplierTimeoutsForTesting(150*time.Millisecond, 150*time.Millisecond)
t.Cleanup(restore)
srv := testServerWithCollab(t)
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
f := newPatchFixture(t, srv, "the fox ran\n")
u, _ := url.Parse(ts.URL)
dial := func() *websocket.Conn {
t.Helper()
c, resp, err := websocket.DefaultDialer.Dial("ws://"+u.Host+"/api/v1/collab/"+f.item.ID+"?applier_bracket=1&patch_capable=1", nil)
if err != nil {
status := ""
if resp != nil {
status = resp.Status
}
t.Fatalf("dial: %v (%s)", err, status)
}
return c
}
waitPatchApplier := func() {
t.Helper()
deadline := time.Now().Add(applierProbeBudget)
for !srv.collab.HasElectablePatchApplier(f.item.ID) {
if time.Now().After(deadline) {
t.Fatal("no patch-capable applier became electable")
}
time.Sleep(applierProbePoll)
}
}
// Tab A, connected first so it is elected first: applies (brackets and
// sends a frame) but acks only after the server's timeout.
a := dial()
aDone := make(chan struct{})
go func() {
defer close(aDone)
for {
mt, data, err := a.ReadMessage()
if err != nil {
return
}
var ctl collab.ControlMessage
if mt != websocket.TextMessage || json.Unmarshal(data, &ctl) != nil || ctl.Type != collab.ControlMessageApplierRequest {
continue
}
send := func(m collab.ControlMessage) { b, _ := json.Marshal(m); _ = a.WriteMessage(websocket.TextMessage, b) }
send(collab.ControlMessage{Type: collab.ControlMessageApplierApplyStart, RequestID: ctl.RequestID})
_ = a.WriteMessage(websocket.BinaryMessage, []byte{0x00, 0x01, 0x02})
time.Sleep(400 * time.Millisecond) // past the timeout
before, after := "the fox ran\n", "the fox, then a dog, ran\n"
send(collab.ControlMessage{Type: collab.ControlMessageApplierAck, RequestID: ctl.RequestID, Before: &before, After: &after})
}
}()
t.Cleanup(func() { _ = a.Close(); <-aDone })
waitPatchApplier()
time.Sleep(20 * time.Millisecond) // A's connectedAt strictly earlier than B's
// Tab B: patch-capable, counts every applier_request it is sent.
b := dial()
var bAsked atomic.Int32
bDone := make(chan struct{})
go func() {
defer close(bDone)
for {
mt, data, err := b.ReadMessage()
if err != nil {
return
}
var ctl collab.ControlMessage
if mt == websocket.TextMessage && json.Unmarshal(data, &ctl) == nil && ctl.Type == collab.ControlMessageApplierRequest {
bAsked.Add(1)
}
}
}()
t.Cleanup(func() { _ = b.Close(); <-bDone })
code, out := f.patch(t, map[string]any{"edits": edits("fox", "fox, then a dog,")})
time.Sleep(200 * time.Millisecond)
if n := bAsked.Load(); n != 0 {
t.Fatalf("the timed-out patch was re-sent to another tab %d time(s); an insert-shaped edit would apply twice", n)
}
e, _ := out["error"].(map[string]any)
d, _ := e["details"].(map[string]any)
if code != http.StatusConflict || e["code"] != "content_not_applied" || d["content_outcome"] != contentOutcomeUnknown {
t.Fatalf("want the unknown outcome, got %d %v", code, out)
}
}