Skip to content

fix(router): share route syntax across routing operations - #3133

Merged
vishr merged 9 commits into
masterfrom
fix/router-shared-syntax
Sep 30, 2026
Merged

vishr merged 9 commits into
masterfrom
fix/router-shared-syntax

Conversation

@vishr

@vishr vishr commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-up to #3113. Three route-syntax bugs remained after it: a literal \: after a parameter was swallowed into the parameter name, Remove could not find escaped-colon routes, and reverse routing dropped the literal verb.

  • One route-syntax scanner for registration, removal and reverse routing. An escaped colon after a parameter starts an inline verb (/:name\:cancel) when the rest of that path segment is static. Other escaped colons after a parameter keep their older meaning as part of the parameter name.
  • Inline verb matching. At a param node with an inline verb child, the value first ends at a literal colon where the verb can match. If that route fails (path or method), the same param node is retried with the next split and finally the whole segment before routing backtracks to its parent. Static > param > any priority, group middleware and catch-all routes are unaffected by a failed split. A failed wildcard below a split keeps backtracking, so other routes below the split and then the next split are still tried.
  • Linear time. Split candidates are scanned once per segment, so a request with many colons costs O(path length × longest registered verb). A 1 MB path of colons routes in about 15 ms.
  • Leaf params keep working. When the inline verb child is a param node's only child, a value without a split still takes the rest of the path, so GET /files/:path next to POST /files/:name\:upload still matches /files/a/b.
  • Remove finds the route by tree path and checks that the path is the one registered for that method.

Compatibility notes (release notes)

  • /x/:id\:y now registers parameter id and a literal :y. Before, it registered one parameter named id\:y that matched any segment, so RouteInfo.Parameters, c.Param lookups and matching change for apps that relied on that.
  • A matching inline verb split is preferred over the whole segment, also when the verb is followed by a wildcard.
  • Remove returns an error for a path that only shares a tree node with the route registered for that method (for example /u/:uid when GET was registered as /u/:id), instead of removing that route.

Performance

Router benchmarks against master, 8 interleaved runs on Apple M3 Max: geomean +0.8%, 0 allocs/op everywhere. The param scan now uses strings.IndexByte, which pays for the extra checks.

Verification

  • go test ./... -count=1, go test -race ., go vet ./..., staticcheck ./...
  • Regression tests cover verb registration orders, colons inside values, longer suffixes, following params and wildcards, method and generic-route fallback, group middleware and catch-all routes, nested splits, RouteNotFound, leaf params, Remove and re-add, Reverse, and a 64k-colon request with a time bound.
  • Reviewed in four high-effort rounds with differential fuzzing against master (random route sets, add/remove sequences) until no issues remained.

Refs #3111. The v4 backport is in #3132.

@vishr vishr left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review at 20c64cb. The three issues left after #3113 are fixed: /:name\:verb registers parameter name plus a literal :verb, Remove finds escaped-colon routes (and no longer deletes an unrelated entry when the lookup misses), and Reverse keeps the verb (/a/:x\:v/:y with "1","2" gives /a/1:v/2). CI is green.

The new colon split in Find introduces regressions that need fixing before merge. Each was reproduced against this head and master.

Must fix

  1. The colon split cannot be backtracked (router.go#L1026). Once the split is chosen, a failed match never retries with the whole segment as the parameter value.

    • GET /r/:id/info + GET /r/:name\:ab:p/z: GET /r/q:abc/info returns 404 here, 200 with id=q:abc on master.
    • Same with /r/:name\:y/:p/z and GET /r/q:y/info.
  2. canMatchStaticSuffix ignores the request method (router.go#L811). It accepts any isHandler node, so a verb route for another method hides a generic parameter route that allows this method.

    • GET /r/:id + POST /r/:name\:cancel: GET /r/foo:cancel returns 405 here, 200 on master.
    • Echo otherwise falls back from static to parameter on a method mismatch (POST /r/foo + GET /r/:id, then GET /r/foo is 200).
  3. An empty parameter value is accepted (router.go#L1027). The split loop starts at 0, so GET /r/:name\:cancel + GET /r/:cancel returns 200 with name="". Starting at 1 keeps the usual non-empty parameter rule.

Findings 1 and 2 share a cause: canMatchStaticSuffix (router.go#L805) is a second, simplified matcher that guesses whether the rest of the path will match. Suggest dropping it and recording the split as a backtrack point in the main loop: try the split, and on failure retry with the value running to the next /.

Should fix

  1. Find cost on parameter nodes (router.go#L1026). Every parameter node with children now does a linear findStaticChild(':') scan. The benchmark job shows RouterParamsAndAnyAPI 1.984µs to 2.119µs (+6.8%, p=0.000), paid by apps that never register an inline verb. A colonChild *node (or a flag) kept in sync by insert and Remove would make it O(1).
  2. Two tree-path builders (router.go#L549). Add builds the tree path, markers and names inline, while Remove uses routeTreePath. Use the helper in both, so the two cannot drift apart again.
  3. Reverse allocates per call (route.go#L85). It now runs parseRoutePath on every call. Parse once at Add time, or keep a single pass over the path.

Notes

  1. Behavior change for release notes (route_path.go#L32). /x/:id\:y used to register one parameter named id\:y; it now registers id plus a literal :y. c.Param and RouteInfo.Parameters change for anyone relying on the old form.
  2. Sibling /:name stops matching slashes once a verb route is registered next to it, because the parameter node is no longer a leaf. This matches what adding /r/:name/edit already does, so worth documenting rather than changing.
  3. Pre-existing, not from this PR: Remove still compares against the node's single originalPath. GET /u/:id + POST /u/:uid: Remove("GET", "/u/:id") fails, while Remove("GET", "/u/:uid") removes the GET route. Could be a follow-up.

Regression tests for 1–3 would keep these fixed.

@vishr vishr left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-review at 9b86d77. All seven findings from the previous review are fixed: split backtracking, method-aware fallback, empty values, the reported static-priority case, the Find slowdown (RouterParamsAndAnyAPI now within noise of master, 0 allocs), one routeTreePath for Add/Remove, and Reverse allocations. Remove then re-add keeps hasInlineVerb correct, and go test -race . passes.

The new retry design brings its own problems. Each item below was reproduced against this head.

Must fix

  1. CPU denial of service (router.go#L1041). Every colon in the segment adds a full re-search pass, and each pass rescans the segment, so cost grows with the square of the colon count. Chained inline params multiply it. Only one inline-verb route needs to be registered.
    • GET /r/:name\:cancel, request /r/a + 40,000 :: 1.3 s, then 404. With 100,000 colons (about 100 KB, under the default 1 MB header limit): 8.2 s.
    • GET /:a\::b\::c/end, request /x + 1000 × :x + /nope (about 2 KB): 1.95 s.
  2. Group middleware or a catch-all route disables the fallback (router.go#L1109). The unsplit retry only runs after a whole pass fails. If backtracking in that pass reaches an any-route or RouteNotFound node, that counts as the match and the generic route is never tried.
    • Group /r with middleware, routes /:name and /:name\:cancel: GET /r/foo:other returns 404.
    • Routes /r/:name, /r/:name\:cancel and /*: GET /r/foo:other goes to /*.
    • Most real apps have one of these, so the basic verb-plus-generic case breaks for them.
  3. A trailing leaf param stops at / after any split (router.go#L1026). splitUsed covers the whole pass.
    • GET /r/:name\:x/:rest: GET /r/a:x/b/c returns 404. The plain /r/:name/x/:rest gives rest=b/c.
  4. Priority across branches (router.go#L1113, #L1041). Split choices are enumerated globally, and the leftmost colon is always tried first.
    • /r/:id/info, /r/:name\:zz/q, /:p/:name\:ab/info: GET /r/q:ab/info goes to the root param route, although static r/ outranks :p.
    • /r/:name\::verb + /r/:name\:cancel: GET /r/x:y:cancel gives name=x, verb=y:cancel instead of the static cancel route.
    • /r/:name\:x* + /r/:id/info: GET /r/a:x/info goes to the any-verb route, while the analogous :param case resolves to the static route.

Root cause and suggestion

Findings 2–4 come from restarting the whole search with a global split plan (splitPlan/splitOptions/splitUsed) instead of treating the split as a backtrack point on the param node. The global plan loses the static > param > any order of ancestor nodes and the per-node leaf rule. Suggestion:

  • When backtracking out of a param node with hasColonChild, re-enter the same param with the next split position (store the split index next to its path value) before going up to the parent.
  • Only count a split where the colon child's prefix actually matches (strings.HasPrefix(search[split:], child.prefix)). That bounds the work and also fixes finding 1.

Alternative worth considering: limit inline verbs to the last segment of a route (/:name\:verb, nothing after). Google-style custom methods are always terminal, so #3111 is covered, and matching becomes a single check of the final segment against the colon child with no retries.

Should fix

  1. Allow header on 405 (router.go#L1109). Only the first failing pass's node is kept. POST /r/:name\:cancel + PUT /r/:id: GET /r/foo:cancel returns 405 with Allow: OPTIONS, POST, but PUT works.
  2. Two copies of Route (router_plain.go#L14). The ~270-line matcher is duplicated in router_plain.go and routeInline, differing only in the split block and retry loop. A single Route with the split block gated on currentNode.hasColonChild should keep the no-verb path the same cost.
  3. Allocations and redundant state (router.go#L481, #L1110). Remove re-parses every route to recompute hasInlineVerb (O(N²) over N removals). hasColonChild duplicates bytes.IndexByte(n.scLabels, ':') >= 0. The retry path allocates 2 per request (fallbackValues, splitPlan).

Replace the whole-search retry plan with a backtrack point on the param
node: when a candidate split fails, the same param node is retried with
the next literal-colon split and finally the whole path segment before
routing backtracks to its parent. Static > param > any priority of
ancestors, the leaf rule of later params, group middleware and catch-all
routes are no longer affected by a failed split.

An escaped colon after a parameter starts an inline verb only when the
rest of that path segment is static (`/:name\:cancel`, optionally
followed by `/...`). Split candidates are then scanned once per segment,
so a request with many colons is routed in linear time. Other escaped
colons after a parameter keep their older meaning as part of the
parameter name.

The duplicated fast-path Route and the router-level inline verb flag are
removed. The param scan uses strings.IndexByte, which keeps router
benchmarks within about 1% of master (geomean).
A wildcard ends the route search, but a param value above it that ended
at an inline verb split is now retried with the next split and the whole
segment, so a verb route with a wildcard cannot shadow a generic route
for other methods.

The retry now runs only when routing backtracks from the inline verb
child into its param node, instead of on every dead end.

An escaped colon after a parameter keeps its older meaning (part of the
parameter name) unless the first one in the segment starts an inline
verb, so a later `\:` cannot turn such a legacy route into a verb route.
Reverse writes placeholders for such names without the backslash, as
before. Dead hasColonChild bookkeeping for split nodes is removed.
When a wildcard fails below a param value that ended at an inline verb
split, keep backtracking one node at a time instead of jumping to that
param node, so the other routes below the split are tried before the
next split. Without a pending split a failed wildcard still ends the
search as before, and the check does not change the routing state.

Adds tests for nested splits, routes below a split and a RouteNotFound
wildcard below a split.
Remove found a node by the last route that set its original path and
then removed whichever route owned the method there. Routes with
different param names share a node, so Remove now checks that the path
is the one registered for that method.

An escaped colon after a param name that contains ':' or '*' keeps its
older meaning as part of the name. The escaped colon check is shared in
the route syntax scanner. Adds tests for RouteNotFound on the whole
segment, encoded colons, Reverse placeholders and Remove.
Registering an inline verb route under a param gave the param node a
static child, so a sibling route ending in that param (`/files/:path`)
stopped matching values across slashes. When the inline verb child is
the node's only child, a param value without a split now takes the rest
of the path, as a leaf param does.
Drop the param and any child checks that cannot fail for a param node, and pin 405, RouteNotFound and wildcard fallbacks for a param value that spans slashes next to an inline verb route.
@vishr
vishr merged commit af07b69 into master Sep 30, 2026
11 checks passed
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