Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(cli): request path-app workspace-traffic URL with trailing slash - #29245

Open
cstyan wants to merge 2 commits into
mainfrom
callum/traffic-scaletest-improvements
Open

fix(cli): request path-app workspace-traffic URL with trailing slash#29245
cstyan wants to merge 2 commits into
mainfrom
callum/traffic-scaletest-improvements

Conversation

@cstyan

@cstyan cstyan commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What

Fixes the coder exp scaletest workspace-traffic --app=<slug> runs failing the
WebSocket handshake against workspace apps. Two commits:

  1. refactor(cli) — extract the workspace-traffic command and its
    createWorkspaceAppConfig helper out of the 2400-line cli/exp_scaletest.go
    into cli/exp_scaletest_wstraffic.go (pure move, no behavior change).
  2. fix(cli) — build the path-based app URL with a trailing slash.

Why

The path-app URL was constructed without a trailing slash
(.../apps/<slug>). coderd and workspace proxies normalize path apps by
307-redirecting /apps/<slug>/apps/<slug>/
(coderd/workspaceapps/proxy.go, the path == "" branch). The scaletest
client rejects redirects (rejectRedirect in cli/root.go, absent
--allow-redirects), so the WebSocket dial fails during the handshake with:

websocket dial: failed to WebSocket dial: failed to send handshake request:
Get ".../apps/wsec/": server redirected request from .../apps/wsec to .../apps/wsec/

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:

Region URL form HTTP GET WebSocket dial
primary no-slash 307 → /apps/wsec/ err (redirected)
primary slash 400 ok
europe no-slash 307 → /apps/wsec/ err (redirected)
europe slash 400 ok
asia no-slash 307 → /apps/wsec/ err (redirected)
asia slash 400 ok

Conclusions:

  • The redirect (and resulting handshake failure) affects all regions,
    including primary — it was never proxy-specific; the earlier observation was
    confounded.
  • The trailing slash fixes the dial everywhere (ws=ok).
  • http=400 on the slash GET is expected: a plain GET is not a WebSocket
    upgrade, so the wsec echo app rejects it. The adjacent ws=ok is the real
    success 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

  • New cli/exp_scaletest_wstraffic_internal_test.go covers
    createWorkspaceAppConfig: path apps get the trailing slash, subdomain apps
    are unchanged, and the not-found / missing-app-host error paths.
  • go build ./cli/..., go vet ./cli/, and the new test pass.
Investigation notes
  • The server redirected request from … to … text comes from the CLI's own
    rejectRedirect CheckRedirect hook (cli/root.go), not the websocket
    library — so any redirect during the app dial is a hard failure unless
    --allow-redirects is set.
  • coderd and workspace proxies mount the path-app route with identical code
    (workspaceapps.Server.Attach), and chi routes both slash and no-slash forms
    to the handler; the 307 originates from the application-level path == ""
    normalization in proxyWorkspaceApp.
  • Subdomain apps are unaffected: their root request already uses /, so no
    normalization redirect fires. The fix is scoped to the path-app branch.
  • A trailing slash was preferred over enabling redirect-following because it
    removes the redirect round-trip entirely and keeps the dial behavior explicit.

Opened by Coder Agents on behalf of @cstyan.

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.
@cstyan

cstyan commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress (15/15 reviewers complete) | View chat
Requested: 2026-09-11 17:53 UTC by @cstyan

deep-review v0.9.0 | Round 1 | c6299c0..ab1af4f

Last posted: Round 1, 7 findings (3 P3, 2 Nit, 2 Note), COMMENT. Review

Finding inventory

Finding inventory - PR #29245

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open cli/exp_scaletest_wstraffic.go:296 Trailing slash only defeats the path-normalization 307; query-default path apps still redirect and fail the dial R1 Mafuuu P3, Hisoka Note Yes
CRF-2 P3 Open cli/exp_scaletest_wstraffic.go:296 client.URL.String() concatenation double-slashes when base URL has a trailing slash, re-breaking the app dial R1 Meruem P3, Ryosuke Note, Mafuuu Note, Kite Note Yes
CRF-3 P3 Open cli/exp_scaletest_wstraffic.go:99 --output parse error discarded; operator loses the actual diagnosis. Class of 4 copies (1 moved + 3 siblings) R1 Leorio P3, Chopper P3 Yes
CRF-4 Nit Open cli/exp_scaletest_wstraffic.go:1 Filename abbreviation wstraffic breaks the spelled-out sibling naming pattern R1 Gon Yes
CRF-5 Nit Open cli/exp_scaletest_wstraffic_internal_test.go:42 Error-path subtests assert bare require.Error, not which error fired R1 Kite Nit, Chopper Nit, Bisky Note Yes
CRF-6 Nit Open commit subject Refactor commit subject is 82 chars, over the 72 limit R1 Leorio Body
CRF-7 Nit Open PR description PR body uses em dashes (4 occurrences), which project style forbids R1 Mafu-san Body
CRF-8 Note Open cli/exp_scaletest_wstraffic.go:296 Path-app URL format duplicated from open.go buildAppLinkURL and appurl.Path() R1 Kite Note, Pariston Note, Ryosuke Note Yes
CRF-9 Note Open cli/exp_scaletest_wstraffic_internal_test.go:52 Test pins the URL string but nothing exercises the redirect it exists to prevent R1 Bisky Note, Pariston Note, Chopper Note Yes

Round log

Round 1

Netero-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:

  • CRF-1 and CRF-2 converge on the same line as "the fix does not fully close the redirect/URL-malformation class." CRF-2's structural fix (client.URL.JoinPath) removes the double-slash hazard; CRF-1 is a server-side query-default redirect that the trailing slash cannot address.
  • CRF-3 is carried-in pre-existing code (verbatim move) with 3 unchanged sibling copies at cli/exp_scaletest.go:867,1218,1492. Kept at P3 (two independent P3 votes) but framed as author's call: fix the class or defer explicitly.
  • In-code comment at line 291 is accurate ("the already-normalized URL"); the "avoids the redirect entirely" overstatement is only in the PR body.
About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

proxyWorkspaceApp has two redirect branches for the app root. coderd/workspaceapps/proxy.go:625 (path == "") is the one the trailing slash defeats. But coderd/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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), ...) yields https://coder.example.com//@alice/... (double slash). The configured URL is stored unmodified: resolveClientURL does url.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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 returned err entirely and returns a flat xerrors.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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is workspace-traffic and the package is workspacetraffic; wstraffic is 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-5] Error-path subtests assert only require.Error, not which error fired. (Kite Nit, Chopper Nit, Bisky Note)

createWorkspaceAppConfig returns two distinct messages ("app %q not found in workspace %q" and "app %q is a subdomain app but no app host is configured"). The NotFound subtest asserts only require.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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant