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.
@@ -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 stopped = await deps.processes.terminate(pid);
23
- return stopped.ok ? ok({ pid, port }) : stopped;
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
- async restart(id) {
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
- const started = await this.#spawn(entry);
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();
@@ -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,