Hanami.bundled? returns false for already-activated gems, leaving "rack.monitor" unregistered and returning 500 for every request
- Dominant language
- Ruby
- Stars
- 6.4k
- Forks
- 552
- Avg merge
- 21h 33m
- Merged PRs (30d)
- 3
Description
### Summary
`Hanami.bundled?` decides whether a gem is part of the bundle by looking at the return value of `Kernel#gem`. `Kernel#gem` returns **`false` when the gem is already activated** (it only returns `true` when *that call* performs the activation). So `Hanami.bundled?` reports `false` for any gem that was already activated by the time it is first probed.
Because `hanami-router` declares `rack` as a runtime dependency, probing `Hanami.bundled?("hanami-router")` 鈥?which happens while `require "hanami"` is loading 鈥?transitively activates `rack`. Every later `Hanami.bundled?("rack")` therefore returns `false`, `Hanami::App.prepare_app_providers` never registers the `:rack` provider, and `Hanami::Slice.load_router` blows up on `self["rack.monitor"]`.
### Environment
- hanami 3.0.2 (also present on `main`)
- Ruby 3.4.10, RubyGems 3.6.9, Windows x64-mingw-ucrt
- App run against system gems, i.e. **not** under `bundle exec`
### Root cause
```ruby
# lib/hanami.rb
def self.bundled?(gem_name)
@_mutex.synchronize do
@_bundled[gem_name] ||= begin
gem(gem_name) # <-- Kernel#gem returns false when already activated
rescue Gem::LoadError
false
end
end
end
```
```ruby
# rubygems/core_ext/kernel_gem.rb (3.6.9)
def gem(gem_name, *requirements)
dep = Gem::Dependency.new(gem_name, *requirements)
loaded = Gem.loaded_specs[gem_name]
return false if loaded && dep.matches_spec?(loaded) # already activated -> false
...
true
end
```
Minimal demonstration:
```
$ ruby -e 'require "rubygems"; p Gem::VERSION; gem "hanami-router"; p gem("rack"); p !Gem.loaded_specs["rack"].nil?'
"3.6.9"
false # gem("rack") reports false although rack is present
true # rack really is loaded (pulled in by hanami-router)
```
`hanami-router` runtime dependencies: `["rack >= 2.2.16", "mustermann ~> 3.1", "csv ~> 3.3"]`
### Symptoms
1. Every request returns `500`, including static assets and unmatched routes:
```
HTTP/1.1 500 Internal Server Error
Dry::Core::Container::KeyError: key not found: "rack.monitor"
dry-core/lib/dry/core/container/resolver.rb:32
hanami/slice.rb:655:in 'Hanami::Slice::ClassMethods#[]'
hanami/slice.rb:1102:in 'Hanami::Slice::ClassMethods#load_router'
```
(the `:rack` provider is skipped at `lib/hanami/app.rb` because `Hanami.bundled?("rack")` is false)
2. If more gems are already activated before boot (a bootstrapper that requires its dependencies, `Bundler.require`, `RUBYOPT`, ...), then all optional configs degrade to `Hanami::Config::NullConfig`:
```
config.actions=Hanami::Config::NullConfig assets=Hanami::Config::NullConfig
views=Hanami::Config::NullConfig middleware=Hanami::Config::NullConfig
Hanami::NoRoutesDefinedError: Could not handle this rack request
because the hanami router gem is missing, please add it
```
That silently drops CSP, `X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`, sessions, CSRF protection and the assets middleware, with no warning.
### Reproduction
```ruby
# repro.rb - run from an app root, NOT under bundle exec
require "hanami/boot"
require "rack/mock"
p %w[rack hanami-router hanami-action].map { |name| [name, Hanami.bundled?(name)] }
status, = Hanami.app.call(Rack::MockRequest.env_for("/"))
p status
```
```
$ ruby -Ilib repro.rb
[["rack", false], ["hanami-router", true], ["hanami-action", true]]
```
Note `rack=false` alongside `hanami-router=true` - that is the transitive-activation fingerprint. With a Bundler-equivalent detection (or when run under `bundle exec`) the same app returns `200` and all security headers are present, which isolates the cause to `bundled?`.
### Scope
- Triggered when the app is **not** running under Bundler: `ruby config.ru`, `rackup`, system-gem deployments, or any bootstrapper that activates gems before Hanami probes them.
- Under `bundle exec` Bundler replaces `Kernel#gem`, so `bundled?` happens to work - which is probably why this has gone unnoticed.
- Related, but different: #1248 (fixed a `NullConfig` crash by *adding* `Hanami.bundled?` guards), #1488 / #1490 (same class of problem for `db.rom`).
### Suggested fix
Decouple "is the gem available" from "did this call activate it":
```ruby
def self.bundled?(gem_name)
@_mutex.synchronize do
@_bundled[gem_name] ||= begin
if defined?(Bundler)
Bundler.load.dependencies.any? { |dep| dep.name == gem_name }
else
Gem::Specification.find_by_name(gem_name)
true
end
rescue Gem::LoadError
false
end
end
end
```
It would also help if `load_router` resolved `"rack.monitor"` defensively (or raised an error naming the missing provider), so that a gem-detection mistake cannot turn into a hard 500 on every request.
Happy to open a PR if this direction looks good.
Contributor guide
Research direction
Read lib/hanami.rb to trace Hanami.bundled? and compare its result with Gem.loaded_specs or the available dependency metadata. Then inspect lib/hanami/app.rb and lib/hanami/slice.rb, run repro.rb outside bundle exec, and verify that rack.monitor is registered, the request no longer returns 500, and the existing optional-gem behavior remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ruby
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100