Skip to content

Architecture

Procwire is a dual-channel parent↔child IPC library.

Procwire uses a binary protocol for the data plane and JSON-RPC for the control plane — a high-performance wire format where it matters, and a simple text protocol where it doesn’t.

ChannelTransportProtocolUse Case
Control PlanestdioJSON-RPC 2.0Handshake, heartbeat, lifecycle
Data PlaneNamed Pipe / Unix SocketBinary (11-byte header)User data, high throughput

JSON-RPC on the data channel would cause severe performance issues:

  • 5x larger payload (JSON text vs binary)
  • ~500ms serialization overhead per 1MB of data
  • ~30 MB/s throughput vs ~2.5 GB/s with binary protocol

Every data-plane message is a frame: an 11-byte header followed by the codec-encoded payload.

+----------+-------+----------+----------+----------------------+
| Method ID| Flags | Req ID | Length | Payload |
| 2 bytes | 1 byte| 4 bytes | 4 bytes | N bytes |
+----------+-------+----------+----------+----------------------+
FieldSizeTypeMeaning
Method ID2 bytesuint16 BEMethod/event ID assigned during handshake; 0xFFFF is the abort signal
Flags1 bytebitfieldDirection, response/error, stream chunk/end, ack (bits 6–7 reserved)
Request ID4 bytesuint32 BECorrelation ID; 0 means fire-and-forget or unsolicited (events)
Length4 bytesuint32 BEPayload length in bytes
PayloadN bytescodec outputSerialized by the method’s codec (msgpack, Arrow, raw, …)

All integers are big-endian. Method names never travel on the data plane — only the numeric IDs exchanged in the handshake.

The control plane is newline-delimited JSON-RPC 2.0 over the child’s stdio:

MessageDirectionPurpose
$initchild → parentHandshake: carries the data-plane pipe path and the child’s method/event schema
$pingparent → childHeartbeat probe (only when spawnPolicy.heartbeat is enabled)
$pongchild → parentHeartbeat reply; a missed reply within timeoutMs kills the child
$shutdownparent → childGraceful stop: the child closes its pipe server and exits on its own
  • Per-module shutdownmanager.shutdown(name) stops a single module: pending crash-restarts are cancelled, $shutdown is sent, and only a child that fails to exit within 5 seconds is force-killed.
  • Unregister & retrymanager.unregister(name) removes a module from the registry (cancelling any pending restart/retry timers) so the name can be re-registered with a freshly built Module — e.g. a supervisor retrying after a terminal SpawnError with new CLI args. A running module requires { force: true }, which shuts it down first; register(module, { replace: true }) swaps a non-running module in one call.
  • Orphan prevention — the child treats stdin EOF as parent death and shuts itself down, so a crashed parent never leaves worker processes running forever.
  • Exception-safe receive path — a framing error (e.g. an oversized payload) drops the connection instead of throwing from the socket handler, and a corrupt payload rejects only the affected request or stream. A bad frame cannot crash the parent — the supervisor of every module.