NAME

App::Actions::Process - run other programs, the same way on Unix and Windows

SYNOPSIS

Run a program and read what it printed:

use App::Actions::Process;

my ($output, $status) = App::Actions::Process::capture(['git', 'rev-parse', 'HEAD']);
die "not a git checkout\n" if $status != 0;

Run a program and show its output to the user:

my $status = App::Actions::Process::stream(['make', 'test']);

Run a program and hide everything it prints:

my $status = App::Actions::Process::run_quiet(['docker', 'info']);

Run several programs at the same time:

my $statuses = App::Actions::Process::run_parallel(
    [ ['docker', 'pull', 'alpine'], ['docker', 'pull', 'perl'] ],
    concurrency => App::Actions::Process::cpu_count(),
);

Send the output to a file:

my $status = App::Actions::Process::run(['ls', '-l'], stdout => 'listing.txt');

DESCRIPTION

This module starts other programs and tells you how they ended. It works on Linux, macOS and Windows with the same code.

Every function takes the command as a reference to a list: the program first, then each argument. A shell is never used. Each argument reaches the program exactly as you wrote it, even if it contains spaces, quotes, $, ; or |. So text from a workflow cannot run a second command.

Windows has no separate arguments: a program gets one command line and splits it up itself. On Windows this module quotes each argument with "quote_win32" so that it is split back correctly. On other systems the arguments are passed as they are.

Every function returns an exit status as a shell shows it:

  • 0 - the program succeeded;

  • 1 to 125 - the program failed, and this is the number it chose;

  • 255 - the program could not be started (for example, it does not exist);

  • 128 + N - the program was stopped by signal N (130 for Ctrl-C).

FUNCTIONS

No function is exported. Call them with their full names, for example App::Actions::Process::capture(...).

exit_code

my $status = App::Actions::Process::exit_code($?);

Turns Perl's $? into an exit status, as described above.

ARGUMENTS

  • $status - the value of $? after system or waitpid.

RETURNS

The exit status.

SIDE EFFECTS

None.

EXAMPLE

system { 'true' } 'true';
print App::Actions::Process::exit_code($?);    # 0

API SPECIFICATION

INPUT

{ status => { type => 'integer', optional => 1 } }

OUTPUT

{ type => 'integer', min => 0, max => 255 }

MESSAGES

None.

cpu_count

my $count = App::Actions::Process::cpu_count();

Returns how many processors this computer can use now. action-run uses it as the default for --parallel.

ARGUMENTS

None.

RETURNS

A whole number, 1 or more. If the computer does not say, 1.

SIDE EFFECTS

Reads NUMBER_OF_PROCESSORS on Windows, /proc/cpuinfo on Linux, or asks the system with sysconf.

EXAMPLE

my $jobs = App::Actions::Process::cpu_count();

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', min => 1 }

MESSAGES

None.

capture

my ($output, $status) = App::Actions::Process::capture(\@command, %options);

Runs a program and returns what it printed.

ARGUMENTS

  • \@command - the program and its arguments. Not empty.

  • quiet_stderr - optional. True to throw away the error output. Otherwise it goes to your terminal.

  • merge_stderr - optional. True to include the error output in the returned text, in the order the program wrote it (like the shell's 2>&1).

RETURNS

A list of two values: the output, and the exit status. Windows line ends (\r\n) in the output become \n.

SIDE EFFECTS

Writes the output to a temporary file, which is deleted afterwards.

EXAMPLE

my ($version) = App::Actions::Process::capture(['perl', '-e', 'print $]']);

API SPECIFICATION

INPUT

{
    command      => { type => 'arrayref', min => 1 },
    quiet_stderr => { type => 'boolean', optional => 1 },
    merge_stderr => { type => 'boolean', optional => 1 },
}

OUTPUT

{ type => 'array', min => 2, max => 2 }    # (string, integer)

MESSAGES

+------------------------------------------+---------------------------------+---------------------------+
| Exception (croak)                        | Meaning                         | What to do                |
+------------------------------------------+---------------------------------+---------------------------+
| No command given                         | not a non-empty array reference | pass [program, args...]   |
| Unable to rewind captured output: ERR    | the temporary file failed       | check the temp directory  |
| Unable to redirect command output: ERR   | the output file could not be    | check the temp directory  |
|                                          | opened                          | and disk space            |
| Unable to duplicate STDOUT/STDERR: ERR   | the program's own handles are   | do not close STDOUT or    |
| Unable to restore STDOUT/STDERR: ERR     | broken                          | STDERR before calling     |
+------------------------------------------+---------------------------------+---------------------------+

run

my $status = App::Actions::Process::run(\@command, %options);

Runs a program and sends its output to a file.

ARGUMENTS

  • \@command - the program and its arguments. Not empty.

  • stdout - optional. The file for the output. Default: the null device (the output is thrown away).

  • stderr - optional. The file for the error output. Default: your terminal.

  • merge_stderr - optional. True to send the error output to the stdout file too. It wins over stderr.

RETURNS

The exit status.

SIDE EFFECTS

Replaces the files named by stdout and stderr. While the program runs, this process's own STDOUT and STDERR point to those files; they are put back afterwards.

EXAMPLE

my $status = App::Actions::Process::run(['make'], stdout => 'build.log', merge_stderr => 1);

API SPECIFICATION

INPUT

{
    command      => { type => 'arrayref', min => 1 },
    stdout       => { type => 'string', optional => 1 },
    stderr       => { type => 'string', optional => 1 },
    merge_stderr => { type => 'boolean', optional => 1 },
}

OUTPUT

{ type => 'integer', min => 0, max => 255 }

MESSAGES

As for "capture", except the "rewind" message.

run_quiet

my $status = App::Actions::Process::run_quiet(\@command);

Runs a program and throws away everything it prints.

ARGUMENTS

  • \@command - the program and its arguments.

RETURNS

The exit status.

SIDE EFFECTS

None, apart from what the program does.

API SPECIFICATION

INPUT

{ command => { type => 'arrayref', min => 1 } }

OUTPUT

{ type => 'integer', min => 0, max => 255 }

MESSAGES

As for "run".

stream

my $status = App::Actions::Process::stream(\@command);

Runs a program with its output and error output going straight to your terminal, as it happens.

ARGUMENTS

  • \@command - the program and its arguments.

RETURNS

The exit status.

SIDE EFFECTS

Flushes STDOUT and STDERR first, so that what you printed before appears before the program's output.

API SPECIFICATION

INPUT

{ command => { type => 'arrayref', min => 1 } }

OUTPUT

{ type => 'integer', min => 0, max => 255 }

MESSAGES

+------------------+------------------------------------+-------------------------+
| Exception (croak)| Meaning                            | What to do              |
+------------------+------------------------------------+-------------------------+
| No command given | not a non-empty array reference    | pass [program, args...] |
+------------------+------------------------------------+-------------------------+

run_parallel

my $statuses = App::Actions::Process::run_parallel(\@commands, %options);

Runs several programs, some at the same time.

ARGUMENTS

  • \@commands - a list of commands, each a list reference as for "stream".

  • concurrency - optional. The most programs that run at the same time. Default 4.

  • stdout, stderr - optional. Files for every program's output, as for "run". Without them, the output goes to your terminal, mixed together.

RETURNS

An array reference of exit statuses, in the same order as \@commands. An empty list gives an empty array reference.

SIDE EFFECTS

On Unix, uses fork. On Windows, or with concurrency below 2, the programs run one after the other. If this process is interrupted while waiting, the programs still running are killed.

EXAMPLE

my $statuses = App::Actions::Process::run_parallel(
    [ map { ['gzip', '-9', $_] } @files ],
    concurrency => 2,
);
my @failed = grep { $statuses->[$_] != 0 } 0 .. $#files;

API SPECIFICATION

INPUT

{
    commands    => { type => 'arrayref' },
    concurrency => { type => 'integer', min => 1, optional => 1, default => 4 },
    stdout      => { type => 'string', optional => 1 },
    stderr      => { type => 'string', optional => 1 },
}

OUTPUT

{ type => 'arrayref', element_type => 'integer' }

MESSAGES

+--------------------------+----------------------------------+------------------------------+
| Message                  | Meaning                          | What to do                   |
+--------------------------+----------------------------------+------------------------------+
| No commands given (croak)| \@commands is not an array ref   | pass an array reference      |
| Unable to run 'P': ERR   | (printed by the child) program P | install P or fix its path;   |
|                          | could not start; status 127      | the status is 127            |
| Unable to redirect       | (printed by the child) the file  | check the path; status 127   |
| STREAM to 'F': ERR       | could not be opened              |                              |
+--------------------------+----------------------------------+------------------------------+

quote_win32

my $argument = App::Actions::Process::quote_win32($text);

Quotes one argument for a Windows command line, using the rules Windows programs use to split it again. The other functions call it for you on Windows; you need it only to build a Windows command line yourself.

ARGUMENTS

  • $text - one argument. undef is treated as the empty string.

RETURNS

The argument, quoted if it needs to be. An argument without spaces or double quotes is returned unchanged. An empty argument becomes "".

SIDE EFFECTS

None.

EXAMPLE

print App::Actions::Process::quote_win32('say "hi"');    # "say \"hi\""
print App::Actions::Process::quote_win32('C:\dir\\');    # C:\dir\
print App::Actions::Process::quote_win32('a b\\');       # "a b\\"

API SPECIFICATION

INPUT

{ argument => { type => 'string', optional => 1 } }

OUTPUT

{ type => 'string', min => 1 }

MESSAGES

None.

COMMON PITFALLS

  • Pass a list reference, not a string. capture('ls -l') is an error, and capture(['ls -l']) looks for a program called "ls -l". Write capture(['ls', '-l']).

  • There is no shell. Pipes (|), redirection (>), wildcards (*.txt) and variables ($HOME) are not expanded. If you need them, run the shell yourself: ['sh', '-c', 'ls *.txt'], and never put untrusted text inside that string.

  • capture returns a list. my $text = capture(...) gives the status, not the text. Write my ($text, $status) = capture(...).

  • A status is not Perl's $?. Compare it with 0, not with $? >> 8.

  • A missing program is status 255, not an exception. Perl also prints a "Can't exec" warning, except in "run", "run_quiet" and "capture", which hide it.

  • Output from parallel programs is mixed. Without stdout, "run_parallel" lets every program write to the same terminal, so their lines can be interleaved.

  • Captured output is held in memory. Do not capture a program that prints gigabytes.

ENCODING

Commands, arguments and captured output are bytes. Encode text with non-ASCII characters or emoji (for example with Encode::encode('UTF-8', $text)) before you put it in a command, and decode captured output if you need characters. On Windows, the program receives the command line through the Windows "ANSI" code page, so characters outside it may be lost. An argument cannot contain a NUL character.

AUTHOR

Nigel Horne, njh@nigelhorne.com

FORMAL SPECIFICATION

This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above.

How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. The lines below the rule are always true when the routine returns. The module keeps no state of its own; Host is the computer, whose processes and files the routines change.

[ARG, FILE]
STATUS == 0 .. 255
COMMAND == seq₁ ARG
exec : COMMAND → STATUS × seq BYTE × seq BYTE    status, output, error output
exec(c) runs program head c with arguments tail c, without a shell

exit_code

┌─ ExitCode ────────────────────────────────────────
│ status? : optional ℤ
│ r!      : STATUS
├───────────────────────────────────────────────────
│ status? = 0 ∨ status? = ⊥ ⇒ r! = 0
│ status? mod 128 ≠ 0 ⇒ r! = 128 + (status? mod 128)
│ otherwise r! = status? div 256
└───────────────────────────────────────────────────

cpu_count

┌─ CpuCount ────────────────────────────────────────
│ ΞHost
│ r! : ℕ₁
├───────────────────────────────────────────────────
│ r! = first defined of ⟨NUMBER_OF_PROCESSORS (Windows),
│      #processor lines in /proc/cpuinfo, sysconf(NPROCESSORS_ONLN), 1⟩
└───────────────────────────────────────────────────

capture

┌─ Capture ─────────────────────────────────────────
│ ΔHost
│ command? : COMMAND
│ quiet?, merge? : 𝔹
│ r! : seq BYTE × STATUS
├───────────────────────────────────────────────────
│ (s, out, err) = exec(command?)
│ merge? ⇒ r! = (crlf_to_lf(out ⋈ err), s)     ⋈ : in time order
│ ¬merge? ⇒ r! = (crlf_to_lf(out), s)
│ ¬merge? ∧ ¬quiet? ⇒ err goes to the caller's STDERR
└───────────────────────────────────────────────────

run

┌─ Run ─────────────────────────────────────────────
│ ΔHost
│ command? : COMMAND
│ stdout?, stderr? : optional FILE
│ merge? : 𝔹
│ r! : STATUS
├───────────────────────────────────────────────────
│ (s, out, err) = exec(command?)
│ r! = s
│ contents'(stdout? or null) = out ⋈ (if merge? then err else ⟨⟩)
│ ¬merge? ∧ stderr? ≠ ⊥ ⇒ contents'(stderr?) = err
│ STDOUT' = STDOUT ∧ STDERR' = STDERR
└───────────────────────────────────────────────────

run_quiet

┌─ RunQuiet ────────────────────────────────────────
│ Run[stdout? := null, stderr? := null]
└───────────────────────────────────────────────────

stream

┌─ Stream ──────────────────────────────────────────
│ ΔHost
│ command? : COMMAND
│ r! : STATUS
├───────────────────────────────────────────────────
│ (s, out, err) = exec(command?) ∧ r! = s
│ out goes to STDOUT and err to STDERR, after what was already buffered
└───────────────────────────────────────────────────

run_parallel

┌─ RunParallel ─────────────────────────────────────
│ ΔHost
│ commands?    : seq COMMAND
│ concurrency? : ℕ₁
│ r!           : seq STATUS
├───────────────────────────────────────────────────
│ #r! = #commands?
│ ∀ i ∈ dom commands? • r!(i) = first(exec(commands?(i)))
│ at no time do more than min(concurrency?, #commands?) commands run
│ Windows ∨ concurrency? < 2 ⇒ the commands run one after another
└───────────────────────────────────────────────────

quote_win32

┌─ QuoteWin32 ──────────────────────────────────────
│ arg? : optional seq CHAR
│ r!   : seq₁ CHAR
├───────────────────────────────────────────────────
│ arg? = ⊥ ∨ arg? = ⟨⟩ ⇒ r! = "\"\""
│ arg? contains no white space and no '"' ⇒ r! = arg?
│ otherwise r! = '"' ⁀ escape(arg?) ⁀ '"' where
│     a run of n '\' before '"' becomes 2n+1 '\' then '"',
│     a run of n '\' at the end becomes 2n '\',
│     any other run of '\' is unchanged
│ CommandLineToArgv(r!) = ⟨arg?⟩
└───────────────────────────────────────────────────

STATE DIAGRAM

The module keeps no state between calls. Each command goes through these states:

capture / run / run_quiet / stream / run_parallel
                     |
                     v
            +------------------+  not a non-empty list   +-------------+
            | CHECK ARGUMENTS  |------------------------>| croak       |
            +------------------+                         +-------------+
                     |
                     v
            +------------------+  (run, capture, run_quiet)
            | REDIRECT         |  STDOUT/STDERR -> files;  open fails
            | (if asked)       |------------------------------+
            +------------------+                              |
                     |                                        |
                     v                                        |
            +------------------+  Windows: quote_win32 each   |
            | START PROGRAM    |  argument; never a shell     |
            +------------------+                              |
              |            |                                  |
 cannot start |            | started                          |
              v            v                                  |
      status 255    +------------------+                      |
              |     |    RUNNING       |  run_parallel: up to |
              |     +------------------+  "concurrency" here  |
              |            |                                  |
              |            | exits, or killed by signal N     |
              |            v                                  |
              |     status = code, or 128 + N                 |
              |            |                                  |
              +------+-----+                                  |
                     v                                        |
            +------------------+                              |
            | RESTORE HANDLES  |<-----------------------------+
            +------------------+
              |            |
              |            +--- redirect had failed --> croak
              v
     return status (capture: and the output)

LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.