Skip to content

Commit f23d942

Browse files
committed
fix(cli): stabilize pm2 session handling
1 parent bc5ef99 commit f23d942

2 files changed

Lines changed: 149 additions & 76 deletions

File tree

packages/cli/src/pm2-control.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ describe("pm2-control", () => {
4545
delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
4646

4747
connect.mockImplementation((callback: (error: Error | null) => void) => callback(null));
48-
disconnect.mockImplementation(() => undefined);
48+
disconnect.mockImplementation((callback?: (error: Error | null) => void) => {
49+
callback?.(null);
50+
return undefined;
51+
});
4952
start.mockImplementation(
5053
(_config: unknown, callback: (error: Error | null, apps: unknown[]) => void) => {
5154
writeRuntimeConfig({
@@ -173,6 +176,67 @@ describe("pm2-control", () => {
173176
).resolves.toBe("waiting");
174177

175178
expect(start).not.toHaveBeenCalled();
179+
await pendingStart;
180+
});
181+
182+
it("reuses one pm2 session while polling deletion during startup", async () => {
183+
describeProcess
184+
.mockImplementationOnce(
185+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
186+
callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
187+
)
188+
.mockImplementationOnce(
189+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
190+
callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
191+
)
192+
.mockImplementationOnce(
193+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
194+
callback(null, [])
195+
);
196+
197+
await startManagedServer({
198+
script: "/cli/dist/esm/server-runner.js",
199+
cwd: "/repo",
200+
waitMs: 10,
201+
});
202+
203+
expect(connect).toHaveBeenCalledTimes(1);
204+
expect(disconnect).toHaveBeenCalledTimes(1);
205+
});
206+
207+
it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => {
208+
describeProcess
209+
.mockImplementationOnce(
210+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
211+
callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
212+
)
213+
.mockImplementationOnce(
214+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
215+
callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
216+
)
217+
.mockImplementationOnce(
218+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
219+
callback(null, [])
220+
);
221+
deleteProcess.mockImplementationOnce((_name: string, callback: (error: Error | null) => void) =>
222+
callback(new Error("process or namespace not found"))
223+
);
224+
225+
const pendingStart = startManagedServer({
226+
script: "/cli/dist/esm/server-runner.js",
227+
cwd: "/repo",
228+
waitMs: 10,
229+
});
230+
231+
await expect(
232+
Promise.race([
233+
pendingStart.then(() => "started"),
234+
new Promise((resolve) => setTimeout(() => resolve("waiting"), 20)),
235+
])
236+
).resolves.toBe("waiting");
237+
238+
expect(start).not.toHaveBeenCalled();
239+
await pendingStart;
176240
});
177241

178242
it("ignores delete-time missing errors when requested", async () => {
@@ -185,6 +249,8 @@ describe("pm2-control", () => {
185249
);
186250

187251
await expect(deleteManagedServer({ ignoreMissing: true })).resolves.toBe(false);
252+
expect(connect).toHaveBeenCalledTimes(1);
253+
expect(disconnect).toHaveBeenCalledTimes(1);
188254
});
189255

190256
it("fails background startup when runtime readiness times out", async () => {
@@ -263,6 +329,8 @@ describe("pm2-control", () => {
263329
pm2Pid: null,
264330
restartCount: 0,
265331
});
332+
expect(connect).toHaveBeenCalledTimes(1);
333+
expect(disconnect).toHaveBeenCalledTimes(1);
266334
});
267335

268336
it("maps an online PM2 app to running status", async () => {

packages/cli/src/pm2-control.ts

Lines changed: 80 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const PM2_RESTART_DELAY_MS = 2000;
99
const PM2_MIN_UPTIME = "5s";
1010
const PM2_MAX_RESTARTS = 10;
1111
const PM2_DELETE_WAIT_MS = 5000;
12+
const PM2_DISCONNECT_WAIT_MS = 1000;
1213
const STARTUP_POLL_INTERVAL_MS = 100;
1314
const STARTUP_FAILURE_GUIDANCE =
1415
"Run `coder-studio logs` for details or `coder-studio serve --foreground` for interactive debugging.";
@@ -62,7 +63,7 @@ const isPm2BrokenStateError = (error: unknown): boolean => {
6263

6364
type Pm2Module = {
6465
connect: (cb: (err: Error | null) => void) => void;
65-
disconnect: () => void;
66+
disconnect: (cb?: (err: Error | null, data?: unknown) => void) => void;
6667
describe: (name: string, cb: (err: Error | null, result: unknown[]) => void) => void;
6768
delete: (name: string, cb: (err: Error | null) => void) => void;
6869
start: (opts: unknown, cb: (err: Error | null) => void) => void;
@@ -111,36 +112,51 @@ const sleep = async (ms: number): Promise<void> =>
111112

112113
const disconnectPm2 = async (): Promise<void> => {
113114
const pm2 = await loadPm2();
114-
pm2.disconnect();
115+
await new Promise<void>((resolve) => {
116+
let settled = false;
117+
const finish = () => {
118+
if (settled) {
119+
return;
120+
}
121+
122+
settled = true;
123+
resolve();
124+
};
125+
126+
const timer = setTimeout(finish, PM2_DISCONNECT_WAIT_MS);
127+
try {
128+
pm2.disconnect(() => {
129+
clearTimeout(timer);
130+
finish();
131+
});
132+
} catch {
133+
clearTimeout(timer);
134+
finish();
135+
}
136+
});
115137
};
116138

117-
const describeManagedServer = async (): Promise<Pm2ProcessDescription[]> => {
118-
const pm2 = await loadPm2();
119-
return new Promise((resolve, reject) => {
139+
const describeManagedServer = async (pm2: Pm2Module): Promise<Pm2ProcessDescription[]> =>
140+
new Promise((resolve, reject) => {
120141
pm2.describe(MANAGED_SERVER_NAME, (error, result) => {
121142
if (error) {
122143
reject(error);
123144
return;
124145
}
125-
126146
resolve((result ?? []) as Pm2ProcessDescription[]);
127147
});
128148
});
129-
};
130149

131-
const removeManagedServer = async (): Promise<void> => {
132-
const pm2 = await loadPm2();
133-
return new Promise((resolve, reject) => {
150+
const removeManagedServer = async (pm2: Pm2Module): Promise<void> =>
151+
new Promise((resolve, reject) => {
134152
pm2.delete(MANAGED_SERVER_NAME, (error) => {
135153
if (error) {
136154
reject(error);
137155
return;
138156
}
139-
140157
resolve();
141158
});
142159
});
143-
};
144160

145161
/**
146162
* Kill the PM2 daemon to clear stale paths/caches.
@@ -159,9 +175,10 @@ const killPm2Daemon = async (): Promise<void> => {
159175
* Try to connect to PM2, and if it's in a broken state (stale worktree path),
160176
* kill the daemon and reconnect fresh.
161177
*/
162-
const connectWithRecovery = async (): Promise<void> => {
178+
const connectWithRecovery = async (): Promise<Pm2Module> => {
163179
try {
164180
await connectPm2();
181+
return loadPm2();
165182
} catch (error) {
166183
if (isPm2BrokenStateError(error)) {
167184
console.warn("PM2 daemon is in a stale state. Killing and reconnecting...");
@@ -174,23 +191,25 @@ const connectWithRecovery = async (): Promise<void> => {
174191
// Clear cached module so next loadPm2 gets a fresh instance
175192
cachedPm2 = null;
176193
await connectPm2();
194+
return loadPm2();
177195
} else {
178196
throw error;
179197
}
180198
}
181199
};
182200

183-
const withPm2Connection = async <T>(operation: () => Promise<T>): Promise<T> => {
184-
await connectWithRecovery();
201+
const withPm2Connection = async <T>(operation: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
202+
const pm2 = await connectWithRecovery();
185203

186204
try {
187-
return await operation();
205+
return await operation(pm2);
188206
} finally {
189207
await disconnectPm2();
190208
}
191209
};
192210

193211
const waitForRuntimeReady = async (
212+
pm2: Pm2Module,
194213
waitMs: number,
195214
logOffsets: StartupLogOffsets
196215
): Promise<void> => {
@@ -201,7 +220,7 @@ const waitForRuntimeReady = async (
201220
return;
202221
}
203222

204-
const processes = await describeManagedServer();
223+
const processes = await describeManagedServer(pm2);
205224
const process = processes[0];
206225
if (!process) {
207226
throw createStartupError(
@@ -226,22 +245,11 @@ const waitForRuntimeReady = async (
226245
throw createStartupError(`runtime readiness timed out after ${waitMs}ms`, logOffsets);
227246
};
228247

229-
const waitForManagedServerExit = async (): Promise<void> => {
230-
while (true) {
231-
const processes = await describeManagedServer();
232-
if (processes.length === 0) {
233-
return;
234-
}
235-
236-
await sleep(STARTUP_POLL_INTERVAL_MS);
237-
}
238-
};
239-
240-
const waitForManagedServerDeletion = async (waitMs: number): Promise<void> => {
248+
const waitForManagedServerDeletion = async (pm2: Pm2Module, waitMs: number): Promise<void> => {
241249
const deadline = Date.now() + waitMs;
242250

243251
while (Date.now() <= deadline) {
244-
const processes = await withPm2Connection(describeManagedServer);
252+
const processes = await describeManagedServer(pm2);
245253
if (processes.length === 0) {
246254
return;
247255
}
@@ -257,6 +265,34 @@ const waitForManagedServerDeletion = async (waitMs: number): Promise<void> => {
257265
throw new Error(`Timed out waiting for the managed server to stop after ${waitMs}ms.`);
258266
};
259267

268+
const deleteManagedServerInSession = async (
269+
pm2: Pm2Module,
270+
{
271+
ignoreMissing = false,
272+
}: {
273+
ignoreMissing?: boolean;
274+
} = {}
275+
): Promise<boolean> => {
276+
const processes = await describeManagedServer(pm2);
277+
if (processes.length === 0) {
278+
return false;
279+
}
280+
281+
try {
282+
await removeManagedServer(pm2);
283+
} catch (error) {
284+
if (ignoreMissing && isMissingManagedServerError(error)) {
285+
await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
286+
return false;
287+
}
288+
289+
throw error;
290+
}
291+
292+
await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
293+
return true;
294+
};
295+
260296
const ensureLogDirectory = (): void => {
261297
mkdirSync(join(homedir(), ".coder-studio", "logs"), { recursive: true });
262298
};
@@ -307,56 +343,26 @@ export const deleteManagedServer = async ({
307343
ignoreMissing = false,
308344
}: {
309345
ignoreMissing?: boolean;
310-
} = {}): Promise<boolean> => {
311-
const deleted = await withPm2Connection(async () => {
312-
const processes = await describeManagedServer();
313-
if (processes.length === 0) {
314-
return false;
315-
}
316-
317-
try {
318-
await removeManagedServer();
319-
return true;
320-
} catch (error) {
321-
if (ignoreMissing && isMissingManagedServerError(error)) {
322-
return false;
323-
}
324-
325-
throw error;
326-
}
327-
});
328-
329-
if (!deleted) {
330-
return false;
331-
}
332-
333-
await waitForManagedServerDeletion(PM2_DELETE_WAIT_MS);
334-
return true;
335-
};
346+
} = {}): Promise<boolean> =>
347+
withPm2Connection((pm2) => deleteManagedServerInSession(pm2, { ignoreMissing }));
336348

337349
export const startManagedServer = async ({
338350
script,
339351
cwd,
340352
waitMs,
341353
args,
342-
}: StartManagedServerOptions): Promise<void> => {
343-
// First try to delete any existing managed server
344-
await deleteManagedServer({ ignoreMissing: true });
354+
}: StartManagedServerOptions): Promise<void> =>
355+
withPm2Connection(async (pm2) => {
356+
await deleteManagedServerInSession(pm2, { ignoreMissing: true });
345357

346-
// Wait for the old process to actually exit
347-
await withPm2Connection(waitForManagedServerExit);
348-
349-
// Clear stale runtime config
350-
if (readRuntimeConfig()) {
351-
deleteRuntimeConfig();
352-
}
353-
354-
ensureLogDirectory();
355-
const { outFile, errFile } = getLogPaths();
356-
const pm2 = await loadPm2();
358+
if (readRuntimeConfig()) {
359+
deleteRuntimeConfig();
360+
}
357361

358-
await withPm2Connection(async () => {
362+
ensureLogDirectory();
363+
const { outFile, errFile } = getLogPaths();
359364
const logOffsets = captureStartupLogOffsets();
365+
360366
await new Promise<void>((resolve, reject) => {
361367
pm2.start(
362368
{
@@ -386,13 +392,12 @@ export const startManagedServer = async ({
386392
);
387393
});
388394

389-
await waitForRuntimeReady(waitMs, logOffsets);
395+
await waitForRuntimeReady(pm2, waitMs, logOffsets);
390396
});
391-
};
392397

393398
export const getManagedServerStatus = async (): Promise<ManagedServerStatus> =>
394-
withPm2Connection(async () => {
395-
const processes = await describeManagedServer();
399+
withPm2Connection(async (pm2) => {
400+
const processes = await describeManagedServer(pm2);
396401
const process = processes[0];
397402

398403
if (!process) {

0 commit comments

Comments
 (0)