Skip to content
v0.7.1: no process left behind

One fix in v0.7.1, and it deserves a post of its own.

v0.7.0 made the dev server restart on its own whenever a config or env file is saved. So whatever a restart leaves behind, it now leaves behind more often.

And a few things could stay behind: an API program still running after a reload, a preview runner still holding the port, a dev server still alive after Ctrl-C.

You may have seen it as EADDRINUSE on a port nothing should hold, a database running out of connections, a sidecar job running twice, or a preview serving a build from before your last save.

A reload that kept the old API running ​

On every save the dev server reloads the API in place: it tears the running program down, then evaluates api/dev.ts anew. Three things could leave the old program running next to the new one.

Vite reloaded it too. Vite's module runner has an HMR client of its own, and when told to do a full reload, it evaluated the entry again by itself, next to the program already running. That made a second database pool and a second set of sockets, with nothing to close them. The runner has no HMR client now, so the dev server's own reload is the only thing that replaces a program.

Two reloads at once. One save fires several file events, and each event started a reload. Two reloads running side by side both tore down the same program, then both loaded a new one, and only one of them was kept. Reloads now run one at a time, and saves landing during a reload fold into a single reload after it.

Half a program. A save that could not compile was only found out while loading, and by then the old program was already torn down. Worse, the modules ahead of the broken one had been evaluated, so whatever they open at the top level was open, with no teardownHandler to close it. Now the saved program is checked first: every module transformed, none evaluated. A broken save is reported, and the running API stays up until the save that fixes it.

So a reload always goes the same way: check, tear down, evaluate. There is never more than one API program alive, and a port the old one held is free by the time the new one asks for it.

Between the teardown and the new program there is nothing to answer, so for that moment the dev server responds with 503 and Retry-After: 1.

The same reload also ran too early. It started straight from the file watcher, before Vite had invalidated the changed modules, so the program could come back up running the source from before the save. It now starts after the invalidation.

Sidecars now reload the same way, one at a time and with no HMR client, for consistency.

teardownHandler on stop, too ​

teardownHandler ran before each reload, but not when the dev server stopped, and since v0.7.0 the dev server stops on every config save.

It now runs once for every program that was loaded: before each reload, and on stop.

The next program is evaluated only after the hook settles, so await whatever you close:

api/dev.ts
ts
export default devSetup({
  requestHandler() {
    // ...
  },

  async teardownHandler() {
    await pool.end();
  },
});

A hook still pending after 3 seconds is reported, and no longer waited for.

Preview: a runner on the port ​

kosmo preview builds, then starts dist/run.js as a child process, and waits until it is up before serving.

It used to tell by connecting to the port. But a connection cannot say whose listener answered. With an old runner still on the port, the new one died on EADDRINUSE, the probe connected to the old one just fine, and preview went on serving whatever the old one served.

Now dist/run.js reports that it is listening itself, over the IPC channel preview spawns it with. Under a plain node dist/run.js there is no channel, and nothing is sent.

Two more ways a runner could outlive preview are closed as well:

  • a runner whose app code handles SIGTERM and never exits now gets SIGKILL after 2 seconds
  • a shutdown that arrives while a build is still running no longer lets that build start a runner

Ctrl-C, twice ​

Ctrl-C reaches the whole process group: the CLI supervisor and the worker that runs the dev server. The worker tears down, and the supervisor waits for it, with SIGKILL after 5 seconds.

But a second Ctrl-C ended the supervisor at once, and a kill -9 ended it without a word. Either way the worker ran on, an orphan holding the port.

Now the worker holds an IPC channel to the supervisor that carries no messages. It is there to close: when it does, the worker knows the supervisor is gone, whichever way it went, and stops.

The worker also gives its own teardown 4 seconds, then exits by itself, before a SIGKILL could skip its exit hooks.

Upgrading ​

Nothing to change.

If your teardownHandler closes things without awaiting them, it is worth adding the await. The next program now waits for the hook to settle, and a close still pending when the old program goes is exactly what this release is about.

Released under the MIT License.