Skip to content

Commit

Permalink
Replace TracePoint with const_added for explicit namespaces
Browse files Browse the repository at this point in the history
  • Loading branch information
fxn committed Oct 1, 2024
1 parent 8f37a65 commit b96832b
Show file tree
Hide file tree
Showing 9 changed files with 42 additions and 199 deletions.
7 changes: 0 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,9 @@ jobs:
ruby-version:
- "2.5"
- "2.6"
- "2.7"
- "3.0"
- "3.1"
- "3.2"
- "3.3"
- "head"
# GitHub is not able to set this combination up lately.
exclude:
- os: "macos-latest"
ruby-version: "2.5"
runs-on: ${{ matrix.os }}
steps:
- uses: "actions/checkout@v4"
Expand Down
4 changes: 3 additions & 1 deletion lib/zeitwerk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ module Zeitwerk
require_relative "zeitwerk/inflector"
require_relative "zeitwerk/gem_inflector"
require_relative "zeitwerk/null_inflector"
require_relative "zeitwerk/kernel"
require_relative "zeitwerk/error"
require_relative "zeitwerk/version"

require_relative "zeitwerk/core_ext/kernel"
require_relative "zeitwerk/core_ext/module"

# This is a dangerous method.
#
# @experimental
Expand Down
14 changes: 14 additions & 0 deletions lib/zeitwerk/core_ext/module.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Zeitwerk::ExplicitNamespacesRegistry
def const_added(cname)
# Module#autoload triggers const_added too, but we are only interested in
# "actual" added constants.
unless autoload?(cname, false)
Zeitwerk::ExplicitNamespace.__on_const_added(self, cname)
end
super
end

Module.prepend(self)
end
56 changes: 11 additions & 45 deletions lib/zeitwerk/explicit_namespace.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# frozen_string_literal: true

module Zeitwerk
# Centralizes the logic for the trace point used to detect the creation of
# explicit namespaces, needed to descend into matching subdirectories right
# Centralizes the logic needed to descend into matching subdirectories right
# after the constant has been defined.
#
# The implementation assumes an explicit namespace is managed by one loader.
Expand All @@ -20,31 +19,17 @@ class << self
attr_reader :cpaths
private :cpaths

# @sig Mutex
attr_reader :mutex
private :mutex

# @sig TracePoint
attr_reader :tracer
private :tracer

# Asserts `cpath` corresponds to an explicit namespace for which `loader`
# is responsible.
#
# @sig (String, Zeitwerk::Loader) -> void
internal def register(cpath, loader)
mutex.synchronize do
cpaths[cpath] = loader
# We check enabled? because, looking at the C source code, enabling an
# enabled tracer does not seem to be a simple no-op.
tracer.enable unless tracer.enabled?
end
cpaths[cpath] = loader
end

# @sig (Zeitwerk::Loader) -> void
internal def unregister_loader(loader)
cpaths.delete_if { |_cpath, l| l == loader }
disable_tracer_if_unneeded
end

# This is an internal method only used by the test suite.
Expand All @@ -54,40 +39,21 @@ class << self
cpaths.key?(cpath)
end

# @sig () -> void
private def disable_tracer_if_unneeded
mutex.synchronize do
tracer.disable if cpaths.empty?
end
end
# @sig (Module, Symbol) -> void
internal def on_const_added(mod, cname)
# This is common and fast if true.
return if cpaths.empty?

# @sig (TracePoint) -> void
private def tracepoint_class_callback(event)
# If the class is a singleton class, we won't do anything with it so we
# can bail out immediately. This is several orders of magnitude faster
# than accessing its name.
return if event.self.singleton_class?
# We are only interested in class/module objects.
value = mod.const_get(cname, false)
return unless value.is_a?(Module)

# It might be tempting to return if name.nil?, to avoid the computation
# of a hash code and delete call. But Ruby does not trigger the :class
# event on Class.new or Module.new, so that would incur in an extra call
# for nothing.
#
# On the other hand, if we were called, cpaths is not empty. Otherwise
# the tracer is disabled. So we do need to go ahead with the hash code
# computation and delete call.
if loader = cpaths.delete(real_mod_name(event.self))
loader.on_namespace_loaded(event.self)
disable_tracer_if_unneeded
if loader = cpaths.delete(real_mod_name(value))
loader.on_namespace_loaded(value)
end
end
end

@cpaths = {}
@mutex = Mutex.new

# We go through a method instead of defining a block mainly to have a better
# label when profiling.
@tracer = TracePoint.new(:class, &method(:tracepoint_class_callback))
end
end
2 changes: 1 addition & 1 deletion lib/zeitwerk/loader/callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ module Zeitwerk::Loader::Callbacks
end

# Invoked when a class or module is created or reopened, either from the
# tracer or from module autovivification. If the namespace has matching
# const_added or from module autovivification. If the namespace has matching
# subdirectories, we descend into them now.
#
# @private
Expand Down
114 changes: 12 additions & 102 deletions test/lib/zeitwerk/test_explicit_namespace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ module Namespace; end
end
end

test "explicit namespaces defined with an explicit constant assignment are loaded correctly" do
files = [
["hotel.rb", "Hotel = Class.new; Hotel::X = 1"],
["hotel/pricing.rb", "class Hotel::Pricing; end"]
]
with_setup(files) do
assert_kind_of Class, Hotel
assert Hotel::X
assert Hotel::Pricing
end
end

test "explicit namespaces are loaded correctly even if #name is overridden" do
files = [
["hotel.rb", <<~RUBY],
Expand Down Expand Up @@ -138,108 +150,6 @@ def self.name
end
end

# As of this writing, a tracer on the :class event does not seem to have any
# performance penalty in an ordinary code base. But I prefer to precisely
# control that we use a tracer only if needed in case this issue
#
# https://bugs.ruby-lang.org/issues/14104
#
# goes forward.
def tracer
Zeitwerk::ExplicitNamespace.send(:tracer)
end

test "the tracer starts disabled" do
assert !tracer.enabled?
end

test "simple autoloading does not enable the tracer" do
files = [["x.rb", "X = true"]]
with_setup(files) do
assert !tracer.enabled?
assert X
assert !tracer.enabled?
end
end

test "autovivification does not enable the tracer, one directory" do
files = [["foo/bar.rb", "module Foo::Bar; end"]]
with_setup(files) do
assert !tracer.enabled?
assert Foo::Bar
assert !tracer.enabled?
end
end

test "autovivification does not enable the tracer, two directories" do
files = [
["rd1/foo/bar.rb", "module Foo::Bar; end"],
["rd2/foo/baz.rb", "module Foo::Baz; end"],
]
with_setup(files) do
assert !tracer.enabled?
assert Foo::Bar
assert !tracer.enabled?
end
end

test "explicit namespaces enable the tracer until loaded" do
files = [
["hotel.rb", "class Hotel; end"],
["hotel/pricing.rb", "class Hotel::Pricing; end"]
]
with_setup(files) do
assert tracer.enabled?
assert Hotel
assert !tracer.enabled?
assert Hotel::Pricing
assert !tracer.enabled?
end
end

test "the tracer is enabled until everything is loaded" do
files = [
["a/m.rb", "module M; end"], ["a/m/n.rb", "M::N = true"],
["b/x.rb", "module X; end"], ["b/x/y.rb", "X::Y = true"],
]
with_files(files) do
new_loader(dirs: "a")
assert tracer.enabled?

new_loader(dirs: "b")
assert tracer.enabled?

assert M
assert tracer.enabled?

assert X
assert !tracer.enabled?
end
end

# This is a regression test.
test "the tracer handles singleton classes" do
files = [
["hotel.rb", <<-EOS],
class Hotel
class << self
def x
1
end
end
end
EOS
["hotel/pricing.rb", "class Hotel::Pricing; end"],
["car.rb", "class Car; end"],
["car/pricing.rb", "class Car::Pricing; end"],
]
with_setup(files) do
assert tracer.enabled?
assert_equal 1, Hotel.x
assert tracer.enabled?
end
end

test "non-hashable explicit namespaces are supported" do
files = [
["m.rb", <<~EOS],
Expand Down
41 changes: 0 additions & 41 deletions test/lib/zeitwerk/test_ruby_compatibility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -261,47 +261,6 @@ class C; end
end
end

# This is why we issue a namespace_dirs.delete call in the tracer block, to
# ignore events triggered by reopenings.
test "tracing :class calls you back on creation and on reopening" do
on_teardown do
@tracer.disable
remove_const :C, from: self.class
remove_const :M, from: self.class
end

traced = []
@tracer = TracePoint.trace(:class) do |tp|
traced << tp.self
end

2.times do
class C; end
module M; end
end

assert_equal [C, M, C, M], traced
end

# Computing hash codes is costly and we want the tracer to be as efficient as
# possible. The TP callback doesn't short-circuit anonymous classes/modules
# because Class.new/Module.new do not trigger the :class event. We leverage
# this property to save a nil? call.
#
# However, if that changes in future versions of Ruby, this test would tell us
# and we could revisit the callback implementation.
test "trace points on the :class events don't get called on Class.new and Module.new" do
on_teardown { @tracer.disable }

$tracer_for_anonymous_class_and_modules_called = false
@tracer = TracePoint.trace(:class) { $tracer_for_anonymous_class_and_modules_called = true }

Class.new
Module.new

assert !$tracer_for_anonymous_class_and_modules_called
end

# If the user issues a require call with a Pathname object for a path that is
# autoloadable, we are still able to intercept it because $LOADED_FEATURES
# stores it as a string and loader_for is able to find its loader. During
Expand Down
1 change: 0 additions & 1 deletion test/support/loader_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ def reset_registry

def reset_explicit_namespace
Zeitwerk::ExplicitNamespace.send(:cpaths).clear
Zeitwerk::ExplicitNamespace.send(:tracer).disable
end

def teardown
Expand Down
2 changes: 1 addition & 1 deletion zeitwerk.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ Gem::Specification.new do |spec|
"bug_tracker_uri" => "https://github.com/fxn/zeitwerk/issues"
}

spec.required_ruby_version = ">= 2.5"
spec.required_ruby_version = ">= 3.2"
end

0 comments on commit b96832b

Please sign in to comment.