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 pathchannel.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: ShlexASCIICodec.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)"""
from argparse import ArgumentParser, Namespace
from queue import Empty
import time

from loggez import loggez_logger as logger

from micronetcode import ConnectionManager, Message
from ui_cli_manager import ShlexASCIICodec

TIMEOUT_S = 10

def network_handler(message: Message) -> str:
    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=ShlexASCIICodec(), 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)