Round Trip
Yesterday I closed with the bet that "within a few iterations the round trip works end to end." Iteration one closed faster than that rhetoric implied.
The lowered shape fixtures/spinel-blog was demonstrating by hand is now what roundhouse produces from fixtures/real-blog. The produced thing runs.
The demo
Two commands from a fresh clone:
git clone https://github.com/rubys/roundhouse
cd roundhouse
make real-blog # generate the Rails fixture (~60s)
make spinel-dev # transpile + assets + serve on :3000 (~3-5min cold)
make real-blog runs rails new + the standard scaffold + bin/rails db:prepare, populating fixtures/real-blog/storage/development.sqlite3 with three articles and seven comments via db/seeds.rb. make spinel-dev builds the roundhouse Rust crate, transpiles the Rails source through the lower-and-emit pipeline, overlays the result onto a verbatim copy of the spinel-blog scaffold (runtime, dev server, Gemfile, Makefile), and starts the dev server.
Open http://localhost:3000 in a browser. Three Rails-seeded articles render, served by the Spinel-subset Ruby that roundhouse produced. Create a new article — the form posts, the redirect lands on the article page, the flash notice appears and clears on the next render. Open a second tab; create another article in the first; the second tab's index updates in real time without a page refresh, via Turbo Stream broadcasts forwarded over WebSocket. Destroy a comment — the data-turbo-confirm dialog appears; confirming dispatches a _method=delete POST and the row vanishes from the DOM and the SQLite file behind it.
Two things worth being explicit about:
- The output runs on CRuby, not Spinel-compiled. Today's demo is a multi-process CGI dispatched by a hand-written dev server. The compiled-binary half of the round trip is the unfinished one.
- The deployment substrate is demo-grade.
sqlite3is the CRuby gem (Spinel can't FFI yet); broadcasts use a tmp-directory file watcher (no shared memory across CGI forks). Both are replaceable when the runtime substrate matures. Neither is architectural.
What it took
A sampling of what landed in iteration one. Each is a small focused lowerer pass — mechanical once the framework is in place. One example, the most striking:
Real-blog's Comment model has this:
after_create_commit { article.broadcast_replace_to("articles") rescue nil }
after_destroy_commit { article.broadcast_replace_to("articles") rescue nil }
broadcast_replace_to is a Rails-API method on associations. Spinel's runtime doesn't implement it; the rescue nil swallows the failure silently. So before this iteration, when a comment was created the parent article's index card never re-rendered. The hand-written fixtures/spinel-blog rewrites the same intent into:
parent = article
return if parent.nil?
Broadcasts.replace(
stream: "articles",
target: "article_#{parent.id}",
html: Views::Articles.article(parent),
)
Same semantics, different API surface. The lowerer now does this rewrite automatically: detect <assoc>.broadcast_replace_to(<stream>) where <assoc> names a belongs_to on the model, replace with the Spinel-shape sequence, drop the rescue nil modifier (the explicit nil-check supersedes it). broadcast_append_to and broadcast_remove_to get the same treatment.
Other rewrites in the same vein — less photogenic, each closing a specific gap:
form_with(model: rec)restructured to the runtime's expectedmodel:/model_name:/action:/method:/opts:shape, withactionandmethodternaried onrec.persisted?.- Polymorphic
form_with(model: [parent, Class.new])lowered to the nested-resource form (RouteHelpers.<parent>_<children>_path(parent.id)). - Inside
errors.each |error|,error.full_messagestrips to a bareerror— Spinel-runtime errors are plain strings, nofull_messagemethod. data: { turbo_confirm: "..." }Hash kebab-flattens to a flatdata-turbo-confirmattribute at attribute-rendering time.
Some of these gaps surfaced on the roundhouse side. One surfaced on the other.
The flywheel
matz/spinel#49 — Default-argument values not inserted at omitting call sites — was filed Monday morning as a six-line repro extracted from the spinel-blog fixture. By the time I'd written the rest of that day's commits, Matz had closed it.
I'd hesitate to read too much into one issue. But it's a concrete instance of the loop both projects gain from: roundhouse's emit forces specific Spinel coverage decisions ("can it compile this?"), and Spinel maturing enlarges what the lowerer can confidently emit. The default-arg fix means roundhouse can keep emitting def initialize(attrs = {}) — the natural Ruby shape — without contorting around it. The contract is bidirectional, and the channel works.
The honest gap
What's still incomplete, in order of decreasing visibility:
Spinel hasn't compiled this output yet. Today's demo runs on CRuby. Four of the five inference gaps from the previous post remain. The path is open — issue #49 is the proof — but the compile-half of the round trip stays unproven until those close.
Coverage is one app. Real-blog is idiomatic Rails 8 MVC: two models, two controllers, four resourceful routes, broadcasts, validations, basic flash, no concerns, no jobs, no mailers, no file uploads. Other Rails apps will surface DSL patterns and callback shapes the lowerer doesn't yet recognize. Each is a small focused pass; the work scales linearly per feature — but it scales, and one app is one app.
Some integration parts are placeholders. sqlite3 linked through the CRuby gem instead of through Spinel FFI; broadcasts via a watched tmp-directory instead of shared memory or unix sockets. Both work for the demo. Neither is the right answer for production. Both are deployment-substrate concerns, separable from the architectural shape.
What this changes
The bet yesterday was that the architectural shape was right and the maturity gaps would close on both sides. Iteration one's empirical answer:
- The lowering side has a runnable artifact end to end. Anyone can clone the repo and verify in five minutes.
- The compiling side has a working feedback channel and one closed issue. The four remaining gaps look like ordinary inference maturity, not architectural cliffs.
What was unproven before is now demonstrated; what's unproven now is the Spinel half. That's a different thing to be unsure about than "does the architecture work" — it's a finite list of compiler issues with a responsive author on the other end.
The reason any of this matters — requests per second per gigabyte of resident memory, the line item the cloud bill is actually charging for — is what CRuby's fork-per-worker shape leaves on the table. Compiled Ruby that stays inside the Rails ecosystem closes that gap without rewriting into another language. WHY.md is the full case.
Roundhouse generates the Spinel-shape Ruby; Spinel compiles it. The interface holds, and the feedback runs in both directions. From here, the work of growing both halves is something the two projects are well-positioned to do together.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.