Repository navigation
Run every top-level path filter that matches the request - #828
Conversation
Path filters shared one radix tree and only the payload of its single best match ran, so a broader guard such as `before_all "/api/*"` was skipped wherever a more specific filter path like "/api/public" also matched, and a key the tree split (e.g. "/users/:id" next to "/users/:uid/posts") could match nothing at all. Each filter path now gets its own single-key radix tree, indexed by type/verb in registration order, and every tree whose lookup is found runs. What one filter path matches stays what radix gives it on its own. Global filters still run first and router filters last. A path filter that halts stops the filters of the paths after it, so they do not write to a closed response.
The before filters after a halting one kept running on a response already sent: a guard's 401 went out, but a later filter's `content_type=` raised IO::Error on the closed response, which skipped `after_all`, and its work ran for a request the guard had refused. With overlapping path filters now all running, the halting guard and the filter behind it are the common case, and the stop between path filters did not reach later blocks on the same path, path filters after a halting global filter, the next verb's step or router filters. The first before filter that closes the response now ends the before filters, as it already kept the route handler from running. After filters still all run, so a refused request reaches after_all logging.
Review follow-ups to running every matching path filter: - Stop feeding @tree. Nothing reads it to dispatch any more, and it still refused some combinations of filter paths at boot with SharedKeyError (`/users/me`, `/users/:id`, `/users/:uid/posts`) that the per-path trees take. `tree` stays as an empty getter; `tree=` resets every filter. - A block registered for several paths at once (`before_all ["/admin", "/admin/*"]`) is now one shared FilterBlock and runs once per step, however many of its paths match, instead of once per path. - Skip the radix key and lookups of the remaining steps once a before filter has closed the response. - Specs register the specific filter before the halting guard, so they show both run; cover the refused combination and the multi-path block. - CHANGELOG: filters run in the order their paths were first registered, refuse with `halt`, and an `after` filter writing to a halted response has to check `closed?`.
|
Hi @sdogruyol |
sdogruyol
left a comment
There was a problem hiding this comment.
Looks good to me. The breaking changes are the right call, and the CHANGELOG covers them. I checked locally: with before_all "/api/*" (halting) plus before_all "/api/public", GET /api/public is 401 here and 200 on master. CI is green.
One thing before ready: Radix::Tree#find allocates a Result and its params Hash on every call, so each path filter that doesn't match still costs two allocations per step. With 20 before_get "/otherN/:id" filters, GET /home went from 36.5 KB to 38.2 KB allocated per request (release build). Storing each path's literal prefix (up to the first :/*, trailing / chopped so trailing-slash tolerance still works) and skipping find unless radix_key.starts_with?(prefix) makes the non-matching ones a byte compare, without touching what a match means.
Nit: no other comment in the repo uses a ponytail: tag. A plain comment is enough, or drop it if the prefix check goes in.
|
@sdogruyol Thanks a lot! |
… its key Radix::Tree#find allocates a Result and its params Hash on every call, so each path filter that did not match still cost two allocations per step. Each per-path tree now carries the literal start of its key - everything before the first `:` or `*`, less a trailing `/` so the trailing-slash tolerance still matches - and a request path that does not start with it is turned away with a byte compare. With 20 `before_get "/otherN/:id"` filters and a `GET /home` route, a request allocates 35.3 KB instead of 37.5 KB (release build). A spec compares, for a table of filter and request paths, whether the handler runs a filter with whether radix finds its path on its own.
|
@sdogruyol |
sdogruyol
left a comment
There was a problem hiding this comment.
Thanks, the prefix check is right. I ran it against radix directly: 3,600 filter/request pairs (static, :param, *glob, mid-segment params, trailing slashes), and radix never matched a path the prefix turned away. With 20 non-matching filter paths, GET /home now allocates 35.7 KB per request, less than master's 36.5 KB. Suite (609), format and ameba pass locally.
LGTM.
Description of the Change
Kemal::FilterHandlerstored every top-level path filter in oneRadix::Treeand ran only the payload of the single best match for the request path. When two path filters both covered a request, only the more specific one ran, so a broader guard silently did not:The same happens with a param guard next to a static path (
before_get "/users/:id"was skipped forGET /users/meoncebefore_get "/users/me"existed), with two paths that differ by a trailing slash, and with a glob next to its own prefix. The shared tree could also lose filters altogether:before_get "/users/:id"andbefore_get "/users/:uid/posts"together ran for neither/users/5nor/users/5/posts, and addingbefore_get "/users/me"to those two failed at boot withRadix::Tree::SharedKeyError.Router-scoped filters were taken out of that tree in GHSA-89xc-29wg-6mm5 for the same reason; this does the same for the top-level ones.
Every matching path filter runs. Each filter path now gets its own single-key
Radix::Tree, kept in@path_filtersand grouped by"#{type}/#{verb}"(the key the global filters already use) in the order the paths were first registered.call_block_for_path_typeasks each tree of the current verb/type and runs every one that matches. Since every tree holds one key, what a single filter path matches is exactly what radix gives it on its own today (static exact,:paramone segment,*glob, trailing-slash tolerance); there is no new matcher. Each tree also carries the literal start of its key (everything before the first:or*, less a trailing/so the trailing-slash tolerance still matches), and a request path that does not start with it is turned away withstarts_with?beforeRadix::Tree#findallocates anything. With 20before_get "/otherN/:id"filters and aGET /homeroute, a request allocates 35.3 KB instead of 37.5 KB without the check (release build). A fuzz run over 4.2M filter/request path pairs found no path radix matches that the check turns away. The order within a step is unchanged otherwise: global filters first, then the matching path filters, then the route's router filters. Blocks on the same path keep their definition order.call_block_for_path_typeis the one place every caller goes through, so routed requests, unrouted requests (404/405) and theHEAD->GETfallback are all covered.A
beforefilter that closes the response ends thebeforefilters. Running every match made a latent problem common: the filters after ahaltkept running on a response that had already been sent. With a guard halting and a more specific filter behind it settingcontent_type=, the client got its401, butcontent_type=raisedIO::Erroron the closed response, which skippedafter_all, and the later filter's work ran for a request the guard had refused. The firstbeforefilter that closes the response (halt,redirect) now stops every laterbeforefilter: later blocks on the same path, path filters after a halting global filter, the next step (before_getafterbefore_all), and router filters, just asRouteHandleralready returns on a closed response.afterfilters all still run.A block registered for several paths at once runs once.
before_all ["/admin", "/admin/*"]used to wrap the block once per path; with both paths matching/adminit would now run twice. TheEnumerableoverload indsl.crnow registers oneFilterBlockflaggedshared?for all of its paths, and the path loop skips a shared block that already ran in the step. The list that tracks them is only allocated once a shared block matches.@treeis no longer filled. Nothing reads it to dispatch, and it was the only source of the boot-timeSharedKeyError.treestays as a getter (now empty) andtree=still resets every filter, whichspec/spec_helper.crrelies on.path_filters_empty?is replaced by the@path_filters[key]?lookup, which skips the radix key allocation and every lookup when the verb/type has no path filters. The unused privatefilter_for_path_type_defined?andlookup_filters_for_path_typeare removed.Specs in
spec/filters_spec.crcover each overlap case above,afterfilters overlapping,HEADserved by theGETroute, an unrouted request, run order, the split and refused keys, the multi-path block, and halting in every position, plus a table that checks the handler runs a filter exactly where radix alone finds its path. In the overlap specs the more specific filter is registered before the halting guard, so each one shows that both filters ran. All of them fail oncfdbeeeand pass here.crystal specpasses (607 examples), as docrystal tool format --checkandameba.Alternate Designs
Radix::Tree#findreturns one result and the shard has no API to walk all matches. Writing one, or a new matcher, risks changing what an individual filter path matches; a tree per path reuses radix as it is.filter_pattern. The two give different answers for the same path (top-levelbefore_all "/admin"matches/adminand/admin/,router.before "/admin"also matches/admin/x), so it would change what existing top-level filters match.before_all "/api/*"guard halting still let abefore_get "/api/public"filter run, since that is the next step. Not stopping at all keeps theIO::Errorabove on every refused request.errorhandler covers. Not done; that adds a second meaning to a status code. A guard refuses withhalt, and the CHANGELOG says so.@treesotreestill reflects the filters. Rejected, as it keeps the boot-timeSharedKeyErrorfor combinations the per-path trees accept. The class is:nodoc:, and no shard I checked (spec-kemal, kemal-session, kemal-cache, kemal-hmac, kemal-basic-auth) touchestree.Enumerableoverload shares a block across paths, so only those blocks are tracked.Benefits
HEADserved by aGETroute.beforefilters: noIO::Errorfrom later filters writing to the closed response, no skippedafter_all, and no work done for a refused request.before_all ["/admin", "/admin/*"]runs its block once.Possible Drawbacks
All of these are in the two (BREAKING) CHANGELOG entries, each of which starts with what to check before upgrading.
before_all "/api/*"with anybefore_all "/api/login"filter) starts answering401. Nothing fails at compile time; the fix is to skip such paths inside the guard (next if env.request.path == "/api/login").before_get "/admin"andbefore_get "/admin/"both run for either spelling. Only a block registered for several paths at once is de-duplicated, and only within one step (a path list that mixes"*"with a path still runs the block twice).content_typeset by/api/htmlis replaced by one set by/api/*).afterfilters run in the same order, not reversed.beforefilter that comes after a guard and has to see refused requests (audit logging of failed logins, metrics) no longer runs for them; it belongs in anafterfilter.afterfilters still run on a halted response, and more of them now run. One that sets a header or the content type has to checkenv.response.closed?first, or itsIO::Errorskips theafterfilters behind it.Radix::Tree#find, which allocates aResultand a paramsHashper call. So the cost still grows with the number of filter paths sharing a prefix with the request.Kemal::FilterHandler#treenow returns an empty tree; a filter added to it directly no longer runs.