Project

General

Profile

Actions

Bug #20089

open

Fiber#kill transfers to root fiber

Added by rmosolgo (Robert Mosolgo) 4 months ago. Updated 10 days ago.

Status:
Open
Target version:
-
ruby -v:
ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [x86_64-darwin22]
[ruby-core:115911]

Description

I was hoping to use Fiber#kill to clean up formerly .transfer-d Fibers and work around https://bugs.ruby-lang.org/issues/20081, but I found that Fiber#kill has a similar control flow jump behavior. Is this on purpose, or a bug?

Here's a script to test the behavior:

manager = Fiber.new do
  worker = Fiber.new do
    puts "2. Begin Worker"
    manager.transfer
    puts "This should never print -- killed"
  end

  puts "1. Transfer to Worker"
  worker.transfer
  puts "3. Killing Worker"
  worker.kill
  puts "4. Finished manager"
end

manager.transfer
puts "5. Finished script"

I expected items 1 through 5 to be printed in order, but in fact, 4 is never printed:

$ ruby fiber_transfer_test.rb
1. Transfer to Worker
2. Begin Worker
3. Killing Worker
5. Finished script

It seems like worker.kill is transferring control to the top-level fiber instead of giving it back to manager.

I also tried having the thread kill itself, hoping it would return to the fiber that originally .transfered to it, but it also seems to jump out:

manager = Fiber.new do
  worker = Fiber.new do
    puts "2. Begin Worker"
    manager.transfer
    Fiber.current.kill
    puts "This should never print -- killed"
  end

  puts "1. Transfer to Worker"
  worker.transfer
  puts "3. Killing Worker"
  worker.transfer
  puts "4. Finished manager"
end

manager.transfer
puts "5. Finished script"

Prints:

1. Transfer to Worker
2. Begin Worker
3. Killing Worker
5. Finished script

Related issues 1 (0 open1 closed)

Related to Ruby master - Bug #20414: `Fiber#raise` should recurse to `resumed_fiber` rather than failing.Closedioquatix (Samuel Williams)Actions

Updated by ioquatix (Samuel Williams) 4 months ago

Transfer is uni-directional and keeps no state about the caller. It's up to the caller to implement its own control flow if preferred. It's a low level operation which must be used more carefully.

manager = Fiber.new do
  worker = Fiber.new do
    puts "2. Begin Worker"
    manager.transfer
    Fiber.current.kill
    puts "This should never print -- killed"
  ensure
    manager.transfer
  end

  puts "1. Transfer to Worker"
  worker.transfer
  puts "3. Killing Worker"
  worker.transfer
  puts "4. Finished manager"
end

manager.transfer
puts "5. Finished script"

By explicitly adding the flow control, we can achieve your desired output. Hope this helps.

Updated by rmosolgo (Robert Mosolgo) 4 months ago

That definitely makes sense for a Fiber killing itself, but would you say that killing a different Fiber should cause a fiber to transfer away? In my first script above, calling worker.kill causes the manager fiber to transfer.

I looked a little deeper, it looks like this only happens when both Fibers have been started with .transfer. Here's a scenario with two different Fibers, one killing the other, and only the .transfer/.transfer case exits early:

puts "\n\nResume/Transfer"
fiber1 = Fiber.new {
  puts "1. Fiber1 Runs"
  fiber2 = Fiber.new {
    puts "2. Fiber2 Runs"
    fiber1.transfer
    puts "Never: Fiber2 is killed"
  }
  fiber2.transfer
  fiber2.kill
  puts "3. Fiber1 finishes"
}
fiber1.resume
puts "4. Exit"
# Resume/Transfer
# 1. Fiber1 Runs
# 2. Fiber2 Runs
# 3. Fiber1 finishes
# 4. Exit

puts "\n\nTransfer/Resume"
fiber1 = Fiber.new {
  puts "1. Fiber1 Runs"
  fiber2 = Fiber.new {
    puts "2. Fiber2 Runs"
    Fiber.yield
    puts "Never: Fiber2 is killed"
  }
  fiber2.resume
  fiber2.kill
  puts "3. Fiber1 finishes"
}
fiber1.transfer
puts "4. Exit"
# Transfer/Resume
# 1. Fiber1 Runs
# 2. Fiber2 Runs
# 3. Fiber1 finishes
# 4. Exit

puts "\n\nResume/Resume"
fiber1 = Fiber.new {
  puts "1. Fiber1 Runs"
  fiber2 = Fiber.new {
    puts "2. Fiber2 Runs"
    Fiber.yield
    puts "Never: Fiber2 is killed"
  }
  fiber2.resume
  fiber2.kill
  puts "3. Fiber1 finishes"
}
fiber1.resume
puts "4. Exit"
# Resume/Resume
# 1. Fiber1 Runs
# 2. Fiber2 Runs
# 3. Fiber1 finishes
# 4. Exit

puts "\n\nTransfer/Transfer"
fiber1 = Fiber.new {
  puts "1. Fiber1 Runs"
  fiber2 = Fiber.new {
    puts "2. Fiber2 Runs"
    fiber1.transfer
    puts "Never: Fiber2 is killed"
  }
  fiber2.transfer
  fiber2.kill
  puts "3. Fiber1 finishes"
}

fiber1.transfer
puts "4. Exit"
# Transfer/Transfer
# 1. Fiber1 Runs
# 2. Fiber2 Runs
# 4. Exit

Is there a more appropriate way to terminate fiber2 in that case, while keeping control inside fiber1, or is this a bug?

Updated by ioquatix (Samuel Williams) 20 days ago

Thanks for the great examples.

On the surface of it, it looks like a bug. I'll need to check the logic of the implementation to see if we can improve the behaviour.

Updated by ioquatix (Samuel Williams) 20 days ago

  • Assignee set to ioquatix (Samuel Williams)
Actions #5

Updated by ioquatix (Samuel Williams) 19 days ago

  • Related to Bug #20414: `Fiber#raise` should recurse to `resumed_fiber` rather than failing. added

Updated by ioquatix (Samuel Williams) 10 days ago

In the case of transfer, it may be possible to store the previous fiber, and on exiting a fiber, when no explicit transfer takes place, transfer back to the fiber that originally transferred into it. I think this is fairly compatible with existing code, but we'd need to survey the impact.

Actions

Also available in: Atom PDF

Like1
Like0Like0Like0Like0Like0Like0