2. Fast handler vs slow handler (the robosim split)

examples/2-fast-handler-cli.py is the pattern robosim's server uses to answer commands: some responses can be computed the instant the message arrives, others must be handled by the app's main loop. It drives ConnectionManager directly (no UICLIManager):

python examples/2-fast-handler-cli.py            # run the server
printf 'fast\nslow\n' | ncat localhost 42069     # fast: answered from the network thread; slow: via the main loop

fast is answered immediately; slow is round-tripped through the main loop; anything else replies "Unknown message" and closes the app.

Two threads, two ways to answer

The listener hands each connection to its own daemon thread (_handle_client in ConnectionManager). Every received message is passed to network_handler, and whatever it returns is sent back over the socket. That one fact gives you the two answer paths:

TIMEOUT_S = 10

def network_handler(message: Message) -> str:
    logger.info(f"Received: {message}")
    if message.data == "fast":
        return "fast response\n"                                          # fast: answer here, in the network thread
    else:
        return message.client.channel.put_then_get(message, timeout=TIMEOUT_S)  # slow: hand off to the main loop and block
  • Fast path — return the response directly. The network thread computes it and the bytes go straight out; the main loop never sees the message. Zero latency, no coupling.
  • Slow path — channel.put_then_get(message) pushes the message into the channel's tcp2main queue and blocks the network thread waiting for the app's answer on main2tcp. The main loop does the real work and replies; put_then_get returns that reply to the network thread, which sends it back.

The channel is the two queues (micronetcode.channel) that connect the two threads: tcp2main (wire → app, capped by channel_queue_maxsize) and main2tcp (app → wire, depth 1). This is the same mechanism the default handler uses — the one UICLIManager builds on top of.

The main loop is the slow handler

get_one_message() is the drain: it polls every channel's tcp2main queue (non-blocking) and returns the first message found, raising Empty when there is nothing. The Message carries message.client.channel, so the loop knows exactly which socket it is answering — it replies with main2tcp.put(...), which unblocks the parked network thread:

while manager.is_alive():
    try:
        msg = manager.get_one_message()
        if msg.data == "slow":
            msg.client.channel.main2tcp.put("slow response\n", timeout=TIMEOUT_S)
        else:
            msg.client.channel.main2tcp.put(f"Unknown message: '{msg.data}'. Exiting\n", timeout=TIMEOUT_S)
            break
    except Empty:
        pass
    time.sleep(0.1)

Note the responses already end in \n: NewlineCodec.encode sends the string byte-for-byte, it does not append a newline for you. And TIMEOUT_S on both the forward (put_then_get) and return (main2tcp.put) legs is the deadlock insurance: if one side stops answering, the other raises Empty instead of hanging forever.

The robosim case

This is exactly the split in src/robosim/protocol.py. The network_handler there runs in the TCP thread and answers robot_get_state_with_camera (FPV streaming) directly — the camera frame is read under its lock and returned on the spot. Every other command (move, robot_get_state, connect, ...) is forwarded through put_then_get to the simulator main loop, which drains with get_one_message() and answers on main2tcp. The main loop owns the sim state; the fast path owns only what can be answered without touching it.

Full code

Everything above, in one file.

#!/usr/bin/env python
"""
examples/2-fast-handler-cli.py - minimal CLI app with a TCP socket and a 'fast handler' (robosim-like)

Raw micronetcode, no CLI layer: a `Codec` says how bytes become messages, and `network_handler`
answers the cheap ones straight from the client thread instead of waking the main loop. Every app
brings its own codec - see examples/1-ui-cli-manager for a fuller one (quoting + typed arguments).
"""
from argparse import ArgumentParser, Namespace
from queue import Empty
from typing import Any
import socket
import time

from loggez import loggez_logger as logger

from micronetcode import ConnectionManager, Message, Codec, SourceType
from micronetcode.utils import SOCKET_RECV_SIZE, SOCKET_RECV_MAXSIZE

TIMEOUT_S = 10

class NewlineCodec(Codec):
    """One line of ASCII = one message. recv() accumulates socket chunks until a newline arrives."""
    def recv(self, sock: socket.socket) -> bytes | None:
        res = bytearray()
        while True:
            buf = sock.recv(SOCKET_RECV_SIZE)
            if len(buf) == 0: # the client closed its write side
                return bytes(res) if len(res) > 0 else None
            res.extend(buf)
            if res[-1] == ord("\n"):
                return bytes(res)
            if len(res) > SOCKET_RECV_MAXSIZE:
                raise ValueError(f"Too much data sent: {len(res)=} vs {SOCKET_RECV_MAXSIZE=}")

    def decode(self, data: bytes) -> list[Message]:
        return [Message(source_type=SourceType.SOCKET, data=line) for line in data.decode("ascii").strip().split("\n")]

    def encode(self, resp: Any) -> bytes:
        if not isinstance(resp, str):
            raise TypeError(f"Expected str, got {type(resp)}")
        return resp.encode("ascii")

def network_handler(message: Message) -> str:
    """[client thread] The network handler where all the client TCP messages arrive for one client."""
    logger.info(f"Received: {message}")
    if message.data == "fast":
        return "fast response\n"
    else:
        return message.client.channel.put_then_get(message, timeout=TIMEOUT_S)

def main(args: Namespace):
    """main fn"""

    manager = ConnectionManager("0.0.0.0", port=args.port, codec=NewlineCodec(), network_handler=network_handler)
    manager.start()

    while manager.is_alive():
        try:
            msg = manager.get_one_message()
            if msg.data == "slow":
                msg.client.channel.main2tcp.put("slow response\n", timeout=TIMEOUT_S)
            else:
                msg.client.channel.main2tcp.put(f"Unknown message: '{msg.data}'. Exiting\n", timeout=TIMEOUT_S)
                break
        except Empty:
            pass
        time.sleep(0.1)

if __name__ == "__main__":
    parser = ArgumentParser()
    parser.add_argument("--port", type=int, default=42069)
    arg = parser.parse_args()
    main(arg)