Skip to content

Concurrent::Map is not modeled (only the concurrent-ruby thread pool is) #647

Closed

Description

@iberianpig

The stdlib registry models concurrent-ruby's ThreadPoolExecutor, FixedThreadPool and CyclicBarrier (src/analyze/registry/stdlib.rs:391), but not Concurrent::Map. On main, a constant assigned Concurrent::Map.new is refused, so every use of it is an error.

Repro

# app/models/widget.rb
class Widget < ApplicationRecord
  LOCKS = Concurrent::Map.new

  def self.lock_for(id) = LOCKS.compute_if_absent(id) { Mutex.new }
  def self.forget_locks = LOCKS.clear
end

In Rails, Widget.lock_for(1) returns the same Mutex on every call, and Widget.forget_locks empties the map.

roundhouse check --continue . in the app root, main a3acfb4 (the absolute path of the temporary app is shortened to ./):

./app/models/widget.rb:4:27: error[unsupported]: constant not supported (all targets): Widget::LOCKS
./app/models/widget.rb:4:51: warning[unresolved_type]: local read `id` has unresolved type
./app/models/widget.rb:5:27: error[unsupported]: constant not supported (all targets): Widget::LOCKS

roundhouse-check: 2 gems: 1 framework, 1 infrastructure, 0 unknown
roundhouse-check: . — 0 parse error(s), 2 error(s), 1 warning(s), 0 gap-attributed note(s), 0 survey gap(s)

The release 2026.9.18 reports the method calls instead: no known method `compute_if_absent` on Concurrent::Map at 4:27 and no known method `clear` on Concurrent::Map at 5:27.

Self-contained repro.rb (bundler/inline; exits 1 while the bug is present)

ruby repro.rb needs network access for bundler/inline and the roundhouse binary (on PATH, in $ROUNDHOUSE, or at /tmp/roundhouse-bin/roundhouse). It writes the app into a temporary directory, boots it in Rails and calls Widget.lock_for twice and Widget.forget_locks, generates Gemfile.lock with bundle lock --local, then runs roundhouse check --continue . on the same files.

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  gem "rails", "~> 8.0"
  gem "sqlite3"
end

require "fileutils"
require "tmpdir"

APP = {
  "Gemfile" => <<~RUBY,
    source "https://rubygems.org"
    gem "rails", "~> 8.0"
    gem "sqlite3"
  RUBY
  "config/application.rb" => <<~RUBY,
    require "rails/all"

    module Repro
      class Application < Rails::Application
        config.load_defaults 8.0
      end
    end
  RUBY
  "config/routes.rb" => <<~RUBY,
    Rails.application.routes.draw do
    end
  RUBY
  "db/schema.rb" => <<~RUBY,
    ActiveRecord::Schema[8.0].define(version: 1) do
      create_table "widgets", force: :cascade do |t|
        t.string "name", null: false
        t.timestamps
      end
    end
  RUBY
  "app/models/application_record.rb" => <<~RUBY,
    class ApplicationRecord < ActiveRecord::Base
      primary_abstract_class
    end
  RUBY
  "app/models/widget.rb" => <<~RUBY
    class Widget < ApplicationRecord
      LOCKS = Concurrent::Map.new

      def self.lock_for(id) = LOCKS.compute_if_absent(id) { Mutex.new }
      def self.forget_locks = LOCKS.clear
    end
  RUBY
}

root = Dir.mktmpdir("roundhouse-repro")
at_exit { FileUtils.remove_entry(root) }
APP.each do |path, source|
  FileUtils.mkdir_p(File.dirname(File.join(root, path)))
  File.write(File.join(root, path), source)
end

puts "== Rails #{Rails.version} runs the app"
ENV["RAILS_ENV"] = "test"
ENV["DATABASE_URL"] = "sqlite3::memory:"
Dir.chdir(root) { require "./config/application" }
Rails.application.config.eager_load = false
Rails.application.initialize!
ActiveRecord::Schema.verbose = false
load File.join(root, "db/schema.rb")

same_lock = Widget.lock_for(1).equal?(Widget.lock_for(1))
Widget.forget_locks
puts "Widget.lock_for(1) twice -> same Mutex: #{same_lock}; after forget_locks size: #{Widget::LOCKS.size}"
raise "unexpected result" unless same_lock && Widget::LOCKS.size.zero?

puts "== roundhouse check --continue"
Bundler.with_unbundled_env { system("bundle", "lock", "--local", chdir: root, out: File::NULL, exception: true) }
roundhouse = ENV["ROUNDHOUSE"] ||
  ENV["PATH"].split(File::PATH_SEPARATOR).map { File.join(_1, "roundhouse") }.find { File.executable?(_1) } ||
  "/tmp/roundhouse-bin/roundhouse"
output = IO.popen([roundhouse, "check", "--continue", "."], chdir: root, err: [:child, :out], &:read)
puts output
exit 1 if output.match?(/no known method `\w+` on Concurrent::Map/) ||
  output.include?("constant not supported (all targets): Widget::LOCKS")

Impact

In a Rails 8.0 app, 3 of the 335 errors on main are constant not supported (all targets): Concurrent::Map, at three @x ||= Concurrent::Map.new readers in two models. The maps hold per-record mutexes and process-local delivery state.

Expected

Concurrent::Map is typed like a Hash ([], []=, fetch, delete, clear, key?, compute_if_absent, put_if_absent, each_pair). It is the standard process-local, thread-safe cache in Rails code, and Rails uses it internally. On a target without threads it can lower to Hash.

I found no existing issue or PR for Concurrent::Map. #503 (merged 2026-10-06) is why main refuses the constant instead of the method calls.

roundhouse 2026.9.18 (2e286e6), x86_64-unknown-linux-musl release binary, and main a3acfb4 built from source with cargo build --release --locked --bin roundhouse. Ruby 3.3.10, Rails 8.1.4, sqlite3 2.9.6, Linux x86_64, 2026-10-08.

Written with Claude Code (AI assistant) on behalf of @iberianpig.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions