Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Fish Pipeline Status Semantics: Inspecting Every Stage Without Losing Context

Read Fish pipeline results correctly with status and pipestatus, snapshot stage codes before they change, and distinguish real failures from expected SIGPIPE.

A pipeline connects the output of one command to the input of another, but a single exit status cannot describe every stage’s outcome. Fish exposes status for the last foreground job and pipestatus as a list containing the exit statuses of the processes in the last executed pipe. Robust scripts decide which stage outcomes matter and capture them before another command replaces the context.

This is not the same as blindly treating every nonzero stage as failure. An early-exiting consumer can close its input and cause a producer to receive SIGPIPE even when the requested result was obtained. Correct handling requires both mechanics and an explicit policy for the pipeline’s purpose.

status and pipestatus answer different questions

For a simple pipeline, status normally reflects the last process in the pipeline, while pipestatus exposes the status of every process that made up the last pipe. If a three-stage pipeline is producer | transform | consumer, pipestatus contains the producer, transform, and consumer statuses in that order.

producer | transform | consumer

# Snapshot the raw outcomes before running other logic.
set -l stage_statuses $pipestatus

printf 'pipeline stages: %s\n' $stage_statuses

The snapshot is useful because the special variables describe recent execution, not a permanent record. Copy pipestatus immediately after the pipeline if later commands need to inspect it. Once captured, stage_statuses is an ordinary local list that can be passed to helper functions or tested without depending on whatever command ran in between.

Status is a scalar, while pipestatus is a list. Fish’s not operator negates status for conditional use but deliberately does not negate the individual elements of pipestatus. This preserves the underlying stage outcomes; a boolean view and a diagnostic view are not interchangeable.

not cat input.txt | grep -q fish
printf 'negated status=%s; raw cat=%s; raw grep=%s\n' \
    $status $pipestatus[1] $pipestatus[2]

Here the output is expanded before printf runs. The resulting status reflects the negated pipeline result, while pipestatus still reports the raw cat and grep process statuses. This is useful for conditions but can be confusing if a script reports only one of those views.

Snapshot the list before branching

A common failure pattern is to run a pipeline, execute an unrelated command, and only then inspect pipestatus. At that point the list may describe a different command or pipeline. Capture it directly and derive the pipeline’s last-stage status from the final list element when needed.

function check_pipeline
    producer | transform | consumer
    set -l stage_statuses $pipestatus
    set -l last_stage_status $stage_statuses[-1]

    if test $last_stage_status -ne 0
        printf 'consumer failed with status %s\n' $last_stage_status >&2
        return 1
    end
end

Fish list indexing uses one-based indices; [-1] selects the final element. This example checks the consumer because its result is the pipeline’s usual scalar result. It does not prove the earlier producer and transform succeeded. If intermediate failure matters, inspect those elements explicitly.

function require_all_pipeline_stages
    producer | transform | consumer
    set -l codes $pipestatus
    set -l failed 0
    set -l stage 1

    for code in $codes
        if test $code -ne 0
            printf 'stage %s exited %s\n' $stage $code >&2
            set failed 1
        end
        set stage (math $stage + 1)
    end

    if test $failed -ne 0
        return 1
    end
end

The strict all-stages policy is appropriate only if every stage is expected to complete successfully. A general pipeline has no universal failure policy: the producer may be allowed to report a missing optional source, or a filtering command may use a nonzero status to mean no match. Map codes to documented command meanings, not merely to zero versus nonzero.

Handle expected SIGPIPE deliberately

When a consumer stops reading early, a producer may write to a closed pipe and receive SIGPIPE. Fish documents an example where cat feeding head can produce a producer status of 141, which is 128 plus signal 13, while head exits successfully. Whether that happens can depend on the amount of output and implementation details; a different run may show zero for both stages.

This is not automatically a data-loss bug. If the application asked for the first 50 lines, an early consumer exit may be intentional. But if the pipeline is supposed to transform and deliver the entire stream, a producer-side failure may indicate truncation. Decide based on the operation’s contract and verify the result, rather than hard-coding “ignore 141” everywhere.

function read_prefix
    cat large.log | head -n 50
    set -l codes $pipestatus

    # The intended output is bounded to the first 50 lines.
    # Interpret a producer-side SIGPIPE only in this specific context.
    set -l producer_status $codes[1]
    set -l consumer_status $codes[-1]

    if test $consumer_status -ne 0
        printf 'head failed: %s\n' $consumer_status >&2
        return 1
    end
end

The snippet does not suppress every nonzero producer code because a failure to open large.log is different from an expected closed pipe. A production pipeline should distinguish those cases if they have different meanings, for example by validating its input before starting the bounded read or by using a producer that can report the relevant error independently.

Match status handling to command semantics

Many commands use nonzero statuses for ordinary domain results, not only operational errors. grep commonly returns a nonzero status when no lines match, which can be an expected search outcome. A parser may use nonzero for invalid input. A network client may use different codes for timeout and application errors. Consult the command’s own documentation and define the policy at the call site.

Do not treat an empty output stream as proof of failure, or a nonempty stream as proof of success. Commands can emit partial output and fail afterward. Likewise, redirection and pipeline setup errors can be reported by Fish before a child process runs. Preserve stderr separately when a diagnostic distinction matters.

When a pipeline feeds a destructive or state-changing operation, validate all stages whose failures could leave an incomplete input. If a transform exits early, the consumer may still receive a syntactically valid but incomplete stream. Stage-level statuses are part of the operation’s acceptance criteria, not merely debugging output.

Avoid status races in control flow

The next command can replace status. A conditional immediately following a command can test the preceding result because Fish expands the variable before running the test, but code that branches through helper commands should first store the relevant value. Similarly, do not call a logging function and then inspect status expecting it to still describe the original operation.

function report_pipeline
    producer | transform | consumer
    set -l codes $pipestatus
    set -l consumer_status $codes[-1]

    if test $consumer_status -eq 0
        printf 'consumer stage completed\n'
    else
        printf 'consumer stage failed: %s\n' $consumer_status >&2
        return $consumer_status
    end
end

Use return codes that make sense to the caller. Returning the consumer’s exact status can preserve a command-specific result; returning a documented wrapper status can provide a stable API. In either case, make sure the wrapper does not accidentally report success after a failed stage.

Background jobs and process substitutions are separate concerns from the statuses of a foreground pipeline. Do not assume a list of pipe statuses contains every asynchronous activity launched by a larger command line. If a script starts jobs, retain their process identifiers and collect their statuses with an explicit job-waiting design.

Build a repeatable pipeline test matrix

Test at least four cases: all stages succeed; the producer fails before output; an intermediate transform fails; and the consumer fails after reading. Add a bounded-consumer case that can produce SIGPIPE, and a no-match case for filters whose nonzero result is normal. Use test fixtures with known output so a successful exit status cannot hide truncation or malformed data.

Keep diagnostic output from the tested pipeline separate from the script’s own reporting. Test a missing input path, invalid transformation options, empty input, very small output, and enough output to exercise early consumer exit. Because SIGPIPE behavior can depend on how much data is produced, one tiny fixture is not sufficient for that case.

Record both the intended result and the status policy in code comments. For example, producer status 141 is acceptable only because this call deliberately requests a bounded prefix. A comment that says to ignore all nonzero pipeline statuses is not a policy.

Acceptance rules for reliable Fish pipelines

Before marking a pipeline successful:

  1. Capture pipestatus immediately after the pipeline.
  2. Decide which stages must succeed and what nonzero means for each command.
  3. Treat SIGPIPE as contextual evidence, not a universal exception.
  4. Verify output completeness or structure when partial output would be dangerous.
  5. Preserve meaningful diagnostics and return a stable status to the caller.
  6. Test producer, transform, consumer, and early-exit failures with controlled fixtures.

Fish gives scripts enough information to make this policy explicit. The difficult part is not reading a list of integers; it is deciding which outcomes satisfy the operation’s contract and preserving that decision before subsequent commands overwrite the evidence.

Related:

Sources:

Comments