fix(cli): request path-app workspace-traffic URL with trailing slash - #29245
fix(cli): request path-app workspace-traffic URL with trailing slash#29245cstyan wants to merge 2 commits into
Conversation
The scaletest workspace-traffic app runs failed the WebSocket handshake because the constructed path-app URL lacked a trailing slash. coderd and workspace proxies 307-redirect /apps/<slug> to /apps/<slug>/, and the scaletest client rejects redirects, so the dial failed on all regions. A probe (exp scaletest workspace-traffic-probe) confirmed no-slash fails and slash succeeds across primary/europe/asia.
|
/coder-agents-review |
|
Chat: Review in progress (15/15 reviewers complete) | View chat deep-review v0.9.0 | Round 1 | Last posted: Round 1, 7 findings (3 P3, 2 Nit, 2 Note), COMMENT. Review Finding inventoryFinding inventory - PR #29245Findings
Round logRound 1Netero-only first pass: no findings (mechanical/structural floor clean). Panel of 14 (bisky, hisoka, mafu-san, mafuuu, pariston, gon, leorio, ging-go, ryosuke, kite, chopper, komugi, kurapika + meruem wildcard). Reviewed c6299c0..ab1af4f. Verified pure-move claim (byte-identical except the trailing slash) via multiple reviewers and orchestrator spot-check. Root cause and fix confirmed correct at the right layer. Findings: 3 P3 (residual query-redirect gap, double-slash base URL, swallowed --output error), 2 Nit inline (filename, error-path tests), 2 Nit in body (commit subject length, PR-body em dashes), 2 Note (URL duplication, string-only regression guard). No P0/P1. Event: COMMENT. Cross-check notes:
About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Clean, well-scoped fix. The refactor is a genuine pure move (byte-for-byte identical apart from the one-character change), verified independently by several reviewers and spot-checked here. The root cause is correctly diagnosed and fixed at the right layer: coderd 307-redirects /apps/<slug> to /apps/<slug>/, the scaletest client rejects redirects, so requesting the already-normalized URL removes the round-trip. The fix matches an existing commented sibling (cli/open.go buildAppLinkURL), the in-code comment explains what breaks and where, and the new test pins the exact URL so the slash cannot silently regress. As Hisoka put it: "I came looking for a fight. I found a tidy little fix and one thread worth pulling."
Severity count: 0 P0/P1, 3 P3, 2 Nit, 2 Note (plus 2 process nits below).
The three P3s all say the same thing from different angles: the trailing slash closes the common failure but not the whole class. It leaves a server-side redirect branch open for query-bearing path apps (CRF-1), it can reintroduce the same broken dial via a double slash when the deployment URL has a trailing slash (CRF-2), and the moved --output error-swallow drops the diagnosis operators need (CRF-3). CRF-2 has a clean structural fix: client.URL.JoinPath(...) collapses the leading double slash and preserves the intended trailing slash in one move.
Process notes, not code: the refactor commit subject is 82 characters, over the project's 72-char limit (CRF-6); and the PR description uses em dashes in four places (lines 20, 23, 61, 83), which the project style rules forbid in documentation (CRF-7).
CRF-3 is carried-in pre-existing code with three unchanged sibling copies (cli/exp_scaletest.go:867,1218,1492). Fixing only the moved copy would be inconsistent; fixing the whole class or explicitly deferring it is the author's call, but the diagnostic loss is real.
🤖 This review was automatically generated with Coder Agents.
| // (see coderd/workspaceapps/proxy.go). The scaletest client rejects | ||
| // redirects, so the WebSocket handshake fails on the redirect unless we | ||
| // request the already-normalized URL. | ||
| c.URL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s/", client.URL.String(), workspace.OwnerName, workspace.Name, agent.Name, agent.Apps[i].Slug) |
There was a problem hiding this comment.
P3 [CRF-1] The trailing slash only defeats coderd's path == "" normalization redirect; a path app whose configured URL carries default query params still 307-redirects and fails the dial. (Mafuuu P3, Hisoka Note)
proxyWorkspaceApphas two redirect branches for the app root.coderd/workspaceapps/proxy.go:625(path == "") is the one the trailing slash defeats. Butcoderd/workspaceapps/proxy.go:638(path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "") still fires a 307 for the trailing-slash request when the target app's configured URL has a query string.
Verified: both branches exist. rejectRedirect (cli/root.go, absent --allow-redirects) rejects that 307, so --app pointed at a query-bearing path app re-breaks the exact way this PR set out to fix. The scaletest's wsec echo app has no default query, which is why every probe row went ws=ok, so the common path works. The in-code comment at line 291 is accurate; the PR body's "avoids the redirect entirely" overstates it. Narrow the claim to the path-normalization redirect and note the query-default case, or follow redirects for the app dial.
🤖
| // (see coderd/workspaceapps/proxy.go). The scaletest client rejects | ||
| // redirects, so the WebSocket handshake fails on the redirect unless we | ||
| // request the already-normalized URL. | ||
| c.URL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s/", client.URL.String(), workspace.OwnerName, workspace.Name, agent.Name, agent.Apps[i].Slug) |
There was a problem hiding this comment.
P3 [CRF-2] Building the URL with fmt.Sprintf("%s/@...", client.URL.String(), ...) reintroduces the same broken-dial class when the configured deployment URL has a trailing slash. (Meruem P3, Ryosuke Note, Mafuuu Note, Kite Note)
when the base is
https://coder.example.com/,fmt.Sprintf("%s/@%s/...", client.URL.String(), ...)yieldshttps://coder.example.com//@alice/...(double slash). The configured URL is stored unmodified:resolveClientURLdoesurl.Parse(strings.TrimSpace(rawURL))with no trailing-slash trim.
Verified: cli/root.go:654 does not trim the trailing slash. An operator who logged in with a trailing-slash --url gets a malformed app URL that coderd normalizes or 404s, re-breaking the dial this line was changed to fix. Structural fix (Meruem): client.URL.JoinPath("@"+workspace.OwnerName, workspace.Name+"."+agent.Name, "apps", agent.Apps[i].Slug+"/") collapses the leading double slash and preserves the trailing slash in one move. Pre-existing in the moved code, but it is the same class the PR fixes and rides on the modified line.
🤖
|
|
||
| outputs, err := output.parse() | ||
| if err != nil { | ||
| return xerrors.Errorf("could not parse --output flags") |
There was a problem hiding this comment.
P3 [CRF-3] The --output parse error is discarded and replaced with a context-free message. (Leorio P3, Chopper P3)
output.parse()returns specific, actionable errors:invalid output format %q in output flag %d,multiple output flags specified for stdout,invalid output flag %d: %q. The handler discards that returnederrentirely and returns a flatxerrors.Errorf("could not parse --output flags")with no%w.
An operator who typos --output josn:/tmp/x sees only "could not parse --output flags" and has to read source to learn which flag was bad. Fix: return xerrors.Errorf("parse --output flags: %w", err). This is carried-in pre-existing code (verbatim move), and the same swallow appears in three unchanged siblings at cli/exp_scaletest.go:867,1218,1492. Fix the whole class or defer it explicitly; do not silently ship one fixed copy and three broken ones.
🤖
| @@ -0,0 +1,300 @@ | |||
| //go:build !slim | |||
There was a problem hiding this comment.
Nit [CRF-4] The wstraffic abbreviation breaks the sibling naming pattern. (Gon)
Every other extracted scaletest subcommand file names its domain in full:
exp_scaletest_createusers.go,exp_scaletest_notifications.go,exp_scaletest_prebuilds.go,exp_scaletest_dynamicparameters.go. The command isworkspace-trafficand the package isworkspacetraffic;wstrafficis the only abbreviation in the set.
Rename to cli/exp_scaletest_workspacetraffic.go (and the test file to match).
🤖
| t.Run("NotFound", func(t *testing.T) { | ||
| t.Parallel() | ||
| agent := codersdk.WorkspaceAgent{Name: "main"} | ||
| _, err := createWorkspaceAppConfig(newClient(t, "https://coder.example.com"), "", "missing", ws, agent) |
There was a problem hiding this comment.
Nit [CRF-5] Error-path subtests assert only require.Error, not which error fired. (Kite Nit, Chopper Nit, Bisky Note)
createWorkspaceAppConfigreturns two distinct messages ("app %q not found in workspace %q" and "app %q is a subdomain app but no app host is configured"). TheNotFoundsubtest asserts onlyrequire.Error(t, err), so a future change that made the not-found branch fall through to the wrong error would still pass green.
Use require.ErrorContains(t, err, "not found") here and the matching string at the SubdomainAppRequiresAppHost case so each subtest fails for its own path.
🤖
| // (see coderd/workspaceapps/proxy.go). The scaletest client rejects | ||
| // redirects, so the WebSocket handshake fails on the redirect unless we | ||
| // request the already-normalized URL. | ||
| c.URL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s/", client.URL.String(), workspace.OwnerName, workspace.Name, agent.Name, agent.Apps[i].Slug) |
There was a problem hiding this comment.
Note [CRF-8] Path-app URL construction is now duplicated. (Kite Note, Pariston Note, Ryosuke Note)
The route shape and the trailing-slash normalization are encoded here, in cli/open.go buildAppLinkURL, and partially in coderd/workspaceapps/appurl.ApplicationURL.Path(). A future change to path-app routing must update multiple places or one silently regresses. Not worth unifying for a scaletest tool now, but recorded so the shared invariant is visible: the CLI copies a server-owned contract it cannot see change.
🤖
| Name: "main", | ||
| Apps: []codersdk.WorkspaceApp{{Slug: "wsec", Subdomain: false}}, | ||
| } | ||
| cfg, err := createWorkspaceAppConfig(newClient(t, "https://coder.example.com"), "*.apps.example.com", "wsec", ws, agent) |
There was a problem hiding this comment.
Note [CRF-9] The test pins the URL string, not the redirect behavior it exists to protect. (Bisky Note, Pariston Note, Chopper Note)
The string assertion is the right guard for a URL builder and fails loudly if the slash is dropped. But the causal link (trailing slash -> no redirect -> handshake succeeds) was only ever verified by the now-removed probe. If coderd's normalization branch changes, this suite stays green while the dial breaks again. A full integration dial is heavy and likely not justified; flagging the coverage boundary, not asking for the test.
🤖
What
Fixes the
coder exp scaletest workspace-traffic --app=<slug>runs failing theWebSocket handshake against workspace apps. Two commits:
workspace-trafficcommand and itscreateWorkspaceAppConfighelper out of the 2400-linecli/exp_scaletest.gointo
cli/exp_scaletest_wstraffic.go(pure move, no behavior change).Why
The path-app URL was constructed without a trailing slash
(
.../apps/<slug>). coderd and workspace proxies normalize path apps by307-redirecting/apps/<slug>→/apps/<slug>/(
coderd/workspaceapps/proxy.go, thepath == ""branch). The scaletestclient rejects redirects (
rejectRedirectincli/root.go, absent--allow-redirects), so the WebSocket dial fails during the handshake with:Requesting the already-normalized (trailing-slash) URL avoids the redirect
entirely.
Verification (probe experiment)
Initial reports suggested the failure was proxy-region-specific and
intermittent. To settle it, we deployed a temporary diagnostic that reused the
exact traffic-test app setup and, for each target, issued a redirect-observing
HTTP GET and the production WebSocket dial against both the direct (coderd) and
proxied URLs, with and without a trailing slash. Representative results:
307 → /apps/wsec/err(redirected)400ok307 → /apps/wsec/err(redirected)400ok307 → /apps/wsec/err(redirected)400okConclusions:
including primary — it was never proxy-specific; the earlier observation was
confounded.
ws=ok).http=400on the slash GET is expected: a plain GET is not a WebSocketupgrade, so the
wsececho app rejects it. The adjacentws=okis the realsuccess signal (auth passed, request reached the app).
The diagnostic command and its Terraform runner were removed after the
experiment; this PR keeps only the fix.
Testing
cli/exp_scaletest_wstraffic_internal_test.gocoverscreateWorkspaceAppConfig: path apps get the trailing slash, subdomain appsare unchanged, and the not-found / missing-app-host error paths.
go build ./cli/...,go vet ./cli/, and the new test pass.Investigation notes
server redirected request from … to …text comes from the CLI's ownrejectRedirectCheckRedirecthook (cli/root.go), not the websocketlibrary — so any redirect during the app dial is a hard failure unless
--allow-redirectsis set.(
workspaceapps.Server.Attach), and chi routes both slash and no-slash formsto the handler; the
307originates from the application-levelpath == ""normalization in
proxyWorkspaceApp./, so nonormalization redirect fires. The fix is scoped to the path-app branch.
removes the redirect round-trip entirely and keeps the dial behavior explicit.
Opened by Coder Agents on behalf of @cstyan.