May 15, 2025·5 min read·engineeringrustprocessesiosafety

Rust 1.87 adds portable anonymous pipes

Anonymous pipes give Rust a standard way to connect process streams, including combined stdout and stderr. Their blocking and ownership rules determine when a process pipeline completes.

Combining a child process's standard output and standard error sounds like a small operation until the implementation has to work across operating systems. Rust callers previously needed platform-specific functions or additional threads for common arrangements. Rust 1.87 adds an anonymous pipe API whose ends can be passed directly to process I/O configuration.

Released on May 15, Rust 1.87 falls on the tenth anniversary of Rust 1.0. The pipe API is a useful example of the kind of standard-library addition that makes a systems language easier to use without changing its ownership model. The hard details remain blocking, endpoint lifetime, and the meaning of the resulting byte stream.

I am particularly interested in Rust where an alternative would be C with manual resource handling. A portable API for a common operating-system mechanism makes that comparison more practical. The benefit is a defined ownership and I/O contract, not merely fewer foreign function declarations in application code.

A pipe connects owned endpoints

The tagged pipe documentation defines std::io::pipe as a one-way operating-system channel. It returns a reader and a writer inside an io::Result. The endpoints implement ordinary I/O behavior and integrate with Command through conversions to Stdio.

For combined child output, both child streams can receive writers to the same pipe. One writer is cloned before ownership is transferred. The parent retains the reader and consumes the resulting data. This expresses the connection through owned values instead of leaving the caller to coordinate borrowed numeric handles.

The pipe is still a byte stream. Combining stdout and stderr removes their separate channel identities. If the application needs to distinguish which stream produced a message, this arrangement does not preserve that distinction automatically. The program needs a protocol that carries the information or separate channels.

use std::io::Read;
use std::process::Command;

let (mut reader, writer) = std::io::pipe()?;
let mut child = Command::new("path/to/program")
    .stdout(writer.try_clone()?)
    .stderr(writer)
    .spawn()?;
let mut output = Vec::new();
reader.read_to_end(&mut output)?;
let status = child.wait()?;

This fragment belongs in a function returning an I/O-compatible error and uses a placeholder executable path. The caller must interpret status according to its application policy. Reading bytes successfully does not imply that the child exited successfully.

Cloning an endpoint also has a lifetime consequence. EOF arrives only after all writer copies close. A writer unintentionally retained by the parent can keep a read waiting after the child has finished. Ownership therefore determines completion as well as resource cleanup.

Read before waiting for completion

The release announcement's example starts the child and reads the pipe before waiting for its exit. That ordering is necessary because pipe buffers have finite capacity. If the child fills the pipe while the parent waits for exit, the child can block waiting for the parent to read. The parent can then wait for an exit the child cannot reach.

The standard-library documentation explicitly says capacity is platform dependent. An application should consume output as it becomes available instead of depending on a particular buffer size. A test with short output does not establish that waiting first is correct for a longer real workload.

The same concern applies to a parent writing child input. A full input pipe can block the parent, and the child may be blocked trying to send output in the opposite direction. Two one-way pipes provide bidirectional communication, but they do not automatically define a deadlock-free exchange protocol.

The pipe API makes the operating-system operation portable. It does not schedule the application's communication for it. A program still needs to decide which side reads while the other writes and when each endpoint closes.

EOF is a protocol event

Closing the last writer tells readers that no further bytes can arrive. This is often how a simple child process knows its complete input has arrived. Sending the expected payload without closing the pipe can leave a child waiting for more, even when the parent believes its message is finished.

Multiple writers complicate that condition because any surviving copy keeps the stream open. The tagged examples use explicit drops where closure is part of the exchange. That is an application-level event expressed through ownership, rather than an incidental cleanup postponed until the end of a large scope.

Multiple readers have a different consequence. Each byte is consumed by one reader, and the documentation gives no useful message-interleaving guarantee. Cloning a reader does not create a broadcast stream in which every consumer sees every byte. It creates multiple consumers of one underlying channel.

Similarly, applications cannot assume portable atomicity for multi-byte messages. The documented portable guarantee is deliberately limited. A writer producing a structured record needs to consider framing and concurrent writers instead of assuming each call becomes one indivisible record for the reader.

Safe intrinsics keep hardware conditions visible

The release also makes many architecture intrinsics safe to call when their only unsafe requirement is an enabled target feature and the calling context supplies it. This follows Rust 1.86's support for safe target_feature functions, described in the previous release announcement.

The distinction is precise. A function compiled with a required processor feature can safely call matching intrinsics under the new rules. Entering that function from an ordinary context still needs justification that the hardware supports the feature. Other unsafe operations, such as reinterpreting memory, retain their own obligations.

This prevents a broad misreading of the change as making all vectorized code safe. The release's example still marks memory reinterpretation explicitly. Separating hardware availability from memory validity gives each unsafe boundary a more specific explanation.

I prefer that precision to wrapping an entire optimized function in one undifferentiated unsafe region. A reviewer should be able to see which operations depend on processor support and which depend on layout or access conditions. Safe intrinsics remove a redundant marker only when the compiler already has the required feature information.

A smaller portable process layer

Anonymous pipes remove a common reason for platform-specific process setup, while retaining the underlying operating-system behavior. Reading blocks on an empty open pipe, writing blocks on a full one, and EOF depends on all writers closing. Those rules remain the basis of a correct pipeline.

The practical improvement in Rust 1.87 is that a caller can express the endpoints through standard owned types and pass them to Command. The application can then focus on its communication protocol, including when to drain output and when to signal completion through closure. A portable constructor supplies the mechanism, while ownership makes the endpoint lifetime visible in the code that connects the processes.