Clean up unresponsive processes occupying the dev server port on boot

cd84a25efbd7 · AtlantisPleb · · parent 223d423a248f

Clean up unresponsive processes occupying the dev server port on boot

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/coder-dev-server.ts
  • modified packages/openagents-cli/test/coder-dev-server.test.ts

Diff

2 files changed, +98 -20

packages/openagents-cli/src/coder-dev-server.ts modified +87 -20

@@ -1,4 +1,4 @@

1
import { spawn } from "node:child_process";
1
import { execFileSync, spawn } from "node:child_process";
2 2
import { existsSync, openSync, readFileSync } from "node:fs";
3 3
import { homedir, tmpdir } from "node:os";
4 4
import { dirname, join, resolve } from "node:path";

@@ -31,28 +31,30 @@ export const findSiteCheckout = (

31 31
  from: string = process.cwd(),
32 32
  env: NodeJS.ProcessEnv = process.env,
33 33
): string | undefined => {
34
  const named = env["OPENAGENTS_COM_PATH"];
35
  if (named !== undefined && isSiteCheckout(named)) return resolve(named);
34
  const named = env.OPENAGENTS_COM_PATH;
35
  if (named !== undefined && siteAppRoot(named)) return named;
36 36
37
  for (let path = resolve(from); ; path = dirname(path)) {
38
    if (isSiteCheckout(path)) return path;
39
    if (dirname(path) === path) break;
37
  let at = resolve(from);
38
  for (;;) {
39
    if (siteAppRoot(at)) return at;
40
    const up = dirname(at);
41
    if (up === at) break;
42
    at = up;
40 43
  }
41 44
42
  const beside = join(homedir(), "work", "openagents.com");
43
  return isSiteCheckout(beside) ? beside : undefined;
45
  // A conventional checkout in the home workspace, when the session was
46
  // started outside both repositories.
47
  const common = join(homedir(), "work", "openagents.com");
48
  return siteAppRoot(common) ? common : undefined;
44 49
};
45 50
46
/**
47
 * Whether this directory is the Phoenix application rather than some other Mix
48
 * project. Read from `mix.exs`, because a directory named `openagents.com` that
49
 * is not the app would fail later and less clearly.
50
 */
51
const isSiteCheckout = (path: string): boolean => {
52
  const manifest = join(path, "mix.exs");
53
  if (!existsSync(manifest)) return false;
51
/** True when a directory is the root of the `openagents.com` Phoenix application. */
52
const siteAppRoot = (path: string): boolean => {
53
  const mix = join(path, "mix.exs");
54
  if (!existsSync(mix)) return false;
54 55
  try {
55
    return /app:\s*:openagents\b/.test(readFileSync(manifest, "utf8"));
56
    const contents = readFileSync(mix, "utf8");
57
    return contents.includes("app: :openagents") && existsSync(join(path, "lib", "openagents_web"));
56 58
  } catch {
57 59
    return false;
58 60
  }

@@ -80,6 +82,60 @@ const health = async (origin: string, timeoutMs = 1_500): Promise<Health> => {

80 82
export const devServerReady = async (origin: string): Promise<boolean> =>
81 83
  (await health(origin)) === "ok";
82 84
85
/** Extract the port number from a target origin URL, default 4000 for standard local dev. */
86
export const originPort = (origin: string): number | undefined => {
87
  try {
88
    const url = new URL(origin);
89
    if (url.port) return Number.parseInt(url.port, 10);
90
    if (url.protocol === "http:") return 80;
91
    if (url.protocol === "https:") return 443;
92
    return undefined;
93
  } catch {
94
    return undefined;
95
  }
96
};
97
98
/** Find all PIDs listening on a given TCP port. */
99
export const listeningPids = (port: number): number[] => {
100
  try {
101
    const stdout = execFileSync("lsof", ["-ti", `tcp:${port}`], {
102
      encoding: "utf8",
103
      stdio: ["ignore", "pipe", "ignore"],
104
    });
105
    return stdout
106
      .split("\n")
107
      .map((line) => line.trim())
108
      .filter((line) => line.length > 0)
109
      .map((pid) => Number.parseInt(pid, 10))
110
      .filter((pid) => !Number.isNaN(pid) && pid !== process.pid);
111
  } catch {
112
    return [];
113
  }
114
};
115
116
/** Kill processes that occupy a port when they are unresponsive to health checks. */
117
export const killPortOccupants = (port: number): number[] => {
118
  const pids = listeningPids(port);
119
  for (const pid of pids) {
120
    try {
121
      process.kill(pid, "SIGTERM");
122
    } catch {
123
      // Process may already be dead or owned by another user.
124
    }
125
  }
126
  // Brief pause before SIGKILL for stubborn hung processes
127
  if (pids.length > 0) {
128
    for (const pid of pids) {
129
      try {
130
        process.kill(pid, "SIGKILL");
131
      } catch {
132
        // Ignored
133
      }
134
    }
135
  }
136
  return pids;
137
};
138
83 139
const run = (command: string, args: readonly string[], cwd: string, log: number) =>
84 140
  new Promise<number>((settle) => {
85 141
    const child = spawn(command, args, {

@@ -101,9 +157,10 @@ export interface DevServerStart {

101 157
 * Bring a development server up at this origin, or report why not.
102 158
 *
103 159
 * Already serving is success and starts nothing. Otherwise it starts one in the
104
 * checkout it finds, migrates when the server says its database is behind, and
105
 * waits for `/healthz` to answer — a first boot compiles, so the wait is long
106
 * and says so rather than looking like a hang.
160
 * checkout it finds, cleans up any unresponsive process occupying the port,
161
 * migrates when the server says its database is behind, and waits for `/healthz`
162
 * to answer — a first boot compiles, so the wait is long and says so rather
163
 * than looking like a hang.
107 164
 */
108 165
export const startDevServer = async (
109 166
  origin: string,

@@ -135,6 +192,16 @@ export const startDevServer = async (

135 192
  const logPath = devServerLog();
136 193
  const log = openSync(logPath, "a");
137 194
195
  // If health check failed ("down"), check if an unresponsive process is already
196
  // squatting on the target port and holding locks or preventing binding.
197
  const port = originPort(origin);
198
  if (port !== undefined) {
199
    const killed = killPortOccupants(port);
200
    if (killed.length > 0) {
201
      notice(`Terminated unresponsive process (${killed.join(", ")}) on port ${port}.`);
202
    }
203
  }
204
138 205
  // A server that is up but refusing every request because its database is
139 206
  // behind is one migration away from working, and running it is what a person
140 207
  // would do next anyway.
packages/openagents-cli/test/coder-dev-server.test.ts modified +11

@@ -97,3 +97,14 @@ describe("starting one", () => {

97 97
    expect(said).toEqual([]);
98 98
  });
99 99
});
100
101
describe("port occupant detection and cleanup", () => {
102
  it("resolves the port correctly from origin urls", async () => {
103
    const { originPort } = await import("../src/coder-dev-server.js");
104
    expect(originPort("http://localhost:4000")).toBe(4000);
105
    expect(originPort("http://127.0.0.1:8080/")).toBe(8080);
106
    expect(originPort("http://localhost")).toBe(80);
107
    expect(originPort("https://localhost")).toBe(443);
108
    expect(originPort("invalid-url")).toBeUndefined();
109
  });
110
});

This page updates live while a promote is in flight · changelog