@spencer-kit/coder-studio 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,14 @@
6
6
  <meta name="description" content="Coder Studio - Agent-First Development Environment" />
7
7
  <title>Coder Studio</title>
8
8
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
9
- <script type="module" crossorigin src="/assets/main-D3dXqSaA.js"></script>
9
+ <script type="module" crossorigin src="/assets/main-CZuF2VZA.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CpWojdLp.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/monaco-editor-VTbRDH-J.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/xterm-BVlcrOZ1.js">
13
- <link rel="modulepreload" crossorigin href="/assets/components-omWbMLvf.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/components-C4SKshs2.js">
14
14
  <link rel="stylesheet" crossorigin href="/assets/monaco-editor-Br_kD0ds.css">
15
15
  <link rel="stylesheet" crossorigin href="/assets/xterm-BrP-ENHg.css">
16
- <link rel="stylesheet" crossorigin href="/assets/components-AKM1pxhf.css">
16
+ <link rel="stylesheet" crossorigin href="/assets/components-CMahvybm.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -4,14 +4,14 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Coder Studio UI Preview</title>
7
- <script type="module" crossorigin src="/assets/ui-preview-BGZz053-.js"></script>
7
+ <script type="module" crossorigin src="/assets/ui-preview-DCeC0YmD.js"></script>
8
8
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CpWojdLp.js">
9
9
  <link rel="modulepreload" crossorigin href="/assets/monaco-editor-VTbRDH-J.js">
10
10
  <link rel="modulepreload" crossorigin href="/assets/xterm-BVlcrOZ1.js">
11
- <link rel="modulepreload" crossorigin href="/assets/components-omWbMLvf.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/components-C4SKshs2.js">
12
12
  <link rel="stylesheet" crossorigin href="/assets/monaco-editor-Br_kD0ds.css">
13
13
  <link rel="stylesheet" crossorigin href="/assets/xterm-BrP-ENHg.css">
14
- <link rel="stylesheet" crossorigin href="/assets/components-AKM1pxhf.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/components-CMahvybm.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spencer-kit/coder-studio",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "description": "Deploy once, code everywhere. Browser-based AI coding workspace for Claude Code and Codex.",
6
6
  "main": "./dist/esm/index.mjs",
@@ -148,14 +148,15 @@ describe("pm2-control", () => {
148
148
  });
149
149
 
150
150
  it("waits for the previous PM2 app to disappear before starting a replacement", async () => {
151
+ const previousPid = 999_990;
151
152
  describeProcess
152
153
  .mockImplementationOnce(
153
154
  (_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 } }])
155
156
  )
156
157
  .mockImplementationOnce(
157
158
  (_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 } }])
159
160
  )
160
161
  .mockImplementationOnce(
161
162
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -180,14 +181,15 @@ describe("pm2-control", () => {
180
181
  });
181
182
 
182
183
  it("reuses one pm2 session while polling deletion during startup", async () => {
184
+ const previousPid = 999_992;
183
185
  describeProcess
184
186
  .mockImplementationOnce(
185
187
  (_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 } }])
187
189
  )
188
190
  .mockImplementationOnce(
189
191
  (_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 } }])
191
193
  )
192
194
  .mockImplementationOnce(
193
195
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -204,15 +206,55 @@ describe("pm2-control", () => {
204
206
  expect(disconnect).toHaveBeenCalledTimes(1);
205
207
  });
206
208
 
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
+
207
248
  it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => {
249
+ const previousPid = 999_993;
208
250
  describeProcess
209
251
  .mockImplementationOnce(
210
252
  (_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 } }])
212
254
  )
213
255
  .mockImplementationOnce(
214
256
  (_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 } }])
216
258
  )
217
259
  .mockImplementationOnce(
218
260
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -265,6 +265,39 @@ const waitForManagedServerDeletion = async (pm2: Pm2Module, waitMs: number): Pro
265
265
  throw new Error(`Timed out waiting for the managed server to stop after ${waitMs}ms.`);
266
266
  };
267
267
 
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
+
268
301
  const deleteManagedServerInSession = async (
269
302
  pm2: Pm2Module,
270
303
  {
@@ -277,19 +310,27 @@ const deleteManagedServerInSession = async (
277
310
  if (processes.length === 0) {
278
311
  return false;
279
312
  }
313
+ const previousPid = processes[0]?.pid;
314
+ const deadline = Date.now() + PM2_DELETE_WAIT_MS;
280
315
 
281
316
  try {
282
317
  await removeManagedServer(pm2);
283
318
  } catch (error) {
284
319
  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
+ }
286
324
  return false;
287
325
  }
288
326
 
289
327
  throw error;
290
328
  }
291
329
 
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
+ }
293
334
  return true;
294
335
  };
295
336
 
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { runUpdateWorker } from "./update-worker.js";
5
+ import { runRestartHandoff, runUpdateWorker } from "./update-worker.js";
6
6
 
7
7
  describe("update-worker", () => {
8
8
  const tempDirs: string[] = [];
@@ -29,13 +29,16 @@ describe("update-worker", () => {
29
29
  };
30
30
  }
31
31
 
32
- it("writes restarting state after install success and restart handoff", async () => {
32
+ it("writes restarting state and spawns a detached restart handoff after install success", async () => {
33
33
  const env = createEnv();
34
34
  const runCommand = vi.fn(async () => {});
35
+ const spawnDetachedProcess = vi.fn(async () => {});
35
36
 
36
37
  await runUpdateWorker(env, {
37
38
  runCommand,
38
39
  now: () => 1000,
40
+ processId: 4242,
41
+ spawnDetachedProcess,
39
42
  });
40
43
 
41
44
  const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { updateStatus: string };
@@ -46,11 +49,13 @@ describe("update-worker", () => {
46
49
  ["install", "-g", "@spencer-kit/coder-studio@0.5.0"],
47
50
  expect.any(Object)
48
51
  );
49
- expect(runCommand).toHaveBeenNthCalledWith(
50
- 2,
51
- "coder-studio",
52
- ["serve", "--restart"],
53
- expect.any(Object)
52
+ expect(spawnDetachedProcess).toHaveBeenCalledWith(
53
+ process.execPath,
54
+ expect.any(Array),
55
+ expect.objectContaining({
56
+ CODER_STUDIO_UPDATE_WORKER_MODE: "restart-handoff",
57
+ CODER_STUDIO_UPDATE_PARENT_PID: "4242",
58
+ })
54
59
  );
55
60
  });
56
61
 
@@ -77,14 +82,14 @@ describe("update-worker", () => {
77
82
 
78
83
  it("marks restart failures with manual restart guidance", async () => {
79
84
  const env = createEnv();
80
- const runCommand = vi
81
- .fn()
82
- .mockResolvedValueOnce(undefined)
83
- .mockRejectedValueOnce(new Error("pm2 restart failed"));
85
+ const runCommand = vi.fn().mockRejectedValueOnce(new Error("pm2 restart failed"));
86
+ const waitForProcessExit = vi.fn(async () => {});
84
87
 
85
- await runUpdateWorker(env, {
88
+ await runRestartHandoff(env, {
86
89
  runCommand,
87
90
  now: () => 1000,
91
+ waitForProcessExit,
92
+ restartParentPid: 999,
88
93
  });
89
94
 
90
95
  const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as {
@@ -95,5 +100,101 @@ describe("update-worker", () => {
95
100
  expect(state.updateStatus).toBe("failed");
96
101
  expect(state.manualCommand).toBe("coder-studio serve --restart");
97
102
  expect(state.errorSummary).toContain("restart failed");
103
+ expect(waitForProcessExit).toHaveBeenCalledWith(999);
104
+ });
105
+
106
+ it("sanitizes pm2 and runtime override env before invoking install and restart commands", async () => {
107
+ const env = createEnv();
108
+ const runCommand = vi.fn(async () => {});
109
+ const spawnDetachedProcess = vi.fn(async () => {});
110
+ const originalEnv = {
111
+ PM2_HOME: process.env.PM2_HOME,
112
+ PM2_PROGRAMMATIC: process.env.PM2_PROGRAMMATIC,
113
+ PM2_JSON_PROCESSING: process.env.PM2_JSON_PROCESSING,
114
+ PM2_INTERACTOR_PROCESSING: process.env.PM2_INTERACTOR_PROCESSING,
115
+ NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE,
116
+ NODE_CHANNEL_FD: process.env.NODE_CHANNEL_FD,
117
+ NODE_CHANNEL_SERIALIZATION_MODE: process.env.NODE_CHANNEL_SERIALIZATION_MODE,
118
+ CODER_STUDIO_RUNTIME_JSON_PATH: process.env.CODER_STUDIO_RUNTIME_JSON_PATH,
119
+ CODER_STUDIO_SESSION_ID: process.env.CODER_STUDIO_SESSION_ID,
120
+ CODER_STUDIO_UPDATE_STATE_PATH: process.env.CODER_STUDIO_UPDATE_STATE_PATH,
121
+ pm_id: process.env.pm_id,
122
+ };
123
+
124
+ process.env.PM2_HOME = "/tmp/custom-pm2-home";
125
+ process.env.PM2_PROGRAMMATIC = "true";
126
+ process.env.PM2_JSON_PROCESSING = "true";
127
+ process.env.PM2_INTERACTOR_PROCESSING = "true";
128
+ process.env.NODE_APP_INSTANCE = "0";
129
+ process.env.NODE_CHANNEL_FD = "3";
130
+ process.env.NODE_CHANNEL_SERIALIZATION_MODE = "json";
131
+ process.env.CODER_STUDIO_RUNTIME_JSON_PATH = "/tmp/runtime.json";
132
+ process.env.CODER_STUDIO_SESSION_ID = "sess_test";
133
+ process.env.CODER_STUDIO_UPDATE_STATE_PATH = "/tmp/update-state.json";
134
+ process.env.pm_id = "0";
135
+
136
+ try {
137
+ await runUpdateWorker(env, {
138
+ runCommand,
139
+ now: () => 1000,
140
+ processId: 4242,
141
+ spawnDetachedProcess,
142
+ });
143
+ } finally {
144
+ for (const [key, value] of Object.entries(originalEnv)) {
145
+ if (value === undefined) {
146
+ delete process.env[key];
147
+ } else {
148
+ process.env[key] = value;
149
+ }
150
+ }
151
+ }
152
+
153
+ for (const call of runCommand.mock.calls) {
154
+ const options = call[2] as { env?: NodeJS.ProcessEnv };
155
+ expect(options.env?.PM2_HOME).toBe("/tmp/custom-pm2-home");
156
+ expect(options.env?.PM2_PROGRAMMATIC).toBeUndefined();
157
+ expect(options.env?.PM2_JSON_PROCESSING).toBeUndefined();
158
+ expect(options.env?.PM2_INTERACTOR_PROCESSING).toBeUndefined();
159
+ expect(options.env?.NODE_APP_INSTANCE).toBeUndefined();
160
+ expect(options.env?.NODE_CHANNEL_FD).toBeUndefined();
161
+ expect(options.env?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined();
162
+ expect(options.env?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined();
163
+ expect(options.env?.CODER_STUDIO_SESSION_ID).toBeUndefined();
164
+ expect(options.env?.CODER_STUDIO_UPDATE_STATE_PATH).toBeUndefined();
165
+ expect(options.env?.pm_id).toBeUndefined();
166
+ }
167
+
168
+ const handoffEnv = spawnDetachedProcess.mock.calls[0]?.[2] as NodeJS.ProcessEnv | undefined;
169
+ expect(handoffEnv?.PM2_HOME).toBe("/tmp/custom-pm2-home");
170
+ expect(handoffEnv?.PM2_PROGRAMMATIC).toBeUndefined();
171
+ expect(handoffEnv?.PM2_JSON_PROCESSING).toBeUndefined();
172
+ expect(handoffEnv?.PM2_INTERACTOR_PROCESSING).toBeUndefined();
173
+ expect(handoffEnv?.NODE_APP_INSTANCE).toBeUndefined();
174
+ expect(handoffEnv?.NODE_CHANNEL_FD).toBeUndefined();
175
+ expect(handoffEnv?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined();
176
+ expect(handoffEnv?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined();
177
+ expect(handoffEnv?.CODER_STUDIO_SESSION_ID).toBeUndefined();
178
+ expect(handoffEnv?.pm_id).toBeUndefined();
179
+ });
180
+
181
+ it("waits for the install worker to exit before running the restart command", async () => {
182
+ const env = createEnv();
183
+ const waitForProcessExit = vi.fn(async () => {});
184
+ const runCommand = vi.fn(async () => {});
185
+
186
+ await runRestartHandoff(env, {
187
+ runCommand,
188
+ now: () => 1000,
189
+ waitForProcessExit,
190
+ restartParentPid: 777,
191
+ });
192
+
193
+ expect(waitForProcessExit).toHaveBeenCalledWith(777);
194
+ expect(runCommand).toHaveBeenCalledWith(
195
+ "coder-studio",
196
+ ["serve", "--restart"],
197
+ expect.any(Object)
198
+ );
98
199
  });
99
200
  });
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { createWriteStream } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
 
6
7
  interface UpdateStateSnapshot {
7
8
  version: 1;
@@ -37,6 +38,13 @@ interface WorkerEnv {
37
38
  installArgsPrefix: string[];
38
39
  }
39
40
 
41
+ type WorkerMode = "install" | "restart-handoff";
42
+
43
+ const RESTART_HANDOFF_MODE: WorkerMode = "restart-handoff";
44
+ const DEFAULT_MODE: WorkerMode = "install";
45
+ const RESTART_HANDOFF_WAIT_MS = 5_000;
46
+ const WORKER_ENTRY_PATH = fileURLToPath(import.meta.url);
47
+
40
48
  async function writeState(filePath: string, value: UpdateStateSnapshot): Promise<void> {
41
49
  await mkdir(dirname(filePath), { recursive: true });
42
50
  await import("node:fs/promises").then(({ writeFile }) =>
@@ -44,6 +52,16 @@ async function writeState(filePath: string, value: UpdateStateSnapshot): Promise
44
52
  );
45
53
  }
46
54
 
55
+ function closeLogStream(stream: NodeJS.WritableStream): Promise<void> {
56
+ return new Promise((resolve, reject) => {
57
+ stream.once("error", reject);
58
+ stream.end(() => {
59
+ stream.off("error", reject);
60
+ resolve();
61
+ });
62
+ });
63
+ }
64
+
47
65
  function parseJsonArray(value: string | undefined, fallback: string[]): string[] {
48
66
  if (!value) {
49
67
  return fallback;
@@ -97,15 +115,128 @@ function buildManualCommand(input: WorkerEnv): string {
97
115
  ].join("\n");
98
116
  }
99
117
 
118
+ function readWorkerMode(env = process.env): WorkerMode {
119
+ return env.CODER_STUDIO_UPDATE_WORKER_MODE === RESTART_HANDOFF_MODE
120
+ ? RESTART_HANDOFF_MODE
121
+ : DEFAULT_MODE;
122
+ }
123
+
124
+ function readRestartParentPid(env = process.env): number | null {
125
+ const raw = env.CODER_STUDIO_UPDATE_PARENT_PID;
126
+ if (!raw) {
127
+ return null;
128
+ }
129
+
130
+ const pid = Number.parseInt(raw, 10);
131
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
132
+ }
133
+
134
+ const INTERNAL_ENV_KEYS = new Set([
135
+ "CODER_STUDIO_RUNTIME_JSON_PATH",
136
+ "CODER_STUDIO_SESSION_ID",
137
+ "NODE_APP_INSTANCE",
138
+ "NODE_CHANNEL_FD",
139
+ "NODE_CHANNEL_SERIALIZATION_MODE",
140
+ "PM2_INTERACTOR_PROCESSING",
141
+ "PM2_JSON_PROCESSING",
142
+ "PM2_PROGRAMMATIC",
143
+ ]);
144
+
145
+ function buildChildProcessEnv(env = process.env): NodeJS.ProcessEnv {
146
+ const nextEnv: NodeJS.ProcessEnv = { ...env };
147
+
148
+ for (const key of Object.keys(nextEnv)) {
149
+ if (INTERNAL_ENV_KEYS.has(key)) {
150
+ delete nextEnv[key];
151
+ continue;
152
+ }
153
+
154
+ if (key.startsWith("CODER_STUDIO_UPDATE_") || key.startsWith("pm_")) {
155
+ delete nextEnv[key];
156
+ }
157
+ }
158
+
159
+ return nextEnv;
160
+ }
161
+
162
+ function buildWorkerEnv(input: WorkerEnv): NodeJS.ProcessEnv {
163
+ return {
164
+ CODER_STUDIO_UPDATE_STATE_PATH: input.stateFilePath,
165
+ CODER_STUDIO_UPDATE_LOG_PATH: input.logFilePath,
166
+ CODER_STUDIO_UPDATE_PACKAGE_NAME: input.packageName,
167
+ CODER_STUDIO_UPDATE_TARGET_VERSION: input.targetVersion,
168
+ CODER_STUDIO_UPDATE_CLI_COMMAND: input.cliCommand,
169
+ CODER_STUDIO_UPDATE_CURRENT_VERSION: input.currentVersion,
170
+ CODER_STUDIO_UPDATE_NPM_COMMAND: input.npmCommand,
171
+ CODER_STUDIO_UPDATE_RESTART_ARGS: JSON.stringify(input.restartArgs),
172
+ CODER_STUDIO_UPDATE_INSTALL_ARGS_PREFIX: JSON.stringify(input.installArgsPrefix),
173
+ };
174
+ }
175
+
176
+ function spawnDetachedProcess(
177
+ command: string,
178
+ args: string[],
179
+ env: NodeJS.ProcessEnv
180
+ ): Promise<void> {
181
+ return new Promise((resolve, reject) => {
182
+ const child = spawn(command, args, {
183
+ detached: true,
184
+ stdio: "ignore",
185
+ env,
186
+ });
187
+
188
+ child.on("error", reject);
189
+ child.unref();
190
+ resolve();
191
+ });
192
+ }
193
+
194
+ const isMissingProcessError = (error: unknown): boolean =>
195
+ Boolean(
196
+ error &&
197
+ typeof error === "object" &&
198
+ "code" in error &&
199
+ (error as NodeJS.ErrnoException).code === "ESRCH"
200
+ );
201
+
202
+ async function waitForProcessExit(pid: number, waitMs = RESTART_HANDOFF_WAIT_MS): Promise<void> {
203
+ const deadline = Date.now() + waitMs;
204
+
205
+ while (Date.now() <= deadline) {
206
+ try {
207
+ process.kill(pid, 0);
208
+ } catch (error) {
209
+ if (isMissingProcessError(error)) {
210
+ return;
211
+ }
212
+
213
+ throw error;
214
+ }
215
+
216
+ const remainingMs = deadline - Date.now();
217
+ if (remainingMs <= 0) {
218
+ break;
219
+ }
220
+
221
+ await new Promise((resolve) => {
222
+ setTimeout(resolve, Math.min(100, remainingMs));
223
+ });
224
+ }
225
+ }
226
+
100
227
  function runCommand(
101
228
  command: string,
102
229
  args: string[],
103
- options?: { stdio?: "ignore" | "pipe"; logStream?: NodeJS.WritableStream }
230
+ options?: {
231
+ stdio?: "ignore" | "pipe";
232
+ logStream?: NodeJS.WritableStream;
233
+ env?: NodeJS.ProcessEnv;
234
+ }
104
235
  ): Promise<void> {
105
236
  return new Promise((resolve, reject) => {
106
237
  const child = spawn(command, args, {
107
238
  stdio: options?.stdio === "ignore" ? "ignore" : "pipe",
108
- env: process.env,
239
+ env: options?.env ?? process.env,
109
240
  });
110
241
 
111
242
  if (options?.logStream && child.stdout) {
@@ -131,18 +262,23 @@ export async function runUpdateWorker(
131
262
  deps?: {
132
263
  runCommand?: typeof runCommand;
133
264
  now?: () => number;
265
+ processId?: number;
266
+ spawnDetachedProcess?: typeof spawnDetachedProcess;
134
267
  }
135
268
  ): Promise<void> {
136
269
  const now = deps?.now ?? Date.now;
137
270
  await mkdir(dirname(input.logFilePath), { recursive: true });
138
271
  const logStream = createWriteStream(input.logFilePath, { flags: "a" });
139
272
  const execute = deps?.runCommand ?? runCommand;
273
+ const childEnv = buildChildProcessEnv(process.env);
274
+ const processId = deps?.processId ?? process.pid;
275
+ const spawnRestartHandoff = deps?.spawnDetachedProcess ?? spawnDetachedProcess;
140
276
 
141
277
  try {
142
278
  await execute(
143
279
  input.npmCommand,
144
280
  [...input.installArgsPrefix, `${input.packageName}@${input.targetVersion}`],
145
- { logStream }
281
+ { logStream, env: childEnv }
146
282
  );
147
283
  } catch (error) {
148
284
  const message = error instanceof Error ? error.message : String(error);
@@ -163,7 +299,7 @@ export async function runUpdateWorker(
163
299
  manualCommand: permissionRelated ? buildManualCommand(input) : null,
164
300
  errorSummary: message,
165
301
  });
166
- logStream.end();
302
+ await closeLogStream(logStream);
167
303
  return;
168
304
  }
169
305
 
@@ -183,7 +319,56 @@ export async function runUpdateWorker(
183
319
  });
184
320
 
185
321
  try {
186
- await execute(input.cliCommand, input.restartArgs, { logStream });
322
+ await spawnRestartHandoff(process.execPath, [WORKER_ENTRY_PATH], {
323
+ ...childEnv,
324
+ ...buildWorkerEnv(input),
325
+ CODER_STUDIO_UPDATE_WORKER_MODE: RESTART_HANDOFF_MODE,
326
+ CODER_STUDIO_UPDATE_PARENT_PID: String(processId),
327
+ });
328
+ } catch (error) {
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ await writeState(input.stateFilePath, {
331
+ version: 1,
332
+ currentVersion: input.currentVersion,
333
+ latestVersion: input.targetVersion,
334
+ availability: "update_available",
335
+ updateStatus: "failed",
336
+ lastCheckedAt: now(),
337
+ targetVersion: input.targetVersion,
338
+ startedAt: now(),
339
+ finishedAt: now(),
340
+ requiresManualStep: true,
341
+ manualCommand: `${input.cliCommand} ${input.restartArgs.join(" ")}`,
342
+ errorSummary: `new version installed but service restart failed: ${message}`,
343
+ });
344
+ } finally {
345
+ await closeLogStream(logStream);
346
+ }
347
+ }
348
+
349
+ export async function runRestartHandoff(
350
+ input = readEnv(),
351
+ deps?: {
352
+ runCommand?: typeof runCommand;
353
+ now?: () => number;
354
+ waitForProcessExit?: typeof waitForProcessExit;
355
+ restartParentPid?: number | null;
356
+ }
357
+ ): Promise<void> {
358
+ const now = deps?.now ?? Date.now;
359
+ await mkdir(dirname(input.logFilePath), { recursive: true });
360
+ const logStream = createWriteStream(input.logFilePath, { flags: "a" });
361
+ const execute = deps?.runCommand ?? runCommand;
362
+ const waitForParentExit = deps?.waitForProcessExit ?? waitForProcessExit;
363
+ const childEnv = buildChildProcessEnv(process.env);
364
+ const restartParentPid = deps?.restartParentPid ?? readRestartParentPid(process.env);
365
+
366
+ try {
367
+ if (restartParentPid !== null) {
368
+ await waitForParentExit(restartParentPid);
369
+ }
370
+
371
+ await execute(input.cliCommand, input.restartArgs, { logStream, env: childEnv });
187
372
  } catch (error) {
188
373
  const message = error instanceof Error ? error.message : String(error);
189
374
  await writeState(input.stateFilePath, {
@@ -201,12 +386,15 @@ export async function runUpdateWorker(
201
386
  errorSummary: `new version installed but service restart failed: ${message}`,
202
387
  });
203
388
  } finally {
204
- logStream.end();
389
+ await closeLogStream(logStream);
205
390
  }
206
391
  }
207
392
 
208
393
  if (process.env.CODER_STUDIO_UPDATE_STATE_PATH) {
209
- void runUpdateWorker().catch((error) => {
394
+ const run =
395
+ readWorkerMode(process.env) === RESTART_HANDOFF_MODE ? runRestartHandoff : runUpdateWorker;
396
+
397
+ void run().catch((error) => {
210
398
  console.error("[update-worker]", error);
211
399
  process.exitCode = 1;
212
400
  });