Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions bolt-modules/boltlib/lib/puppet/functions/with_timeout.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

require 'timeout'

# Control the specified block execution timeout.
#
# If code in the block specified is executing longer than timeout speficied,
# then execution is cancelled and 'bolt/execution-expired' kind of an Error is raised.
#
# > **Note:** Not available in apply block
Puppet::Functions.create_function(:with_timeout) do
# @param timeout Timeout in seconds (0 disables timeout).
# @param block The block to control execution timeout for.
# @return [Any] The block return value.
# @example Raise error if the block execution takes longer that 1 minute
# $result = with_timeout(60) || {
# run_task('deploy', $target)
# }
# out::verbose('Deploy is not timed out')
# @example Ensure the block execution takes no longer that 1 minute
# $result = catch_errors(['bolt/execution-expired']) || {
# with_timeout(60) || {
# run_task('deploy', $target)
# }
# }
# if $result =~ Error {
# fail_plan("Deploy task timed out", 'deploy/timed-out')
# } else {
# out::verbose('Deploy is not timed out')
# }
dispatch :with_timeout do
param 'Integer[0]', :timeout
block_param 'Callable[0, 0]', :block
end

def with_timeout(timeout, &)
unless Puppet[:tasks]
raise Puppet::ParseErrorWithIssue
.from_issue_and_stack(Bolt::PAL::Issues::PLAN_OPERATION_NOT_SUPPORTED_WHEN_COMPILING,
action: 'with_timeout')
end

# Send Analytics Report
Puppet.lookup(:bolt_executor).report_function_call(self.class.name)

Timeout.timeout(timeout, &)
Copy link
Copy Markdown
Member

@nmburgan nmburgan Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We perhaps shouldn't use Timeout.timeout. It's considered dangerous. It works by raising Timeout::Error via Thread#raise, which can interrupt execution at any point, including inside ensure blocks, mutex operations, etc. If Timeout::Error fires inside a run_task call's internal ensure block (like while it's closing an SSH connection), the cleanup code gets interrupted and the reraised Bolt::Error masks the real problem.

In order to make this safe, you'd either need to guard a bunch of stuff in the internals of OpenBolt, or perhaps you could do this:

def with_timeout(timeout, &block)
    unless Puppet[:tasks]
      raise Puppet::ParseErrorWithIssue
        .from_issue_and_stack(Bolt::PAL::Issues::PLAN_OPERATION_NOT_SUPPORTED_WHEN_COMPILING,
                              action: 'with_timeout')
    end

    executor = Puppet.lookup(:bolt_executor)
    executor.report_function_call(self.class.name)

    # Each invocation gets a unique exception class so that:
    # 1. Transport code's `rescue => e` (StandardError) won't catch it
    # 2. Nested with_timeout calls won't catch each other's exceptions
    exc_class = Class.new(Exception)

    Thread.handle_interrupt(exc_class => :never) do
      Timeout.timeout(timeout, exc_class) do
        Thread.handle_interrupt(exc_class => :on_blocking) do
          block.call
        end
      end
    end
  rescue exc_class
    raise Bolt::Error.new(
      "Execution in with_timeout() block expired after #{timeout} seconds",
      'bolt/execution-expired',
      details: { timeout: timeout }
    )
  end
end

But I'm not 100% confident this doesn't have edge cases and problems of its own.

rescue Timeout::Error
raise Bolt::Error.new('Execution expired', 'bolt/execution-expired', details: { timeout: timeout })
end
end
Loading