26

I have a ruby timeout that calls a system (bash) command like this..

Timeout::timeout(10) {
  `my_bash_command -c12 -o text.txt`
}

but I think that even if the ruby thread is interrupted, the actual command keeps running in the background.. is it normal? How can I kill it?

Mladen Jablanović
  • 43,461
  • 10
  • 90
  • 113
luca
  • 12,311
  • 15
  • 70
  • 103
  • That's not true. The sub-shell running the command should terminate when the parent ruby process terminates. Please give a more specific example. – Ben Lee Nov 28 '11 at 06:07
  • 2
    @BenLee: The parent process doesn't terminate when the timeout expires. – Mladen Jablanović Nov 28 '11 at 14:51
  • @MladenJablanović, in a quick experiment it does. I created a ruby file that did nothing but: `require 'timeout'; Timeout::timeout(100) { `sleep 500` }`. While running it, I do `ps aux | grep sleep` and see the sleep process. Then I send SIGKILL to the ruby process, and again run `ps aux | grep sleep` and no longer see the child process. – Ben Lee Nov 28 '11 at 16:37
  • @BenLee: Please reread my comment above. Thanks. – Mladen Jablanović Nov 28 '11 at 16:45
  • Actually Mladen is right, parent process does not terminate when timeout is reached (unless there is nothing more to do after running the command). What happens is that Timeout::Error exception is raised and that is all. The sub-shell is still left running. Even if the parent (ruby) process terminates after the exception is thrown, the shell command started is still running, it just is reassigned from the ruby process to the process with PID 1 as it's child. At least this is what happens on Mac OSX. – Lukasz Korzybski Oct 26 '12 at 11:06

4 Answers4

40

I think you have to kill it manually:

require 'timeout'

puts 'starting process'
pid = Process.spawn('sleep 20')
begin
  Timeout.timeout(5) do
    puts 'waiting for the process to end'
    Process.wait(pid)
    puts 'process finished in time'
  end
rescue Timeout::Error
  puts 'process not finished in time, killing it'
  Process.kill('TERM', pid)
end
Mladen Jablanović
  • 43,461
  • 10
  • 90
  • 113
  • 2
    This is different than the example because use you are using ` Process.spawn`. With that command, the child process *doesn't* terminate when the main process does. It also doesn't halt application execution while waiting for the subprocess to return; it runs it in parallel. But when using backticks (or `exec`), the main process waits for the subprocess to return, and kills the subprocess if the main process is terminated. – Ben Lee Nov 28 '11 at 16:39
  • 1
    Er.. The OP doesn't terminate the main process at all. The problem is whether an exception raised by `timeout` terminates the child process or not (and it doesn't). – Mladen Jablanović Nov 28 '11 at 16:47
  • you're right I misunderstood the OP's question. Changed my vote from downvote to upvote. (initially it wouldn't let me change it saying "Your vote is now locked in unless this answer is edited" so I made a quick edit just adding a single whitespace character, not changing any content). – Ben Lee Nov 28 '11 at 19:38
  • hah, and then it let me undo my edit without reverting my vote. interesting way to game the system, in a sense -- the "lock" isn't real if you can always get around it like that. – Ben Lee Nov 28 '11 at 19:40
  • Thanks. Well I guess it would be needlessly complex to cover such edge voting cases. :) Not sure why they put the lock to begin with... – Mladen Jablanović Nov 28 '11 at 20:21
  • @MladenJablanović Every vote propagates a notification. You can run a script to upvote and then downvote say like 10,000 times. A very easy way to harass a user. If they were sending a mail on upvote. You could bring down their mailer server. :) – CantGetANick May 24 '13 at 02:20
  • @CantGetANick Doesn't seem like a logical place to prevent email flooding, as email can be triggered by many such ways (email sending module seems like a better place IMO). – Mladen Jablanović May 24 '13 at 11:50
  • Please note, this solution doesn't kill the tree, e.g. the child and the grandchild. There will be a grandchild if your command executes in a shell, which happens if you do something like Process.spawn('sleep 1; ls'). In this case the sleep command will not be killed and will continue to run, the child shell only will be killed. The exec_with_timeout solution is superior in that it kills the whole tree. – Jeff McCune Sep 05 '14 at 23:17
  • After the `Process.kill`, you should do a `Process.wait(pid)`. Two reasons: (1) To avoid zombies. (2) To see whether, when and how the process dies. The process might be killed, by the signal, it might suspend itself, it might ignore the signal or it might exit normally with an exit value that you should examine. – hagello Jun 21 '17 at 10:48
11

in order to properly stop spawned process tree (not just the parent process) one should consider something like this:

def exec_with_timeout(cmd, timeout)
  pid = Process.spawn(cmd, {[:err,:out] => :close, :pgroup => true})
  begin
    Timeout.timeout(timeout) do
      Process.waitpid(pid, 0)
      $?.exitstatus == 0
    end
  rescue Timeout::Error
    Process.kill(15, -Process.getpgid(pid))
    false
  end
end

this also allows you to track process status

shurikk
  • 534
  • 7
  • 8
  • Killing the tree is important and code that does it should probably be generally the answer to this question. Two points regarding your solution: Process::kill docs say the _signal_ should be negative to kill a process group (your code has the process group ID as negative). Also, Process::spawn doesn't seem to take code blocks, which makes it less convenient. Still, I think you're going in the right direction. – Ray Jan 06 '15 at 18:50
  • I think that Process.kill(15, -Process.getpgid(pid)) == Process.kill(-15, pid), I don't remember where I read about it (might be wrong of course). Important thing here is :pgroup => true – shurikk May 19 '15 at 20:35
8

Perhaps this will help someone else looking to achieve similar timeout functionality, but needs to collect the output from the shell command.

I've adapted @shurikk's method to work with Ruby 2.0 and some code from Fork child process with timeout and capture output to collect the output.

def exec_with_timeout(cmd, timeout)
  begin
    # stdout, stderr pipes
    rout, wout = IO.pipe
    rerr, werr = IO.pipe
    stdout, stderr = nil

    pid = Process.spawn(cmd, pgroup: true, :out => wout, :err => werr)

    Timeout.timeout(timeout) do
      Process.waitpid(pid)

      # close write ends so we can read from them
      wout.close
      werr.close

      stdout = rout.readlines.join
      stderr = rerr.readlines.join
    end

  rescue Timeout::Error
    Process.kill(-9, pid)
    Process.detach(pid)
  ensure
    wout.close unless wout.closed?
    werr.close unless werr.closed?
    # dispose the read ends of the pipes
    rout.close
    rerr.close
  end
  stdout
 end
Community
  • 1
  • 1
mwalsher
  • 2,790
  • 2
  • 33
  • 41
8

Handling processes, signals and timers is not very easy. That's why you might consider delegating this task: Use the command timeout on new versions of Linux:

timeout --kill-after=5s 10s my_bash_command -c12 -o text.txt
hagello
  • 2,843
  • 2
  • 27
  • 37
  • 1
    on my system the syntax is `timeout `: so for eg `timeout 10s my_bash_command`. The `--kill-after` option, is specific to the amount of time to wait after having send the term signal. Using this option, you still need to specify the original duration : `timeout --kill-after=5s 10s my_bash_command`. – yves Jan 17 '18 at 10:10
  • @yves: _on my system the syntax is timeout _ Well, here the syntax is the same, isn't it? Or do you want to say that `timeout` does not accept any options on your system? – hagello Mar 03 '21 at 10:51