Run two commands side-by-side, with optional piping and exit status control
  • Shell 96.6%
  • Awk 2.3%
  • Makefile 1.1%
Find a file
Sam Stephenson 3b29b06943 Update Brat
2026-08-09 20:41:21 -06:00
.forgejo/workflows Add CI and release workflows 2026-05-23 15:58:54 -06:00
bin Update Brut 2026-08-09 20:41:21 -06:00
doc/caveats Forward SIGINT as SIGTERM to supervised processes 2026-05-22 22:17:34 -06:00
lib/dyad Update Brut 2026-08-09 20:41:21 -06:00
libexec Silence errors when restoring $OLDENV 2026-08-06 18:51:11 -06:00
test Update Brat 2026-08-09 20:41:21 -06:00
vendor Update Brat 2026-08-09 20:41:21 -06:00
.gitignore Initial commit 2026-05-21 19:55:22 -06:00
.program.env.sh Dyad 0.9.1 2026-08-06 19:08:27 -06:00
CHANGELOG.md Dyad 0.9.1 2026-08-06 19:08:27 -06:00
LICENSE.md Initial commit 2026-05-21 19:55:22 -06:00
Makefile The dist target should track changes to the router 2026-08-06 17:42:26 -06:00
README.md Dyad doesn’t use awk 2026-08-09 20:41:21 -06:00

Dyad

Dyad is a miniature process supervision primitive that runs two commands side-by-side with an optional pipe between them.


Jump to: Installation | Usage | Implementation Notes | Contributing | License



Overview

Each Dyad invocation runs a left side and a right side with shared fate:

dyad run [<OPTIONS>] [--] <LEFT> [...] :: <RIGHT> [...]

Dyad starts both commands and watches them together. When one side exits, Dyad signals and reaps the other. When the supervisor itself receives a signal, it forwards the signal to both sides and waits for them to exit.

You can customize Dyad’s behavior by specifying what happens when one side exits (--stop), which side’s exit status Dyad reports (--report), or whether the left side’s output is connected to the right side’s input (--channel, --pipe). The right side can itself be another dyad run, so two sides extend to a chain of three or more.

Dyad can also act as a portable replacement for Bash’s set -o pipefail: the dyad pipefail command runs LEFT | RIGHT and exits with a nonzero status if either side fails.

See Usage for details.


Portability

Dyad is written in POSIX shell, targeting the POSIX.1-2024 standard. It is architecture-independent and does not require a C compiler.

We test Dyad using Brat with continuous integration on the following platforms:

sh
Alpine Linux busybox ash
Debian Linux dash
Fedora Linux Bash
FreeBSD FreeBSD ash
macOS Bash (3.2)

Build Status



Installation

Dyad runs entirely from source and has no build step or dependencies to install.


Installing Dyad Globally

Download and extract the latest release archive and symlink bin/dyad into your PATH. For example, to install Dyad in /usr/local:

# curl -sL https://codeberg.org/sstephenson/dyad/archive/latest.tar.gz | tar -C /usr/local -xzf -
# ln -s /usr/local/dyad/bin/dyad /usr/local/bin/dyad

Or if you prefer a per-user installation (assuming $HOME/.local/bin is in your PATH):

$ curl -sL https://codeberg.org/sstephenson/dyad/archive/latest.tar.gz | tar -C ~/.local -xzf -
$ ln -s ~/.local/dyad/bin/dyad ~/.local/bin/dyad

Vendoring Dyad in Your Project

Download the single-file bundle from the package registry and commit it directly to your repository:

$ mkdir -p vendor
$ curl -sL https://codeberg.org/api/packages/sstephenson/generic/dyad/latest/dyad >vendor/dyad
$ chmod +x vendor/dyad
$ vendor/dyad --help run

The bundle is a self-contained, plain-text POSIX shell script generated by brut-pack.



Usage

In its simplest form, dyad run takes a left command and a right command as argument lists on either side of a :: separator:

$ dyad run make assets :: make docs

dyad run starts both commands at once. By default, if either side fails, Dyad stops the other and exits with the failing status code. If both sides succeed, it exits zero. The options below change when a side is stopped, which status is reported, and whether the sides are connected with a pipe.

Note

In this documentation, success means a command exited with a zero status code and failure means it exited with a non-zero status code.


Specifying Commands

The special :: argument divides the left command from the right. Write commands just as you would without Dyad, with each command’s name as the first word, and any arguments it takes in subsequent words.

$ dyad run tail -f access.log :: tail -f error.log

(Don’t combine commands with arguments into a single string argument.)

Running Scriptlets

If you want to redirect input or output or run multiple commands on one side, you can write a scriptlet, an inline shell script wrapped in the command sh -c '...':

$ dyad run sh -c 'exec bin/server >>server.log' :: tail -f access.log

You must remember to exec into the scriptlet’s command. See Executing Into the Real Program.

Delimiting Options

You can pass -- before the left command to mark the end of Dyad’s own options. This is only necessary in the unlikely event your left side’s command starts with a dash.

However, you may find a superfluous -- makes a long Dyad invocation more readable, or a multi-line invocation more symmetric:

$ dyad run \
    -- tail -f access.log \
    :: tail -f error.log

Setting a Custom Separator

To change the :: separator to something else, set DYAD_SEP:

$ DYAD_SEP=@@@ dyad run ./left @@@ ./right

Choosing Which Exit Status to Report

Use -r/--report to control which side’s exit status Dyad reports:

Status Dyad exits with…
last (default) the right side’s status if nonzero, otherwise the left side’s
left the left side’s status
right the right side’s status

You can think of last as the “rightmost failure.”


Specifying the Stop Policy

When one side exits before the other, -s/--stop decides whether Dyad stops the side that remains:

Policy The remaining side is…
on-failure (default) stopped only if the exiting side failed
always stopped unconditionally
no left alone; Dyad waits for it to exit

Use always to bind a helper to a task. For example, to start a server, run a test suite against it, and tear the server down when the tests finish:

$ dyad run --stop always bin/server :: make test

About Stop Signals

Dyad always stops a side by sending it SIGTERM.

When Dyad itself receives SIGINT or SIGTERM, it forwards the signal to both sides as SIGTERM and waits for them to exit. See Signal Handling.


Connecting Sides With Channels

You can configure Dyad to open a channel, or a linked pair of file descriptors, that allows for unidirectional data transfer from the left side to the right.

Use the -c/--channel option to establish channels. Specify a single file descriptor number to link that descriptor on both sides, or a pair <LEFT-FD>:<RIGHT-FD> to link a different descriptor on each side. You can use file descriptors 0 through 6, and you can repeat the option to establish several channels at once.

Tip

A file descriptor is an integer that represents an open file in a process. Standard input is file descriptor 0, standard output is file descriptor 1, and standard error is file descriptor 2. Dyad uses file descriptors 7, 8, and 9 internally. POSIX shells are not required to support file descriptors greater than 9.

For example, you could use -c 3:0 to run ffmpeg configured to write its machine-readable progress reports to grep over file descriptor 3, leaving its log output on standard error:

$ dyad run -c 3:0 -- ffmpeg -i in.mp4 -progress pipe:3 out.mp4 :: grep out_time=

Piping Standard Output to Standard Input

You can replicate the behavior of the standard Unix shell pipeline using Dyad channels. A shell pipeline is also just a linked pair of file descriptors, where the standard output (on file descriptor 1) of the left side is connected to the standard input (on file descriptor 0) of the right side.

Add --pipe to connect the left side’s standard output to the right side’s standard input, like the shell’s | operator:

$ dyad run --pipe yes :: head -n 5

The --pipe option is equivalent to --channel 1:0:

$ dyad run --channel 1:0 yes :: head -n 5

You can combine --pipe with other --channel options. For example, you could use --pipe -c 3 to open an authentication channel on file descriptor 3 to send a password to an openssl filter without exposing the password on the command line or filesystem:

$ dyad run --pipe -c 3 \
    -- sh -c "pass show backup >&3; exec tar cf - ~/Documents" \
    :: openssl enc -aes-256-cbc -pbkdf2 -pass fd:3 -out backup.tar.enc

Note

When two channels use the same file descriptor on the same side, the last one specified takes precedence.

Using pipe and pipefail

Dyad provides shorthand syntax for the option combinations that match a shell pipeline.

dyad pipe works like the shell’s | operator: it connects the left side’s output to the right side’s input, lets each side run to completion, and reports the right side’s status. It is shorthand for dyad run --pipe --stop no --report right.

One use for dyad pipe is as a way to pass a pipeline to programs that wrap a command. For example, instead of sudo zfs send | sudo zfs receive, you could sudo dyad pipe:

$ sudo dyad pipe zfs send store/data@today :: zfs receive backup/data

dyad pipefail works the same as dyad pipe, except it reports the “rightmost failure” from either side, like a pipeline under Bash’s set -o pipefail. It is shorthand for dyad run --pipe --stop no --report last.

For example, make test | tee test.log hides test failures, because the pipeline exits with tee’s status rather than make’s. dyad pipefail reports the failure instead:

$ dyad pipefail make test :: tee test.log

Chaining More Than Two Sides

The right side of one Dyad invocation can be another Dyad invocation. This lets you chain three or more commands together. For example:

$ dyad run redis-server :: dyad run sidekiq :: bin/rails server

Chaining is possible because each Dyad invocation looks for the first :: argument before splitting commands into a left and a right side.

Each chained Dyad invocation specifies its own options. Consider a scenario where you want to run a test suite with two helper commands; if any side exits, so should the others, but you only care about the exit status of the test command. In this case, you can pass --report right and --stop always to both Dyad invocations:

$ dyad run --report right --stop always \
    -- redis-server \
    :: dyad run --report right --stop always \
      -- sidekiq \
      :: bin/rails test

The shorthand for this common set of options is dyad link:

$ dyad link \
    -- redis-server \
    :: dyad link \
      -- sidekiq \
      :: bin/rails test

If you want to chain Dyad invocations on the left side, or create a more elaborate tree structure of commands, you can invoke Dyad in a scriptlet:

$ dyad run \
    -- sh -c 'exec dyad run redis-server :: sidekiq' \
    :: bin/rails server

Executing Into the Real Program

Each side, like a “run script” under s6 and other daemontools-style supervisors, must execute into the long-lived process that is to be supervised. Most of the time you don’t need to think about this rule because the command you specify is the long-lived process. One common exception is a scriptlet, whose commands run under a new shell process:

$ dyad run sh -c 'exec redis-server >>tmp/redis.log 2>&1' :: sidekiq

You must remember to use exec here. It replaces the scriptlet shell with redis-server, so the server, not the shell, is the process Dyad supervises. Otherwise, when Dyad tries to stop the left side, the shell will terminate and the server will be orphaned.

In general, any program that runs its real work in a process other than the one Dyad launches must either execute into that process itself, or forward Dyad’s SIGTERM to it.



Contributing

Dyad is hosted on Codeberg: https://codeberg.org/sstephenson/dyad

We welcome issues and tested pull requests from human contributors. However, before submitting a large pull request, or one that changes behavior that is not a bug, we ask that you please open an issue first so we can discuss whether it is a good fit for the project.


About the Test Suite

Dyad’s tests live in the test/ directory; the test/*.brat files together comprise its test suite. The tests are written using Brat, a parallel TAP testing harness. Run them with:

$ vendor/brat -j 12 test/*.brat

Reporting Issues

Dyad is portable software and compatibility is a moving target. When reporting issues, please be sure to include information about your operating system, including its release version, and the version and lineage of the sh command.


Code Conventions

When contributing changes to Dyad, please respect the conventions of existing code in lib/dyad/ and libexec/.

Shell should be written with set -eu and careful consideration of what is specified by POSIX. See the Shell Command Language specification for more details.



License

Dyad is free software, distributable under the terms of the MIT + Trans Rights License. See LICENSE.md for details.


© 2026 Sam Stephenson. Handwritten in Mexico City.