Skip to content

Ignore SIGPIPE in the CLI (silent exit when a connection dies, e.g. after sleep/wake)#481

Open
kk1987 wants to merge 1 commit into
axel-download-accelerator:masterfrom
kk1987:fix-sigpipe
Open

Ignore SIGPIPE in the CLI (silent exit when a connection dies, e.g. after sleep/wake)#481
kk1987 wants to merge 1 commit into
axel-download-accelerator:masterfrom
kk1987:fix-sigpipe

Conversation

@kk1987

@kk1987 kk1987 commented Jun 6, 2026

Copy link
Copy Markdown

Summary

axel dies silently — no error message, output cut off mid-progress-line — whenever a write hits a connection that the peer has already closed, because nothing in axel handles SIGPIPE and its default action terminates the process. The easiest way to hit this in real life: start a download, close the laptop lid, reopen it ten minutes later — the server (or a NAT box) has dropped the connections during sleep, axel wakes up, writes to a dead socket, and is gone without a trace (exit status 141 = 128+SIGPIPE).

Root cause

The only signal handlers axel installs are SIGINT/SIGTERM (src/text.c). There is no signal(SIGPIPE, ...), no SO_NOSIGPIPE, no MSG_NOSIGNAL anywhere in the tree. So the first write on a reset connection raises SIGPIPE and kills the process before any of the existing error paths can run. The writes that trigger it are exactly the ones that run while recovering from dead connections: ssl_disconnect()SSL_shutdown() sending close_notify into a reset socket (ssl.c), and tcp_write() re-sending an HTTP/FTP request (http.c / ftp.c). The error handling for failed writes is already in place — it just never gets a chance, because the signal fires first.

This is what wget, curl and aria2 all do: wget runs signal(SIGPIPE, SIG_IGN) in main() ("What we want is to ignore SIGPIPE and just check for the return value of write()"), aria2 sets SIG_IGN in its signal setup, and libcurl wraps its internals in sigpipe_ignore()/sigpipe_restore() (plus SO_NOSIGPIPE where available).

Steps to reproduce

Tested on macOS Tahoe 26.4.1 (Darwin 25.4.0), MacBook Pro M4 Max, axel 2.17.14 from Homebrew. (Linux is affected too — the default SIGPIPE action is the same; macOS users just hit the sleep/wake trigger constantly.)

Quick proof that the handler is missing:

axel -n2 -o /tmp/u.iso https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso &
sleep 5; kill -PIPE $(pgrep -x axel); sleep 1; wait $!; echo "exit=$?"

The process disappears mid-progress-line with no message; exit=141.

Realistic reproduction of the wake-from-sleep path — a local TLS server that serves a 200 MB file slowly and, 8 seconds in, force-RSTs every connection (SO_LINGER(1,0) + close), which is what axel finds when the machine wakes up:

#!/usr/bin/env python3
import socket, ssl, struct, threading, time, re, os
PORT, SIZE, CHUNK, RST_AFTER = 8443, 200*1024*1024, 65536, 8
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain('cert.pem', 'key.pem')  # openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 2 -nodes -subj /CN=127.0.0.1
clients, lock, first = [], threading.Lock(), threading.Event()
def handle(tls):
    try:
        req = b''
        while b'\r\n\r\n' not in req: req += tls.recv(4096)
        m = re.search(rb'Range: bytes=(\d+)-(\d*)', req)
        start = int(m.group(1)) if m else 0
        end = int(m.group(2)) if m and m.group(2) else SIZE - 1
        n = end - start + 1
        tls.sendall((f"HTTP/1.1 {'206 Partial Content' if m else '200 OK'}\r\nContent-Length: {n}\r\n"
                     + (f"Content-Range: bytes {start}-{end}/{SIZE}\r\n" if m else "Accept-Ranges: bytes\r\n")
                     + "Connection: keep-alive\r\n\r\n").encode())
        first.set()
        sent = 0
        while sent < n:
            c = min(CHUNK, n - sent); tls.sendall(b'\0' * c); sent += c; time.sleep(0.05)
    except OSError: pass
def rst_all():
    first.wait(); time.sleep(RST_AFTER)
    with lock:
        for tls in clients:
            try:
                tls.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
                os.close(tls.fileno())
            except OSError: pass
threading.Thread(target=rst_all, daemon=True).start()
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('127.0.0.1', PORT)); srv.listen(16)
while True:
    raw, _ = srv.accept()
    try: tls = ctx.wrap_socket(raw, server_side=True)
    except OSError: continue
    with lock: clients.append(tls)
    threading.Thread(target=handle, args=(tls,), daemon=True).start()
python3 rst_server.py &
axel -k -n4 -o /tmp/t.bin https://127.0.0.1:8443/big; echo "exit=$?"

With 2.17.14 the moment the RSTs land axel vanishes mid-line — no error output of any kind — with exit=141. With this patch the same RST burst produces the normal connection-error/reconnect handling: axel re-establishes all four connections (the server keeps listening) and the 200 MB download completes with exit 0.

Real-world case

This was tracked down from a download that was left running when a MacBook's lid was closed; on wake ~10 minutes later, the axel process had vanished without printing anything. During sleep the server had dropped the connections, and on wake axel's own recovery path was what killed it: the connection-timeout cleanup calls conn_disconnect()SSL_shutdown(), whose close_notify write into a reset socket raised the fatal SIGPIPE.

Fix

Ignore SIGPIPE in main(), exactly as wget does, so failed writes surface as EPIPE/SSL errors and flow into the reconnect logic that already exists. One-line change plus a comment.

Nothing in axel handles SIGPIPE, so the first write to a connection
whose peer has already gone away kills the process silently with the
signal's default action -- no error message, output cut off mid-line.

The typical real-world trigger is waking a laptop from sleep: the
server has dropped the connections in the meantime, and the writes
that run while recovering from that (SSL_shutdown() sending
close_notify into a reset socket, tcp_write() re-sending a request)
raise the fatal signal before the existing write-error handling gets
a chance to run.

Ignore SIGPIPE in main(), as wget, curl and aria2 do, so failed
writes surface as EPIPE/SSL errors and flow into the reconnect logic
that already exists.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant