> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use ProcessBuilder to Run Commands in Java 21
- URL: https://www.ggorantala.dev/how-to-use-processbuilder-run-commands-java/
- Published: 2026-04-08T05:21:00.000Z
- Updated: 2026-10-31T06:31:45.000Z
- Description: How to use ProcessBuilder in Java 21: run a command, read stdout and stderr, feed stdin, set a timeout and kill a stuck child, with 14 checks and 5 traps.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-io, java-tutorials

## A ticket export that runs four commands

A support team keeps its open tickets in a CSV file, and every morning someone runs `grep`, `cut`, `sort` and `uniq` by hand to see how many tickets each category has. This is what the finished Java program prints when it does that for them, then runs a command that hangs, and then runs one that doesn't exist:

```log
-- 1. run and read stdout
exit=0 stdout=6 tickets.csv
-- 2. stderr kept separate, non-zero exit
exit=2 ok=false
stderr=ls: cannot access 'no-such-file.csv': No such file or directory
-- 5. pipeline: open tickets per category
      2 billing
      2 login
-- 6. timeout
timedOut=true tookAbout=1000ms
-- 7. command that does not exist
IOException: Cannot run program "no-such-tool" (in directory "/tmp/tickets6891492897552760550"): Exec failed, error: 2 (No such file or directory)

```

If you're searching for **how to use ProcessBuilder in Java**, the short version is `new ProcessBuilder("wc", "-l", "tickets.csv").start()`, then read the output, then `waitFor()`. That works on a sunny day. It also hangs forever on a rainy one, and most of this article is about the rainy days: a child that writes too much, a child that never finishes, a child that spawned a child of its own. I forced each of those on purpose so you can see them before they happen in production.

It's for you if you can write a Java class and run it from a terminal. There's one small library class (`CommandRunner`), a demo, a trap file and a checks file. No framework, no build tool, no container.

I built and ran everything on plain `javac`, because Maven Central wasn't reachable from my build machine. Most of the build time went on one bug: a timeout that fired on time but still took six seconds to return. That's Step 5, and it's the part I'd read twice.

## Check your setup

| Tool              | Version I used                                                     | Check it with  |
| ----------------- | ------------------------------------------------------------------ | -------------- |
| JDK               | OpenJDK 21.0.12.1 (Ubuntu build)                                   | java -version  |
| javac             | 21.0.12.1                                                          | javac -version |
| OS                | Ubuntu 24.04 (Linux)                                               | uname -a       |
| Commands used     | GNU coreutils 9.4 (wc, ls, sort, cut, uniq, head), grep, sh (dash) | wc --version   |
| Build tool        | none                                                               |                |
| Container runtime | none needed                                                        |                |

The commands are Linux/macOS commands. On Windows the Java is identical but the commands inside the lists aren't; there's a note in the going-further section. I did not run this on Windows.

`CommandRunner` uses `Executors.newVirtualThreadPerTaskExecutor()`, so it needs Java 21\. On Java 11 to 20, swap in `Executors.newCachedThreadPool()` and nothing else changes.

If you don't have a JDK 21, SDKMAN installs one: `sdk install java 21.0.12-tem` (check `sdk list java` for the exact identifier on your machine; I didn't run SDKMAN myself).

## Lay out the project

Create the folders and open them in your editor. The layout is flat on purpose:

```bash
mkdir -p ticket-tools/src/main/java/dev/ggorantala/tickets
mkdir -p ticket-tools/src/test/java/dev/ggorantala/tickets
cd ticket-tools

```

```log
ticket-tools/
├── src/main/java/dev/ggorantala/tickets/
│   ├── CommandRunner.java
│   ├── Main.java
│   ├── Traps.java
│   └── Extras.java
└── src/test/java/dev/ggorantala/tickets/
    └── Checks.java

```

**In IntelliJ IDEA:** File | New | Project, choose Java, pick your JDK 21 and leave the build system on IntelliJ, then create the package `dev.ggorantala.tickets` under `src`. Put the test file in a separate folder marked as Test Sources if you like, or drop it in `src`. I did not run IntelliJ for this article; the wizard names are from the JetBrains help pages.

**In Eclipse:** File | New | Java Project, set the execution environment to JavaSE-21, and create the package with File | New | Package. Same caveat: docs only, not executed.

**From the command line** (what I ran): you compile everything into an `out` folder.

```bash
javac --release 21 -Xlint:all -d out $(find src -name '*.java')

```

On Windows PowerShell, `find` isn't the Unix one. Use `javac --release 21 -d out (Get-ChildItem -Recurse -Filter *.java src).FullName`. Docs only; I didn't run it.

## How to use ProcessBuilder in Java, step by step

Five steps. Each one adds a capability and ends with something you can run.

### Step 1: Run a command and read what it prints

**Goal:** the smallest useful wrapper. Start a process, read its output, report the exit code.

`src/main/java/dev/ggorantala/tickets/CommandRunner.java`

```java
package dev.ggorantala.tickets;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/** Runs one external command and hands back everything that happened. */
public final class CommandRunner {

    public record Result(int exitCode, String stdout, String stderr, boolean timedOut) {
        public boolean ok() {
            return !timedOut && exitCode == 0;
        }
    }

    private static final ExecutorService DRAINERS = Executors.newVirtualThreadPerTaskExecutor();

    private final Path workDir;
    private final Map<String, String> extraEnv;

    public CommandRunner(Path workDir, Map<String, String> extraEnv) {
        this.workDir = workDir;
        this.extraEnv = new HashMap<>(extraEnv);
    }

    public static CommandRunner inDefaultDirectory() {
        return new CommandRunner(null, Map.of());
    }

    public Result run(List<String> command, Duration timeout, String stdin)
            throws IOException, InterruptedException {
        ProcessBuilder pb = new ProcessBuilder(command);
        if (workDir != null) {
            pb.directory(workDir.toFile());
        }
        pb.environment().putAll(extraEnv);

        Process process = pb.start();

        // Start reading BEFORE anything else, or a chatty child fills the pipe and blocks.
        CompletableFuture<String> out = drain(process.getInputStream());
        CompletableFuture<String> err = drain(process.getErrorStream());

        try (OutputStream toChild = process.getOutputStream()) {
            if (stdin != null) {
                toChild.write(stdin.getBytes(StandardCharsets.UTF_8));
            }
        } catch (IOException childClosedEarly) {
            // The child exited before reading everything ("head", for example). That's fine.
        }

        boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
        if (!finished) {
            killTree(process);
            return new Result(-1, collect(out), collect(err), true);
        }
        return new Result(process.exitValue(), collect(out), collect(err), false);
    }

    private static void killTree(Process process) throws InterruptedException {
        // Kill the children first: a shell that forked "sleep" leaves it holding our pipes open.
        process.descendants().forEach(ProcessHandle::destroyForcibly);
        process.destroy();
        if (!process.waitFor(2, TimeUnit.SECONDS)) {
            process.destroyForcibly();
            process.waitFor();
        }
    }

    private static CompletableFuture<String> drain(InputStream in) {
        return CompletableFuture.supplyAsync(() -> {
            try (in) {
                return new String(in.readAllBytes(), StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }, DRAINERS);
    }

    private static String collect(CompletableFuture<String> future) {
        try {
            return future.get(5, TimeUnit.SECONDS);
        } catch (Exception e) {
            return "[output unavailable: " + e.getClass().getSimpleName() + "]";
        }
    }
}

```

That's the whole library class, and I'll walk through it in pieces below, because the order of the lines inside `run` matters more than any single line.

A command is a `List<String>`: the program first, then one list element per argument. There's no shell in between, so no quoting rules and no expansion. `List.of("wc", "-l", "tickets.csv")` is three strings, not a sentence.

`start()` returns immediately. The child is now running, and its stdout and stderr are two pipes that the operating system buffers for you (typically around 64 KB on Linux; I didn't measure the exact number on this machine). When a buffer's full, the child blocks until somebody reads. That somebody is you. That's why `drain` is called right after `start()` and before anything that waits.

`Result` is a record with the exit code, both streams as strings, and a `timedOut` flag. By convention exit code 0 means success; I wrapped that in `ok()` so callers don't re-learn it.

**Checkpoint:** compile the lot.

```bash
javac --release 21 -Xlint:all -d out $(find src -name '*.java')

```

No output means success. `-Xlint:all` stays quiet on this code.

### Step 2: Add stdin, a working directory and an environment variable

**Goal:** run a command somewhere specific, with its own variables and some input.

`CommandRunner` already does this. The three relevant lines are `pb.directory(...)`, `pb.environment().putAll(extraEnv)`, and the `try` block that writes `stdin` and closes the child's input. Closing matters: `sort` doesn't print anything until its input ends, so forgetting to close it is another way to hang.

`src/main/java/dev/ggorantala/tickets/Main.java`

```java
package dev.ggorantala.tickets;

import dev.ggorantala.tickets.CommandRunner.Result;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;

public class Main {

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("tickets");
        Path csv = dir.resolve("tickets.csv");
        Files.writeString(csv, """
                1001,OPEN,billing
                1002,CLOSED,login
                1003,OPEN,login
                1004,OPEN,billing
                1005,PENDING,shipping
                1006,OPEN,login
                """);

        CommandRunner runner = new CommandRunner(dir, Map.of("TICKET_QUEUE", "support"));
        Duration limit = Duration.ofSeconds(5);

        System.out.println("-- 1. run and read stdout");
        Result r = runner.run(List.of("wc", "-l", "tickets.csv"), limit, null);
        System.out.println("exit=" + r.exitCode() + " stdout=" + r.stdout().trim());

        System.out.println("-- 2. stderr kept separate, non-zero exit");
        r = runner.run(List.of("ls", "no-such-file.csv"), limit, null);
        System.out.println("exit=" + r.exitCode() + " ok=" + r.ok());
        System.out.println("stderr=" + r.stderr().trim());

        System.out.println("-- 3. feed stdin");
        r = runner.run(List.of("sort", "-t,", "-k3,3", "-k1,1"), limit, Files.readString(csv));
        System.out.print(r.stdout());

        System.out.println("-- 4. environment variable reaches the child");
        r = runner.run(List.of("sh", "-c", "echo queue=$TICKET_QUEUE"), limit, null);
        System.out.print(r.stdout());

        System.out.println("-- 5. pipeline: open tickets per category");
        List<Process> chain = ProcessBuilder.startPipeline(List.of(
                new ProcessBuilder("grep", ",OPEN,").directory(dir.toFile())
                        .redirectInput(csv.toFile()),
                new ProcessBuilder("cut", "-d,", "-f3"),
                new ProcessBuilder("sort"),
                new ProcessBuilder("uniq", "-c")));
        String piped = new String(chain.get(chain.size() - 1).getInputStream().readAllBytes());
        System.out.print(piped);
        for (Process p : chain) {
            p.waitFor();
        }

        System.out.println("-- 6. timeout");
        long t0 = System.nanoTime();
        r = runner.run(List.of("sh", "-c", "sleep 30; echo never"), Duration.ofSeconds(1), null);
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.println("timedOut=" + r.timedOut() + " tookAbout=" + (ms / 100) * 100 + "ms");

        System.out.println("-- 7. command that does not exist");
        try {
            runner.run(List.of("no-such-tool"), limit, null);
        } catch (IOException e) {
            System.out.println("IOException: " + e.getMessage());
        }
    }
}

```

Don't run it yet. Steps 3 to 5 explain the rest of it.

One note on `pb.environment()`: it starts as a copy of your own environment, so the child inherits `PATH`, `HOME` and everything else. Call `pb.environment().clear()` first if you want a clean slate, and then remember you've also removed `PATH`.

### Step 3: Keep stderr separate and check the exit code

**Goal:** tell "it printed an error" apart from "it printed something".

In `CommandRunner`, stdout and stderr are drained by two separate virtual threads, so the two streams never mix. Section 2 of the demo asks `ls` for a file that isn't there, and the runner reports exit code 2 and the message on stderr, with stdout empty.

If you'd rather have a single interleaved stream (handy for a log file), `ProcessBuilder` can merge them for you. `Extras.java` shows that along with `inheritIO()` and the process metadata:

`src/main/java/dev/ggorantala/tickets/Extras.java`

```java
package dev.ggorantala.tickets;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Extras {

    public static void main(String[] args) throws IOException, InterruptedException {
        Path log = Files.createTempFile("export", ".log");

        // Merge stderr into stdout and send both to a file.
        Process p = new ProcessBuilder("sh", "-c", "echo exporting; echo warning: slow disk >&2; echo finished")
                .redirectErrorStream(true)
                .redirectOutput(ProcessBuilder.Redirect.appendTo(log.toFile()))
                .start();
        System.out.println("exit=" + p.waitFor());
        System.out.print(Files.readString(log));

        // Let the child write straight to this JVM's console.
        System.out.println("-- inheritIO");
        int code = new ProcessBuilder("echo", "hello from the child").inheritIO().start().waitFor();
        System.out.println("exit=" + code);

        // Process metadata
        Process sleeper = new ProcessBuilder("sleep", "5").start();
        System.out.println("alive=" + sleeper.isAlive() + " command=" + sleeper.info().command().orElse("?"));
        sleeper.destroy();
        System.out.println("exit after destroy=" + sleeper.waitFor());
    }
}

```

Run it:

```bash
java -cp out dev.ggorantala.tickets.Extras

```

```log
exit=0
exporting
warning: slow disk
finished
-- inheritIO
hello from the child
exit=0
alive=true command=/usr/bin/sleep
exit after destroy=143

```

Exit code 143 is 128 plus 15: the child died from signal 15 (SIGTERM) on Linux. Don't build logic on that specific value; it's platform behaviour.

### Step 4: Chain commands with startPipeline

**Goal:** `grep ... | cut ... | sort | uniq -c` without a shell.

Section 5 of `Main` does it. `ProcessBuilder.startPipeline` takes a list of builders, wires each child's stdout to the next child's stdin at the operating system level, and returns the processes. The first builder needs an input source (I used `redirectInput` on the CSV), and you read from the last process only.

Two details I tripped over. First, the intermediate processes' output never passes through the JVM, which is why this is cheap for large files. Second, you still need to `waitFor()` on every process in the list; reading the last one's output doesn't reap the others.

### Step 5: Add a timeout, and kill the whole process tree

**Goal:** a command that never finishes can't hang the application.

This is the `waitFor(timeout)` call and the `killTree` method in `CommandRunner`. The first version I wrote was shorter:

```java
// What I wrote first. Don't copy this.
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
    process.destroy();
}

```

It looked right. The timeout fired after half a second and `destroy()` returned at once. And the reader thread was still blocked six seconds later. I'll show you why in the traps section; the fix is the first line of `killTree`, which kills the descendants before the process itself.

**Checkpoint:** run the demo.

```bash
java -cp out dev.ggorantala.tickets.Main

```

The full output is in the next section.

## See it working

Run the demo:

```bash
java -cp out dev.ggorantala.tickets.Main

```

```log
-- 1. run and read stdout
exit=0 stdout=6 tickets.csv
-- 2. stderr kept separate, non-zero exit
exit=2 ok=false
stderr=ls: cannot access 'no-such-file.csv': No such file or directory
-- 3. feed stdin
1001,OPEN,billing
1004,OPEN,billing
1002,CLOSED,login
1003,OPEN,login
1006,OPEN,login
1005,PENDING,shipping
-- 4. environment variable reaches the child
queue=support
-- 5. pipeline: open tickets per category
      2 billing
      2 login
-- 6. timeout
timedOut=true tookAbout=1000ms
-- 7. command that does not exist
IOException: Cannot run program "no-such-tool" (in directory "/tmp/tickets6891492897552760550"): Exec failed, error: 2 (No such file or directory)

```

The temp directory name changes on every run. Everything else should match yours.

Now the traps. Each one below is something I've seen in real code reviews, forced here so you can watch it fail.

`src/main/java/dev/ggorantala/tickets/Traps.java`

```java
package dev.ggorantala.tickets;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;

/** Each trap is forced on purpose. Don't copy these into real code. */
public class Traps {

    public static void main(String[] args) throws Exception {
        // Trap 1: child writes 200 KB to stderr, we only read stdout (after waitFor).
        Process p = new ProcessBuilder("sh", "-c", "head -c 200000 /dev/zero | tr '\\0' x >&2; echo done")
                .start();
        boolean finished = p.waitFor(3, TimeUnit.SECONDS);
        System.out.println("1. undrained stderr finished within 3s? " + finished);
        p.destroyForcibly();

        // Trap 2: no shell, so no expansion.
        p = new ProcessBuilder("echo", "$HOME", "*.csv").start();
        System.out.print("2. echo via ProcessBuilder: " + new String(p.getInputStream().readAllBytes()));
        p = new ProcessBuilder("sh", "-c", "echo $HOME").start();
        System.out.print("   echo via sh -c:          " + new String(p.getInputStream().readAllBytes()));

        // Trap 3: destroy() only the shell while another thread is reading its stdout.
        p = new ProcessBuilder("sh", "-c", "sleep 6; echo late").start();
        InputStream stdout = p.getInputStream();
        long t0 = System.nanoTime();
        Thread reader = Thread.ofVirtual().start(() -> {
            try {
                stdout.readAllBytes();
            } catch (IOException ignored) {
            }
        });
        Thread.sleep(300);
        p.destroy();
        p.waitFor();
        System.out.println("3. shell dead after " + (System.nanoTime() - t0) / 1_000_000 + " ms, alive=" + p.isAlive());
        reader.join();
        System.out.println("   reader finished after " + (System.nanoTime() - t0) / 1_000_000 + " ms");

        // Trap 4: one argument string with spaces.
        try {
            new ProcessBuilder("wc -l tickets.csv").start();
        } catch (IOException e) {
            System.out.println("4. " + e.getMessage());
        }

        // Trap 5: shell injection
        String userInput = "x; echo INJECTED";
        p = new ProcessBuilder("sh", "-c", "echo searching " + userInput).start();
        System.out.print("5. sh -c with concatenated input prints: " + new String(p.getInputStream().readAllBytes()).replace("\n", " | ") + "\n");
        p = new ProcessBuilder("echo", "searching", userInput).start();
        System.out.print("   argument list prints:               " + new String(p.getInputStream().readAllBytes()));
        System.exit(0);
    }
}

```

```bash
java -cp out dev.ggorantala.tickets.Traps

```

```log
1. undrained stderr finished within 3s? false
2. echo via ProcessBuilder: $HOME *.csv
   echo via sh -c:          /root
3. shell dead after 310 ms, alive=false
   reader finished after 6002 ms
4. Cannot run program "wc -l tickets.csv": Exec failed, error: 2 (No such file or directory) 
5. sh -c with concatenated input prints: searching x | INJECTED | 
   argument list prints:               searching x; echo INJECTED

```

Five lines, five bugs:

1. The child wrote 200 KB to stderr while the parent only waited. The pipe filled, the child blocked, and `waitFor` never saw it exit. This is the classic deadlock. The fix is to read both streams from the start, which `CommandRunner` does.
2. `echo $HOME *.csv` printed the dollar sign and the star, literally. No shell, no expansion. When you do need a shell, you have to ask for one with `sh -c`, and then you've also taken on shell quoting.
3. `destroy()` killed the shell in 310 ms (300 of those are my deliberate sleep), but the `sleep 6` it had started carried on, still holding the output pipe open. The reader didn't finish until 6002 ms. That's the bug from Step 5.
4. One string containing spaces is a program name, not a command line. Split it into list elements.
5. When user input is glued into an `sh -c` string, `; echo INJECTED` runs as a second command. The same input as a list element is one inert argument. If any part of a command comes from a user, a file or a network call, build a list and don't use a shell.

## Write the checks

I'd normally reach for JUnit 5 here, and I'll say how in a moment. But Maven Central wasn't reachable from my build machine, so these are plain checks with a `main` method, one line per behaviour. Run them the same way.

`src/test/java/dev/ggorantala/tickets/Checks.java`

```java
package dev.ggorantala.tickets;

import dev.ggorantala.tickets.CommandRunner.Result;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;

/** Plain checks, no test framework. Exit code 1 if any check fails. */
public class Checks {

    private static int passed;
    private static int failed;

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("checks");
        Files.writeString(dir.resolve("a.txt"), "one\ntwo\nthree\n");
        CommandRunner runner = new CommandRunner(dir, Map.of("TICKET_QUEUE", "support"));
        Duration limit = Duration.ofSeconds(5);

        Result r = runner.run(List.of("wc", "-l", "a.txt"), limit, null);
        check("exit code 0 on success", r.exitCode() == 0 && r.ok());
        check("stdout captured", r.stdout().trim().equals("3 a.txt"));
        check("working directory applied", r.stdout().contains("a.txt"));

        r = runner.run(List.of("ls", "missing.txt"), limit, null);
        check("non-zero exit code reported", r.exitCode() != 0 && !r.ok());
        check("stderr is separate from stdout", r.stdout().isEmpty() && r.stderr().contains("missing.txt"));

        r = runner.run(List.of("sort"), limit, "b\na\nc\n");
        check("stdin reaches the child", r.stdout().equals("a\nb\nc\n"));

        r = runner.run(List.of("sh", "-c", "echo $TICKET_QUEUE"), limit, null);
        check("extra environment variable visible", r.stdout().trim().equals("support"));

        r = runner.run(List.of("echo", "$TICKET_QUEUE", "*.txt"), limit, null);
        check("no shell means no expansion", r.stdout().trim().equals("$TICKET_QUEUE *.txt"));

        r = runner.run(List.of("sh", "-c", "head -c 300000 /dev/zero >&2; head -c 300000 /dev/zero"), limit, null);
        check("300 KB on both streams does not deadlock", !r.timedOut() && r.stdout().length() == 300_000 && r.stderr().length() == 300_000);

        r = runner.run(List.of("head", "-c", "1"), limit, "x".repeat(2_000_000));
        check("child that stops reading stdin early is fine", !r.timedOut() && r.stdout().equals("x"));

        long t0 = System.nanoTime();
        r = runner.run(List.of("sh", "-c", "sleep 30; echo never"), Duration.ofMillis(500), null);
        long ms = (System.nanoTime() - t0) / 1_000_000;
        check("timeout flagged", r.timedOut() && !r.ok());
        check("timeout returns in under 3 s even though sleep was a grandchild (" + ms + " ms)", ms < 3000);
        check("no output captured from a killed command", r.stdout().isEmpty());

        try {
            runner.run(List.of("definitely-not-a-command"), limit, null);
            check("missing executable throws IOException", false);
        } catch (IOException expected) {
            check("missing executable throws IOException", expected.getMessage().contains("definitely-not-a-command"));
        }

        System.out.println(passed + " passed, " + failed + " failed");
        System.exit(failed == 0 ? 0 : 1);
    }

    private static void check(String name, boolean condition) {
        if (condition) {
            passed++;
            System.out.println("PASS " + name);
        } else {
            failed++;
            System.out.println("FAIL " + name);
        }
    }
}

```

```bash
javac --release 21 -Xlint:all -d out $(find src -name '*.java')
java -cp out dev.ggorantala.tickets.Checks

```

```log
PASS exit code 0 on success
PASS stdout captured
PASS working directory applied
PASS non-zero exit code reported
PASS stderr is separate from stdout
PASS stdin reaches the child
PASS extra environment variable visible
PASS no shell means no expansion
PASS 300 KB on both streams does not deadlock
PASS child that stops reading stdin early is fine
PASS timeout flagged
PASS timeout returns in under 3 s even though sleep was a grandchild (507 ms)
PASS no output captured from a killed command
PASS missing executable throws IOException
14 passed, 0 failed

```

The 507 ms line is the one worth keeping. I checked that it earns its place: with the `descendants()` line deleted, the same check fails at 10511 ms (two 5-second output guards back to back) and the "no output" check fails with it, so 12 pass and 2 fail.

To move these into JUnit 5, each `check(...)` becomes an `@Test` with an AssertJ or JUnit assertion. I did not run that version, so I won't show output for it. These tests also use Linux commands, so annotate them with `@EnabledOnOs({OS.LINUX, OS.MAC})` if your CI includes Windows.

## What the pipe buffer is doing to you

Every child process gets three pipes from the operating system: one in, two out. Each has a small buffer, and a write to a full buffer blocks the writer. Java doesn't read the child's output on your behalf; `getInputStream()` is you reading the child's stdout, and `getErrorStream()` is you reading its stderr. If you read one and the child fills the other, both of you wait on each other forever.

That one fact explains three of the traps. Undrained stderr: the child blocks on write. Unclosed stdin: the child blocks on read. Grandchild holding a pipe: your read blocks because the pipe's write end is still open somewhere, even though the process you started is gone. A pipe only reports end-of-file when every holder of the write end has closed it.

`destroy()` sends SIGTERM to that one process on Linux and macOS. `destroyForcibly()` sends SIGKILL. Neither touches the children. `Process.descendants()` returns a stream of `ProcessHandle`s for them, which is how `killTree` reaches them.

For deeper reading on the threading side, the virtual-thread drainers in `CommandRunner` are the same pattern as the worker pools in [how to configure a thread pool with ExecutorService](https://ggorantala.dev/how-to-configure-thread-pool-executorservice-java/?ref=ggorantala.dev), and the stream handling follows the same rules as [how to use try-with-resources correctly](https://ggorantala.dev/how-to-use-try-resources-java/?ref=ggorantala.dev). If you want a producer and a consumer on separate threads instead of one-shot draining, see [how to use BlockingQueue for producer-consumer pipelines](https://ggorantala.dev/how-to-use-blockingqueue-producer-consumer-java/?ref=ggorantala.dev).

## If something breaks

| What you see                                                                                  | What it means                                                                                                                                            | Fix                                                                                                       |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| java.io.IOException: Cannot run program "no-such-tool" ... error=2, No such file or directory | The executable isn't on PATH (or the working directory doesn't exist). Note: the message names the directory too, which misleads people into blaming it. | Check the program name with which <name>. On Windows, built-ins like dir aren't programs; run cmd /c dir. |
| The program hangs, no output, CPU idle                                                        | A pipe filled up. Usually stderr you didn't read.                                                                                                        | Read stdout and stderr concurrently from the start, or use redirectErrorStream(true).                     |
| The timeout fires, but the call takes much longer to return                                   | You killed the parent but a grandchild kept the pipe open.                                                                                               | Destroy process.descendants() first, then the process.                                                    |
| cannot find symbol ... newVirtualThreadPerTaskExecutor() when compiling with \--release 17    | Virtual threads are Java 21.                                                                                                                             | Compile with \--release 21, or replace the executor with newCachedThreadPool().                           |
| echo $HOME prints $HOME                                                                       | No shell, no expansion.                                                                                                                                  | Resolve the value in Java (System.getenv("HOME")), or deliberately use sh -c.                             |
| A command with a space in its name fails with error=2                                         | One string is one program name.                                                                                                                          | Split into list elements.                                                                                 |
| Exit code 143 or 137                                                                          | 143 is SIGTERM (128+15), 137 is SIGKILL (128+9).                                                                                                         | You, or the operating system, killed it. Not a bug in the command.                                        |

## Taking it to production

Treat the command line as an API boundary. Fixed program name, fixed flags, user data only ever as a single list element. Never build a string and hand it to `sh -c`. If you must use a shell, validate the input against an allow-list first.

Set a timeout on every call. Pick it from what the command should take, then double it. There's no "no timeout" setting that's safe.

Log the command, the exit code and the first few hundred characters of stderr on failure. Don't log the full stdout; it can be enormous, and it can contain data you shouldn't be writing to logs.

Large output: `CommandRunner` keeps all of stdout in memory. For a command that emits gigabytes, use `redirectOutput(Redirect.to(file))` instead and read the file with [NIO.2](https://ggorantala.dev/how-to-read-write-files-nio-java/?ref=ggorantala.dev).

Retries: a command that failed with a transient error is a candidate for [retry with exponential backoff](https://ggorantala.dev/how-to-implement-retry-exponential-backoff-java/?ref=ggorantala.dev); one that timed out usually isn't, since it may have done half its work.

Windows: the commands in this article don't exist there. Use `cmd /c <builtin>` for built-ins and `powershell -NoProfile -Command ...` for PowerShell, and expect `.cmd` and `.bat` files to need `cmd /c` as well. I didn't test any of that, so check the current JDK documentation for `ProcessBuilder` and the Windows quoting notes.

Versions: `Process.inputReader()` and `errorReader()` arrived in Java 17 and are a shorter way to get a text reader. `ProcessHandle` and `Process.descendants()` came with Java 9\. `startPipeline` is also Java 9\. Java 8 code looks different, and you'll see `Runtime.exec(String)` in older tutorials; it splits the string on whitespace, which is trap 4 in disguise.

Next in this series: reading configuration safely is covered in the environment variables article (coming at [how to read environment variables and system properties in Java](https://ggorantala.dev/how-to-read-environment-variables-system-properties-java/?ref=ggorantala.dev)), and when you're ready for proper tests, [how to write unit tests with JUnit 5](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev).

## Checklist

- Build the command as a `List<String>`, one argument per element.
- Start reading stdout and stderr right after `start()`.
- Close the child's stdin when you're done writing.
- Wait with a timeout, and on timeout kill the descendants first, then the process.
- Check the exit code; don't assume 0.
- Never glue user input into an `sh -c` string.
- Expect an `IOException` when the program isn't found.

## Questions people ask about running commands from Java

**How do I get the output of a command in Java?**  
Start it with `ProcessBuilder.start()` and read `getInputStream()` (stdout) and `getErrorStream()` (stderr). Read both from the start on separate threads, or merge them with `redirectErrorStream(true)`. In Java 17 and later, `process.inputReader()` gives you a `BufferedReader` directly.

**What's the difference between ProcessBuilder and Runtime.exec()?**  
`Runtime.exec` is the older API, and its single-string form splits on whitespace. `ProcessBuilder` takes a list, lets you set the working directory, environment and redirects, and is what `Runtime.exec` uses internally. Use `ProcessBuilder`.

**How do I run a command with pipes or wildcards in Java?**  
Pipes: use `ProcessBuilder.startPipeline`, as in Step 4\. Wildcards: expand them yourself with `Files.newDirectoryStream` and pass the matches as arguments, or deliberately run `sh -c` with a fixed string that contains no user input.

**How do I stop a command that runs too long?**  
Use `process.waitFor(timeout, unit)`. If it returns false, destroy `process.descendants()` and then the process, as in `CommandRunner.killTree`.

**Why does my Java program hang after starting a process?**  
Almost always a full pipe: the child is blocked writing output nobody is reading, usually stderr. Drain both streams as soon as you start the process.

## References

- Java SE 21 API: `java.lang.ProcessBuilder`, `java.lang.Process`, `java.lang.ProcessHandle` (docs.oracle.com/en/java/javase/21/docs/api/)
- JEP 444, Virtual Threads (openjdk.org/jeps/444).