-
Notifications
You must be signed in to change notification settings - Fork 1
fix(mount): treat 429 backpressure as a yield, not a terminal cycle failure #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
bca3d8c
fix(cli): name the host in messages when mounted as agent-relay file
0f03ead
fix(cli): route every mounted command hint through programName()
167a64d
test(sdk): compare routing, not the name the binary calls itself
0ec5909
fix(mount): treat 429 backpressure as a yield, not a terminal cycle f…
8f81c20
fix(mount): never report a cold backpressure yield as a completed boo…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "regexp" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // commandMention matches guidance naming the relayfile binary as a command to | ||
| // run — "relayfile mount", "relayfile supervisor install". It deliberately | ||
| // requires a space, so the systemd unit name (relayfile-listen.service) and the | ||
| // launchd label (com.relayfile.listen) do not match: those are real filenames | ||
| // and must stay literal whoever is invoking us. | ||
| // | ||
| // A bare `relayfile` on its own in a usage block counts too: one such line | ||
| // survived a pass that only looked for `relayfile <verb>`. | ||
| var commandMention = regexp.MustCompile(`\brelayfile(?: [a-z]|\s*$|\s{2,})`) | ||
|
|
||
| // Nouns, not instructions. "delegated relayfile credentials" describes what is | ||
| // missing; it tells nobody to run anything. | ||
| var allowedNouns = []string{"relayfile credentials", "relayfile workspace id"} | ||
|
|
||
| func withoutAllowedNouns(text string) string { | ||
| for _, noun := range allowedNouns { | ||
| text = strings.ReplaceAll(text, noun, "") | ||
| } | ||
| return text | ||
| } | ||
|
|
||
| // TestUsageNamesTheInvokingProgram pins the contract behind relayfile#509: | ||
| // mounted as `agent-relay file`, nothing may tell the user to run `relayfile`, | ||
| // because that binary is not installed for them. | ||
| // | ||
| // Checked over the usage printers rather than one message, since the leak was | ||
| // never in one place: an earlier fix corrected five call sites found by | ||
| // grepping for "run relayfile", and review found dozens more phrased | ||
| // differently. A pattern is the only thing that catches the next one. | ||
| func TestUsageNamesTheInvokingProgram(t *testing.T) { | ||
| t.Setenv(programNameEnv, "agent-relay file") | ||
|
|
||
| printers := map[string]func(*bytes.Buffer){ | ||
| "usage": func(b *bytes.Buffer) { printUsage(b) }, | ||
| "listen": func(b *bytes.Buffer) { printListenUsage(b) }, | ||
| "supervisor": func(b *bytes.Buffer) { printSupervisorUsage(b) }, | ||
| "workspace": func(b *bytes.Buffer) { printWorkspaceUsage(b, "") }, | ||
| "integration": func(b *bytes.Buffer) { | ||
| printIntegrationUsage(b, "") | ||
| }, | ||
| "ops": func(b *bytes.Buffer) { printOpsUsage(b, "") }, | ||
| "writeback": func(b *bytes.Buffer) { printWritebackUsage(b, "") }, | ||
| "digest": func(b *bytes.Buffer) { printDigestUsage(b, "") }, | ||
| } | ||
|
|
||
| for name, print := range printers { | ||
| t.Run(name, func(t *testing.T) { | ||
| var out bytes.Buffer | ||
| print(&out) | ||
| if found := commandMention.FindString(withoutAllowedNouns(out.String())); found != "" { | ||
| t.Errorf("%s usage tells a mounted user to run %q; use programName()", name, found) | ||
| } | ||
| if !strings.Contains(out.String(), "agent-relay file") { | ||
| t.Errorf("%s usage never names the invoking program", name) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestUsageKeepsServiceFileNames guards the other direction: the systemd unit | ||
| // and launchd label are filenames on disk, identical for every caller, and a | ||
| // blanket rename would have broken them. | ||
| func TestUsageKeepsServiceFileNames(t *testing.T) { | ||
| t.Setenv(programNameEnv, "agent-relay file") | ||
| var out bytes.Buffer | ||
| printSupervisorUsage(&out) | ||
| for _, literal := range []string{"relayfile-listen.service", "com.relayfile.listen.plist"} { | ||
| if !strings.Contains(out.String(), literal) { | ||
| t.Errorf("supervisor usage no longer names %s; that is a real path, not a command", literal) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestUsageDefaultsToRelayfile keeps direct users seeing the name they typed. | ||
| func TestUsageDefaultsToRelayfile(t *testing.T) { | ||
| t.Setenv(programNameEnv, "") | ||
| var out bytes.Buffer | ||
| printUsage(&out) | ||
| if !strings.Contains(out.String(), "relayfile ") { | ||
| t.Error("unmounted usage should name relayfile") | ||
| } | ||
| if strings.Contains(out.String(), "agent-relay file") { | ||
| t.Error("unmounted usage must not name the host") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "testing" | ||
|
|
||
| "github.com/agentworkforce/relayfile/internal/mountsync" | ||
| ) | ||
|
|
||
| // A busy workspace answers 429 workspace_busy with an advertised Retry-After. | ||
| // Classifying that as a cycle FAILURE made the first cycle of an initial | ||
| // bootstrap fatal, so the run died roughly 35s into a 210s budget having synced | ||
| // zero files — surfacing to Cloud as BootstrapFailedError. That accounted for 50 | ||
| // of 62 proactive mount-bootstrap failures over three days in production. The | ||
| // retry budget already existed; the cycle only had to yield so the ticker could | ||
| // use it. | ||
| func TestBackpressureIsAYieldNotACycleFailure(t *testing.T) { | ||
| busy := &mountsync.HTTPError{ | ||
| StatusCode: http.StatusTooManyRequests, | ||
| Code: "workspace_busy", | ||
| Message: "workspace durable object is busy; retry after the advertised delay", | ||
| } | ||
|
|
||
| if !isBackpressureError(busy) { | ||
| t.Fatalf("429 workspace_busy must be recognised as backpressure") | ||
| } | ||
| if !cycleYielded(&cycleOutcomeError{cause: busy, yielded: true}) { | ||
| t.Fatalf("a backpressure cycle must report as yielded so the bootstrap resumes") | ||
| } | ||
| // Wrapped the way the syncer actually returns it. | ||
| if !isBackpressureError(fmt.Errorf("mount sync cycle: %w", busy)) { | ||
| t.Fatalf("backpressure must be detected through error wrapping") | ||
| } | ||
| } | ||
|
|
||
| // Deliberately narrow. A 5xx is the server being BROKEN, not busy, and a | ||
| // context deadline is handled by its own pre-existing branch. Treating either | ||
| // as backpressure would let a genuinely unhealthy backend look like a queue and | ||
| // spin the bootstrap for its whole budget instead of failing loudly. | ||
| func TestOnlyTooManyRequestsCountsAsBackpressure(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| name string | ||
| err error | ||
| }{ | ||
| {"500", &mountsync.HTTPError{StatusCode: http.StatusInternalServerError, Message: "boom"}}, | ||
| {"503", &mountsync.HTTPError{StatusCode: http.StatusServiceUnavailable, Message: "down"}}, | ||
| {"403", &mountsync.HTTPError{StatusCode: http.StatusForbidden, Message: "nope"}}, | ||
| {"deadline", context.DeadlineExceeded}, | ||
| {"plain", errors.New("something else")}, | ||
| } { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| if isBackpressureError(tc.err) { | ||
| t.Fatalf("%v must not be treated as server backpressure", tc.err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // Devin and Cursor both flagged this independently on PR #511, and they were | ||
| // right: marking a 429 `yielded` is only half the story. finishInitialBootstrap | ||
| // treats a yielded first cycle with nothing in progress as a COMPLETED | ||
| // bootstrap, because the only other thing that yields — a per-cycle deadline — | ||
| // cannot reach that state without a persisted checkpoint. A 429 can: it may | ||
| // arrive before the very first saveState. Falling through would exit 0 and hand | ||
| // Cloud an empty mirror it believes is fully synced, which is worse than the | ||
| // hard failure this PR set out to fix, because it fails silently. | ||
| func TestColdBackpressureIsNotReportedAsACompletedBootstrap(t *testing.T) { | ||
| busy := &cycleOutcomeError{ | ||
| cause: &mountsync.HTTPError{ | ||
| StatusCode: http.StatusTooManyRequests, | ||
| Code: "workspace_busy", | ||
| Message: "workspace durable object is busy; retry after the advertised delay", | ||
| }, | ||
| yielded: true, | ||
| backpressure: true, | ||
| } | ||
|
|
||
| if !cycleYielded(busy) { | ||
| t.Fatalf("a 429 must still yield, so it is not a terminal cycle failure") | ||
| } | ||
| if !cycleBackpressure(busy) { | ||
| t.Fatalf("a 429 yield must be distinguishable from a deadline yield") | ||
| } | ||
|
|
||
| // A deadline yield must NOT be mistaken for backpressure: it reaches | ||
| // finishInitialBootstrap only with a checkpoint on disk, where completing is | ||
| // the correct outcome. | ||
| deadline := &cycleOutcomeError{cause: context.DeadlineExceeded, yielded: true} | ||
| if cycleBackpressure(deadline) { | ||
| t.Fatalf("a deadline yield must not be treated as server backpressure") | ||
| } | ||
| if !cycleYielded(deadline) { | ||
| t.Fatalf("a deadline yield must still count as yielded") | ||
| } | ||
| } | ||
|
|
||
| // The resumable outcome is what makes the cold case safe: it exits | ||
| // initialBootstrapIncompleteExitCode so the caller reruns us, rather than | ||
| // exit 1 (fatal, the original bug) or exit 0 (silently empty, the regression). | ||
| func TestResumableIncompleteExitsRetryableUnderOnce(t *testing.T) { | ||
| resumable := newResumableInitialBootstrapIncompleteError( | ||
| bootstrapResumeState{}, | ||
| "initial cycle yielded to server backpressure before any bootstrap progress", | ||
| errors.New("http 429 workspace_busy"), | ||
| ) | ||
|
|
||
| if got := mountProcessExitCode(mountConfig{once: true}, resumable); got != initialBootstrapIncompleteExitCode { | ||
| t.Fatalf("cold backpressure must exit retryable, got %d", got) | ||
| } | ||
| terminal := newInitialBootstrapIncompleteError( | ||
| bootstrapResumeState{}, | ||
| "initial cycle failed", | ||
| errors.New("http 500"), | ||
| ) | ||
| if got := mountProcessExitCode(mountConfig{once: true}, terminal); got != 1 { | ||
| t.Fatalf("a real failure must stay fatal, got %d", got) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Cold backpressure falsely completes bootstrap
When a cold bootstrap gets 429 before persisting state,
isBackpressureErrormarks it yielded and finishInitialBootstrap returns success. Cloud then accepts an empty mirror as bootstrapped.Learn more
A cold bootstrap can receive HTTP 429 before
saveStatepublishes abootstrapblock. The new branch records a yielded cycle and returns nil. The caller then invokes finishInitialBootstrap, wherereadBootstrapResumeStatereportsinProgress == false. That branch suppresses yielded errors while the root context remains live, then returns nil. No subsequent cycle runs, despite the authoritative private bootstrap state remaining incomplete.Example: A new workspace receives
429 workspace_busyon its first tree request and writes no state checkpoint. The mount exits 0 with zero files instead of retrying or returning resumable exit 75.Recommended fix: Before accepting a yielded cycle with no public checkpoint, consult
syncer.InitialBootstrapComplete(). If it remains incomplete, keep retrying within the bootstrap budget or return a resumableinitialBootstrapIncompleteError; never treat the missing public block as completion.Was this helpful? React with 👍 or 👎 to provide feedback.