mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
51cd6e84e4
Closes the restore-rollback vs applier-ack clobber race with a durable operation-id correlation instead of a timing heuristic. The client brackets its setContent with an applier_apply_start{request_id} control frame; the server decides whether the external write persisted by reading the per-conn op-log high-water UNDER the same appendMu that sets the restore freeze (finalize-at-freeze — no drain, so a blocked write can't stall the restore; no timing window). Edges handled: unanchored conns are never elected; gate admission spans registration; legacy (pre-bracket) clients negotiate capability and an unconfirmable legacy round-trip returns a retryable 409 applier_ambiguous (fail-safe, never a clobber); the applier callback is synchronous-by-type so nothing can split the bracket. Normal acks stay on a lock-free, latency-identical fast path.
Confirming Codex (high effort): redesigned from a timing grace after review; 3 rounds on the op-id design (2 P1 -> 3 P1+P2 -> CLEAN/converging). E2E + Go(PostgreSQL) green; go test -race clean 8x. Go/Web CI red only on the pre-existing dependency advisories (BUG-2278).
https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
666 lines
20 KiB
Go
666 lines
20 KiB
Go
package collab
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// runApplierEcho spawns a goroutine that reads all incoming TextMessage
|
|
// frames on conn, looks for applier_request, and immediately responds
|
|
// with applier_ack. Returns a stop function that closes the conn.
|
|
func runApplierEcho(t *testing.T, conn *websocket.Conn) func() {
|
|
t.Helper()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
mt, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if mt != websocket.TextMessage {
|
|
continue
|
|
}
|
|
var ctl ControlMessage
|
|
if err := json.Unmarshal(data, &ctl); err != nil {
|
|
continue
|
|
}
|
|
if ctl.Type != ControlMessageApplierRequest {
|
|
continue
|
|
}
|
|
ack := ControlMessage{
|
|
Type: ControlMessageApplierAck,
|
|
RequestID: ctl.RequestID,
|
|
}
|
|
payload, _ := json.Marshal(ack)
|
|
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return func() {
|
|
_ = conn.Close()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentNoActiveRoom returns ErrNoActiveRoom when
|
|
// the manager doesn't know about the item yet.
|
|
func TestApplyExternalContentNoActiveRoom(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
err := mgr.ApplyExternalContent("item-a", "# new content")
|
|
if !errors.Is(err, ErrNoActiveRoom) {
|
|
t.Fatalf("want ErrNoActiveRoom, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentHappyPath: one connected applier echoes the
|
|
// ack immediately; ApplyExternalContent returns nil within a few ms.
|
|
func TestApplyExternalContentHappyPath(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWS(t, srv, "item-a")
|
|
stop := runApplierEcho(t, conn)
|
|
defer stop()
|
|
|
|
// Wait for the conn to register AND anchor (electable).
|
|
waitElectable(t, mgr, "item-a", 1)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- mgr.ApplyExternalContent("item-a", "# new content")
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("happy path: want nil, got %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("ApplyExternalContent did not return within 2s on happy path")
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentTimeoutsThenFails simulates a connected
|
|
// applier that NEVER acks. The first attempt should run until
|
|
// applierFirstTimeout fires, the retry until applierRetryTimeout
|
|
// (with the same conn re-tried only if no other appliers exist;
|
|
// here there's just one, so the second attempt picks no candidate
|
|
// and we get ErrAllAppliersTimedOut).
|
|
func TestApplyExternalContentTimeoutsThenFails(t *testing.T) {
|
|
// Shrink timeouts so the test runs in seconds, not minutes.
|
|
origFirst, origRetry := applierFirstTimeoutVar, applierRetryTimeoutVar
|
|
applierFirstTimeoutTest := 80 * time.Millisecond
|
|
applierRetryTimeoutTest := 40 * time.Millisecond
|
|
swapApplierTimeouts(applierFirstTimeoutTest, applierRetryTimeoutTest)
|
|
defer swapApplierTimeouts(origFirst, origRetry)
|
|
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWS(t, srv, "item-a")
|
|
defer conn.Close()
|
|
// NO applier echo — frames are dropped on the floor by the
|
|
// drainer, so applier_request never produces an ack.
|
|
go func() {
|
|
for {
|
|
if _, _, err := conn.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
waitElectable(t, mgr, "item-a", 1)
|
|
|
|
start := time.Now()
|
|
err := mgr.ApplyExternalContent("item-a", "# new content")
|
|
elapsed := time.Since(start)
|
|
|
|
if !errors.Is(err, ErrAllAppliersTimedOut) {
|
|
t.Fatalf("want ErrAllAppliersTimedOut, got %v", err)
|
|
}
|
|
// First attempt's timeout fires (~80ms). The retry loop then
|
|
// looks for another applier, finds none (only one conn was
|
|
// dialed), and returns immediately. Floor at firstTimeout,
|
|
// ceiling well below firstTimeout + retryTimeout.
|
|
if elapsed < applierFirstTimeoutTest {
|
|
t.Errorf("returned too fast: %v < %v", elapsed, applierFirstTimeoutTest)
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentTimeoutThenSecondAcks: first applier never
|
|
// responds, second one does — the retry should hit the second
|
|
// applier and succeed.
|
|
func TestApplyExternalContentTimeoutThenSecondAcks(t *testing.T) {
|
|
origFirst, origRetry := applierFirstTimeoutVar, applierRetryTimeoutVar
|
|
swapApplierTimeouts(80*time.Millisecond, 1*time.Second)
|
|
defer swapApplierTimeouts(origFirst, origRetry)
|
|
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
// First conn: no echo, just drains.
|
|
first := dialWS(t, srv, "item-a")
|
|
defer first.Close()
|
|
go func() {
|
|
for {
|
|
if _, _, err := first.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Wait so the second conn has a strictly later connectedAt.
|
|
time.Sleep(15 * time.Millisecond)
|
|
|
|
// Second conn: echoes acks. Because pickApplier sorts by
|
|
// connectedAt asc, `first` is chosen for attempt 1; `second`
|
|
// for the retry.
|
|
second := dialWS(t, srv, "item-a")
|
|
stop := runApplierEcho(t, second)
|
|
defer stop()
|
|
|
|
waitElectable(t, mgr, "item-a", 2)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- mgr.ApplyExternalContent("item-a", "# new content")
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("retry path: want nil, got %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("retry path did not complete within 3s")
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentCleansPendingAcksOnSuccess ensures the
|
|
// per-room pendingAcks map doesn't leak entries across successful
|
|
// applies — without the cancel-on-success cleanup, every external
|
|
// update would retain a request_id + channel + conn pointer for the
|
|
// rest of the room's lifetime.
|
|
func TestApplyExternalContentCleansPendingAcksOnSuccess(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWS(t, srv, "item-a")
|
|
stop := runApplierEcho(t, conn)
|
|
defer stop()
|
|
|
|
waitElectable(t, mgr, "item-a", 1)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if err := mgr.ApplyExternalContent("item-a", "# rev"); err != nil {
|
|
t.Fatalf("apply %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Drill into the room to count pending entries — this is a
|
|
// white-box assertion against the cleanup contract.
|
|
mgr.mu.Lock()
|
|
room := mgr.rooms["item-a"]
|
|
mgr.mu.Unlock()
|
|
if room == nil {
|
|
t.Fatal("room missing after applies")
|
|
}
|
|
room.pendingMu.Lock()
|
|
left := len(room.pendingAcks)
|
|
room.pendingMu.Unlock()
|
|
if left != 0 {
|
|
t.Fatalf("pendingAcks leaked across applies: %d entries left", left)
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentSendsExpiresAt confirms the server stamps
|
|
// the applier_request with an expires_at_millis. Defends against
|
|
// a regression where the field is silently dropped — the browser
|
|
// (TASK-1263) needs it to refuse late-arriving requests.
|
|
func TestApplyExternalContentSendsExpiresAt(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWS(t, srv, "item-a")
|
|
defer conn.Close()
|
|
|
|
captured := make(chan ControlMessage, 1)
|
|
go func() {
|
|
for {
|
|
mt, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if mt != websocket.TextMessage {
|
|
continue
|
|
}
|
|
var ctl ControlMessage
|
|
if err := json.Unmarshal(data, &ctl); err != nil {
|
|
continue
|
|
}
|
|
if ctl.Type == ControlMessageApplierRequest {
|
|
select {
|
|
case captured <- ctl:
|
|
default:
|
|
}
|
|
// Honest ack so ApplyExternalContent returns.
|
|
ack := ControlMessage{Type: ControlMessageApplierAck, RequestID: ctl.RequestID}
|
|
payload, _ := json.Marshal(ack)
|
|
_ = conn.WriteMessage(websocket.TextMessage, payload)
|
|
}
|
|
}
|
|
}()
|
|
|
|
waitElectable(t, mgr, "item-a", 1)
|
|
|
|
if err := mgr.ApplyExternalContent("item-a", "# new"); err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
|
|
select {
|
|
case msg := <-captured:
|
|
if msg.ExpiresAtMillis == 0 {
|
|
t.Errorf("expires_at_millis must be set on applier_request")
|
|
}
|
|
// Sanity: must be in the future.
|
|
if msg.ExpiresAtMillis < time.Now().UnixMilli() {
|
|
t.Errorf("expires_at_millis must be in the future, got %d (now=%d)",
|
|
msg.ExpiresAtMillis, time.Now().UnixMilli())
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("did not capture applier_request")
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentRejectsAckFromUnexpectedConn confirms the
|
|
// expectedConn check in resolveApplierAck. We wire two conns; the FIRST
|
|
// is the chosen applier; the SECOND tries to forge an ack for the
|
|
// first's request. The forgery should be ignored, and the request
|
|
// should still time out.
|
|
func TestApplyExternalContentRejectsAckFromUnexpectedConn(t *testing.T) {
|
|
origFirst, origRetry := applierFirstTimeoutVar, applierRetryTimeoutVar
|
|
swapApplierTimeouts(120*time.Millisecond, 60*time.Millisecond)
|
|
defer swapApplierTimeouts(origFirst, origRetry)
|
|
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
// First conn — chosen applier (longest-connected) but does NOT
|
|
// auto-ack. We capture the inbound request_id and forge the ack
|
|
// from the OTHER conn.
|
|
first := dialWS(t, srv, "item-a")
|
|
defer first.Close()
|
|
requestIDCh := make(chan string, 1)
|
|
go func() {
|
|
for {
|
|
mt, data, err := first.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if mt != websocket.TextMessage {
|
|
continue
|
|
}
|
|
var ctl ControlMessage
|
|
if err := json.Unmarshal(data, &ctl); err != nil {
|
|
continue
|
|
}
|
|
if ctl.Type == ControlMessageApplierRequest {
|
|
select {
|
|
case requestIDCh <- ctl.RequestID:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
time.Sleep(15 * time.Millisecond)
|
|
second := dialWS(t, srv, "item-a")
|
|
defer second.Close()
|
|
go func() {
|
|
for {
|
|
if _, _, err := second.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
waitElectable(t, mgr, "item-a", 2)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- mgr.ApplyExternalContent("item-a", "# new content")
|
|
}()
|
|
|
|
// Wait for the request_id to surface, then forge the ack from
|
|
// the SECOND conn — this should be ignored by the room.
|
|
var reqID string
|
|
select {
|
|
case reqID = <-requestIDCh:
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatal("did not capture applier_request on first conn")
|
|
}
|
|
|
|
forge := ControlMessage{Type: ControlMessageApplierAck, RequestID: reqID}
|
|
payload, _ := json.Marshal(forge)
|
|
if err := second.WriteMessage(websocket.TextMessage, payload); err != nil {
|
|
t.Fatalf("forge write: %v", err)
|
|
}
|
|
|
|
// The forged ack must NOT unblock ApplyExternalContent. It
|
|
// should run to its timeout (and on the retry, since `second`
|
|
// also doesn't honestly ack, the second attempt will also
|
|
// time out).
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, ErrAllAppliersTimedOut) {
|
|
t.Fatalf("forged ack must not satisfy ApplyExternalContent; got err=%v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Apply did not return; forged ack should have been rejected and timeouts honoured")
|
|
}
|
|
}
|
|
|
|
// TestApplyExternalContentSkipsReadOnlyApplier is a TASK-265
|
|
// regression: a read-only participant must NEVER be elected applier.
|
|
// If it were, it would ack a no-op — its resulting Y.Doc sync frames
|
|
// are dropped by the read-only gate — and ApplyExternalContent would
|
|
// falsely return nil, making the PATCH handler skip its direct-write
|
|
// fallback and silently lose the external edit. With the only conn
|
|
// read-only, pickApplier finds no candidate → ErrNoApplierAvailable,
|
|
// so the caller falls back to a direct write (no loss). The conn even
|
|
// runs an honest ack-echo to prove that a *willing* but unauthorized
|
|
// applier is still never asked.
|
|
func TestApplyExternalContentSkipsReadOnlyApplier(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWSReadOnly(t, srv, "item-a")
|
|
stop := runApplierEcho(t, conn)
|
|
defer stop()
|
|
|
|
// Wait for the room to register the read-only conn.
|
|
for i := 0; i < 200; i++ {
|
|
if mgr.RoomCount() == 1 && bus.SubscriberCount("item-a") == 1 {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
err := mgr.ApplyExternalContent("item-a", "# new content")
|
|
if !errors.Is(err, ErrNoApplierAvailable) {
|
|
t.Fatalf("read-only-only room: want ErrNoApplierAvailable (so the caller direct-writes), got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHandleControlMessageIgnoresAckFromReadOnlyConn is the
|
|
// defence-in-depth half of the TASK-265 applier fix: even if a
|
|
// read-only conn somehow holds a pending-ack slot (e.g. an editor
|
|
// demoted after being elected), an applier_ack from it must be
|
|
// ignored so ApplyExternalContent doesn't report a phantom success.
|
|
func TestHandleControlMessageIgnoresAckFromReadOnlyConn(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
room := mgr.getOrCreate("item-a")
|
|
|
|
// Read-only conn: canWrite defaults to false. The conn pointer is
|
|
// only used for identity (pending-ack expectedConn match); no I/O
|
|
// happens on it because the ack is rejected before resolveApplierAck.
|
|
rc := &roomConn{id: 1, conn: &websocket.Conn{}}
|
|
|
|
reqID := "req-readonly"
|
|
ch, err := room.registerPendingAck(reqID, rc)
|
|
if err != nil {
|
|
t.Fatalf("registerPendingAck: %v", err)
|
|
}
|
|
|
|
payload, _ := json.Marshal(ControlMessage{Type: ControlMessageApplierAck, RequestID: reqID})
|
|
room.handleControlMessage(rc, payload)
|
|
|
|
select {
|
|
case <-ch:
|
|
t.Fatal("applier_ack from a read-only conn must be ignored, but it resolved the round-trip")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// Correct: the ack was dropped before resolveApplierAck.
|
|
}
|
|
}
|
|
|
|
// TestApplierAckFromFrozenConnResolvesFromDurableState is the BUG-2276 residual-2
|
|
// replacement for the old "frozen conn ack is ignored" test. A frozen conn's ack is
|
|
// now PROCESSED, but it resolves the round-trip from DURABLE op-log state, not a
|
|
// phantom success: if a frame in the apply bracket was frozen-dropped
|
|
// (frozenDropSeq advanced past the apply_start baseline), the ack resolves to FALSE
|
|
// (retry) — never a phantom success while the setContent was lost to the freeze.
|
|
func TestApplierAckFromFrozenConnResolvesFromDurableState(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
room := mgr.getOrCreate("item-a")
|
|
|
|
rc := &roomConn{id: 1, conn: &websocket.Conn{}}
|
|
rc.canWrite.Store(true)
|
|
rc.frozen.Store(true)
|
|
rc.bracketCapable.Store(true) // bracket-capable → a frozen-dropped frame is NotPersisted (retry)
|
|
|
|
reqID := "req-frozen"
|
|
ch, err := room.registerPendingAck(reqID, rc)
|
|
if err != nil {
|
|
t.Fatalf("registerPendingAck: %v", err)
|
|
}
|
|
|
|
// Open the apply bracket (captures the baseline), then simulate the setContent
|
|
// frame being frozen-dropped by readLoop while this conn is frozen.
|
|
room.startApplierApply(reqID, rc)
|
|
rc.frozenDropSeq.Add(1)
|
|
|
|
payload, _ := json.Marshal(ControlMessage{Type: ControlMessageApplierAck, RequestID: reqID})
|
|
room.handleControlMessage(rc, payload)
|
|
|
|
select {
|
|
case outcome := <-ch:
|
|
if outcome != applierNotPersisted {
|
|
t.Fatalf("frozen-dropped setContent (bracket-capable) must resolve NotPersisted (retry), not a phantom success; got %v", outcome)
|
|
}
|
|
case <-time.After(50 * time.Millisecond):
|
|
t.Fatal("frozen conn ack must resolve the round-trip from durable state, but nothing was delivered")
|
|
}
|
|
}
|
|
|
|
// TestApplierAckFromLegacyFrozenConnIsAmbiguous: a LEGACY (non-bracket-capable) conn
|
|
// whose frame was frozen-dropped can't be attributed → the ack resolves AMBIGUOUS
|
|
// (fail-safe), NEVER a phantom success.
|
|
func TestApplierAckFromLegacyFrozenConnIsAmbiguous(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
room := mgr.getOrCreate("item-a")
|
|
rc := &roomConn{id: 1, conn: &websocket.Conn{}}
|
|
rc.canWrite.Store(true)
|
|
rc.frozen.Store(true) // legacy: bracketCapable defaults false
|
|
|
|
reqID := "req-legacy-frozen"
|
|
ch, err := room.registerPendingAck(reqID, rc)
|
|
if err != nil {
|
|
t.Fatalf("registerPendingAck: %v", err)
|
|
}
|
|
rc.frozenDropSeq.Add(1) // a frame was frozen-dropped in the round-trip window
|
|
|
|
payload, _ := json.Marshal(ControlMessage{Type: ControlMessageApplierAck, RequestID: reqID})
|
|
room.handleControlMessage(rc, payload)
|
|
|
|
select {
|
|
case outcome := <-ch:
|
|
if outcome != applierAmbiguous {
|
|
t.Fatalf("legacy frozen-dropped round-trip must resolve Ambiguous (fail-safe), got %v", outcome)
|
|
}
|
|
case <-time.After(50 * time.Millisecond):
|
|
t.Fatal("legacy frozen conn ack must resolve, but nothing was delivered")
|
|
}
|
|
}
|
|
|
|
// TestApplierAckSucceedsWhenFramePersisted confirms the positive durable case: a
|
|
// setContent frame that persisted (no frozen drop in the bracket) resolves the ack
|
|
// to SUCCESS — including the no-op-diff case where setContent persists nothing.
|
|
func TestApplierAckSucceedsWhenFramePersisted(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
room := mgr.getOrCreate("item-a")
|
|
rc := &roomConn{id: 1, conn: &websocket.Conn{}}
|
|
rc.canWrite.Store(true)
|
|
rc.bracketCapable.Store(true)
|
|
|
|
reqID := "req-ok"
|
|
ch, err := room.registerPendingAck(reqID, rc)
|
|
if err != nil {
|
|
t.Fatalf("registerPendingAck: %v", err)
|
|
}
|
|
room.startApplierApply(reqID, rc)
|
|
// setContent persisted a frame (op-log id 7); no frozen drop.
|
|
rc.lastPersistedOpID.Store(7)
|
|
|
|
payload, _ := json.Marshal(ControlMessage{Type: ControlMessageApplierAck, RequestID: reqID})
|
|
room.handleControlMessage(rc, payload)
|
|
|
|
select {
|
|
case outcome := <-ch:
|
|
if outcome != applierPersisted {
|
|
t.Fatalf("a persisted setContent (no frozen drop) must resolve to applierPersisted, got %v", outcome)
|
|
}
|
|
case <-time.After(50 * time.Millisecond):
|
|
t.Fatal("ack must resolve the round-trip, but nothing was delivered")
|
|
}
|
|
}
|
|
|
|
// TestPruneAndApplyAllowsReadOnlyRoom verifies the load-bearing
|
|
// writer-aware guard (TASK-265): a room whose only peers are read-only
|
|
// must NOT block the no-applier direct write. PruneAndApply runs applyFn
|
|
// (prune + write) rather than returning ErrRoomActiveDuringPrune, so the
|
|
// op-log is safely pruned even with viewers attached and a later editor
|
|
// lazy-seeds from the fresh items.content. (Read-only peers keep a
|
|
// possibly-stale Y.Doc until they reconnect/refresh — an accepted
|
|
// best-effort residual tracked in BUG-2103.)
|
|
func TestPruneAndApplyAllowsReadOnlyRoom(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWSReadOnly(t, srv, "item-a")
|
|
defer conn.Close()
|
|
|
|
for i := 0; i < 200; i++ {
|
|
if mgr.RoomCount() == 1 && bus.SubscriberCount("item-a") == 1 {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
ran := false
|
|
if err := mgr.PruneAndApply("item-a", func() error { ran = true; return nil }); err != nil {
|
|
t.Fatalf("read-only-only room: want nil (prune allowed), got %v", err)
|
|
}
|
|
if !ran {
|
|
t.Fatal("applyFn did not run — a read-only peer wrongly blocked the prune")
|
|
}
|
|
}
|
|
|
|
// TestPruneAndApplyBlockedByLiveWriter confirms the complementary
|
|
// invariant: a live WRITER still blocks the prune (ErrRoomActiveDuringPrune)
|
|
// so the caller routes the external update through the applier protocol
|
|
// rather than clobbering the writer's in-memory Y.Doc.
|
|
func TestPruneAndApplyBlockedByLiveWriter(t *testing.T) {
|
|
bus := NewMemoryOpBus()
|
|
defer bus.Close()
|
|
mgr := NewRoomManager(&fakeOpLog{}, bus)
|
|
defer mgr.Close()
|
|
|
|
srv := newCollabTestServer(t, mgr)
|
|
defer srv.Close()
|
|
|
|
conn := dialWS(t, srv, "item-a") // writer (canWrite=true)
|
|
defer conn.Close()
|
|
|
|
waitElectable(t, mgr, "item-a", 1)
|
|
|
|
ran := false
|
|
err := mgr.PruneAndApply("item-a", func() error { ran = true; return nil })
|
|
if !errors.Is(err, ErrRoomActiveDuringPrune) {
|
|
t.Fatalf("want ErrRoomActiveDuringPrune with a live writer, got %v", err)
|
|
}
|
|
if ran {
|
|
t.Fatal("applyFn must not run while a live writer is present")
|
|
}
|
|
}
|
|
|
|
// swapApplierTimeouts is a small test-only mutator. The package
|
|
// constants are *not* exported as vars so the swap goes through a
|
|
// dedicated helper that lives only in test builds. We accept a
|
|
// brief mutex / shared-state hand-wave here because the tests run
|
|
// serially within the package by default.
|
|
var applierTimeoutMu sync.Mutex
|
|
|
|
func swapApplierTimeouts(first, retry time.Duration) {
|
|
applierTimeoutMu.Lock()
|
|
defer applierTimeoutMu.Unlock()
|
|
applierFirstTimeoutVar = first
|
|
applierRetryTimeoutVar = retry
|
|
}
|