Gracefully close sub-subprocess using signals in Deno

Cover image featuring the Deno logo on a shiny techy background and the article title Deno logo by kevinkassimo, techy background by Krzysztof Kowalik found in Macro Tech

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 awaiting 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

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)

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 () => {
  // if (isLocalDev) console.clear();
  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 killing 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

Stay up to date with my progress

CanRau.com Roadmap

FIXME's (11 😨)
  • fix .prose WindiTypography styling

    www/ssr/nano-roadmap.tsx
  • other way than allowDangerousHtml to allow comments in micromark? Obsolete once I get MDX working

    www/ssr/lib/markdown/mod.ts
  • might be able to import types lazily as well right?

    www/ssr/lib/leasot_deno/leasot_deno.ts
  • Reconsider API before publishing article or at least the module!

    www/ssr/lib/leasot_deno/leasot_deno.ts
  • only import parsers actually in use according to associateParser, or at least provide a way of disabling parsers not in associateParser

    www/ssr/lib/leasot_deno/leasot_deno.ts
  • this is a test case, so nothing to fix πŸ₯°

    www/ssr/lib/leasot_deno/leasot_deno_test.ts
  • add trailing slash or not?

    www/ssr/server.tsx
  • fix .prose WindiTypography styling

    www/ssr/server.tsx
  • static middleware not working?, though only needed locally as fly.io handles static files automatically

    www/ssr/middlewares/static-middleware.ts
  • outsource generateRoadmap into it's own module, then open-source!

    commands/roadmap.ts
  • make <title> overridable from within a component

    modules/nanojsx/components/helmet.ts
TODO's (40 😱)
  • deno.jsonc
  • Make age a dynamic component

    www/content/en/welcome.md
  • Add link to actual generate-roadmap code once open-sourced

    www/content/en/leasot-deno.md
  • Add ids to md headlines

    www/content/en/index.md
  • or switch to unknown?

    www/components/components.tsx
  • www/components/newsletter-signup/NewsletterSignup.tsx
  • eat more fruit 🍌`,

    www/ssr/lib/leasot_deno/leasot_deno_test.ts
  • Make leasot file exclusion less error prone

    www/ssr/lib/generate-roadmap.ts
  • tokenize sub elements (bold, underline, strikethrough, italics, code)

    www/ssr/lib/mdx_tokenizer.ts
  • tokenize sub elements (bold, underline, strikethrough, italics, code)

    www/ssr/lib/mdx_tokenizer.ts
  • tokenize sub elements (bold, underline, strikethrough, italics, code)

    www/ssr/lib/mdx_tokenizer.ts
  • Add RSS feed with PrettyFeed example

    www/ssr/server.tsx
  • before open sourcing set the fly secret DB_DOMAIN to the current app info's Hostname

    www/ssr/server.tsx
  • TLS in dev mkcert, usage, vite-plugin, npm

    www/ssr/server.tsx
  • www/ssr/server.tsx
  • move Roadmap to it's own filterable page 😍

    www/ssr/server.tsx
  • cache busting, hashing, eTag?

    www/ssr/server.tsx
  • Favicons & Webmanifest

    www/ssr/server.tsx
  • www/ssr/server.tsx
  • Outsource the page creation to throw here and handle anything else in errorMiddleware?

    www/ssr/server.tsx
  • activate client scripts when esbuild or sthg?

    www/ssr/server.tsx
  • Put pageviews middleware in its own file

    www/ssr/server.tsx
  • implement something like Netlify's _redirects.yml, check out KCD's

    www/ssr/server.tsx
  • www/ssr/middlewares/spa-middleware.ts
  • wrap emojis to make them bigger?

    www/lib/render-dynamic-components.ts
  • fix Type Guard?

    www/lib/render-dynamic-components.ts
  • Maybe I add a sourcemap?

    www/lib/generate-styles.ts
  • Or disable it for development and curious devs to learn if some query parameter is provided?

    www/lib/generate-styles.ts
  • www/client/client.tsx
  • add bundle stage like in denoland/deno_docker#5 awaiting #12086

    Dockerfile
  • maybe use dotenv for domain?

    www.config.ts
  • add cli tests like in deployctl

    cli.ts
  • auto-completion using omelette

    cli.ts
  • add TLS to dev-server

    cli.ts
  • Maybe add release command which updates VERSION, git commits and deploys?

    cli.ts
  • destroy fly-builder app after successful deploy

    cli.ts
  • add release command to bump version, commit & git tag --annotate

    cli.ts
  • Show only for longer running processes and not for e.g. version

    cli.ts
  • get cmds working!!! probably better splitting deploy and dev into their own files already!

    cli.ts
  • Maybe defining the file instead of cmd which will be async imported and deno.runed?

    cli.ts
DONE's (6 😌)
  • how to markup keyboard shortcut in md?

    www/content/en/gracefully-close-sub-subprocess-using-signals-in-deno.md
  • add link to Promise.any docs

    www/content/en/gracefully-close-sub-subprocess-using-signals-in-deno.md
  • Add syntax highlighting

    www/content/en/leasot-deno.md
  • WINDICONFIG?

    www/lib/generate-styles.ts
  • figure out dev.to profile url

    www.config.ts
  • shutdown not freeing http.listen addr! 😒

    cli.ts
NOTE's (25 😱)
  • wanted to switch to unified for easier rehype integration but can't get it working so far because of type issues

    www/ssr/lib/markdown/mod.ts
  • not using import-map here to be able to resue in CLI, going to have to fix more things and probably relocate this file then, maybe crux.land?

    www/ssr/lib/generate-roadmap.ts
  • switched from markdown_wasm (failed on things like <title> in todo comments) to micromark

    www/ssr/lib/generate-roadmap.ts
  • www/ssr/lib/generate-roadmap.ts
  • list of supported languages by leasot

    www/ssr/lib/generate-roadmap.ts
  • or use a ready markdown file so no need for nano-app.tsx

    www/ssr/lib/generate-roadmap.ts
  • www/ssr/server.tsx
  • www/ssr/server.tsx
  • asterism from ctrl.blog

    www/ssr/server.tsx
  • had to set ENV via

    Dockerfile
  • ARGs in multi-stage

    Dockerfile
  • had to set ENV

    Dockerfile
  • importing PluginUtils type from WindiCss doesn't work (yet)

    windi.config.ts
  • just to make sure Windi won't unnecessarily try to extract

    windi.config.ts
  • WindiTypography needs the .prose class and optionally the .dark class on body along prose-dark

    windi.config.ts
  • --build-arg via

    commands/deploy.ts
  • import-maps are cool but can easily bring more in than expected which can lead to strange errors πŸ₯²

    commands/roadmap.ts
  • that MDXLayout is special as it’s taken from

    modules/xdm-deno/plugin/recma-jsx-rewrite.js
  • CLI inspirations

    cli.ts
  • Setting flags as boolean allows them to be used even between commands πŸ₯³

    cli.ts
  • interesting Docker API for Deno - denocker

    cli.ts
  • send SIGQUIT to the subprocess which'll get picked up by the server to .abort() it, to free up the address it was listening on

    cli.ts
  • SIGKILL not supported in Rust #12515

    cli.ts
  • send SIGKILL to the subprocess to stop the Deno --watcher

    cli.ts
  • p.close to end the Deno.run mainprocess to free up memory

    cli.ts

Stay up to date with my progress

CanRau.com
Can Rau

Designed and developed by

Can Rau

Pageviews so far:
4785
v0.7.4 | #046a905 (to be open-sourced) | Deployed as empty-wave-3298 (v111) on Fly.io
Β© Copyright Can Rau | moc.liamg@uarsnac