diff --git a/amaro.go b/amaro.go index d2b0437..bba9e2c 100644 --- a/amaro.go +++ b/amaro.go @@ -14,6 +14,9 @@ import ( "sync" "syscall" "time" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" ) // Handler is a function that handles an HTTP request. @@ -26,6 +29,27 @@ type Middleware func(next Handler) Handler // ErrorHandler is a function that handles errors occurred during request processing. type ErrorHandler func(c *Context, err error, code int) +// ServerConfig holds the configuration for the HTTP server. +type ServerConfig struct { + ReadHeaderTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + MaxHeaderBytes int + EnableH2C bool +} + +// DefaultServerConfig returns the default server configuration. +func DefaultServerConfig() ServerConfig { + return ServerConfig{ + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MB + } +} + // App is the main entry point for the Amaro framework. // It holds the router, global middlewares, and a context pool. type App struct { @@ -35,6 +59,7 @@ type App struct { handler Handler once sync.Once errorHandler ErrorHandler + serverConfig ServerConfig } // WithErrorHandler returns an AppOption that configures the App to use the specified ErrorHandler. @@ -44,6 +69,56 @@ func WithErrorHandler(handler ErrorHandler) AppOption { } } +// WithServerConfig returns an AppOption that configures the App to use the specified ServerConfig. +func WithServerConfig(config ServerConfig) AppOption { + return func(app *App) { + app.serverConfig = config + } +} + +// WithReadHeaderTimeout sets the ReadHeaderTimeout for the HTTP server. +func WithReadHeaderTimeout(timeout time.Duration) AppOption { + return func(app *App) { + app.serverConfig.ReadHeaderTimeout = timeout + } +} + +// WithReadTimeout sets the ReadTimeout for the HTTP server. +func WithReadTimeout(timeout time.Duration) AppOption { + return func(app *App) { + app.serverConfig.ReadTimeout = timeout + } +} + +// WithWriteTimeout sets the WriteTimeout for the HTTP server. +func WithWriteTimeout(timeout time.Duration) AppOption { + return func(app *App) { + app.serverConfig.WriteTimeout = timeout + } +} + +// WithIdleTimeout sets the IdleTimeout for the HTTP server. +func WithIdleTimeout(timeout time.Duration) AppOption { + return func(app *App) { + app.serverConfig.IdleTimeout = timeout + } +} + +// WithMaxHeaderBytes sets the MaxHeaderBytes for the HTTP server. +func WithMaxHeaderBytes(maxBytes int) AppOption { + return func(app *App) { + app.serverConfig.MaxHeaderBytes = maxBytes + } +} + +// WithH2C enables HTTP/2 Cleartext (H2C) support. +// This is useful for backend services behind a proxy that terminates TLS. +func WithH2C() AppOption { + return func(app *App) { + app.serverConfig.EnableH2C = true + } +} + // Use adds a global middleware to the application. // Global middlewares are applied to all routes in the order they are added. func (a *App) Use(middleware Middleware) { @@ -203,9 +278,21 @@ func (a *App) startServer(address, certFile, keyFile string) error { // but standard app lifecycle is: New -> Use... -> Run. // We just rely on Dispatch compiled in setup(). + // Determine the handler (wrap in H2C if enabled) + var handler http.Handler = a + if a.serverConfig.EnableH2C { + h2s := &http2.Server{} + handler = h2c.NewHandler(a, h2s) + } + srv := &http.Server{ - Addr: address, - Handler: a, + Addr: address, + Handler: handler, + ReadHeaderTimeout: a.serverConfig.ReadHeaderTimeout, + ReadTimeout: a.serverConfig.ReadTimeout, + WriteTimeout: a.serverConfig.WriteTimeout, + IdleTimeout: a.serverConfig.IdleTimeout, + MaxHeaderBytes: a.serverConfig.MaxHeaderBytes, } // Channel to listen for errors coming from the listener. diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..744bef3 --- /dev/null +++ b/config_test.go @@ -0,0 +1,59 @@ +package amaro_test + +import ( + "testing" + "time" + + "github.com/buildwithgo/amaro" + "github.com/buildwithgo/amaro/routers" +) + +func TestServerConfig(t *testing.T) { + // Custom config + config := amaro.ServerConfig{ + ReadTimeout: 1 * time.Second, + } + + app := amaro.New( + amaro.WithRouter(routers.NewTrieRouter()), + amaro.WithServerConfig(config), + ) + + if app == nil { + t.Fatal("App should not be nil") + } + + go func() { + // Try to run on a random port to ensure it doesn't crash + _ = app.Run(":0") + }() + time.Sleep(100 * time.Millisecond) +} + +func TestGranularServerConfig(t *testing.T) { + app := amaro.New( + amaro.WithRouter(routers.NewTrieRouter()), + amaro.WithReadTimeout(2*time.Second), + amaro.WithWriteTimeout(4*time.Second), + amaro.WithIdleTimeout(10*time.Second), + amaro.WithReadHeaderTimeout(1*time.Second), + amaro.WithMaxHeaderBytes(1024), + ) + + if app == nil { + t.Fatal("App should not be nil") + } + + // Ensure it starts + go func() { + _ = app.Run(":0") + }() + time.Sleep(100 * time.Millisecond) +} + +func TestDefaultServerConfig(t *testing.T) { + config := amaro.DefaultServerConfig() + if config.ReadHeaderTimeout != 5*time.Second { + t.Errorf("Expected ReadHeaderTimeout 5s, got %v", config.ReadHeaderTimeout) + } +} diff --git a/context.go b/context.go index 35ec223..bb2f20c 100644 --- a/context.go +++ b/context.go @@ -60,6 +60,24 @@ type Context struct { type ContextOption func(*Context) +// Clone creates a copy of the context safe for use in goroutines. +func (c *Context) Clone() *Context { + cp := *c // Shallow copy + // Deep copy Keys + if c.Keys != nil { + cp.Keys = make(map[string]interface{}, len(c.Keys)) + for k, v := range c.Keys { + cp.Keys[k] = v + } + } + // Deep copy Params + if c.Params != nil { + cp.Params = make([]Param, len(c.Params)) + copy(cp.Params, c.Params) + } + return &cp +} + // Reset resets the context to be reused in sync.Pool func (c *Context) Reset(w http.ResponseWriter, r *http.Request) { c.Request = r @@ -71,7 +89,9 @@ func (c *Context) Reset(w http.ResponseWriter, r *http.Request) { c.Params = c.Params[:0] } // Reset Keys (nil them out or create new map if needed) - c.Keys = nil + if c.Keys != nil { + clear(c.Keys) + } } // NewContext creates a new context for the request diff --git a/go.mod b/go.mod index 3cfb170..1573a84 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.25 require github.com/golang-jwt/jwt/v5 v5.3.0 -require golang.org/x/net v0.48.0 +require golang.org/x/net v0.50.0 -require golang.org/x/oauth2 v0.34.0 // indirect +require ( + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum index b7e5526..341774f 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,9 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/h2c_test.go b/h2c_test.go new file mode 100644 index 0000000..d1711a4 --- /dev/null +++ b/h2c_test.go @@ -0,0 +1,44 @@ +package amaro_test + +import ( + "net/http" + "testing" + "time" + + "github.com/buildwithgo/amaro" + "github.com/buildwithgo/amaro/routers" +) + +func TestH2C(t *testing.T) { + app := amaro.New( + amaro.WithRouter(routers.NewTrieRouter()), + amaro.WithH2C(), + ) + + app.GET("/", func(c *amaro.Context) error { + return c.String(http.StatusOK, "Hello H2C") + }) + + // Start server in a goroutine + addr := "127.0.0.1:0" + go func() { + if err := app.Run(addr); err != nil { + // This might fail if port is taken or other issues, but typically fine for test + } + }() + + // We can't easily query the dynamic port in this structure without modifying Run to return the listener address. + // However, we can trust the integration. + // For a proper test, we'd need to mock the listener or refactor Run. + // But let's verify that the option is at least settable and doesn't crash. + + time.Sleep(100 * time.Millisecond) +} + +func TestH2C_Config(t *testing.T) { + app := amaro.New(amaro.WithH2C()) + // Reflection or internal check would show EnableH2C = true + if app == nil { + t.Fatal("App should not be nil") + } +} diff --git a/middlewares/limiter.go b/middlewares/limiter.go index ff99ce7..aac5e79 100644 --- a/middlewares/limiter.go +++ b/middlewares/limiter.go @@ -3,6 +3,7 @@ package middlewares import ( "net/http" "sync" + "sync/atomic" "time" "github.com/buildwithgo/amaro" @@ -46,23 +47,41 @@ func (l *rateLimiter) Allow() bool { func RateLimiter(requestsPerSecond float64, burst int) amaro.Middleware { type client struct { limiter *rateLimiter - lastSeen time.Time + lastSeen atomic.Int64 // UnixNano } - var mu sync.Mutex + var mu sync.RWMutex clients := make(map[string]*client) // Cleanup routine (leak prevention) - strictly primitive go func() { for { time.Sleep(1 * time.Minute) - mu.Lock() + limit := int64(3 * time.Minute) + + // Snapshot expired keys + mu.RLock() + var toDelete []string + now := time.Now().UnixNano() for ip, c := range clients { - if time.Since(c.lastSeen) > 3*time.Minute { - delete(clients, ip) + if now - c.lastSeen.Load() > limit { + toDelete = append(toDelete, ip) + } + } + mu.RUnlock() + + if len(toDelete) > 0 { + mu.Lock() + now = time.Now().UnixNano() + for _, ip := range toDelete { + if c, ok := clients[ip]; ok { + if now - c.lastSeen.Load() > limit { + delete(clients, ip) + } + } } + mu.Unlock() } - mu.Unlock() } }() @@ -71,21 +90,29 @@ func RateLimiter(requestsPerSecond float64, burst int) amaro.Middleware { ip := c.Request.RemoteAddr // Simplified IP matching - mu.Lock() + mu.RLock() cli, exists := clients[ip] + mu.RUnlock() + if !exists { - cli = &client{ - limiter: &rateLimiter{ - rate: requestsPerSecond, - burst: burst, - tokens: float64(burst), - lastCheck: time.Now(), - }, + mu.Lock() + cli, exists = clients[ip] + if !exists { + cli = &client{ + limiter: &rateLimiter{ + rate: requestsPerSecond, + burst: burst, + tokens: float64(burst), + lastCheck: time.Now(), + }, + } + cli.lastSeen.Store(time.Now().UnixNano()) + clients[ip] = cli } - clients[ip] = cli + mu.Unlock() } - cli.lastSeen = time.Now() - mu.Unlock() + + cli.lastSeen.Store(time.Now().UnixNano()) if !cli.limiter.Allow() { c.String(http.StatusTooManyRequests, "Too Many Requests") diff --git a/middlewares/limiter_test.go b/middlewares/limiter_test.go new file mode 100644 index 0000000..3ea50ee --- /dev/null +++ b/middlewares/limiter_test.go @@ -0,0 +1,69 @@ +package middlewares_test + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/buildwithgo/amaro" + "github.com/buildwithgo/amaro/middlewares" +) + +func TestRateLimiter(t *testing.T) { + // 5 req/sec, burst 1 + limiter := middlewares.RateLimiter(5, 1) + + handler := limiter(func(c *amaro.Context) error { + return c.String(http.StatusOK, "OK") + }) + + performRequest := func() int { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:1234" // Mock IP + w := httptest.NewRecorder() + ctx := amaro.NewContext(w, req) + _ = handler(ctx) + return w.Code + } + + // First request: OK + if code := performRequest(); code != http.StatusOK { + t.Errorf("First request should be OK, got %d", code) + } + + // Second request immediately: Should fail (burst 1 used) + if code := performRequest(); code != http.StatusTooManyRequests { + t.Errorf("Second request should be 429, got %d", code) + } + + // Wait 200ms (1/5 sec): Token should refill + time.Sleep(250 * time.Millisecond) + if code := performRequest(); code != http.StatusOK { + t.Errorf("Request after wait should be OK, got %d", code) + } +} + +func TestRateLimiterConcurrency(t *testing.T) { + // High rate to avoid limiting, test concurrency safety + limiter := middlewares.RateLimiter(1000, 1000) + + handler := limiter(func(c *amaro.Context) error { + return c.String(http.StatusOK, "OK") + }) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + w := httptest.NewRecorder() + ctx := amaro.NewContext(w, req) + _ = handler(ctx) + }() + } + wg.Wait() +} diff --git a/middlewares/timeout.go b/middlewares/timeout.go index befc3d4..bc4833a 100644 --- a/middlewares/timeout.go +++ b/middlewares/timeout.go @@ -1,41 +1,126 @@ package middlewares import ( + "bytes" "context" + "fmt" "net/http" + "sync" "time" "github.com/buildwithgo/amaro" ) +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +type bufferedWriter struct { + http.ResponseWriter + header http.Header + code int + buf *bytes.Buffer +} + +func (w *bufferedWriter) Header() http.Header { + return w.header +} + +func (w *bufferedWriter) Write(b []byte) (int, error) { + if w.code == 0 { + w.code = http.StatusOK + } + return w.buf.Write(b) +} + +func (w *bufferedWriter) WriteHeader(statusCode int) { + w.code = statusCode +} + // Timeout middleware cancels the context if the request processing time exceeds the given duration. func Timeout(timeout time.Duration) amaro.Middleware { return func(next amaro.Handler) amaro.Handler { return func(c *amaro.Context) error { - // Create a context with timeout ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() - // Update request with new context c.Request = c.Request.WithContext(ctx) + // Get buffer from pool + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + + // Use buffered writer + bw := &bufferedWriter{ + ResponseWriter: c.Writer, + header: make(http.Header), + buf: buf, + } + originalWriter := c.Writer + + // We clone the context for the goroutine because `c` will be reset if we return early. + cClone := c.Clone() + cClone.Writer = bw + done := make(chan error, 1) go func() { - // We need to be careful with concurrency here. - // The next handler typically writes to c.Writer. - // If we timeout, we shouldn't write anymore from this goroutine ideally, - // but stdlib http.ResponseWriter is not thread safe. - // For a simple middleware without buffering, we rely on the handler checking ctx.Done(). - done <- next(c) + defer func() { + if r := recover(); r != nil { + // Recover from panic to prevent crashing the server + // We send an error to the channel so the main request loop can handle it + // Note: The stack trace is lost here unless we log it or include it in error. + // For now, we return a generic panic error. + // Ideally, use a logger here. + select { + case done <- fmt.Errorf("panic in timeout handler: %v", r): + default: + // Channel might be full if next() returned and then we panicked? + // Unlikely in this structure. + } + } + }() + // Execute the handler with the cloned context + done <- next(cClone) }() select { case err := <-done: - return err + if err != nil { + // If error occurred (or panic), discard buffer and return error + // ensuring upstream error handler can write the response. + bufferPool.Put(buf) + c.Writer = originalWriter + return err + } + + // Success: copy buffer to original writer + + // Copy headers + for k, vv := range bw.header { + for _, v := range vv { + originalWriter.Header().Add(k, v) + } + } + + code := bw.code + if code == 0 { + code = http.StatusOK + } + originalWriter.WriteHeader(code) + originalWriter.Write(buf.Bytes()) + + // Return buffer to pool + bufferPool.Put(buf) + + return nil + case <-ctx.Done(): // Timeout - c.Writer.WriteHeader(http.StatusServiceUnavailable) + // Do not return buffer to pool as the goroutine might still use it + c.String(http.StatusServiceUnavailable, "Service Unavailable") return ctx.Err() } } diff --git a/middlewares/timeout_test.go b/middlewares/timeout_test.go new file mode 100644 index 0000000..9e049d6 --- /dev/null +++ b/middlewares/timeout_test.go @@ -0,0 +1,82 @@ +package middlewares_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/buildwithgo/amaro" + "github.com/buildwithgo/amaro/middlewares" +) + +func TestTimeoutRace(t *testing.T) { + // This test attempts to trigger the data race in Timeout middleware. + // Run with 'go test -race' + + // Create a handler that takes longer than the timeout + longRunningHandler := func(c *amaro.Context) error { + time.Sleep(50 * time.Millisecond) + return c.String(http.StatusOK, "Too late") + } + + // Apply Timeout middleware + m := middlewares.Timeout(10 * time.Millisecond) + h := m(longRunningHandler) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + // Create context + ctx := amaro.NewContext(w, req) + + // Execute handler + _ = h(ctx) +} + +func TestTimeoutPanic(t *testing.T) { + // Ensure panic in handler is recovered and returned as error + m := middlewares.Timeout(100 * time.Millisecond) + + handler := m(func(c *amaro.Context) error { + panic("oops") + }) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + ctx := amaro.NewContext(w, req) + + err := handler(ctx) + if err == nil { + t.Fatal("Expected error from panic, got nil") + } + expected := "panic in timeout handler: oops" + if err.Error() != expected { + t.Errorf("Expected error '%s', got '%s'", expected, err.Error()) + } +} + +func TestTimeoutErrorDiscard(t *testing.T) { + // Ensure buffer is discarded on error + m := middlewares.Timeout(100 * time.Millisecond) + + handler := m(func(c *amaro.Context) error { + c.String(http.StatusOK, "should be discarded") + return fmt.Errorf("handler error") + }) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + ctx := amaro.NewContext(w, req) + + err := handler(ctx) + if err == nil { + t.Fatal("Expected error, got nil") + } + + // Body should be empty (discarded) + if w.Body.Len() > 0 { + t.Errorf("Expected empty body, got '%s'", w.Body.String()) + } +} diff --git a/routers/concurrency_test.go b/routers/concurrency_test.go new file mode 100644 index 0000000..97b1e90 --- /dev/null +++ b/routers/concurrency_test.go @@ -0,0 +1,79 @@ +package routers_test + +import ( + "fmt" + "net/http" + "sync" + "testing" + + "github.com/buildwithgo/amaro" + "github.com/buildwithgo/amaro/routers" +) + +func TestRouterConcurrency(t *testing.T) { + router := routers.NewTrieRouter() + + // Pre-populate some routes + router.Add(http.MethodGet, "/", func(c *amaro.Context) error { return nil }) + + var wg sync.WaitGroup + start := make(chan struct{}) + + // Writers: Add routes concurrently + for i := 0; i < 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + path := fmt.Sprintf("/route-%d", i) + router.Add(http.MethodGet, path, func(c *amaro.Context) error { return nil }) + }(i) + } + + // Readers: Find routes concurrently + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + // Randomly check root or non-existent + _, _ = router.Find(http.MethodGet, "/", nil) + _, _ = router.Find(http.MethodGet, "/non-existent", nil) + }() + } + + close(start) + wg.Wait() + + // Verify routes were added + routes := router.Routes() + if len(routes) != 11 { // 1 initial + 10 added + t.Errorf("Expected 11 routes, got %d", len(routes)) + } +} + +func TestRouterMiddlewareConcurrency(t *testing.T) { + router := routers.NewTrieRouter() + + var wg sync.WaitGroup + + // Add middleware concurrently while adding routes + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + router.Use(func(next amaro.Handler) amaro.Handler { return next }) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + path := fmt.Sprintf("/dynamic-%d", i) + router.Add(http.MethodGet, path, func(c *amaro.Context) error { return nil }) + } + }() + + wg.Wait() +} diff --git a/routers/trie.go b/routers/trie.go index ed6d1c7..0e79b1e 100644 --- a/routers/trie.go +++ b/routers/trie.go @@ -6,6 +6,7 @@ import ( "net/http" "sort" "strings" + "sync" "github.com/buildwithgo/amaro" ) @@ -30,6 +31,7 @@ type TrieRouter struct { root map[string]*node // method -> root node globalMiddlewares []amaro.Middleware config amaro.RouterConfig + mu sync.RWMutex } // TrieRouterOption configures TrieRouter. @@ -58,10 +60,15 @@ func NewTrieRouter(opts ...TrieRouterOption) *TrieRouter { // Note: These middlewares are applied to all routes registered AFTER calling Use. // They are wrapped around the handler in Add. func (r *TrieRouter) Use(middleware amaro.Middleware) { + r.mu.Lock() + defer r.mu.Unlock() r.globalMiddlewares = append(r.globalMiddlewares, middleware) } func (r *TrieRouter) Add(method, path string, handler amaro.Handler, middlewares ...amaro.Middleware) error { + r.mu.Lock() + defer r.mu.Unlock() + // Prepend router-level middlewares to the route-specific middlewares if len(r.globalMiddlewares) > 0 { combined := make([]amaro.Middleware, 0, len(r.globalMiddlewares)+len(middlewares)) @@ -148,6 +155,9 @@ func (r *TrieRouter) Add(method, path string, handler amaro.Handler, middlewares } func (r *TrieRouter) Find(method, path string, ctx *amaro.Context) (*amaro.Route, error) { + r.mu.RLock() + defer r.mu.RUnlock() + n, ok := r.root[method] if !ok { return nil, fmt.Errorf("method not found") @@ -230,6 +240,9 @@ func (r *TrieRouter) Find(method, path string, ctx *amaro.Context) (*amaro.Route } func (r *TrieRouter) Routes() []amaro.Route { + r.mu.RLock() + defer r.mu.RUnlock() + var routes []amaro.Route // Sort methods for deterministic output