Gracefully close sub-subprocess using signals in Deno
Deno logo by kevinkassimo, techy background by Krzysztof Kowalik found in Macro Tech
Table of contents
- Preface
- How I got there
- The actual problem
- Sub-subprocesses
- Terminal signals
- How to stop the Oak server sub-subprocess?
- More signals to the rescue
- The future
Preface
I've got a custom and very simple CLI to help me execute various tasks, mostly, or for now, in development, as there is no package.json
in Deno and I didn't want to use Make
or something, but use this oportunity to familiarize myself more with Deno's syntax and features.
One command of this CLI executes Deno run
for me with all the required flags one of which is --watch
to restart the server when I hit save, as I don't have any hot-reload (yet).
Everything works/worked as expected until I started to add a little pomodoro like feature so I could, or actually have to, provide a time duration like 25m
, before the actual command, to automatically terminate the running task and tell me in the terminal that time is up and I should keep the promise I made to my partner that I'll be available in 25m 😅
How I got there
So before this, I would, whenever I was ready, focus the console, hit Ctrl + C to quit the session and everything was fine.
Though stopping the thing from the script itself was a little more involved than earlier expected. Okay enough theorizing lets add some code examples, shall we? So the interesting part (for now) is the following
const p = Deno.run({
cmd,
env: {
DENO_DIR: "./deno_dir",
},
});
cmd
is the command to be run which'll be picked earlier based on what I enter in the command line.
p
is a reference to the running process which provides a handy way of await
ing the process asynchronously, so it runs infinitely until canceled and also a close()
method to programmatically end the process.
So before my time management helper I would just
await p.status();
which is the nifty helper to keep the process running and called it a day. 🥳
Introducing the pomodoro "counter" I changed that line to
await Promise.any([
p.status() as unknown as Promise<void>,
commands[command].pomodoro && time !== "x"
? timeout(pomodoroUp, timeInMs)
: null,
]);
😳
Okay so I use Promise.any
to await
more than one Promise
by giving it an array of promises.
The first is the same as before, only that I had to tell Typescript that it should accept it and to change its type I had to first tell TS it's unknown
so I could then type it as Promise<void>
which Promise.any
would accept.
The second "thing" is ternary
condition to check if the command to run is allowed to terminate after a timeout, as I also have tasks to deploy this website which should run until finished. So every command has a pomodoro
property set to true
, false
or not set at all. If it's set and the provided time duration isn't equal to x
, which is my prosiblity to bypass the timeout, if I please, then it'll add the timeout Promise
to the array, otherwise it just adds null
which is valid but won't do anything 😎
timeout
is a simple promisified setTimeout
I've found on levelup.gitconnected.com. Yes it's simple, but sometimes it's just easier not to spend any time coming up with something I know I can find ready made in a matter of seconds.
const timeout = async (func: () => void, ms: number) => {
await new Promise((resolve) => setTimeout(resolve, ms));
func();
};
The pomodoroUp
function I provide to timeout
will then be called after timeInMs
, which I calculate earlier based on the provided string
in the terminal like 13m
or 2h
.
function pomodoroUp() {
shutDown(`Time Up — Take a Break! (${time})`);
}
As you can see pomodoroUp
just calls shutDown
with a reason, as I use it in different scenarios with differing reasons.
The actual problem
function shutDown(reason?: string | void) {
p.close();
sigInt.dispose();
console.log(reason ?? red(bold("Gracefully shutting down")));
Deno.exit();
}
As mentioned earlier, Deno.run
returns a couple of properties one of which is .close()
so I figured "perfect!", let's call it and listo!
Well, it stops the subprocess and Deno.exit()
stops the CLI process, but trying to run ./cli x dev
again would error telling me the port is already in use 😨
But I'm .close()
ing the process??
Sub-subprocesses
Yes you read that right, sub-subprocess. The CLI itself is a process, which I can terminate when needed using Deno.exit()
. Deno.run
starts a subprocess, which is easily stopped using p.close()
. But the server I'm starting from within this subprocess, is yet another process, which Deno.run
is not aware of, how could it 🤷🏻♂️
Terminal signals
Be aware, that the signal API in Deno is still
--unstable
I was actually already using a terminal signal within my CLI, that's where the sigInt.dispose();
comes from, I'm listening earlier to the SIGINT
signal like so
const sigInt = Deno.signal("SIGINT");
sigInt.then(shutDown);
I'm not exactly sure when or why I started using it, as hitting Ctrl + C which sends a SIGINT
signal to the running process was already working, so normally you wouldn't need to listen to SIGINT
in your CLI to be able to close the process via Ctrl + C, Deno does that for you.
Though, be aware that when you listen to
SIGINT
like in the last code snippet, you're actually "overriding", it. So you could prevent people from stopping your CLI or do other things, in my case it didn't help at all 🥲
How to stop the Oak server sub-subprocess?
Researching a little more I stumbled upon a hint to the Oak docs on how to close the server.
Now I only needed a way of telling the server to abort
from within the CLI.
I could rewrite the whole logic and actually import the server from the CLI and call startServer()
, though I'd loose the easy --watch
helper.
I could also add a, maybe authenticated, route which I could call using fetch()
from the CLI 🤡
More signals to the rescue
Luckily, before diving into all those complexities, I realized my server could also listen to signals 🤯😍
Knowing now that overriding SIGINT
can bring problems I figured I should probably use another signal. Deno's types lets me autocomplete all the possible signals Deno.signal()
accepts, the only other I recognized and associated with ending something was SIGKILL
😬 haha
So then I modified my server to look like this (the relevant part)
Update: Since Deno 1.16 you have to use
Deno.addSignalListener
Since Deno 1.16
const controller = new AbortController();
Deno.addSignalListener("SIGQUIT", () => controller.abort); // abort the controller when receiving the signal
const { signal } = controller;
export const server = async () => {
await app.listen({ port, hostname, signal });
};
Before Deno 1.16
const controller = new AbortController();
const sig = Deno.signal("SIGQUIT");
sig.then(controller.abort); // abort the controller when receiving the signal
const { signal } = controller;
export const server = async () => {
await app.listen({ port, hostname, signal });
};
Which shockingly throw the following kinda cryptic error in my face
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Custom { kind: Other, error: "Refusing to register signal 9" }', runtime/ops/signal.rs:185:67
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
So I investigated and luckily stumbled upon an existing issue before opening a new one which lead me to the fact that Rust doesn't all SIGKILL
among a few others 😩
Now I have to take one of the more complex routes or .. wait, let me look if there's another signal I could use without messing with SIGINT
, and sure enough I found SIGQUIT
which sounds perfectly fitting for what I want. For all I ever wanted was quitting my server and not kill
ing it 😭😄
So I just changed the former code to listen for the friendlier signal like so
const sig = Deno.signal("SIGKILL");
and changed the shutDown
function in my CLI so it would send the according signal to the subprocess
function shutDown(reason?: string | void) {
p.kill("SIGQUIT"); // `.kill()` sends the provided signal to the subprocess
p.close(); // closes the subprocess and frees the memory
console.log(reason ?? red(bold("Gracefully shutting down")));
Deno.exit(); // finally terminates the CLI with everything cleaned up
}
🥳
Note: the
| void
type was added to let me put it straight into.then
const sigInt = Deno.signal("SIGINT");
sigInt.then(shutDown);
Now that I don't need the SIGINT
anymore I can get rid of the void
, which I just noticed thanks to you, writing this article 🥰
I realize now that the CLI API might not be the best cli 7m dev
as every change in time messes with command completion from history, might be able to improve this by providing actual auto completion, using omelette, but I might as well change the API first 🤔
The future
In an upcoming article I'm going to share in more detail how I've build my CLI, so stay tuned if your interested. 😉 Also in general going to share more about Deno, which is in many parts "just" Typescript, and in my opinion a much better Node! And also about Go(lang), as that's the language I use for the pageviews counter you can find at the very end of all pages here on this website and in the future also about non programming topics that interest me 🌴🍍🐒
If you wanna get a notification when I update this article follow me on Twitter or subscribe freely to my newsletter

Doing web-development since around 2000, building my digital garden with a mix of back-to-the-roots-use-the-platform and modern edge-rendered-client-side-magic tech 📻🚀
Living and working in Cusco, Perú 🦙