Fluxpinel: Plugins and eval for Ruby Compiled with Spinel
In my post about Ruby in WebAssembly I tried Spinel, Matz’s new compiler that turns a Ruby program into C and then into a fast native binary. Its price is that the whole program must be known at compile time: no eval, no method_missing, and no loading of new code while the program runs.
Fluxpinel by Hitoshi Hasumi, the author of PicoRuby, takes exactly this limit on. Its tagline is “Make Spinel Dynamic”. It is a Ruby interpreter that lives in the same binary as the compiled program and uses the same runtime. The compiled part stays fast, and the parts that should change, like plugins, configuration, queries or a console, are plain Ruby files that the binary reads at run time.
The project is brand new: the first commit is from 19 October 2026, and it already has more than 200 commits. I built it and tried it out.
How it works
Spinel compiles your program, which Fluxpinel calls the kernel, to C. The build puts that C code and Fluxpinel into one binary. Both sides share one heap, one garbage collector and the builtin methods of the Spinel runtime. So an object crosses between compiled code and script as it is, without copying or conversion, and a rescue on one side catches a raise on the other.
The kernel talks to the interpreter through a small module:
Fluxpinel.load(path)runs a Ruby file and returns the value of its last line.Fluxpinel.eval(src)does the same for a String.Fluxpinel.call(obj, name, args)calls a method on an object that the script created.
In the other direction, a script can call the module methods of the kernel that you list at build time.
Fluxpinel also works on its own, like the ruby command, with -e, -I, -r and a console. There is also an --irb option for IRB, but on my machine it stopped with a load error in the installed irb and prism gems, so I couldn’t try it. RUBY_ENGINE is "spinel" there.
Building it
You need git, a C compiler, libffi and Ruby. make deps fetches and builds Spinel at the commit that Fluxpinel pins, and make builds the interpreter:
git clone https://github.com/hasumikin/fluxpinel
cd fluxpinel
make deps
make
build/fluxpinel -v
On my laptop with Ubuntu, GCC 15 and Ruby 4.0.9, this worked without problems. Spinel took about ten minutes, Fluxpinel about 40 seconds. Then:
$ build/fluxpinel -v
fluxpinel 1 (spinel 5cce6eb03, ruby 4.0.9) [x86_64-linux]
$ build/fluxpinel -e 'p RUBY_ENGINE'
"spinel"
The default build raises a RangeError when an Integer overflows, as Spinel does by default. For Bignums like CRuby, there is a second build: make BUILD=build/promote INT_OVERFLOW=PROMOTE build/promote/fluxpinel, and then 2**100 works.
My test: invoice rules as plugins
As in my earlier posts, I used a small Swiss invoice. The compiled part reads the items, computes the subtotal, the 8.1% VAT and rounds the total to 5 Rappen. The discount rules are not compiled. Each rule is a Ruby file in a rules/ folder:
# app.rb: Spinel compiles this file
require_relative "fluxpinel/kernel/fluxpinel"
module Invoice
VAT = 0.081
# round to 5 Rappen
#: (Float) -> Float
def self.round5(amount) = (amount * 20).round / 20.0
#: (Array[String]) -> nil
def self.main(args)
lines = File.readlines(args[0], chomp: true).map do |l|
name, qty, price = l.split(",")
{ "name" => name, "qty" => qty.to_i, "price" => price.to_f }
end
subtotal = lines.sum { |l| l["qty"] * l["price"] }
puts format("%-24s %10.2f", "Subtotal", subtotal)
Dir.glob(File.join(args[1], "*.rb")).sort.each do |path|
rule = Fluxpinel.load(path)
off = Fluxpinel.call(rule, "discount", [lines, subtotal])
next if off == 0
puts format("%-24s %10.2f", Fluxpinel.call(rule, "name", []), -off)
subtotal -= off
end
vat = subtotal * VAT
puts format("%-24s %10.2f", "VAT 8.1%", vat)
puts format("%-24s %10.2f", "Total", round5(subtotal + vat))
nil
end
end
The #: comments are inline RBS types. Spinel needs them for methods that only the script calls, because it can’t infer the types from the compiled code alone.
Two rules:
# rules/10_bulk.rb: 10% off every line with 10 or more pieces
class Bulk
def name = "Bulk discount"
def discount(lines, _subtotal)
lines.select { _1["qty"] >= 10 }.sum { _1["qty"] * _1["price"] * 0.1 }
end
end
Bulk.new
# rules/20_loyalty.rb: 5% off orders over 200 francs
class Loyalty
def name = "Loyalty 5%"
def discount(_lines, subtotal) = subtotal > 200 ? Invoice.round5(subtotal * 0.05) : 0
end
Loyalty.new
The loyalty rule calls Invoice.round5, which is compiled code. One make call builds the binary and names the methods that scripts may call:
make host HOST=build/invoice HOST_NAME=invoice KERNEL=app.rb \
KERNEL_MAIN=Invoice.main KERNEL_ENTRIES=Invoice.main,Invoice.round5
$ build/invoice/invoice items.csv rules
Subtotal 275.50
Bulk discount -12.00
Loyalty 5% -13.20
VAT 8.1% 20.27
Total 270.55
It worked on the first try. Then I added a third rule, a coffee week where every third bag is free, as a new file in rules/, and ran the same binary again. Same checksum, new line on the invoice, total 250.60. No compiler involved. That is the whole point of Fluxpinel, and it feels just like plain Ruby.
The same files also run under CRuby: kernel/fluxpinel_cruby.rb provides the Fluxpinel module there. Both printed exactly the same invoice. The compiled binary started and finished in under 10 milliseconds, CRuby needed about 0.25 seconds, mostly for starting up.
The examples
The repository has three examples, and I built all of them:
- calc evaluates each command-line argument with
Fluxpinel.eval. A method that one argument defines is there for the next one. Spinel alone refusesevalof a String it only gets at run time. - textkit is a text tool with plugins, much like my invoice. It worked as described.
- logq analyzes access logs. A user writes queries like
where_status(500..599).slower_than(1.0), and the DSL creates these methods on first use withmethod_missinganddefine_method, things that Spinel alone does not allow. The loops over the rows run in compiled code.
logq was the one that didn’t work for me: it crashed with a segmentation fault, even on a log of 1,000 lines. I looked into it, and the cause is narrow. A String created by Symbol#to_s in the script, here by.to_s for top(5, by: :duration), ends up as garbage when it is passed to a compiled method. A String literal, or :duration.to_s.dup, works fine. The project’s CI is green, but it doesn’t run the examples. For a project that is three weeks old, I find one bug like this quite normal. I reported it with a small reproduction and the cause: issue #1.
How fast is the interpreter?
The interpreter is meant for the glue of a program, not for its hot loops, and the README says so. I measured anyway:
| Fluxpinel | CRuby 4.0.9 | CRuby + YJIT | |
|---|---|---|---|
fib(32), many method calls |
1.78 s | 0.37 s | 0.18 s |
| Hash and String work, 300,000 iterations | 0.22 s | 0.27 s | 0.25 s |
Method calls in the interpreter are about five times slower than in CRuby. But when a script mostly calls builtin methods, which are compiled C in the Spinel runtime, it is as fast as CRuby or even a bit faster. The README reports the same pattern on a larger scale: optcarrot, the NES emulator benchmark, runs at about 9 frames per second in Fluxpinel, against 55 in CRuby. The compiled fib(32) from Spinel, for comparison, finished in under 10 milliseconds.
So the split that logq shows is the right way to use it: the script decides what to do, and the compiled code does the loops.
More than plugins
Two more things I find interesting:
- A console in a running program. With
--irb-port, you can connect to a running binary with telnet and get IRB with its live objects. The repository shows this with a Rails blog that Roundhouse by Sam Ruby turned into a Spinel binary: you can count and create records while the server keeps serving pages. - A test oracle for Spinel. Fluxpinel runs a program without type inference or optimizations, on the same runtime as the compiled code. Running Spinel’s tests in CRuby, Fluxpinel and as compiled code, and comparing the output, shows whether a bug is in Spinel’s analysis, in its runtime or in Fluxpinel.
There are still limits. A script can only call the module methods that you export, not methods on instances of compiled classes. And compiled code can’t fall back to the interpreter for a call it can’t resolve. Both need changes in Spinel. Matz keeps eval and a VM out of Spinel’s design on purpose, so Fluxpinel is a separate project and pins one Spinel commit.
Conclusion
Spinel makes Ruby fast by closing the program. Fluxpinel opens it again, exactly where you want it: plugins, rules, queries, a console. My invoice with plugin rules worked on the first try, and adding a rule without compiling again felt just like plain Ruby. The interpreter is slow for method-heavy code, so the heavy work belongs in the compiled part.
I like the idea a lot. It gives a Spinel program the part of Ruby that I would miss most. It is very young, so expect rough edges, like the logq crash. I’m curious to see where it goes, and how Spinel and Fluxpinel grow together.