@ctrl-spc/cs 0.7.13 → 0.7.15

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.
@@ -1,48 +1,155 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { configDir } from './config.js';
4
- import { processIsAlive } from './win-shell.js';
5
- /**
6
- * One `cs start` per config dir.
7
- *
8
- * Two daemons on one machine share one `cliv2_agents` row and take turns
9
- * overwriting it every heartbeat. The case that produced it: `cs start` was
10
- * running, `npm i -g` replaced dist under it, and a second `cs start` came up
11
- * on the new code. The row then flipped between 0.7.0 and 0.7.4 every few
12
- * seconds and the Machines popover flickered with it.
13
- *
14
- * Keyed on the config dir, not the machine id, so the documented two-instance
15
- * setup (`CTRL_SPC_V2_CONFIG_DIR` plus `CTRL_SPC_V2_MACHINE_ID`) still works.
16
- *
17
- * ponytail: a pid file, not an OS lock. A pid recycled onto an unrelated
18
- * process after a crash reads as "already running" until that process exits;
19
- * `rm ~/.config/ctrl-spc-v2/daemon.pid` is the way out. Upgrade to an
20
- * exclusive-open lock if that is ever hit in practice.
21
- */
22
- function lockPath() {
23
- return join(configDir(), 'daemon.pid');
24
- }
25
- /** Claims the lock for this process, or returns the pid of the daemon that holds it. */
26
- export function claimDaemonLock() {
27
- const path = lockPath();
28
- if (existsSync(path)) {
29
- const pid = Number(readFileSync(path, 'utf8').trim());
30
- if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
31
- return { held: true, pid };
32
- }
1
+ import { closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { configDir, ensureLifecycleDir, lifecycleDir } from './config.js';
6
+ import { inspectProcess, processIdentityMatches, findHandoverProcesses } from './win-shell.js';
7
+ const identity = z.object({
8
+ pid: z.number().int().positive(), ppid: z.number().int().nonnegative(),
9
+ pgid: z.number().int().positive().optional(), owner: z.string().min(1),
10
+ birth: z.string().min(1), commandHash: z.string().regex(/^[a-f0-9]{64}$/),
11
+ macProcessVersion: z.number().int().positive().max(0xffffffff).optional(),
12
+ });
13
+ const runtimeSchema = z.object({
14
+ schema: z.literal(1), nonce: z.string().uuid(), config: z.string(), machineId: z.string(),
15
+ process: identity, entry: z.string(), version: z.string(), port: z.number().int().min(1).max(65535),
16
+ state: z.enum(['starting', 'running', 'stopping', 'degraded']),
17
+ });
18
+ const operationSchema = z.object({
19
+ schema: z.literal(1), id: z.string().uuid(), owner: identity,
20
+ action: z.enum(['start', 'stop', 'restart']),
21
+ successor: z.string().uuid().nullable(), successorPid: z.number().int().positive().nullable(),
22
+ entry: z.string().nullable(),
23
+ });
24
+ const migrationSchema = z.object({
25
+ schema: z.literal(1), boot: z.string().min(1), installation: z.string(), prepared: z.boolean(),
26
+ completed: z.boolean(), machineId: z.string(), accountId: z.string().nullable(),
27
+ });
28
+ function path(name) { return join(lifecycleDir(), name); }
29
+ function read(name, schema) {
30
+ let text;
31
+ try {
32
+ text = readFileSync(path(name), 'utf8');
33
33
  }
34
- mkdirSync(configDir(), { recursive: true });
35
- writeFileSync(path, String(process.pid));
36
- return { held: false };
37
- }
38
- /** Removes the lock if this process wrote it. Never throws: it runs during shutdown. */
39
- export function releaseDaemonLock() {
34
+ catch (error) {
35
+ if (error.code === 'ENOENT')
36
+ return null;
37
+ throw new Error('Local service control records could not be read.', { cause: error });
38
+ }
39
+ let parsed;
40
40
  try {
41
- const path = lockPath();
42
- if (Number(readFileSync(path, 'utf8').trim()) === process.pid)
43
- rmSync(path);
41
+ parsed = JSON.parse(text);
44
42
  }
45
43
  catch {
46
- // no lock, or not ours
44
+ throw new Error('Local service control records are incomplete. No unverified process will be controlled.');
45
+ }
46
+ const result = schema.safeParse(parsed);
47
+ if (!result.success)
48
+ throw new Error('Local service control records are incomplete. No unverified process will be controlled.');
49
+ return result.data;
50
+ }
51
+ /** Publish complete records only. A temporary file never grants ownership. */
52
+ function publish(name, value, exclusive) {
53
+ const temp = join(ensureLifecycleDir(), name + '.' + randomUUID() + '.tmp');
54
+ const fd = openSync(temp, 'wx', 0o600);
55
+ try {
56
+ writeFileSync(fd, JSON.stringify(value));
57
+ fsyncSync(fd);
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ try {
63
+ if (exclusive)
64
+ linkSync(temp, path(name));
65
+ else
66
+ renameSync(temp, path(name));
67
+ }
68
+ finally {
69
+ rmSync(temp, { force: true });
47
70
  }
48
71
  }
72
+ export function readRuntime() {
73
+ const record = read('runtime.json', runtimeSchema);
74
+ if (record && record.config !== resolve(configDir()))
75
+ throw new Error('The service record belongs to another local instance.');
76
+ if (record && process.env.CTRL_SPC_V2_MACHINE_ID && record.machineId !== process.env.CTRL_SPC_V2_MACHINE_ID)
77
+ throw new Error('The service record belongs to another machine identity. Use that instance’s config and machine settings.');
78
+ return record;
79
+ }
80
+ export function readOperation() { return read('operation.json', operationSchema); }
81
+ export function readMigration() { return read('upgrade.json', migrationSchema); }
82
+ export function writeMigration(record) { publish('upgrade.json', migrationSchema.parse(record), false); }
83
+ export function publishRuntime(record) {
84
+ publish('runtime.json', runtimeSchema.parse(record), true);
85
+ writeFileSync(join(configDir(), 'daemon.pid'), String(record.process.pid), { mode: 0o600 });
86
+ }
87
+ export function updateRuntime(record) {
88
+ if (readRuntime()?.nonce !== record.nonce)
89
+ throw new Error('This process no longer owns the service record.');
90
+ publish('runtime.json', runtimeSchema.parse(record), false);
91
+ }
92
+ export function removeRuntime(nonce) {
93
+ const record = readRuntime();
94
+ if (!record || record.nonce !== nonce)
95
+ return;
96
+ rmSync(path('runtime.json'));
97
+ if (legacyPid() === record.process.pid)
98
+ rmSync(join(configDir(), 'daemon.pid'), { force: true });
99
+ }
100
+ export function legacyPid() {
101
+ const file = join(configDir(), 'daemon.pid');
102
+ if (!existsSync(file))
103
+ return null;
104
+ const value = Number(readFileSync(file, 'utf8').trim());
105
+ if (!Number.isInteger(value) || value <= 0)
106
+ throw new Error('The older service record is incomplete; prepare the one-time upgrade with cs restart.');
107
+ return value;
108
+ }
109
+ export async function claimLifecycleOperation(action, deadline) {
110
+ const owner = await inspectProcess(process.pid, deadline);
111
+ if (!owner)
112
+ throw new Error('Cannot verify this command process.');
113
+ const record = { schema: 1, id: randomUUID(), owner, action, successor: null, successorPid: null, entry: null };
114
+ for (let attempt = 0; attempt < 3; attempt++) {
115
+ try {
116
+ publish('operation.json', record, true);
117
+ return record;
118
+ }
119
+ catch (error) {
120
+ if (error.code !== 'EEXIST')
121
+ throw error;
122
+ }
123
+ const prior = readOperation();
124
+ if (!prior)
125
+ continue;
126
+ const actual = await inspectProcess(prior.owner.pid, deadline);
127
+ if (actual && processIdentityMatches(prior.owner, actual))
128
+ throw new Error('Another service command is in progress. Run cs status to check its result.');
129
+ if (prior.successor) {
130
+ const runtime = readRuntime();
131
+ if (runtime?.nonce === prior.successor) {
132
+ const child = await inspectProcess(runtime.process.pid, deadline);
133
+ if (child && processIdentityMatches(runtime.process, child)) {
134
+ removeOperation(prior.id);
135
+ continue;
136
+ }
137
+ }
138
+ const candidates = (await findHandoverProcesses(prior.successor, deadline)).filter((candidate) => candidate.owner === prior.owner.owner);
139
+ if (candidates.length) {
140
+ throw new Error('A prior restart is still being reconciled. Run cs status before retrying; no second service was started.');
141
+ }
142
+ }
143
+ removeOperation(prior.id);
144
+ }
145
+ throw new Error('Service ownership changed while the command was starting. Run cs status and retry.');
146
+ }
147
+ export function updateOperation(record) {
148
+ if (readOperation()?.id !== record.id)
149
+ throw new Error('The lifecycle operation is no longer owned by this command.');
150
+ publish('operation.json', operationSchema.parse(record), false);
151
+ }
152
+ export function removeOperation(id) {
153
+ if (readOperation()?.id === id)
154
+ rmSync(path('operation.json'));
155
+ }