diff --git a/.document b/.document new file mode 100644 index 0000000..5035202 --- /dev/null +++ b/.document @@ -0,0 +1,3 @@ +LICENSE.txt +README.md +lib/ diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..db1d8e9 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,4 @@ +changelog: + exclude: + labels: + - dependencies # Added by Dependabot diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml new file mode 100644 index 0000000..9b94bbd --- /dev/null +++ b/.github/workflows/push_gem.yml @@ -0,0 +1,46 @@ +name: Publish gem to rubygems.org + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + push: + if: github.repository == 'ruby/timeout' + runs-on: ubuntu-latest + + environment: + name: rubygems.org + url: https://rubygems.org/gems/timeout + + permissions: + contents: write + id-token: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + with: + egress-policy: audit + + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + + - name: Set up Ruby + uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 + with: + bundler-cache: true + ruby-version: ruby + + - name: Publish to RubyGems + uses: rubygems/release-gem@1c162a739e8b4cb21a676e97b087e8268d8fc40b # v1.1.2 + + - name: Create GitHub release + run: | + tag_name="$(git describe --tags --abbrev=0)" + gh release create "${tag_name}" --verify-tag --generate-notes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-ruby.yml b/.github/workflows/sync-ruby.yml new file mode 100644 index 0000000..78e94eb --- /dev/null +++ b/.github/workflows/sync-ruby.yml @@ -0,0 +1,33 @@ +name: Sync ruby +on: + push: + branches: [master] +jobs: + sync: + name: Sync ruby + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'ruby' }} + steps: + - uses: actions/checkout@v6 + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: 2060836 + private-key: ${{ secrets.RUBY_SYNC_DEFAULT_GEMS_PRIVATE_KEY }} + owner: ruby + repositories: ruby + + - name: Sync to ruby/ruby + uses: convictional/trigger-workflow-and-wait@v1.6.5 + with: + owner: ruby + repo: ruby + workflow_file_name: sync_default_gems.yml + github_token: ${{ steps.app-token.outputs.token }} + ref: master + client_payload: | + {"gem":"${{ github.event.repository.name }}","before":"${{ github.event.before }}","after":"${{ github.event.after }}"} + propagate_failure: true + wait_interval: 10 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8f391e..a0de3ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,16 +3,28 @@ name: test on: [push, pull_request] jobs: - build: + ruby-versions: + uses: ruby/actions/.github/workflows/ruby_versions.yml@master + with: + engine: cruby-truffleruby + min_version: 2.6 + + test: + needs: ruby-versions name: build (${{ matrix.ruby }} / ${{ matrix.os }}) strategy: fail-fast: false matrix: - ruby: [ '3.1', '3.0', 2.7, head, jruby, truffleruby-head ] - os: [ ubuntu-latest, macos-latest ] + ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }} + os: [ ubuntu-latest, macos-latest, windows-latest ] + exclude: + - os: windows-latest + ruby: truffleruby + - os: windows-latest + ruby: truffleruby-head runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/LICENSE.txt b/BSDL similarity index 83% rename from LICENSE.txt rename to BSDL index a009cae..66d9359 100644 --- a/LICENSE.txt +++ b/BSDL @@ -4,10 +4,10 @@ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. + notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright -notice, this list of conditions and the following disclaimer in the -documentation and/or other materials provided with the distribution. + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..48e5a96 --- /dev/null +++ b/COPYING @@ -0,0 +1,56 @@ +Ruby is copyrighted free software by Yukihiro Matsumoto . +You can redistribute it and/or modify it under either the terms of the +2-clause BSDL (see the file BSDL), or the conditions below: + +1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + +2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a. place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b. use the modified software only within your corporation or + organization. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + +3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a. distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b. accompany the distribution with the machine-readable source of + the software. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + +4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + +5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + +6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. diff --git a/Gemfile b/Gemfile index 3dc2883..ba909e8 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,5 @@ group :development do gem "bundler" gem "rake" gem "test-unit" + gem "test-unit-ruby-core" end diff --git a/README.md b/README.md index db1ad0a..61fe2d3 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,6 @@ Timeout provides a way to auto-terminate a potentially long-running operation if it hasn't finished in a fixed amount of time. -Previous versions didn't use a module for namespacing, however -#timeout is provided for backwards compatibility. You -should prefer Timeout.timeout instead. - ## Installation Add this line to your application's Gemfile: @@ -27,7 +23,7 @@ Or install it yourself as: ```ruby require 'timeout' -status = Timeout::timeout(5) { +status = Timeout.timeout(5) { # Something that should be interrupted if it takes more than 5 seconds... } ``` diff --git a/Rakefile b/Rakefile index 5a7afab..5d512c8 100644 --- a/Rakefile +++ b/Rakefile @@ -7,11 +7,4 @@ Rake::TestTask.new(:test) do |t| t.test_files = FileList["test/**/test_*.rb"] end -task :sync_tool do - require 'fileutils' - FileUtils.cp "../ruby/tool/lib/core_assertions.rb", "./test/lib" - FileUtils.cp "../ruby/tool/lib/envutil.rb", "./test/lib" - FileUtils.cp "../ruby/tool/lib/find_executable.rb", "./test/lib" -end - task :default => :test diff --git a/lib/timeout.rb b/lib/timeout.rb index 7f40baf..5d1f61d 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -4,7 +4,7 @@ # == Synopsis # # require 'timeout' -# status = Timeout::timeout(5) { +# status = Timeout.timeout(5) { # # Something that should be interrupted if it takes more than 5 seconds... # } # @@ -13,49 +13,132 @@ # Timeout provides a way to auto-terminate a potentially long-running # operation if it hasn't finished in a fixed amount of time. # -# Previous versions didn't use a module for namespacing, however -# #timeout is provided for backwards compatibility. You -# should prefer Timeout.timeout instead. -# # == Copyright # # Copyright:: (C) 2000 Network Applied Communication Laboratory, Inc. # Copyright:: (C) 2000 Information-technology Promotion Agency, Japan module Timeout - VERSION = "0.3.1" + # The version + VERSION = "0.6.0" + + # Internal exception raised to when a timeout is triggered. + class ExitException < Exception + def exception(*) # :nodoc: + self + end + end # Raised by Timeout.timeout when the block times out. class Error < RuntimeError - attr_reader :thread - - def self.catch(*args) - exc = new(*args) - exc.instance_variable_set(:@thread, Thread.current) - exc.instance_variable_set(:@catch_value, exc) - ::Kernel.catch(exc) {yield exc} - end - - def exception(*) - # TODO: use Fiber.current to see if self can be thrown - if self.thread == Thread.current - bt = caller - begin - throw(@catch_value, bt) - rescue UncaughtThrowError - end + def self.handle_timeout(message) # :nodoc: + exc = ExitException.new(message) + + begin + yield exc + rescue ExitException => e + raise new(message) if exc.equal?(e) + raise end - super end end # :stopdoc: - CONDVAR = ConditionVariable.new - QUEUE = Queue.new - QUEUE_MUTEX = Mutex.new - TIMEOUT_THREAD_MUTEX = Mutex.new - @timeout_thread = nil - private_constant :CONDVAR, :QUEUE, :QUEUE_MUTEX, :TIMEOUT_THREAD_MUTEX + + # We keep a private reference so that time mocking libraries won't break Timeout. + GET_TIME = Process.method(:clock_gettime) + if defined?(Ractor.make_shareable) + # Ractor.make_shareable(Method) only works on Ruby 4+ + Ractor.make_shareable(GET_TIME) rescue nil + end + private_constant :GET_TIME + + class State + def initialize + @condvar = ConditionVariable.new + @queue = Queue.new + @queue_mutex = Mutex.new + + @timeout_thread = nil + @timeout_thread_mutex = Mutex.new + end + + if defined?(Ractor.store_if_absent) && defined?(Ractor.shareable?) && Ractor.shareable?(GET_TIME) + # Ractor support if + # 1. Ractor.store_if_absent is available + # 2. Method object can be shareable (4.0~) + def self.instance + Ractor.store_if_absent :timeout_gem_state do + State.new + end + end + else + GLOBAL_STATE = State.new + + def self.instance + GLOBAL_STATE + end + end + + def create_timeout_thread + # Threads unexpectedly inherit the interrupt mask: https://github.com/ruby/timeout/issues/41 + # So reset the interrupt mask to the default one for the timeout thread + Thread.handle_interrupt(Object => :immediate) do + watcher = Thread.new do + requests = [] + while true + until @queue.empty? and !requests.empty? # wait to have at least one request + req = @queue.pop + requests << req unless req.done? + end + closest_deadline = requests.min_by(&:deadline).deadline + + now = 0.0 + @queue_mutex.synchronize do + while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and @queue.empty? + @condvar.wait(@queue_mutex, closest_deadline - now) + end + end + + requests.each do |req| + req.interrupt if req.expired?(now) + end + requests.reject!(&:done?) + end + end + + if !watcher.group.enclosed? && (!defined?(Ractor.main?) || Ractor.main?) + ThreadGroup::Default.add(watcher) + end + + watcher.name = "Timeout stdlib thread" + watcher.thread_variable_set(:"\0__detached_thread__", true) + watcher + end + end + + def ensure_timeout_thread_created + unless @timeout_thread&.alive? + # If the Mutex is already owned we are in a signal handler. + # In that case, just return and let the main thread create the Timeout thread. + return if @timeout_thread_mutex.owned? + + Sync.synchronize @timeout_thread_mutex do + unless @timeout_thread&.alive? + @timeout_thread = create_timeout_thread + end + end + end + end + + def add_request(request) + Sync.synchronize @queue_mutex do + @queue << request + @condvar.signal + end + end + end + private_constant :State class Request attr_reader :deadline @@ -70,6 +153,7 @@ def initialize(thread, timeout, exception_class, message) @done = false # protected by @mutex end + # Only called by the timeout thread, so does not need Sync.synchronize def done? @mutex.synchronize do @done @@ -80,6 +164,7 @@ def expired?(now) now >= @deadline end + # Only called by the timeout thread, so does not need Sync.synchronize def interrupt @mutex.synchronize do unless @done @@ -90,78 +175,60 @@ def interrupt end def finished - @mutex.synchronize do + Sync.synchronize @mutex do @done = true end end end private_constant :Request - def self.create_timeout_thread - watcher = Thread.new do - requests = [] - while true - until QUEUE.empty? and !requests.empty? # wait to have at least one request - req = QUEUE.pop - requests << req unless req.done? - end - closest_deadline = requests.min_by(&:deadline).deadline - - now = 0.0 - QUEUE_MUTEX.synchronize do - while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and QUEUE.empty? - CONDVAR.wait(QUEUE_MUTEX, closest_deadline - now) - end - end - - requests.each do |req| - req.interrupt if req.expired?(now) - end - requests.reject!(&:done?) - end - end - ThreadGroup::Default.add(watcher) - watcher.name = "Timeout stdlib thread" - watcher.thread_variable_set(:"\0__detached_thread__", true) - watcher - end - private_class_method :create_timeout_thread - - def self.ensure_timeout_thread_created - unless @timeout_thread and @timeout_thread.alive? - TIMEOUT_THREAD_MUTEX.synchronize do - unless @timeout_thread and @timeout_thread.alive? - @timeout_thread = create_timeout_thread - end + module Sync + # Calls mutex.synchronize(&block) but if that fails on CRuby due to being in a trap handler, + # run mutex.synchronize(&block) in a separate Thread instead. + def self.synchronize(mutex, &block) + begin + mutex.synchronize(&block) + rescue ThreadError => e + raise e unless e.message == "can't be called from trap context" + # Workaround CRuby issue https://bugs.ruby-lang.org/issues/19473 + # which raises on Mutex#synchronize in trap handler. + # It's expensive to create a Thread just for this, + # but better than failing. + Thread.new { + mutex.synchronize(&block) + }.join end end end - - # We keep a private reference so that time mocking libraries won't break - # Timeout. - GET_TIME = Process.method(:clock_gettime) - private_constant :GET_TIME + private_constant :Sync # :startdoc: - # Perform an operation in a block, raising an error if it takes longer than + # Perform an operation in a block, raising an exception if it takes longer than # +sec+ seconds to complete. # - # +sec+:: Number of seconds to wait for the block to terminate. Any number - # may be used, including Floats to specify fractional seconds. A + # +sec+:: Number of seconds to wait for the block to terminate. Any non-negative number + # or nil may be used, including Floats to specify fractional seconds. A # value of 0 or +nil+ will execute the block without any timeout. + # Any negative number will raise an ArgumentError. # +klass+:: Exception Class to raise if the block fails to terminate # in +sec+ seconds. Omitting will use the default, Timeout::Error # +message+:: Error message to raise with Exception Class. # Omitting will use the default, "execution expired" # # Returns the result of the block *if* the block completed before - # +sec+ seconds, otherwise throws an exception, based on the value of +klass+. + # +sec+ seconds, otherwise raises an exception, based on the value of +klass+. + # + # The exception raised to terminate the given block is the given +klass+, or + # Timeout::ExitException if +klass+ is not given. The reason for that behavior + # is that Timeout::Error inherits from RuntimeError and might be caught unexpectedly by `rescue`. + # Timeout::ExitException inherits from Exception so it will only be rescued by `rescue Exception`. + # Note that the Timeout::ExitException is translated to a Timeout::Error once it reaches the Timeout.timeout call, + # so outside that call it will be a Timeout::Error. # - # The exception thrown to terminate the given block cannot be rescued inside - # the block unless +klass+ is given explicitly. However, the block can use - # ensure to prevent the handling of the exception. For that reason, this - # method cannot be relied on to enforce timeouts for untrusted blocks. + # In general, be aware that the code block may rescue the exception, and in such a case not respect the timeout. + # Also, the block can use +ensure+ to prevent the handling of the exception. + # For those reasons, this method cannot be relied on to enforce timeouts for untrusted blocks. # # If a scheduler is defined, it will be used to handle the timeout by invoking # Scheduler#timeout_after. @@ -169,8 +236,48 @@ def self.ensure_timeout_thread_created # Note that this is both a method of module Timeout, so you can include # Timeout into your classes so they have a #timeout method, as well as # a module method, so you can call it directly as Timeout.timeout(). - def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ + # + # ==== Ensuring the exception does not fire inside ensure blocks + # + # When using Timeout.timeout it can be desirable to ensure the timeout exception does not fire inside an +ensure+ block. + # The simplest and best way to do so it to put the Timeout.timeout call inside the body of the begin/ensure/end: + # + # begin + # Timeout.timeout(sec) { some_long_operation } + # ensure + # cleanup # safe, cannot be interrupt by timeout + # end + # + # If that is not feasible, e.g. if there are +ensure+ blocks inside +some_long_operation+, + # they need to not be interrupted by timeout, and it's not possible to move these ensure blocks outside, + # one can use Thread.handle_interrupt to delay the timeout exception like so: + # + # Thread.handle_interrupt(Timeout::Error => :never) { + # Timeout.timeout(sec, Timeout::Error) do + # setup # timeout cannot happen here, no matter how long it takes + # Thread.handle_interrupt(Timeout::Error => :immediate) { + # some_long_operation # timeout can happen here + # } + # ensure + # cleanup # timeout cannot happen here, no matter how long it takes + # end + # } + # + # An important thing to note is the need to pass an exception klass to Timeout.timeout, + # otherwise it does not work. Specifically, using +Thread.handle_interrupt(Timeout::ExitException => ...)+ + # is unsupported and causes subtle errors like raising the wrong exception outside the block, do not use that. + # + # Note that Thread.handle_interrupt is somewhat dangerous because if setup or cleanup hangs + # then the current thread will hang too and the timeout will never fire. + # Also note the block might run for longer than +sec+ seconds: + # e.g. some_long_operation executes for +sec+ seconds + whatever time cleanup takes. + # + # If you want the timeout to only happen on blocking operations one can use :on_blocking + # instead of :immediate. However, that means if the block uses no blocking operations after +sec+ seconds, + # the block will not be interrupted. + def self.timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ return yield(sec) if sec == nil or sec.zero? + raise ArgumentError, "Timeout sec must be a non-negative number" if 0 > sec message ||= "execution expired" @@ -178,13 +285,12 @@ def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ return scheduler.timeout_after(sec, klass || Error, message, &block) end - Timeout.ensure_timeout_thread_created + state = State.instance + state.ensure_timeout_thread_created + perform = Proc.new do |exc| request = Request.new(Thread.current, sec, exc, message) - QUEUE_MUTEX.synchronize do - QUEUE << request - CONDVAR.signal - end + state.add_request(request) begin return yield(sec) ensure @@ -195,9 +301,11 @@ def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ if klass perform.call(klass) else - backtrace = Error.catch(&perform) - raise Error, message, backtrace + Error.handle_timeout(message, &perform) end end - module_function :timeout + + private def timeout(*args, &block) + Timeout.timeout(*args, &block) + end end diff --git a/test/lib/core_assertions.rb b/test/lib/core_assertions.rb deleted file mode 100644 index bac3856..0000000 --- a/test/lib/core_assertions.rb +++ /dev/null @@ -1,768 +0,0 @@ -# frozen_string_literal: true - -module Test - module Unit - module Assertions - def _assertions= n # :nodoc: - @_assertions = n - end - - def _assertions # :nodoc: - @_assertions ||= 0 - end - - ## - # Returns a proc that will output +msg+ along with the default message. - - def message msg = nil, ending = nil, &default - proc { - msg = msg.call.chomp(".") if Proc === msg - custom_message = "#{msg}.\n" unless msg.nil? or msg.to_s.empty? - "#{custom_message}#{default.call}#{ending || "."}" - } - end - end - - module CoreAssertions - require_relative 'envutil' - require 'pp' - - def mu_pp(obj) #:nodoc: - obj.pretty_inspect.chomp - end - - def assert_file - AssertFile - end - - FailDesc = proc do |status, message = "", out = ""| - now = Time.now - proc do - EnvUtil.failure_description(status, now, message, out) - end - end - - def assert_in_out_err(args, test_stdin = "", test_stdout = [], test_stderr = [], message = nil, - success: nil, **opt) - args = Array(args).dup - args.insert((Hash === args[0] ? 1 : 0), '--disable=gems') - stdout, stderr, status = EnvUtil.invoke_ruby(args, test_stdin, true, true, **opt) - desc = FailDesc[status, message, stderr] - if block_given? - raise "test_stdout ignored, use block only or without block" if test_stdout != [] - raise "test_stderr ignored, use block only or without block" if test_stderr != [] - yield(stdout.lines.map {|l| l.chomp }, stderr.lines.map {|l| l.chomp }, status) - else - all_assertions(desc) do |a| - [["stdout", test_stdout, stdout], ["stderr", test_stderr, stderr]].each do |key, exp, act| - a.for(key) do - if exp.is_a?(Regexp) - assert_match(exp, act) - elsif exp.all? {|e| String === e} - assert_equal(exp, act.lines.map {|l| l.chomp }) - else - assert_pattern_list(exp, act) - end - end - end - unless success.nil? - a.for("success?") do - if success - assert_predicate(status, :success?) - else - assert_not_predicate(status, :success?) - end - end - end - end - status - end - end - - if defined?(RubyVM::InstructionSequence) - def syntax_check(code, fname, line) - code = code.dup.force_encoding(Encoding::UTF_8) - RubyVM::InstructionSequence.compile(code, fname, fname, line) - :ok - ensure - raise if SyntaxError === $! - end - else - def syntax_check(code, fname, line) - code = code.b - code.sub!(/\A(?:\xef\xbb\xbf)?(\s*\#.*$)*(\n)?/n) { - "#$&#{"\n" if $1 && !$2}BEGIN{throw tag, :ok}\n" - } - code = code.force_encoding(Encoding::UTF_8) - catch {|tag| eval(code, binding, fname, line - 1)} - end - end - - def assert_no_memory_leak(args, prepare, code, message=nil, limit: 2.0, rss: false, **opt) - # TODO: consider choosing some appropriate limit for MJIT and stop skipping this once it does not randomly fail - pend 'assert_no_memory_leak may consider MJIT memory usage as leak' if defined?(RubyVM::MJIT) && RubyVM::MJIT.enabled? - - require_relative 'memory_status' - raise Test::Unit::PendedError, "unsupported platform" unless defined?(Memory::Status) - - token = "\e[7;1m#{$$.to_s}:#{Time.now.strftime('%s.%L')}:#{rand(0x10000).to_s(16)}:\e[m" - token_dump = token.dump - token_re = Regexp.quote(token) - envs = args.shift if Array === args and Hash === args.first - args = [ - "--disable=gems", - "-r", File.expand_path("../memory_status", __FILE__), - *args, - "-v", "-", - ] - if defined? Memory::NO_MEMORY_LEAK_ENVS then - envs ||= {} - newenvs = envs.merge(Memory::NO_MEMORY_LEAK_ENVS) { |_, _, _| break } - envs = newenvs if newenvs - end - args.unshift(envs) if envs - cmd = [ - 'END {STDERR.puts '"#{token_dump}"'"FINAL=#{Memory::Status.new}"}', - prepare, - 'STDERR.puts('"#{token_dump}"'"START=#{$initial_status = Memory::Status.new}")', - '$initial_size = $initial_status.size', - code, - 'GC.start', - ].join("\n") - _, err, status = EnvUtil.invoke_ruby(args, cmd, true, true, **opt) - before = err.sub!(/^#{token_re}START=(\{.*\})\n/, '') && Memory::Status.parse($1) - after = err.sub!(/^#{token_re}FINAL=(\{.*\})\n/, '') && Memory::Status.parse($1) - assert(status.success?, FailDesc[status, message, err]) - ([:size, (rss && :rss)] & after.members).each do |n| - b = before[n] - a = after[n] - next unless a > 0 and b > 0 - assert_operator(a.fdiv(b), :<, limit, message(message) {"#{n}: #{b} => #{a}"}) - end - rescue LoadError - pend - end - - # :call-seq: - # assert_nothing_raised( *args, &block ) - # - #If any exceptions are given as arguments, the assertion will - #fail if one of those exceptions are raised. Otherwise, the test fails - #if any exceptions are raised. - # - #The final argument may be a failure message. - # - # assert_nothing_raised RuntimeError do - # raise Exception #Assertion passes, Exception is not a RuntimeError - # end - # - # assert_nothing_raised do - # raise Exception #Assertion fails - # end - def assert_nothing_raised(*args) - self._assertions += 1 - if Module === args.last - msg = nil - else - msg = args.pop - end - begin - line = __LINE__; yield - rescue Test::Unit::PendedError - raise - rescue Exception => e - bt = e.backtrace - as = e.instance_of?(Test::Unit::AssertionFailedError) - if as - ans = /\A#{Regexp.quote(__FILE__)}:#{line}:in /o - bt.reject! {|ln| ans =~ ln} - end - if ((args.empty? && !as) || - args.any? {|a| a.instance_of?(Module) ? e.is_a?(a) : e.class == a }) - msg = message(msg) { - "Exception raised:\n<#{mu_pp(e)}>\n" + - "Backtrace:\n" + - e.backtrace.map{|frame| " #{frame}"}.join("\n") - } - raise Test::Unit::AssertionFailedError, msg.call, bt - else - raise - end - end - end - - def prepare_syntax_check(code, fname = nil, mesg = nil, verbose: nil) - fname ||= caller_locations(2, 1)[0] - mesg ||= fname.to_s - verbose, $VERBOSE = $VERBOSE, verbose - case - when Array === fname - fname, line = *fname - when defined?(fname.path) && defined?(fname.lineno) - fname, line = fname.path, fname.lineno - else - line = 1 - end - yield(code, fname, line, message(mesg) { - if code.end_with?("\n") - "```\n#{code}```\n" - else - "```\n#{code}\n```\n""no-newline" - end - }) - ensure - $VERBOSE = verbose - end - - def assert_valid_syntax(code, *args, **opt) - prepare_syntax_check(code, *args, **opt) do |src, fname, line, mesg| - yield if defined?(yield) - assert_nothing_raised(SyntaxError, mesg) do - assert_equal(:ok, syntax_check(src, fname, line), mesg) - end - end - end - - def assert_normal_exit(testsrc, message = '', child_env: nil, **opt) - assert_valid_syntax(testsrc, caller_locations(1, 1)[0]) - if child_env - child_env = [child_env] - else - child_env = [] - end - out, _, status = EnvUtil.invoke_ruby(child_env + %W'-W0', testsrc, true, :merge_to_stdout, **opt) - assert !status.signaled?, FailDesc[status, message, out] - end - - def assert_ruby_status(args, test_stdin="", message=nil, **opt) - out, _, status = EnvUtil.invoke_ruby(args, test_stdin, true, :merge_to_stdout, **opt) - desc = FailDesc[status, message, out] - assert(!status.signaled?, desc) - message ||= "ruby exit status is not success:" - assert(status.success?, desc) - end - - ABORT_SIGNALS = Signal.list.values_at(*%w"ILL ABRT BUS SEGV TERM") - - def separated_runner(out = nil) - include(*Test::Unit::TestCase.ancestors.select {|c| !c.is_a?(Class) }) - out = out ? IO.new(out, 'w') : STDOUT - at_exit { - out.puts [Marshal.dump($!)].pack('m'), "assertions=#{self._assertions}" - } - Test::Unit::Runner.class_variable_set(:@@stop_auto_run, true) if defined?(Test::Unit::Runner) - end - - def assert_separately(args, file = nil, line = nil, src, ignore_stderr: nil, **opt) - unless file and line - loc, = caller_locations(1,1) - file ||= loc.path - line ||= loc.lineno - end - capture_stdout = true - unless /mswin|mingw/ =~ RUBY_PLATFORM - capture_stdout = false - opt[:out] = Test::Unit::Runner.output if defined?(Test::Unit::Runner) - res_p, res_c = IO.pipe - opt[:ios] = [res_c] - end - src = < marshal_error - ignore_stderr = nil - res = nil - end - if res and !(SystemExit === res) - if bt = res.backtrace - bt.each do |l| - l.sub!(/\A-:(\d+)/){"#{file}:#{line + $1.to_i}"} - end - bt.concat(caller) - else - res.set_backtrace(caller) - end - raise res - end - - # really is it succeed? - unless ignore_stderr - # the body of assert_separately must not output anything to detect error - assert(stderr.empty?, FailDesc[status, "assert_separately failed with error message", stderr]) - end - assert(status.success?, FailDesc[status, "assert_separately failed", stderr]) - raise marshal_error if marshal_error - end - - # Run Ractor-related test without influencing the main test suite - def assert_ractor(src, args: [], require: nil, require_relative: nil, file: nil, line: nil, ignore_stderr: nil, **opt) - return unless defined?(Ractor) - - require = "require #{require.inspect}" if require - if require_relative - dir = File.dirname(caller_locations[0,1][0].absolute_path) - full_path = File.expand_path(require_relative, dir) - require = "#{require}; require #{full_path.inspect}" - end - - assert_separately(args, file, line, <<~RUBY, ignore_stderr: ignore_stderr, **opt) - #{require} - previous_verbose = $VERBOSE - $VERBOSE = nil - Ractor.new {} # trigger initial warning - $VERBOSE = previous_verbose - #{src} - RUBY - end - - # :call-seq: - # assert_throw( tag, failure_message = nil, &block ) - # - #Fails unless the given block throws +tag+, returns the caught - #value otherwise. - # - #An optional failure message may be provided as the final argument. - # - # tag = Object.new - # assert_throw(tag, "#{tag} was not thrown!") do - # throw tag - # end - def assert_throw(tag, msg = nil) - ret = catch(tag) do - begin - yield(tag) - rescue UncaughtThrowError => e - thrown = e.tag - end - msg = message(msg) { - "Expected #{mu_pp(tag)} to have been thrown"\ - "#{%Q[, not #{thrown}] if thrown}" - } - assert(false, msg) - end - assert(true) - ret - end - - # :call-seq: - # assert_raise( *args, &block ) - # - #Tests if the given block raises an exception. Acceptable exception - #types may be given as optional arguments. If the last argument is a - #String, it will be used as the error message. - # - # assert_raise do #Fails, no Exceptions are raised - # end - # - # assert_raise NameError do - # puts x #Raises NameError, so assertion succeeds - # end - def assert_raise(*exp, &b) - case exp.last - when String, Proc - msg = exp.pop - end - - begin - yield - rescue Test::Unit::PendedError => e - return e if exp.include? Test::Unit::PendedError - raise e - rescue Exception => e - expected = exp.any? { |ex| - if ex.instance_of? Module then - e.kind_of? ex - else - e.instance_of? ex - end - } - - assert expected, proc { - flunk(message(msg) {"#{mu_pp(exp)} exception expected, not #{mu_pp(e)}"}) - } - - return e - ensure - unless e - exp = exp.first if exp.size == 1 - - flunk(message(msg) {"#{mu_pp(exp)} expected but nothing was raised"}) - end - end - end - - # :call-seq: - # assert_raise_with_message(exception, expected, msg = nil, &block) - # - #Tests if the given block raises an exception with the expected - #message. - # - # assert_raise_with_message(RuntimeError, "foo") do - # nil #Fails, no Exceptions are raised - # end - # - # assert_raise_with_message(RuntimeError, "foo") do - # raise ArgumentError, "foo" #Fails, different Exception is raised - # end - # - # assert_raise_with_message(RuntimeError, "foo") do - # raise "bar" #Fails, RuntimeError is raised but the message differs - # end - # - # assert_raise_with_message(RuntimeError, "foo") do - # raise "foo" #Raises RuntimeError with the message, so assertion succeeds - # end - def assert_raise_with_message(exception, expected, msg = nil, &block) - case expected - when String - assert = :assert_equal - when Regexp - assert = :assert_match - else - raise TypeError, "Expected #{expected.inspect} to be a kind of String or Regexp, not #{expected.class}" - end - - ex = m = nil - EnvUtil.with_default_internal(expected.encoding) do - ex = assert_raise(exception, msg || proc {"Exception(#{exception}) with message matches to #{expected.inspect}"}) do - yield - end - m = ex.message - end - msg = message(msg, "") {"Expected Exception(#{exception}) was raised, but the message doesn't match"} - - if assert == :assert_equal - assert_equal(expected, m, msg) - else - msg = message(msg) { "Expected #{mu_pp expected} to match #{mu_pp m}" } - assert expected =~ m, msg - block.binding.eval("proc{|_|$~=_}").call($~) - end - ex - end - - MINI_DIR = File.join(File.dirname(File.expand_path(__FILE__)), "minitest") #:nodoc: - - # :call-seq: - # assert(test, [failure_message]) - # - #Tests if +test+ is true. - # - #+msg+ may be a String or a Proc. If +msg+ is a String, it will be used - #as the failure message. Otherwise, the result of calling +msg+ will be - #used as the message if the assertion fails. - # - #If no +msg+ is given, a default message will be used. - # - # assert(false, "This was expected to be true") - def assert(test, *msgs) - case msg = msgs.first - when String, Proc - when nil - msgs.shift - else - bt = caller.reject { |s| s.start_with?(MINI_DIR) } - raise ArgumentError, "assertion message must be String or Proc, but #{msg.class} was given.", bt - end unless msgs.empty? - super - end - - # :call-seq: - # assert_respond_to( object, method, failure_message = nil ) - # - #Tests if the given Object responds to +method+. - # - #An optional failure message may be provided as the final argument. - # - # assert_respond_to("hello", :reverse) #Succeeds - # assert_respond_to("hello", :does_not_exist) #Fails - def assert_respond_to(obj, (meth, *priv), msg = nil) - unless priv.empty? - msg = message(msg) { - "Expected #{mu_pp(obj)} (#{obj.class}) to respond to ##{meth}#{" privately" if priv[0]}" - } - return assert obj.respond_to?(meth, *priv), msg - end - #get rid of overcounting - if caller_locations(1, 1)[0].path.start_with?(MINI_DIR) - return if obj.respond_to?(meth) - end - super(obj, meth, msg) - end - - # :call-seq: - # assert_not_respond_to( object, method, failure_message = nil ) - # - #Tests if the given Object does not respond to +method+. - # - #An optional failure message may be provided as the final argument. - # - # assert_not_respond_to("hello", :reverse) #Fails - # assert_not_respond_to("hello", :does_not_exist) #Succeeds - def assert_not_respond_to(obj, (meth, *priv), msg = nil) - unless priv.empty? - msg = message(msg) { - "Expected #{mu_pp(obj)} (#{obj.class}) to not respond to ##{meth}#{" privately" if priv[0]}" - } - return assert !obj.respond_to?(meth, *priv), msg - end - #get rid of overcounting - if caller_locations(1, 1)[0].path.start_with?(MINI_DIR) - return unless obj.respond_to?(meth) - end - refute_respond_to(obj, meth, msg) - end - - # pattern_list is an array which contains regexp and :*. - # :* means any sequence. - # - # pattern_list is anchored. - # Use [:*, regexp, :*] for non-anchored match. - def assert_pattern_list(pattern_list, actual, message=nil) - rest = actual - anchored = true - pattern_list.each_with_index {|pattern, i| - if pattern == :* - anchored = false - else - if anchored - match = /\A#{pattern}/.match(rest) - else - match = pattern.match(rest) - end - unless match - msg = message(msg) { - expect_msg = "Expected #{mu_pp pattern}\n" - if /\n[^\n]/ =~ rest - actual_mesg = +"to match\n" - rest.scan(/.*\n+/) { - actual_mesg << ' ' << $&.inspect << "+\n" - } - actual_mesg.sub!(/\+\n\z/, '') - else - actual_mesg = "to match " + mu_pp(rest) - end - actual_mesg << "\nafter #{i} patterns with #{actual.length - rest.length} characters" - expect_msg + actual_mesg - } - assert false, msg - end - rest = match.post_match - anchored = true - end - } - if anchored - assert_equal("", rest) - end - end - - def assert_warning(pat, msg = nil) - result = nil - stderr = EnvUtil.with_default_internal(pat.encoding) { - EnvUtil.verbose_warning { - result = yield - } - } - msg = message(msg) {diff pat, stderr} - assert(pat === stderr, msg) - result - end - - def assert_warn(*args) - assert_warning(*args) {$VERBOSE = false; yield} - end - - def assert_deprecated_warning(mesg = /deprecated/) - assert_warning(mesg) do - Warning[:deprecated] = true - yield - end - end - - def assert_deprecated_warn(mesg = /deprecated/) - assert_warn(mesg) do - Warning[:deprecated] = true - yield - end - end - - class << (AssertFile = Struct.new(:failure_message).new) - include Assertions - include CoreAssertions - def assert_file_predicate(predicate, *args) - if /\Anot_/ =~ predicate - predicate = $' - neg = " not" - end - result = File.__send__(predicate, *args) - result = !result if neg - mesg = "Expected file ".dup << args.shift.inspect - mesg << "#{neg} to be #{predicate}" - mesg << mu_pp(args).sub(/\A\[(.*)\]\z/m, '(\1)') unless args.empty? - mesg << " #{failure_message}" if failure_message - assert(result, mesg) - end - alias method_missing assert_file_predicate - - def for(message) - clone.tap {|a| a.failure_message = message} - end - end - - class AllFailures - attr_reader :failures - - def initialize - @count = 0 - @failures = {} - end - - def for(key) - @count += 1 - yield - rescue Exception => e - @failures[key] = [@count, e] - end - - def foreach(*keys) - keys.each do |key| - @count += 1 - begin - yield key - rescue Exception => e - @failures[key] = [@count, e] - end - end - end - - def message - i = 0 - total = @count.to_s - fmt = "%#{total.size}d" - @failures.map {|k, (n, v)| - v = v.message - "\n#{i+=1}. [#{fmt%n}/#{total}] Assertion for #{k.inspect}\n#{v.b.gsub(/^/, ' | ').force_encoding(v.encoding)}" - }.join("\n") - end - - def pass? - @failures.empty? - end - end - - # threads should respond to shift method. - # Array can be used. - def assert_join_threads(threads, message = nil) - errs = [] - values = [] - while th = threads.shift - begin - values << th.value - rescue Exception - errs << [th, $!] - th = nil - end - end - values - ensure - if th&.alive? - th.raise(Timeout::Error.new) - th.join rescue errs << [th, $!] - end - if !errs.empty? - msg = "exceptions on #{errs.length} threads:\n" + - errs.map {|t, err| - "#{t.inspect}:\n" + - RUBY_VERSION >= "2.5.0" ? err.full_message(highlight: false, order: :top) : err.message - }.join("\n---\n") - if message - msg = "#{message}\n#{msg}" - end - raise Test::Unit::AssertionFailedError, msg - end - end - - def assert_all?(obj, m = nil, &blk) - failed = [] - obj.each do |*a, &b| - unless blk.call(*a, &b) - failed << (a.size > 1 ? a : a[0]) - end - end - assert(failed.empty?, message(m) {failed.pretty_inspect}) - end - - def assert_all_assertions(msg = nil) - all = AllFailures.new - yield all - ensure - assert(all.pass?, message(msg) {all.message.chomp(".")}) - end - alias all_assertions assert_all_assertions - - def assert_all_assertions_foreach(msg = nil, *keys, &block) - all = AllFailures.new - all.foreach(*keys, &block) - ensure - assert(all.pass?, message(msg) {all.message.chomp(".")}) - end - alias all_assertions_foreach assert_all_assertions_foreach - - def message(msg = nil, *args, &default) # :nodoc: - if Proc === msg - super(nil, *args) do - ary = [msg.call, (default.call if default)].compact.reject(&:empty?) - if 1 < ary.length - ary[0...-1] = ary[0...-1].map {|str| str.sub(/(? e + assert e.class == MyNewErrorOuter + end + end + + # DOES fail with + # - raise new(message) if exc.equal?(e) + # + raise new(message) if exc.class == e.class + def test_nested_timeout_which_error_bubbles_up + raised_exception = nil + begin + Timeout.timeout(0.1) { + Timeout.timeout(1) { + raise Timeout::ExitException.new("inner message") + } + } + rescue Exception => e + raised_exception = e + end + + assert_equal 'inner message', raised_exception.message + end + def test_cannot_convert_into_time_interval bug3168 = '[ruby-dev:41010]' def (n = Object.new).zero?; false; end - assert_raise(TypeError, bug3168) {Timeout.timeout(n) { sleep 0.1 }} + assert_raise(ArgumentError, bug3168) {Timeout.timeout(n) { sleep 0.1 }} end - def test_skip_rescue - bug8730 = '[Bug #8730]' + def test_skip_rescue_standarderror e = nil - assert_raise_with_message(Timeout::Error, /execution expired/, bug8730) do + assert_raise_with_message(Timeout::Error, /execution expired/) do Timeout.timeout 0.01 do begin sleep 3 - rescue Exception => e + rescue => e flunk "should not see any exception but saw #{e.inspect}" end end end - assert_nil(e, bug8730) + end + + def test_raises_exception_internally + e = nil + assert_raise_with_message(Timeout::Error, /execution expired/) do + Timeout.timeout 0.01 do + begin + sleep 3 + rescue Exception => exc + e = exc + raise + end + end + end + assert_equal Timeout::ExitException, e.class end def test_rescue_exit @@ -123,16 +235,17 @@ def o.each end end - def test_handle_interrupt + def test_handle_interrupt_with_exception_class bug11344 = '[ruby-dev:49179] [Bug #11344]' ok = false assert_raise(Timeout::Error) { Thread.handle_interrupt(Timeout::Error => :never) { - Timeout.timeout(0.01) { + Timeout.timeout(0.01, Timeout::Error) { sleep 0.2 ok = true Thread.handle_interrupt(Timeout::Error => :on_blocking) { sleep 0.2 + raise "unreachable" } } } @@ -140,6 +253,112 @@ def test_handle_interrupt assert(ok, bug11344) end + def test_handle_interrupt + bug11344 = '[ruby-dev:49179] [Bug #11344]' + ok = false + assert_raise(Timeout::Error) { + Thread.handle_interrupt(Timeout::ExitException => :never) { + Timeout.timeout(0.01) { + sleep 0.2 + ok = true + Thread.handle_interrupt(Timeout::ExitException => :on_blocking) { + sleep 0.2 + raise "unreachable" + } + } + } + } + assert(ok, bug11344) + end + + def test_handle_interrupt_with_interrupt_mask_inheritance + issue = 'https://github.com/ruby/timeout/issues/41' + + [ + -> {}, # not blocking so no opportunity to interrupt + -> { sleep 5 } + ].each_with_index do |body, idx| + # We need to create a new Timeout thread + kill_timeout_thread + + # Create the timeout thread under a handle_interrupt(:never) + # due to the interrupt mask being inherited + Thread.handle_interrupt(Object => :never) { + assert_equal :ok, Timeout.timeout(1) { :ok } + } + + # Ensure a simple timeout works and the interrupt mask was not inherited + assert_raise(Timeout::Error) { + Timeout.timeout(0.001) { sleep 1 } + } + + r = [] + # This raises Timeout::ExitException and not Timeout::Error for the non-blocking body + # because of the handle_interrupt(:never) which delays raising Timeout::ExitException + # on the main thread until getting outside of that handle_interrupt(:never) call. + # For this reason we document handle_interrupt(Timeout::ExitException) should not be used. + exc = idx == 0 ? Timeout::ExitException : Timeout::Error + assert_raise(exc) { + Thread.handle_interrupt(Timeout::ExitException => :never) { + Timeout.timeout(0.1) do + sleep 0.2 + r << :sleep_before_done + Thread.handle_interrupt(Timeout::ExitException => :on_blocking) { + r << :body + body.call + } + ensure + sleep 0.2 + r << :ensure_sleep_done + end + } + } + assert_equal([:sleep_before_done, :body, :ensure_sleep_done], r, issue) + end + end + + # Same as above but with an exception class + def test_handle_interrupt_with_interrupt_mask_inheritance_with_exception_class + issue = 'https://github.com/ruby/timeout/issues/41' + + [ + -> {}, # not blocking so no opportunity to interrupt + -> { sleep 5 } + ].each do |body| + # We need to create a new Timeout thread + kill_timeout_thread + + # Create the timeout thread under a handle_interrupt(:never) + # due to the interrupt mask being inherited + Thread.handle_interrupt(Object => :never) { + assert_equal :ok, Timeout.timeout(1) { :ok } + } + + # Ensure a simple timeout works and the interrupt mask was not inherited + assert_raise(Timeout::Error) { + Timeout.timeout(0.001) { sleep 1 } + } + + r = [] + assert_raise(Timeout::Error) { + Thread.handle_interrupt(Timeout::Error => :never) { + Timeout.timeout(0.1, Timeout::Error) do + sleep 0.2 + r << :sleep_before_done + Thread.handle_interrupt(Timeout::Error => :on_blocking) { + r << :body + body.call + } + ensure + sleep 0.2 + r << :ensure_sleep_done + end + } + } + assert_equal([:sleep_before_done, :body, :ensure_sleep_done], r, issue) + end + end + def test_fork omit 'fork not supported' unless Process.respond_to?(:fork) r, w = IO.pipe @@ -172,4 +391,72 @@ def test_threadgroup end; end + # https://github.com/ruby/timeout/issues/24 + def test_handling_enclosed_threadgroup + assert_separately(%w[-rtimeout], <<-'end;') + Thread.new { + t = Thread.current + group = ThreadGroup.new + group.add(t) + group.enclose + + assert_equal 42, Timeout.timeout(1) { 42 } + }.join + end; + end + + def test_ractor + assert_separately(%w[-rtimeout -W0], <<-'end;') + r = Ractor.new do + Timeout.timeout(1) { 42 } + end.value + + assert_equal 42, r + + r = Ractor.new do + begin + Timeout.timeout(0.1) { sleep } + rescue Timeout::Error + :ok + end + end.value + + assert_equal :ok, r + end; + end if defined?(::Ractor) && RUBY_VERSION >= '4.0' + + def test_timeout_in_trap_handler + # https://github.com/ruby/timeout/issues/17 + + # Test as if this was the first timeout usage + kill_timeout_thread + + rd, wr = IO.pipe + + signal = :TERM + + original_handler = trap(signal) do + begin + Timeout.timeout(0.1) do + sleep 1 + end + rescue Timeout::Error + wr.write "OK" + wr.close + else + wr.write "did not raise" + ensure + wr.close + end + end + + begin + Process.kill signal, Process.pid + + assert_equal "OK", rd.read + rd.close + ensure + trap(signal, original_handler) + end + end end diff --git a/timeout.gemspec b/timeout.gemspec index 7449ae1..5449494 100644 --- a/timeout.gemspec +++ b/timeout.gemspec @@ -1,7 +1,7 @@ # frozen_string_literal: true name = File.basename(__FILE__, ".gemspec") -version = ["lib", Array.new(name.count("-")+1, "..").join("/")].find do |dir| +version = ["lib", Array.new(name.count("-")+1, ".").join("/")].find do |dir| break File.foreach(File.join(__dir__, dir, "#{name.tr('-', '/')}.rb")) do |line| /^\s*VERSION\s*=\s*"(.*)"/ =~ line and break $1 end rescue nil @@ -20,6 +20,9 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = spec.homepage + "/releases" + + spec.required_ruby_version = '>= 2.6.0' spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f|