The Missing Bit

Ensure no zombie process when esbuild is started from elixir

2024-11-12

I use elixir and the Phoenix Framework in many of my projects along with esbuild. While Phoenix includes an esbuild Elixir package that provides a streamlined "out of the box" experience.

But if you want full build customization, you need to write your own build file.

When you have your own build file, you need something like this in your dev.exs config file:

    node: ["build.mjs", "--watch", cd: Path.expand("../assets", __DIR__)],

This spawns a node process with the build.mjs build file.

A common issue occurs when the Elixir development server is terminated with Ctrl-C: ESBuild processes may remain as zombies. This appears to be related to how BEAM (Erlang VM) handles subprocess management, specifically regarding stdin closure for child processes.

After trying various approaches to properly terminate the ESBuild process, I found a reliable solution: periodically check if the parent process is alive by sending a SIGNULL (signal 0) to it. If the parent doesn't respond, we terminate the ESBuild process.

Here's a working build.mjs implementation that includes this cleanup mechanism:

import esbuild from 'esbuild';

const args = process.argv.slice(2);
const watch = args.includes('--watch');
const deploy = args.includes('--deploy');


const loader = {
};

const plugins = [
];

// Define esbuild options
let opts = {
    nodePaths: ["../deps"],
    entryPoints: ["js/app.js"],
    bundle: true,
    logLevel: "info",
    target: "es2017",
    outdir: "../priv/static/assets",
    external: ["*.css", "fonts/*", "images/*"],
    loader: loader,
    plugins: plugins,
    jsxFactory: 'h', // preact
    jsxFragment: 'Fragment',
};

if (deploy) {
    opts = {
        ...opts,
        minify: true,
    };
}

if (watch) {
    opts = {
        ...opts,
        sourcemap: "inline",
    };
    const ctx = await esbuild.context(opts);

    const cleanup = async () => {
        await ctx.dispose();
        process.exit(0);
    };

    // 1 The usual solution is this, but it does not work reliably on my setup
    process.stdin.resume();
    process.stdin.on("close", () => cleanup());

    // 2 The parent signal solution using an interval
    const check_parent = () => {
        try {
            // Try to send 0 signal to parent process to check if it exists
            process.kill(process.ppid, 0);
        } catch (e) {
            cleanup().catch(console.error);
        }
    };

    setInterval(check_parent, 1000);

    await ctx.watch();
} else {
    esbuild.build(opts);
}

Try 1 and if it works, great, if it does not, try 2.

This solution ensures clean process termination when the Phoenix development server stops, preventing zombie processes.

If you wish to comment or discuss this post, just mention me on Bluesky or email me.