Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 22 min read

TCP Is a Byte Stream: Designing a Framed Application Protocol in Rust with Tokio

A companion article, Discovery Is Not Authentication, covers how Lanweave establishes an authorized session between two devices on a local network: mDNS discovery, a one-time pairing code, TLS 1.3, and separate approval

A companion article, Discovery Is Not Authentication, covers how Lanweave establishes an authorized session between two devices on a local network: mDNS discovery, a one-time pairing code, TLS 1.3, and separate approval for every transfer. This article starts where that trust boundary ends: once two peers have an authenticated, encrypted connection, what actually travels over it, and why is it built this way?

Lanweave is a Rust terminal application for sending files and folders between devices on the same LAN (github.com/etim66/lanweave). It is a work in progress and has not been audited; nothing here is a security claim. The focus is protocol engineering: framing, strict parsing, connection state, backpressure, and streaming โ€” the part of the system that must stay correct when the peer is hostile and the file is larger than memory.

Reliability is not message orientation

TCP guarantees that bytes arrive in order, without duplication, and without loss, or the connection fails (RFC 9293). It does not guarantee that a read corresponds to a write, or that a message arrives whole. A single read may return:

  • one byte of a header,
  • a complete 12-byte header and half of its body,
  • exactly one message,
  • five messages concatenated,
  • or any other split the kernel, network, and retransmission timing happen to produce.

This is not a bug to be fixed; it is the interface. TCP is a byte stream. TLS does not change that at the application layer: the record layer has its own framing, but an application read returns decrypted stream bytes, and no API promises that a record boundary lines up with a read or write. Any protocol whose messages are larger than one byte needs its own boundaries and validation.

The failure mode is familiar: code that works on loopback with small messages, then corrupts data on a real network where a 4,000-byte write is split across several reads. The fix is not a bigger read buffer; it is an incremental parser that treats the socket as an arbitrary byte source and the protocol as a state machine over it.

Lanweave's frame codec is that parser. The design rule: validate everything before allocating, and handle partial and coalesced reads identically to perfectly sized ones. A unit test takes a wire image of several frames and decodes it with the input split at every byte position; all splits must produce the same frame sequence.

flowchart TB
    A[TcpStream<br/>ordered bytes, no messages] --> B[TLS 1.3<br/>confidentiality + integrity]
    B --> C[Frame codec<br/>boundaries, lengths, kinds]
    C --> D[Strict JSON controls<br/>or raw DATA bytes]
    D --> E[Protocol state machine<br/>direction, order, phase]
    E --> F[Transfer engine<br/>stream, hash, verify, publish]

Each layer has one job. The frame codec knows nothing about messages, the message parser nothing about sockets, the state machine nothing about files. That separation makes each layer independently testable โ€” and lets the frame codec be fuzzed without a network.

What goes on the wire

One TCP connection carries one authorized session, and that session can carry several sequential transfers in either direction. Control traffic is strict JSON; file bytes are raw binary frames. The distinction is deliberate:

  • JSON for control because the messages are small, inspectable field names help debugging, and a strict schema is easy to test exhaustively. A transfer_request is a list of names and sizes.
  • Binary for file data because base64 or numeric arrays would add CPU and memory overhead for no benefit. File bytes never pass through the JSON parser โ€” the frame layer routes them to the transfer sink.

Newline-delimited JSON is a third possibility. It suits control-only protocols, but not binary file data: file bytes can contain newlines, so the data would still need an escaping layer. Length-prefixing applies one rule to both control and data.

The frame format

Every control message and every binary chunk travels in one frame with a fixed 12-byte header:

Offset Size Field Value
0 4 magic ASCII LNWV
4 1 frame version 1
5 1 kind 0 = JSON control, 1 = DATA
6 2 header length unsigned big-endian 12
8 4 body length unsigned big-endian
12 N body JSON object or raw file bytes

The header is fixed-size and fully specified, so a parser can reject a malformed frame after 12 bytes. The body length is a 32-bit unsigned integer, but the codec never trusts it: each frame kind has a maximum, checked before any body allocation.

Item Limit
Generic JSON control body 1 MiB
DATA body 1 MiB (minimum 1 byte)
hello body 4,000 bytes
pairing body 4,096 bytes
transfer_request body 256 KiB
Files per manifest 1 to 1,024
Integer and checked total 2^53 โˆ’ 1

One design detail is easy to miss: a DATA frame carries no file index and no offset. The connection state already knows which file is in flight and how many bytes have been accepted, so repeating that would create two sources of truth that a malicious peer could make disagree. DATA validity is instead a property of the protocol phase: data is acceptable only while receiving, and only after ready. A zero-byte file sends no DATA frame at all; it goes straight to file_end.

Note the difference between the maximum frame size (1 MiB, enforced on untrusted input) and the streaming chunk size (64 KiB, the sender's policy). The maximum bounds what an attacker can make the process allocate; the chunk size controls how often the sender checks channels, deadlines, and cancellation.

Incremental decoding without unbounded allocation

The decoder works over a growing BytesMut buffer. It returns Ok(None) when more bytes are needed, Ok(Some(frame)) for a complete frame, and an error that closes the connection for anything malformed:

pub fn decode(src: &mut BytesMut) -> Result<Option<Frame>, FrameError> {
    if src.len() < HEADER_LEN {
        return Ok(None);                          // wait for more bytes
    }
    if src[..4] != MAGIC {
        return Err(FrameError::BadMagic);
    }
    if src[4] != FRAME_VERSION {
        return Err(FrameError::BadVersion);
    }
    let kind = match src[5] {
        KIND_CONTROL => FrameKind::Control,
        KIND_DATA => FrameKind::Data,
        _ => return Err(FrameError::BadKind),
    };
    let header_len = u16::from_be_bytes([src[6], src[7]]);
    if usize::from(header_len) != HEADER_LEN {
        return Err(FrameError::BadHeaderLength);
    }
    let body_len = u32::from_be_bytes([src[8], src[9], src[10], src[11]]) as usize;
    if body_len > kind.max_body() {
        return Err(FrameError::BodyTooLarge);     // validated before allocation
    }
    if kind == FrameKind::Data && body_len == 0 {
        return Err(FrameError::EmptyData);
    }

    let needed = HEADER_LEN + body_len;
    if src.len() < needed {
        // Bounded by the just-validated body limit.
        src.reserve(needed - src.len());
        return Ok(None);
    }
    let mut frame = src.split_to(needed);
    frame.advance(HEADER_LEN);
    let body = frame.freeze();
    Ok(Some(match kind {
        FrameKind::Control => Frame::Control(body),
        FrameKind::Data => Frame::Data(body),
    }))
}

Four properties carry most of the weight:

  1. Validate before allocate. Magic, version, kind, header length, and body length are checked from the first 12 bytes. A claimed 4 GiB body gets BodyTooLarge after 12 bytes, not a 4 GiB allocation; reserve only runs for a length that already passed a per-kind limit.
  2. The buffer is bounded by construction. It accumulates at most one maximum-size frame plus whatever arrived after it. split_to removes the consumed prefix, so memory does not grow with frame count.
  3. The body is handed off, not copied. split_to gives the frame its own slice of the buffer; advance skips the header; freeze yields an immutable Bytes the next layer can forward without copying the payload. For DATA, that Bytes reaches the file writer essentially unchanged.
  4. Malformed framing is terminal. There is no "skip and continue" path: a bad magic, unknown kind, or impossible header length means a bug or hostile intent, and the connection closes. Recovery would mean trusting more of the stream that just proved untrustworthy.

Reading frames from a socket

FramedConnection::read_frame combines the decoder with an async read loop (read_buf):

pub(crate) async fn read_frame(&mut self) -> Result<Option<Frame>, ReadError> {
    loop {
        if let Some(frame) = decode(&mut self.buffer).map_err(ReadError::Frame)? {
            return Ok(Some(frame));
        }
        let read = self
            .reader
            .read_buf(&mut self.buffer)
            .await
            .map_err(ReadError::Io)?;
        if read == 0 {
            return if self.buffer.is_empty() {
                Ok(None)          // clean close after complete frames
            } else {
                Err(ReadError::Truncated)   // close in the middle of a frame
            };
        }
    }
}

The difference between Ok(None) and Err(Truncated) is protocol honesty in miniature. A close after complete frames is a normal end of stream, and every frame that arrived has been delivered. A close mid-frame means the stream was cut short โ€” possibly a crash, possibly an attack โ€” and the partial data cannot be interpreted. The transport refuses to pretend those cases are the same; a unit test writes a frame minus its last three bytes and asserts truncation rather than a clean close.

ReadError keeps the taxonomy narrow: I/O failure, malformed frame, or truncation. Message-level problems belong to the parser that receives the body.

Strict JSON, because ambiguity is a vulnerability

The control layer decodes a Bytes body into a typed Control enum. The rules are intentionally unforgiving:

  • The body must be exactly one UTF-8 JSON object with no trailing value (RFC 8259).
  • Duplicate fields are rejected.
  • Unknown fields are rejected once a schema has consumed its fields.
  • Numbers are integers from 0 through 2^53 โˆ’ 1. Floats, exponent notation, and negatives are rejected; the protocol does not use null.
  • String values come from closed sets (accepted, verified, user_rejected, and so on); anything else is invalid.
  • Field names, strings, arrays, nesting, and total body size have fixed bounds.
  • Binary values use canonical unpadded base64url with an exact decoded length (RFC 4648 ยง5); digests are exactly 64 lowercase hexadecimal characters.

Two of these choices look like pedantry until they prevent a class of bug.

Duplicate and unknown fields create parser disagreement: two implementations that both "accept" {"accepted": false, "accepted": true} or {"type": "ready", "files": [...]} may disagree about what the message means. Rejecting removes the disagreement, and it stops a future protocol version from smuggling fields past an old implementation.

Errors never carry peer-controlled text. The parser's error type holds only &'static str schema names, never the peer's bytes. Error messages are the classic place untrusted input gets logged or reflected, and a detailed parse error can become an oracle for probing the parser. The wire error codes are coarse (invalid_message, authentication_failed, and so on); the exact reason stays local.

The parser's shape is deliberate. A JSON visitor collects entries as borrowed key/value pairs, each value an unparsed RawValue slice of the original body. Schemas ask for fields by name, and duplicate detection happens at access time:

/// Returns the single value of `field`, rejecting repeats.
fn one(&self, field: &'static str) -> Result<Option<&'a RawValue>, MessageError> {
    let mut values = self.values(field).into_iter();
    match values.next() {
        None => Ok(None),
        Some(value) => {
            if values.next().is_some() {
                Err(MessageError::DuplicateField(field))
            } else {
                Ok(Some(value))
            }
        }
    }
}

Because entries are borrowed slices of the body โ€” already capped at 1 MiB by the frame layer โ€” parser memory is bounded by the body itself, with no intermediate tree of owned strings.

On the encoding side, messages serialize with a documented field order and canonical encodings so golden fixtures are byte-stable. That reproducibility lets tests assert exact wire bytes, not just round-trips.

A protocol state machine, not just a parser

Parsing tells you a message is well-formed. It cannot tell you that a message is allowed right now. Lanweave answers the second question in a separate, pure module: protocol::state.

A connection end has a Role (pairing initiator or responder) and a Phase tracking the exchange in progress: hello handshake, pairing request and response, pairing record count, authorized-idle, pending outbound proposal, inbound review, sending or receiving a file, terminal closing. The session owner feeds decoded messages into three functions:

  • send(state, &control) โ€” validates a locally generated control against the phase and returns the implied actions.
  • accept(state, inbound) โ€” validates an inbound control or DATA frame and returns actions such as "deliver these bytes", "proposal ended", "transfer finished", or "session closed".
  • send_data(state) โ€” answers a narrower question: may this side put file bytes on the wire right now?

The state layer has no sockets, no files, no TLS, no UI. Its tests can therefore walk an initiator and a responder through an entire session โ€” two plain state values, no network โ€” and assert every transition.

The rules are the specification in executable form: the initiator sends the first hello; pairing records alternate and only the correct party may send each one; DATA is valid only while receiving inside an un-ended file; file_end and file_result indices must match the tracked file; any transition not explicitly allowed is a terminal WrongState error. Wrong state is not recoverable: a peer that violates the sequence has made the rest of the stream unreliable.

The state machine also encodes the version 1 simultaneous-proposal rule. Two peers can both propose while idle, and there is no request ID, so the pairing initiator's request wins. A responder with a pending proposal withdraws and re-queues it and sets one collision_pending marker. The protocol layer then consumes exactly one stale transfer_response(busy) for that withdrawn proposal โ€” in any phase of the winning transfer โ€” and clears the marker:

// The responder consumes exactly one stale busy response for its
// withdrawn proposal, in any phase of the winning transfer.
if state.collision_pending
    && let Inbound::Control(Control::TransferResponse(response)) = &inbound
    && matches!(response.reason, Some(TransferRejection::Busy))
{
    state.collision_pending = false;
    return Ok(Vec::new());
}

That rule is a direct consequence of the "no request IDs" decision. Request IDs and a transaction layer would handle races more uniformly, but they would add state and failure modes. The chosen design keeps the state space small and pays with a slightly awkward collision rule the tests exercise explicitly.

One writer, two bounded queues

Reading is a loop. Writing is a task.

Concurrent writers could interleave their bytes and corrupt the stream, so there is exactly one writer task per connection, fed by two bounded queues:

flowchart LR
    S[session owner] -->|controls, capacity 64| CQ[control queue]
    S -->|DATA frames, capacity 8| DQ[DATA queue]
    CQ --> W[single writer task]
    DQ --> W
    W -->|write_all + flush per frame| TLS[TLS stream]
    B[barrier] -->|queued behind the frames it observes| CQ

The writer resolves frames in a select! loop with a biased branch order that checks DATA first:

loop {
    tokio::select! {
        biased;
        frame = data.recv() => {
            let Some(frame) = frame else { break };
            if write_frame(&mut writer, &frame).await.is_err() { break; }
        }
        item = controls.recv() => {
            match item {
                None => break,
                Some(WriterItem::Frame(frame)) => {
                    if write_frame(&mut writer, &frame).await.is_err() { break; }
                }
                Some(WriterItem::Barrier(acknowledge)) => {
                    let _ = acknowledge.send(());
                }
            }
        }
        stopped = stop.changed() => {
            if stopped.is_err() || *stop.borrow() { break; }
        }
    }
}

The ordering guarantee is precise, and the module documents both halves:

  • A control queued after a DATA frame cannot overtake it. That makes file_end safe: the session queues the trailing DATA chunks, then file_end, and the writer prefers DATA while any is queued, so file_end always follows the last chunk of its file.
  • A control queued before a DATA frame may still be overtaken, because the writer prefers DATA. Callers must not rely on cross-queue send order.

The second half is easy to get wrong by assuming one global FIFO across both queues. Naming the limitation and testing the first half was worth more than inventing an ordering guarantee the implementation does not need.

Two mechanisms make writing robust:

  • Flush per frame. Every frame is written with write_all, flushed (flush), and the whole write-plus-flush is wrapped in a 30-second deadline. A peer that stops reading applies backpressure; one that stops forever surfaces as a write deadline instead of a task that hangs until shutdown.
  • A flush barrier. send_control_flushed queues the frame and a barrier in the same control queue and returns only when the writer reaches the barrier โ€” meaning every earlier frame has been written and flushed. This exists for one requirement: the pairing responder must not treat the session as authorized until its final confirmation is actually on the wire. It is the kind of ordering requirement that is invisible until it breaks a security property.

Shutdown has two flavors. Outbound::close signals the writer, waits for the task, lets queued frames finish, and shuts the write half down so the peer sees one clean EOF. Dropping the Outbound aborts the writer, which can truncate an in-flight frame โ€” the peer sees Truncated instead. Both paths are tested: how a connection ends is part of the protocol.

Backpressure, end to end

Streaming to a peer whose network is slower than the disk is a classic way to turn a small program into a memory-hungry one: read fast, buffer without limit, watch RSS climb. Lanweave's answer: every handoff in the pipeline is a bounded channel, so a slow consumer blocks a fast producer instead of accumulating a queue.

Channel Capacity What it protects
App events 32 The event loop's inbox
App effects 16 Work dispatched to network services
Discovery events 32 mDNS updates into the app
Session commands 8 UI actions into the session owner
Accepted sockets 4 Listener to session owner (connection floods)
Per-connection commands 4 Local decisions into one connection task
Outbound control frames 64 Control traffic to the writer
Outbound DATA frames 8 File data to the writer (one frame โ‰ค 1 MiB)
File reader to sender 4 64 KiB chunks from disk to the session

The DATA queue matters most for memory: the writer path holds at most eight queued 1 MiB frames plus the one being written, the file-reader channel holds at most four 64 KiB chunks, and the reader owns one 64 KiB buffer. A 40 GB file does not produce a 40 GB buffer; it produces a steady state where the disk read task sleeps whenever the network cannot keep up. Backpressure is not a feature added later โ€” it is what makes the memory bound true.

Streaming files without loading them

Both ends of a transfer run the same bounded pattern: read a chunk, hash it, hand it to the network; receive a chunk, write it to a temporary file, hash it.

On the sending side, the source is re-checked immediately before reading โ€” a file that is no longer a regular file of the reviewed size is refused rather than streamed:

pub(crate) async fn send_file(
    path: &Path,
    size: u64,
    sink: &mpsc::Sender<Bytes>,
) -> Result<[u8; 32], SourceError> {
    let metadata = std::fs::symlink_metadata(path).map_err(|_| SourceError::Changed)?;
    if !metadata.file_type().is_file() || metadata.len() != size {
        return Err(SourceError::Changed);
    }

    let mut file = tokio::fs::File::open(path).await.map_err(|_| SourceError::Changed)?;
    let mut hasher = Sha256::new();
    let mut buffer = vec![0u8; CHUNK_SIZE]; // 65_536

    loop {
        let read = file.read(&mut buffer).await.map_err(|_| SourceError::Io)?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
        sink.send(Bytes::copy_from_slice(&buffer[..read]))
            .await
            .map_err(|_| SourceError::Io)?;
    }

    Ok(hasher.finalize().into())
}

read_to_end would have been three lines. It would also make peak memory proportional to file size, contradicting the bounded-memory constraint. Streaming costs something real: an error can appear mid-file, after bytes have been sent. The protocol handles that by scoping failure to the current transfer and making the consequences explicit.

On the receiving side, each chunk is checked against the declared size before it is written, and the file is published only after the accumulated digest matches:

pub(crate) async fn write(&mut self, chunk: &[u8]) -> Result<(), FileFailure> {
    if self.written.saturating_add(chunk.len() as u64) > self.expected {
        return Err(FileFailure::SizeMismatch);   // excess data is rejected
    }
    self.temp.write_all(chunk).await.map_err(|_| FileFailure::WriteFailed)?;
    self.hasher.update(chunk);
    self.written += chunk.len() as u64;
    Ok(())
}

pub(crate) async fn finish(self, digest: [u8; 32]) -> Result<(), FileFailure> {
    if self.written != self.expected {
        return Err(FileFailure::SizeMismatch);   // short or long
    }
    let actual: [u8; 32] = self.hasher.finalize().into();
    if actual != digest {
        return Err(FileFailure::HashMismatch);
    }
    self.temp.finalize().await.map_err(|error| match error {
        StorageError::DestinationExists => FileFailure::DestinationExists,
        _ => FileFailure::WriteFailed,
    })
}

The receiver writes to a restrictive temporary file and never under the final name until verification succeeds; dropping the unfinished file removes the partial. Publication is a no-replace operation (persist_noclobber), so a file that appears after preflight is never overwritten. The sender waits for each file_result before starting the next file, so "verified" is always a peer's statement about a complete, published file, not a local hope based on a socket write.

sequenceDiagram
    participant R as File reader task
    participant S as Session owner
    participant W as Writer task
    participant P as Peer
    R->>S: 64 KiB chunk (bounded channel, capacity 4)
    S->>W: DATA frame (bounded queue, capacity 8)
    W->>P: write_all + flush per frame
    R-->>S: reader finished, returns SHA-256
    S->>W: file_end(index, sha256)
    W->>P: file_end
    P-->>S: file_result(verified)
    Note over S: only now does the next file start

Cancellation, timeouts, and cleanup

The send and receive loops are select! expressions over three events: an inbound read, a local UI command, and (on the sending side) the next chunk from the file reader. Cancellation is not a special path bolted on top; it is one of the arms the loop already waits on.

Cancellation semantics follow the protocol's failure scopes:

  • Before ready, nothing has been sent, so cancellation ends the proposal and the session stays authorized. The reviewed file list is kept locally so the user can try again.
  • After ready, file bytes may be in flight. The canceller sends transfer_cancel, the partial file is removed, and the session closes โ€” in-flight DATA cannot be attributed to a later transfer without transfer IDs.
  • On a progress deadline, a stalled or silent peer is reported with a terminal error(timeout) when the connection is still writable, and the session closes after cleanup.

Receiver cleanup is tied to ownership, not a manual "run this on failure" step: the partial file is an object, and dropping it without finalizing removes the file. Cleanup therefore holds on every early return, not just the ones a developer remembered.

Testing the protocol

Protocol input is a byte string, which makes the set of interesting inputs more enumerable than in UI code. The test suite uses that:

  • Every split point. A wire image of several frames is decoded with the input divided at every byte position; all splits must produce the identical frame sequence, covering header splits, body splits, and coalesced frames in one sweep.
  • Byte-by-byte feeding. The same images are fed one byte at a time, catching parsers that only handle boundaries one level deep.
  • Golden vectors. Every control message has an exact expected JSON encoding, including canonical base64url pairing values and 64-character lowercase digests, so a specification and an implementation can be compared directly.
  • Malformed input. Bad magic, bad version, unknown kind, wrong header length, oversized body, and empty DATA form a table of expected errors, all asserted to fail before any body allocation.
  • Boundedness under pressure. A gated writer that never accepts bytes proves a full DATA queue blocks the sender instead of growing, and that the blocked send completes when the writer drains.
  • Real connections. The frame and protocol layers also run through actual TLS connections and an end-to-end loopback session: real handshake, real pairing, real files on disk, both directions.

One fuzz target complements the deterministic tests: frame_codec feeds arbitrary bytes to the decoder and requires that it never panics and never allocates for invalid or oversized headers. It is built with cargo fuzz (cargo-fuzz book), lives in a separate crate excluded from CI, and requires nightly Rust. Its whole body is small:

fuzz_target!(|data: &[u8]| {
    let mut buffer = bytes::BytesMut::from(data);
    while let Ok(Some(_frame)) = lanweave::fuzzing::decode(&mut buffer) {}
});

The library exposes the private codec to the fuzzer through a fuzz feature that compiles a re-export shim, so normal builds keep a private API. Fuzzing covers only the frame decoder; property tests for strict JSON, the state machine, pasted paths, and destination-name mapping are planned work.

Parsing untrusted bytes: the security implications

A protocol parser is attack surface. The most dangerous designs are generous: they accept what they do not understand, allocate from lengths before validating, keep owned copies of arbitrary size, and return errors that quote the input. Lanweave moves the other way at every point:

  • Validate before allocating, and bound the schema, not just the transport. The frame length is checked against a per-kind maximum using only the 12-byte header โ€” a hostile 4 GiB claim costs 12 bytes to reject โ€” and body limits are per message type, with arrays, strings, and integers capped at 2^53 โˆ’ 1 so checked totals cannot exceed what the domain can represent.
  • Reject ambiguity. Duplicate fields, unknown fields, out-of-set string values, and impossible state transitions are errors, not warnings. An implementation that accepts more than the specification allows is an implementation whose behaviour is defined by accident.
  • Never reflect peer data. Parse errors contain only static schema names, wire errors use closed codes, and untrusted names are escaped before they reach a terminal. A malformed message is data to be judged, not text to be repeated.
  • Close on malformed framing. Once the frame layer has seen a violation, the rest of the stream is untrustworthy. There is no half-open recovery state, which removes a whole category of desynchronization bugs.

None of this is a proof of safety. Fuzzing shows the absence of crashes it happened to observe; tests show conformance to a draft; parser bugs can still exist in carefully read code. What the design provides is a small, explicit surface with limits stated in one document and enforced in one place.

Lessons learned

Boundaries are the protocol's first job. The frame header, per-kind limits, and the Ok(None)-means-incomplete contract make everything above them possible. Deciding what a DATA frame does not know โ€” no file index, no offset โ€” pushed the streaming questions to the front, and the answer (the connection state knows) simplified both layers.

Keep the pure layers pure. The state machine has no I/O, the parser no sockets, the frame codec no message knowledge. Each is cheap to test exhaustively, and when something breaks, "which layer is wrong" is usually obvious.

Say exactly what the writer guarantees. Single-writer serialization is easy; cross-queue ordering is subtle. Documenting that file_end cannot overtake its DATA but that a control queued before DATA may be overtaken beats a vague "frames are ordered" claim โ€” and tells the next person which invariants not to rely on.

Backpressure is architecture, not tuning. Bounded channels everywhere make the memory ceiling a property of the topology, not a benchmark. The cost is that producers must be written to await โ€” exactly the discipline the design wants.

Test the seams. Split-point decoding, coalesced reads, truncated closes, full queues, and real TLS connections test where assumptions meet reality. Most protocol bugs live at the seams, not in the middle of functions.

Conclusion

TCP gives reliability, not messages; everything above it in Lanweave is built on that distinction. The frame codec turns bytes into bounded, validated frames; the message layer turns frames into unambiguous typed controls; the state machine decides whether those controls are allowed right now; the writer serialises output under an ordering contract it states precisely; the transfer engine streams file data through bounded channels with hashes and deadlines attached. The hard parts โ€” ordering, memory bounds, failure scopes, hostile input โ€” are visible in the code and covered by tests aimed at the seams rather than the happy path.

Source and further reading

The implementation is at github.com/etim66/lanweave. The frame header and message schemas are documented in docs/MESSAGE_FORMAT.md, the protocol rules in docs/PROTOCOL.md, the state machines in docs/STATE_MACHINES.md, and the transport profile in docs/TRANSPORT.md.

Written by the developer of Lanweave. The project is open source and the design documents are part of it; if you find a protocol flaw or a case the framing tests miss, a reproducible example is the most useful contribution.

References

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.