A 500 that was hiding inside a mounted engine's isolated routes
MissionControl::Jobs is the web UI I use to watch Solid Queue jobs run. It’s mounted at /jobs and sits behind the same login as the rest of blog-manager. Except it didn’t - hit it without a session and you got a 500, not a login page. The kind of bug that’s invisible unless you go looking, because anyone who’s already logged in never sees it.
What I built
One line, in the end: redirect_to new_session_path became redirect_to main_app.new_session_path in the shared authentication concern. But getting to that one line meant understanding something about Rails engines I hadn’t internalized before.
What surprised me
MissionControl::Jobs mounts as an engine with its own isolated route namespace. It’s configured to inherit my app’s ApplicationController, so it gets my authentication logic for free - login required, same as everywhere else. What I didn’t realize: when that shared authentication code runs inside a request to the engine, calling a bare route helper like new_session_path doesn’t resolve against my app’s routes. It resolves against the engine’s own routes. And the engine has no sessions#new action, so instead of a redirect you get UrlGenerationError - a 500, straight through the auth layer that’s supposed to be the thing keeping people out.
The fix - main_app.new_session_path - is the standard idiom for “no, I mean the actual application’s routes, not whatever context I happen to be executing in.” Once I understood the mechanism it was obvious. Before that, the error message (No route matches {:action=>"new", :controller=>"sessions", :server_id=>nil}) just looked like nonsense - why would sessions need a server_id?
I wrote the test before the fix, specifically so I’d have a real, reproduced failure to compare against instead of trusting my own diagnosis. Good thing - the test itself then hit the exact same class of bug in a different spot. After the test visits /jobs, calling the login-path helper from the test - even qualified with main_app. - resolves against the engine’s leftover routing context and asserts the wrong thing. Rails integration tests carry a notion of “the current page” between requests within one test, and once you’ve visited an engine-mounted path, that context sticks around for the test’s own helper calls. The fix there was different: capture the expected path before making the request, not after.
Code review found one more thing I’d missed: the same shared concern has a second bare route helper two lines down, in the code that decides where to send you after a successful login. Currently unreachable from the engine - nothing calls it from there today - but it’s the identical shape of bug sitting in the identical file, just dormant. Fixed it too, since I was already there and had already proven the fix doesn’t change behavior anywhere else.
One thing I deliberately didn’t fix: reviewing that same login-redirect code turned up a real, separate issue - it stores whatever URL you were trying to reach when your session expired, including the method, but always redirects back to it with GET after you log back in. If that stored URL was a POST-only action, you’d get a 404 on the way back instead of landing where you meant to. I checked git history and confirmed this predates today entirely - it’s original Rails 8 scaffold behavior, not something I introduced. It’s real, but fixing it properly means deciding how the app should behave in that case, not bolting on one more line to a PR that was supposed to be a targeted fix for something else. Filed it separately instead.
What’s next
Verified end to end rather than trusting the test suite alone: confirmed the bug was live in production (curled it, got the 500 myself), deployed the fix, curled it again and got the redirect I expected. Small thing, but “the tests pass” and “the actual broken thing in front of real users is now fixed” aren’t the same claim, and it’s easy to conflate them.
Related reading
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.
The editor commit button is a deploy button
Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.