centralu 0.1.0-beta.26 → 0.1.0-beta.28
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/bin/platform.mjs +10 -1
- package/bin/serve-args.mjs +97 -0
- package/bin/serve-autostart.mjs +358 -0
- package/bin/serve-detach.mjs +277 -0
- package/bin/serve-files.mjs +218 -0
- package/bin/serve-host.mjs +317 -0
- package/bin/serve-install.mjs +204 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +192 -0
- package/bin/serve-util.mjs +127 -0
- package/bin/serve-windows.mjs +156 -0
- package/bin/serve.mjs +18 -1833
- package/package.json +5 -5
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centralu serve --detach` and `--stop`: starting serve outside the ssh session that asked, and
|
|
3
|
+
* stopping it in order (docs/plans/remote-hub.md §10.4).
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { existsSync, openSync, renameSync, writeSync } from 'node:fs'
|
|
7
|
+
import { userInfo } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep, waitUntil } from './serve-util.mjs'
|
|
10
|
+
import { SERVE_LOG_FILE, serveDataDir, readServeState, ensureServeState, heldHere, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
11
|
+
import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
|
|
12
|
+
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
13
|
+
import { CHILD_FLAG } from './serve-args.mjs'
|
|
14
|
+
import { protocolVersionOf, connectionLine, hostProvesOf, probeHost, askHostToStop } from './serve-host.mjs'
|
|
15
|
+
import { describe, runServe, serveFolder } from './serve-run.mjs'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a detached launcher needs from the `--detach` that started it. A process created through WMI
|
|
19
|
+
* does not inherit this environment: it gets the account's default one. Everything else it needs
|
|
20
|
+
* (the token, the port) is in the data folder. Never a secret.
|
|
21
|
+
*/
|
|
22
|
+
export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
|
|
23
|
+
|
|
24
|
+
export function encodeChildSpec({ env, log }) {
|
|
25
|
+
const carried = {}
|
|
26
|
+
for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
|
|
27
|
+
return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
|
|
31
|
+
export function decodeChildSpec(raw) {
|
|
32
|
+
try {
|
|
33
|
+
const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
|
|
34
|
+
if (typeof v?.log !== 'string' || !v.log) return null
|
|
35
|
+
const env = {}
|
|
36
|
+
for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
|
|
37
|
+
return { log: v.log, env }
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
|
|
45
|
+
* something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
|
|
46
|
+
* refusal the hub falls back from.
|
|
47
|
+
*/
|
|
48
|
+
export async function startDetached({ how, execPath, args, env, cwd }) {
|
|
49
|
+
if (how === 'setsid') {
|
|
50
|
+
return await new Promise((resolve) => {
|
|
51
|
+
let child
|
|
52
|
+
try {
|
|
53
|
+
child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
|
|
56
|
+
}
|
|
57
|
+
child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
|
|
58
|
+
child.once('spawn', () => {
|
|
59
|
+
const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
|
|
60
|
+
child.unref()
|
|
61
|
+
resolve({ ok: true, pid: child.pid, exited })
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
const ps = windowsPowershell(how, env)
|
|
66
|
+
if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
|
|
67
|
+
let commandLine
|
|
68
|
+
try {
|
|
69
|
+
if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
|
|
70
|
+
else {
|
|
71
|
+
const distro = wslDistro(env.WSL_DISTRO_NAME ?? '')
|
|
72
|
+
const user = wslUser(userInfo().username)
|
|
73
|
+
commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { ok: false, blocked: false, message: e.message }
|
|
77
|
+
}
|
|
78
|
+
const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
|
|
79
|
+
const answer = await askPowershell(ps, script, env, parseWmiAnswer)
|
|
80
|
+
if (!answer.ok) return { ok: false, blocked: true, message: wmiBlockedMessage(answer.message) }
|
|
81
|
+
// A Windows pid can be watched from Windows; from inside WSL it names nothing here
|
|
82
|
+
/*
|
|
83
|
+
* Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
|
|
84
|
+
* running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
|
|
85
|
+
* from the same context answers. `--detach` reports that as a start that never ran, and ends what
|
|
86
|
+
* it created rather than leave it waiting: this pid and its children, nothing else.
|
|
87
|
+
*/
|
|
88
|
+
const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
|
|
89
|
+
const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
|
|
90
|
+
return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
|
|
95
|
+
* session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
|
|
96
|
+
* hub over ssh, can connect at once.
|
|
97
|
+
*
|
|
98
|
+
* stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
|
|
99
|
+
* `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
|
|
100
|
+
* how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
|
|
101
|
+
* that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
|
|
102
|
+
* end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
|
|
103
|
+
* what counts; the exit code is 0 or 1 for a person at a prompt.
|
|
104
|
+
*
|
|
105
|
+
* `start` and the two waits are parameters so tests can stand in for WMI.
|
|
106
|
+
*/
|
|
107
|
+
export async function runDetach({
|
|
108
|
+
env,
|
|
109
|
+
home,
|
|
110
|
+
entry,
|
|
111
|
+
version,
|
|
112
|
+
cliPath,
|
|
113
|
+
port = null,
|
|
114
|
+
platform = process.platform,
|
|
115
|
+
execPath = process.execPath,
|
|
116
|
+
cwd = process.cwd(),
|
|
117
|
+
start = startDetached,
|
|
118
|
+
how = detachHow(platform, env),
|
|
119
|
+
timeoutMs = 30_000,
|
|
120
|
+
noStartMs = 10_000,
|
|
121
|
+
write = (line) => process.stdout.write(line),
|
|
122
|
+
}) {
|
|
123
|
+
// Created here, so the token exists before the launcher reads it and `answered` reads it again
|
|
124
|
+
const { dataDir } = serveFolder({ env, home, cliPath, platform })
|
|
125
|
+
const fail = (reason, message) => answerLine(write, 'detach', { ok: false, how, reason, message })
|
|
126
|
+
const answered = async (already) => {
|
|
127
|
+
const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
|
|
128
|
+
if (!d?.hostRunning) return false
|
|
129
|
+
write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
if (await answered(true)) return 0
|
|
133
|
+
|
|
134
|
+
const log = join(dataDir, SERVE_LOG_FILE)
|
|
135
|
+
// One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
|
|
136
|
+
try {
|
|
137
|
+
renameSync(log, `${log}.1`)
|
|
138
|
+
} catch {
|
|
139
|
+
// there was none
|
|
140
|
+
}
|
|
141
|
+
const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
|
|
142
|
+
let started
|
|
143
|
+
try {
|
|
144
|
+
started = await start({ how, execPath, args, env, cwd })
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return fail('failed', `could not start centralu serve in the background: ${e.message}`)
|
|
147
|
+
}
|
|
148
|
+
if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
|
|
149
|
+
say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
|
|
150
|
+
|
|
151
|
+
let exited
|
|
152
|
+
started.exited?.then((c) => (exited = c ?? 'unknown'))
|
|
153
|
+
const t0 = Date.now()
|
|
154
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
155
|
+
if (await answered(false)) return 0
|
|
156
|
+
const why = tail(log)
|
|
157
|
+
if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
|
|
158
|
+
const gone = started.alive?.() === false
|
|
159
|
+
if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
|
|
160
|
+
// WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
|
|
161
|
+
if (!gone) await started.stop?.()
|
|
162
|
+
return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
|
|
163
|
+
}
|
|
164
|
+
if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
|
|
165
|
+
await sleep(250)
|
|
166
|
+
}
|
|
167
|
+
const why = tail(log)
|
|
168
|
+
return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
|
|
173
|
+
* already in `env` (centralu.mjs merges them before it looks for the host).
|
|
174
|
+
*/
|
|
175
|
+
export async function runDetachedChild({ spec, ...opts }) {
|
|
176
|
+
let fd = null
|
|
177
|
+
try {
|
|
178
|
+
fd = openSync(spec.log, 'a')
|
|
179
|
+
sayTo((text) => {
|
|
180
|
+
try {
|
|
181
|
+
writeSync(fd, text)
|
|
182
|
+
} catch {
|
|
183
|
+
// nowhere left to say it
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
} catch {
|
|
187
|
+
// no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
|
|
188
|
+
}
|
|
189
|
+
say(`started in the background (pid ${process.pid}, Node ${process.version})`)
|
|
190
|
+
try {
|
|
191
|
+
return await runServe({ ...opts, logFd: fd })
|
|
192
|
+
} catch (e) {
|
|
193
|
+
say(e?.message ?? String(e))
|
|
194
|
+
return 1
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
|
|
200
|
+
* its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
|
|
201
|
+
* processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
|
|
202
|
+
* launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
|
|
203
|
+
* libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
|
|
204
|
+
*
|
|
205
|
+
* Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
|
|
206
|
+
* the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
|
|
207
|
+
* too. The launcher ends with its host; one still there afterwards is ended.
|
|
208
|
+
*
|
|
209
|
+
* Only the processes serve.pid and host.lock name, and only while they agree with each other and
|
|
210
|
+
* the host answered with this folder's token: a stale pid is never signalled.
|
|
211
|
+
*
|
|
212
|
+
* stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
|
|
213
|
+
*/
|
|
214
|
+
export async function runStop({
|
|
215
|
+
env,
|
|
216
|
+
home,
|
|
217
|
+
entry,
|
|
218
|
+
platform = process.platform,
|
|
219
|
+
timeoutMs = 30_000,
|
|
220
|
+
graceMs = 15_000,
|
|
221
|
+
kill = (pid, sig) => process.kill(pid, sig),
|
|
222
|
+
write = (line) => process.stdout.write(line),
|
|
223
|
+
}) {
|
|
224
|
+
const dataDir = serveDataDir(env, home)
|
|
225
|
+
const report = (stop) => answerLine(write, 'stop', stop)
|
|
226
|
+
const state = readServeState(dataDir)
|
|
227
|
+
if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
|
|
228
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
229
|
+
const installed = protocolVersionOf(entry) ?? 0
|
|
230
|
+
const prove = hostProvesOf(entry)
|
|
231
|
+
const probe = () => probeHost({ port, token: state.token, protocolVersion: installed, prove })
|
|
232
|
+
const first = await probe()
|
|
233
|
+
if (!heldHere(first, dataDir)) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
|
|
234
|
+
|
|
235
|
+
const hostPid = lockHolderPid(dataDir)
|
|
236
|
+
const launcher = readServePid(dataDir)
|
|
237
|
+
// serve.pid is only believed when it names the host that holds the folder now
|
|
238
|
+
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
239
|
+
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
240
|
+
|
|
241
|
+
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed, prove })
|
|
242
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion, prove })
|
|
243
|
+
let how = 'asked'
|
|
244
|
+
let stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
245
|
+
let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
|
|
246
|
+
if (!stopped) {
|
|
247
|
+
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
248
|
+
if (target) {
|
|
249
|
+
how = platform === 'win32' ? 'ended' : 'signal'
|
|
250
|
+
try {
|
|
251
|
+
kill(target, 'SIGTERM')
|
|
252
|
+
} catch {
|
|
253
|
+
// gone in the meantime
|
|
254
|
+
}
|
|
255
|
+
stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
|
|
256
|
+
if (!stopped && hostPid && isAlive(hostPid)) {
|
|
257
|
+
how = 'ended'
|
|
258
|
+
try {
|
|
259
|
+
kill(hostPid, 'SIGKILL')
|
|
260
|
+
} catch {
|
|
261
|
+
// gone in the meantime
|
|
262
|
+
}
|
|
263
|
+
stopped = await waitUntil(down, 5_000)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
|
|
267
|
+
}
|
|
268
|
+
if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
|
|
269
|
+
try {
|
|
270
|
+
kill(launcherPid, 'SIGKILL')
|
|
271
|
+
} catch {
|
|
272
|
+
// gone in the meantime
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
|
|
276
|
+
return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
|
|
277
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files `centralu serve` keeps in the data folder: the token and port (serve.json, private to the
|
|
3
|
+
* user), the running launcher's pid (serve.pid), and the host's own lock (host.lock), read to tell a
|
|
4
|
+
* live host of this folder from a stale pid.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { randomBytes } from 'node:crypto'
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { isAlive, replaceFile, sleepSync } from './serve-util.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The file in the data folder that holds the token and the port, mode 0600.
|
|
14
|
+
*
|
|
15
|
+
* The token is generated once, the first time either `serve` or `serve --connection` runs, and kept:
|
|
16
|
+
* the client stores it (in the OS secret store) and has to find the same one after the host
|
|
17
|
+
* restarts. The port is the one the last `serve` actually listened on, which is how
|
|
18
|
+
* `--connection`, a separate process, learns a `--port` given to `serve` in a systemd unit.
|
|
19
|
+
*/
|
|
20
|
+
export const SERVE_STATE_FILE = 'serve.json'
|
|
21
|
+
|
|
22
|
+
/** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
|
|
23
|
+
export const SERVE_LOG_FILE = 'serve.log'
|
|
24
|
+
|
|
25
|
+
/** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
|
|
26
|
+
export const SERVE_PID_FILE = 'serve.pid'
|
|
27
|
+
|
|
28
|
+
/** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
|
|
29
|
+
export function serveDataDir(env, home) {
|
|
30
|
+
return env.CC_DATA_DIR || join(home, '.centralu')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The state, or null when there is none yet or it cannot be read as one. A file that is refused (Windows, while a
|
|
35
|
+
* writer or a scanner has it) or does not parse is read again a few times before it counts as unreadable: a
|
|
36
|
+
* `--detach` waiting for the port reads it while the launcher replaces it (#1041).
|
|
37
|
+
*/
|
|
38
|
+
export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
|
|
39
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
40
|
+
for (let i = 1; ; i++) {
|
|
41
|
+
let raw
|
|
42
|
+
try {
|
|
43
|
+
raw = readFileSync(file, 'utf8')
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e?.code === 'ENOENT' || i >= tries) return null
|
|
46
|
+
sleep(10 * i)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const state = parseServeState(raw)
|
|
50
|
+
if (state || i >= tries) return state
|
|
51
|
+
sleep(10 * i)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseServeState(raw) {
|
|
56
|
+
try {
|
|
57
|
+
const v = JSON.parse(raw)
|
|
58
|
+
if (typeof v?.token !== 'string' || v.token.length < 32) return null
|
|
59
|
+
return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The state, created on first use. Never replaces a token that is already there.
|
|
67
|
+
*
|
|
68
|
+
* Created with `wx`, so two first runs at the same moment (a `serve` and a `--connection`) cannot
|
|
69
|
+
* each write a different token: the second one fails to create and reads the first one's. A file
|
|
70
|
+
* that exists but cannot be read as state is refused rather than overwritten: it may be a token
|
|
71
|
+
* the client already holds, and a silent new one would lock that client out with "bad token".
|
|
72
|
+
*/
|
|
73
|
+
export function ensureServeState(dataDir, platform = process.platform) {
|
|
74
|
+
mkdirSync(dataDir, { recursive: true })
|
|
75
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
76
|
+
if (!existsSync(file)) {
|
|
77
|
+
const token = randomBytes(32).toString('base64url')
|
|
78
|
+
try {
|
|
79
|
+
writePrivate(file, { v: 1, token, port: null }, 'wx')
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e?.code !== 'EEXIST') throw e
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const state = readServeState(dataDir)
|
|
85
|
+
if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
|
|
86
|
+
keepPrivate(file, platform)
|
|
87
|
+
return state
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
|
|
91
|
+
export function recordServePort(dataDir, port, platform = process.platform) {
|
|
92
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
93
|
+
const state = readServeState(dataDir)
|
|
94
|
+
if (!state) throw new Error(`${file} is missing or unreadable`)
|
|
95
|
+
if (state.port === port) return
|
|
96
|
+
replacePrivate(file, { v: 1, token: state.token, port }, platform)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
|
|
101
|
+
* stored the old one has to fetch the new one with `--connection`, and a serve already running
|
|
102
|
+
* keeps answering to the old one until it restarts.
|
|
103
|
+
*/
|
|
104
|
+
export function rotateServeToken(dataDir, platform = process.platform) {
|
|
105
|
+
const before = ensureServeState(dataDir, platform)
|
|
106
|
+
replacePrivate(join(dataDir, SERVE_STATE_FILE), { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, platform)
|
|
107
|
+
return readServeState(dataDir)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Created 0600 from the first byte, not chmod-ed after: a file written with the default mode and
|
|
112
|
+
* narrowed afterwards is readable by everyone on the machine for the moment in between.
|
|
113
|
+
*/
|
|
114
|
+
function writePrivate(file, value, flag) {
|
|
115
|
+
const fd = openSync(file, flag, 0o600)
|
|
116
|
+
try {
|
|
117
|
+
writeSync(fd, `${JSON.stringify(value)}\n`)
|
|
118
|
+
} finally {
|
|
119
|
+
closeSync(fd)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Replaces a private file whole (`replaceFile`), created 0600 as `writePrivate` creates one */
|
|
124
|
+
function replacePrivate(file, value, platform) {
|
|
125
|
+
replaceFile(file, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
|
|
126
|
+
keepPrivate(file, platform)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 0600 again on every use. A copy, a restore from backup or an editor that rewrites the file can
|
|
131
|
+
* leave it readable by the group; the token in it is the key to every RPC. Windows has no mode
|
|
132
|
+
* bits to set (the profile folder's ACL is what protects it there).
|
|
133
|
+
*/
|
|
134
|
+
function keepPrivate(file, platform) {
|
|
135
|
+
if (platform === 'win32') return
|
|
136
|
+
if ((statSync(file).mode & 0o777) !== 0o600) chmodSync(file, 0o600)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
|
|
140
|
+
export function readServePid(dataDir) {
|
|
141
|
+
try {
|
|
142
|
+
const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
|
|
143
|
+
return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function removeServePid(dataDir, pid) {
|
|
150
|
+
if (readServePid(dataDir)?.pid !== pid) return
|
|
151
|
+
try {
|
|
152
|
+
rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
|
|
153
|
+
} catch {
|
|
154
|
+
// another serve's by now, or gone
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A probe that did not prove the token, while this data folder's host is alive (`host.lock`): a host from before #838
|
|
160
|
+
* still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
|
|
161
|
+
* until it restarts. Without a live lock holder, what answered is not ours.
|
|
162
|
+
*/
|
|
163
|
+
export function heldHere(probe, dataDir, startOf = processStartTime) {
|
|
164
|
+
if (probe.running) return true
|
|
165
|
+
if (!probe.unproven) return false
|
|
166
|
+
return lockHolderIsAlive(dataDir, startOf)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
|
|
171
|
+
* (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
|
|
172
|
+
* process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
|
|
173
|
+
* start time (a host from before #184) or a system without `ps` (Windows) cannot be told apart, so it does not count.
|
|
174
|
+
*/
|
|
175
|
+
export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
|
|
176
|
+
let holder
|
|
177
|
+
try {
|
|
178
|
+
holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
if (!holder || !holder.started || !isAlive(holder.pid)) return false
|
|
183
|
+
return startOf(holder.pid) === holder.started
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** host.lock's `{ pid, started }`; a bare pid (an older host) has no start time */
|
|
187
|
+
function parseLockHolder(text) {
|
|
188
|
+
const t = text.trim()
|
|
189
|
+
if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
|
|
190
|
+
try {
|
|
191
|
+
const v = JSON.parse(t)
|
|
192
|
+
return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, started: typeof v.started === 'string' ? v.started : null } : null
|
|
193
|
+
} catch {
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A pid's start time, as `instance-lock.ts`'s `processStartTime` reads it: `ps -o lstart=` with the C locale, so the
|
|
200
|
+
* host that wrote the lock and this launcher read the same text. null without `ps` (Windows) or when it fails.
|
|
201
|
+
*/
|
|
202
|
+
export function processStartTime(pid) {
|
|
203
|
+
if (process.platform === 'win32') return null
|
|
204
|
+
try {
|
|
205
|
+
return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null
|
|
206
|
+
} catch {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Who `host.lock` names as the data folder's owner, or null */
|
|
212
|
+
export function lockHolderPid(dataDir) {
|
|
213
|
+
try {
|
|
214
|
+
return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))?.pid ?? null
|
|
215
|
+
} catch {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|