portless-studio 0.1.3 → 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.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/mcp/studio-tools.js +36 -2
  3. package/dist/cli/studio-items.js +2 -0
  4. package/dist/core/api/contract.js +6 -1
  5. package/dist/core/config/studio-config.js +83 -0
  6. package/dist/core/terminals/terminal-history.js +53 -0
  7. package/dist/core/terminals/terminal.js +0 -4
  8. package/dist/server/application/port-discovery.js +1 -0
  9. package/dist/server/application/stop-process.js +44 -3
  10. package/dist/server/application/studio-runtime.js +10 -0
  11. package/dist/server/application/terminals.js +267 -53
  12. package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
  13. package/dist/server/infrastructure/system/dev-process-tree.js +70 -0
  14. package/dist/server/infrastructure/system/linux-ports.js +31 -1
  15. package/dist/server/infrastructure/system/macos-ports.js +31 -2
  16. package/dist/server/infrastructure/system/port-closed.js +15 -0
  17. package/dist/server/infrastructure/system/system-port-scanner.js +7 -4
  18. package/dist/server/infrastructure/system/system-process-control.js +44 -0
  19. package/dist/server/infrastructure/system/windows-ports.js +9 -1
  20. package/dist/server/interface/http/http-server.js +5 -1
  21. package/dist/server/interface/http/static-files.js +6 -2
  22. package/dist/server/main.js +14 -2
  23. package/dist/web/assets/index-DhggsQyU.js +23 -0
  24. package/dist/web/assets/index-Q29k6Jdf.css +1 -0
  25. package/dist/web/index.html +2 -2
  26. package/package.json +1 -1
  27. package/dist/web/assets/index-9WPxv6pu.js +0 -17
  28. package/dist/web/assets/index-Bckc4cIr.css +0 -1
@@ -1,5 +1,6 @@
1
1
  import { err, ok } from "../../core/result.js";
2
2
  import { OutputBuffer } from "../../core/terminals/output-buffer.js";
3
+ import { rememberCommand, } from "../../core/terminals/terminal-history.js";
3
4
  import { appError } from "./app-error.js";
4
5
  const DEFAULT_MAX_OUTPUT = 2 * 1024 * 1024;
5
6
  const DEFAULT_GRACE_MS = 3_000;
@@ -9,6 +10,13 @@ const DEFAULT_COLS = 120;
9
10
  const DEFAULT_ROWS = 30;
10
11
  const MAX_COMMAND_CHARS = 8 * 1024;
11
12
  const MAX_TITLE_CHARS = 60;
13
+ const DEFAULT_PERSIST_DELAY_MS = 300;
14
+ /**
15
+ * A command that ended this shortly before Studio stopped counts as running then: a service
16
+ * manager stops Studio's commands together with Studio, and their exits can arrive first.
17
+ */
18
+ const RECENT_EXIT_MS = 2_000;
19
+ const RESTART_NOTE = "\r\n[Studio] Stopped by a Studio restart. Run it again to start it.\r\n";
12
20
  /**
13
21
  * The terminals Studio runs. Each keeps its last `maxOutputChars` of output, numbered so a client
14
22
  * can resume where it stopped. From a page, commands run only in folders Studio shows; a local
@@ -25,6 +33,11 @@ export class TerminalManager {
25
33
  #namingTimeoutMs;
26
34
  #processTree;
27
35
  #homeDir;
36
+ #history;
37
+ #persistDelayMs;
38
+ #recent = null;
39
+ #persistTimer;
40
+ #closing = false;
28
41
  #entries = new Map();
29
42
  #watchers = new Set();
30
43
  /** Folders a local program ran a terminal in. */
@@ -45,6 +58,8 @@ export class TerminalManager {
45
58
  this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
46
59
  this.#processTree = options.processTree;
47
60
  this.#homeDir = options.homeDir;
61
+ this.#history = options.history;
62
+ this.#persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS;
48
63
  }
49
64
  get mode() {
50
65
  return this.#spawner.mode;
@@ -73,6 +88,7 @@ export class TerminalManager {
73
88
  const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
74
89
  entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
75
90
  this.#publishStatus(entry);
91
+ this.#schedulePersist();
76
92
  return ok({ ...entry.info });
77
93
  }
78
94
  async create(request, origin) {
@@ -87,69 +103,97 @@ export class TerminalManager {
87
103
  return cwd;
88
104
  if (origin.trusted)
89
105
  this.#trustedFolders.add(cwd.value);
90
- const id = `t${this.#nextId++}`;
91
- const cols = readSize(request.cols, DEFAULT_COLS);
92
- const rows = readSize(request.rows, DEFAULT_ROWS);
93
106
  const naming = readNaming(request.name);
94
- const defaultTitle = (command.value || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
95
- const title = request.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
96
107
  const project = await this.#projectOf(cwd.value);
97
- const entry = {
98
- defaultTitle,
99
- info: {
100
- id,
101
- title,
102
- cwd: cwd.value,
103
- command: command.value ?? "",
104
- project: project?.name ?? null,
105
- worktree: project?.worktree ?? null,
106
- branch: project?.branch ?? null,
107
- ports: [],
108
- status: "running",
109
- exitCode: null,
110
- startedAt: this.#now(),
111
- endedAt: null,
112
- pid: null,
113
- mode: this.#spawner.mode,
114
- cols,
115
- rows,
116
- nextSeq: 0,
117
- naming,
118
- },
119
- request: {
120
- command: command.value,
121
- cwd: cwd.value,
122
- cols,
123
- rows,
124
- env: {
125
- TERM: "xterm-256color",
126
- COLUMNS: String(cols),
127
- LINES: String(rows),
128
- PORTLESS_STUDIO_TERMINAL: id,
129
- },
130
- },
131
- buffer: new OutputBuffer(this.#maxOutput),
132
- process: null,
133
- stopTimers: [],
134
- watchers: new Set(),
135
- baseline: null,
136
- namingTimer: undefined,
137
- };
108
+ const entry = this.#newEntry({
109
+ id: `t${this.#nextId++}`,
110
+ command: command.value,
111
+ cwd: cwd.value,
112
+ title: request.title,
113
+ cols: readSize(request.cols, DEFAULT_COLS),
114
+ rows: readSize(request.rows, DEFAULT_ROWS),
115
+ naming,
116
+ project,
117
+ startedBy: origin.trusted ? "program" : "person",
118
+ startedAt: this.#now(),
119
+ });
138
120
  if (naming)
139
121
  entry.baseline = await this.#portsInUse();
140
122
  const started = await this.#spawn(entry);
141
123
  if (!started.ok)
142
124
  return started;
143
- this.#entries.set(id, entry);
125
+ this.#entries.set(entry.info.id, entry);
126
+ if (command.value) {
127
+ this.#recent = rememberCommand(this.#recentCommands(), cwd.value, command.value);
128
+ }
144
129
  if (naming)
145
130
  this.#startNaming(entry);
146
131
  else
147
132
  this.#updateWorkspaceWatch();
148
133
  this.#changed();
134
+ this.#schedulePersist();
149
135
  if (this.#watchers.size > 0)
150
136
  void this.#refreshPorts();
151
137
  return ok({ ...entry.info });
152
138
  }
139
+ /** The last commands run in each folder, newest first. */
140
+ recentCommands() {
141
+ return Object.fromEntries(Object.entries(this.#recentCommands()).map(([folder, list]) => [folder, [...list]]));
142
+ }
143
+ /**
144
+ * Brings back the terminals that were running when Studio last stopped: listed as stopped by a
145
+ * restart (`ask`), started again unless they are a plain shell (`always`), or forgotten
146
+ * (`never`). Folders that no longer exist are skipped. Call it once, on start.
147
+ */
148
+ async restore(mode) {
149
+ if (this.#closing)
150
+ return;
151
+ const saved = this.#history?.read().running ?? [];
152
+ if (mode === "never" || saved.length === 0) {
153
+ if (saved.length > 0)
154
+ this.#persistNow();
155
+ return;
156
+ }
157
+ const restored = [];
158
+ for (const item of saved) {
159
+ if (this.#entries.size >= this.#maxTerminals)
160
+ break;
161
+ const cwd = this.#folders.directory(item.cwd);
162
+ if (cwd === null)
163
+ continue;
164
+ if (item.startedBy === "program")
165
+ this.#trustedFolders.add(cwd);
166
+ const entry = this.#newEntry({
167
+ id: `t${this.#nextId++}`,
168
+ command: item.command || null,
169
+ cwd,
170
+ title: item.title,
171
+ cols: DEFAULT_COLS,
172
+ rows: DEFAULT_ROWS,
173
+ naming: null,
174
+ project: { name: item.project ?? "", worktree: item.worktree, branch: item.branch },
175
+ startedBy: item.startedBy,
176
+ startedAt: item.startedAt,
177
+ });
178
+ if (item.project === null) {
179
+ entry.info = { ...entry.info, project: null, worktree: null, branch: null };
180
+ }
181
+ entry.info = { ...entry.info, status: "exited", stoppedByRestart: true };
182
+ this.#append(entry, RESTART_NOTE);
183
+ this.#entries.set(entry.info.id, entry);
184
+ restored.push(entry);
185
+ }
186
+ this.#changed();
187
+ this.#persistNow();
188
+ if (mode !== "always")
189
+ return;
190
+ for (const entry of restored) {
191
+ if (this.#closing)
192
+ return;
193
+ if (entry.info.command !== "")
194
+ await this.restart(entry.info.id);
195
+ }
196
+ }
153
197
  input(id, text) {
154
198
  const entry = this.#entries.get(id);
155
199
  if (!entry)
@@ -164,29 +208,86 @@ export class TerminalManager {
164
208
  const entry = this.#entries.get(id);
165
209
  if (!entry)
166
210
  return notFound(id);
211
+ entry.restartGeneration += 1;
167
212
  if (!entry.process || entry.info.status !== "running")
168
213
  return ok({ ...entry.info });
214
+ entry.stoppedOnPurpose = true;
169
215
  this.#escalate(entry, entry.process);
170
216
  return ok({ ...entry.info });
171
217
  }
172
218
  /** Runs the same command again; stops the current one first and waits for it to end. */
173
- async restart(id) {
219
+ restart(id) {
174
220
  const entry = this.#entries.get(id);
175
221
  if (!entry)
176
- 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."));
177
249
  const current = entry.process;
178
250
  if (current && entry.info.status === "running") {
179
251
  const ended = new Promise((resolve) => current.onExit(() => resolve()));
252
+ entry.stoppedOnPurpose = true;
180
253
  this.#escalate(entry, current);
181
254
  await ended;
182
255
  }
256
+ if (!stillWanted())
257
+ return cancelled();
183
258
  this.#append(entry, `\r\n[Studio] restarted: ${entry.info.command || "shell"}\r\n`);
184
- const started = await this.#spawn(entry);
259
+ if (entry.info.project === null) {
260
+ const project = await this.#projectOf(entry.info.cwd);
261
+ if (project) {
262
+ entry.info = {
263
+ ...entry.info,
264
+ project: project.name,
265
+ worktree: project.worktree ?? null,
266
+ branch: project.branch ?? null,
267
+ };
268
+ }
269
+ }
270
+ if (!stillWanted())
271
+ return cancelled();
272
+ const started = await this.#spawn(entry, stillWanted);
185
273
  if (!started.ok)
186
274
  return started;
187
- entry.info = { ...entry.info, status: "running", exitCode: null, endedAt: null };
275
+ if (!stillWanted()) {
276
+ entry.process?.kill();
277
+ entry.process = null;
278
+ return cancelled();
279
+ }
280
+ entry.stoppedOnPurpose = false;
281
+ entry.info = {
282
+ ...entry.info,
283
+ status: "running",
284
+ exitCode: null,
285
+ endedAt: null,
286
+ stoppedByRestart: false,
287
+ };
188
288
  entry.info.startedAt = this.#now();
189
289
  this.#publishStatus(entry);
290
+ this.#schedulePersist();
190
291
  this.#updateWorkspaceWatch();
191
292
  if (this.#watchers.size > 0)
192
293
  void this.#refreshPorts();
@@ -205,6 +306,7 @@ export class TerminalManager {
205
306
  entry.watchers.clear();
206
307
  this.#updateWorkspaceWatch();
207
308
  this.#changed();
309
+ this.#schedulePersist();
208
310
  return ok(undefined);
209
311
  }
210
312
  read(id, from, maxChars) {
@@ -225,8 +327,16 @@ export class TerminalManager {
225
327
  entry.watchers.add(watcher);
226
328
  return ok(() => entry.watchers.delete(watcher));
227
329
  }
228
- /** Studio is stopping: every command gets SIGTERM and SIGKILL right away. */
330
+ /**
331
+ * Studio is stopping: the terminals still running are written down for the next start, then
332
+ * every command gets SIGTERM and SIGKILL right away.
333
+ */
229
334
  closeAll() {
335
+ if (!this.#closing) {
336
+ this.#persistNow(true);
337
+ this.#closing = true;
338
+ clearTimeout(this.#persistTimer);
339
+ }
230
340
  for (const entry of this.#entries.values()) {
231
341
  this.#clearTimers(entry);
232
342
  if (entry.process && entry.info.status === "running") {
@@ -237,7 +347,7 @@ export class TerminalManager {
237
347
  this.#stopWorkspaceWatch?.();
238
348
  this.#stopWorkspaceWatch = undefined;
239
349
  }
240
- async #spawn(entry) {
350
+ async #spawn(entry, stillWanted) {
241
351
  let child;
242
352
  try {
243
353
  child = await this.#spawner.spawn(entry.request);
@@ -245,6 +355,10 @@ export class TerminalManager {
245
355
  catch (error) {
246
356
  return err(appError("command_failed", `Could not start it: ${error.message}`));
247
357
  }
358
+ if (stillWanted && !stillWanted()) {
359
+ child.kill();
360
+ return err(appError("terminal_not_running", "The restart was cancelled."));
361
+ }
248
362
  entry.process = child;
249
363
  entry.info.pid = child.pid;
250
364
  entry.info.mode = this.#spawner.mode;
@@ -267,10 +381,110 @@ export class TerminalManager {
267
381
  }
268
382
  entry.info.ports = [];
269
383
  this.#publishStatus(entry);
384
+ this.#schedulePersist();
270
385
  this.#updateWorkspaceWatch();
271
386
  });
272
387
  return ok(undefined);
273
388
  }
389
+ #newEntry(fields) {
390
+ const defaultTitle = (fields.command || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
391
+ const title = fields.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
392
+ const { id, cols, rows } = fields;
393
+ return {
394
+ defaultTitle,
395
+ info: {
396
+ id,
397
+ title,
398
+ cwd: fields.cwd,
399
+ command: fields.command ?? "",
400
+ project: fields.project?.name ?? null,
401
+ worktree: fields.project?.worktree ?? null,
402
+ branch: fields.project?.branch ?? null,
403
+ ports: [],
404
+ status: "running",
405
+ exitCode: null,
406
+ startedAt: fields.startedAt,
407
+ endedAt: null,
408
+ pid: null,
409
+ mode: this.#spawner.mode,
410
+ cols,
411
+ rows,
412
+ nextSeq: 0,
413
+ naming: fields.naming,
414
+ startedBy: fields.startedBy,
415
+ stoppedByRestart: false,
416
+ },
417
+ request: {
418
+ command: fields.command,
419
+ cwd: fields.cwd,
420
+ cols,
421
+ rows,
422
+ env: {
423
+ TERM: "xterm-256color",
424
+ COLUMNS: String(cols),
425
+ LINES: String(rows),
426
+ PORTLESS_STUDIO_TERMINAL: id,
427
+ },
428
+ },
429
+ buffer: new OutputBuffer(this.#maxOutput),
430
+ process: null,
431
+ stopTimers: [],
432
+ watchers: new Set(),
433
+ baseline: null,
434
+ namingTimer: undefined,
435
+ stoppedOnPurpose: false,
436
+ restartPromise: null,
437
+ restartGeneration: 0,
438
+ };
439
+ }
440
+ #recentCommands() {
441
+ this.#recent ??= this.#history?.read().recentCommands ?? {};
442
+ return this.#recent;
443
+ }
444
+ #schedulePersist() {
445
+ if (!this.#history || this.#closing || this.#persistTimer)
446
+ return;
447
+ this.#persistTimer = setTimeout(() => {
448
+ this.#persistTimer = undefined;
449
+ this.#persistNow();
450
+ }, this.#persistDelayMs);
451
+ this.#persistTimer.unref?.();
452
+ }
453
+ /**
454
+ * Writes the terminals that run and the recent commands. When Studio stops, commands that just
455
+ * ended without anyone asking count as running too.
456
+ */
457
+ #persistNow(stopping = false) {
458
+ const history = this.#history;
459
+ if (!history || this.#closing)
460
+ return;
461
+ clearTimeout(this.#persistTimer);
462
+ this.#persistTimer = undefined;
463
+ const now = this.#now();
464
+ const running = [...this.#entries.values()]
465
+ .filter((entry) => entry.info.status === "running" ||
466
+ entry.info.stoppedByRestart === true ||
467
+ (stopping &&
468
+ !entry.stoppedOnPurpose &&
469
+ entry.info.endedAt !== null &&
470
+ now - entry.info.endedAt <= RECENT_EXIT_MS))
471
+ .map(({ info }) => ({
472
+ command: info.command,
473
+ cwd: info.cwd,
474
+ title: info.title,
475
+ startedAt: info.startedAt,
476
+ startedBy: info.startedBy ?? "person",
477
+ project: info.project,
478
+ worktree: info.worktree,
479
+ branch: info.branch,
480
+ }));
481
+ try {
482
+ history.write({ running, recentCommands: this.#recentCommands() });
483
+ }
484
+ catch {
485
+ // A full disk or a read-only config folder: terminals keep working, they just are not kept.
486
+ }
487
+ }
274
488
  #escalate(entry, child) {
275
489
  this.#clearTimers(entry);
276
490
  child.interrupt();
@@ -0,0 +1,30 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parseTerminalHistory, } from "../../../core/terminals/terminal-history.js";
4
+ import { writeFileAtomic } from "./atomic-write.js";
5
+ const FILE_NAME = "terminals.json";
6
+ /** `terminals.json` in the config file's folder. */
7
+ export function terminalHistoryPathFor(configPath) {
8
+ return path.join(path.dirname(configPath), FILE_NAME);
9
+ }
10
+ /**
11
+ * The terminals running when Studio stopped and the recent commands per folder, as
12
+ * `{ "running": [...], "recentCommands": { "<folder>": [...] } }`. Studio writes it on its own.
13
+ */
14
+ export class JsonTerminalHistoryStore {
15
+ path;
16
+ constructor(filePath) {
17
+ this.path = filePath;
18
+ }
19
+ read() {
20
+ try {
21
+ return parseTerminalHistory(JSON.parse(fs.readFileSync(this.path, "utf8")));
22
+ }
23
+ catch {
24
+ return parseTerminalHistory(null);
25
+ }
26
+ }
27
+ write(history) {
28
+ writeFileAtomic(this.path, `${JSON.stringify(history, null, 2)}\n`);
29
+ }
30
+ }
@@ -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
+ }
@@ -19,22 +19,51 @@ export function parseProcNetTcp(text) {
19
19
  }
20
20
  return sockets;
21
21
  }
22
+ /** Clock ticks per second, the unit of `/proc/<pid>/stat` times; 100 on every common kernel. */
23
+ export const DEFAULT_CLOCK_TICKS = 100;
24
+ /**
25
+ * When a process started, in epoch milliseconds, from field 22 of `/proc/<pid>/stat` (clock ticks
26
+ * since boot) and `btime` in `/proc/stat` (boot time in epoch seconds). Null when unreadable.
27
+ */
28
+ export function processStartTime(stat, procStat, ticksPerSecond) {
29
+ if (stat === null || procStat === null || ticksPerSecond <= 0)
30
+ return null;
31
+ // The command name in field 2 may hold spaces and parentheses, so count fields after the last ")".
32
+ const fields = stat
33
+ .slice(stat.lastIndexOf(")") + 1)
34
+ .trim()
35
+ .split(/\s+/);
36
+ const ticks = Number(fields[19]);
37
+ const boot = Number(/^btime\s+(\d+)$/m.exec(procStat)?.[1]);
38
+ if (!Number.isFinite(ticks) || !Number.isFinite(boot) || boot <= 0)
39
+ return null;
40
+ return Math.round(boot * 1000 + (ticks * 1000) / ticksPerSecond);
41
+ }
42
+ /** `getconf CLK_TCK`, or the usual 100 when it cannot run. */
43
+ export async function readClockTicks(io) {
44
+ const answer = await io.run("getconf", ["CLK_TCK"]).catch(() => null);
45
+ const ticks = Number(answer?.stdout.trim());
46
+ return Number.isInteger(ticks) && ticks > 0 ? ticks : DEFAULT_CLOCK_TICKS;
47
+ }
22
48
  /**
23
49
  * Lists listening ports from `/proc`. A socket is linked to its process through the
24
50
  * `/proc/<pid>/fd/*` links; processes of other users cannot be read and stay unknown.
25
51
  */
26
- export async function listLinuxPorts(io) {
52
+ export async function listLinuxPorts(io, ticksPerSecond = async () => DEFAULT_CLOCK_TICKS) {
27
53
  const sockets = [
28
54
  ...parseProcNetTcp(io.readFile("/proc/net/tcp") ?? ""),
29
55
  ...parseProcNetTcp(io.readFile("/proc/net/tcp6") ?? ""),
30
56
  ];
31
57
  const pids = pidsByInode(io, new Set(sockets.map((socket) => socket.inode)));
58
+ const ticks = pids.size > 0 ? await ticksPerSecond() : DEFAULT_CLOCK_TICKS;
59
+ const procStat = pids.size > 0 ? io.readFile("/proc/stat") : null;
32
60
  const byPort = new Map();
33
61
  for (const socket of sockets) {
34
62
  const known = byPort.get(socket.port);
35
63
  if (known?.pid != null)
36
64
  continue;
37
65
  const pid = pids.get(socket.inode) ?? null;
66
+ const startedAt = pid === null ? null : processStartTime(io.readFile(`/proc/${pid}/stat`), procStat, ticks);
38
67
  byPort.set(socket.port, {
39
68
  port: socket.port,
40
69
  pid,
@@ -43,6 +72,7 @@ export async function listLinuxPorts(io) {
43
72
  executable: pid === null
44
73
  ? null
45
74
  : (io.readLink(`/proc/${pid}/exe`)?.replace(DELETED_SUFFIX, "") ?? null),
75
+ ...(startedAt === null ? {} : { startedAt }),
46
76
  });
47
77
  }
48
78
  return [...byPort.values()].sort((a, b) => a.port - b.port);
@@ -48,28 +48,57 @@ export function parsePsExecutables(text) {
48
48
  }
49
49
  return executables;
50
50
  }
51
+ /**
52
+ * Reads `ps -o pid=,etime= -p ...` into pid -> start in epoch milliseconds. `etime` is the time
53
+ * since the process started as `[[dd-]hh:]mm:ss`, which unlike `lstart` reads the same in every
54
+ * locale.
55
+ */
56
+ export function parsePsStartTimes(text, now) {
57
+ const starts = new Map();
58
+ for (const line of text.split("\n")) {
59
+ const match = /^\s*(\d+)\s+(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s*$/.exec(line);
60
+ if (!match)
61
+ continue;
62
+ const [, pid, days, hours, minutes, seconds] = match;
63
+ const elapsed = ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
64
+ starts.set(Number(pid), now - elapsed * 1000);
65
+ }
66
+ return starts;
67
+ }
51
68
  /** Lists listening ports with `lsof` and `ps`, which ship with macOS. */
52
- export async function listMacosPorts(io) {
69
+ export async function listMacosPorts(io, starts = new Map(), now = Date.now) {
53
70
  const listen = await io.run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"]);
54
71
  const rows = parseLsofListen(listen.stdout);
55
72
  if (rows.length === 0)
56
73
  return [];
57
74
  const pids = [...new Set(rows.map((row) => row.pid))];
58
- const [cwd, ps] = await Promise.all([
75
+ for (const pid of starts.keys())
76
+ if (!pids.includes(pid))
77
+ starts.delete(pid);
78
+ const unknownStarts = pids.filter((pid) => !starts.has(pid));
79
+ const [cwd, ps, elapsed] = await Promise.all([
59
80
  io.run("lsof", ["-a", "-d", "cwd", "-p", pids.join(","), "-F", "pn"]),
60
81
  // -ww: without it ps cuts long paths at the terminal width.
61
82
  io.run("ps", ["-ww", "-o", "pid=,comm=", "-p", pids.join(",")]),
83
+ unknownStarts.length === 0
84
+ ? null
85
+ : io.run("ps", ["-o", "pid=,etime=", "-p", unknownStarts.join(",")]),
62
86
  ]);
87
+ const read = parsePsStartTimes(elapsed?.stdout ?? "", now());
88
+ for (const pid of unknownStarts)
89
+ starts.set(pid, read.get(pid) ?? null);
63
90
  const cwds = parseLsofCwds(cwd.stdout);
64
91
  const executables = parsePsExecutables(ps.stdout);
65
92
  const byPort = new Map();
66
93
  for (const row of rows) {
67
94
  if (byPort.has(row.port))
68
95
  continue;
96
+ const startedAt = starts.get(row.pid) ?? null;
69
97
  byPort.set(row.port, {
70
98
  ...row,
71
99
  cwd: cwds.get(row.pid) ?? null,
72
100
  executable: executables.get(row.pid) ?? null,
101
+ ...(startedAt === null ? {} : { startedAt }),
73
102
  });
74
103
  }
75
104
  return [...byPort.values()].sort((a, b) => a.port - b.port);
@@ -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
+ }