Files
pad/internal/server/bug3080_client_write_test.go
T
xarmian 371ea3a145 fix: a tab's older content write can no longer land over its newer one (BUG-3080) (#1435)
* fix: a tab's older content write can no longer land over its newer one (BUG-3080)

The item pane's teardown flushes (collab snapshot and raw markdown) are
fire-and-forget PATCHes carrying the whole body. Two can be in flight at once,
for example after a cancelled unload, more typing, then a real close. When
they arrive in the wrong order, the older one puts old text back.

The client cannot order them: the second is sent from a page that is going
away. A version token cannot either: both are dispatched before the tab sees
either result, so both carry the same token, and the older landing first would
get the NEWER refused. So the order is decided at the server, from what the
tab says:

- Every item-pane content PATCH now carries client_write {tab, n}. The tab is
  a per-page-load random id (never a session or user id), and n only rises.
  This covers all five content writes the pane sends, not only teardown, so an
  ordinary slow-then-fast pair is ordered too. That is intended.
- The server keeps the highest n APPLIED per (item, tab) in one bounded map
  (10,000 keys, 10 minutes idle). A lower n answers 409 superseded_write,
  naming the n it lost to, and writes nothing. The pane treats that answer as
  success and never retries.
- The per-key lock is held from the check through the write, so the older
  write cannot pass its check and then land last.
- Per instance, and forgotten on restart: a missing mark refuses nothing, so
  every gap degrades to today's behaviour. A write without client_write (the
  CLI, MCP) is never compared against a mark. The CLAUDE.md note sits beside
  the SSE budget.
- Refusals are counted in pad_content_writes_superseded_total.

Tests: Go handler tests on both paths, including a concurrent case held open
through a test-only seam, age and bound, and unstamped writes. Playwright
holds a real older flush and delivers it last. Removing the server check,
releasing the lock early, or not sending the stamp each turns a test red.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(server): client_write orders CONTENT writes only (BUG-3080, codex round 1)

A stamp on a PATCH that carries no content (a field or title write) used to
advance the (item, tab) mark. That could refuse this tab's next real content
write with a lower n. The stamp now neither orders nor marks a write that
carries no body. The pane never stamps such writes, so this pins the contract
rather than a current path. A test covers it, and dropping the gate turns it
red (409 superseded_by 50).

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(server): the client_write mark moves when the write commits, not at the response (BUG-3080, codex round 2)

The mark was advanced only on the 200 exit. A write that committed and then
failed in response enrichment (a 500) left it behind, so this tab's older
write could pass its check and overwrite what was just stored. It now moves at
the point every write path has confirmed its commit. That is argued from the
code: there is no seam to make enrichment fail on demand.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 13:54:24 -04:00

281 lines
11 KiB
Go

package server
import (
"encoding/json"
"net/http"
"sync"
"testing"
"time"
)
// BUG-3080: one browser tab's content writes to an item are ordered by the
// `client_write` counter it stamps on them. A write whose counter is below one
// already APPLIED for the same (item, tab) is refused 409 `superseded_write`
// and not written. The teardown flushes are the case: both are dispatched
// before the tab sees either result, so no version token can order them.
func patchContent(t *testing.T, srv *Server, slug, itemSlug, query string, body map[string]interface{}) (int, map[string]any) {
t.Helper()
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+itemSlug+query, body)
var out map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &out)
return rr.Code, out
}
func stamped(content, tab string, n int64) map[string]interface{} {
return map[string]interface{}{"content": content, "client_write": map[string]interface{}{"tab": tab, "n": n}}
}
func contentOf(t *testing.T, srv *Server, id string) string {
t.Helper()
it, err := srv.store.GetItem(id)
if err != nil {
t.Fatalf("GetItem: %v", err)
}
return it.Content
}
func TestBUG3080_OlderWriteLandingLastIsRefusedAndNotWritten(t *testing.T) {
for _, path := range []struct{ name, query string }{
{"plain content PATCH (raw saver)", ""},
{"collab-snapshot flush", "?source=collab-snapshot"},
} {
t.Run(path.name, func(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
// The NEWER write lands first...
if code, body := patchContent(t, srv, slug, item.Slug, path.query, stamped("newer", "tab-a", 2)); code != http.StatusOK {
t.Fatalf("the newer write must apply; got %d %v", code, body)
}
// ...then the OLDER one arrives.
code, body := patchContent(t, srv, slug, item.Slug, path.query, stamped("older", "tab-a", 1))
if code != http.StatusConflict {
t.Fatalf("the older write must be refused 409; got %d %v", code, body)
}
errObj, _ := body["error"].(map[string]any)
if errObj["code"] != "superseded_write" {
t.Fatalf("want code superseded_write; got %v", body)
}
details, _ := errObj["details"].(map[string]any)
if details["superseded_by"] != float64(2) || details["n"] != float64(1) {
t.Fatalf("the refusal must name the n it lost to; got %v", details)
}
if got := contentOf(t, srv, item.ID); got != "newer" {
t.Fatalf("BUG-3080: the older write overwrote the newer; content=%q", got)
}
})
}
}
func TestBUG3080_InOrderWritesBothApply(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
for n, content := range []string{"one", "two", "three"} {
if code, body := patchContent(t, srv, slug, item.Slug, "", stamped(content, "tab-a", int64(n+1))); code != http.StatusOK {
t.Fatalf("write %d must apply; got %d %v", n+1, code, body)
}
}
// An EQUAL counter is the last applied write again, not an older one.
if code, body := patchContent(t, srv, slug, item.Slug, "", stamped("three", "tab-a", 3)); code != http.StatusOK {
t.Fatalf("a repeat of the last applied counter must apply; got %d %v", code, body)
}
if got := contentOf(t, srv, item.ID); got != "three" {
t.Fatalf("content=%q", got)
}
}
func TestBUG3080_TabsAreIndependent(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
if code, _ := patchContent(t, srv, slug, item.Slug, "", stamped("tab a at 5", "tab-a", 5)); code != http.StatusOK {
t.Fatalf("tab a write must apply; got %d", code)
}
// Another tab's sequence starts from nothing: its n=1 is not "older".
if code, body := patchContent(t, srv, slug, item.Slug, "", stamped("tab b at 1", "tab-b", 1)); code != http.StatusOK {
t.Fatalf("another tab is another writer, not ordered against tab a; got %d %v", code, body)
}
// The mark is per ITEM too.
other := createTaskWithFields(t, srv, slug, "Other", `{"status":"open"}`)
if code, body := patchContent(t, srv, slug, other.Slug, "", stamped("other item", "tab-a", 1)); code != http.StatusOK {
t.Fatalf("tab a's mark on one item must not order its writes to another; got %d %v", code, body)
}
}
func TestBUG3080_UnstampedWriteIsUnaffectedByAnyMark(t *testing.T) {
// The CLI and MCP never send client_write. A tab having a high mark on
// the row must not change what they get.
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
if code, _ := patchContent(t, srv, slug, item.Slug, "", stamped("from the tab", "tab-a", 99)); code != http.StatusOK {
t.Fatalf("stamped write must apply; got %d", code)
}
code, body := patchContent(t, srv, slug, item.Slug, "", map[string]interface{}{"content": "from the CLI"})
if code != http.StatusOK {
t.Fatalf("an unstamped write must never be refused by a mark; got %d %v", code, body)
}
if got := contentOf(t, srv, item.ID); got != "from the CLI" {
t.Fatalf("content=%q", got)
}
// And it leaves the tab's mark where it was: its next write still orders.
if code, _ := patchContent(t, srv, slug, item.Slug, "", stamped("tab older", "tab-a", 50)); code != http.StatusConflict {
t.Fatalf("an unstamped write must not reset the tab's mark; got %d", code)
}
}
func TestBUG3080_AFailedWriteDoesNotMoveTheMark(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
// Refused on something other than order: an expected_seq nobody issued.
body := stamped("never lands", "tab-a", 7)
body["expected_seq"] = 999999
if code, out := patchContent(t, srv, slug, item.Slug, "", body); code != http.StatusConflict {
t.Fatalf("the stale token must refuse; got %d %v", code, out)
}
// n=7 was never APPLIED, so a lower n is not superseded by it.
if code, out := patchContent(t, srv, slug, item.Slug, "", stamped("lands", "tab-a", 3)); code != http.StatusOK {
t.Fatalf("a failed write must not advance the mark; got %d %v", code, out)
}
if got := contentOf(t, srv, item.ID); got != "lands" {
t.Fatalf("content=%q", got)
}
}
func TestBUG3080_MalformedStampIsA400(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
for _, stamp := range []map[string]interface{}{
{"tab": "", "n": 1},
{"tab": "tab-a", "n": 0},
{"tab": "tab-a", "n": -3},
{"tab": string(make([]byte, 65)), "n": 1},
} {
code, out := patchContent(t, srv, slug, item.Slug, "", map[string]interface{}{"content": "x", "client_write": stamp})
if code != http.StatusBadRequest {
t.Fatalf("stamp %v: want 400; got %d %v", stamp, code, out)
}
}
}
// The check, the write and the mark are ONE step per (item, tab). The older
// write is held after passing its check; the newer one must WAIT for it rather
// than write first, or the older would then land last — the defect, inside
// the server.
func TestBUG3080_NewerWriteWaitsForAnOlderOneMidWrite(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
holdOlder := make(chan struct{})
olderInside := make(chan struct{})
var once sync.Once
srv.afterClientWriteCheck = func(itemID string, n int64) {
if n == 1 {
once.Do(func() { close(olderInside) })
<-holdOlder
}
}
olderDone := make(chan int, 1)
go func() {
code, _ := patchContent(t, srv, slug, item.Slug, "", stamped("older", "tab-a", 1))
olderDone <- code
}()
<-olderInside
newerDone := make(chan int, 1)
go func() {
code, _ := patchContent(t, srv, slug, item.Slug, "", stamped("newer", "tab-a", 2))
newerDone <- code
}()
select {
case code := <-newerDone:
t.Fatalf("the newer write finished (%d) while the older one was inside its write — "+
"the check and the write are not one step", code)
case <-time.After(300 * time.Millisecond):
}
close(holdOlder)
if code := <-olderDone; code != http.StatusOK {
t.Fatalf("older write: %d", code)
}
if code := <-newerDone; code != http.StatusOK {
t.Fatalf("newer write: %d", code)
}
if got := contentOf(t, srv, item.ID); got != "newer" {
t.Fatalf("content=%q, want the newer write", got)
}
}
func TestBUG3080_MarksAgeOutAndStayBounded(t *testing.T) {
m := newClientWriteMarks()
clock := time.Unix(1_000_000, 0)
m.now = func() time.Time { return clock }
e, applied := m.acquire("item", "tab")
if applied != 0 {
t.Fatalf("a new key starts at 0; got %d", applied)
}
m.release(e, 4, true)
e, applied = m.acquire("item", "tab")
if applied != 4 {
t.Fatalf("the mark must persist; got %d", applied)
}
m.release(e, 4, true)
// Fill to the cap with other keys, then age everything.
for i := 0; len(m.entries) < clientWriteMarkMaxEntries; i++ {
e, _ := m.acquire("item", "t"+string(rune('a'+i%26))+time.Duration(i).String())
m.release(e, 1, true)
}
clock = clock.Add(clientWriteMarkMaxAge + time.Second)
e, _ = m.acquire("fresh", "tab")
m.release(e, 1, true)
if len(m.entries) != 1 {
t.Fatalf("idle entries past the age must be dropped when the cap is hit; %d remain", len(m.entries))
}
// A HELD entry is never evicted, even over the cap.
held, _ := m.acquire("held", "tab")
for i := 0; len(m.entries) < clientWriteMarkMaxEntries; i++ {
e, _ := m.acquire("x", time.Duration(i).String())
m.release(e, 1, true)
}
e, _ = m.acquire("one-more", "tab")
m.release(e, 1, true)
if _, ok := m.entries[clientWriteKey{item: "held", tab: "tab"}]; !ok {
t.Fatal("an entry a request holds was evicted")
}
if len(m.entries) > clientWriteMarkMaxEntries {
t.Fatalf("the map grew past its cap with idle entries to evict: %d", len(m.entries))
}
m.release(held, 1, true)
}
func TestBUG3080_AStampOnANonContentWriteNeitherOrdersNorMarks(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
// A field-only PATCH carrying a high stamp (codex round 1). A field, not a
// title: a rename moves the slug this test addresses the item by.
code, out := patchContent(t, srv, slug, item.Slug, "", map[string]interface{}{
"fields_patch": map[string]interface{}{"status": "done"}, "client_write": map[string]interface{}{"tab": "tab-a", "n": 50},
})
if code != http.StatusOK {
t.Fatalf("field write: %d %v", code, out)
}
// This tab's next CONTENT write, at a lower n, is not superseded by it.
if code, out := patchContent(t, srv, slug, item.Slug, "", stamped("real content", "tab-a", 2)); code != http.StatusOK {
t.Fatalf("a non-content write must not raise the content mark; got %d %v", code, out)
}
if got := contentOf(t, srv, item.ID); got != "real content" {
t.Fatalf("content=%q", got)
}
}