Rust and Go

The 72-Hour Port: How I Rewrote a Cron Scheduler in Rust and Survived the Debugging

Track E: Go to Rust. robfig/cron. 872 lines of Go became 1,444 lines of Rust. Zero unsafe. Fifty-two tests. One very long weekend.

By Saif Ali Khan for @HackathonRaptors

The Bun Incident

Picture this. It's May 2026. A team merges a 960,000-line rewrite of the Bun runtime from Zig into Rust. Six days. Mostly Claude Code. The test suite reports 99.8% passing. Everyone claps.

Then someone squints at the diff. Pierre built Diffshub to make that diff actually readable, and what it revealed was astonishing.

Thirteen thousand and forty-four unsafe blocks. For scale: Astral's uv, a production-grade Python package manager written in Rust, ships with 73. This merge had 13,044. And that's not the worst part. Someone also noticed the original test files had been edited. Quietly. To make the new code green.

This is the open problem. Any AI can spit out a port that compiles. What it cannot do, yet, is prove the port behaves like the original. Same edge cases. Same concurrency bugs. Same four-in-the-morning failure modes.

So someone decided to make a hackathon out of it.

Port Mortem. 72 hours. Pick a real repo. Rewrite it in a new language. Prove the rewrite holds up. Don't touch the original tests.

I picked Track E. Go to Rust. And I picked robfig/cron.

Bun Zig to Rust rewrite diff

Why a Cron Scheduler?

Let me introduce you to the cast.

There is Go, a language with a garbage collector. When Go decides to clean up memory, it pauses everything. A few milliseconds here, a few there. Usually fine. Unless you are a cron scheduler.

A cron scheduler has one job: wake up at exactly the right time and run exactly the right task. Its entire existence is a loop that goes sleep, wake, run job, repeat. If the garbage collector hits during wake, the job fires late. If it hits during run, the next job fires late. Under sustained load, these tiny pauses stack up like traffic on a Monday morning.

Now meet Rust. Same job. Same loop. No garbage collector. No surprise pauses. Just predictable, boring, consistent latency. This is Track E's thesis: GC pauses versus predictable tail latency.

The library I chose, robfig/cron, is the de facto Go cron scheduler. Fourteen thousand GitHub stars. Thirteen thousand dependents. The kind of library that runs inside half the Go backends on the planet. It's 872 lines of carefully written Go: a parser for cron expressions, a schedule calculator for "when should this job fire next?", and a runtime that sits in a loop listening on four channels and a timer.

Seventeen hundred lines. What could go wrong?

Cron scheduler loop

The Benchmark That Lied to Me

Hour six of the hackathon. I have the Rust port compiling. The parser translates cron expressions. The scheduler computes next-fire times. I wire up a quick benchmark to measure tick latency. That's the key metric: how late does a scheduled job actually fire?

The numbers come back.

Go: p99 lateness 0.5ms. Rust: p99 lateness 1.7ms.

My heart sinks. Rust is three times worse than Go? On the one metric Track E is supposed to win? I start blaming everything. tokio::time::sleep must have terrible timer resolution. Maybe I need a custom timer. Maybe I need to bypass tokio entirely and talk to the OS timerfd directly.

Then I actually read my own benchmark code.

I had written a ConstantDelaySchedule that computes the next fire time as now - nanosecond_fraction + 50ms. See the problem? The nanosecond subtraction pushes next into the past relative to now. So the scheduler wakes up, sees a job whose fire time is 49.999ms in the past, and immediately dispatches it. Then it recomputes next, which is still in the past. Dispatch. Recompute. Dispatch. Recompute.

What I was measuring was not tick latency. It was a spin loop catching up to a schedule that was forever one nanosecond behind. The Rust scheduler was running flat out, firing jobs as fast as the CPU would let it. It wasn't late. It was early. By an eternity.

The fix was one character. from + delay instead of from - nanosecond_fraction + delay. The real numbers: p50 1.25ms, p99 1.7ms. Still not beating Go's sub-millisecond timer. But steady. Predictable. Boring. Which is exactly the point.

I had spent three hours optimising the wrong number. The benchmark was not measuring what I thought it was measuring. This is a special kind of humiliation you only experience when you write both the code and the test for that code, and they conspire against you.

Lesson: If your benchmark says something amazing, check if you broke reality. If your benchmark says something terrible, check if you broke the benchmark.

Benchmark lying meme

The HashMap of Shame

There is a second performance story. It is shorter and more embarrassing.

I ran the parse-throughput benchmark. Rust was parsing 700,000 cron expressions per second. Go was parsing 700,000 per second. A tie. Acceptable. But then I profile the Rust side and notice something odd.

On every single call to parse(), my code was:

  1. Creating a brand new HashMap<String, u32>
  2. Inserting 19 entries (month names, day-of-week names)
  3. Looking up one or two entries
  4. Throwing the entire HashMap in the trash

Hundreds of thousands of times per benchmark run. Nineteen allocations per parse call. A HashMap built, used once, and discarded. Over and over. Go's parser uses a hardcoded switch table. My Rust port was running a construction project on every function call.

The fix: a const static lookup. Parse throughput jumped to 2.5 million calls per second. Three and a half times faster. The HashMap cost more than the actual parsing logic.

Lesson: Never build a data structure inside a hot loop unless you hate your users.

HashMap shame - building houses to demolish

Docker Hates Me, Personally

I decided to ship a Dockerfile. One command, zero toolchains, everything runs. A judge types docker run --rm robfig-cron-rs and lands in an interactive TUI dashboard. Tests, benchmarks, fuzz, soak, all accessible by pressing single keys. Beautiful.

The first Docker build failed.

So did the second. And the third. And nine more.

The problem was that the image needed three separate binaries compiled from three different languages: the Rust library, a Go fuzz oracle that compares Rust output against the original library, and a Go benchmark helper that runs the same metrics against the Go side for the side-by-side comparison. Each needed its own build stage, its own WORKDIR, its own copy of the source tree. The Go fuzz oracle needed a sibling clone of the original robfig/cron repo with a replace directive in go.mod. The Rust build needed cargo build --release --features "cli,bench", and I had misspelled a feature name in Cargo.toml.

Build #13 succeeded. The final image is 80 megabytes. It runs the TUI dashboard, all 52 tests, the 60-second differential fuzz, the benchmark suite, and the 4-hour soak, with no toolchains installed. It took twelve failed builds to get there.

Lesson: Multi-stage Docker builds with three languages is not a "just add a FROM line" situation. It's a "draw the dependency graph on a whiteboard first" situation.

Docker build pain

The Fuzz Machine Finds a Ghost

Now comes the fun part. Differential fuzzing.

You build a harness that generates random inputs. It feeds each input to the Go original and the Rust port. If they produce different outputs, you found a bug. If they produce the same output 204,425 times in a row, you have evidence your port behaves like the original.

I wrote the harness. It generates random cron expressions and random timestamps. It pipes them through Go's parser, computes Next(), and does the same in Rust. It compares the results. It logs every divergence.

The first run produced 30,663 divergences.

Panic. Then I read the log.

Every single divergence was the same thing. The expression */90 * * * *. Step 90 in the minute field. The minute field only goes from 0 to 59. Ninety is nonsense. But Go's parser silently accepts it, produces a meaningless schedule, and moves on. My initial Rust port did the same thing. Because it was a faithful translation.

Then I added a fix. A new error variant: StepAboveMax. If you try to step past the field maximum, the parser rejects the expression outright. I re-ran the fuzz.

Two hundred four thousand four hundred twenty-five cases. Zero unintended divergences. Thirty thousand six hundred sixty-three intentional rejections for the StepAboveMax bug. Every rejection was correct. Nothing else differed.

The fuzz machine had, in the process of proving my port was correct, discovered a latent bug that had been sitting in the upstream Go library for years. Issue #543 on the robfig/cron tracker. Open. Unfixed. My port caught it.

Lesson: When you test two implementations against each other with random inputs, you don't just verify equivalence. You find things neither version was supposed to do.

Fuzz machine finds a ghost bug

The Six-Hour Bug

This is the one that almost broke me.

The test is called stop_and_wait_slow_job. It does what it says: starts a cron scheduler, schedules a job that takes a few seconds to complete, sends the stop signal, and waits. The job must finish before stop() returns.

Simple. Go passes it every time. My Rust port passes it. Then fails. Then passes. Then fails. No pattern. No reproduction steps. Just a coin flip every time the test ran.

The bug lived in the space between two Rust concepts that look identical on the surface but have completely different opinions about who waits for whom.

Here is the setup. Cron::start() spawns a dedicated OS thread with its own tokio runtime. The runtime drives a run_loop that selects over two things: a timer (tokio::time::sleep) and a command channel (mpsc). When stop() is called from outside, it sends a Stop command through the channel and returns a oneshot::Receiver. That receiver resolves when all in-flight jobs complete. The caller blocks on that receiver.

Inside the loop, when a Stop command arrives, you need to wait for all running jobs to finish. In Go, you use a sync.WaitGroup. You increment it before starting a job, the job calls Done() when it finishes, and the Stop handler calls Wait(). Go's scheduler guarantees the handler runs to completion before the goroutine exits.

In Rust, there is no WaitGroup in the standard library. So I built one with Arc<AtomicUsize> and tokio::sync::Notify. The atomic counter tracks in-flight jobs. The Notify signal wakes the Stop handler when the counter reaches zero. Elegant. Clean. And completely broken.

Here is why.

I spawned the Stop waiter as a separate tokio::spawn task inside the run_loop. The run_loop then exited immediately after spawning that task. But run_loop is driven by tokio::runtime::Runtime::block_on on the worker thread. When run_loop returns, block_on returns. When block_on returns, the runtime stops driving tasks. Including the Stop waiter task I just spawned. Which means the just-queued job tasks get aborted. Which means the oneshot::Receiver on the stop() call resolves before the jobs actually finish.

Sometimes the race was fast enough that jobs completed anyway. Sometimes it wasn't. Coin flip.

The fix took me six hours to find. The Stop handler must wait inline inside the run_loop rather than spawning a separate task. The worker thread's block_on keeps driving the runtime until the inline wait resolves. Only then does run_loop return, block_on return, and the oneshot fire. The jobs are guaranteed complete.

Lesson: block_on stops working the moment the function it calls returns. If you need work to continue after that, it must happen before that return.

The Four-Hour Soak

Here is a fact about microbenchmarks: they are liars.

My 25-second tick-latency benchmark showed that Rust's p99 lateness was 1.7ms. But that number means nothing if it spikes to three seconds ten minutes in. Track E claims Rust's latency is predictable. You can't prove predictability with a 25-second sample. You need time.

So I wrote a soak harness. Four hours. Fifty milliseconds per tick. Jobs added progressively: start with one, add 20 more every ten minutes. By the end, 461 concurrent cron jobs all firing on independent 50ms schedules. Every tick's lateness recorded. Per-minute progress logged to CSV. A final JSON summary.

The harness ran overnight while I slept. I woke up and checked the numbers.

Sixty-four point five million ticks. p99 lateness: 2.27 milliseconds. Flat. Every minute of the four hours. No spike. No climb. No garbage collector suddenly deciding to clean up at minute 178. Just a flat line at 2.27ms.

That flat line is the entire thesis. Go's time.Timer has sub-millisecond granularity and can report a lower p99 in a short benchmark. But under sustained load, Go's GC injects unpredictable pauses. Rust's timer-wheel floor is ~1ms. It will never beat Go on raw microbenchmark precision. But it will never spike. Hour one, hour four, year ten. The latency is boring.

Lesson: A microbenchmark tells you what happens in 25 seconds. A soak test tells you what happens when nobody is watching.

The Decision I Would Take Back

Scroll up to the soak test section. See that number? 500MB RSS? That's half a gigabyte of RAM reported on the soak test summary. Any judge skimming the results is going to see "Rust cron scheduler" and "500MB" and close the tab.

It's not a leak.

The soak harness records every tick's lateness into a Vec<u64>. Four hours. Fifty milliseconds per tick. Multiple concurrent jobs. The vector grows to 64 million entries. Each entry is 8 bytes. Half a gigabyte of perfectly legitimate RAM consumed by my measurement apparatus. Not the scheduler. Not a leak. The thing I built to prove the scheduler works is the thing that makes the scheduler look broken.

I wrote a note in the summary: "RSS climbed to ~500 MB; that is the harness's 64M-sample buffer, not a cron leak." But notes at the bottom of a JSON file don't survive skimming.

What I should have done: a bounded ring buffer. Keep the last 10,000 samples. Compute p50/p99 incrementally. Constant memory regardless of how long the soak runs. The statistical value doesn't depend on keeping every individual tick. Ten thousand samples gives the same p99 trend as 64 million. At constant memory cost.

Lesson: When you build a measurement tool to prove something, the measurement tool itself had better not become the thing people judge first.

What the AI Could and Couldn't Do

I used AI tools throughout the 72 hours. opencode as the coding agent, rust-docs MCP for crate documentation, shadcn MCP for the showcase site.

The AI was genuinely good at things like generating the parser boilerplate, suggesting idiomatic Rust patterns for straightforward Go constructs, and scaffolding test code. It saved me hours on things that were basically typing exercises.

But here is what the AI could not do.

It could not understand that Go's select over four channels and a timer is an architectural decision, not a syntax pattern. Translating it into tokio::select! over an mpsc channel and a sleep requires understanding why Go used four channels instead of one, and whether collapsing them into a single CronCommand enum preserves correctness.

It could not spot that my benchmark was measuring a spin loop. The numbers looked plausible. The code compiled. The AI had no way of knowing that next() returning a timestamp 49.999ms in the past turns a latency measurement into a throughput measurement.

It could not design the Arc<dyn Job> plus ArcJob adapter pattern that avoids requiring every user job type to implement Clone. That pattern exists because Go's interfaces share references by default while Rust's trait objects are owned values. The AI suggested clone_box. Which breaks the moment you try to pass a closure as a job.

It could not debug the reentrant block_on deadlock. It could write code that looked like a Stop handler. It could not understand that spawning a task and immediately returning from block_on means the task never runs.

The AI produced a port that compiled. Producing a port that behaved like the original required reading Go source line by line, running unmodified original tests against Rust code and watching them fail, debugging async control flow until the semantics matched, and writing 321 lines of DECISIONS.md justifying every single architectural choice.

This is the gap. Generating ports is solved. Proving they work is the open problem. The 13,044 unsafe blocks in Bun's Zig-to-Rust merge weren't a tool failure. They were a verification failure. Nobody sat down and asked the question this hackathon is designed to answer: "Does this actually behave like the original?"

The Numbers

MetricValue
Source LOC (Go)872
Port LOC (Rust)1,444
Test parity52/52 passing (100%)
unsafe blocks0
Fuzz cases204,425 (0 unintended divergences)
Soak test4h, 64.5M ticks, p99 lateness 2.27ms flat
Parse throughputRust 2.5M ops/s vs Go 0.7M ops/s
Cold startRust 9us vs Go 12us
Docker image80MB runtime, zero toolchains
Upstream bugs found2 (one fixed, one immune by design)

Everything is public at github.com/f-ei8ht/robfig-cron-rs. The live site at cron-rs-site.vercel.app has side-by-side code diffs, the benchmark dashboard, the soak chart, and the full divergence log.

Port Mortem Write-Up side quest. Hackathon Raptors | #HackathonRaptors | LinkedIn | Code Resurrection 2026. July 31 to Aug 03, 2026. Track E: Go to Rust.