JRuby, Falcon, Fiber · · Sampo Kuokkanen

Moving Japan Voyage to JRuby, part 1: the fiber scheduler under Falcon

Japan Voyage, our own product, runs in production on Ruby master. I want to know what it takes to run the same application on JRuby. Partly because we sell JRuby work and should know the road ourselves, and partly because a real Rails 8.1 application with chat, background jobs and four sites is a better test than any benchmark.

This is a progress log, not a success story. Japan Voyage does not boot on JRuby yet. This post is about the first layer: the web server.

Why the fiber scheduler comes first

Japan Voyage is served by Falcon. Falcon is built on async, and async is built on the fiber scheduler interface that CRuby added in Ruby 3.0: each request runs in a fiber, and when it would block on IO, a sleep or a lock, the scheduler parks that fiber and runs another one. The interface is just a set of hooks the interpreter calls (io_wait, io_read, io_write, kernel_sleep, block, unblock and so on), and the event loop underneath is io-event.

JRuby implements the same interface. But a server like Falcon only works if every blocking operation goes through it. One path that blocks the thread instead of yielding is enough to freeze every other request on that thread.

io-event's fast selectors (io_uring, epoll, kqueue) are C extensions, but it also ships a selector written in plain Ruby, IO::Event::Selector::Select. On JRuby that is the one to use, so the question becomes whether JRuby's side of the interface behaves like CRuby's.

What has been fixed so far

These are the JRuby pull requests I have had merged on the fiber scheduler path since late August 2026. Each one is a place where JRuby did something different from CRuby:

  • Fiber.schedule inside a non-blocking fiber raised "No scheduler is available!", because it looked up the scheduler on the fiber's carrier thread instead of the thread that owns it. (#9604)
  • The thread's blocking count drifted across fiber switches, so Fiber.current_scheduler could answer wrongly. (#9607)
  • Kernel#sleep ignored the scheduler, so a sleeping non-blocking fiber parked its whole thread instead of yielding. The scheduler is now also closed at exit the way CRuby does it. (#9622)
  • The scheduler IO path returned a positive errno where CRuby returns a negative one, so a scheduler checking for -EAGAIN read it as a byte count. Zero-length reads and writes and buffer sizes were fixed in the same change. (#9645)
  • Mutex deadlock detection asked the wrong question: when a fiber held the lock and the root fiber tried to take it, Mutex#lock blocked forever instead of raising ThreadError. The mutex now records the fiber that owns it. (#9649)
  • Fiber#transfer handed control back to the wrong place. (#9660)
  • IO#wait had the wrong arguments and return values and did not defer to the scheduler. (#9656)
  • Zero-size IO::Buffers were not supported. (#9691)
  • Reads blocked the thread when the scheduler has no io_read hook. CRuby creates sockets and pipes non-blocking, so a read that returns EAGAIN falls through to io_wait. JRuby's are blocking, so the read parked the thread and the fiber that would have produced the data never ran. Reads on the scheduler path are now non-blocking. (#9708)
  • io_wait got the wrong event flag, FMODE_WRITABLE instead of IO::WRITABLE. (#9733)
  • Fiber#raise resumed a transferred fiber instead of transferring into it. (#9734)
  • Writes held the IO lock while waiting. A second fiber writing to the same IO blocked on the lock and never yielded, so the scheduler could never resume the first one; closing the IO from another fiber hung the same way. (#9749)
  • Socket.pair and UNIXSocket.pair ignored a block, so code written for CRuby never ran it and leaked the sockets. They now yield the pair and close it afterwards. That also lets more of CRuby's IO tests run on JRuby, which is how the next gap below became visible. (#9750)

Most of these come with tests: new specs in ruby/spec, the shared suite that tells every Ruby implementation how CRuby behaves, or CRuby's own fiber and IO tests, which JRuby also runs and which several of these changes un-excluded. Fixing a behaviour upstream, with a spec, means nobody running async on JRuby has to find the same bug again.

What is still in the way

The scheduler itself is not finished. The one gap I know about: closing an IO while another fiber waits on it through the scheduler hangs, because JRuby's close does not call the scheduler's fiber_interrupt hook. CRuby's test_io_close_across_fibers covers it, and it is what I am working on now.

Above the scheduler, Japan Voyage itself has to load, and there are three obvious walls:

  • The database. Japan Voyage runs on SQLite in production. The sqlite3 gem is a C extension with no JRuby build, so on JRuby it has to go through JDBC instead. The SQLite adapter for that, activerecord-jdbcsqlite3-adapter, is at version 72.x, which tracks Rails 7.2, and Japan Voyage is on Rails 8.1.
  • Native gems. 28 gems in our bundle compile native code on CRuby. Several are default gems that JRuby ships its own versions of, and nokogiri, nio4r, bcrypt and msgpack publish java builds. The rest need checking one by one.
  • The Ruby version. Japan Voyage runs on CRuby master. JRuby 10 targets Ruby 3.4, so anything the application uses from newer Ruby has to wait or be written around.

Next

The next step is to get a plain Rack application running under Falcon on JRuby with the Select selector, under load, and then move up: Rails without the database, then the database. I will write up each layer as it moves, including the parts that do not work.

If your company runs Ruby on the JVM, or is thinking about it, this is the kind of work we do for clients: JRuby and Java integration.