The modded Rust server image we run on Flux: pfeiffermax/rust-game-server
with Carbon instead of Oxide, because
on a Flux node Oxide cannot compile a plugin at all.
Same game, same runds.sh contract, same app spec shape. Published as
runonflux/rust-carbon-server:latest.
On Flux nodes, every Oxide plugin install fails. Always the same way, about 25 seconds after the file lands:
[CSharp] Started Oxide.Compiler v successfully
[Error] Timed out waiting for plugin to be compiled: <PluginName>
[Warning] Compiler failed to gracefully shutdown, killing the process...
Oxide compiles plugins in a separate process, Oxide.Compiler, which it launches
with Process.Start. On a node that process never runs. Traced at the kernel
level on one of our own nodes, with ftrace and zero dropped events, the sequence is:
- Mono
fork()s — a real process,clone_flags 0x1200011, preceded by threepipe2calls. - The child opens
/proc/self/fd, reads it, and closes every inherited descriptor, 3 through 0x5e. - Then it stops making syscalls and spins in user space at 100% CPU, forever:
State: R,syscall: running, one thread, a 3.4 GB address space, and a user-space stack that never leaves a 27-byte window oflibmonobdwgc-2.0.so— Mono's Boehm garbage collector.
It never reaches execve. The parent sees Process.Start return, logs "Started
… successfully", opens its message-broker socket, and waits for a compiler that
does not exist. Each attempt leaves a second RustDedicated burning a core until
Oxide kills it 60 seconds later.
What it is not: the image, the Oxide version, the compiler binary (identical
md5 on both sides), the app spec, the mounts, FluxOS, cgroup CPU throttling, the
heap size, a pty, or the number of visible CPUs. All eliminated by measurement,
including running the same image under a plain docker run with FluxOS stopped,
which fails identically. The same image on a developer machine compiles the same
plugin in 1.4 seconds.
Carbon does not have this problem, by construction. It compiles plugins
in-process with Roslyn loaded as a library — carbon/managed/lib/Microsoft.CodeAnalysis.CSharp.dll —
so nothing has to be spawned. uMod plugins keep working through
Carbon.Hooks.Oxide.dll.
Three things, all forced by how a Flux app is shaped:
- The Carbon runtime survives an empty volume. A Flux volume arrives empty and
is mounted over
/srv/rust/carbon, which would hide a runtime shipped at that path — exactly what happens tooxide/on the Oxide image. So the tree is baked in at/opt/carbon-template, outside every mount, and seeded on first boot. Later boots refresh onlymanaged/,tools/andnative/: plugins, configs, data and lang are the customer's and are never touched. - Uploaded plugins are loadable, without an exception in the log. Carbon
rewrites the
.csfiles it loads — superseded copies go tobackups/— where Oxide only ever read them. FluxOS writes dashboard uploads asroot, so without help a freshly uploaded plugin fails withAccess to the path ... is deniedand never loads. PID 1 stays root and hands the file to the game user on the inotify event itself, in single-digit milliseconds. That timing is the point: Carbon's own watcher opens an upload about 280 ms after it lands, so a periodic pass — the first version of this, every 5 s — always lost the race and printed a stack trace at the customer before loading the plugin seconds later. The periodic pass is still there underneath as the safety net. docker stopreaches the game. The server runs as a child of PID 1 with SIGTERM forwarded, so a stop is a shutdown rather than a 10-second timeout and a kill.
The game itself still runs as uid 10001, the user the base image uses.
./tests/test-flux.sh # pure logic, no docker
docker build -t rust-carbon-server:local .
./tests/test-entrypoint.sh rust-carbon-server:local # PID 1 against a stub server
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable -x scripts/*.sh tests/*.shTwo upstreams, pinned differently because they version differently:
| tag | how it is followed | |
|---|---|---|
| base image | pfeiffermax/rust-game-server:build-NNNNNNNN |
immutable per Facepunch build; the workflow resolves the newest and bumps it |
| Carbon | production_build |
a moving tag, republished in place; the version actually built is read from Carbon.Linux.Release.info and recorded as a label |
.github/workflows/rebuild-on-new-release.yml runs daily and rebuilds when either
one moves. Neither :latest nor Carbon's edge/experimental/rustbeta builds
are ever used: a Flux app pinned to a rolling tag gets every customer's container
rebuilt onto unreleased code.
The Carbon tree needs its own mount, the way the Oxide one does:
containerData: "g:/srv/rust/server/rustcarbon|m:carbon:/srv/rust/carbon"
Without the second mount the plugins live in the container's writable layer:
they survive a restart and are erased by the next redeploy, and the dashboard's
file browser cannot see them at all. Plugins go in carbon/plugins, their
settings in carbon/configs.