When working with asynchronous I/O Rust applications, handling
shutdowns and errors gracefully is essential. Tokio provides
facilities to listen for OS signals (SIGTERM
,
SIGINT
, etc.), making it straightforward to
implement clean termination paths. However, confusing signals
with exit codes can lead to errors like:
Custom { kind: Other, error: "signal too large" }
This article explores why this happens, what the error means, and how to properly distinguish between signals and exit codes in Rust.
Tokio’s tokio::signal::unix module exposes UNIX signals as
asynchronous streams. For example, listening for the termination
signal (SIGTERM
, code 15):
let terminate_signal = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(15))
.unwrap()
.recv()
.await
}
.shared();
tokio::select! {
_ = terminate_signal.clone() => {
println!("terminate signal receieved");
Ok(())
}
}
Suppose you want to handle a network connection failure, such as
ECONNABORTED
(connection aborted). On some systems,
this corresponds to numeric error code 53.
A common mistake is to try handling it like a signal:
let connection_aborted_signal = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(53))
.unwrap()
.recv()
.await
}
.shared();
tokio::select! {
_ = connection_aborted_signal.clone() => {
println!("connection aborted signal received");
Ok(())
}
}
At runtime, this fails with:
Custom { kind: Other, error: "signal too large" }
Why “signal too large”?
To understand the error, we need to understand the relationship between exit codes and signals.
Exit codes are unsigned 8-bit integers (ie 1-255) that a program
returns to the operating system, indicating how the program
ended. Each of these codes represents what it represents. The
most notable ones are 1(SIGHUP
), which is a
catchall for general errors 1, and 2 (SIGINT
),
which is what the OS gets when you use Ctrl-C. Some signals are
only native to UNIX-like systems. From 1 to 31 are signals,
hence why their names start from “SIG” and they are native to
UNIX-like systems.
Now back to our error. “signal too large”. Handling an exit code with a value of 53 logically is more than the range expected, which makes sense why we got the error we got
To handle exit codes that are not signals, we can simply map the
I/O operation error to std::io::ErrorKind
variants
be calling the kind()
method. In the context of
handling an
ECONNABORTED
exit code because it is is available as an error kind and
handled like this:
match err.kind() {
ErrorKind::ConnectionAborted => {
tracing::warn!("Connection was aborted. Retrying...");
continue;
}
_ => {
tracing::error!("Unexpected error: {:?}", err);
return Err(err);
}
}
This ensures retriable errors are handled gracefully, without confusing them with OS signals.