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

Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix(cli): request path-app traffic URL with trailing slash
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.
  • Loading branch information
cstyan authored and Callum Styan committed Sep 11, 2026
commit ab1af4f2d2a912821be092f6349abe76ea64bd3d
7 changes: 6 additions & 1 deletion cli/exp_scaletest_wstraffic.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,12 @@ func createWorkspaceAppConfig(client *codersdk.Client, appHost, app string, work

c.URL = fmt.Sprintf("%s://%s", client.URL.Scheme, strings.Replace(appHost, "*", agent.Apps[i].SubdomainName, 1))
} else {
c.URL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s", client.URL.String(), workspace.OwnerName, workspace.Name, agent.Name, agent.Apps[i].Slug)
// Path-based apps are served at a trailing-slash URL: coderd (and
// workspace proxies) 307-redirect "/apps/<slug>" to "/apps/<slug>/"
// (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.

🤖

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.

🤖

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.

🤖

}

return c, nil
Expand Down
80 changes: 80 additions & 0 deletions cli/exp_scaletest_wstraffic_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//go:build !slim

package cli

import (
"net/url"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/codersdk"
)

func TestCreateWorkspaceAppConfig(t *testing.T) {
t.Parallel()

newClient := func(t *testing.T, rawURL string) *codersdk.Client {
t.Helper()
u, err := url.Parse(rawURL)
require.NoError(t, err)
return codersdk.New(u)
}

ws := codersdk.Workspace{
ID: uuid.New(),
Name: "myws",
OwnerName: "alice",
}

t.Run("Empty", func(t *testing.T) {
t.Parallel()
cfg, err := createWorkspaceAppConfig(newClient(t, "https://coder.example.com"), "", "", ws, codersdk.WorkspaceAgent{Name: "main"})
require.NoError(t, err)
require.Empty(t, cfg.Name)
require.Empty(t, cfg.URL)
})

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.

🤖

require.Error(t, err)
})

t.Run("PathAppHasTrailingSlash", func(t *testing.T) {
t.Parallel()
agent := codersdk.WorkspaceAgent{
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.

🤖

require.NoError(t, err)
require.Equal(t, "wsec", cfg.Name)
// The trailing slash avoids coderd's path-app normalization redirect,
// which the scaletest client rejects.
require.Equal(t, "https://coder.example.com/@alice/myws.main/apps/wsec/", cfg.URL)
})

t.Run("SubdomainApp", func(t *testing.T) {
t.Parallel()
agent := codersdk.WorkspaceAgent{
Name: "main",
Apps: []codersdk.WorkspaceApp{{Slug: "wsec", Subdomain: true, SubdomainName: "wsec--main--myws--alice"}},
}
cfg, err := createWorkspaceAppConfig(newClient(t, "https://coder.example.com"), "*.apps.example.com", "wsec", ws, agent)
require.NoError(t, err)
require.Equal(t, "https://wsec--main--myws--alice.apps.example.com", cfg.URL)
})

t.Run("SubdomainAppRequiresAppHost", func(t *testing.T) {
t.Parallel()
agent := codersdk.WorkspaceAgent{
Name: "main",
Apps: []codersdk.WorkspaceApp{{Slug: "wsec", Subdomain: true, SubdomainName: "wsec--main--myws--alice"}},
}
_, err := createWorkspaceAppConfig(newClient(t, "https://coder.example.com"), "", "wsec", ws, agent)
require.Error(t, err)
})
}
Loading