├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── auto_reloader.gemspec ├── bin ├── console └── setup ├── lib ├── auto_reloader.rb └── auto_reloader │ └── version.rb └── spec ├── auto_reloader_spec.rb └── fixtures ├── lib ├── a.rb ├── a │ └── inner.rb ├── b.rb ├── c.rb └── raise_exception_on_load.rb └── load_once └── settings.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.1 4 | - jruby-9.1.2.0 5 | before_install: gem install bundler -v 1.11.2 6 | env: 7 | - SKIP_WATCH=1 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in auto_reloader.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Rodrigo Rosenfeld Rosas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutoReloader [![Build Status](https://travis-ci.org/rosenfeld/auto_reloader.svg?branch=master)](https://travis-ci.org/rosenfeld/auto_reloader) 2 | 3 | AutoReloader is a lightweight code reloader intended to be used specially in development mode of server applications. 4 | 5 | It will override `require` and `require_relative` when activated. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'auto_reloader' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install auto_reloader 22 | 23 | ## Usage 24 | 25 | AutoReloader will try to add auto-reloading code support transparently by unloading all files 26 | belonging to the list of reloadable paths and the constants defined by them. This is not always 27 | possible to handle transparently, so please read the Known Caveats to properly understand what 28 | you should do to avoid them. 29 | 30 | Here's how it would be used in a Rack application. 31 | 32 | app.rb: 33 | 34 | ```ruby 35 | App = ->(env){['200', {'Content-Type' => 'text/plain'}, ['Sample output']]} 36 | ``` 37 | 38 | config.ru: 39 | 40 | ```ruby 41 | if ENV['RACK_ENV'] != 'development' 42 | require_relative 'app' 43 | run App 44 | else 45 | require 'auto_reloader' 46 | # won't reload before 1s elapsed since last reload by default. It can be overridden 47 | # in the reload! call below 48 | AutoReloader.activate reloadable_paths: [__dir__], delay: 1 49 | run ->(env) { 50 | AutoReloader.reload! do |unloaded| 51 | # by default, AutoReloader only unloads constants when a watched file changes; 52 | # when it unloads code before calling this block, the value for unloaded will be true. 53 | ActiveSupport::Dependencies.clear if unloaded && defined?(ActiveSupport::Dependencies) 54 | require_relative 'app' 55 | App.call env 56 | end 57 | } 58 | end 59 | ``` 60 | 61 | Just change "Sample output" to something else and reload the page. 62 | 63 | By default reloading will only happen if one of the reloadable file was changed since it was 64 | required. This can be overriden by providing the `onchange: false` option to either `activate` 65 | or `reload!`. When the `listen` gem is available it's used by default to detect changes in 66 | the `reloadable_paths` using specific OS watch mechanisms which allows AutoReloader to speed 67 | up `reload!` when no changes happened although it won't probably do much difference unless 68 | your application has tons of reloadable files loaded upon each request. If you don't want to 69 | use `listen` when available, set `watch_paths: false` when calling `activate`. 70 | 71 | Currently AutoReloader does not watch files other than those being required and I'm not sure 72 | if it would be a good idea to provide this kind of feature through some option. However if 73 | you want to force unloading the reloadable files when some configuration file (YAML, JSON, etc) 74 | changes, it should be quite simple with the `listen` gem. Here's an example: 75 | 76 | ```ruby 77 | app_config = File.expand_path('config/app.json', __dir__) 78 | Listen.to(File.expand_path('config', __dir__)) do |added, modified, removed| 79 | AutoReloader.force_next_reload if (added + modified + removed).include?(app_config) 80 | end.start 81 | ``` 82 | 83 | ## Thread-safety 84 | 85 | In order for the automatic constants and required files detection to work correctly it should 86 | process a single require at a time. If your code has multiple threads requiring code, then it 87 | might cause a race condition that could cause unexpected bugs to happen in AutoReloader. This 88 | is the default behavior because it's not common to call require from multiple threads in the 89 | development environment but adding a monitor around require could create a dead-lock which is 90 | a more serious issue. 91 | 92 | For example, if requiring a file would start a web server and block, if the web server is 93 | started in a separate thread (which could be joined so that the require doesn't return), then 94 | it wouldn't be able to require new files because the lock was acquired by another thread and 95 | won't be released while the web server is running. 96 | 97 | If you are sure that no require should block in your application (which is also common), you're 98 | encouraged to call `AutoReloader.sync_require!`. Or pass `sync_require: true` to 99 | `AutoReloader.activate`. You may even control this behavior dynamically so that you call 100 | `AutoReloader.async_require!` before the blocking require and then reenable the sync behavior. 101 | The sync behavior will ensure no race conditions that would break the automatic detection 102 | mechanism would ever happen. 103 | 104 | Also, it may be dangerous to unload classes while some requests are being processed. So, since 105 | version 0.4, the default is to await for all blocks executed by `reload!` to finish running before 106 | unloading. In case you prefer the old behavior because some requests may never return, which 107 | could happen with some implementations of websocket connections handled by the same process 108 | for example, just set the `await_before_unload` to `false` on `activate` or `reload!` calls. 109 | 110 | ## Known Caveats 111 | 112 | In order to work transparently AutoReloader will override `require` and `require_relative` when 113 | activated and track changes to the top-level constants after each require. Top-level constants 114 | defined by reloadable files are removed upon `reload!` or `unload!`. So, if your application 115 | does something crazy like this: 116 | 117 | json-extension.rb: 118 | 119 | ```ruby 120 | class JSON 121 | class MyExtension 122 | # crazy stuff: don't do that 123 | end 124 | end 125 | ``` 126 | 127 | If you require 'json-extension' before requiring 'json', supposing it's reloadable, `unload!` 128 | and `reload!` would remove the JSON constant because AutoReloader will think it was defined 129 | by 'json-extension'. If you require 'json' before this file, then JSON won't be removed but 130 | neither will JSON::MyExtension. 131 | 132 | As a general rule, any reloadable file should not change the behavior of code in non 133 | reloadable files. 134 | 135 | ## Implementation description 136 | 137 | AutoReloader doesn't try to reload only the changed files. If any of the reloadable files change 138 | then all reloadable files are unloaded and the constants they defined are removed. Reloadable 139 | files are those living in one of the `reloadable_paths` entries. The more paths it has to search 140 | the bigger will be the overhead to `require` and `require_relative`. 141 | 142 | Currently this implementation does not use an evented watcher to detect changes to files but 143 | it may be considered in future versions. Currently it traverses each loaded reloadable file and 144 | check whether it was changed. 145 | 146 | ## AutoReloadable does not support automatic autoload 147 | 148 | AutoReloadable does not provide automatic autoload features like ActiveSupport::Dependencies 149 | by design and won't support it ever, although such feature could be implemented as an extension 150 | or as a separate gem. Personally I don't find it a good practice and I think all dependencies 151 | should be declared explicitly by all files depending on them even if it's not necessary because 152 | it was already required by another file. 153 | 154 | ## Development 155 | 156 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` 157 | to run the tests. You can also run `bin/console` for an interactive prompt that will allow 158 | you to experiment. 159 | 160 | To install this gem onto your local machine, run `bundle exec rake install`. To release a 161 | new version, update the version number in `version.rb`, and then run `bundle exec rake release`, 162 | which will create a git tag for the version, push git commits and tags, and push the `.gem` 163 | file to [rubygems.org](https://rubygems.org). 164 | 165 | ## Contributing 166 | 167 | Bug reports and pull requests are welcome 168 | [on GitHub](https://github.com/rosenfeld/auto_reloader). 169 | 170 | 171 | ## License 172 | 173 | The gem is available as open source under the terms of the 174 | [MIT License](http://opensource.org/licenses/MIT). 175 | 176 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /auto_reloader.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'auto_reloader/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'auto_reloader' 8 | spec.version = AutoReloader::VERSION 9 | spec.authors = ['Rodrigo Rosenfeld Rosas'] 10 | spec.email = ['rr.rosas@gmail.com'] 11 | 12 | spec.summary = %q{A transparent code reloader.} 13 | spec.homepage = 'https://github.com/rosenfeld/auto_reloader' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) } 17 | spec.require_paths = ['lib'] 18 | 19 | spec.add_development_dependency 'bundler', '>= 2.2.33' 20 | spec.add_development_dependency 'rake', '>= 12.3.3' 21 | spec.add_development_dependency 'rspec', '~> 3.0' 22 | spec.add_development_dependency 'listen', '~> 3.0' 23 | end 24 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "auto_reloader" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/auto_reloader.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'auto_reloader/version' 4 | require 'singleton' 5 | require 'forwardable' 6 | require 'monitor' 7 | require 'thread' # for Mutex 8 | require 'set' 9 | require 'time' unless defined?(Process::CLOCK_MONOTONIC) 10 | 11 | class AutoReloader 12 | include Singleton 13 | extend SingleForwardable 14 | 15 | # default_await_before_unload will await for all calls to reload! to finish before calling 16 | # unload!. This behavior is usually desired in web applications to avoid unloading anything 17 | # while a request hasn't been finished, however it won't work fine if some requests are 18 | # supposed to remain open, like websockets connections or something like that. 19 | 20 | attr_reader :reloadable_paths, :default_onchange, :default_delay, :default_await_before_unload 21 | 22 | def_delegators :instance, :activate, :reload!, :reloadable_paths, :reloadable_paths=, 23 | :unload!, :force_next_reload, :sync_require!, :async_require!, :register_unload_hook 24 | 25 | module RequireOverride 26 | def require(path) 27 | AutoReloader.instance.require(path) { super } 28 | end 29 | 30 | def require_relative(path) 31 | from = caller.first.split(':', 2)[0] 32 | fullpath = File.expand_path File.join File.dirname(caller.first), path 33 | AutoReloader.instance.require_relative path, fullpath 34 | end 35 | end 36 | 37 | def initialize 38 | @activate_lock = Mutex.new 39 | end 40 | 41 | ActivatedMoreThanOnce = Class.new RuntimeError 42 | def activate(reloadable_paths: [], onchange: true, delay: nil, watch_paths: nil, 43 | watch_latency: 1, sync_require: false, await_before_unload: true) 44 | @activate_lock.synchronize do 45 | raise ActivatedMoreThanOnce, 'Can only activate Autoreloader once' if @reloadable_paths 46 | @default_delay = delay 47 | @default_onchange = onchange 48 | @default_await_before_unload = await_before_unload 49 | @watch_latency = watch_latency 50 | sync_require! if sync_require 51 | @reload_lock = Mutex.new 52 | @zero_requests_condition = ConditionVariable.new 53 | @requests_count = 0 54 | @top_level_consts_stack = [] 55 | @unload_constants = Set.new 56 | @unload_files = Set.new 57 | @unload_hooks = [] 58 | @last_reloaded = clock_time 59 | try_listen unless watch_paths == false 60 | self.reloadable_paths = reloadable_paths 61 | Object.include RequireOverride 62 | end 63 | end 64 | 65 | # when concurrent threads require files race conditions may prevent the automatic detection 66 | # of constants created by a given file. Calling sync_require! will ensure only a single file 67 | # is required at a single time. However, if a required file blocks (think of a web server) 68 | # then any requires by a separate thread would be blocked forever (or until the web server 69 | # shutdowns). That's why require is async by default even though it would be vulnerable to 70 | # race conditions. 71 | def sync_require! 72 | @require_lock ||= Monitor.new # monitor is like Mutex, but reentrant 73 | end 74 | 75 | # See the documentation for sync_require! to understand the reasoning. Async require is the 76 | # default behavior but could lead to race conditions. If you know your requires will never 77 | # block it may be a good idea to call sync_require!. If you know what require will block you 78 | # can call async_require!, require it, and then call sync_require! which will generate a new 79 | # monitor. 80 | def async_require! 81 | @require_lock = nil 82 | end 83 | 84 | def reloadable_paths=(paths) 85 | @reloadable_paths = paths.map{|rp| File.expand_path(rp).freeze }.freeze 86 | setup_listener if @watch_paths 87 | end 88 | 89 | def require(path, &block) 90 | was_required = false 91 | error = nil 92 | maybe_synchronize do 93 | @top_level_consts_stack << Set.new 94 | old_consts = Object.constants 95 | prev_consts = new_top_level_constants = nil 96 | begin 97 | was_required = yield 98 | rescue Exception => e 99 | error = e 100 | ensure 101 | prev_consts = @top_level_consts_stack.pop 102 | return false if !error && !was_required # was required already, do nothing 103 | 104 | new_top_level_constants = Object.constants - old_consts - prev_consts.to_a 105 | 106 | (new_top_level_constants.each{|c| safe_remove_constant c }; raise error) if error 107 | 108 | @top_level_consts_stack.each{|c| c.merge new_top_level_constants } 109 | 110 | full_loaded_path = $LOADED_FEATURES.last 111 | return was_required unless reloadable? full_loaded_path, path 112 | @reload_lock.synchronize do 113 | @unload_constants.merge new_top_level_constants 114 | @unload_files << full_loaded_path 115 | end 116 | end 117 | end 118 | was_required 119 | end 120 | 121 | def maybe_synchronize(&block) 122 | @require_lock ? @require_lock.synchronize(&block) : yield 123 | end 124 | 125 | def require_relative(path, fullpath) 126 | require(fullpath){ Kernel.require fullpath } 127 | end 128 | 129 | InvalidUsage = Class.new RuntimeError 130 | def reload!(delay: default_delay, onchange: default_onchange, watch_paths: @watch_paths, 131 | await_before_unload: default_await_before_unload) 132 | if onchange && !block_given? 133 | raise InvalidUsage, 'A block must be provided to reload! when onchange is true (the default)' 134 | end 135 | 136 | unless reload_ignored = ignore_reload?(delay, onchange, watch_paths) 137 | @reload_lock.synchronize do 138 | @zero_requests_condition.wait(@reload_lock) unless @requests_count == 0 139 | end if await_before_unload && block_given? 140 | unload! 141 | end 142 | 143 | result = nil 144 | if block_given? 145 | @reload_lock.synchronize{ @requests_count += 1 } 146 | begin 147 | result = yield !reload_ignored 148 | ensure 149 | @reload_lock.synchronize{ 150 | @requests_count -= 1 151 | @zero_requests_condition.signal if @requests_count == 0 152 | } 153 | end 154 | find_mtime 155 | end 156 | @last_reloaded = clock_time if delay 157 | result 158 | end 159 | 160 | def unload! 161 | @force_reload = false 162 | @reload_lock.synchronize do 163 | @unload_hooks.reverse_each{|h| run_unload_hook h } 164 | @unload_files.each{|f| $LOADED_FEATURES.delete f } 165 | @unload_constants.each{|c| safe_remove_constant c } 166 | @unload_hooks = [] 167 | @unload_files = Set.new 168 | @unload_constants = Set.new 169 | end 170 | end 171 | 172 | def register_unload_hook(&hook) 173 | raise InvalidUsage, "A block is required for register_unload_hook" unless block_given? 174 | @reload_lock.synchronize do 175 | @unload_hooks << hook 176 | end 177 | end 178 | 179 | def run_unload_hook(hook) 180 | hook.call 181 | rescue => e 182 | puts "Failed to run unload hook in AutoReloader: #{e.message}.\n\n#{e.backtrace.join("\n")}" 183 | end 184 | 185 | def stop_listener 186 | @reload_lock.synchronize do 187 | @listener.stop if @listener 188 | @listener = nil 189 | end 190 | end 191 | 192 | def force_next_reload 193 | @force_reload = true 194 | end 195 | 196 | private 197 | 198 | def try_listen 199 | Kernel.require 'listen' 200 | @watch_paths = true 201 | rescue LoadError # ignore 202 | #puts 'listen is not available. Add it to Gemfile if you want to speed up change detection.' 203 | end 204 | 205 | def setup_listener 206 | @reload_lock.synchronize do 207 | @listener.stop if @listener 208 | @listener = Listen.to(*@reloadable_paths, latency: @watch_latency) do |m, a, r| 209 | @paths_changed = [m, a, r].any?{|o| o.any? {|f| reloadable?(f, nil) }} 210 | end 211 | @listener.start 212 | end 213 | end 214 | 215 | if defined?(Process::CLOCK_MONOTONIC) 216 | def clock_time 217 | Process.clock_gettime(Process::CLOCK_MONOTONIC) 218 | end 219 | else 220 | def clock_time 221 | Time.now.to_f 222 | end 223 | end 224 | 225 | def reloadable?(fullpath, path) 226 | @reloadable_paths.any?{|rp| fullpath.start_with? rp} 227 | end 228 | 229 | def ignore_reload?(delay, onchange, watch_paths = @watch_paths) 230 | return false if @force_reload 231 | (delay && (clock_time - @last_reloaded < delay)) || (onchange && !changed?(watch_paths)) 232 | end 233 | 234 | def changed?(watch_paths = @watch_paths) 235 | return false if watch_paths && !@paths_changed 236 | @paths_changed = false 237 | return true unless @last_mtime_by_path 238 | @reload_lock.synchronize do 239 | return @unload_files.any?{|f| @last_mtime_by_path[f] != safe_mtime(f) } 240 | end 241 | end 242 | 243 | def safe_mtime(path) 244 | File.mtime(path) if File.exist?(path) 245 | end 246 | 247 | def find_mtime 248 | @reload_lock.synchronize do 249 | @last_mtime_by_path = {} 250 | @unload_files.each{|f| @last_mtime_by_path[f] = safe_mtime f } 251 | end 252 | @last_mtime_by_path 253 | end 254 | 255 | def safe_remove_constant(constant) 256 | Object.send :remove_const, constant 257 | rescue NameError # ignore if it has been already removed 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /lib/auto_reloader/version.rb: -------------------------------------------------------------------------------- 1 | class AutoReloader 2 | VERSION = '0.6.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/auto_reloader_spec.rb: -------------------------------------------------------------------------------- 1 | gem 'listen' 2 | 3 | # we apply a monkey patch to rb-inotify to avoid polluting the logs when calling 4 | # fd on a closed handler 5 | 6 | begin 7 | require 'rb-inotify' 8 | 9 | ::INotify::Notifier.prepend Module.new { 10 | def fd 11 | super 12 | rescue IOError, Errno::EBADF 13 | end 14 | } 15 | rescue LoadError # ignore 16 | end 17 | 18 | require_relative '../lib/auto_reloader' 19 | 20 | describe AutoReloader, order: :defined do 21 | # for some reason RSpec doesn't exit in Travis CI when enabling this example 22 | # even though it seems to work fine locally 23 | def watch_paths? 24 | return ENV['FORCE_WATCH'] == '1' if ENV.key?('FORCE_WATCH') 25 | return false if ENV['SKIP_WATCH'] == '1' 26 | RUBY_PLATFORM != 'java' || ENV['SKIP_JRUBY_WATCH'] != '1' 27 | end 28 | 29 | def watch_sleep_time 30 | return ENV['WATCH_SLEEP_TIME'].to_f if ENV.key?('WATCH_SLEEP_TIME') 31 | RUBY_PLATFORM == 'java' ? 2 : 0.3 32 | end 33 | 34 | fixture_lib_path = File.join __dir__, 'fixtures', 'lib' 35 | before(:all){ 36 | load_once_path = File.join __dir__, 'fixtures', 'load_once' 37 | AutoReloader.activate onchange: false, reloadable_paths: [ fixture_lib_path ], 38 | watch_latency: 0.1, watch_paths: watch_paths? 39 | $LOAD_PATH << fixture_lib_path << load_once_path 40 | } 41 | before(:each) do |example| 42 | AutoReloader.unload! 43 | AutoReloader.reloadable_paths = [fixture_lib_path] 44 | end 45 | after(:all) { AutoReloader.instance.stop_listener } 46 | 47 | it 'detects only constants defined in autoreloadable files' do 48 | expect(defined? ::Settings).to be nil 49 | expect(defined? ::C).to be nil 50 | require 'c' 51 | expect(defined? ::C).to eq 'constant' 52 | expect(defined? ::Settings).to eq 'constant' 53 | AutoReloader.unload! 54 | expect(defined? ::C).to be nil 55 | expect(defined? ::Settings).to eq 'constant' 56 | end 57 | 58 | it 'supports require_relative and recursive requires' do 59 | expect(defined? ::A).to be nil 60 | expect(defined? ::B).to be nil 61 | expect(defined? ::C).to be nil 62 | expect(defined? ::JSON).to be nil 63 | require 'a' 64 | expect(defined? ::A).to eq 'constant' 65 | expect(defined? ::B).to eq 'constant' 66 | expect(defined? ::C).to eq 'constant' 67 | expect(defined? ::JSON).to eq 'constant' 68 | AutoReloader.unload! 69 | expect(defined? ::A).to be nil 70 | expect(defined? ::B).to be nil 71 | expect(defined? ::C).to be nil 72 | expect(defined? ::JSON).to eq 'constant' 73 | end 74 | 75 | context 'with random order', order: :random do 76 | 77 | it 'reloads files upon reload! and accepts a block' do 78 | AutoReloader.reload! { require 'c' } 79 | expect(C.count).to be 1 80 | expect(C.count).to be 2 81 | AutoReloader.reload! { require 'c' } 82 | expect(C.count).to be 1 83 | end 84 | 85 | it 'raises on attempts to activate more than once' do 86 | expect{ AutoReloader.activate }.to raise_exception(AutoReloader::ActivatedMoreThanOnce) 87 | end 88 | 89 | it 'raises if onchange is specified and a block is not passed to reload!' do 90 | expect { AutoReloader.reload! onchange: true }.to raise_exception(AutoReloader::InvalidUsage) 91 | end 92 | 93 | it 'supports reloading only upon changing any of the loaded files' do 94 | require 'fileutils' 95 | 96 | AutoReloader.reload!(onchange: true){ require 'a' } # b and c are required as well 97 | expect(C.count).to be 1 98 | AutoReloader.reload!(onchange: true){ require 'a' } 99 | expect(C.count).to be 2 # C wasn't reloaded 100 | FileUtils.touch File.join __dir__, 'fixtures', 'lib', 'b.rb' 101 | sleep watch_sleep_time if watch_paths? # wait for listen to detect the change 102 | AutoReloader.reload!(onchange: true){ require 'a' } 103 | expect(C.count).to be 1 # C was reloaded 104 | end 105 | 106 | it 'supports forcing next reload' do 107 | AutoReloader.reload!(onchange: true){ require 'c' } 108 | expect(C.count).to be 1 109 | AutoReloader.reload!(onchange: true){ require 'c' } 110 | expect(C.count).to be 2 111 | AutoReloader.force_next_reload 112 | AutoReloader.reload!(onchange: true){ require 'c' } 113 | expect(C.count).to be 1 114 | AutoReloader.reload!(onchange: true){ require 'c' } 115 | expect(C.count).to be 2 116 | end 117 | 118 | it 'returns the block return value when passed to reload!' do 119 | expect(AutoReloader.reload!{ 'abc' }).to eq 'abc' 120 | end 121 | 122 | it 'supports a delay option' do 123 | AutoReloader.reload!(delay: 0.01){ require 'c' } 124 | expect(C.count).to be 1 125 | AutoReloader.reload!(delay: 0.01){ require 'c' } 126 | expect(C.count).to be 2 127 | sleep 0.01 128 | expect(C.count).to be 3 129 | AutoReloader.reload!(delay: 0.01){ require 'c' } 130 | expect(C.count).to be 1 131 | end 132 | 133 | it 'runs unload hooks in reverse order' do 134 | order = [] 135 | AutoReloader.register_unload_hook{ order << 'first' } 136 | AutoReloader.register_unload_hook{ order << 'second' } 137 | AutoReloader.unload! 138 | expect(order).to eq ['second', 'first'] 139 | end 140 | 141 | it 'requires a block when calling register_unload_hook' do 142 | expect{ AutoReloader.register_unload_hook }.to raise_error AutoReloader::InvalidUsage 143 | end 144 | 145 | context "changing reloadable paths" do 146 | around(:each) do |example| 147 | require 'tmpdir' 148 | Dir.mktmpdir do |dir| 149 | @dir = dir 150 | AutoReloader.reloadable_paths = [dir] 151 | $LOAD_PATH.unshift dir 152 | 153 | example.metadata[:tmpdir] = dir 154 | example.run 155 | 156 | $LOAD_PATH.shift 157 | end 158 | end 159 | 160 | example 'moving or removing a file should not raise while checking for change' do 161 | tmp_filename = File.join @dir, 'to_be_removed.rb' 162 | FileUtils.touch tmp_filename 163 | 164 | AutoReloader.reload!(onchange: true){ require 'to_be_removed' } 165 | File.delete tmp_filename 166 | expect { AutoReloader.reload!(onchange: true){} }.to_not raise_exception 167 | end 168 | end 169 | 170 | it 'respects default options passed to activate when calling reload!' do 171 | expect(AutoReloader.instance.default_onchange).to be false 172 | end 173 | 174 | # In case another reloader is in use or if the application itself removed it 175 | it 'does not raise if a reloadable constant has been already removed' do 176 | require 'c' 177 | Object.send :remove_const, 'C' 178 | expect(defined? ::C).to be nil 179 | expect{ AutoReloader.unload! }.to_not raise_exception 180 | end 181 | 182 | it 'unloads constants defined when a require causes an error' do 183 | error = nil 184 | begin 185 | require 'raise_exception_on_load' 186 | rescue Exception => e 187 | error = e 188 | end 189 | expect(error).to_not be nil 190 | expect(error.message).to eq 'protect against all kinds of exceptions' 191 | expect(error.backtrace.first). 192 | to start_with File.expand_path('spec/fixtures/lib/raise_exception_on_load.rb:3') 193 | expect(defined? ::DEFINED_CONSTANT).to be nil 194 | expect($LOADED_FEATURES.any?{|f| f =~ /raise_exception_on_load/}).to be false 195 | end 196 | 197 | context 'awaits for requests to finish before unloading by default' do 198 | let(:executed){ [] } 199 | 200 | def start_threads(force_reload:) 201 | thread_started = false 202 | [ 203 | Thread.start do 204 | AutoReloader.reload!(onchange: true) do |unloaded| 205 | expect(unloaded).to be false 206 | AutoReloader.force_next_reload if force_reload 207 | thread_started = true 208 | sleep 0.01 209 | executed << 'a' 210 | end 211 | end, 212 | Thread.start do 213 | sleep 0.001 until thread_started 214 | AutoReloader.reload!(onchange: true) do |unloaded| 215 | expect(unloaded).to be force_reload 216 | executed << 'b' 217 | end 218 | end 219 | ].each &:join 220 | end 221 | 222 | it 'does not await when there is no need to unload' do 223 | start_threads force_reload: false 224 | expect(executed).to eq ['b', 'a'] 225 | end 226 | 227 | it 'awaits before unload' do 228 | start_threads force_reload: true 229 | expect(executed).to eq ['a', 'b'] 230 | end 231 | end 232 | end # random order 233 | 234 | # this should run as the last one because it will load some files that won't be reloaded 235 | # due to the change in autoreloadable_paths 236 | context 'with restricted reloadable_paths' do 237 | before do 238 | AutoReloader.reloadable_paths = [File.join(__dir__, 'fixtures', 'lib', 'a')] 239 | end 240 | 241 | it 'respects reloadable_paths' do 242 | expect(defined? ::A::Inner).to be nil 243 | require 'a' 244 | expect(defined? ::A::Inner).to eq 'constant' 245 | AutoReloader.unload! 246 | expect(defined? ::A::Inner).to be nil 247 | # WARNING: one might expect A to remain defined since a.rb is not reloadable but it's 248 | # not possible to detect that automatically so this reloader is supposed to be used with 249 | # a sane files hierarchy. 250 | # Also, since it attempts to be fully transparent, we can't specify options to require 251 | # If there are any compelling real cases were this is causing troubles we may consider 252 | # providing a more specialized reloader to which some would specify which classes should 253 | # not be unloaded, for example. It could be extended through built-in modules for example. 254 | expect(defined? ::B).to eq 'constant' 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /spec/fixtures/lib/a.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require_relative 'b' 3 | require 'a/inner' 4 | 5 | class A 6 | end 7 | -------------------------------------------------------------------------------- /spec/fixtures/lib/a/inner.rb: -------------------------------------------------------------------------------- 1 | class A 2 | class Inner 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/fixtures/lib/b.rb: -------------------------------------------------------------------------------- 1 | require 'c' 2 | class B 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/lib/c.rb: -------------------------------------------------------------------------------- 1 | require 'settings' 2 | 3 | class C 4 | @@count = 0 5 | def self.count 6 | @@count += 1 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/fixtures/lib/raise_exception_on_load.rb: -------------------------------------------------------------------------------- 1 | DEFINED_CONSTANT = 1 2 | 3 | raise Exception.new('protect against all kinds of exceptions') 4 | -------------------------------------------------------------------------------- /spec/fixtures/load_once/settings.rb: -------------------------------------------------------------------------------- 1 | class Settings 2 | end 3 | --------------------------------------------------------------------------------