Skip to content

Fix unbounded memory growth in Conditional middleware#214

Merged
lizmat merged 1 commit into
croservices:mainfrom
zaucker:fix/conditional-middleware-early-responses-leak
May 30, 2026
Merged

Fix unbounded memory growth in Conditional middleware#214
lizmat merged 1 commit into
croservices:mainfrom
zaucker:fix/conditional-middleware-early-responses-leak

Conversation

@zaucker

@zaucker zaucker commented May 29, 2026

Copy link
Copy Markdown
Contributor

Problem

Any HTTP server with a Cro::HTTP::Middleware::Conditional in its pipeline leaks memory: RSS grows linearly and without bound as requests are served. This affects, among others, Cro::APIToken-based authentication (its middleware does Cro::HTTP::Middleware::Conditional) and Cro's own auth middleware — i.e. every authenticated request leaks.

Root cause

Conditional and RequestResponse are built the same way: a Request and a Response transform that share a per-connection SkipPipelineState holding a Supplier $.early-responses. The Response transform taps that Supplier with whenever.

RequestResponse's Response completes the Supplier when its pipeline ends:

whenever wrap-response-logging(...) -> $response {
    emit $response;
    LAST $connection-state.early-responses.done;   # <-- completes the Supplier
}

Conditional's Response never did. The early-responses Supplier is therefore never completed, so the subscription (and the connection-scoped state reachable from it) is retained — leaking per request.

Fix

One line — mirror RequestResponse and .done the Supplier when the pipeline ends:

whenever $pipeline -> $response {
    emit $response;
    LAST $connection-state.early-responses.done;
}

Reproduction & verification

Minimal server with a no-op Conditional middleware, hammered with trivial GET requests:

before after
no-op Conditional middleware RSS climbs linearly, unbounded (~80 KB/req) plateaus
no-op RequestResponse middleware (control) plateaus plateaus
no middleware (control) plateaus plateaus

Verified end-to-end in a real app (Cro::APIToken auth): a /api/v1 endpoint that previously grew ~0.27 MB/request now plateaus.

Tests

All existing tests pass against the patched source: t/http-middleware (24), t/http-router (439), t/router-auth, t/http-auth-basic, t/http-auth-webtoken-bearer, t/http-session-inmemory.

I'm happy to add a regression test if you'd like, though a memory-growth assertion is inevitably timing/threshold-based — guidance welcome on the form you'd prefer.

The Conditional middleware's Response transform taps the per-connection
`early-responses` Supplier but never completes it. The RequestResponse
middleware completes the same Supplier via `LAST ... .done` when its
response pipeline ends; Conditional omitted this, so the Supplier (and the
connection-scoped state reachable from its subscription) was retained,
leaking memory on every request that passes through any Conditional
middleware (e.g. Cro::APIToken-based auth, Cro's own auth middleware).

Reproduction: a server with a no-op Conditional middleware grows RSS
linearly and unboundedly under repeated requests (~80 KB/request), while
an equivalent RequestResponse middleware plateaus. Adding the matching
`.done` brings Conditional in line with RequestResponse and the growth
stops.

All existing tests pass (t/http-middleware, t/http-router, t/router-auth,
t/http-auth-*, t/http-session-*).
@zaucker

zaucker commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

Worth flagging the blast radius: this isn't limited to apps that explicitly write a Conditional middleware. The router itself wraps every before {} / after {} route block in a Conditional (BeforeMiddleTransform does Cro::HTTP::Middleware::Conditional, Router.rakumod ~line 1211, wired via add-before($conditional.request) / add-after($conditional.response)). So any route { before {...}; ... } — a very common pattern — leaks on every request.

Direct demo, a no-op before { Nil } block, 4000 trivial GETs:

  • unpatched: RSS 338 → 707 MB, linear and unbounded
  • patched: RSS 280 → 361 MB, plateaus

Same one-line fix resolves it.

@librasteve

Copy link
Copy Markdown
Contributor

Thanks for the PR.

This looks good to me. Since it’s quite a deep fix, I’d like to see a second review.

@lizmat

lizmat commented May 29, 2026

Copy link
Copy Markdown
Member

Great to see this PR! @zaucker could you create a PR for the other fix as well?

zaucker added a commit to zaucker/agrammon that referenced this pull request May 29, 2026
Stock Cro from 'zef install --deps-only .' has an unbounded-memory-growth
bug (Cro::HTTP::Middleware::Conditional) and, on Cro::HTTP::Router 0.8.12+,
an OpenAPI-include crash. Document the patched fork branches to install
until croservices/cro-http#214 and cro-openapi-routes-from-definition#15
are released.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zaucker

zaucker commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

Great to see this PR! @zaucker could you create a PR for the other fix as well?

Which other fix? This one?
croservices/cro-openapi-routes-from-definition#15

@zaucker

zaucker commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

BTW: as disclosure, I got a little help by my friend Claude on tracking that down. After an initial rather wild guess about Garbage Collection, it finally found the real culprit and I learned about systematically tracking such a problem down.

The two graphs show the problem we just fixed:

Before/after
agrammon-leak-compare

Overnight test with memory usage sampling every 15min.
agrammon-leaktest

We finally could get rid of our nightly restart of Agrammon. Would have been a real road blocker on a busy web service.

@librasteve

librasteve commented May 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for explaining the background. It is very helpful to see your experience with Cro!

I have pulled your PR and tested with Air and raku.org on moar-2026.05 - all functional tests are good.

Looking at croservices/cro-openapi-routes-from-definition#15 I would say we can handle as two parallel PRs ... please note my comment over there.

I just want to spelunk the code to eyeball what .done does.

OK - I am happy with the change. Will merge now and do a point release of cro-http shortly.

(We can do a point release of cro-services on the other PR when that is accepted)

@zaucker

zaucker commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

Looking at croservices/cro-openapi-routes-from-definition#15 I would say we can handle as two parallel PRs ... please note my comment over there.

See my answer there :-)

I just want to spelunk the code to eyeball what .done does.

OK - I am happy with the change. Will merge now and do a point release of cro-http shortly.

Cool, thanks for the fast handling, makes my life easier without a locally patched module.

(We can do a point release of cro-services on the other PR when that is accepted)

Sounds good.

@lizmat lizmat merged commit a2949b6 into croservices:main May 30, 2026
2 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.

3 participants