herdr-remote 0.2.30 → 0.2.32

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.
@@ -191,6 +191,7 @@ export class HostConnector {
191
191
  ? { shellProfile: 'git-bash' }
192
192
  : {}),
193
193
  arch: process.arch,
194
+ version: this.updates.runningVersion,
194
195
  terminalPalette: this.terminalPalette || null,
195
196
  terminalFont: this.font.publicFont(),
196
197
  capabilities: [
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { ensureDir } from 'herdr-remote-relay/state';
5
- import { pidAlive } from '../lib/process.js';
5
+ import { processStart, stillRunning } from '../lib/process.js';
6
6
  function readOwner(lockPath) {
7
7
  try {
8
8
  return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
@@ -19,21 +19,28 @@ function lockError(message, code) {
19
19
  }
20
20
  /**
21
21
  * Take the lock, or throw `HOST_ALREADY_RUNNING` when a live process holds it.
22
- * A lock left by a dead process is removed and taken over.
22
+ * A lock left by a dead process is removed and taken over — including one whose
23
+ * pid a reboot handed to an unrelated process, which would otherwise keep every
24
+ * later connector standing down.
23
25
  */
24
26
  export function acquireHostLock(lockPath, hostId) {
25
27
  ensureDir(path.dirname(lockPath));
26
28
  for (let attempt = 0; attempt < 2; attempt += 1) {
27
29
  try {
28
30
  const fd = fs.openSync(lockPath, 'wx', 0o600);
29
- fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId, startedAt: new Date().toISOString() })}\n`);
31
+ fs.writeFileSync(fd, `${JSON.stringify({
32
+ pid: process.pid,
33
+ processStart: processStart(process.pid),
34
+ hostId,
35
+ startedAt: new Date().toISOString(),
36
+ })}\n`);
30
37
  return fd;
31
38
  }
32
39
  catch (error) {
33
40
  if (error.code !== 'EEXIST')
34
41
  throw error;
35
42
  const owner = readOwner(lockPath);
36
- if (owner && pidAlive(owner.pid)) {
43
+ if (owner && stillRunning([owner]).length > 0) {
37
44
  throw lockError(`another host connector is already running (pid ${owner.pid})`, 'HOST_ALREADY_RUNNING');
38
45
  }
39
46
  try {
@@ -10,6 +10,7 @@ const UPDATE_RECHECK_MS = 60_000;
10
10
  export class UpdateReport {
11
11
  checkUpdate;
12
12
  readInstalledVersion;
13
+ /** The release this process runs, fixed at start. */
13
14
  runningVersion;
14
15
  send;
15
16
  checkedAt = 0;
@@ -4,19 +4,23 @@ import { spawn } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { ensureDir, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
7
- import { pidAlive } from '../lib/process.js';
7
+ import { processStart, stillRunning } from '../lib/process.js';
8
8
  import { logPath, stateDir } from '../paths.js';
9
9
  import { cliEntryPoint, serviceEnvironment } from './environment.js';
10
10
  function fallbackPidPath() {
11
11
  return path.join(stateDir(), 'supervisor.pid');
12
12
  }
13
- function readPid() {
13
+ /**
14
+ * The supervisor the pid file names, if it is still that process. The file
15
+ * outlives a reboot; its pid alone would then name something unrelated.
16
+ */
17
+ function runningPid() {
14
18
  const record = readJson(fallbackPidPath(), {});
15
- return Number.isInteger(record.pid) ? record.pid : null;
19
+ return stillRunning([record]).length > 0 ? record.pid : null;
16
20
  }
17
21
  function start() {
18
- const running = readPid();
19
- if (pidAlive(running))
22
+ const running = runningPid();
23
+ if (running !== null)
20
24
  return { ok: true, alreadyRunning: true, pid: running };
21
25
  ensureDir(stateDir());
22
26
  const logFd = fs.openSync(logPath('supervisor'), 'a');
@@ -30,7 +34,11 @@ function start() {
30
34
  stdio: ['ignore', logFd, logFd],
31
35
  });
32
36
  child.unref();
33
- writeJsonAtomic(fallbackPidPath(), { pid: child.pid, startedAt: new Date().toISOString() });
37
+ writeJsonAtomic(fallbackPidPath(), {
38
+ pid: child.pid,
39
+ processStart: child.pid ? processStart(child.pid) : null,
40
+ startedAt: new Date().toISOString(),
41
+ });
34
42
  return { ok: true, pid: child.pid, note: 'this fallback does not survive a reboot' };
35
43
  }
36
44
  finally {
@@ -38,8 +46,8 @@ function start() {
38
46
  }
39
47
  }
40
48
  function stopAndForget() {
41
- const pid = readPid();
42
- if (pid !== null && pidAlive(pid)) {
49
+ const pid = runningPid();
50
+ if (pid !== null) {
43
51
  try {
44
52
  process.kill(pid, 'SIGTERM');
45
53
  }
@@ -58,8 +66,8 @@ export function detachedLogsHint(platform = process.platform, logFile = logPath(
58
66
  export const detached = {
59
67
  name: 'supervisor',
60
68
  status() {
61
- const pid = readPid();
62
- const alive = pidAlive(pid);
69
+ const pid = runningPid();
70
+ const alive = pid !== null;
63
71
  return {
64
72
  manager: 'supervisor',
65
73
  installed: alive,
@@ -1,3 +1,10 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ const PROCESS_QUERY_TIMEOUT_MS = 10_000;
4
+ // Records written before start tokens existed carry only a pid. Until none are
5
+ // left, such a record is trusted only while its process runs one of our entry
6
+ // points: the relay, the host connector or `herdr-remote run`.
7
+ const OUR_ENTRY_POINT = /herdr-remote-relay\.js|connector[\\/]main\.js|herdr-remote\.js"?\s+run\b/;
1
8
  /** Whether `pid` names a running process, including one owned by another user. */
2
9
  export function pidAlive(pid) {
3
10
  if (!Number.isInteger(pid) || pid <= 0)
@@ -10,3 +17,116 @@ export function pidAlive(pid) {
10
17
  return error.code === 'EPERM';
11
18
  }
12
19
  }
20
+ function runCommand(command, args) {
21
+ const result = spawnSync(command, args, {
22
+ encoding: 'utf8',
23
+ // The same instant must print the same text whenever and wherever it is asked.
24
+ env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
25
+ stdio: ['ignore', 'pipe', 'ignore'],
26
+ timeout: PROCESS_QUERY_TIMEOUT_MS,
27
+ windowsHide: true,
28
+ });
29
+ // The exit code is not trusted: ps and PowerShell fail when any one pid is
30
+ // gone, yet still print the rest. A command that could not run prints nothing.
31
+ return typeof result.stdout === 'string' ? result.stdout : '';
32
+ }
33
+ /** `<pid> <value>` lines, as ps and our PowerShell queries print them. */
34
+ function parsePidLines(output) {
35
+ const details = new Map();
36
+ for (const line of output.split(/\r?\n/)) {
37
+ const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line);
38
+ if (match)
39
+ details.set(Number(match[1]), match[2]);
40
+ }
41
+ return details;
42
+ }
43
+ function linuxDetails(pids, detail, run) {
44
+ let bootId = '';
45
+ if (detail === 'start') {
46
+ try {
47
+ // Start times count clock ticks since boot, so they only identify a
48
+ // process together with the boot they were counted in.
49
+ bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
50
+ }
51
+ catch {
52
+ // A /proc without boot ids (some sandboxes) still has ps.
53
+ return psDetails(pids, detail, run);
54
+ }
55
+ }
56
+ const details = new Map();
57
+ for (const pid of pids) {
58
+ let text;
59
+ try {
60
+ text = fs.readFileSync(`/proc/${pid}/${detail === 'start' ? 'stat' : 'cmdline'}`, 'utf8');
61
+ }
62
+ catch {
63
+ // Not running, or hidden from us: either way it is not a process we can vouch for.
64
+ continue;
65
+ }
66
+ if (detail === 'command') {
67
+ details.set(pid, text.replaceAll('\0', ' ').trim());
68
+ continue;
69
+ }
70
+ // The command name before this may itself contain spaces and parentheses;
71
+ // after it, field 3 (state) comes first and field 22 is the start time.
72
+ const fields = text.slice(text.lastIndexOf(')') + 2).split(' ');
73
+ if (fields[19])
74
+ details.set(pid, `${bootId}:${fields[19]}`);
75
+ }
76
+ return details;
77
+ }
78
+ function psDetails(pids, detail, run) {
79
+ const column = detail === 'start' ? 'lstart' : 'command';
80
+ return parsePidLines(run('ps', ['-o', `pid=,${column}=`, '-p', pids.join(',')]));
81
+ }
82
+ function windowsDetails(pids, detail, run) {
83
+ const script = detail === 'start'
84
+ ? // StartTime is empty for processes we may not inspect; those are not ours anyway.
85
+ `Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | Where-Object StartTime | ForEach-Object { "$($_.Id) $($_.StartTime.ToFileTimeUtc())" }`
86
+ : `Get-CimInstance Win32_Process -Filter "${pids.map((pid) => `ProcessId=${pid}`).join(' OR ')}" | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }`;
87
+ return parsePidLines(run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]));
88
+ }
89
+ function processDetails(pids, detail, platform, run) {
90
+ // Dead pids are the common case after a clean stop, and ruling them out here
91
+ // saves spawning ps or PowerShell just to hear so.
92
+ const candidates = [...new Set(pids)].filter(pidAlive);
93
+ if (candidates.length === 0)
94
+ return new Map();
95
+ if (platform === 'linux')
96
+ return linuxDetails(candidates, detail, run);
97
+ if (platform === 'win32')
98
+ return windowsDetails(candidates, detail, run);
99
+ return psDetails(candidates, detail, run);
100
+ }
101
+ /**
102
+ * When each running process in `pids` started, as opaque tokens; pids that are
103
+ * not running, or that the system will not describe, are left out.
104
+ *
105
+ * A pid alone does not identify a process: the number goes to another process
106
+ * once this one exits, and after a reboot the same boot-time services land on
107
+ * the same numbers again. On Linux the number of a thread in another process
108
+ * even passes `pidAlive`. Recorded next to its pid, this token only ever
109
+ * matches the process it was read from.
110
+ */
111
+ export function processStarts(pids, platform = process.platform, run = runCommand) {
112
+ return processDetails(pids, 'start', platform, run);
113
+ }
114
+ /** The command line of each running process in `pids`, which the system is willing to show. */
115
+ export function processCommands(pids, platform = process.platform, run = runCommand) {
116
+ return processDetails(pids, 'command', platform, run);
117
+ }
118
+ export function processStart(pid, platform = process.platform) {
119
+ return processStarts([pid], platform).get(pid) ?? null;
120
+ }
121
+ /** The records that still name the very process they were taken from. */
122
+ export function stillRunning(records) {
123
+ const pidsOf = (list) => list.map((record) => record.pid).filter((pid) => Number.isInteger(pid));
124
+ const legacy = records.filter((record) => typeof record.processStart !== 'string');
125
+ const starts = processStarts(pidsOf(records.filter((record) => !legacy.includes(record))));
126
+ const commands = legacy.length > 0 ? processCommands(pidsOf(legacy)) : new Map();
127
+ return records.filter((record) => legacy.includes(record)
128
+ ? // We only ever write start tokens, so a legacy record naming our own pid
129
+ // was left by an earlier process that happened to have it.
130
+ record.pid !== process.pid && OUR_ENTRY_POINT.test(commands.get(record.pid) ?? '')
131
+ : starts.get(record.pid) === record.processStart);
132
+ }
package/dist/lifecycle.js CHANGED
@@ -6,8 +6,7 @@
6
6
  // here so the managed and unmanaged paths behave the same from the outside.
7
7
  import { loadConfig } from './config.js';
8
8
  import * as keepalive from './keepalive/index.js';
9
- import { pidAlive } from './lib/process.js';
10
- import { readRuntime } from './runtime.js';
9
+ import { managedPids, readRuntime } from './runtime.js';
11
10
  import { restartServices, startServices, statusServices, stopServices } from './service.js';
12
11
  function managerInUse(config) {
13
12
  const status = keepalive.status(config);
@@ -51,7 +50,10 @@ function restartAll(config = loadConfig()) {
51
50
  }
52
51
  /** Whether the host connector is up, under a keep-alive manager or detached. */
53
52
  function servicesRunning(config = loadConfig()) {
54
- return Boolean(managerInUse(config)?.active || pidAlive(readRuntime().hostPid));
53
+ if (managerInUse(config)?.active)
54
+ return true;
55
+ const state = readRuntime();
56
+ return managedPids(state).some(({ pid }) => pid === state.hostPid);
55
57
  }
56
58
  /** Stop Windows keep-alive processes while npm replaces their package files. */
57
59
  async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => servicesRunning(config), stop = stopAll, start = startAll, } = {}) {
package/dist/runtime.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // for it, and facts other modules remember there (terminal palette and font).
3
3
  // Every read and write of that file goes through here.
4
4
  import { ensureDir, randomToken, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
5
- import { pidAlive } from './lib/process.js';
5
+ import { processStart, stillRunning } from './lib/process.js';
6
6
  import { configDir, runtimeStatePath, stateDir } from './paths.js';
7
7
  const RUNTIME_VERSION = 2;
8
8
  export function readRuntime() {
@@ -73,34 +73,35 @@ export function regenerateHostIdentity() {
73
73
  * (minus dead entries) precisely so no writer can lose another writer's pids.
74
74
  */
75
75
  export function recordManagedPid(state, name, pid) {
76
- const existing = Array.isArray(state.managedPids) ? state.managedPids : [];
77
- const kept = existing.filter((entry) => entry && entry.pid !== pid && pidAlive(entry.pid));
78
- // Only track something that is actually running: a pid that already exited
79
- // would sit in the ledger until its number is recycled by an unrelated
80
- // process, which we would then happily signal.
81
- if (Number.isInteger(pid) && pidAlive(pid)) {
82
- kept.push({ name, pid: pid, startedAt: new Date().toISOString() });
76
+ const existing = Array.isArray(state.managedPids) ? state.managedPids.filter(Boolean) : [];
77
+ const kept = stillRunning(existing.filter((entry) => entry.pid !== pid));
78
+ // Only track something that is actually running, and together with its start
79
+ // token: the ledger outlives a reboot, and a bare pid it kept would then name
80
+ // whichever unrelated process got that number next — which we would signal.
81
+ const start = Number.isInteger(pid) ? processStart(pid) : null;
82
+ if (start) {
83
+ kept.push({
84
+ name,
85
+ pid: pid,
86
+ processStart: start,
87
+ startedAt: new Date().toISOString(),
88
+ });
83
89
  }
84
90
  state.managedPids = kept;
85
91
  return state;
86
92
  }
87
- /** Every process we believe we started, alive right now. */
93
+ /**
94
+ * Every process we started that is still running, and still that process.
95
+ *
96
+ * Only the ledger counts. `supervisorPid`, `relayPid` and `hostPid` are bare
97
+ * numbers that survive a reboot, and trusting them would signal whichever
98
+ * unrelated process inherited one.
99
+ */
88
100
  export function managedPids(state = readRuntime()) {
89
- const pids = new Map();
90
- // Supervisor first and never overwritten: `stopServices` relies on the order
91
- // to signal it before its children, and one pid can appear under several
92
- // fields.
93
- for (const [name, pid] of [
94
- ['supervisor', state.supervisorPid],
95
- ['host', state.hostPid],
96
- ['relay', state.relayPid],
97
- ]) {
98
- if (pidAlive(pid) && !pids.has(pid))
99
- pids.set(pid, name);
100
- }
101
+ const unique = new Map();
101
102
  for (const entry of state.managedPids || []) {
102
- if (entry && pidAlive(entry.pid) && !pids.has(entry.pid))
103
- pids.set(entry.pid, entry.name);
103
+ if (entry && !unique.has(entry.pid))
104
+ unique.set(entry.pid, entry);
104
105
  }
105
- return [...pids].map(([pid, name]) => ({ pid, name }));
106
+ return stillRunning([...unique.values()]);
106
107
  }
package/dist/service.js CHANGED
@@ -7,7 +7,6 @@ import path from 'node:path';
7
7
  import { ensureDir } from 'herdr-remote-relay/state';
8
8
  import { loadConfig } from './config.js';
9
9
  import { findHerdrCommand, herdrVersion, MIN_HERDR_VERSION, resolveHerdrCommand, } from './herdr-command.js';
10
- import { pidAlive } from './lib/process.js';
11
10
  import { preferredLanAddress } from './net-interfaces.js';
12
11
  import { logPath, PACKAGE_ROOT, stateDir } from './paths.js';
13
12
  import { healthUrl, hostHeaders, requestJson, waitForHost, waitForRelay } from './relay-client.js';
@@ -119,16 +118,18 @@ export function startServices() {
119
118
  const state = ensureRuntime();
120
119
  const next = { ...state };
121
120
  const specs = serviceSpecs(config, state);
121
+ const running = managedPids(next);
122
+ const isRunning = (pid) => running.some((entry) => entry.pid === pid);
122
123
  for (const spec of specs) {
123
124
  const field = PID_FIELDS[spec.name];
124
- if (pidAlive(next[field]))
125
+ if (isRunning(next[field]))
125
126
  continue;
126
127
  next[field] = spawnDetached(spec) ?? null;
127
128
  recordManagedPid(next, spec.name, next[field]);
128
129
  }
129
130
  // A relay that is no longer part of the plan (switched to remote mode) must
130
131
  // not be left running on the old port.
131
- if (!specs.some((spec) => spec.name === 'relay') && pidAlive(next.relayPid)) {
132
+ if (!specs.some((spec) => spec.name === 'relay') && isRunning(next.relayPid)) {
132
133
  try {
133
134
  process.kill(next.relayPid, 'SIGTERM');
134
135
  }
@@ -140,20 +141,21 @@ export function startServices() {
140
141
  next.startedAt = next.startedAt || new Date().toISOString();
141
142
  next.mode = config.relay.mode;
142
143
  writeRuntime(next);
144
+ const live = managedPids(next);
143
145
  return {
144
146
  ok: true,
145
147
  mode: config.relay.mode,
146
148
  relay: {
147
149
  local: runsLocalRelay(config),
148
150
  pid: next.relayPid || null,
149
- alive: pidAlive(next.relayPid),
151
+ alive: live.some(({ pid }) => pid === next.relayPid),
150
152
  bind: runsLocalRelay(config) ? bindAddress(config) : null,
151
153
  port: config.relay.port,
152
154
  remoteUrl: config.relay.remoteUrl || null,
153
155
  },
154
156
  host: {
155
157
  pid: next.hostPid || null,
156
- alive: pidAlive(next.hostPid),
158
+ alive: live.some(({ pid }) => pid === next.hostPid),
157
159
  socketPath: resolveSocketPath(config.herdr.socketPath),
158
160
  },
159
161
  publicUrl: resolvePublicUrl(config, preferredLanAddress()),
@@ -190,6 +192,7 @@ export function restartServices() {
190
192
  export async function statusServices() {
191
193
  const config = loadConfig();
192
194
  const state = readRuntime();
195
+ const live = managedPids(state);
193
196
  const lanAddress = preferredLanAddress();
194
197
  let health;
195
198
  try {
@@ -213,7 +216,7 @@ export async function statusServices() {
213
216
  relay: {
214
217
  local: runsLocalRelay(config),
215
218
  pid: state.relayPid || null,
216
- alive: runsLocalRelay(config) ? pidAlive(state.relayPid) : null,
219
+ alive: runsLocalRelay(config) ? live.some(({ pid }) => pid === state.relayPid) : null,
217
220
  bind: runsLocalRelay(config) ? bindAddress(config) : null,
218
221
  port: config.relay.port,
219
222
  remoteUrl: config.relay.remoteUrl || null,
@@ -221,7 +224,7 @@ export async function statusServices() {
221
224
  },
222
225
  host: {
223
226
  pid: state.hostPid || null,
224
- alive: pidAlive(state.hostPid),
227
+ alive: live.some(({ pid }) => pid === state.hostPid),
225
228
  hostId: state.hostId || null,
226
229
  socketPath,
227
230
  socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
@@ -6,7 +6,7 @@ import { ensureDir } from 'herdr-remote-relay/state';
6
6
  import { ensureRuntime, managedPids, recordManagedPid, updateRuntime, } from './runtime.js';
7
7
  import { baseEnvironment, serviceSpecs } from './service.js';
8
8
  import { EXIT_REPLACED, EXIT_AUTH_FAILED } from './exit-codes.js';
9
- import { pidAlive } from './lib/process.js';
9
+ import { pidAlive, stillRunning } from './lib/process.js';
10
10
  const MIN_BACKOFF_MS = 500;
11
11
  const MAX_BACKOFF_MS = 30_000;
12
12
  // A child that stayed up this long is considered healthy, so the next crash
@@ -50,6 +50,10 @@ class Supervisor {
50
50
  * spawned died with EADDRINUSE and retried forever, while the orphaned host
51
51
  * connector and ours both claimed the same host id on the relay and kicked
52
52
  * each other off — which is what browsers saw as an endless reconnect.
53
+ *
54
+ * Only ledger entries whose start token still matches are touched: the
55
+ * ledger survives a reboot, and its pids then belong to whatever the new
56
+ * boot started.
53
57
  */
54
58
  async reclaimStrays({ timeoutMs = 5000 } = {}) {
55
59
  const strays = managedPids().filter((entry) => entry.pid !== process.pid);
@@ -79,9 +83,8 @@ class Supervisor {
79
83
  setTimeout(resolve, 100);
80
84
  });
81
85
  }
82
- for (const { pid, name } of strays) {
83
- if (!pidAlive(pid))
84
- continue;
86
+ // Checked again before the harder signal, in case the number moved on meanwhile.
87
+ for (const { pid, name } of stillRunning(strays)) {
85
88
  this.emit({
86
89
  type: 'reclaim',
87
90
  name,
@@ -284,7 +287,7 @@ class Supervisor {
284
287
  current.relayPid = null;
285
288
  current.hostPid = null;
286
289
  current.startedAt = null;
287
- current.managedPids = (current.managedPids || []).filter((entry) => entry && pidAlive(entry.pid));
290
+ current.managedPids = stillRunning((current.managedPids || []).filter(Boolean));
288
291
  });
289
292
  }
290
293
  catch {
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.30"
3
+ version = "0.2.32"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos", "windows"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",