agent endpoints: add a path router that matches in declaration order

Manifest matching runs a compiled regex per route over a linear scan, once per
candidate registration, on the serving path. At the 256-route cap that is up to
256 RE2 executions per replica per request.

The router compiles a manifest into a compressed trie instead. Starlette
selects the first route declared that matches, so the trie cannot resolve
static before wildcard the way net/http's does: every node carries the lowest
route index in its subtree, the walk tracks the lowest full match found and
prunes any subtree that cannot improve on it, and inserting in declaration
order leaves edges and leaves sorted by that index with no sort pass.

Params are not segment-aligned - starlette allows /f/{name}.{ext}, a {p:path}
anywhere, and a float whose fraction backtracks - so the walk must search. An
edge is single when nothing below it can start with a byte its own convertor
could have consumed, which is a property of the target node and so is settled
at build time; a single edge takes its greedy run and descends once. Templates
whose params are segment-aligned are entirely single and never search. What
remains is bounded by a step budget, and exhausting it returns ResultOverBudget
rather than a route the cut-short search cannot vouch for.

Templates parse without regexp. Scanning the grammar by hand keeps a brace that
opens nothing well-formed as an ordinary literal, which is what starlette's
finditer does. Anchoring is `\n?$` rather than `$`: python's '$' matches before
one trailing newline where Go's does not, and [^/] accepts a newline where .
refuses one, so the two convertors diverge in opposite directions on a decoded
%0A.

The regex implementation moves into the tests as an oracle, carrying its own
parser so it references the hand-rolled scanner as well as the trie.
FuzzMatchAgainstOracle generates a table and a request and asserts the two
agree on both the winning template and the result. Matching allocates nothing,
asserted by AllocsPerRun on hit, 405, miss and on the searching path.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Wells
2026-09-15 10:21:50 -07:00
co-authored by Claude Opus 5
parent aae12e30d5
commit 0da02c4717
11 changed files with 1370 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2026 LiveKit, Inc.
//go:build !race
package router
import (
"testing"
"github.com/stretchr/testify/require"
)
// The race runtime allocates, so this cannot run under -race.
func TestMatchZeroAlloc(t *testing.T) {
specs := benchSpecs(256)
r, _ := build(t, specs)
for _, c := range benchCases(specs) {
n := testing.AllocsPerRun(200, func() { r.Match(c.path, c.mask) })
require.Zero(t, n, c.name)
}
// an ambiguous table searches, and must not allocate while it does
amb, _ := build(t, get("/f/{name}.{ext}", "/{p:path}/end"))
n := testing.AllocsPerRun(200, func() { amb.Match("/f/a.b.c", mGET) })
require.Zero(t, n, "ambiguous")
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 LiveKit, Inc.
package router
import (
"fmt"
"testing"
)
// benchSpecs is shaped like a FastAPI app: a few static routes, then resource
// collections with an id param, then a catch-all.
func benchSpecs(n int) []spec {
specs := []spec{
{"/health", mGET},
{"/ready", mGET},
{"/metrics", mGET},
}
for i := len(specs); i < n-1; i += 2 {
res := fmt.Sprintf("/api/v1/res%d", i)
specs = append(specs, spec{res, mGET | mPOST})
specs = append(specs, spec{res + "/{id:int}", mGET | mPUT})
}
specs = append(specs, spec{"/static/{p:path}", mGET})
return specs[:min(n, len(specs))]
}
type benchCase struct {
name, path string
mask Mask
}
func benchCases(specs []spec) []benchCase {
last := specs[len(specs)-2].tpl
return []benchCase{
{"hit_first", "/health", mGET},
{"hit_last", last[:len(last)-len("/{id:int}")] + "/42", mGET},
{"method_not_allowed", "/health", mPOST},
{"miss", "/api/v1/nope/42", mGET},
}
}
func BenchmarkMatch(b *testing.B) {
for _, n := range []int{8, 64, 256} {
specs := benchSpecs(n)
r, _ := build(b, specs)
for _, c := range benchCases(specs) {
b.Run(fmt.Sprintf("routes=%d/%s", n, c.name), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
r.Match(c.path, c.mask)
}
})
}
}
}
// No shared prefixes, so the root's edge scan cannot be narrowed.
func BenchmarkMatchWideFanout(b *testing.B) {
specs := make([]spec, 0, 256)
for i := range 256 {
specs = append(specs, spec{fmt.Sprintf("/%c%c/x", 'a'+i/16, 'a'+i%16), mGET})
}
r, _ := build(b, specs)
b.ReportAllocs()
for b.Loop() {
r.Match("/pp/x", mGET)
}
}
func BenchmarkBuild(b *testing.B) {
specs := benchSpecs(256)
tpls := make([]*Template, len(specs))
for i, s := range specs {
t, err := ParseTemplate(s.tpl)
if err != nil {
b.Fatal(err)
}
tpls[i] = t
}
b.ReportAllocs()
for b.Loop() {
bld := NewBuilder[string]()
for i, t := range tpls {
_ = bld.Add(t, specs[i].mask, specs[i].tpl)
}
bld.Build()
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package router
import "errors"
const MaxRoutes = 1 << 16
var errTooManyRoutes = errors.New("router: too many routes")
type buildNode[T any] struct {
kids []buildEdge[T]
leaves []leaf[T]
minIdx uint32
}
type buildEdge[T any] struct {
lit string
to *buildNode[T]
minIdx uint32
kind kind
}
// Builder accumulates a route table. A route's index is its position in the
// order added.
type Builder[T any] struct {
root *buildNode[T]
n uint32
ambiguous []string
}
func NewBuilder[T any]() *Builder[T] {
return &Builder[T]{root: &buildNode[T]{minIdx: noIdx}}
}
// Add appends a route. Routes must be added in declaration order: the first
// one added that matches a path wins.
func (b *Builder[T]) Add(t *Template, m Mask, v T) error {
if b.n >= MaxRoutes {
return errTooManyRoutes
}
idx := b.n
b.n++
if ambiguousTemplate(t) {
b.ambiguous = append(b.ambiguous, t.raw)
}
n := b.root
if n.minIdx == noIdx {
n.minIdx = idx
}
for _, e := range t.elements {
if e.kind == kindLiteral {
n = insertLiteral(n, e.lit, idx)
} else {
n = insertParam(n, e.kind, idx)
}
}
n.leaves = append(n.leaves, leaf[T]{idx: idx, mask: m, val: v})
return nil
}
// Build freezes the table, compacting its nodes into one arena.
func (b *Builder[T]) Build() *Router[T] {
r := &Router[T]{ambiguous: b.ambiguous, routes: int(b.n)}
r.nodes = make([]node[T], 0, countNodes(b.root))
r.compact(b.root)
return r
}
func countNodes[T any](n *buildNode[T]) int {
total := 1
for i := range n.kids {
total += countNodes(n.kids[i].to)
}
return total
}
func (r *Router[T]) compact(bn *buildNode[T]) uint32 {
ni := uint32(len(r.nodes))
r.nodes = append(r.nodes, node[T]{leaves: bn.leaves, minIdx: bn.minIdx})
kids := make([]edge, len(bn.kids))
for i := range bn.kids {
be := &bn.kids[i]
kids[i] = edge{lit: be.lit, minIdx: be.minIdx, kind: be.kind, single: singleRun(be)}
if be.kind == kindLiteral {
kids[i].first = be.lit[0]
}
}
// recurse before wiring: appending children may move the arena
for i := range bn.kids {
kids[i].to = r.compact(bn.kids[i].to)
}
r.nodes[ni].kids = kids
return ni
}
// singleRun reports that only the convertor's greedy run can lead anywhere: a
// shorter run leaves at the head a byte the convertor could have consumed, and
// no child below can start with one. A terminal param qualifies vacuously.
func singleRun[T any](e *buildEdge[T]) bool {
if e.kind == kindLiteral || e.kind == kindUUID {
return true
}
for i := range e.to.kids {
k := &e.to.kids[i]
if k.kind != kindLiteral || e.kind.charset(k.lit[0]) {
return false
}
}
return true
}
// ambiguousTemplate reports whether a template's own shape can force a search,
// independent of what other templates put in the tree.
func ambiguousTemplate(t *Template) bool {
for i, e := range t.elements {
if e.kind == kindLiteral || e.kind == kindUUID {
continue
}
if i == len(t.elements)-1 {
continue // terminal: only the greedy run can reach the end
}
next := t.elements[i+1]
if next.kind != kindLiteral || e.kind.charset(next.lit[0]) {
return true
}
}
return false
}
func insertLiteral[T any](n *buildNode[T], lit string, idx uint32) *buildNode[T] {
for len(lit) > 0 {
e := literalEdge(n, lit[0])
if e == nil {
child := &buildNode[T]{minIdx: idx}
n.kids = append(n.kids, buildEdge[T]{kind: kindLiteral, lit: lit, to: child, minIdx: idx})
return child
}
cp := commonPrefix(e.lit, lit)
if cp < len(e.lit) {
// the edge keeps its slot and its minIdx, so kids stay ordered
mid := &buildNode[T]{minIdx: e.minIdx}
mid.kids = append(mid.kids, buildEdge[T]{kind: kindLiteral, lit: e.lit[cp:], to: e.to, minIdx: e.minIdx})
e.lit, e.to = e.lit[:cp], mid
}
n, lit = e.to, lit[cp:]
}
return n
}
func insertParam[T any](n *buildNode[T], k kind, idx uint32) *buildNode[T] {
for i := range n.kids {
if n.kids[i].kind == k {
return n.kids[i].to
}
}
child := &buildNode[T]{minIdx: idx}
n.kids = append(n.kids, buildEdge[T]{kind: k, to: child, minIdx: idx})
return child
}
// literalEdge finds the one literal edge that can consume c. Splitting keeps
// the first bytes of a node's literal edges distinct, so there is at most one.
func literalEdge[T any](n *buildNode[T], c byte) *buildEdge[T] {
for i := range n.kids {
if e := &n.kids[i]; e.kind == kindLiteral && e.lit[0] == c {
return e
}
}
return nil
}
func commonPrefix(a, b string) int {
n := min(len(a), len(b))
i := 0
for i < n && a[i] == b[i] {
i++
}
return i
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2026 LiveKit, Inc.
package router
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// fragments compose into templates. %d takes a per-template counter so
// generated param names never collide, which ParseTemplate rejects.
var fragments = []string{
"/a", "/b", "/ab", "/abc", "/",
"/{p%d}", "/{n%d:int}", "/{f%d:float}", "/{u%d:uuid}", "/{r%d:path}",
"{q%d}", ".json", ".{e%d}", "-{s%d}", "0", "/end", "//", "\n", "{", "x",
}
func genSpecs(script []byte) []spec {
var specs []spec
var b strings.Builder
n := 0
flush := func() {
if b.Len() > 0 {
specs = append(specs, spec{"/" + b.String(), Mask(n%7) + 1})
b.Reset()
}
}
for _, c := range script {
if int(c)%(len(fragments)+2) >= len(fragments) {
flush()
continue
}
fmt.Fprintf(&b, fragments[int(c)%len(fragments)], n)
n++
}
flush()
if len(specs) > 64 {
specs = specs[:64]
}
return specs
}
// The trie answers what a linear scan of compiled starlette patterns answers:
// the same winning template and the same result, for every table and path.
func FuzzMatchAgainstOracle(f *testing.F) {
f.Add([]byte{5, 200, 0, 5}, "/x/y", uint8(1))
f.Add([]byte{5, 1, 200, 1, 5}, "/a/b", uint8(1))
f.Add([]byte{9, 200, 15}, "/a/b/end", uint8(1))
f.Add([]byte{5, 12, 200, 5}, "/f/a.b.json", uint8(1))
f.Add([]byte{7, 11, 200, 7}, "/1.25.json", uint8(3))
f.Add([]byte{8, 200, 8, 15}, "/123e4567e89b12d3a456426614174000", uint8(1))
f.Add([]byte{0, 1, 2, 3, 200, 0, 200, 2}, "/ab", uint8(1))
f.Add([]byte{5, 200, 5, 200, 5}, "/x\n", uint8(7))
f.Add([]byte{13, 5, 200, 5}, "/a-b", uint8(1))
f.Fuzz(func(t *testing.T, script []byte, path string, q uint8) {
specs := genSpecs(script)
if len(specs) == 0 {
return
}
b := NewBuilder[string]()
o := &oracle{}
added := 0
for _, s := range specs {
tpl, err := ParseTemplate(s.tpl)
if err != nil {
continue
}
require.NoError(t, o.add(s.tpl, s.mask), "oracle rejected an accepted template %q", s.tpl)
require.NoError(t, b.Add(tpl, s.mask, s.tpl))
added++
}
if added == 0 {
return
}
r := b.Build()
mask := Mask(q)
got, res := r.Match(path, mask)
if res == ResultOverBudget {
return
}
wantTpl, wantRes := o.match(path, mask)
require.Equal(t, wantRes, res, "path %q mask %d over %v", path, mask, specs)
require.Equal(t, wantTpl, got, "path %q mask %d over %v", path, mask, specs)
})
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package router
import "strings"
// state lives in Match's frame and is threaded by pointer; nothing reachable
// from it may escape.
type state[T any] struct {
path string
q Mask
val T
// best is the lowest index of a route matching both path and mask. Only a
// full match may assign it, since it gates pruning.
best uint32
// acc is the second accepting position, len(path)-1 when the path ends in a
// newline and -1 otherwise: a template anchors as `\n?$`.
acc int
steps int
partial bool
}
// Match returns the value of the first route in declaration order whose
// template matches path and whose mask intersects q. It returns ResultPartial
// when a template matched but no such route carried the mask, and
// ResultOverBudget when the search exceeded its step budget.
//
// Match allocates nothing.
func (r *Router[T]) Match(path string, q Mask) (T, Result) {
st := state[T]{path: path, q: q, best: noIdx, acc: -1}
if n := len(path); n > 0 && path[n-1] == '\n' {
st.acc = n - 1
}
if !r.walk(0, 0, &st) {
// a cut-short search may have missed a lower-indexed route, so anything
// found is not necessarily what declaration order selects
var zero T
return zero, ResultOverBudget
}
switch {
case st.best != noIdx:
return st.val, ResultFull
case st.partial:
var zero T
return zero, ResultPartial
default:
var zero T
return zero, ResultNone
}
}
// walk explores the subtree at ni with pos bytes of the path consumed,
// reporting false when the step budget ran out. Declaration order is
// uncorrelated with depth, so a match is not a stopping condition; only the
// minIdx prune cuts the scan short.
func (r *Router[T]) walk(ni uint32, pos int, st *state[T]) bool {
n := &r.nodes[ni]
if pos == len(st.path) || pos == st.acc {
for i := range n.leaves {
lf := &n.leaves[i]
if lf.idx >= st.best {
break
}
if lf.mask&st.q != 0 {
st.best, st.val = lf.idx, lf.val
break
}
st.partial = true
}
// a path convertor matches the empty string, so a route may still
// terminate below this node at this same position
}
rest := st.path[pos:]
for i := range n.kids {
e := &n.kids[i]
if e.minIdx >= st.best {
break // kids ascend by minIdx, so no later one can improve on best
}
if e.kind == kindLiteral {
if len(e.lit) > len(rest) || rest[0] != e.first || !strings.HasPrefix(rest, e.lit) {
continue
}
if !r.walk(e.to, pos+len(e.lit), st) {
return false
}
continue
}
k := e.kind.scan(rest)
if k < 0 {
continue
}
if e.single {
if !r.walk(e.to, pos+k, st) {
return false
}
continue
}
for ; k >= 0; k = e.kind.next(rest, k) {
st.steps++
if st.steps > maxSteps {
return false
}
if !r.walk(e.to, pos+k, st) {
return false
}
if e.minIdx >= st.best {
break
}
}
}
return true
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 LiveKit, Inc.
package router
import (
"fmt"
"regexp"
"strings"
)
// One compiled regex per route, scanned linearly. Transcribed from starlette's
// compile_path and carrying its own parser, so it references the hand-rolled
// scanner as well as the trie.
var oracleConvertors = map[string]string{
"str": `[^/]+`,
"path": `.*`,
"int": `[0-9]+`,
"float": `[0-9]+(?:\.[0-9]+)?`,
"uuid": `[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}`,
}
// starlette's PARAM_REGEX
var oracleParamRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?\}`)
func oracleCompile(path string) (*regexp.Regexp, error) {
var pattern strings.Builder
pattern.WriteString("^")
idx := 0
for _, m := range oracleParamRegex.FindAllStringSubmatchIndex(path, -1) {
start, end := m[0], m[1]
convertor := "str"
if m[4] != -1 {
convertor = path[m[4]+1 : m[5]]
}
p, ok := oracleConvertors[convertor]
if !ok {
return nil, fmt.Errorf("unknown convertor %q", convertor)
}
pattern.WriteString(regexp.QuoteMeta(path[idx:start]))
pattern.WriteString("(?:")
pattern.WriteString(p)
pattern.WriteString(")")
idx = end
}
pattern.WriteString(regexp.QuoteMeta(path[idx:]))
// python's '$' is Go's `\n?\z`
pattern.WriteString("\n?$")
return regexp.Compile(pattern.String())
}
type oracleRoute struct {
re *regexp.Regexp
raw string
mask Mask
}
type oracle struct{ routes []oracleRoute }
func (o *oracle) add(raw string, mask Mask) error {
re, err := oracleCompile(raw)
if err != nil {
return err
}
o.routes = append(o.routes, oracleRoute{re: re, raw: raw, mask: mask})
return nil
}
func (o *oracle) match(path string, q Mask) (string, Result) {
partial := false
for _, r := range o.routes {
if !r.re.MatchString(path) {
continue
}
if r.mask&q != 0 {
return r.raw, ResultFull
}
partial = true
}
if partial {
return "", ResultPartial
}
return "", ResultNone
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package router matches request paths against an ordered table of
// starlette-style path templates. The first route declared that matches wins:
// declaration order is the entire priority rule.
package router
import (
"go.uber.org/zap/zapcore"
)
// maxSteps bounds the backtracking a single Match may do. Only a non-single
// edge spends a step, so a table of unambiguous templates never reaches it.
const maxSteps = 100_000
// Mask is an opaque per-route tag. A route fully matches when its mask
// intersects the query mask, and partially matches when only its template does.
type Mask uint32
type Result uint8
const (
ResultNone Result = iota
// ResultPartial means a template matched but no route carrying the queried
// mask did.
ResultPartial
ResultFull
// ResultOverBudget means the search exceeded maxSteps and was abandoned, so
// no route was decided.
ResultOverBudget
)
func (r Result) String() string {
switch r {
case ResultNone:
return "none"
case ResultPartial:
return "partial"
case ResultFull:
return "full"
case ResultOverBudget:
return "over-budget"
}
return "unknown"
}
// noIdx is above every route index, so an unset best never prunes.
const noIdx = ^uint32(0)
type edge struct {
lit string // kindLiteral only
to uint32 // index into Router.nodes
minIdx uint32 // mirrors nodes[to].minIdx, so a prune dereferences no child
kind kind
first byte // kindLiteral only: lit[0]
single bool // the convertor admits one viable run at any position
}
type leaf[T any] struct {
idx uint32
mask Mask
val T
}
type node[T any] struct {
kids []edge
// several routes can terminate at one node: /u/{id} and /u/{name} compile
// alike, and a path may be declared once per method
leaves []leaf[T]
minIdx uint32
}
// Router matches paths against a route table. It is built once and never
// mutated, so readers need no lock and a new table is published by a single
// atomic.Pointer store, which synchronises everything a reader reaches.
type Router[T any] struct {
nodes []node[T]
ambiguous []string
routes int
}
// MarshalLogObject describes the table's shape. A Router holds no per-match
// state.
func (r *Router[T]) MarshalLogObject(e zapcore.ObjectEncoder) error {
if r == nil {
return nil
}
e.AddInt("routes", r.routes)
if len(r.ambiguous) > 0 {
err := e.AddArray("ambiguous", zapcore.ArrayMarshalerFunc(func(a zapcore.ArrayEncoder) error {
for _, t := range r.ambiguous {
a.AppendString(t)
}
return nil
}))
if err != nil {
return err
}
}
return nil
}
// Ambiguous returns the templates whose shape forces the matcher to backtrack -
// a param that a following literal can extend, adjacent params, or a non-final
// path convertor. The result is read-only.
func (r *Router[T]) Ambiguous() []string { return r.ambiguous }
+254
View File
@@ -0,0 +1,254 @@
// Copyright 2026 LiveKit, Inc.
package router
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
)
const (
mGET Mask = 1 << iota
mPOST
mPUT
)
type spec struct {
tpl string
mask Mask
}
func get(tpls ...string) []spec {
s := make([]spec, len(tpls))
for i, t := range tpls {
s[i] = spec{t, mGET}
}
return s
}
func build(t testing.TB, specs []spec) (*Router[string], *oracle) {
t.Helper()
b := NewBuilder[string]()
o := &oracle{}
for _, s := range specs {
tpl, err := ParseTemplate(s.tpl)
require.NoError(t, err, s.tpl)
require.NoError(t, b.Add(tpl, s.mask, s.tpl))
require.NoError(t, o.add(s.tpl, s.mask), s.tpl)
}
return b.Build(), o
}
// Every case asserts the expected answer and that the oracle agrees.
func TestMatchAdversarial(t *testing.T) {
cases := []struct {
name string
routes []spec
path string
mask Mask
want string
res Result
}{
// declaration order decides
{"order beats specificity", get("/u/{id}", "/u/me"), "/u/me", mGET, "/u/{id}", ResultFull},
{"order the other way", get("/u/me", "/u/{id}"), "/u/me", mGET, "/u/me", ResultFull},
{"order with int first", get("/x/{a:int}", "/x/{b}"), "/x/42", mGET, "/x/{a:int}", ResultFull},
{"order falls through to str", get("/x/{a:int}", "/x/{b}"), "/x/4x", mGET, "/x/{b}", ResultFull},
{"literal buried deep", get("/a/{x}/c", "/a/b/c"), "/a/b/c", mGET, "/a/{x}/c", ResultFull},
// the method dimension must not let a partial prune a later full match
{"later method wins", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull},
{"wildcard partial then literal full", []spec{{"/{a}", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull},
{"partial at a lower index", []spec{{"/a/{x}", mGET}, {"/a/b", mPOST}}, "/a/b", mPOST, "/a/b", ResultFull},
{"no method matches", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPUT, "", ResultPartial},
{"nothing matches", get("/x"), "/nope", mGET, "", ResultNone},
// mid-segment params, which the walk must search for
{"name dot ext", get("/f/{name}.{ext}"), "/f/a.b.c", mGET, "/f/{name}.{ext}", ResultFull},
{"name dot ext needs a dot", get("/f/{name}.{ext}"), "/f/abc", mGET, "", ResultNone},
{"suffix literal", get("/f/{name}.json"), "/f/a.b.json", mGET, "/f/{name}.json", ResultFull},
{"prefix and suffix", get("/v{major}.{minor}"), "/v1.2", mGET, "/v{major}.{minor}", ResultFull},
{"adjacent params", get("/{a}{b}"), "/xy", mGET, "/{a}{b}", ResultFull},
// greedy path, which crosses '/'
{"path backtracks to a literal", get("/{p:path}/end"), "/a/b/end/end", mGET, "/{p:path}/end", ResultFull},
{"path matches empty", get("/files/{p:path}"), "/files/", mGET, "/files/{p:path}", ResultFull},
{"path spans slashes", get("/files/{p:path}"), "/files/a/b/c.txt", mGET, "/files/{p:path}", ResultFull},
{"path refuses newline", get("/files/{p:path}"), "/files/a\nb", mGET, "", ResultNone},
{"str accepts newline", get("/files/{p}"), "/files/a\nb", mGET, "/files/{p}", ResultFull},
// a template anchors as `\n?$`
{"trailing newline accepted", get("/x"), "/x\n", mGET, "/x", ResultFull},
{"two trailing newlines rejected", get("/x"), "/x\n\n", mGET, "", ResultNone},
{"path convertor eats to the newline", get("/files/{p:path}"), "/files/a\n", mGET, "/files/{p:path}", ResultFull},
// float's optional fraction backtracks
{"float with fraction", get("/p/{v:float}.json"), "/p/1.25.json", mGET, "/p/{v:float}.json", ResultFull},
{"float without fraction", get("/p/{v:float}.json"), "/p/1.json", mGET, "/p/{v:float}.json", ResultFull},
{"float rejects a bare dot", get("/p/{v:float}"), "/p/1.", mGET, "", ResultNone},
{"float takes the fraction", get("/p/{v:float}"), "/p/1.25", mGET, "/p/{v:float}", ResultFull},
// int followed by a digit needs a shorter run
{"int backtracks off a digit", get("/{n:int}0/x"), "/120/x", mGET, "/{n:int}0/x", ResultFull},
// uuid admits exactly one length
{"uuid hyphenated", get("/o/{u:uuid}"), "/o/123e4567-e89b-12d3-a456-426614174000", mGET, "/o/{u:uuid}", ResultFull},
{"uuid bare", get("/o/{u:uuid}"), "/o/123e4567e89b12d3a456426614174000", mGET, "/o/{u:uuid}", ResultFull},
{"uuid then literal", get("/o/{u:uuid}/z"), "/o/123e4567e89b12d3a456426614174000/z", mGET, "/o/{u:uuid}/z", ResultFull},
{"uuid too short", get("/o/{u:uuid}"), "/o/123e4567", mGET, "", ResultNone},
// radix splits
{"split abc", get("/abc", "/abd", "/ab", "/a"), "/ab", mGET, "/ab", ResultFull},
{"split abd", get("/abc", "/abd", "/ab", "/a"), "/abd", mGET, "/abd", ResultFull},
{"split a", get("/abc", "/abd", "/ab", "/a"), "/a", mGET, "/a", ResultFull},
{"split reversed", get("/a", "/ab", "/abd", "/abc"), "/abc", mGET, "/abc", ResultFull},
{"split miss", get("/abc", "/abd"), "/abe", mGET, "", ResultNone},
// literals are byte-exact
{"case sensitive", get("/token"), "/Token", mGET, "", ResultNone},
{"trailing slash is not implied", get("/token"), "/token/", mGET, "", ResultNone},
{"registered with a slash", get("/token/"), "/token/", mGET, "/token/", ResultFull},
// degenerate paths
{"root", get("/"), "/", mGET, "/", ResultFull},
{"double slash", get("/", "//"), "//", mGET, "//", ResultFull},
{"empty segment", get("/a//b"), "/a//b", mGET, "/a//b", ResultFull},
{"str will not cross a slash", get("/a/{x}"), "/a/b/c", mGET, "", ResultNone},
{"str needs a byte", get("/a/{x}"), "/a/", mGET, "", ResultNone},
{"a decoded %2F is just a slash", get("/files/{p}"), "/files/a/b", mGET, "", ResultNone},
// a brace that opens nothing well-formed is a literal
{"stray brace", get("/a{b"), "/a{b", mGET, "/a{b", ResultFull},
{"empty convertor is literal", get("/x/{a:}"), "/x/{a:}", mGET, "/x/{a:}", ResultFull},
{"digit-leading name is literal", get("/x/{1bad}"), "/x/{1bad}", mGET, "/x/{1bad}", ResultFull},
{"doubled brace", get("/{{a}"), "/{x", mGET, "/{{a}", ResultFull},
}
for _, c := range cases {
r, o := build(t, c.routes)
got, res := r.Match(c.path, c.mask)
require.Equal(t, c.res, res, "%s: %q", c.name, c.path)
require.Equal(t, c.want, got, "%s: %q", c.name, c.path)
oGot, oRes := o.match(c.path, c.mask)
require.Equal(t, c.res, oRes, "oracle disagrees on %s: %q", c.name, c.path)
require.Equal(t, c.want, oGot, "oracle disagrees on %s: %q", c.name, c.path)
}
}
// Whether a template can force a search is fixed at build time.
func TestAmbiguousClassification(t *testing.T) {
cases := []struct {
tpl string
ambiguous bool
}{
{"/static", false},
{"/u/{id}", false},
{"/u/{id}/posts", false},
{"/u/{id:int}/posts", false},
{"/files/{p:path}", false},
{"/o/{u:uuid}/z", false},
{"/o/{u:uuid}{rest}", false},
{"/f/{name}.json", true},
{"/f/{name}.{ext}", true},
{"/{p:path}/end", true},
{"/p/{v:float}.json", true},
{"/{n:int}0/x", true},
{"/{a}{b}", true},
{"/{n:int}/x", false},
{"/{v:float}/x", false},
}
for _, c := range cases {
r, _ := build(t, get(c.tpl))
require.Equal(t, c.ambiguous, len(r.Ambiguous()) == 1, "%s", c.tpl)
}
}
// An unambiguous table holds no edge that can search, so no path length reaches
// the budget; an ambiguous one gives up at it.
func TestStepBudget(t *testing.T) {
r, _ := build(t, get("/api/{v:int}/res/{id}/sub/{name}"))
for i := range r.nodes {
for _, e := range r.nodes[i].kids {
require.True(t, e.single, "%q", e.lit)
}
}
_, res := r.Match("/api/1/res/"+strings.Repeat("x", 4096)+"/sub/y", mGET)
require.Equal(t, ResultFull, res)
r, _ = build(t, get("/{a}{b}{c}{d}x"))
_, res = r.Match("/"+strings.Repeat("a", 4096), mGET)
require.Equal(t, ResultOverBudget, res)
}
func TestEmptyRouter(t *testing.T) {
r := NewBuilder[string]().Build()
got, res := r.Match("/x", mGET)
require.Equal(t, ResultNone, res)
require.Empty(t, got)
}
// Readers match against a published table while writers swap in new ones.
func TestConcurrentSwapUnderLoad(t *testing.T) {
tables := make([]*Router[string], 4)
for i := range tables {
tables[i], _ = build(t, get(
fmt.Sprintf("/v%d/{id:int}", i),
"/f/{name}.json",
"/static/{p:path}",
"/health",
))
}
var h atomic.Pointer[Router[string]]
h.Store(tables[0])
var stop atomic.Bool
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for i := 0; !stop.Load(); i++ {
h.Store(tables[i%len(tables)])
}
})
}
for range 8 {
wg.Go(func() {
for !stop.Load() {
r := h.Load()
r.Match("/v2/42", mGET)
r.Match("/f/a.b.json", mGET)
r.Match("/static/a/b/c", mGET)
r.Match("/health", mPOST)
}
})
}
time.Sleep(200 * time.Millisecond)
stop.Store(true)
wg.Wait()
}
func TestMarshalLogObject(t *testing.T) {
r, _ := build(t, get("/health", "/f/{name}.json"))
enc := zapcore.NewMapObjectEncoder()
require.NoError(t, r.MarshalLogObject(enc))
require.Equal(t, 2, enc.Fields["routes"])
require.Equal(t, []any{"/f/{name}.json"}, enc.Fields["ambiguous"])
plain, _ := build(t, get("/health"))
enc = zapcore.NewMapObjectEncoder()
require.NoError(t, plain.MarshalLogObject(enc))
require.NotContains(t, enc.Fields, "ambiguous")
var nilRouter *Router[string]
require.NoError(t, nilRouter.MarshalLogObject(zapcore.NewMapObjectEncoder()))
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package router
import "strings"
// Scanning is byte-wise: every byte a convertor distinguishes ('/', '\n', and
// [0-9a-fA-F-]) is ASCII, and UTF-8 is self-synchronising, so no multi-byte
// sequence can contain one.
// scan returns the longest run the convertor consumes at the head of s, or -1
// when it cannot match at all.
func (k kind) scan(s string) int {
switch k {
case kindStr:
if n := runTo(s, '/'); n > 0 {
return n
}
case kindPath:
return runTo(s, '\n')
case kindInt:
if n := digitRun(s); n > 0 {
return n
}
case kindFloat:
d := digitRun(s)
if d == 0 {
break
}
if d < len(s) && s[d] == '.' {
if f := digitRun(s[d+1:]); f > 0 {
return d + 1 + f
}
}
return d
case kindUUID:
return uuidRun(s)
}
return -1
}
// next returns the next shorter viable run after prev, or -1 when prev was the
// last candidate. Candidates descend, so runs are enumerated greedily.
func (k kind) next(s string, prev int) int {
switch k {
case kindStr, kindInt:
if prev > 1 {
return prev - 1
}
case kindPath:
if prev > 0 {
return prev - 1
}
case kindFloat:
// the integer part is a digit run from the head, so a '.' can only sit
// at its end: viable runs are 1..d and d+1+1..d+1+f, never d+1
d := digitRun(s)
switch {
case prev > d+2:
return prev - 1
case prev == d+2:
return d
case prev > 1:
return prev - 1
}
case kindUUID:
// every '-?' is forced by the input: skipping a present hyphen demands a
// hex digit where the hyphen is, so the shape admits one length
}
return -1
}
// charset reports whether a shorter run of this convertor could be followed by
// c, which is what makes an edge ambiguous.
func (k kind) charset(c byte) bool {
switch k {
case kindStr:
return c != '/'
case kindPath:
return c != '\n'
case kindInt:
return isDigit(c)
case kindFloat:
return isDigit(c) || c == '.'
}
return false
}
func runTo(s string, c byte) int {
if i := strings.IndexByte(s, c); i >= 0 {
return i
}
return len(s)
}
func digitRun(s string) int {
i := 0
for i < len(s) && isDigit(s[i]) {
i++
}
return i
}
func uuidRun(s string) int {
i := 0
for g, n := range [...]int{8, 4, 4, 4, 12} {
if g > 0 && i < len(s) && s[i] == '-' {
i++
}
for range n {
if i >= len(s) || !isHex(s[i]) {
return -1
}
i++
}
}
return i
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package router
import (
"fmt"
"strings"
"unicode/utf8"
)
// kind is what one element of a template consumes. The convertor set is part
// of the wire contract and is closed.
type kind uint8
const (
kindLiteral kind = iota
kindStr // [^/]+
kindPath // .*, which excludes '\n'
kindInt // [0-9]+
kindFloat // [0-9]+(?:\.[0-9]+)?
kindUUID // 8-4-4-4-12 hex, every hyphen optional
)
func convertorKind(name string) (kind, bool) {
switch name {
case "str":
return kindStr, true
case "path":
return kindPath, true
case "int":
return kindInt, true
case "float":
return kindFloat, true
case "uuid":
return kindUUID, true
}
return 0, false
}
// element is one step of a parsed template: literal bytes to match exactly, or
// a convertor to consume.
type element struct {
kind kind
lit string // kindLiteral only
}
// Template is a parsed starlette-style path template. Parsing mirrors
// starlette's compile_path exactly.
type Template struct {
// the template as declared
raw string
elements []element
}
// String returns the template as declared.
func (t *Template) String() string { return t.raw }
// ParseTemplate parses a starlette path template. Custom convertors are
// rejected: only the five built-ins may travel over the wire.
func ParseTemplate(path string) (*Template, error) {
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf("path template must start with '/': %q", path)
}
// literals are compared byte-wise, which agrees with rune-wise semantics
// only for valid UTF-8
if !utf8.ValidString(path) {
return nil, fmt.Errorf("path template is not valid UTF-8: %q", path)
}
t := &Template{raw: path}
seen := map[string]struct{}{}
lit := 0
for i := 0; i < len(path); {
name, convertor, end, ok := scanParam(path, i)
if !ok {
i++
continue
}
k, ok := convertorKind(convertor)
if !ok {
return nil, fmt.Errorf("unknown path convertor %q in template %q", convertor, path)
}
if _, dup := seen[name]; dup {
return nil, fmt.Errorf("duplicated param name %q in template %q", name, path)
}
seen[name] = struct{}{}
if i > lit {
t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:i]})
}
t.elements = append(t.elements, element{kind: k})
i, lit = end, end
}
if lit < len(path) {
t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:]})
}
return t, nil
}
// scanParam matches starlette's PARAM_REGEX at i:
//
// {([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}
//
// Everything between matches is a literal, so a brace that does not open a
// well-formed param is an ordinary literal character.
func scanParam(s string, i int) (name, convertor string, end int, ok bool) {
if s[i] != '{' {
return "", "", 0, false
}
j := i + 1
n := scanIdent(s, j)
if n == j {
return "", "", 0, false
}
name, j = s[j:n], n
convertor = "str"
if j < len(s) && s[j] == ':' {
c := scanIdent(s, j+1)
if c == j+1 {
return "", "", 0, false
}
convertor, j = s[j+1:c], c
}
if j >= len(s) || s[j] != '}' {
return "", "", 0, false
}
return name, convertor, j + 1, true
}
func scanIdent(s string, i int) int {
if i >= len(s) || !(isAlpha(s[i]) || s[i] == '_') {
return i
}
j := i + 1
for j < len(s) && (isAlpha(s[j]) || isDigit(s[j]) || s[j] == '_') {
j++
}
return j
}
func isAlpha(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isHex(c byte) bool {
return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
@@ -0,0 +1,94 @@
// Copyright 2026 LiveKit, Inc.
package router
import (
"testing"
"github.com/stretchr/testify/require"
)
func matches(t *testing.T, tpl, path string) bool {
t.Helper()
r, o := build(t, get(tpl))
got, res := r.Match(path, mGET)
oGot, oRes := o.match(path, mGET)
require.Equal(t, oRes, res, "oracle disagrees: %q vs %q", tpl, path)
require.Equal(t, oGot, got, "oracle disagrees: %q vs %q", tpl, path)
return res == ResultFull
}
func TestTemplateStarletteSemantics(t *testing.T) {
cases := []struct {
template string
path string
match bool
}{
{"/token", "/token", true},
{"/token", "/token/", false},
{"/token", "/Token", false},
{"/users/{id}", "/users/42", true},
{"/users/{id}", "/users/42/posts", false},
{"/users/{id}", "/users/", false},
{"/users/{id:int}", "/users/42", true},
{"/users/{id:int}", "/users/4x2", false},
{"/files/{p:path}", "/files/a/b/c.txt", true},
{"/files/{p:path}", "/files/", true},
{"/price/{v:float}", "/price/1.25", true},
{"/price/{v:float}", "/price/1.", false},
{"/obj/{u:uuid}", "/obj/123e4567-e89b-12d3-a456-426614174000", true},
// starlette's uuid convertor makes every hyphen optional
{"/obj/{u:uuid}", "/obj/123e4567e89b12d3a456426614174000", true},
{"/obj/{u:uuid}", "/obj/123e4567", false},
{"/a/{x}/b/{y}", "/a/1/b/2", true},
{"/a/{x}/b/{y}", "/a/1/c/2", false},
}
for _, c := range cases {
require.Equal(t, c.match, matches(t, c.template, c.path), "%s vs %s", c.template, c.path)
}
}
// Vectors generated from CPython against a transcription of compile_path.
func TestTemplateTrailingNewline(t *testing.T) {
cases := []struct {
template string
path string
match bool
}{
{"/x", "/x\n", true},
{"/x", "/x\n\n", false},
{"/x", "/x\nz", false},
{"/files/{p:path}", "/files/a\n", true},
{"/files/{p:path}", "/files/a\nb", false},
{"/files/{p}", "/files/a\n", true},
{"/files/{p}", "/files/a\nb", true},
{"/n/{i:int}", "/n/42\n", true},
{"/n/{i:int}", "/n/4\n2", false},
{"/", "/\n", true},
{"/a/", "/a/\n", true},
}
for _, c := range cases {
require.Equal(t, c.match, matches(t, c.template, c.path), "%q vs %q", c.template, c.path)
}
}
func TestParseTemplateRejects(t *testing.T) {
for _, tpl := range []string{
"/x/{id:slug}",
"/x/{a}/{a}",
"no-slash",
"/x/\xff",
} {
_, err := ParseTemplate(tpl)
require.Error(t, err, tpl)
}
}
// A brace that opens nothing well-formed is an ordinary literal.
func TestParseTemplateLenientBraces(t *testing.T) {
for _, tpl := range []string{"/a{b", "/x/{a:}", "/x/{1bad}", "/{{a}", "/}", "/{}"} {
tt, err := ParseTemplate(tpl)
require.NoError(t, err, tpl)
require.Equal(t, tpl, tt.String())
}
}