portless-studio 0.1.4 → 0.1.5
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/dist/server/application/stop-process.js +44 -3
- package/dist/server/application/studio-runtime.js +9 -0
- package/dist/server/application/terminals.js +46 -4
- package/dist/server/infrastructure/system/dev-process-tree.js +70 -0
- package/dist/server/infrastructure/system/port-closed.js +15 -0
- package/dist/server/infrastructure/system/system-process-control.js +44 -0
- package/dist/server/interface/http/http-server.js +1 -1
- package/dist/server/main.js +6 -2
- package/dist/web/assets/index-DhggsQyU.js +23 -0
- package/dist/web/assets/index-Q29k6Jdf.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CZVCj1cX.css +0 -1
- package/dist/web/assets/index-DODZ4DTe.js +0 -23
|
@@ -5,7 +5,7 @@ import { appError } from "./app-error.js";
|
|
|
5
5
|
* Stops a dev server from its row. Only a process of the current user that listens on a port the
|
|
6
6
|
* UI shows can be stopped, so the endpoint cannot be used to kill anything else on the machine.
|
|
7
7
|
*/
|
|
8
|
-
export async function stopProcess(input, deps) {
|
|
8
|
+
export async function stopProcess(input, deps, all = false) {
|
|
9
9
|
if (!/^\d+$/.test(input) || Number(input) < 1) {
|
|
10
10
|
return err(appError("invalid_request", "The pid must be a whole number above 0."));
|
|
11
11
|
}
|
|
@@ -19,6 +19,47 @@ export async function stopProcess(input, deps) {
|
|
|
19
19
|
const who = owner === "root" ? "root" : owner === "other" ? "another user" : "an unknown user";
|
|
20
20
|
return err(appError("process_not_allowed", `Process ${pid} on port ${port} belongs to ${who}; stop it from a terminal.`, killCommand(deps.platform ?? process.platform, [pid], true)));
|
|
21
21
|
}
|
|
22
|
-
const
|
|
23
|
-
|
|
22
|
+
const targets = all ? await deps.processes.devTree?.(pid) : [pid];
|
|
23
|
+
if (!targets?.length) {
|
|
24
|
+
return err(appError("unsupported", "Could not identify the development command tree. Stop it from its terminal."));
|
|
25
|
+
}
|
|
26
|
+
// Check the entire plan before sending any signal. A tree must never include Studio.
|
|
27
|
+
for (const target of targets) {
|
|
28
|
+
if (target === deps.selfPid || (await deps.processes.ownerOf(target)) !== "you") {
|
|
29
|
+
return err(appError("process_not_allowed", "The development command tree includes a protected process. Stop it from its terminal."));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const watchedPorts = new Set([port]);
|
|
33
|
+
for (const listener of (await deps.listeners?.()) ?? []) {
|
|
34
|
+
if (listener.pid !== null && targets.includes(listener.pid))
|
|
35
|
+
watchedPorts.add(listener.port);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
for (const target of targets) {
|
|
39
|
+
const stopped = await deps.processes.terminate(target);
|
|
40
|
+
if (!stopped.ok)
|
|
41
|
+
return stopped;
|
|
42
|
+
}
|
|
43
|
+
if (deps.listeners || deps.portClosed) {
|
|
44
|
+
// Observe a settling window too: a supervisor may immediately restart the listener.
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, deps.settleMs ?? 750));
|
|
46
|
+
const listeners = (await deps.listeners?.()) ?? [];
|
|
47
|
+
for (const watchedPort of watchedPorts) {
|
|
48
|
+
const survivor = listeners.find((item) => item.port === watchedPort);
|
|
49
|
+
const closed = deps.portClosed ? await deps.portClosed(watchedPort) : !survivor;
|
|
50
|
+
if (!closed) {
|
|
51
|
+
const reason = !survivor
|
|
52
|
+
? "The port could not be confirmed closed"
|
|
53
|
+
: targets.includes(survivor.pid ?? -1)
|
|
54
|
+
? `Process ${survivor.pid} is still listening`
|
|
55
|
+
: `Process ${survivor.pid ?? "unknown"} is now listening; a parent or another program may have restarted the server`;
|
|
56
|
+
return err(appError("port_in_use", `Port ${watchedPort} did not stay closed. ${reason}. Try Stop all to stop the development command, or stop it from its terminal.`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ok({ pid, port });
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await deps.refresh?.();
|
|
64
|
+
}
|
|
24
65
|
}
|
|
@@ -196,6 +196,15 @@ export class StudioRuntime {
|
|
|
196
196
|
* pid -> port for every process listening on a detected or routed port, from a fresh scan.
|
|
197
197
|
* These are the only processes the UI may stop.
|
|
198
198
|
*/
|
|
199
|
+
/** Refresh listeners and health immediately after a process action and broadcast the snapshot. */
|
|
200
|
+
async refreshProcesses() {
|
|
201
|
+
// A scan started before the stop may still contain the listener that just exited.
|
|
202
|
+
await this.#scanning;
|
|
203
|
+
const state = await this.#registry.readSnapshot();
|
|
204
|
+
await this.#detect(state, await this.#currentConfig(), true);
|
|
205
|
+
await this.#probeRoutes(state.routes);
|
|
206
|
+
this.#notify();
|
|
207
|
+
}
|
|
199
208
|
async stoppablePids() {
|
|
200
209
|
const state = await this.#registry.readSnapshot();
|
|
201
210
|
const found = await this.#detect(state, await this.#currentConfig(), true);
|
|
@@ -208,6 +208,7 @@ export class TerminalManager {
|
|
|
208
208
|
const entry = this.#entries.get(id);
|
|
209
209
|
if (!entry)
|
|
210
210
|
return notFound(id);
|
|
211
|
+
entry.restartGeneration += 1;
|
|
211
212
|
if (!entry.process || entry.info.status !== "running")
|
|
212
213
|
return ok({ ...entry.info });
|
|
213
214
|
entry.stoppedOnPurpose = true;
|
|
@@ -215,10 +216,36 @@ export class TerminalManager {
|
|
|
215
216
|
return ok({ ...entry.info });
|
|
216
217
|
}
|
|
217
218
|
/** Runs the same command again; stops the current one first and waits for it to end. */
|
|
218
|
-
|
|
219
|
+
restart(id) {
|
|
219
220
|
const entry = this.#entries.get(id);
|
|
220
221
|
if (!entry)
|
|
221
|
-
return notFound(id);
|
|
222
|
+
return Promise.resolve(notFound(id));
|
|
223
|
+
if (this.#closing)
|
|
224
|
+
return Promise.resolve(err(appError("command_failed", "Studio is stopping.")));
|
|
225
|
+
if (entry.restartPromise)
|
|
226
|
+
return entry.restartPromise;
|
|
227
|
+
let resolve;
|
|
228
|
+
let reject;
|
|
229
|
+
const pending = new Promise((done, failed) => {
|
|
230
|
+
resolve = done;
|
|
231
|
+
reject = failed;
|
|
232
|
+
});
|
|
233
|
+
// Register before interrupting: an exit callback can synchronously request another restart.
|
|
234
|
+
entry.restartPromise = pending;
|
|
235
|
+
void this.#restartEntry(entry, entry.restartGeneration).then((result) => {
|
|
236
|
+
entry.restartPromise = null;
|
|
237
|
+
resolve(result);
|
|
238
|
+
}, (error) => {
|
|
239
|
+
entry.restartPromise = null;
|
|
240
|
+
reject(error);
|
|
241
|
+
});
|
|
242
|
+
return pending;
|
|
243
|
+
}
|
|
244
|
+
async #restartEntry(entry, generation) {
|
|
245
|
+
const stillWanted = () => !this.#closing &&
|
|
246
|
+
this.#entries.get(entry.info.id) === entry &&
|
|
247
|
+
entry.restartGeneration === generation;
|
|
248
|
+
const cancelled = () => err(appError("terminal_not_running", "The restart was cancelled."));
|
|
222
249
|
const current = entry.process;
|
|
223
250
|
if (current && entry.info.status === "running") {
|
|
224
251
|
const ended = new Promise((resolve) => current.onExit(() => resolve()));
|
|
@@ -226,6 +253,8 @@ export class TerminalManager {
|
|
|
226
253
|
this.#escalate(entry, current);
|
|
227
254
|
await ended;
|
|
228
255
|
}
|
|
256
|
+
if (!stillWanted())
|
|
257
|
+
return cancelled();
|
|
229
258
|
this.#append(entry, `\r\n[Studio] restarted: ${entry.info.command || "shell"}\r\n`);
|
|
230
259
|
if (entry.info.project === null) {
|
|
231
260
|
const project = await this.#projectOf(entry.info.cwd);
|
|
@@ -238,9 +267,16 @@ export class TerminalManager {
|
|
|
238
267
|
};
|
|
239
268
|
}
|
|
240
269
|
}
|
|
241
|
-
|
|
270
|
+
if (!stillWanted())
|
|
271
|
+
return cancelled();
|
|
272
|
+
const started = await this.#spawn(entry, stillWanted);
|
|
242
273
|
if (!started.ok)
|
|
243
274
|
return started;
|
|
275
|
+
if (!stillWanted()) {
|
|
276
|
+
entry.process?.kill();
|
|
277
|
+
entry.process = null;
|
|
278
|
+
return cancelled();
|
|
279
|
+
}
|
|
244
280
|
entry.stoppedOnPurpose = false;
|
|
245
281
|
entry.info = {
|
|
246
282
|
...entry.info,
|
|
@@ -311,7 +347,7 @@ export class TerminalManager {
|
|
|
311
347
|
this.#stopWorkspaceWatch?.();
|
|
312
348
|
this.#stopWorkspaceWatch = undefined;
|
|
313
349
|
}
|
|
314
|
-
async #spawn(entry) {
|
|
350
|
+
async #spawn(entry, stillWanted) {
|
|
315
351
|
let child;
|
|
316
352
|
try {
|
|
317
353
|
child = await this.#spawner.spawn(entry.request);
|
|
@@ -319,6 +355,10 @@ export class TerminalManager {
|
|
|
319
355
|
catch (error) {
|
|
320
356
|
return err(appError("command_failed", `Could not start it: ${error.message}`));
|
|
321
357
|
}
|
|
358
|
+
if (stillWanted && !stillWanted()) {
|
|
359
|
+
child.kill();
|
|
360
|
+
return err(appError("terminal_not_running", "The restart was cancelled."));
|
|
361
|
+
}
|
|
322
362
|
entry.process = child;
|
|
323
363
|
entry.info.pid = child.pid;
|
|
324
364
|
entry.info.mode = this.#spawner.mode;
|
|
@@ -393,6 +433,8 @@ export class TerminalManager {
|
|
|
393
433
|
baseline: null,
|
|
394
434
|
namingTimer: undefined,
|
|
395
435
|
stoppedOnPurpose: false,
|
|
436
|
+
restartPromise: null,
|
|
437
|
+
restartGeneration: 0,
|
|
396
438
|
};
|
|
397
439
|
}
|
|
398
440
|
#recentCommands() {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function devRunner(command) {
|
|
2
|
+
const words = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
|
|
3
|
+
const basename = (word) => word
|
|
4
|
+
.replace(/^['"]|['"]$/g, "")
|
|
5
|
+
.split(/[/\\]/)
|
|
6
|
+
.pop() ?? "";
|
|
7
|
+
const executable = basename(words[0] ?? "");
|
|
8
|
+
const script = /^(?:node|node.exe|bun|bun.exe)$/.test(executable)
|
|
9
|
+
? basename(words[1] ?? "")
|
|
10
|
+
: executable;
|
|
11
|
+
return /^(?:pnpm|npm(?:-cli)?|npx(?:-cli)?|yarn|bun|bunx|turbo|vite|next|astro|wrangler|ng|nuxt|remix|nodemon|tsx)(?:\.(?:c?js|mjs|exe|cmd))?$/i.test(script);
|
|
12
|
+
}
|
|
13
|
+
function commandShell(command) {
|
|
14
|
+
const words = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
|
|
15
|
+
const name = (words[0] ?? "")
|
|
16
|
+
.replace(/^['"]|['"]$/g, "")
|
|
17
|
+
.split(/[/\\]/)
|
|
18
|
+
.pop()
|
|
19
|
+
?.toLowerCase();
|
|
20
|
+
if (name === "cmd.exe" || name === "cmd") {
|
|
21
|
+
const commandAt = words.findIndex((word) => /^\/c$/i.test(word));
|
|
22
|
+
return (commandAt > 0 &&
|
|
23
|
+
commandAt < words.length - 1 &&
|
|
24
|
+
words.slice(1, commandAt).every((word) => /^\/[dsq]$/i.test(word)));
|
|
25
|
+
}
|
|
26
|
+
return /^(?:sh|bash|zsh|dash)$/.test(name ?? "") && words[1] === "-c";
|
|
27
|
+
}
|
|
28
|
+
/** Root first prevents supervisors restarting children; the remaining children are deepest first. */
|
|
29
|
+
export function devProcessTree(pid, table) {
|
|
30
|
+
const byPid = new Map(table.map((entry) => [entry.pid, entry]));
|
|
31
|
+
let root = byPid.get(pid);
|
|
32
|
+
if (!root)
|
|
33
|
+
return [];
|
|
34
|
+
const seen = new Set([pid]);
|
|
35
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
36
|
+
let parent = byPid.get(root.parent);
|
|
37
|
+
if (!parent || parent.pid <= 1 || seen.has(parent.pid))
|
|
38
|
+
break;
|
|
39
|
+
seen.add(parent.pid);
|
|
40
|
+
if (commandShell(parent.command)) {
|
|
41
|
+
parent = byPid.get(parent.parent);
|
|
42
|
+
if (!parent || parent.pid <= 1 || seen.has(parent.pid))
|
|
43
|
+
break;
|
|
44
|
+
seen.add(parent.pid);
|
|
45
|
+
}
|
|
46
|
+
if (!devRunner(parent.command))
|
|
47
|
+
break;
|
|
48
|
+
root = parent;
|
|
49
|
+
}
|
|
50
|
+
const descendants = new Set([root.pid]);
|
|
51
|
+
const ordered = [root.pid];
|
|
52
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
53
|
+
const added = table.filter((entry) => !descendants.has(entry.pid) && descendants.has(entry.parent));
|
|
54
|
+
if (!added.length)
|
|
55
|
+
break;
|
|
56
|
+
for (const entry of added) {
|
|
57
|
+
descendants.add(entry.pid);
|
|
58
|
+
ordered.push(entry.pid);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [root.pid, ...ordered.slice(1).reverse()];
|
|
62
|
+
}
|
|
63
|
+
export function parseProcessTable(output) {
|
|
64
|
+
return output.split(/\r?\n/).flatMap((line) => {
|
|
65
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
66
|
+
return match
|
|
67
|
+
? [{ pid: Number(match[1]), parent: Number(match[2]), command: match[3] ?? "" }]
|
|
68
|
+
: [];
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
/** A refused TCP connection confirms closure; timeouts and unexpected errors do not. */
|
|
3
|
+
export async function portClosed(port) {
|
|
4
|
+
const answers = await Promise.all(["127.0.0.1", "::1"].map((host) => new Promise((resolve) => {
|
|
5
|
+
const socket = createConnection({ host, port });
|
|
6
|
+
const finish = (closed) => {
|
|
7
|
+
socket.destroy();
|
|
8
|
+
resolve(closed);
|
|
9
|
+
};
|
|
10
|
+
socket.once("connect", () => finish(false));
|
|
11
|
+
socket.once("error", (error) => finish(error.code === "ECONNREFUSED" || error.code === "EAFNOSUPPORT"));
|
|
12
|
+
socket.setTimeout(800, () => finish(false));
|
|
13
|
+
})));
|
|
14
|
+
return answers.every(Boolean);
|
|
15
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { err, ok } from "../../../core/result.js";
|
|
2
2
|
import { appError } from "../../application/app-error.js";
|
|
3
3
|
import { isProcessAlive } from "../portless/state-files.js";
|
|
4
|
+
import { parseStatParent } from "../terminal/process-tree.js";
|
|
5
|
+
import { devProcessTree, parseProcessTable } from "./dev-process-tree.js";
|
|
4
6
|
/** How long a dev server gets to shut down cleanly before it is forced. */
|
|
5
7
|
const GRACE_MS = 5000;
|
|
6
8
|
const POLL_MS = 100;
|
|
@@ -40,6 +42,48 @@ export class SystemProcessControl {
|
|
|
40
42
|
constructor(options) {
|
|
41
43
|
this.#options = options;
|
|
42
44
|
}
|
|
45
|
+
async devTree(pid) {
|
|
46
|
+
const { platform, io } = this.#options;
|
|
47
|
+
let table;
|
|
48
|
+
if (platform === "linux") {
|
|
49
|
+
table = io
|
|
50
|
+
.readDir("/proc")
|
|
51
|
+
.filter((name) => /^\d+$/.test(name))
|
|
52
|
+
.flatMap((name) => {
|
|
53
|
+
const stat = io.readFile(`/proc/${name}/stat`);
|
|
54
|
+
const command = io.readFile(`/proc/${name}/cmdline`);
|
|
55
|
+
const parent = stat === null ? null : parseStatParent(stat);
|
|
56
|
+
return parent === null || !command
|
|
57
|
+
? []
|
|
58
|
+
: [{ pid: Number(name), parent, command: command.replaceAll("\0", " ").trim() }];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (platform === "darwin") {
|
|
62
|
+
const answer = await io.run("ps", ["-ww", "-axo", "pid=,ppid=,args="]);
|
|
63
|
+
table = answer.exitCode === 0 ? parseProcessTable(answer.stdout) : [];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const answer = await io.run("powershell.exe", [
|
|
67
|
+
"-NoProfile",
|
|
68
|
+
"-NonInteractive",
|
|
69
|
+
"-Command",
|
|
70
|
+
"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
|
|
71
|
+
]);
|
|
72
|
+
try {
|
|
73
|
+
const rows = JSON.parse(answer.stdout);
|
|
74
|
+
table = (Array.isArray(rows) ? rows : [rows]).flatMap((row) => row &&
|
|
75
|
+
typeof row.ProcessId === "number" &&
|
|
76
|
+
typeof row.ParentProcessId === "number" &&
|
|
77
|
+
typeof row.CommandLine === "string"
|
|
78
|
+
? [{ pid: row.ProcessId, parent: row.ParentProcessId, command: row.CommandLine }]
|
|
79
|
+
: []);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
table = [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return devProcessTree(pid, table);
|
|
86
|
+
}
|
|
43
87
|
async ownerOf(pid) {
|
|
44
88
|
const { platform, io, uid, userName } = this.#options;
|
|
45
89
|
if (platform === "win32") {
|
|
@@ -172,7 +172,7 @@ export function createHttpServer(options) {
|
|
|
172
172
|
if (stop?.[1] && method === "POST") {
|
|
173
173
|
if (!options.stopProcess)
|
|
174
174
|
return sendAppError(res, unsupported);
|
|
175
|
-
const result = await options.stopProcess(stop[1]);
|
|
175
|
+
const result = await options.stopProcess(stop[1], query.get("scope") === "tree");
|
|
176
176
|
if (!result.ok)
|
|
177
177
|
return sendAppError(res, result.error);
|
|
178
178
|
res.writeHead(204, { "Cache-Control": "no-store" }).end();
|
package/dist/server/main.js
CHANGED
|
@@ -29,6 +29,7 @@ import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js
|
|
|
29
29
|
import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
|
|
30
30
|
import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
|
|
31
31
|
import { OsNotifier } from "./infrastructure/system/os-notifier.js";
|
|
32
|
+
import { portClosed } from "./infrastructure/system/port-closed.js";
|
|
32
33
|
import { startForwarder } from "./infrastructure/system/port-forwarder.js";
|
|
33
34
|
import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
|
|
34
35
|
import { nodeSystemIo } from "./infrastructure/system/system-io.js";
|
|
@@ -154,11 +155,14 @@ export function createStudioServices(options = {}) {
|
|
|
154
155
|
detectPorts: () => runtime.detectPorts(),
|
|
155
156
|
doctor,
|
|
156
157
|
controlProxy: proxy,
|
|
157
|
-
stopProcess: (pid) => stopProcess(pid, {
|
|
158
|
+
stopProcess: (pid, all) => stopProcess(pid, {
|
|
158
159
|
processes,
|
|
159
160
|
stoppablePids: () => runtime.stoppablePids(),
|
|
160
161
|
selfPid: process.pid,
|
|
161
|
-
|
|
162
|
+
listeners: () => scanner.scan(),
|
|
163
|
+
portClosed,
|
|
164
|
+
refresh: () => runtime.refreshProcesses(),
|
|
165
|
+
}, all),
|
|
162
166
|
stopContainer: (name) => stopContainer(name, containers),
|
|
163
167
|
studioService,
|
|
164
168
|
openWith,
|