Most teams spend their testing budget on the first second of a process and almost none on the last one. That asymmetry is expensive, because a worker that starts cleanly but gets killed mid-write can leave a half-applied batch, duplicated side effects, or a queue item that looks in-flight forever. The plan below is about measuring the shutdown path you already have: which signal arrives, how long the platform waits, and what your process leaves behind when the window closes early.
To be explicit about the evidence: everything here is an illustrative testing procedure, not a report of an experiment. Any number you end up with comes from your own platform and your own workload, so treat the values below as placeholders you must replace.
What actually happens when a platform stops your process
Shutdown usually has two phases: a polite request (SIGTERM), then an unblockable one (SIGKILL) after some interval. The interval is a platform policy, and it is rarely the value people assume it is. The signal(7) man page documents the underlying semantics if you want the kernel-level detail.
-
SIGTERMhas a default action of terminating the process, so installing a handler turns it into a request you can act on. -
SIGKILLcannot be caught or delayed, so anything not durable before it arrives is simply lost. - When your process runs as PID 1 inside a container, signals with default dispositions may not terminate it at all, so verify that for your runtime instead of assuming.
- In bash, an exit status of
143usually means the default action fired (128 + 15), which is a hint that your handler never ran.
Three timestamps are worth capturing on every run: when the handler observed the signal, when the last durable write was acknowledged, and when the process actually exited. With those three numbers you can reason about the budget instead of guessing at it.
The artifact: a small shutdown-budget harness
Prerequisites are modest: Python 3, a POSIX shell, and a worker whose side effects are keyed by an item ID. The code below is illustrative and unexecuted; replace the queue API with your own and keep the shape.
# worker.py — illustrative, not run. Adapt before use.
import json, os, signal, time
BUDGET_S = float(os.environ.get("SHUTDOWN_BUDGET_S", "8")) # platform policy: measure it
MARGIN_S = float(os.environ.get("SHUTDOWN_MARGIN_S", "2")) # room for ack + flush
stop_requested = False
t_signal = None
def on_term(signum, frame):
# Keep the handler boring: set flags only, log in the main loop.
global stop_requested, t_signal
stop_requested = True
t_signal = time.monotonic()
signal.signal(signal.SIGTERM, on_term)
def emit(event, **fields):
print(json.dumps({"mono": time.monotonic(), "event": event, **fields}), flush=True)
def deadline():
return t_signal + max(0.0, BUDGET_S - MARGIN_S)
def run(queue, process, ack):
while not stop_requested:
item = queue.take()
ack(item["id"], process(item)) # process() must be idempotent per item id
emit("signal_seen", budget_s=BUDGET_S)
while queue.pending() and time.monotonic() < deadline():
item = queue.take()
ack(item["id"], process(item))
leftover = queue.pending()
emit("exit", leftover=len(leftover), drained=not leftover)
One honest caveat: print() inside a signal handler is not async-signal-safe, which is why the handler above only flips flags. If you need logging from inside the handler, write to a pre-opened pipe or defer the log line to the loop.
Measuring locally before you trust a platform
#!/usr/bin/env bash
# illustrative: SIGTERM a worker, then record exit latency and status
set -u
python3 worker.py > worker.log 2>&1 &
pid=$!
sleep 2
start=$(date +%s%N)
kill -TERM "$pid"
wait "$pid"; code=$?
end=$(date +%s%N)
awk -v s="$start" -v e="$end" -v c="$code" \
'BEGIN { printf "exit_code=%s elapsed_ms=%.0f\n", c, (e-s)/1000000 }'
tail -n 5 worker.log
Run it twice: once with the handler installed, once with the handler removed as a control. The difference between the two runs is the only proof that your handler is actually wired to the process the supervisor is signalling.
The test matrix you can run in one sitting
| Scenario | What to try | What to observe | What to verify |
|---|---|---|---|
| Graceful path |
SIGTERM mid-batch |
signal_seen, acks after signal |
no new batch starts after the signal |
| Control |
SIGTERM with no handler |
exit status, last write | default action really fires |
| Hard stop |
SIGKILL mid-batch |
durable store + queue state | restart path recovers cleanly |
| Oversized unit | one item longer than the budget |
leftover count |
your margin is realistic |
| Restart loop | start, kill, restart, repeat | duplicate or missing keys | idempotency keys hold |
Add one row for the failure you actually fear, because the matrix exists to surface it before production does.
Turning the numbers into a policy
- If elapsed time is far below your assumed budget and the last ack is missing, you are being hard-killed; split work into smaller durable units.
- If elapsed time exceeds the assumed budget and the process still exits cleanly, the platform is more generous than you thought, but do not build on that.
- If
leftoveris consistently non-zero, shrink the batch or checkpoint mid-batch, and never acknowledge before the write is durable.
Notice that every one of these decisions reduces the blast radius of a kill rather than trying to prevent it, which is the only approach that survives an unblockable signal.
Where free model access and a free server genuinely fit
Two operator-supplied conveniences are relevant here. MonkeyCode offers free model access and a free server option, and both map onto this workflow without pretending to replace your platform's job.
- Use the free model access to enumerate failure modes for the drain loop, and to normalise your JSON log lines into a table you can paste into a review.
- Review anything the model produces with the async-signal-safety caveat above in hand, because generated handlers often log or allocate inside the handler.
- Use the free server option as one observation point for how a stop request reaches a process you do not control; it is a single data point, not a guarantee.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Limitations worth stating plainly: a free tier may sleep, may stop instances on a schedule, and may not let you control how a stop request is delivered. Do not extrapolate one observation into a production grace-period guarantee, and keep your budget honest by treating the platform value as measurable input rather than a constant.
Who should skip this plan
Skip it if your work is already acknowledged by a managed queue with visibility semantics, because the queue owns the redelivery contract and your shutdown path matters far less. Skip it if you cannot shell into the environment to send signals at all. Skip it too if your side effects are not idempotent yet, because measuring a drain loop over non-idempotent work only tells you how fast you produce duplicates.
If you already have a shutdown log from one of these runs, the three timestamps are more useful than another anecdote, so paste them into the comments and say which platform produced them.
Top comments (0)