Skip to main content

Command Palette

Search for a command to run...

Learning Rust as a C# Engineer

Updated
8 min read
Learning Rust as a C# Engineer

Over the Christmas break I kept seeing the same story surface again and again. Microsoft has started replacing large parts of its C and C++ code with Rust, with a stated long-term goal of removing C and C++ from critical underlying libraries by around 2030. That caught my attention. Not because Rust is fashionable, but because Microsoft rarely makes moves like that without a very clear cost-benefit analysis behind them.

As a C# engineer, that immediately raised a practical question. If Rust is becoming the new systems language inside Microsoft, and if more of the runtime, networking stack, and platform tooling will eventually depend on it, then learning Rust now feels like a safe bet rather than a speculative one.

The question is how to approach it.

There is no shortage of Rust tutorials, books, and courses. Many of them are excellent. Most of them also assume you are either new to programming or moving from another low-level language. If you already spend your day working in high-level C#, a lot of that material feels like friction. You end up re-learning concepts you already understand, just with different syntax.

I saw the same advice from experienced developers who had already crossed this bridge. If you already have strong experience and a high level, the fastest way to make Rust click is not to start with toy examples. Its to build something low-level immediately, where Rust’s constraints actually matter.

I decided to build a project.

The idea

The goal was simple on the surface.

Build a small networking system where a sender sends messages, a receiver processes them, and a separate process visualises what is happening internally. Nothing clever. No abstractions for the sake of it.

Under the surface, the goal was more specific.

I wanted to force myself to deal with explicit framing and byte handling, async IO in a real socket loop, and a clearer separation between transport, parsing, and application logic. Ownership and borrowing showed up too, mainly around buffers and task boundaries, though I haven’t pushed into the deeper lifetime-heavy patterns yet.

At the same time, I wanted to keep one foot in familiar territory. I didnt want to abandon C# entirely while learning Rust. So I split the system across two machines.

I had access to a Mac and a PC on the same network so I used that to my advantage.

The Rust receiver runs on the Mac.
The C# sender and telemetry visualiser run on the PC.

That split turned out to be more useful than I expected.

High-level architecture

The system consists of three very small programs.

A C# sender
A Rust receiver
A C# telemetry visualiser

The sender connects directly to the receiver over TCP and sends framed JSON messages. Each message has a length prefix so the receiver can read full frames cleanly.

The receiver reads the raw bytes, parses the JSON, and emits telemetry events as the message conceptually moves through layers. These layers are not pretending to be the real OS network stack. They are a teaching tool. They make invisible steps visible.

The receiver sends telemetry over UDP to the visualiser. UDP keeps the telemetry path simple and non-blocking.

The visualiser listens on a UDP port and renders a live terminal UI showing messages moving through application, transport, network, and link lanes.

There is no router process. No segmentation. No configurable packet sizes. I deliberately removed all of that. The goal here is learning Rust, not building a networking framework.

Why Rust on the receiver side

Putting the receiver in Rust was a deliberate choice.

In C#, reading from a network stream is trivial. You can build something that works in minutes, but it hides a lot of detail. Buffers are managed for you. Memory ownership is implicit. Errors often surface late.

In Rust, you cannot avoid those details.

Reading a length prefix forces you to think about endianness.
Reading into a buffer forces you to manage capacity and resizing.
Passing data between functions forces you to think about ownership.

None of this feels academic when you are dealing with a real socket.

use anyhow::Context;
use bytes::BytesMut;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::{env, net::SocketAddr, sync::Arc};
use tokio::{
    io::AsyncReadExt,
    net::{TcpListener, TcpStream, UdpSocket},
    time::{sleep, Duration},
};

#[derive(Debug, Deserialize)]
struct Packet {
    packet_id: u64,
    created_at_ms: u64,
    payload: String,
}

#[derive(Debug, Serialize)]
struct TelemetryEvent<'a> {
    ts_ms: i64,
    source: &'a str, // "receiver"
    packet_id: u64,
    layer: &'a str, // "link" | "network" | "transport" | "application"
    kind: &'a str,  // "enter" | "deliver" | "error"
    note: &'a str,
    size: usize,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let data_addr = env::var("DATA_BIND").unwrap_or_else(|_| "0.0.0.0:9000".to_string());
    let telemetry_target =
        env::var("TELEMETRY_TARGET").unwrap_or_else(|_| "127.0.0.1:9100".to_string());

    println!("Receiver listening on {}", data_addr);
    println!("Telemetry target: {}", telemetry_target);

    let listener = TcpListener::bind(&data_addr).await?;

    let udp = Arc::new(UdpSocket::bind("0.0.0.0:0").await?);
    let telemetry_addr: SocketAddr = telemetry_target
        .parse()
        .context("TELEMETRY_TARGET must be like 192.168.0.10:9100")?;

    loop {
        let (socket, peer) = listener.accept().await?;
        println!("Accepted connection from {}", peer);

        let udp = udp.clone();
        tokio::spawn(async move {
            if let Err(e) = handle_connection(socket, peer, udp, telemetry_addr).await {
                eprintln!("Connection error ({}): {:?}", peer, e);
            }
        });
    }
}

async fn handle_connection(
    mut socket: TcpStream,
    peer: SocketAddr,
    udp: Arc<UdpSocket>,
    telemetry_addr: SocketAddr,
) -> anyhow::Result<()> {
    loop {
        // Read u32 big-endian length prefix
        let mut len_buf = [0u8; 4];
        if socket.read_exact(&mut len_buf).await.is_err() {
            println!("Connection closed by {}", peer);
            return Ok(());
        }

        let frame_len = u32::from_be_bytes(len_buf) as usize;

        // Read frame bytes
        let mut buf = BytesMut::with_capacity(frame_len);
        buf.resize(frame_len, 0);
        socket.read_exact(&mut buf).await?;

        // Parse JSON
        let packet: Packet = match serde_json::from_slice(&buf) {
            Ok(p) => p,
            Err(_) => {
                let _ = send_event(
                    &udp,
                    telemetry_addr,
                    TelemetryEvent {
                        ts_ms: Utc::now().timestamp_millis(),
                        source: "receiver",
                        packet_id: 0,
                        layer: "transport",
                        kind: "error",
                        note: "json_parse_failed",
                        size: frame_len,
                    },
                )
                    .await;
                continue;
            }
        };

        // Emit layer events (simple, deterministic)
        send_event(
            &udp,
            telemetry_addr,
            TelemetryEvent {
                ts_ms: Utc::now().timestamp_millis(),
                source: "receiver",
                packet_id: packet.packet_id,
                layer: "link",
                kind: "enter",
                note: "frame_received",
                size: frame_len,
            },
        )
            .await?;
        sleep(Duration::from_millis(layer_delay_ms())).await;

        send_event(
            &udp,
            telemetry_addr,
            TelemetryEvent {
                ts_ms: Utc::now().timestamp_millis(),
                source: "receiver",
                packet_id: packet.packet_id,
                layer: "network",
                kind: "enter",
                note: "packet_parsed",
                size: frame_len,
            },
        )
            .await?;
        sleep(Duration::from_millis(layer_delay_ms())).await;

        send_event(
            &udp,
            telemetry_addr,
            TelemetryEvent {
                ts_ms: Utc::now().timestamp_millis(),
                source: "receiver",
                packet_id: packet.packet_id,
                layer: "transport",
                kind: "deliver",
                note: "delivered_to_app",
                size: frame_len,
            },
        )
            .await?;
        sleep(Duration::from_millis(layer_delay_ms())).await;

        send_event(
            &udp,
            telemetry_addr,
            TelemetryEvent {
                ts_ms: Utc::now().timestamp_millis(),
                source: "receiver",
                packet_id: packet.packet_id,
                layer: "application",
                kind: "deliver",
                note: "payload_ready",
                size: frame_len,
            },
        )
            .await?;
        sleep(Duration::from_millis(layer_delay_ms())).await;

        // Console breakdown
        println!("---");
        println!("From:            {}", peer);
        println!("Frame length:    {} bytes", frame_len);
        println!("packet_id:       {}", packet.packet_id);
        println!("created_at_ms:   {}", packet.created_at_ms);
        println!("payload:         {}", packet.payload);

        // Raw preview (first 96 bytes)
        let preview_len = buf.len().min(96);
        let preview = &buf[..preview_len];
        println!("raw preview ({}): {:02X?}", preview_len, preview);
    }
}

async fn send_event(udp: &UdpSocket, target: SocketAddr, ev: TelemetryEvent<'_>) -> anyhow::Result<()> {
    let bytes = serde_json::to_vec(&ev)?;
    udp.send_to(&bytes, target).await?;
    Ok(())
}

fn layer_delay_ms() -> u64 {
    env::var("LAYER_DELAY_MS")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(120)
}

The receiver reads four bytes for the frame length, then reads exactly that many bytes into a buffer. It then attempts to deserialise JSON from that buffer. If deserialisation fails, it emits an error event and moves on.

Once the packet is parsed, the receiver emits telemetry events as it conceptually moves through layers. Each event includes a timestamp, packet id, layer name, event kind, and size.

Finally, the receiver prints a detailed breakdown to the console. Peer address. Frame size. Packet id. Timestamp. Payload contents. A raw hex preview of the first bytes. Nothing hidden.

This is where Rust started to click.

The compiler forces you to be explicit. Once the code builds, you know the data flow is correct. There is very little ambiguity.

Why keep the sender and visualiser in C

I kept the sender in C# for two reasons.

First, speed. I didnt want to spend time building input handling and framing logic from scratch while still learning Rust. In C#, that part is muscle memory.

Second, comparison. Writing the sender in C# makes the differences obvious. You can see how much the runtime gives you for free, and what Rust expects you to manage yourself.

The telemetry visualiser also stays in C#. Spectre.Console makes it easy to build a clean terminal UI quickly. More importantly, it keeps the visualisation code separate from the Rust learning exercise.

Rust does one job. The visualiser does another.

That separation matters.

Running across two machines

Running the Rust receiver on a Mac and the C# processes on a PC adds one more layer of realism.

This is not a loopback demo. Real sockets are involved. Real IP addresses. Real failure modes. You immediately run into practical issues. Address binding. Firewalls. Ports already in use. Processes exiting unexpectedly. All of that is part of systems work, and Rust does not shield you from it. It also reinforces a useful mental model. Rust is not replacing C# for application development. It is complementing it. The two languages sit at different levels of the stack, and they interact over simple, explicit protocols. That feels very close to how modern platforms are actually built.

What this project taught me

The biggest takeaway is that Rust makes more sense when you stop trying to learn it in isolation.

If you come from C#, you already understand concurrency, async workflows, and data modelling. Rust is not trying to re-teach those concepts. It is forcing you to be explicit about things the runtime normally hides. Ownership is not abstract when you are passing buffers around. Lifetimes are not theoretical when a socket read depends on them. Error handling feels stricter, but also more honest. Building a real, low-level project immediately surfaces the value Rust brings. You stop fighting the language and start seeing why it exists.

Where next?

Im deliberately stopping this project here. It works. Its understandable. It does exactly what I need it to do. If I extend it later, it will be with a clear purpose. Maybe adding a router process. Maybe experimenting with reordering or loss. Maybe rebuilding the receiver in a different async model.

For now, this was enough. I got what I wanted from it.

It sparked my interest enough to think of a different more involved bigger project to take on next time.

If you are a C# engineer thinking about learning Rust, my advice is simple. Do not start with the beginner examples. Pick a problem where memory, and data flow matter. Build something small but real, get your hands dirty & have fun.