본문 바로가기
빌드노트 검색

빌드노트

pipefail을 켰는데 diff <(...)의 생산자 실패가 보이지 않는다

같은 한 줄을 출력하고 7로 끝난 생산자를 diff <(...)에 연결해도 diff는 0이었다. pipefail과 PIPESTATUS의 범위를 구분하고, 생산자 상태를 따로 확인한 뒤 파일을 비교하는 방법을 설명한다.

Bash에서 diff <(producer ...) <(producer ...)가 0으로 끝나도, 두 생산자가 정상 종료했다고 판단할 수는 없다. <(...) 안의 명령은 비동기로 실행되며, 그 종료 상태는 바깥 diff의 상태에 합쳐지지 않는다. 결과를 만든 작업의 성공까지 확인해야 한다면 생산자 상태를 각각 수집하거나, 생산을 마친 파일만 비교하는 단계로 나눠야 한다. pipefail을 켜는 것만으로는 해결되지 않는다.

같은 한 줄을 썼다면 diff는 0이다

정상 생산자와 실패한 생산자가 같은 내용을 쓰도록 해서 차이를 확인했다. Linux나 WSL에서 Bash와 GNU diff가 필요하다. env -i PATH=/usr/bin:/bin LC_ALL=C bash --noprofile --norc로 새 Bash를 열고 다음을 실행한다. set +e는 실패 후에도 상태를 읽기 위한 이 실습의 설정이다.

producer는 alpha 한 줄을 출력하고 인자로 받은 코드로 끝나는 합성 함수다. 함수 몸체를 (...)로 감쌌으므로 그 안의 exit는 호출한 실습 셸을 종료하지 않는다. 두 번의 비교에서 왼쪽 생산자의 종료 코드만 바꾼다.

set +e
set -o pipefail
producer() (
    printf 'alpha\n'
    exit "$1"
)

diff <(producer 0) <(producer 0)
printf 'both-ok diff=%s\n' "$?"
diff <(producer 7) <(producer 0)
printf 'left-failed diff=%s\n' "$?"

Ubuntu 24.04.4의 Bash 5.2.21, GNU diffutils 3.10에서 실제 출력은 다음과 같았다.

both-ok diff=0
left-failed diff=0

두 번째 비교에서도 diff가 읽은 것은 양쪽의 같은 alpha 한 줄이었다. GNU diff 매뉴얼 (새 탭에서 열림)의 종료 상태는 내용이 같으면 0, 다르면 1, 처리 중 문제가 생기면 2다. 여기서 0은 입력 내용의 비교 결과다. 입력을 만든 프로그램의 작업 완료까지 인증하는 값은 아니다. 실제 파일을 비교할 때도 1이 나오면 먼저 내용 차이를 읽고, 2라면 파일 접근 등 비교 자체의 오류를 확인한다.

Debian이 제공하는 GNU Bash 5.2 매뉴얼 (새 탭에서 열림)의 Process Substitution 설명에 따르면, <(...)는 명령을 비동기로 실행하고 그 출력을 읽을 파일 이름을 바깥 명령에 넘긴다. 반면 pipefail은 파이프라인을 구성하는 명령들의 종료 상태를 고르는 옵션이다. diff의 인자로 들어간 두 생산자는 그 전경 파이프라인의 단계가 아니다. 실제 연결에 파이프가 쓰인다는 것과 셸 문법의 파이프라인에 속한다는 것은 구별해야 한다.

별도로 producer 7 | cat을 실행하면 pipefail이 적용돼 전체 상태가 7이었다. 하지만 위 diff 직후 저장한 PIPESTATUS에는 0 하나만 있었다. PIPESTATUS로 바꿔 읽어도 치환 생산자의 7을 얻는 것은 아니었다.

생산을 끝내고 상태를 확인한 뒤 비교한다

두 결과를 동시에 흘려보낼 필요가 없다면, 생산자가 파일을 쓰고 종료한 뒤 비교하는 구성이 단순하다. 같은 새 Bash에서 앞의 producer 정의를 유지하고 다음을 실행한다. mktemp, rm, rmdir와 임시 디렉터리에 쓸 권한이 필요하다. 이 함수는 새 임시 디렉터리의 두 파일만 사용하며 함수가 끝날 때 정리한다.

여기서는 각 생산자 바로 뒤에서 상태를 저장하고, 하나라도 실패하면 diff를 실행하지 않는지 확인한다.

compare() (
    work=$(mktemp -d) || exit 1
    trap 'rm -f -- "$work/left" "$work/right"; rmdir -- "$work"' EXIT
    left=0
    producer 7 >"$work/left" || left=$?
    right=0
    producer 0 >"$work/right" || right=$?
    printf 'left=%s right=%s\n' "$left" "$right"
    if (( left != 0 || right != 0 )); then
        printf 'skip diff: producer failed\n' >&2
        exit 1
    fi
    diff -- "$work/left" "$work/right"
)
compare
printf 'compare=%s\n' "$?"

표준 출력은 다음과 같았고, 표준 오류에는 skip diff: producer failed가 나왔다.

left=7 right=0
compare=1

파일 두 개의 내용은 같아도 왼쪽 생산자의 7을 확인했으므로 비교를 건너뛰었다. 여기서 compare=1은 생산자 실패 시 이 함수가 선택한 상태이고, 생산자의 원래 코드는 left=7에 남는다. 함수 안의 producer 7을 producer 0으로 바꾼 정상 대조에서는 left=0 right=0, compare=0이 나왔다. 두 생산자가 모두 성공하면 마지막 diff의 상태가 함수의 상태가 된다.

실제 코드에서는 두 producer 호출을 자신의 입력 생성 명령으로 바꾸고, 각각의 || left=$?, || right=$?를 유지한다. 진단 메시지보다 먼저 상태를 저장해야 한다. 뒤에 표시용 printf를 실행하면 그 명령의 성공으로 셸의 마지막 상태가 바뀌므로, 배치에서 실패 시 중단하려면 출력용 호출부 대신 compare || exit "$?"처럼 처리한다. 이 호출부를 별도로 실행했을 때 실패 조건은 셸 상태 1, 정상 조건은 0을 반환했다.

파일 방식은 두 생산자를 순서대로 끝내고 중간 결과를 저장한다. 동시 실행과 스트리밍을 유지해야 하는 작업에 그대로 적용하면 처리 방식이 달라진다. 대신 이 예제에서는 생산자 완료 여부를 확인한 다음에만 비교 결과를 사용할 수 있다.

wait를 쓸 때도 생산자마다 상태를 받아야 한다

동시 실행을 유지하려면 각 생산자를 시작할 때 PID를 저장하고, 그 자식을 시작한 Bash에서 wait "$pid"로 상태를 각각 받아야 한다. 두 입력을 별도의 파일 디스크립터로 열고 각 시작 직후의 $!를 저장한 추가 실험에서는 diff=0 left=7 right=0을 얻었다. 오른쪽만 7로 바꾸자 diff=0 left=0 right=7이었다. 바깥 비교 상태와 생산자 상태가 따로 있다는 것을 확인한 결과다.

diff <(...) <(...) 뒤에서 $! 하나만 저장하면 두 생산자의 PID를 모두 확보한 것이 아니다. 인자 없는 wait도 실패 상태를 모아 주지 않는다. Bash의 wait 설명 (새 탭에서 열림)은 인자가 없을 때 기다리는 대상의 조건과 반환값 0을 명시한다. 실제로 7로 끝나는 생산자 하나를 연결한 실험에서도 인자 없는 wait는 0을 반환했다. 완료를 기다렸다는 사실과 성공을 확인했다는 사실을 구분해야 한다.

위 관찰은 한 줄을 끝까지 출력하고 종료하는 합성 생산자에 대한 것이다. 큰 출력, 소비자의 조기 종료나 멈춤, 다른 Bash 버전의 동작은 검증하지 않았다. 특히 소비자가 읽기를 멈춘 채 생산자만 기다리는 구조는 별도의 종료 관리가 필요하다. <(...)는 Bash 문법이며 FIFO 또는 /dev/fd 지원이 필요하므로 POSIX sh 전체에 적용되는 예제로 읽어서는 안 된다.

← 전체 글 보기