centralu 0.1.0-beta.46 → 0.1.0-beta.48

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/centralu.mjs CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from './platform.mjs'
46
46
  import {
47
47
  decodeChildSpec,
48
+ gateHostStart,
48
49
  parseServeArgs,
49
50
  printConnection,
50
51
  rotateToken,
@@ -54,6 +55,9 @@ import {
54
55
  runDetachedChild,
55
56
  runServe,
56
57
  runStop,
58
+ runSwitch,
59
+ runSwitchRun,
60
+ runSwitchStatus,
57
61
  runWindow,
58
62
  SERVE_HELP,
59
63
  } from './serve.mjs'
@@ -631,6 +635,19 @@ switch (cmd) {
631
635
  process.exitCode = rotateToken({ env: process.env, home: HOME })
632
636
  break
633
637
  }
638
+ // The hub's switch of the install it manages (serve-switch.mjs): it starts and checks hosts only through serve's CLI
639
+ if (args.mode === 'switch') {
640
+ process.exitCode = await runSwitch({ env: process.env, home: HOME, raw: args.switchArg, cliPath: realpathSync(process.argv[1]) })
641
+ break
642
+ }
643
+ if (args.mode === 'switch-run') {
644
+ process.exitCode = await runSwitchRun({ env: process.env, home: HOME, raw: args.switchArg, dataDir: args.dataDir })
645
+ break
646
+ }
647
+ if (args.mode === 'switch-status') {
648
+ process.exitCode = await runSwitchStatus({ env: process.env, home: HOME, seen: args.seen })
649
+ break
650
+ }
634
651
  /*
635
652
  * Before anything spawns a host: under a Node below the floor the host crashes on its first
636
653
  * database call, with nothing in its log (issue #699). `--detach` also answers in the line the
@@ -640,6 +657,19 @@ switch (cmd) {
640
657
  * machine what Node it has.
641
658
  */
642
659
  const startsHost = args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window' || (args.mode === 'autostart' && args.autostart === 'on')
660
+ /*
661
+ * A switch of the managed install under way there (§10.5a): no host starts between its stop and its start, and one
662
+ * whose switcher died is finished first, before this version refuses for a reason of its own (a host already
663
+ * answering, a missing host entry, a Node below the floor), so a reboot or a sign-in finishes it with no hub (#1907).
664
+ * Not for the launcher `--detach` started: that one asked already
665
+ */
666
+ if ((args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window') && args.child === undefined) {
667
+ const refused = await gateHostStart({ env: process.env, home: HOME, version: pkg.version, detach: args.mode === 'detach' })
668
+ if (refused !== null) {
669
+ process.exitCode = refused
670
+ break
671
+ }
672
+ }
643
673
  if (startsHost && belowFloor(process.versions.node, NODE_FLOOR)) {
644
674
  const message = oldNodeSentence(process.versions.node, NODE_FLOOR)
645
675
  console.error(`centralu serve: ${message}`)
@@ -77,6 +77,22 @@ export const FROM_RE = /^[A-Za-z0-9._-]{1,64}$/
77
77
  * `--autostart` writes starts serve on the port it recorded.
78
78
  */
79
79
  export function parseServeArgs(argv) {
80
+ // The hub's switch of the install it manages (docs/plans/remote-hub.md §10.5a): whole commands of their own, not in the help
81
+ const b64 = (a) => typeof a === 'string' && /^[A-Za-z0-9_-]{1,4096}$/.test(a)
82
+ if (argv[0] === '--switch') {
83
+ if (argv.length !== 2 || !b64(argv[1])) return { error: '--switch takes one argument, the switch the hub wrote' }
84
+ return { mode: 'switch', port: null, switchArg: argv[1] }
85
+ }
86
+ // What `--switch` starts: the hub's argument as it came, and the data folder (base64url) when it is not the default
87
+ if (argv[0] === '--switch-run') {
88
+ if (argv.length < 2 || argv.length > 3 || !argv.slice(1).every(b64)) return { error: '--switch-run takes the switch the hub wrote, and the data folder' }
89
+ return { mode: 'switch-run', port: null, switchArg: argv[1], dataDir: argv[2] ?? null }
90
+ }
91
+ if (argv[0] === '--switch-status') {
92
+ if (argv.length === 1) return { mode: 'switch-status', port: null, seen: null }
93
+ if (argv.length === 3 && argv[1] === '--seen' && /^[0-9A-Za-z-]{8,64}$/.test(argv[2])) return { mode: 'switch-status', port: null, seen: argv[2] }
94
+ return { error: '--switch-status takes only --seen <id>' }
95
+ }
80
96
  let mode = 'serve'
81
97
  let port = null
82
98
  let help = false
@@ -267,6 +267,8 @@ export async function runDetachedChild({ spec, ...opts }) {
267
267
  * A stop that went through is remembered in the data folder (`markStopped`, #1143), so a linked hub does not start the
268
268
  * host again by itself; the next host that starts here removes the mark. Not for a hub's own stop, which starts the
269
269
  * host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`).
270
+ * The window's "Quit completely" on this host runs it with `CENTRALU_STOP_BY=quit` (#1466), so the mark says the person quit
271
+ * Centralu there, as the app's own host's does, not that they ran `serve --stop`.
270
272
  * With `CENTRALU_STOP_FOR=update` too (#1618), the host is asked to list the turns it ends, which the next host continues
271
273
  */
272
274
  export async function runStop({
@@ -281,6 +283,6 @@ export async function runStop({
281
283
  }) {
282
284
  const dataDir = serveDataDir(env, home)
283
285
  const stopped = await stopServedHost({ dataDir, entry, platform, forUpdate: env.CENTRALU_STOP_FOR === 'update', timeoutMs, graceMs, kill })
284
- if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, 'stop')
286
+ if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, env.CENTRALU_STOP_BY === 'quit' ? 'quit' : 'stop')
285
287
  return answerLine(write, 'stop', stopped)
286
288
  }
@@ -602,12 +602,20 @@ export function foreignHolder(dataDir) {
602
602
  * future (a clock set back since) proves nothing.
603
603
  */
604
604
  export function lockHolderIsAlive(dataDir, startOf = processStartTime, createdAt = processCreatedAt) {
605
- const holder = lockHolder(dataDir)
605
+ return isWriter(lockHolder(dataDir), startOf, createdAt) === true
606
+ }
607
+
608
+ /**
609
+ * Whether `{ pid, started, at }` names the live process that wrote it, by `lockHolderIsAlive`'s rule: true, false, or
610
+ * null when it lives and Windows cannot say when it was created (another logon's process); each caller decides what
611
+ * that counts as
612
+ */
613
+ export function isWriter(holder, startOf = processStartTime, createdAt = processCreatedAt) {
606
614
  if (!holder || !isAlive(holder.pid)) return false
607
615
  if (holder.started) return startOf(holder.pid) === holder.started
608
616
  if (holder.at === undefined || holder.at > Date.now()) return false
609
617
  const created = createdAt(holder.pid)
610
- return created !== null && created <= holder.at + CLOCK_TICK_MS
618
+ return created === null ? null : created <= holder.at + CLOCK_TICK_MS
611
619
  }
612
620
 
613
621
  /** One tick of the Windows clock */
@@ -26,7 +26,7 @@ import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
26
26
  * And only a host `serve` started (`startedByServe`, #1722): a lock held by the app's or the keeper's host is theirs,
27
27
  * whatever answers on the port, so `--stop` says whose it is and leaves it.
28
28
  *
29
- * `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
29
+ * `{ ok, wasRunning, how?, heldByOther?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
30
30
  * when nothing runs there, and `{ ok: false, busy: true }` says it did not. `forUpdate` (#1618): a hub's update stops it, and
31
31
  * the host writes the turns it ends for the next one (`host.stop { forUpdate }`); a host that does not take the request is
32
32
  * ended as any is, and lists nothing.
@@ -45,7 +45,8 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
45
45
  // The folder is held, but not by a serve (#1722): the app's or the keeper's host, whatever answered on the port
46
46
  const other = foreignHolder(dataDir)
47
47
  const whose = other ? `. It belongs to another Centralu host (pid ${other.pid}), ${holderWho(other.by)}; not stopping it` : ''
48
- return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}${whose}` }
48
+ // `heldByOther`: the folder is held by a host that is not this serve's, which this left alone (the window's quit says so rather than claim a stop)
49
+ return { ok: true, wasRunning: false, ...(other ? { heldByOther: true } : {}), message: `centralu serve is not running for ${dataDir}${whose}` }
49
50
  }
50
51
 
51
52
  const hostPid = lockHolderPid(dataDir)
@@ -105,7 +105,7 @@ export function remoteLayout(dataDir) {
105
105
  }
106
106
 
107
107
  /** A word a pointer file may hold: a folder name, nothing a path could escape with */
108
- const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
108
+ export const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
109
109
 
110
110
  /** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
111
111
  export function parsePointer(text) {
@@ -146,7 +146,21 @@ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
146
146
  const source = p && installSource(join(l.versions, p.version))
147
147
  return p && source ? { ...p, source } : p
148
148
  }
149
- return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
149
+ return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion, lastSwitch: lastSwitchOf(l.root) }
150
+ }
151
+
152
+ /**
153
+ * The last switch there (`switch.json`, serve-switch.mjs), as much as a hub needs to know whether to read it: its id,
154
+ * whether it ended, and whether its outcome was reported (`--seen`). A hub that was away for the whole of it, or
155
+ * restarted since, learns from this at its next open that there is an outcome to say (#1907). Null when none ran
156
+ */
157
+ function lastSwitchOf(root) {
158
+ try {
159
+ const s = JSON.parse(readFileSync(join(root, 'switch.json'), 'utf8'))
160
+ return typeof s?.id === 'string' ? { id: s.id, ended: typeof s.outcome === 'string', seen: s.seen === true } : null
161
+ } catch {
162
+ return null
163
+ }
150
164
  }
151
165
 
152
166
  /**
package/bin/serve-run.mjs CHANGED
@@ -143,7 +143,6 @@ async function signingHostKey({ dataDir, state, entry, keyId, hubKey, probeKey }
143
143
  export async function runServe({ env, home, entry, version, cliPath, port: askedPort, from = null, platform = process.platform, execPath = process.execPath, logFd = null, handOver = moveIntoSignIn }) {
144
144
  const { dataDir, state } = serveFolder({ env, home, cliPath, platform })
145
145
  const protocolVersion = protocolVersionOf(entry)
146
-
147
146
  // A serve that is already up answers on the port it recorded; say so before starting a host that
148
147
  // would only fail on the data folder's lock with a less specific message.
149
148
  const recorded = state.port ?? DEFAULT_SERVE_PORT
@@ -0,0 +1,636 @@
1
+ /**
2
+ * `centralu serve --switch`: the machine runs a version switch of the install the hub manages (docs/plans/remote-hub.md
3
+ * §10.5a, #1903). The hub places the new version beside the running one, then hands this one command over ssh, and from
4
+ * then on only reads the outcome (`--switch-status`). Whatever happens to the hub or the link after the handover, the
5
+ * switcher, detached here the way `--detach` detaches a host, stops the old host, switches the pointers, starts and
6
+ * checks the new version, and puts the old one back when it does not answer.
7
+ *
8
+ * The switcher is the launcher of the newer of the two versions. It drives the other one only through the CLI every
9
+ * managed version has (`serve --stop`, `serve --detach`, `serve --connection`), run on its own Node from its folder under
10
+ * `<data>/remote/versions/`, as the managed launcher runs it: nothing is imported across versions.
11
+ *
12
+ * What it does is in `<data>/remote/switch.json`, each step written before it is taken, and `switch.lock` names the live
13
+ * process at work. A switcher that died is finished by the next reader (`reconcile`): `--switch-status`, any `serve` or
14
+ * `--detach` there, or the next `--switch`.
15
+ */
16
+ import { spawn } from 'node:child_process'
17
+ import { closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'
18
+ import { randomBytes } from 'node:crypto'
19
+ import { join } from 'node:path'
20
+ import { answerLine, replaceFile, runCaptured, say, sleep, tail, waitUntil } from './serve-util.mjs'
21
+ import { isWriter, processStartTime, serveDataDir } from './serve-files.mjs'
22
+ import { POINTER_WORD, managedLauncherScript, parsePointer, remoteLayout } from './serve-install.mjs'
23
+ import { detachHow } from './serve-windows.mjs'
24
+
25
+ export const SWITCH_FILE = 'switch.json'
26
+ export const SWITCH_LOCK = 'switch.lock'
27
+ /**
28
+ * The version whose launcher ran the last switch, as a pointer line, written by the hub's handover command before it runs
29
+ * it: the one that reads its status (`--switch-status`), whatever `current` is by then, and that a prune keeps
30
+ */
31
+ export const SWITCH_RUNNER = 'switch.runner'
32
+
33
+ /** How long the started version has to say hello as itself (plan §10.5 step 4) */
34
+ const CHECK_MS = 30_000
35
+ const NO_ANSWER = `it did not answer within ${CHECK_MS / 1000} s`
36
+ /** How long the handover waits for the detached switcher to write its pid before it runs the switch itself */
37
+ const HANDOVER_MS = 10_000
38
+ /**
39
+ * A live pid whose creation time Windows cannot read (another logon's process) counts as the switcher while its record
40
+ * is younger than this: past the longest switch (a stop of 30 s, a start and check of 60 s, a put-back of 60 s), so a
41
+ * pid reused later is not believed for long
42
+ */
43
+ const UNREADABLE_ALIVE_MS = 10 * 60_000
44
+ /** The steps between a stop and the start that follows it: no other host may start then */
45
+ const QUIET_STEPS = new Set(['stop', 'backup', 'switch', 'restore_store', 'roll_back'])
46
+
47
+ const LOCAL_BUILD = /\.local\.\d+$/
48
+ const pointer = (v) => (v && POINTER_WORD.test(String(v.version)) && POINTER_WORD.test(String(v.node)) ? { version: v.version, node: v.node } : null)
49
+ const line = (p) => `${p.version} ${p.node}\n`
50
+
51
+ /**
52
+ * The switch the hub asked for, from `--switch`'s argument (base64url of the hub's JSON, update.ts `switchCommand`),
53
+ * which `--switch-run` is handed unchanged; null when it is not one
54
+ */
55
+ export function decodeSwitch(raw) {
56
+ try {
57
+ const p = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
58
+ const to = pointer(p?.to)
59
+ if (!to || typeof p.id !== 'string' || !/^[0-9A-Za-z-]{8,64}$/.test(p.id)) return null
60
+ const by = typeof p.by === 'string' ? p.by.replace(/[^\w .()@-]/g, '').slice(0, 80) : 'another computer'
61
+ const was = { current: pointer(p.was?.current), previous: pointer(p.was?.previous) }
62
+ return { id: p.id, by, to, keep: pointer(p.keep), was, saveStore: p.saveStore === true }
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /** `--switch-run`'s second argument: the data folder, which a process WMI creates does not inherit (base64url) */
69
+ const dataDirArg = (env) => (env.CC_DATA_DIR ? [Buffer.from(env.CC_DATA_DIR, 'utf8').toString('base64url')] : [])
70
+ const withDataDir = (env, arg) => (arg ? { ...env, CC_DATA_DIR: Buffer.from(arg, 'base64url').toString('utf8') } : env)
71
+
72
+ /** A time as `--detach`, `serve` and the hub's refusal say it: HH:MM, local */
73
+ export const clockOf = (at) => (typeof at === 'number' ? new Date(at).toTimeString().slice(0, 5) : 'an unknown time')
74
+
75
+ function readJson(file) {
76
+ try {
77
+ return JSON.parse(readFileSync(file, 'utf8'))
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ /** `switch.json`, or null when there is none */
84
+ export function readSwitch(dataDir) {
85
+ const s = readJson(join(remoteLayout(dataDir).root, SWITCH_FILE))
86
+ return s && typeof s.id === 'string' ? s : null
87
+ }
88
+
89
+ function readPointerFile(file) {
90
+ try {
91
+ return parsePointer(readFileSync(file, 'utf8'))
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /** Whether `switch.lock` names a live switcher (`isWriter`), a Windows pid of another logon counting for a while */
98
+ export function lockIsLive(holder, writer = isWriter) {
99
+ if (!holder || !Number.isInteger(holder.pid)) return false
100
+ const w = writer(holder)
101
+ return w === null ? Date.now() - holder.at < UNREADABLE_ALIVE_MS : w
102
+ }
103
+
104
+ /**
105
+ * Takes `switch.lock` for `id`: an exclusive create naming this process, so of two switchers only one runs. A lock whose
106
+ * process is not alive is stale and is taken over, moved aside first so of two takers only one removes it. Null when a
107
+ * live process holds it. The function it returns releases it, only while it is still this one's
108
+ */
109
+ function takeLock(root, id) {
110
+ const file = join(root, SWITCH_LOCK)
111
+ const own = { id, pid: process.pid, started: processStartTime(process.pid), at: Date.now() }
112
+ for (let i = 0; i < 3; i++) {
113
+ try {
114
+ const fd = openSync(file, 'wx')
115
+ try {
116
+ writeSync(fd, JSON.stringify(own))
117
+ } finally {
118
+ closeSync(fd)
119
+ }
120
+ return () => {
121
+ const now = readJson(file)
122
+ if (now?.pid === own.pid && now.at === own.at) rmSync(file, { force: true })
123
+ }
124
+ } catch (e) {
125
+ if (e?.code !== 'EEXIST') throw e
126
+ }
127
+ const holder = readJson(file)
128
+ if (holder ? lockIsLive(holder) : youngerThan(file, 5_000)) return null
129
+ const aside = `${file}.${process.pid}-${randomBytes(4).toString('hex')}.stale`
130
+ try {
131
+ renameSync(file, aside)
132
+ rmSync(aside, { force: true })
133
+ } catch {
134
+ // moved by another taker first
135
+ }
136
+ }
137
+ return null
138
+ }
139
+
140
+ function youngerThan(file, ms) {
141
+ try {
142
+ return Date.now() - statSync(file).mtimeMs < ms
143
+ } catch {
144
+ return false
145
+ }
146
+ }
147
+
148
+ /** One managed version's Node and `centralu.mjs`, as the managed launcher finds them */
149
+ function versionPaths(l, v, platform) {
150
+ return {
151
+ node: platform === 'win32' ? join(l.node, `v${v.node}`, 'node.exe') : join(l.node, `v${v.node}`, 'bin', 'node'),
152
+ cli: join(l.versions, v.version, 'node_modules', 'centralu', 'bin', 'centralu.mjs'),
153
+ }
154
+ }
155
+
156
+ /** One version's `centralu serve <args>`, on its own Node, as the managed launcher runs it */
157
+ function serveOf(l, v, args, env, platform) {
158
+ const { node, cli } = versionPaths(l, v, platform)
159
+ return runCaptured(node, [cli, 'serve', ...args], { ...env, CENTRALU_MANAGED: '1' })
160
+ }
161
+
162
+ /** The last `{ v: 1, … }` line of `stdout` that `pick` finds something in */
163
+ function lastLine(stdout, pick) {
164
+ for (const l of String(stdout).split(/\r?\n/).reverse()) {
165
+ if (!l.trim().startsWith('{')) continue
166
+ try {
167
+ const o = JSON.parse(l)
168
+ if (o?.v === 1 && pick(o) !== undefined) return pick(o)
169
+ } catch {
170
+ // not a line of serve's
171
+ }
172
+ }
173
+ return undefined
174
+ }
175
+
176
+ /** The end of `host.log`, in one line for a sentence ("" when there is none) */
177
+ export function hostLogTail(dataDir) {
178
+ const text = tail(join(dataDir, 'host.log'), 12)
179
+ .split(/\r?\n/)
180
+ .map((s) => s.trim())
181
+ .filter(Boolean)
182
+ .slice(-4)
183
+ .join(' / ')
184
+ return text.length > 600 ? `…${text.slice(-600)}` : text
185
+ }
186
+
187
+ /**
188
+ * Writes both pointers (`previous` first, then `current`, each by rename) and the managed launcher when it is not there
189
+ * yet (an update over an npm install). A null `current` gives the machine back the Centralu it ran before: the pointers
190
+ * and the launcher go, so the lookup finds that one again
191
+ */
192
+ export function setPointers(dataDir, current, previous, platform = process.platform) {
193
+ const l = remoteLayout(dataDir)
194
+ const launcher = join(l.bin, platform === 'win32' ? 'centralu.cmd' : 'centralu')
195
+ if (!current) {
196
+ for (const f of [l.previous, l.current, launcher]) rmSync(f, { force: true })
197
+ return
198
+ }
199
+ for (const v of [current, previous].filter(Boolean)) {
200
+ if (!existsSync(join(l.versions, v.version, 'install.json')) || !existsSync(join(l.node, `v${v.node}`))) throw new Error(`Centralu ${v.version} on Node ${v.node} is not there`)
201
+ }
202
+ if (!existsSync(launcher)) {
203
+ mkdirSync(l.bin, { recursive: true })
204
+ replaceFile(launcher, managedLauncherScript(platform), { mode: 0o755 })
205
+ }
206
+ if (previous) replaceFile(l.previous, line(previous))
207
+ else rmSync(l.previous, { force: true })
208
+ replaceFile(l.current, line(current))
209
+ }
210
+
211
+ /**
212
+ * Removes every version that is neither `current` nor `previous` nor the switch's runner (which reads its outcome), every
213
+ * Node none of them uses, and the store copies of versions that are gone (plan §10.5 step 6). A folder that cannot go (a Windows program still running from it) stays
214
+ * and is named, never fatal
215
+ */
216
+ export function prune(dataDir) {
217
+ const l = remoteLayout(dataDir)
218
+ const keep = [readPointerFile(l.current), readPointerFile(l.previous), readPointerFile(join(l.root, SWITCH_RUNNER))].filter(Boolean)
219
+ const removed = []
220
+ const left = []
221
+ const sweep = (dir, kept, named = true) => {
222
+ let names = []
223
+ try {
224
+ names = readdirSync(dir)
225
+ } catch {
226
+ return
227
+ }
228
+ for (const name of names) {
229
+ if (kept(name) || (name.startsWith('.') && !name.startsWith('.replaced-'))) continue
230
+ try {
231
+ rmSync(join(dir, name), { recursive: true, force: true })
232
+ if (named && !name.startsWith('.')) removed.push(name)
233
+ } catch {
234
+ if (named) left.push(name)
235
+ }
236
+ }
237
+ }
238
+ sweep(l.versions, (n) => keep.some((p) => p.version === n))
239
+ // What the installer moved aside and could not remove then
240
+ sweep(l.root, (n) => !n.startsWith('.replaced-'))
241
+ sweep(l.node, (n) => keep.some((p) => `v${p.node}` === n))
242
+ sweep(join(l.root, 'store-backup'), (n) => keep.some((p) => p.version === n), false)
243
+ return { removed, left }
244
+ }
245
+
246
+ /** The files of the host's store in the data folder: the database and SQLite's write-ahead log beside it */
247
+ const STORE_FILES = ['store.db', 'store.db-wal', 'store.db-shm']
248
+
249
+ /**
250
+ * A copy of the store taken while no host runs, before a dev build first opens it (#1036): a dev build may migrate the
251
+ * store past what the version before it reads, which would then refuse to start. Kept per `owner`, the version that left
252
+ * the store so (`store-backup/<owner>/`), replaced whole
253
+ */
254
+ export function storeBackup(dataDir, owner, before) {
255
+ const dir = join(remoteLayout(dataDir).root, 'store-backup')
256
+ const partial = join(dir, `.partial-${process.pid}`)
257
+ mkdirSync(dir, { recursive: true })
258
+ rmSync(partial, { recursive: true, force: true })
259
+ try {
260
+ mkdirSync(partial, { recursive: true })
261
+ const files = STORE_FILES.filter((f) => existsSync(join(dataDir, f)))
262
+ for (const f of files) cpSync(join(dataDir, f), join(partial, f))
263
+ writeFileSync(join(partial, 'backup.json'), JSON.stringify({ owner, before, files, at: new Date().toISOString() }, null, 2) + '\n')
264
+ rmSync(join(dir, owner), { recursive: true, force: true })
265
+ renameSync(partial, join(dir, owner))
266
+ } finally {
267
+ rmSync(partial, { recursive: true, force: true })
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Puts back the copy `storeBackup` took for `owner`, no host running; false when there is none. A write-ahead log the
273
+ * copy did not have is removed, so SQLite does not replay the dev build's changes onto it. The copy stays
274
+ */
275
+ export function storeRestore(dataDir, owner) {
276
+ const dir = join(remoteLayout(dataDir).root, 'store-backup', owner)
277
+ const meta = readJson(join(dir, 'backup.json'))
278
+ if (meta?.owner !== owner || !Array.isArray(meta.files)) return false
279
+ for (const f of STORE_FILES) rmSync(join(dataDir, f), { force: true })
280
+ for (const f of meta.files.filter((f) => STORE_FILES.includes(f))) cpSync(join(dir, f), join(dataDir, f))
281
+ return true
282
+ }
283
+
284
+ /**
285
+ * The work of one switch, in a data folder, with the steps written to `switch.json` as they are taken. `state` is the
286
+ * file's content; `finish` writes the outcome
287
+ */
288
+ function switchWork(dataDir, state, { env, platform }) {
289
+ const l = remoteLayout(dataDir)
290
+ const file = join(l.root, SWITCH_FILE)
291
+ /** Answers the switch as written, which `runSwitcher` and `reconcile` answer in turn (a status read prints it) */
292
+ const write = (patch) => {
293
+ Object.assign(state, patch, { at: Date.now() })
294
+ replaceFile(file, `${JSON.stringify(state)}\n`)
295
+ return state
296
+ }
297
+ const step = (s) => write({ step: s })
298
+ const from = state.was.current
299
+ const to = state.to
300
+
301
+ /** `serve --stop` of `v` (for an update: the stopping host lists the turns it ends, #1618) */
302
+ const stopWith = async (v) => {
303
+ const r = await serveOf(l, v, ['--stop'], { ...env, CENTRALU_STOP_BY: 'hub', CENTRALU_STOP_FOR: 'update' }, platform)
304
+ const stop = lastLine(r.stdout, (o) => o.stop)
305
+ if (!stop) return { ok: false, wasRunning: false, message: r.stderr.trim().split(/\r?\n/).at(-1) || `serve --stop gave no answer (exit ${r.code})` }
306
+ return { ok: stop.ok === true, wasRunning: stop.wasRunning === true, message: typeof stop.message === 'string' ? stop.message : null }
307
+ }
308
+
309
+ /**
310
+ * What answers within `ms`, by the connection line of the version `asker`: `{ ok: true }` once a host answers as
311
+ * `version` (null: any), `{ ok: false, other }` at once when one answers as another version, `{ ok: false }` when none
312
+ * did. Asked at least once
313
+ */
314
+ const hear = async (asker, version, ms = CHECK_MS) => {
315
+ const until = Date.now() + ms
316
+ for (;;) {
317
+ const r = await serveOf(l, asker, ['--connection'], env, platform)
318
+ const said = lastLine(r.stdout, (o) => (typeof o.port === 'number' ? o : undefined))
319
+ if (said?.hostRunning === true && (version === null || said.version === version)) return { ok: true }
320
+ if (said?.hostRunning === true && typeof said.version === 'string') return { ok: false, other: said.version }
321
+ if (Date.now() >= until) return { ok: false }
322
+ await sleep(1_000)
323
+ }
324
+ }
325
+
326
+ /**
327
+ * `v` started and answering as itself; otherwise why not. A start refused for WMI is left to the hub's link
328
+ * (`unchecked`). A host of another version found there instead was started meanwhile by something that does not ask
329
+ * the switch (a version from before it, through the sign-in task, the person or another hub): it is stopped and `v`
330
+ * started again, twice at most (#1907)
331
+ */
332
+ const startAndCheck = async (v, [startStep, checkStep] = ['start', 'check']) => {
333
+ for (let stopped = 0; ; stopped++) {
334
+ step(startStep)
335
+ const r = await serveOf(l, v, ['--detach'], env, platform)
336
+ const detach = lastLine(r.stdout, (o) => o.detach)
337
+ if (detach?.ok === false && detach.reason === 'wmi_blocked') return { why: null, unchecked: detach.message ?? 'WMI is blocked' }
338
+ const failed = detach?.ok === false ? (detach.message ?? 'it did not start') : !detach ? r.stderr.trim().split(/\r?\n/).at(-1) || `serve --detach gave no answer (exit ${r.code})` : null
339
+ if (checkStep) step(checkStep)
340
+ // A start that failed may have met a host starting meanwhile: a few seconds tell
341
+ const heard = await hear(v, v.version, failed ? 5_000 : CHECK_MS)
342
+ if (heard.ok) return { why: null }
343
+ if (!heard.other) return { why: failed ?? NO_ANSWER }
344
+ if (stopped === 2) return { why: `Centralu ${heard.other} answered there instead; something started it again during the switch` }
345
+ write({ startedMeanwhile: heard.other })
346
+ await stopWith(from && heard.other === from.version ? from : v)
347
+ }
348
+ }
349
+
350
+ /** The old version back after `why`: the new host stopped, the pointers as they were, the old host started and heard */
351
+ const putBack = async (why, extra = {}) => {
352
+ // `why` is kept from here, so a reader that finishes this put-back can still say it
353
+ write({ step: 'roll_back', why })
354
+ const log = hostLogTail(dataDir)
355
+ const stop = await stopWith(readPointerFile(l.current) ?? to)
356
+ const stopFailed = stop.ok ? null : stop.message
357
+ try {
358
+ setPointers(dataDir, from, state.was.previous, platform)
359
+ if (state.saveStore && from) storeRestore(dataDir, from.version)
360
+ if (state.wasRunning !== false) {
361
+ if (from) {
362
+ const back = await startAndCheck(from, ['restart', null])
363
+ if (back.why) throw new Error(back.why === NO_ANSWER ? `${NO_ANSWER} either` : back.why)
364
+ } else {
365
+ await startNpm(dataDir, env, platform)
366
+ if (!(await hear(to, null)).ok) throw new Error(`${NO_ANSWER} either`)
367
+ }
368
+ }
369
+ } catch (err) {
370
+ return write({ outcome: 'failed', stage: 'put_back', why, putBackWhy: err.message, stopFailed, log, ...extra })
371
+ }
372
+ return write({ outcome: 'put_back', why, log, ...extra })
373
+ }
374
+
375
+ return { step, write, stopWith, hear, startAndCheck, putBack, from, to }
376
+ }
377
+
378
+ /** The Centralu an npm install keeps its launcher for (`<data>/bin/centralu`), started again after a failed first switch */
379
+ function startNpm(dataDir, env, platform) {
380
+ const win = platform === 'win32'
381
+ const launcher = join(dataDir, 'bin', win ? 'centralu.cmd' : 'centralu')
382
+ if (!existsSync(launcher)) throw new Error('the Centralu that ran before has no launcher in the data folder')
383
+ return new Promise((resolve, reject) => {
384
+ const child = spawn(win ? `"${launcher}"` : launcher, ['serve', '--detach'], { env, stdio: 'ignore', shell: win, windowsHide: true })
385
+ child.once('error', reject)
386
+ child.once('close', () => resolve())
387
+ })
388
+ }
389
+
390
+ /**
391
+ * The switch itself (plan §10.5 steps 2 to 6): stop the running host, switch the pointers, start and check the new
392
+ * version, then prune; or put the old version back. `onLocked` runs once the lock is this one's. Returns the outcome
393
+ * written, or null when a live switcher holds the lock
394
+ */
395
+ export async function runSwitcher({ dataDir, params, env, platform = process.platform, onLocked = () => {} }) {
396
+ const l = remoteLayout(dataDir)
397
+ mkdirSync(l.root, { recursive: true })
398
+ const release = takeLock(l.root, params.id)
399
+ if (!release) return null
400
+ try {
401
+ const state = { v: 1, ...params, from: params.was.current, step: 'stop', startedAt: Date.now(), pid: process.pid, started: processStartTime(process.pid) }
402
+ const w = switchWork(dataDir, state, { env, platform })
403
+ w.write({})
404
+ onLocked(state)
405
+ const { from, to } = w
406
+ const stop = await w.stopWith(from ?? to)
407
+ if (!stop.ok) return w.write({ outcome: 'failed', stage: 'stop', why: stop.message ?? 'no reason given' })
408
+ w.write({ wasRunning: stop.wasRunning })
409
+ if (params.saveStore && from) {
410
+ w.step('backup')
411
+ try {
412
+ storeBackup(dataDir, from.version, to.version)
413
+ } catch (err) {
414
+ // Nothing was switched: the old version starts again as it was
415
+ if (stop.wasRunning) await w.startAndCheck(from, ['restart', null])
416
+ return w.write({ outcome: 'failed', stage: 'backup', why: err.message })
417
+ }
418
+ }
419
+ w.step('switch')
420
+ try {
421
+ setPointers(dataDir, to, params.keep, platform)
422
+ } catch (err) {
423
+ // `await`: the lock is released in `finally` only once the put-back is done
424
+ return await w.putBack(err.message)
425
+ }
426
+ let started = await w.startAndCheck(to)
427
+ let restored = false
428
+ // Leaving a dev build for a release that refuses the store the dev build left: the copy taken for it, and one more start (#1036)
429
+ if (started.why && !params.saveStore && LOCAL_BUILD.test(from?.version ?? '') && !LOCAL_BUILD.test(to.version)) {
430
+ w.step('restore_store')
431
+ await w.stopWith(to)
432
+ if (storeRestore(dataDir, to.version)) {
433
+ restored = true
434
+ started = await w.startAndCheck(to)
435
+ }
436
+ }
437
+ if (started.why) return await w.putBack(started.why)
438
+ return finishDone(dataDir, w, { ...(restored ? { restored } : {}), ...(started.unchecked ? { unchecked: started.unchecked } : {}) })
439
+ } finally {
440
+ release()
441
+ }
442
+ }
443
+
444
+ /** The new version answered: what no pointer, nor the switch's runner, names is removed, and the switch is `done` */
445
+ function finishDone(dataDir, w, extra = {}) {
446
+ w.step('prune')
447
+ let pruned = { removed: [], left: [] }
448
+ try {
449
+ pruned = prune(dataDir)
450
+ } catch {
451
+ // the next switch prunes again
452
+ }
453
+ return w.write({ outcome: 'done', ...pruned, ...extra })
454
+ }
455
+
456
+ /**
457
+ * A switch with no outcome whose switcher is not alive, finished by whoever read it, by the last step written: before
458
+ * the switch nothing changed (`failed`); from the switch until the check passed the old version is put back
459
+ * (`put_back`); after it (`prune`), `done`.
460
+ *
461
+ * What answers there decides first, and a reader never stops a host the switch did not start (#1907):
462
+ * - the version this part of the switch starts (the new one from the switch to the check, the old one in the
463
+ * put-back) answering as itself is taken as it is: `done`, or `put_back`; asked within the time the switcher would
464
+ * have given it, or once when that is past;
465
+ * - any other version answering, except the new one in the put-back (which the switch started and is ending), was
466
+ * started by someone else meanwhile (a version from before this switch asks none). It keeps serving: the old one
467
+ * with the pointers back as they were (`put_back`, `kept`), another with nothing changed (`failed`, stage `kept`).
468
+ * `start`: a host is started too (the old one, or the one that runs when nothing was switched); a caller that starts
469
+ * one itself next passes false. Answers the switch as it is now
470
+ */
471
+ export async function reconcile(dataDir, { env, platform = process.platform, start }) {
472
+ const s = readSwitch(dataDir)
473
+ if (!s || s.outcome) return s
474
+ const l = remoteLayout(dataDir)
475
+ if (lockIsLive(readJson(join(l.root, SWITCH_LOCK)))) return s
476
+ const release = takeLock(l.root, s.id)
477
+ if (!release) return readSwitch(dataDir)
478
+ try {
479
+ const again = readSwitch(dataDir)
480
+ if (!again || again.outcome) return again
481
+ const w = switchWork(dataDir, again, { env, platform })
482
+ if (again.step === 'prune') return finishDone(dataDir, w, { interrupted: true })
483
+ const now = readPointerFile(l.current)
484
+ const back = ['roll_back', 'restart'].includes(again.step)
485
+ const old = again.was.current
486
+ if (now && (back || ['switch', 'start', 'check'].includes(again.step))) {
487
+ // What this part of the switch starts; only it is waited for, and only when the pointers name it
488
+ const own = back ? old?.version : again.to.version
489
+ const left = (typeof again.at === 'number' ? again.at : 0) + (again.step === 'start' || again.step === 'restart' ? 2 : 1) * CHECK_MS - Date.now()
490
+ const heard = own ? await w.hear(now, own, now.version === own ? Math.max(0, left) : 0) : { ok: false }
491
+ if (heard.ok && now.version === own) {
492
+ return back ? w.write({ outcome: 'put_back', why: again.why ?? 'interrupted', interrupted: true }) : finishDone(dataDir, w, { interrupted: true })
493
+ }
494
+ const other = heard.ok ? own : heard.other
495
+ if (other && !(back && other === again.to.version)) {
496
+ const why = `Centralu ${other} was started there during the switch`
497
+ if (old && other === old.version) {
498
+ setPointers(dataDir, old, again.was.previous, platform)
499
+ return w.write({ outcome: 'put_back', why, kept: other, interrupted: true })
500
+ }
501
+ return w.write({ outcome: 'failed', stage: 'kept', why, kept: other, interrupted: true })
502
+ }
503
+ }
504
+ if (again.step === 'stop' || again.step === 'backup') {
505
+ w.write({ outcome: 'failed', stage: 'interrupted', why: 'interrupted before the switch', interrupted: true })
506
+ const now = readPointerFile(l.current)
507
+ if (start && now) await w.startAndCheck(now, ['restart', null])
508
+ return again
509
+ }
510
+ if (!start) again.wasRunning = false
511
+ return await w.putBack(again.why ?? 'interrupted', { interrupted: true })
512
+ } finally {
513
+ release()
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Before `serve` or `--detach` starts a host (§10.5a "No host starts beside a switch"): `{ go: true }`; `{ go: false,
519
+ * by, startedAt }` while a live switcher is between a stop and its start; `{ go: false, rerun }` when a switcher that died
520
+ * was finished here and `current` now names another version than this one (`version`), which then serves instead
521
+ */
522
+ export async function beforeHostStart({ dataDir, env, version, platform = process.platform }) {
523
+ const s = readSwitch(dataDir)
524
+ if (!s || s.outcome) return { go: true }
525
+ const l = remoteLayout(dataDir)
526
+ if (lockIsLive(readJson(join(l.root, SWITCH_LOCK)))) return QUIET_STEPS.has(s.step) ? { go: false, by: s.by, startedAt: s.startedAt } : { go: true }
527
+ await reconcile(dataDir, { env, platform, start: false })
528
+ const now = readPointerFile(l.current)
529
+ if (env.CENTRALU_MANAGED === '1' && now && now.version !== version) return { go: false, rerun: now }
530
+ return { go: true }
531
+ }
532
+
533
+ /** What `--detach` and `serve` say while a switch is under way there */
534
+ export function switchingMessage(gate) {
535
+ return `an update from ${gate.by} started at ${clockOf(gate.startedAt)} is switching versions here; no host starts until it ends`
536
+ }
537
+
538
+ /**
539
+ * `serve`, `--detach` and `--window` ask this before anything else (centralu.mjs), so a switcher that died is finished
540
+ * whatever this version would refuse next: a host already answering, a missing host entry, a Node below the floor (#1907).
541
+ * Null: go on. Otherwise the exit code, having said why or run the version `current` names now
542
+ */
543
+ export async function gateHostStart({ env, home, version, detach, platform = process.platform, write = (s) => process.stdout.write(s) }) {
544
+ const dataDir = serveDataDir(env, home)
545
+ const gate = await beforeHostStart({ dataDir, env, version, platform })
546
+ if (gate.go) return null
547
+ if (gate.rerun) return rerunAs(dataDir, gate.rerun, process.argv.slice(2), env, platform)
548
+ const message = switchingMessage(gate)
549
+ if (detach) return answerLine(write, 'detach', { ok: false, how: detachHow(platform, env), reason: 'switching', by: gate.by, startedAt: gate.startedAt, message })
550
+ say(message)
551
+ return 1
552
+ }
553
+
554
+ /** Runs `centralu serve <args>` of the version `current` names, in this terminal; answers its exit code */
555
+ export function rerunAs(dataDir, v, args, env, platform = process.platform) {
556
+ const { node, cli } = versionPaths(remoteLayout(dataDir), v, platform)
557
+ return new Promise((resolve) => {
558
+ const child = spawn(node, [cli, ...args], { env: { ...env, CENTRALU_MANAGED: '1' }, stdio: 'inherit', windowsHide: true })
559
+ child.once('error', () => resolve(1))
560
+ child.once('close', (code) => resolve(code ?? 1))
561
+ })
562
+ }
563
+
564
+ /** The variable the hub's handover names the running version in (`<version> <node>`), written to `switch.runner` */
565
+ export const RUNNER_ENV = 'CENTRALU_SWITCH_RUNNER'
566
+
567
+ /**
568
+ * `centralu serve --switch <arg>`, the handover: starts the switcher outside this ssh session (`detachHow`, as
569
+ * `--detach` starts a host), waits until it wrote its pid, prints `{ v: 1, switch: { ok: true, id, pid } }` and exits.
570
+ * When nothing can be started outside the session (WMI blocked, a `wsl.exe` that never ran), or the one started never
571
+ * wrote its pid, the switch runs here in the session: a link drop then ends it, and the next reader finishes it
572
+ * (`reconcile`). `{ ok: false, reason: 'busy', by, startedAt }` while another switch runs there, having changed
573
+ * nothing: `switch.runner` is written only once this one may run
574
+ */
575
+ export async function runSwitch({ env, home, raw, cliPath, execPath = process.execPath, platform = process.platform, start, write = (s) => process.stdout.write(s) }) {
576
+ const params = decodeSwitch(raw)
577
+ if (!params) return answerLine(write, 'switch', { ok: false, reason: 'params', message: 'this is not a switch serve --switch can read' })
578
+ const dataDir = serveDataDir(env, home)
579
+ const l = remoteLayout(dataDir)
580
+ mkdirSync(l.root, { recursive: true })
581
+ await reconcile(dataDir, { env, platform, start: false })
582
+ const busy = () => {
583
+ const s = readSwitch(dataDir)
584
+ return answerLine(write, 'switch', { ok: false, reason: 'busy', by: s?.by ?? null, startedAt: s?.startedAt ?? null, message: `another switch runs there${s ? `, from ${s.by} since ${clockOf(s.startedAt)}` : ''}` })
585
+ }
586
+ if (lockIsLive(readJson(join(l.root, SWITCH_LOCK)))) return busy()
587
+ const runner = parsePointer(env[RUNNER_ENV])
588
+ if (runner) replaceFile(join(l.root, SWITCH_RUNNER), line(runner))
589
+ const startOutside = start ?? (await import('./serve-detach.mjs')).startDetached
590
+ const created = await startOutside({ how: detachHow(platform, env), execPath, args: [cliPath, 'serve', '--switch-run', raw, ...dataDirArg(env)], env: { ...env, CENTRALU_MANAGED: '1' }, cwd: dataDir })
591
+ if (created.ok && (await waitUntil(() => readSwitch(dataDir)?.id === params.id, HANDOVER_MS))) {
592
+ return answerLine(write, 'switch', { ok: true, id: params.id, pid: readSwitch(dataDir).pid })
593
+ }
594
+ // Never ran, or not yet: what was created is ended (WSL's `wsl.exe` that hangs, §10.4), and the switch runs here
595
+ if (created.ok && readSwitch(dataDir)?.id !== params.id) await created.stop?.()
596
+ let said = false
597
+ const done = await runSwitcher({
598
+ dataDir,
599
+ params,
600
+ env,
601
+ platform,
602
+ onLocked: () => {
603
+ said = true
604
+ answerLine(write, 'switch', { ok: true, id: params.id, pid: process.pid, inSession: true })
605
+ },
606
+ })
607
+ if (said) return 0
608
+ // The detached one took the lock after all
609
+ const s = readSwitch(dataDir)
610
+ return s?.id === params.id ? answerLine(write, 'switch', { ok: true, id: params.id, pid: s.pid }) : done === null ? busy() : 1
611
+ }
612
+
613
+ /** `centralu serve --switch-run <arg> [<data folder>]`: the detached switcher `runSwitch` started */
614
+ export async function runSwitchRun({ env, home, raw, dataDir: dataArg = null, platform = process.platform }) {
615
+ const params = decodeSwitch(raw)
616
+ if (!params) return 2
617
+ const runEnv = withDataDir(env, dataArg)
618
+ const done = await runSwitcher({ dataDir: serveDataDir(runEnv, home), params, env: runEnv, platform })
619
+ return done ? 0 : 1
620
+ }
621
+
622
+ /**
623
+ * `centralu serve --switch-status [--seen <id>]`: `{ v: 1, switchStatus: <switch.json> | null }`. A switch whose
624
+ * switcher died is finished first, its old version started (`reconcile`). `--seen <id>`: that switch's outcome was
625
+ * reported to the person, so a hub that links later does not report it again
626
+ */
627
+ export async function runSwitchStatus({ env, home, seen = null, platform = process.platform, write = (s) => process.stdout.write(s) }) {
628
+ const dataDir = serveDataDir(env, home)
629
+ let s = await reconcile(dataDir, { env, platform, start: true })
630
+ if (s && seen && s.id === seen && s.outcome && !s.seen) {
631
+ s = { ...s, seen: true }
632
+ replaceFile(join(remoteLayout(dataDir).root, SWITCH_FILE), `${JSON.stringify(s)}\n`)
633
+ }
634
+ write(`${JSON.stringify({ v: 1, switchStatus: s })}\n`)
635
+ return 0
636
+ }
@@ -7,7 +7,7 @@
7
7
  import { join } from 'node:path'
8
8
  import { pairServe, readServeState } from './serve-files.mjs'
9
9
  import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
10
- import { probeHost, protocolVersionOf } from './serve-host.mjs'
10
+ import { askHostRpc, probeHost, protocolVersionOf } from './serve-host.mjs'
11
11
  import { serveFolder } from './serve-run.mjs'
12
12
  import { runDetach } from './serve-detach.mjs'
13
13
  import { startInSignIn } from './serve-autostart.mjs'
@@ -27,6 +27,9 @@ import { say, sleep } from './serve-util.mjs'
27
27
  * (`moveIntoSignIn`, serve-handover.mjs).
28
28
  * - stdin is the window (it runs this with stdin a pipe): when it ends, this exits 0 and leaves the host running,
29
29
  * since it serves the hub's link too.
30
+ * - A line `{"op":"system-ending"}` on that stdin (`"cancelled":true` calls it off) is the window saying the system is ending, a
31
+ * shutdown, restart or sign-out (#1970): the host this runs is no child of the window and hears nothing of it, so this tells it by
32
+ * `host.systemEnding`, with the window's full credential. A sign-out (`"logoff":true`) is not told to a host in a network logon, which it does not end.
30
33
  * - The host stops answering for `misses` polls in a row (the hand-over's gap, or it ended): this exits 1, and the
31
34
  * window's supervisor starts this again, which finds the new host or starts one.
32
35
  *
@@ -45,6 +48,7 @@ export async function runWindow({
45
48
  signIn = startInSignIn,
46
49
  pollMs = 5_000,
47
50
  misses = 3,
51
+ ask = askHostRpc,
48
52
  }) {
49
53
  const { dataDir } = serveFolder({ env, home, cliPath, platform })
50
54
  const protocolVersion = protocolVersionOf(entry) ?? 0
@@ -82,6 +86,16 @@ export async function runWindow({
82
86
  const state = readServeState(dataDir)
83
87
  write(`${JSON.stringify({ ready: true, port: state.port ?? DEFAULT_SERVE_PORT, token, db: join(dataDir, 'store.db') })}\n`)
84
88
 
89
+ const tellEnding = async (line) => {
90
+ const op = parseLine(line)
91
+ if (op?.op !== 'system-ending') return
92
+ // A sign-out does not end a host the ssh logon of a hub started: it runs on
93
+ if (op.logoff === true && op.cancelled !== true && (await probe()).logon === 'network') return
94
+ const { port, proofKey } = readServeState(dataDir) ?? {}
95
+ const sent = await ask({ port: port ?? DEFAULT_SERVE_PORT, token, proofKey, protocolVersion, method: 'host.systemEnding', params: op.cancelled === true ? { cancelled: true } : {} })
96
+ if (!sent) say('the host did not take the notice that the system is ending')
97
+ }
98
+
85
99
  return await new Promise((resolve) => {
86
100
  let done = false
87
101
  const finish = (code) => {
@@ -89,6 +103,9 @@ export async function runWindow({
89
103
  done = true
90
104
  resolve(code)
91
105
  }
106
+ // One after the other: the call-off must not overtake the notice it calls off
107
+ let told = Promise.resolve()
108
+ stdin.on('data', lineReader((line) => void (told = told.then(() => tellEnding(line)).catch(() => {}))))
92
109
  stdin.once('end', () => finish(0))
93
110
  stdin.once('close', () => finish(0))
94
111
  stdin.resume()
@@ -114,3 +131,17 @@ function parseLine(text) {
114
131
  return null
115
132
  }
116
133
  }
134
+
135
+ /** A `data` handler that gives `onLine` each complete line of the stream, however its chunks cut them */
136
+ function lineReader(onLine) {
137
+ let rest = ''
138
+ return (chunk) => {
139
+ rest += String(chunk)
140
+ let i
141
+ while ((i = rest.indexOf('\n')) >= 0) {
142
+ onLine(rest.slice(0, i))
143
+ rest = rest.slice(i + 1)
144
+ }
145
+ if (rest.length > 64 * 1024) rest = ''
146
+ }
147
+ }
package/bin/serve.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * folder (`serve-files.mjs`), the port (`serve-port.mjs`), the launchers and the managed install
22
22
  * (`serve-install.mjs`), the host and its socket (`serve-host.mjs`), the foreground serve
23
23
  * (`serve-run.mjs`), `--detach` and `--stop` (`serve-detach.mjs`), `--autostart`
24
- * (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
24
+ * (`serve-autostart.mjs`), the hub's version switch (`serve-switch.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
25
25
  * (`serve-util.mjs`).
26
26
  */
27
27
  export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, pairServe, pairKeyServe, dropVerifierUnder, revokeServe, verifierOf, limitedOf, changeServeState, heldHere, startedByServe, foreignHolder, holderWho, lockHolderIsAlive, processStartTime, processCreatedAt, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
@@ -36,3 +36,4 @@ export { runWindow } from './serve-window.mjs'
36
36
  export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart, startInSignIn } from './serve-autostart.mjs'
37
37
  export { printConnection, runServe, rotateToken, revokeCredential } from './serve-run.mjs'
38
38
  export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
39
+ export { SWITCH_FILE, SWITCH_LOCK, SWITCH_RUNNER, lockIsLive, decodeSwitch, readSwitch, setPointers, prune, storeBackup, storeRestore, hostLogTail, runSwitcher, reconcile, beforeHostStart, gateHostStart, clockOf, RUNNER_ENV, rerunAs, runSwitch, runSwitchRun, runSwitchStatus } from './serve-switch.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.46",
3
+ "version": "0.1.0-beta.48",
4
4
  "description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
5
5
  "license": "MIT",
6
6
  "author": "ijun17",
@@ -38,9 +38,9 @@
38
38
  "win32"
39
39
  ],
40
40
  "optionalDependencies": {
41
- "@centralu/darwin-arm64": "0.1.0-beta.46",
42
- "@centralu/linux-arm64": "0.1.0-beta.46",
43
- "@centralu/linux-x64": "0.1.0-beta.46",
44
- "@centralu/win32-x64": "0.1.0-beta.46"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.48",
42
+ "@centralu/linux-arm64": "0.1.0-beta.48",
43
+ "@centralu/linux-x64": "0.1.0-beta.48",
44
+ "@centralu/win32-x64": "0.1.0-beta.48"
45
45
  }
46
46
  }