Refactoring rust2go: One Month, Twenty Small PRs
This article also has a Chinese version.
rust2go is a Rust–Go FFI framework created by @ihciah. It lets Rust code call Go (and vice versa) with native async support and without serialization: arguments are passed by memory reference over the C ABI, with an optional shared-memory queue and an assembly fast path for high-frequency calls. The design is covered in ihciah's two articles: Design and Implementation of a Rust-Go FFI Framework and Rust2Go Part2: Exploring CGO Calls for Extreme Performance.
This summer I started helping maintain the project as one of its core contributors. Over the past month (August 24 – September 11, 2026), I carried out a full-repository refactor — with the help of Kimi, an AI coding agent, which planned and executed the work under my supervision: 20 PRs, 96 files changed, +6,426 / −4,046 lines, code coverage introduced from scratch (93.04% at first measurement) and raised to 97.39% — with zero breaking changes to the public API.
This article is a retrospective, but hopefully not a boring one. Yes, I'll go through what was done and in what order — but the interesting part of any refactor is never the moving of code. It's what the moving reveals. So I'll also spend a good part of this article on the bugs: a heap corruption that took valgrind, ASan and rr to pin down, a Mac that hung for weeks before confessing, a pointer that wasn't a pointer, and a task that kept itself alive forever.
Note: this is a maintainer-oriented engineering log, not a user guide.
Why Refactor
rust2go has grown organically since 2023: code generation, derive macros, a CLI, a build-script helper, two runtimes (tokio and monoio), two backends (CGO and shared memory), plus hand-written Go assembly. As features accreted, so did structural debt:
- The Go code emitter lived in a single 1,300-line file, with the same primitive-type mapping spelled out in five different
matchblocks. - The derive-macro path and the CLI path each had their own copy of the ref-struct expansion logic — two parallel code generators that could silently drift apart.
- The five example projects contained byte-identical
build.rsfiles and copy-pasted Go implementations. - Macro errors were reported with
panic!/unwrap(), producing compiler crashes instead of diagnostics. - The documentation had drifted from the code, and there was no coverage gate — in fact, no coverage measurement at all.
None of these hurt users directly — but every one of them made the next change riskier. So the goal of this month was not new features; it was to make the project easy to change safely.
Where to Start: Coverage from Zero
Before this month, the project had no coverage at all — no measurement, no gate, and (as we would soon discover) some tests that had never actually been executed. So the very first PR (#173) didn't refactor anything. It built the measurement itself:
- A CI coverage job: Rust coverage collected with
cargo llvm-cov(lcov), Go coverage from thetest/gomodule, both uploaded to Codecov. sccache had to be disabled for this job — it does not play well with coverage instrumentation. - A first wave of unit tests for the never-tested core:
rust2go-convert(ToRef/FromRef roundtrips for primitives, String, Vec, Option, tuples), the atomic slot state machine inrust2go::slot, theResponseFuturepoll lifecycle, and the mem-ring queue/eventfd internals.
The first measurement came back at 93.04% — better than I feared. But the real value of this PR was not the number. It was what happened when the new tests actually ran.
First, an embarrassing find: the Go unit tests in test/go had never been executed by CI.
Once they ran, every state-dependent case failed with user not exist — the tests
constructed an empty Demo with nil maps. They had been broken from the day they were
written, and nobody knew, because nobody ran them.
Second, a much darker find. The new mem-ring tests crashed the test process — intermittently, with
glibc complaining about tcache_thread_shutdown(): unaligned tcache chunk detected. Heap
corruption. The story of hunting it down is the first detective story below; it ended with a
use-after-free where the kernel was the one writing into freed memory, and a file descriptor
being closed twice. Neither had anything to do with coverage, of course. But without coverage, there
would have been no tests, and without the tests, nobody would have known.
Step 0: Build the Safety Net Before Surgery
With coverage in place, the next task (#176) still added no refactor at all — only tests that pin the current behavior:
- Golden tests for the Go emitter: the generated Go code is compared byte-for-byte against checked-in snapshots, so any codegen drift fails loudly.
- Unit tests for
rust2go-mem-ffi(payload flags, slab helpers, slot state machine) and for the Go side (mem-ring queue/slab, the asmcall trampolines). - A CI freshness check: regenerate
gen.gofrom the binding files and fail if it differs from what is committed.
This paid for itself within days: the freshness check immediately caught a hand-edited gen.go
that had drifted from its generator, and the golden tests caught a typo (u8 → uint)
in my own refactor of the primitive table before it could merge. Refactoring without a safety net is just
hoping; with one, every later PR became boring — which is exactly what you want.
Phase 1: Fix Real Bugs Before Moving Code
Before restructuring anything, I fixed the known P0 bugs — small, independent PRs that are easy to review and easy to revert:
- A self-deadlock in mem-ring's write goroutine (
continuebeforeUnlock). - A broken freelist sentinel in the Go slab allocator.
- A bogus
#[mem_call]attribute in the test suite (should be#[mem]) — caught by the new freshness check. - A wrong argument name in the amd64/arm64 assembly (
arg1+0x18(FP)should bearg2+0x18(FP)) — the machine code was identical by coincidence, but the source was lying. - The
monoioandtokiofeatures were supposed to be mutually exclusive, but nothing enforced it — worse, CI built with--all-features, which compiled a configuration no user could ever build. Now acompile_error!guard rejects the combination, and CI tests each runtime in its own job.
Phase 2: Structure
With bugs fixed and tests in place, the structural work became safe:
- Single source of truth for primitives. One table of
(rust_ident, c_name, go_name, ...)replaced the five scatteredmatchblocks, and the ref-struct classification is now shared by the derive macro and the CLI — the two codegen paths can no longer drift. - A new
rust2go-gencrate.generate()moved out of the CLI into a library crate taking a plain-dataGenArgsstruct. The CLI is now a thin shell, and therust2gocrate no longer leaksclap/cbindgeninto its public API. - Layered codegen. The 1,300-line
common.rswas split intoir/emit-rust/emit-go/emit-clayers for both directions (r2g and g2r), and the embedded Go template strings became standalone.go.tmplfiles pulled in withinclude_str!. The golden tests proved the split byte-exact: not a single byte of generated output changed. - Shared example template. Five byte-identical
build.rsfiles, four identicalimpl.gofiles, and duplicated tutorial text collapsed intoexamples/shared/, with a CI check keeping the fourimpl.gocopies in sync. Each example now shows only what makes it different: runtime × backend.
Phase 3: Robustness
- Macros report, they don't crash. Every
panic!/unwrap()on the macro path became a spannedsyn::Error. A malformed binding file now produces a proper compiler diagnostic pointing at the offending token — with regression tests, including one forqueue_sizeoverflow. (Also fixed along the way: theunreconigzedtypo, and a family of variables namedtrat.) - mem-ring stops cleanly.
Notifier.Notify,Awaiter.Wait, andNewAwaiternow return errors instead of spinning forever on a closed fd; the write goroutine andRunHandlergained real stop mechanisms, replacing a dead-code predecessor; andNewAwaitertakes explicit fd ownership so a finalizer can no longer close a descriptor out from under a live connection. The package also declares its//go:build unixconstraint honestly instead of failing to compile mysteriously on Windows.
Phase 4: Toolchain and CI
- Deduplicated the cgo fallback between
cgocallandasmcall, and centralized workspace dependencies. This surfaced a genuine Cargo pitfall: for workspace-inherited dependencies, a member crate'sdefault-features = falseis silently ignored — it must be declared at the workspace root. The leak had been enabling a default feature that tripped our own monoio/tokio mutex guard. - CI now pins Go toolchains (a
stablealias that tracks new releases, plus a pinned Go 1.18 floor — 1.18 support is a deliberate compatibility commitment, and now CI actually tests it), gates ongofmt -landgo vet, and runs the Go suite on linux amd64 and arm64, macOS arm64, and Windows amd64. - Coverage was raised from the initial 93.04% to 97.39% (249 → 102 uncovered lines), with a Codecov gate at 97% — more on the choice of that number below.
Four Detective Stories
The most valuable output of a refactor is the bugs it exposes. Here are the four best, in the order we met them.
1. The kernel wrote into freed memory
When the first wave of coverage tests ran, the mem-ring suite started crashing — sometimes, not always —
with glibc's tcache_thread_shutdown(): unaligned tcache chunk detected. A heap corruption
that only shows up at thread exit, in a lock-free shared-memory queue, on CI machines I could not SSH
into. My local machine has no Rust toolchain by design, so every hypothesis had to be tested by pushing
a commit and watching CI.
So we turned CI into a debugger. Over a dozen temporary commits: run the tests single-threaded; run each
test in an isolated process; bisect by test elimination; run under valgrind; run under gdb with malloc
checking; run under AddressSanitizer; loop the suite to measure the crash rate; even
rr record with reverse watchpoints to catch the corruptor in the act. The intermediate
findings kept contradicting simple theories: forcing monoio's legacy driver (no io_uring) changed the
crash rate but didn't fix it; the monoio sync feature looked like the discriminant until it
wasn't.
The real answer turned out to be two bugs, not one.
Bug one: a use-after-free where the writer is the kernel. The monoio version of
Awaiter::wait read the notification fd into a thread-local buffer, passed to the runtime as
a raw pointer (RawBuf). Meanwhile the spawned unstuck_handler task could leak
past runtime drop (a detached JoinHandle plus a waker/Op Rc
cycle), leaving an in-flight kernel recv holding a dangling pointer into freed
thread-local storage. When a later notify() completed that read, the kernel happily wrote
eight bytes into memory the allocator had already reused — and the heap metadata died. Changing the op
to read into an owned Vec, which lives exactly as long as the operation itself, closed the
hole.
Bug two: the double-closed fd. Queue::read and run_handler
used to hand the queue's own fd to the peer-side Notifier/Awaiter,
while Queue::drop also closed it. One descriptor, closed twice. Harmless in a quiet
process — but the tests run in parallel, and between the two closes the fd number can be reused by a
completely unrelated owner, which then gets its descriptor closed from under it. Classic fd
recycling, and a nightmare to reproduce on purpose.
The final fix was a small redesign rather than a patch: fd ownership is now tracked separately from
memory ownership. The fd is moved to the Notifier/Awaiter and marked
as -1 in the queue, so it is closed exactly once; Queue::drop closes only the
fds it still owns, and do_drop controls shared-memory freeing and nothing else. Tests
verify the close exactly once via POLLHUP on the peer end — robust against fd-number reuse.
Total cost: one PR, about twenty CI runs, and a healthy new respect for thread-local raw buffers.
2. The hanging Mac
Later, when I added a macOS arm64 leg to CI "just for coverage", it immediately hung:
TestCallFuncP0 timed out after ten minutes, while the G0 variants and all of Linux passed.
The asmcall non-G0 trampoline does a bare CALL R8 + RET on the goroutine
stack; the G0 variant additionally switches stacks, aligns SP, and saves registers. Since I had no local
macOS environment, I temporarily scoped the test to Linux and kept macOS to compile/vet coverage,
documenting the leading hypothesis (SP alignment) in the known-issues list.
Weeks later the real root cause landed — and it wasn't alignment. On arm64, CALL (i.e.
BL) writes the return address into the link register x30 — and the trampoline
never saved it. Any C callee that itself calls another function clobbers x30, so the
trampoline's final RET jumped back into itself and spun forever. On my MacBook, lldb
confirmed it in one look: pc == lr == CallFuncP0+8. One save/restore pair for LR fixed it —
and the new linux/arm64 CI leg now guards it, because the bug was never darwin-specific: linux/arm64
would have hung identically, it was simply never covered. A CI leg added "just for coverage" ended up
finding a latent ISA-level bug that predated the refactor.
3. The GC escape
Two CI runs of the new mem-ring stop tests failed with corrupted memory. The cause was in my own test
helper: it stored a local variable through a uintptr into QueueMeta, which
escaped the Go GC's liveness tracking; the stack frame was reused and the "pointer" silently pointed at
someone else's data. The fix was to heap-allocate the state and store a real pointer. Lesson re-learned:
uintptr is not a pointer, and the GC is under no obligation to keep your target alive.
4. The keep-alive cycle
While raising coverage I noticed that one exit branch of mem-ring's unstuck_handler was
unreachable — not untested, but dead. The handler task held an Arc to the shared
inner state, and the inner state held the stop channel's Receiver. As long as the handler
lived, the receiver could never drop, so tx.closed() could never fire — a keep-alive cycle
that leaked the task until runtime exit. The fix inverts the ownership: the stop guard is shared only
among user-side clones (manual Clone that skips the guard), and the handler receives its
dependencies as explicit parameters instead of holding the world. A regression test now drops every
WriteQueue and asserts pending items are no longer flushed.
Coverage: Why Stop at 97.4%
After the big test-writing push (#191),
coverage stood at 97.39%. The remaining ~24 lines are genuinely untestable or defensive: proc-macro
entry points execute inside rustc where llvm-cov cannot instrument them, impossible-state
panic arms, and narrow race windows. I chose to gate at 97% (with a 0.5% threshold, and no patch-level
gate so that adding defensive code is never blocked) rather than write theatrical tests to chase a
round number — a coverage figure you gamed is worse than one you explained.
Process Notes
- One small PR per workday. Each task was developed on a fresh branch from latest master and squash-merged the same day. Small diffs stay reviewable; twenty small PRs are far easier to trust than one 10k-line bomb.
- Independent review before every push. Every change went through an iterative, independent code-review loop and was fixed until no actionable findings remained.
- Docs in the same PR, no CHANGELOG. User-visible changes were fused directly into the README and
docs/in the same commit as the code, so the documentation always describes the code at HEAD. One PR was a full-history documentation review that reconciled every user-visible change since the first external contribution. - CI as the only verifier. My machine had no Rust/Go toolchain by design, so every PR was validated purely by CI. Several PRs went red on the first run — each failure was root-caused (not retries-and-prayers) and merged green the same day.
- AI-assisted execution. The day-to-day work — planning, coding, the review-and-fix loops, CI log analysis, and merging — was carried out by Kimi, an AI coding agent running on my machine on a one-task-per-workday schedule. I set the goals, made the judgment calls (for example, keeping Go 1.18 support as a deliberate compatibility commitment, and declining to game the coverage number), and reviewed the results. This article itself was also drafted with Kimi's help.
Numbers and Thanks
- 20 PRs merged (#173 – #193), 96 files changed, +6,426 / −4,046 lines.
- Code coverage introduced from scratch: 93.04% at first measurement, 97.39% now, gated at 97%.
- Zero breaking changes to the public API; generated Go output byte-identical before and after the codegen split.
- Four latent bugs found and fixed that had nothing to do with the refactor itself — including heap corruption in mem-ring and an arm64 ABI bug in the assembly trampoline.
Thanks to @ihciah for creating rust2go, for the trust, and for the reviews — and to Kimi for doing the heavy lifting with me. If you're calling Go from Rust — or just enjoy a good FFI rabbit hole — give the project a look.