Port
Actors communicate through message passing within the framework. But what if you need to integrate with an external program written in Python, C, or any other language? You could spawn goroutines to manage stdin/stdout, handle protocol framing, deal with buffer management - but this breaks the actor model and spreads I/O complexity throughout your code.
Port meta-process solves this by wrapping external programs as actors. The external program runs as a child process. You send messages to the Port, and it writes them to the program's stdin. The Port reads from stdout and sends you messages. From your actor's perspective, you're just exchanging messages with another actor - the external program's details are abstracted away.
This enables clean integration with legacy systems, specialized libraries in other languages, or any tool that uses stdin/stdout for communication. The actor model stays intact while bridging to external processes.
Creating a Port
Create a Port with meta.CreatePort and spawn it as a meta-process:
type Controller struct {
act.Actor
portID gen.Alias
}
func (c *Controller) Init(args ...any) error {
// Define port options
options := meta.PortOptions{
Cmd: "python3",
Args: []string{"processor.py", "--mode=batch"},
Env: map[gen.Env]string{
"WORKER_ID": "worker-1",
},
}
// Create port behavior
portBehavior, err := meta.CreatePort(options)
if err != nil {
return fmt.Errorf("failed to create port: %w", err)
}
// Spawn as meta-process
portID, err := c.SpawnMeta(portBehavior, gen.MetaOptions{})
if err != nil {
return fmt.Errorf("failed to spawn port: %w", err)
}
c.portID = portID
c.Log().Info("spawned port for %s (id: %s)", options.Cmd, portID)
return nil
}The Port starts the external program and establishes three pipes: stdin (for writing), stdout (for reading), and stderr (for errors). The program runs as a child process managed by the Port meta-process.
When the Port starts, it sends MessagePortStart to your actor. When the external program terminates (or the Port is stopped), it sends MessagePortTerminate. Between these, you exchange data messages.
Text Mode: Line-Based Communication
By default, Port operates in text mode. It reads stdout line by line and sends each line as MessagePortText. It reads stderr the same way and sends errors as MessagePortError.
Text mode uses bufio.Scanner internally, which splits input by lines (newline delimiter). You can customize the splitting logic:
Text mode is simple and works well for line-oriented protocols: command-response pairs, JSON-per-line, log output, or any text-based format. But it's not suitable for binary protocols.
Binary Mode: Raw Bytes
For binary protocols (Protobuf, MessagePack, custom framing), enable binary mode:
In binary mode, the Port reads raw bytes from stdout and sends them as MessagePortData. You send binary data using MessagePortData messages:
The Port reads up to ReadBufferSize bytes at a time from stdout and sends each chunk as MessagePortData. There's no framing or splitting - you receive raw bytes as the Port reads them. If your protocol has message boundaries, you must track them yourself.
Stderr is always processed in text mode, even when binary mode is enabled. Stderr messages arrive as MessagePortError.
Chunking: Automatic Message Framing
Reading raw bytes means dealing with partial messages. A 1KB message might arrive as three separate MessagePortData messages (512 bytes, 400 bytes, 88 bytes), or multiple messages might arrive together in one chunk. You need to buffer, reassemble, and detect message boundaries.
Chunking solves this by automatically framing messages. Instead of receiving raw bytes, you receive complete chunks - one MessagePortData per message, properly framed.
Fixed-Length Chunks
If every message is the same size, use fixed-length chunking:
The Port buffers stdout until it has 256 bytes, then sends them as one MessagePortData. If a read returns 512 bytes, you receive two MessagePortData messages (256 bytes each). If a read returns 100 bytes, the Port waits for more data before sending.
This is efficient for fixed-size protocols: binary structs, fixed-width encodings, or any format where every message has the same length.
Header-Based Chunking
Most binary protocols use variable-length messages with a header that specifies the length. Chunking can parse these headers automatically:
This configuration matches a protocol where:
Every message starts with a 4-byte header
The header contains a 4-byte big-endian integer (bytes 0-3)
The integer specifies the payload length (header not included)
Messages are:
[4-byte length][payload]
The Port reads the header, extracts the length, waits for the full payload to arrive, then sends the complete message (header + payload) as MessagePortData.
Example protocol:
With the configuration above, you receive two MessagePortData messages:
First: 14 bytes (4-byte header + 10-byte payload)
Second: 260 bytes (4-byte header + 256-byte payload)
If the external program writes both messages at once (274 bytes total), the Port automatically splits them. If the program writes slowly (header arrives, then payload arrives later), the Port waits for the complete message before sending.
Header length options:
HeaderLengthSize can be 1, 2, or 4 bytes. All lengths are big-endian. The Port reads the header, extracts the length value, computes the total message size (adding header size if HeaderLengthIncludesHeader is false), and buffers until the complete message arrives.
MaxLength protection:
If the header specifies a length exceeding MaxLength, the Port terminates with gen.ErrTooLarge. This protects against malformed messages or malicious programs that claim a message is 4GB (causing memory exhaustion).
Set MaxLength based on your protocol's reasonable maximum. Leave it zero for no limit (use cautiously).
Buffer Management
The Port allocates buffers for reading stdout. By default, each read allocates a new buffer, which is sent in MessagePortData and becomes garbage when you're done with it. For high-throughput ports, this causes GC pressure.
Use a buffer pool to reuse buffers:
The Port gets buffers from the pool when reading stdout. When you receive MessagePortData, the Data field is a buffer from the pool. You must return it to the pool when done:
If you forget to return buffers, the pool will allocate new ones, defeating the purpose. If you return a buffer and then access it later, you'll get corrupted data (the buffer is reused by the Port for the next read).
When you send MessagePortData to write to stdin, the Port automatically returns the buffer to the pool after writing (if a pool is configured). You don't need to do anything:
Buffer pools are critical for high-throughput scenarios. For low-volume ports (a few messages per second), the GC overhead is negligible - skip the pool for simplicity.
Write Keepalive
Some external programs expect periodic input to stay alive. If stdin goes silent for too long, they timeout or disconnect. You could send keepalive messages from your actor (with timers), but that's tedious and error-prone.
Enable automatic keepalive:
The Port wraps stdin with a keepalive flusher. If nothing is written for WriteBufferKeepAlivePeriod, it automatically sends WriteBufferKeepAlive bytes. This keeps the connection alive without any action from your actor.
The keepalive message can be anything: a null byte, a specific protocol message, a ping command. The external program receives it as normal stdin input. Design your protocol to ignore or handle keepalive messages.
Keepalive is only available in binary mode. In text mode, you need to send keepalive messages manually.
Environment Variables
The external program inherits environment variables based on your configuration:
EnableEnvOS: Includes the operating system's environment. This gives the program access to PATH, HOME, USER, and other system variables. Useful when the program needs to find other executables or access user-specific paths.
EnableEnvMeta: Includes environment variables from the meta-process (inherited from its parent actor). Meta-processes share their parent's environment. If the parent has MY_VAR=value, the Port's external program sees MY_VAR=value too.
Env: Custom variables specific to this Port. These are always included regardless of the other flags.
Order of precedence (if duplicate names):
Custom
Env(highest priority)Meta-process environment
OS environment (lowest priority)
Routing Messages
By default, all Port messages (start, terminate, data, errors) go to the parent process - the actor that spawned the Port. For single-port scenarios, this is fine. For multiple ports or advanced architectures, you want routing:
All Port messages are sent to the process registered as data_handler. This enables:
Worker pools:
The Port sends all messages to a pool, which distributes them across workers. Multiple ports can share the same pool for load balancing.
Centralized handlers:
Both ports send messages to python_manager, which coordinates multiple Python scripts.
Distinguishing ports with tags:
The Tag field appears in all Port messages. The manager uses it to distinguish which port sent the message:
If Process is empty or not registered, messages go to the parent process.
Port Messages
Messages you receive from the Port:
MessagePortStart - Port started successfully, external program is running:
Sent once after the external program starts. Use this to send initialization commands.
MessagePortTerminate - Port stopped, external program exited:
Sent when the external program terminates (exit, crash, killed) or when you terminate the Port. After this, the Port is dead - you cannot send it more messages.
MessagePortText - Line from stdout (text mode only):
Sent for each line read from stdout in text mode. The delimiter (newline or custom) is stripped from Text.
MessagePortData - Binary data from stdout (binary mode only):
In binary mode without chunking, Data contains whatever bytes the Port read (up to ReadBufferSize). With chunking, Data contains one complete chunk.
If ReadBufferPool is configured, Data is from the pool - return it when done.
MessagePortError - Line from stderr (always text mode):
Sent for each line read from stderr. Stderr is always processed in text mode, even when binary mode is enabled for stdout.
Messages you send to the Port:
MessagePortText - Send text to stdin (text mode):
Writes Text to stdin. Newlines are not added automatically - include them if your protocol needs them.
MessagePortData - Send binary data to stdin (binary mode):
Writes Data to stdin. If ReadBufferPool is configured, the Port returns the buffer to the pool after writing. Don't use the buffer after sending.
Termination and Cleanup
When the external program exits (normally or crash), the Port sends MessagePortTerminate and terminates itself. The Port also kills the external program if:
The Port is terminated (you call
process.SendExitto the Port's ID)The Port's parent terminates (cascading termination)
An error occurs reading stdout (broken pipe, I/O error)
The Port calls Kill() on the child process and waits for it to exit. This ensures cleanup happens even if the program is misbehaving.
Stderr is read in a separate goroutine. This means stderr messages can arrive after MessagePortTerminate if the program wrote to stderr just before exiting. Design your actor to handle this ordering.
Inspection
Port supports inspection for debugging:
Returns a map with Port status:
Use this for monitoring, debugging, or displaying Port status in management UIs.
Patterns and Pitfalls
Pattern: Request-response wrapper
Wrap a Port to provide synchronous Call semantics. Useful for RPC-style protocols.
Pattern: Supervised restart
Supervise the actor that spawns ports. If the actor crashes, the supervisor restarts it, which re-spawns ports. Ports inherit parent lifecycle - when the actor terminates, all its ports terminate.
Pattern: Backpressure with buffer pool
Limit memory usage by capping concurrent buffers. If processing is slow, the semaphore blocks, which blocks the actor's message loop, which applies backpressure to the Port.
Pitfall: Forgetting to return buffers
Pool buffers are reused. If you store them, they'll be overwritten by future reads. Copy data if you need to keep it.
Pitfall: Blocking on stdin writes
If the external program stops reading stdin (buffer full, process blocked), the Port blocks writing. The Port's HandleMessage is blocked, so it can't send you more stdout data. Deadlock.
Solution: Design your protocol so the external program never stops reading stdin. Use flow control or chunking to prevent overflows.
Pitfall: Ignoring MessagePortError
Stderr messages arrive as MessagePortError. If you don't handle them, warnings and errors from the external program are lost. Always handle stderr or explicitly decide to ignore it.
Pitfall: Not handling MessagePortTerminate
After MessagePortTerminate, the Port is dead. Sending messages returns errors. Handle termination: restart the Port, fail gracefully, or terminate your actor.
Port meta-processes enable clean integration with external programs. They handle process management, I/O buffering, protocol framing, and lifecycle coordination - letting you focus on the protocol logic while maintaining the actor model's isolation and simplicity.
Last updated
