Signals
Vix Async provides coroutine-based waiting for operating-system signals through signal_set.
Signals such as SIGINT and SIGTERM often represent process-level events that should change application state or begin shutdown. Waiting for them with a blocking call would occupy the scheduler thread, so signal_set performs signal waiting separately and returns the result through the Vix scheduler.
Header
Use the public Vix Async header:
#include <vix/async.hpp>For examples that print output:
#include <vix/print.hpp>The signal API lives in:
vix::async::coreAccess the signal service
An io_context owns its signal service.
using namespace vix::async::core;
io_context ctx;
auto& signals = ctx.signals();The service is created lazily when signals() is first requested.
It remains associated with that context and returns signal completions through the context scheduler.
Register signals
Before waiting, add the signal numbers the application wants to observe.
ctx.signals().add(SIGINT);
ctx.signals().add(SIGTERM);For a typical command-line application:
#include <csignal>
#include <vix/async.hpp>
#include <vix/print.hpp>
using namespace vix::async::core;
task<void> run(io_context& ctx)
{
auto& signals = ctx.signals();
signals.add(SIGINT);
signals.add(SIGTERM);
int signal = co_await signals.async_wait();
vix::print("signal:", signal);
ctx.stop();
}async_wait() returns the actual signal number that was received.
Wait for a signal
The main coroutine operation is:
int signal = co_await ctx.signals().async_wait();Its result type is:
task<int>The coroutine suspends until one of the registered signals is delivered.
Conceptually:
coroutine
↓
async_wait()
↓
coroutine suspends
↓
signal worker waits
↓
registered signal received
↓
continuation posted to scheduler
↓
coroutine resumes with signal numberApplication code after co_await runs through the Vix scheduler, not on the signal-waiting thread.
Identify the received signal
Because async_wait() returns the actual signal number, one wait can handle several registered signals.
task<void> run(io_context& ctx)
{
auto& signals = ctx.signals();
signals.add(SIGINT);
signals.add(SIGTERM);
int signal = co_await signals.async_wait();
if (signal == SIGINT)
{
vix::print("SIGINT received");
}
else if (signal == SIGTERM)
{
vix::print("SIGTERM received");
}
ctx.stop();
}There is no need to create one signal_set for each signal.
Register signals before waiting
The expected workflow is:
get signal_set
↓
add signals
↓
start async_wait()For example:
auto& signals = ctx.signals();
signals.add(SIGINT);
signals.add(SIGTERM);
int signal = co_await signals.async_wait();The signal worker starts lazily when asynchronous waiting begins.
Register the signal set before starting the wait rather than changing the watched set while an active wait is already in progress.
Duplicate registration
Adding the same signal more than once does not create duplicate entries.
signals.add(SIGINT);
signals.add(SIGINT);SIGINT is still watched once.
This allows initialization code to register a signal without creating duplicate delivery entries inside the Vix signal set.
Invalid signal numbers
add() rejects non-positive signal numbers.
This is invalid:
signals.add(0);and reports:
errc::invalid_argumentthrough std::system_error.
For example:
try
{
signals.add(0);
}
catch (const std::system_error& error)
{
if (error.code() == make_error_code(errc::invalid_argument))
{
vix::print("invalid signal");
}
}Platform rules still determine which positive signal numbers can meaningfully be used.
Remove a signal
Use remove() when a signal should no longer belong to the watched set.
signals.remove(SIGTERM);Removing a signal that is not currently registered is safe.
A common configuration pattern is therefore:
signals.add(SIGINT);
signals.add(SIGTERM);
// later, before another wait
signals.remove(SIGTERM);Signal registration ultimately follows operating-system signal semantics, so applications should configure their watched set before beginning a wait whenever possible.
Only one active waiter
A signal_set supports one active async_wait() at a time.
This is valid:
int signal = co_await signals.async_wait();Starting another wait on the same signal_set while the first wait is still active is rejected.
Conceptually:
signal_set
↓
waiter A active
↓
waiter B attempts async_wait()
↓
errc::not_readyThe second coroutine receives:
errc::not_readythrough std::system_error.
This rule makes ownership of the next signal explicit.
Handle a second-waiter error
For example:
try
{
int signal = co_await ctx.signals().async_wait();
vix::print("signal:", signal);
}
catch (const std::system_error& error)
{
if (error.code() == make_error_code(errc::not_ready))
{
vix::print("another signal waiter is active");
}
}If several parts of an application care about process signals, prefer one coroutine that owns async_wait() and distributes the resulting application event explicitly.
Wait for several signals over time
After one wait completes, the coroutine can wait again.
task<void> monitor(io_context& ctx)
{
auto& signals = ctx.signals();
signals.add(SIGINT);
signals.add(SIGTERM);
while (ctx.is_running())
{
int signal = co_await signals.async_wait();
vix::print("signal:", signal);
if (signal == SIGTERM)
{
ctx.stop();
}
}
}Only one wait is active at each point in the loop.
The next async_wait() starts after the previous one has completed.
Pending signals
The signal worker can remain active between waits.
If a registered signal is captured while no coroutine is currently waiting, Vix stores that signal in a pending queue.
The next async_wait() can consume it immediately.
signal received
↓
no active waiter
↓
pending queue
↓
next async_wait()
↓
signal returnedThis prevents an already captured signal from being lost simply because the next coroutine wait had not yet been installed.
Pending signals are consumed in arrival order.
Signal callbacks
signal_set also supports a callback:
signals.on_signal([](int signal){
vix::print("received:", signal);
});The callback receives the actual signal number.
It is posted through the io_context, so application callback code runs on the scheduler thread rather than inside the signal worker.
signal worker
↓
signal received
↓
callback posted
↓
scheduler
↓
callback executesThis keeps application signal handling outside the low-level waiting thread.
on_signal() complements async_wait()
The current signal worker starts when async_wait() is first used.
For that reason, on_signal() is best understood as an observer attached to the active signal service, not as a replacement for starting asynchronous signal waiting.
For example:
task<void> run(io_context& ctx)
{
auto& signals = ctx.signals();
signals.add(SIGINT);
signals.on_signal([](int signal){
vix::print("observed:", signal);
});
int signal = co_await signals.async_wait();
vix::print("awaited:", signal);
ctx.stop();
}When the signal arrives, the callback is scheduled and the active waiter is also completed.
Callback and waiter can both observe a signal
on_signal() and async_wait() are not competing consumers.
If a callback is installed and a coroutine is waiting, the same received signal can be delivered to both:
signal received
↓
├── on_signal callback
│ ↓
│ scheduler
│
└── active async_wait
↓
schedulerThis makes the callback useful for observation while async_wait() remains the coroutine control-flow mechanism.
If no waiter is active, the signal can be queued for a later async_wait() while the callback is still posted.
Cancel a signal wait
async_wait() accepts a cancel_token.
cancel_source source;
int signal = co_await ctx.signals().async_wait(
source.token()
);Cancellation can be requested elsewhere:
source.request_cancel();If cancellation wins before a signal is delivered, the active waiter is removed and resumed through the scheduler with:
errc::canceledas a std::system_error.
Handle cancellation
For example:
task<void> wait(
io_context& ctx,
cancel_token token)
{
try
{
int signal = co_await ctx.signals().async_wait(
token
);
vix::print("signal:", signal);
}
catch (const std::system_error& error)
{
if (error.code() == cancelled_ec())
{
vix::print("signal wait canceled");
co_return;
}
throw;
}
}Cancellation affects the pending wait.
It does not stop the complete signal service.
Cancellation before waiting
If the token is already cancelled:
cancel_source source;
source.request_cancel();
int signal = co_await ctx.signals().async_wait(
source.token()
);the operation reports cancellation immediately.
No active waiter is installed.
This avoids beginning a wait that the application already knows it no longer needs.
Signal arrival and cancellation race
A signal and a cancellation request can occur close together.
The wait completes once.
Whichever outcome successfully completes the active waiter determines what the coroutine observes:
signal wins
↓
async_wait returns signal number
cancellation wins
↓
async_wait throws errc::canceledThe waiter state is protected so both paths cannot resume the same coroutine independently.
Stop the signal service
Use:
ctx.signals().stop();to stop signal watching.
stop() is idempotent.
Calling it again after the service has already stopped has no additional effect.
The stop request also wakes an active waiter.
Waiting during stop
If a coroutine is inside:
co_await ctx.signals().async_wait();when the service stops, it does not remain suspended indefinitely.
The flow is:
async_wait pending
↓
signal_set::stop()
↓
active waiter completed
↓
continuation posted to scheduler
↓
coroutine resumes
↓
errc::stoppedThe error is delivered as std::system_error.
Handle signal-service stop
For example:
task<void> wait(io_context& ctx)
{
try
{
int signal = co_await ctx.signals().async_wait();
vix::print("signal:", signal);
}
catch (const std::system_error& error)
{
if (error.code() == make_error_code(errc::stopped))
{
vix::print("signal service stopped");
co_return;
}
throw;
}
}Stopping the service and cancelling one wait are different events.
Cancellation and stop are different
Application cancellation reports:
errc::canceledSignal-service shutdown reports:
errc::stoppedThe distinction is:
request_cancel()
↓
cancel this wait
signal_set::stop()
↓
stop the signal serviceThis allows application code to distinguish an operation-level cancellation from runtime teardown.
Stop is permanent
Once a signal_set has been stopped, it does not restart.
A later:
co_await ctx.signals().async_wait();reports:
errc::stoppedrather than creating another worker.
If the complete io_context has already been shut down, attempting to access ctx.signals() again is rejected by the context itself.
Runtime shutdown
The signal service participates in io_context::shutdown().
A signal waiter may still be suspended when shutdown begins:
signal wait pending
↓
io_context shutdown
↓
signal service destroyed
↓
signal_set stops
↓
waiter receives errc::stopped
↓
continuation posted to scheduler
↓
scheduler drains remaining workThe context shuts down services while the scheduler can still accept their final completion posts.
This prevents an active signal wait from blocking runtime destruction.
Worker lifetime
The signal worker starts lazily on first asynchronous use.
The signal_set owns that thread for the rest of its service lifetime.
When the service is destroyed:
signal_set destruction
↓
stop requested
↓
blocking signal wait woken
↓
worker exits
↓
worker joinedApplication code does not need to manage this worker directly.
The public execution boundary remains the signal_set and the Vix scheduler.
Platform support
The asynchronous signal wait is currently implemented for Unix-like platforms:
Linux
macOS
other supported Unix environmentsOn unsupported platforms, async_wait() reports:
errc::not_supportedthrough std::system_error.
Signal delivery itself also follows the operating system's signal and thread-mask rules.
The Async API does not replace those platform semantics.
POSIX signal masks matter
On Unix-like systems, signal_set::add() blocks the registered signal in the calling thread so the signal worker can wait for it with the POSIX signal mechanism.
Applications that create their own threads or manipulate signal masks directly should keep this interaction in mind.
A simple Async application should register its signals early, before creating unrelated thread-level signal policies.
The public Vix model remains:
register signals
↓
async_wait()
↓
signal worker receives
↓
scheduler resumes coroutineA typical shutdown pattern
Process signals are commonly used to begin application shutdown.
#include <csignal>
#include <system_error>
#include <vix/async.hpp>
#include <vix/print.hpp>
using namespace vix::async::core;
task<void> wait_for_shutdown(io_context& ctx)
{
auto& signals = ctx.signals();
signals.add(SIGINT);
signals.add(SIGTERM);
try
{
int signal = co_await signals.async_wait();
vix::print("shutdown signal:", signal);
ctx.stop();
}
catch (const std::system_error& error)
{
if (error.code() == make_error_code(errc::stopped))
{
co_return;
}
throw;
}
}
int main()
{
io_context ctx;
std::move(wait_for_shutdown(ctx)).start(
ctx.get_scheduler()
);
ctx.run();
return 0;
}This gives one coroutine ownership of process shutdown signals and keeps the resulting control flow inside the Async scheduler.
API overview
The signal service exposes:
| API | Purpose |
|---|---|
signal_set(io_context&) | Create a signal watcher bound to a context. |
add(int) | Add a positive signal number to the watched set. |
remove(int) | Remove a signal number from the watched set. |
async_wait(token) | Await the next registered signal. |
on_signal(fn) | Observe received signals through a scheduler callback. |
stop() | Stop signal watching and wake the active waiter. |
async_wait() returns:
task<int>and can report:
errc::canceled
errc::stopped
errc::not_ready
errc::not_supporteddepending on cancellation, service state, concurrent waiters, and platform support.
add() can report:
errc::invalid_argumentfor a non-positive signal number.
Next step
Continue with Lifecycle and Shutdown to see how the scheduler, timers, CPU pool, signal service, and networking backend are brought down together.
Then read: