synvael/docs/adr/0010-net-crate-async-runtime.md
Serkyo cae434d227
Some checks are pending
CI / Rust Check & Lint (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Lua Lint & Format (push) Waiting to run
CI / LFS Pointer Guard (push) Waiting to run
docs(workspace): rewrite the subsystem notes and ADRs
2026-08-06 22:52:57 +02:00

2.7 KiB

0010. Dedicated net crate with a confined async runtime

  • Status: Accepted
  • Date: 2026-07-12

Context

Our network transport uses QUIC via quinn, which is an asynchronous library built squarely on the tokio runtime and inherently requires TLS 1.3 through rustls. These are heavy dependencies that drag an entire async ecosystem into the build tree.

We have a strict mandate that the shared crate must stay incredibly lean and dependency-light. It acts as our core protocol and data layer, holding pure serde message types with absolutely no async, rendering, or engine internals. Dropping transport code straight into shared would brutally violate that mandate, forcing every single consumer of our protocol types to compile both tokio and rustls.

At the same time, our main simulation is completely synchronous. The server runs a synchronous bevy_ecs loop, and the client runs a synchronous winit event loop. Introducing an async runtime must absolutely not force those loops to become async or let tokio leak throughout the entire workspace.

Decision

Transport logic lives completely isolated in a dedicated net crate, entirely separate from shared, and the tokio runtime is strictly confined to it.

  • net completely owns the quinn, tokio, and rustls dependencies. It handles the QUIC endpoints, the full connection lifecycle, and all wire framing.
  • shared remains perfectly clean, holding only the raw protocol message types (using serde, with zero async logic).
  • Both client and server depend on net.
  • We bridge the async runtime to the synchronous simulation using crossbeam-channel, which aligns perfectly with the message-passing concurrency model defined in DEVELOPMENT.md. The synchronous loops never ever call .await; they simply send and receive protocol messages across the channel boundary.

Consequences

  • shared stays extremely lean. Consumers that only need the protocol types do not have to compile the massive async stack.
  • The async surface is perfectly quarantined. Only net actually deals with tokio, keeping both the server and client loops happily synchronous and completely unchanged.
  • The workspace now contains six crates, with net sitting neatly between shared (which provides the types it carries) and the two binaries (which actively drive it).
  • The channel bridge acts as an explicit, hard boundary that must be maintained. Any work crossing between the async runtime and the synchronous simulation must flow purely through channels. We never pass shared async state or force the simulation to become async.
  • Because rustls requires a crypto provider backend, the transport code has to manually install one before building any QUIC configuration.