Skip to content

Commit d9fa333

Browse files
authored
Merge pull request #56 from spencerkit/develop
fix(web): stabilize terminal recovery replay flow
2 parents e9843c5 + 5b93d9d commit d9fa333

8 files changed

Lines changed: 441 additions & 15 deletions

File tree

.changeset/stale-timers-repair.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@spencer-kit/coder-studio": patch
3+
---
4+
5+
Fix terminal recovery so session output no longer stalls after noop reconcile
6+
decisions or gets cleared when queued live chunks flush after snapshot
7+
hydration.

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,15 @@ describe("pm2-control", () => {
148148
});
149149

150150
it("waits for the previous PM2 app to disappear before starting a replacement", async () => {
151+
const previousPid = 999_990;
151152
describeProcess
152153
.mockImplementationOnce(
153154
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
154-
callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
155+
callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
155156
)
156157
.mockImplementationOnce(
157158
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
158-
callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
159+
callback(null, [{ pid: previousPid, pm2_env: { status: "stopping", restart_time: 0 } }])
159160
)
160161
.mockImplementationOnce(
161162
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -180,14 +181,15 @@ describe("pm2-control", () => {
180181
});
181182

182183
it("reuses one pm2 session while polling deletion during startup", async () => {
184+
const previousPid = 999_992;
183185
describeProcess
184186
.mockImplementationOnce(
185187
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
186-
callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
188+
callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
187189
)
188190
.mockImplementationOnce(
189191
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
190-
callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
192+
callback(null, [{ pid: previousPid, pm2_env: { status: "stopping", restart_time: 0 } }])
191193
)
192194
.mockImplementationOnce(
193195
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -204,15 +206,55 @@ describe("pm2-control", () => {
204206
expect(disconnect).toHaveBeenCalledTimes(1);
205207
});
206208

209+
it("waits for the previous process pid to exit before starting a replacement", async () => {
210+
const previousPid = 999_991;
211+
const killSpy = vi.spyOn(process, "kill");
212+
killSpy
213+
.mockImplementationOnce(() => true)
214+
.mockImplementationOnce(() => true)
215+
.mockImplementationOnce(() => {
216+
const error = new Error("process not found") as NodeJS.ErrnoException;
217+
error.code = "ESRCH";
218+
throw error;
219+
});
220+
describeProcess
221+
.mockImplementationOnce(
222+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
223+
callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
224+
)
225+
.mockImplementationOnce(
226+
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
227+
callback(null, [])
228+
);
229+
230+
const pendingStart = startManagedServer({
231+
script: "/cli/dist/esm/server-runner.js",
232+
cwd: "/repo",
233+
waitMs: 10,
234+
});
235+
236+
await expect(
237+
Promise.race([
238+
pendingStart.then(() => "started"),
239+
new Promise((resolve) => setTimeout(() => resolve("waiting"), 20)),
240+
])
241+
).resolves.toBe("waiting");
242+
243+
expect(start).not.toHaveBeenCalled();
244+
await pendingStart;
245+
expect(killSpy).toHaveBeenCalledWith(previousPid, 0);
246+
});
247+
207248
it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => {
249+
const previousPid = 999_993;
208250
describeProcess
209251
.mockImplementationOnce(
210252
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
211-
callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
253+
callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
212254
)
213255
.mockImplementationOnce(
214256
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
215-
callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
257+
callback(null, [{ pid: previousPid, pm2_env: { status: "stopping", restart_time: 0 } }])
216258
)
217259
.mockImplementationOnce(
218260
(_name: string, callback: (error: Error | null, result: unknown[]) => void) =>

packages/cli/src/pm2-control.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,39 @@ const waitForManagedServerDeletion = async (pm2: Pm2Module, waitMs: number): Pro
265265
throw new Error(`Timed out waiting for the managed server to stop after ${waitMs}ms.`);
266266
};
267267

268+
const isMissingProcessError = (error: unknown): boolean =>
269+
Boolean(
270+
error &&
271+
typeof error === "object" &&
272+
"code" in error &&
273+
(error as NodeJS.ErrnoException).code === "ESRCH"
274+
);
275+
276+
const waitForProcessExit = async (pid: number, waitMs: number): Promise<void> => {
277+
const deadline = Date.now() + waitMs;
278+
279+
while (Date.now() <= deadline) {
280+
try {
281+
process.kill(pid, 0);
282+
} catch (error) {
283+
if (isMissingProcessError(error)) {
284+
return;
285+
}
286+
287+
throw error;
288+
}
289+
290+
const remainingMs = deadline - Date.now();
291+
if (remainingMs <= 0) {
292+
break;
293+
}
294+
295+
await sleep(Math.min(STARTUP_POLL_INTERVAL_MS, remainingMs));
296+
}
297+
298+
throw new Error(`Timed out waiting for the managed server pid ${pid} to exit after ${waitMs}ms.`);
299+
};
300+
268301
const deleteManagedServerInSession = async (
269302
pm2: Pm2Module,
270303
{
@@ -277,19 +310,27 @@ const deleteManagedServerInSession = async (
277310
if (processes.length === 0) {
278311
return false;
279312
}
313+
const previousPid = processes[0]?.pid;
314+
const deadline = Date.now() + PM2_DELETE_WAIT_MS;
280315

281316
try {
282317
await removeManagedServer(pm2);
283318
} catch (error) {
284319
if (ignoreMissing && isMissingManagedServerError(error)) {
285-
await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
320+
await waitForManagedServerDeletion(pm2, Math.max(0, deadline - Date.now()));
321+
if (typeof previousPid === "number" && previousPid > 0) {
322+
await waitForProcessExit(previousPid, Math.max(0, deadline - Date.now()));
323+
}
286324
return false;
287325
}
288326

289327
throw error;
290328
}
291329

292-
await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
330+
await waitForManagedServerDeletion(pm2, Math.max(0, deadline - Date.now()));
331+
if (typeof previousPid === "number" && previousPid > 0) {
332+
await waitForProcessExit(previousPid, Math.max(0, deadline - Date.now()));
333+
}
293334
return true;
294335
};
295336

packages/cli/src/update-worker.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,64 @@ describe("update-worker", () => {
9696
expect(state.manualCommand).toBe("coder-studio serve --restart");
9797
expect(state.errorSummary).toContain("restart failed");
9898
});
99+
100+
it("sanitizes pm2 and runtime override env before invoking install and restart commands", async () => {
101+
const env = createEnv();
102+
const runCommand = vi.fn(async () => {});
103+
const originalEnv = {
104+
PM2_HOME: process.env.PM2_HOME,
105+
PM2_PROGRAMMATIC: process.env.PM2_PROGRAMMATIC,
106+
PM2_JSON_PROCESSING: process.env.PM2_JSON_PROCESSING,
107+
PM2_INTERACTOR_PROCESSING: process.env.PM2_INTERACTOR_PROCESSING,
108+
NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE,
109+
NODE_CHANNEL_FD: process.env.NODE_CHANNEL_FD,
110+
NODE_CHANNEL_SERIALIZATION_MODE: process.env.NODE_CHANNEL_SERIALIZATION_MODE,
111+
CODER_STUDIO_RUNTIME_JSON_PATH: process.env.CODER_STUDIO_RUNTIME_JSON_PATH,
112+
CODER_STUDIO_SESSION_ID: process.env.CODER_STUDIO_SESSION_ID,
113+
CODER_STUDIO_UPDATE_STATE_PATH: process.env.CODER_STUDIO_UPDATE_STATE_PATH,
114+
pm_id: process.env.pm_id,
115+
};
116+
117+
process.env.PM2_HOME = "/tmp/custom-pm2-home";
118+
process.env.PM2_PROGRAMMATIC = "true";
119+
process.env.PM2_JSON_PROCESSING = "true";
120+
process.env.PM2_INTERACTOR_PROCESSING = "true";
121+
process.env.NODE_APP_INSTANCE = "0";
122+
process.env.NODE_CHANNEL_FD = "3";
123+
process.env.NODE_CHANNEL_SERIALIZATION_MODE = "json";
124+
process.env.CODER_STUDIO_RUNTIME_JSON_PATH = "/tmp/runtime.json";
125+
process.env.CODER_STUDIO_SESSION_ID = "sess_test";
126+
process.env.CODER_STUDIO_UPDATE_STATE_PATH = "/tmp/update-state.json";
127+
process.env.pm_id = "0";
128+
129+
try {
130+
await runUpdateWorker(env, {
131+
runCommand,
132+
now: () => 1000,
133+
});
134+
} finally {
135+
for (const [key, value] of Object.entries(originalEnv)) {
136+
if (value === undefined) {
137+
delete process.env[key];
138+
} else {
139+
process.env[key] = value;
140+
}
141+
}
142+
}
143+
144+
for (const call of runCommand.mock.calls) {
145+
const options = call[2] as { env?: NodeJS.ProcessEnv };
146+
expect(options.env?.PM2_HOME).toBe("/tmp/custom-pm2-home");
147+
expect(options.env?.PM2_PROGRAMMATIC).toBeUndefined();
148+
expect(options.env?.PM2_JSON_PROCESSING).toBeUndefined();
149+
expect(options.env?.PM2_INTERACTOR_PROCESSING).toBeUndefined();
150+
expect(options.env?.NODE_APP_INSTANCE).toBeUndefined();
151+
expect(options.env?.NODE_CHANNEL_FD).toBeUndefined();
152+
expect(options.env?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined();
153+
expect(options.env?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined();
154+
expect(options.env?.CODER_STUDIO_SESSION_ID).toBeUndefined();
155+
expect(options.env?.CODER_STUDIO_UPDATE_STATE_PATH).toBeUndefined();
156+
expect(options.env?.pm_id).toBeUndefined();
157+
}
158+
});
99159
});

packages/cli/src/update-worker.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,47 @@ function buildManualCommand(input: WorkerEnv): string {
9797
].join("\n");
9898
}
9999

100+
const INTERNAL_ENV_KEYS = new Set([
101+
"CODER_STUDIO_RUNTIME_JSON_PATH",
102+
"CODER_STUDIO_SESSION_ID",
103+
"NODE_APP_INSTANCE",
104+
"NODE_CHANNEL_FD",
105+
"NODE_CHANNEL_SERIALIZATION_MODE",
106+
"PM2_INTERACTOR_PROCESSING",
107+
"PM2_JSON_PROCESSING",
108+
"PM2_PROGRAMMATIC",
109+
]);
110+
111+
function buildChildProcessEnv(env = process.env): NodeJS.ProcessEnv {
112+
const nextEnv: NodeJS.ProcessEnv = { ...env };
113+
114+
for (const key of Object.keys(nextEnv)) {
115+
if (INTERNAL_ENV_KEYS.has(key)) {
116+
delete nextEnv[key];
117+
continue;
118+
}
119+
120+
if (key.startsWith("CODER_STUDIO_UPDATE_") || key.startsWith("pm_")) {
121+
delete nextEnv[key];
122+
}
123+
}
124+
125+
return nextEnv;
126+
}
127+
100128
function runCommand(
101129
command: string,
102130
args: string[],
103-
options?: { stdio?: "ignore" | "pipe"; logStream?: NodeJS.WritableStream }
131+
options?: {
132+
stdio?: "ignore" | "pipe";
133+
logStream?: NodeJS.WritableStream;
134+
env?: NodeJS.ProcessEnv;
135+
}
104136
): Promise<void> {
105137
return new Promise((resolve, reject) => {
106138
const child = spawn(command, args, {
107139
stdio: options?.stdio === "ignore" ? "ignore" : "pipe",
108-
env: process.env,
140+
env: options?.env ?? process.env,
109141
});
110142

111143
if (options?.logStream && child.stdout) {
@@ -137,12 +169,13 @@ export async function runUpdateWorker(
137169
await mkdir(dirname(input.logFilePath), { recursive: true });
138170
const logStream = createWriteStream(input.logFilePath, { flags: "a" });
139171
const execute = deps?.runCommand ?? runCommand;
172+
const childEnv = buildChildProcessEnv(process.env);
140173

141174
try {
142175
await execute(
143176
input.npmCommand,
144177
[...input.installArgsPrefix, `${input.packageName}@${input.targetVersion}`],
145-
{ logStream }
178+
{ logStream, env: childEnv }
146179
);
147180
} catch (error) {
148181
const message = error instanceof Error ? error.message : String(error);
@@ -183,7 +216,7 @@ export async function runUpdateWorker(
183216
});
184217

185218
try {
186-
await execute(input.cliCommand, input.restartArgs, { logStream });
219+
await execute(input.cliCommand, input.restartArgs, { logStream, env: childEnv });
187220
} catch (error) {
188221
const message = error instanceof Error ? error.message : String(error);
189222
await writeState(input.stateFilePath, {

0 commit comments

Comments
 (0)