@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.
- package/CHANGELOG.md +16 -0
- package/dist/esm/bin.mjs +460 -91
- package/dist/esm/bin.mjs.map +4 -4
- package/dist/esm/server-runner.mjs +406 -67
- package/dist/esm/server-runner.mjs.map +4 -4
- package/dist/esm/update-worker.mjs +145 -6
- package/dist/esm/update-worker.mjs.map +2 -2
- package/dist/web/assets/components-C4SKshs2.js +110 -0
- package/dist/web/assets/components-C4SKshs2.js.map +1 -0
- package/dist/web/assets/{components-AKM1pxhf.css → components-CMahvybm.css} +1 -1
- package/dist/web/assets/{main-D3dXqSaA.js → main-CZuF2VZA.js} +2 -2
- package/dist/web/assets/{main-D3dXqSaA.js.map → main-CZuF2VZA.js.map} +1 -1
- package/dist/web/assets/{ui-preview-BGZz053-.js → ui-preview-DCeC0YmD.js} +3 -3
- package/dist/web/assets/{ui-preview-BGZz053-.js.map → ui-preview-DCeC0YmD.js.map} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/ui-preview.html +3 -3
- package/package.json +1 -1
- package/src/pm2-control.test.ts +48 -6
- package/src/pm2-control.ts +43 -2
- package/src/update-worker.test.ts +113 -12
- package/src/update-worker.ts +195 -7
- package/dist/web/assets/components-omWbMLvf.js +0 -110
- package/dist/web/assets/components-omWbMLvf.js.map +0 -1
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
16
|
+
<link rel="stylesheet" crossorigin href="/assets/components-CMahvybm.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
19
|
<div id="root"></div>
|
package/dist/web/ui-preview.html
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
package/src/pm2-control.test.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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) =>
|
package/src/pm2-control.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/src/update-worker.ts
CHANGED
|
@@ -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?: {
|
|
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
|
|
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
|
|
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
|
|
389
|
+
await closeLogStream(logStream);
|
|
205
390
|
}
|
|
206
391
|
}
|
|
207
392
|
|
|
208
393
|
if (process.env.CODER_STUDIO_UPDATE_STATE_PATH) {
|
|
209
|
-
|
|
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
|
});
|