Skip to content

Commit fcd8b7b

Browse files
committed
fix(cli/exp_agents_diff): degrade oversized watcher payloads gracefully
Addresses Codex P2 on head 0d3f417: the local chat git watcher set a 4 MiB websocket read limit but agentgit caps each repository's UnifiedDiff at 3 MiB (maxTotalDiffSize), so a Changes message aggregating two or more maxed-out repos legitimately exceeds 4 MiB and triggers a coder/websocket close with StatusMessageTooBig. Because wsjson.Decoder swallowed the read error (log at Debug, close the channel), fetchLocalChatDiffContents surfaced a generic "git watch connection closed" error and the diff drawer crashed with a hard error in exactly the large-multi-repo case the local fallback was meant to improve: a regression from the pre-fallback behavior. Fix it with a narrowly scoped, two-layered degrade path: 1. Bump the client-side read limit from 4 MiB to 32 MiB (localChatDiffReadLimit), which covers ~10 maxed-out repos and makes realistic multi-repo worktrees just work without any fallback. 2. For pathological payloads beyond 32 MiB, treat the specific StatusMessageTooBig close as ignorable. fetchLocalChatDiffContents now reads from the websocket directly (bypassing wsjson.NewStream) so it can inspect websocket.CloseStatus(err), returning the narrow errLocalDiffMessageTooLarge sentinel on read-limit violations. shouldIgnoreLocalDiffFallbackError maps that sentinel onto the same remote-empty-diff fallback we already use for 404/403/400. Generic websocket close / decode / protocol errors continue to surface as hard errors so real regressions do not silently disappear behind the fallback. Tests: - IgnoresWatcherMessageTooBigCloses accepts the websocket, reads the refresh, then closes with StatusMessageTooBig. The client must degrade to the empty remote diff without error. - SurfacesUnexpectedWatcherCloseErrors closes with StatusInternalError instead and pins that the fallback is scoped to MessageTooBig only, so a future attempt to blanket-ignore every close reason immediately breaks the test. Pre-commit (gen/fmt/lint/build) passed. Full cli/ tests pass in 60.9s. > Mux is Mike's coding agent. This commit was made on Mike's behalf.
1 parent 0d3f417 commit fcd8b7b

2 files changed

Lines changed: 162 additions & 28 deletions

File tree

cli/exp_agents_diff.go

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"net/http"
@@ -13,12 +14,30 @@ import (
1314
"golang.org/x/xerrors"
1415

1516
"github.com/coder/coder/v2/codersdk"
16-
"github.com/coder/coder/v2/codersdk/wsjson"
1717
"github.com/coder/websocket"
1818
)
1919

2020
const localChatDiffWatchTimeout = 5 * time.Second
2121

22+
// localChatDiffReadLimit bounds the size of the Changes message the
23+
// client is willing to receive from the chat git watcher. agentgit
24+
// caps each repository's UnifiedDiff at ~3 MiB (maxTotalDiffSize),
25+
// and a Changes payload can aggregate many repos plus metadata, so
26+
// 4 MiB is too tight for realistic multi-repo worktrees. 32 MiB
27+
// covers ~10 maxed-out repos; pathological payloads beyond that still
28+
// fall back to the remote empty diff via
29+
// errLocalDiffMessageTooLarge / shouldIgnoreLocalDiffFallbackError.
30+
const localChatDiffReadLimit = 32 << 20 // 32 MiB
31+
32+
// errLocalDiffMessageTooLarge is returned when the chat git watcher
33+
// produces a Changes payload that exceeds localChatDiffReadLimit.
34+
// shouldIgnoreLocalDiffFallbackError treats it as ignorable so the
35+
// TUI degrades to the remote empty diff rather than surfacing a hard
36+
// error. Generic websocket close / decode errors are intentionally
37+
// still surfaced so real protocol regressions do not silently
38+
// disappear behind the fallback.
39+
var errLocalDiffMessageTooLarge = xerrors.New("chat git watcher payload exceeded client read limit")
40+
2241
func fetchChatDiffContents(
2342
ctx context.Context,
2443
client *codersdk.ExperimentalClient,
@@ -74,6 +93,17 @@ func fetchChatDiffContents(
7493
// exactly one contributing repository. The caller uses singleRepo to
7594
// decide whether it is safe to backfill remote-only metadata onto the
7695
// local diff. All error paths return singleRepo=false.
96+
//
97+
// This intentionally bypasses wsjson.NewStream and reads the websocket
98+
// directly so we can inspect the close status: an oversized Changes
99+
// payload must degrade to the remote empty diff via
100+
// errLocalDiffMessageTooLarge + shouldIgnoreLocalDiffFallbackError,
101+
// but wsjson.Decoder swallows the read error (logs at debug) and
102+
// closes the channel, which would collapse that specific case into
103+
// the same generic "connection closed" bucket as server crashes or
104+
// decode failures. Reading directly lets us narrowly fall back only
105+
// for read-limit violations while still surfacing real protocol
106+
// regressions.
77107
func fetchLocalChatDiffContents(
78108
parentCtx context.Context,
79109
client *codersdk.ExperimentalClient,
@@ -89,41 +119,57 @@ func fetchLocalChatDiffContents(
89119
defer func() {
90120
_ = conn.Close(websocket.StatusNormalClosure, "")
91121
}()
92-
conn.SetReadLimit(1 << 22) // 4MiB
122+
conn.SetReadLimit(localChatDiffReadLimit)
93123

94-
stream := wsjson.NewStream[
95-
codersdk.WorkspaceAgentGitServerMessage,
96-
codersdk.WorkspaceAgentGitClientMessage,
97-
](conn, websocket.MessageText, websocket.MessageText, client.Logger())
98-
if err := stream.Send(codersdk.WorkspaceAgentGitClientMessage{
124+
refreshPayload, err := json.Marshal(codersdk.WorkspaceAgentGitClientMessage{
99125
Type: codersdk.WorkspaceAgentGitClientMessageTypeRefresh,
100-
}); err != nil {
126+
})
127+
if err != nil {
128+
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("marshal git refresh: %w", err)
129+
}
130+
if err := conn.Write(ctx, websocket.MessageText, refreshPayload); err != nil {
101131
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("request git refresh: %w", err)
102132
}
103133

104-
messages := stream.Chan()
105134
for {
106-
select {
107-
case <-ctx.Done():
108-
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("watch chat git: %w", ctx.Err())
109-
case msg, ok := <-messages:
110-
if !ok {
111-
if ctx.Err() != nil {
112-
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("watch chat git: %w", ctx.Err())
113-
}
114-
return codersdk.ChatDiffContents{}, false, xerrors.New("git watch connection closed")
135+
msgType, payload, err := conn.Read(ctx)
136+
if err != nil {
137+
// Context expiration gets its own wrapping so it threads
138+
// cleanly through shouldIgnoreLocalDiffFallbackError's
139+
// context.DeadlineExceeded case.
140+
if ctxErr := ctx.Err(); ctxErr != nil {
141+
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("watch chat git: %w", ctxErr)
142+
}
143+
// A Changes payload that exceeds localChatDiffReadLimit
144+
// causes coder/websocket to close the connection with
145+
// StatusMessageTooBig. Surface that as the narrow
146+
// sentinel so the caller can fall back to the remote
147+
// empty diff instead of surfacing a hard error.
148+
if websocket.CloseStatus(err) == websocket.StatusMessageTooBig {
149+
return codersdk.ChatDiffContents{}, false, errLocalDiffMessageTooLarge
115150
}
116-
switch msg.Type {
117-
case codersdk.WorkspaceAgentGitServerMessageTypeError:
118-
message := strings.TrimSpace(msg.Message)
119-
if message == "" {
120-
message = "git watch returned an unknown error"
121-
}
122-
return codersdk.ChatDiffContents{}, false, xerrors.New(message)
123-
case codersdk.WorkspaceAgentGitServerMessageTypeChanges:
124-
diff, singleRepo := buildLocalChatDiffContents(chatID, msg.Repositories)
125-
return diff, singleRepo, nil
151+
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("read git watch: %w", err)
152+
}
153+
// Ignore unexpected frame types instead of erroring; the
154+
// watcher only emits text frames today and a future binary
155+
// heartbeat should not break the overlay.
156+
if msgType != websocket.MessageText {
157+
continue
158+
}
159+
var msg codersdk.WorkspaceAgentGitServerMessage
160+
if err := json.Unmarshal(payload, &msg); err != nil {
161+
return codersdk.ChatDiffContents{}, false, xerrors.Errorf("decode git watch message: %w", err)
162+
}
163+
switch msg.Type {
164+
case codersdk.WorkspaceAgentGitServerMessageTypeError:
165+
message := strings.TrimSpace(msg.Message)
166+
if message == "" {
167+
message = "git watch returned an unknown error"
126168
}
169+
return codersdk.ChatDiffContents{}, false, xerrors.New(message)
170+
case codersdk.WorkspaceAgentGitServerMessageTypeChanges:
171+
diff, singleRepo := buildLocalChatDiffContents(chatID, msg.Repositories)
172+
return diff, singleRepo, nil
127173
}
128174
}
129175
}
@@ -220,6 +266,15 @@ func shouldIgnoreLocalDiffFallbackError(err error) bool {
220266
if errors.Is(err, context.DeadlineExceeded) {
221267
return true
222268
}
269+
// An oversized Changes payload is a best-effort degradation
270+
// point: the remote /diff endpoint already returns the empty
271+
// placeholder in this case, so fall back to it instead of
272+
// surfacing a hard error. Scoped narrowly to the explicit
273+
// StatusMessageTooBig sentinel so generic websocket close /
274+
// decode errors still reach the user.
275+
if errors.Is(err, errLocalDiffMessageTooLarge) {
276+
return true
277+
}
223278

224279
sdkErr, ok := codersdk.AsError(err)
225280
if !ok {

cli/exp_agents_diff_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,85 @@ func TestFetchChatDiffContents(t *testing.T) {
406406
require.Equal(t, remotePR, *diff.PullRequestURL)
407407
})
408408

409+
t.Run("IgnoresWatcherMessageTooBigCloses", func(t *testing.T) {
410+
t.Parallel()
411+
412+
// agentgit caps each repository's UnifiedDiff at ~3 MiB and a
413+
// Changes payload aggregates every repo plus metadata, so a
414+
// realistic multi-repo workspace can legitimately produce a
415+
// payload that exceeds the client's websocket read limit.
416+
// When that happens coder/websocket closes the connection
417+
// with StatusMessageTooBig. fetchChatDiffContents must map
418+
// that specific close status onto errLocalDiffMessageTooLarge
419+
// and fall back to the remote empty diff rather than
420+
// surfacing a hard error to the TUI. Without this subtest,
421+
// removing the StatusMessageTooBig branch in
422+
// fetchLocalChatDiffContents or the errLocalDiffMessageTooLarge
423+
// branch in shouldIgnoreLocalDiffFallbackError would
424+
// silently regress the large-multi-repo case this feature is
425+
// meant to improve.
426+
ctx := t.Context()
427+
chatID := uuid.New()
428+
path := fmt.Sprintf("/api/experimental/chats/%s", chatID)
429+
client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
430+
switch r.URL.Path {
431+
case path + "/diff":
432+
rw.Header().Set("Content-Type", "application/json")
433+
require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID}))
434+
case path + "/stream/git":
435+
conn, err := websocket.Accept(rw, r, nil)
436+
require.NoError(t, err)
437+
// Drain the refresh before closing so the client
438+
// surfaces the close status from its next Read, not
439+
// an unrelated write error.
440+
_, _, err = conn.Read(ctx)
441+
require.NoError(t, err)
442+
require.NoError(t, conn.Close(websocket.StatusMessageTooBig, "too big"))
443+
default:
444+
http.NotFound(rw, r)
445+
}
446+
}))
447+
448+
diff, err := fetchChatDiffContents(ctx, client, chatID)
449+
require.NoError(t, err)
450+
require.Equal(t, chatID, diff.ChatID)
451+
require.Empty(t, diff.Diff)
452+
})
453+
454+
t.Run("SurfacesUnexpectedWatcherCloseErrors", func(t *testing.T) {
455+
t.Parallel()
456+
457+
// The StatusMessageTooBig fallback is intentionally narrow:
458+
// a generic websocket close (for example the server
459+
// crashing and closing with StatusInternalError) should
460+
// surface as an error rather than silently degrading,
461+
// because that would hide real protocol regressions behind
462+
// the best-effort fallback. This subtest pins that
463+
// distinction so a future attempt to blanket-ignore every
464+
// close reason immediately breaks the test.
465+
ctx := t.Context()
466+
chatID := uuid.New()
467+
path := fmt.Sprintf("/api/experimental/chats/%s", chatID)
468+
client := newTestExperimentalClient(t, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
469+
switch r.URL.Path {
470+
case path + "/diff":
471+
rw.Header().Set("Content-Type", "application/json")
472+
require.NoError(t, json.NewEncoder(rw).Encode(codersdk.ChatDiffContents{ChatID: chatID}))
473+
case path + "/stream/git":
474+
conn, err := websocket.Accept(rw, r, nil)
475+
require.NoError(t, err)
476+
_, _, err = conn.Read(ctx)
477+
require.NoError(t, err)
478+
require.NoError(t, conn.Close(websocket.StatusInternalError, "boom"))
479+
default:
480+
http.NotFound(rw, r)
481+
}
482+
}))
483+
484+
_, err := fetchChatDiffContents(ctx, client, chatID)
485+
require.Error(t, err)
486+
})
487+
409488
t.Run("ReturnsRemoteDiffWithoutDialingWatcher", func(t *testing.T) {
410489
t.Parallel()
411490

0 commit comments

Comments
 (0)