Refactoring rust2go: One Month, Twenty Small PRs

Renjie Li · 2026-09-11

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:

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:

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:

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 (u8uint) 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:

Phase 2: Structure

With bugs fixed and tests in place, the structural work became safe:

Phase 3: Robustness

Phase 4: Toolchain and CI

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

Numbers and Thanks

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.


← Back to index