Skip to content
Source
Sign inSign up
Open History to see the last commit

serviceProtocol.ts

355 lines13.1 KB

1import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
2​
3// Protocol 4 launchers coordinate selection writes and reconcile the unit. A remote update
4// replaces only the child, so an installed protocol-3 launcher stays until a local update or
5// service install restarts the service.
6export const SERVICE_LAUNCHER_PROTOCOL = 4 as const;
7/** Launchers this runtime runs under. Protocol 3 has the same IPC and durable format, and
8 refusing it stops every remote update until someone opens a shell on the host. */
9export type SupportedLauncherProtocol = 3 | typeof SERVICE_LAUNCHER_PROTOCOL;
10export const isSupportedLauncherProtocol = (
11 protocol: unknown,
12): protocol is SupportedLauncherProtocol =>
13 protocol === 3 || protocol === SERVICE_LAUNCHER_PROTOCOL;
14export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
15export const SERVICE_ENV_FILE = "service.env";
16export const SERVICE_STATE_FILE = "service-state.json";
17/** Set by the unit or launch agent to its own file name, so the launcher can
18 find the definition that started it. */
19export const SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
20/** Written before explicit stop to distinguish shutdown from update handoff. */
21export const SERVICE_STOP_MARKER_FILE = ".service-stopping";
22/** Marks a repointed unit whose service still runs the old version; launcher startup clears it. */
23export const SERVICE_RESTART_PENDING_FILE = ".restart-pending";
24/** Held by a Windows launcher while it runs: Task Scheduler tracks only the task's console host. */
25export const SERVICE_LAUNCHER_LOCK_FILE = "service-launcher.lock";
26​
27const unquoteSystemdValue = (raw: string) =>
28 (raw.startsWith('"') && raw.endsWith('"')
29 ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
30 : raw
31 ).replaceAll("%%", "%");
32​
33const unescapeXmlText = (value: string) =>
34 value.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
35​
36const unquoteWindowsArgument = (raw: string) =>
37 unescapeXmlText(raw).replace(/(\\+)$/, (slashes) => slashes.slice(slashes.length / 2));
38​
39/** Whether Task Scheduler's copy of a task is disabled as a whole, not one of its triggers. */
40export const serviceTaskIsDisabled = (taskXml: string): boolean =>
41 /<Settings>[\s\S]*?<\/Settings>/.exec(taskXml)?.[0].includes("<Enabled>false</Enabled>") === true;
42​
43/** A task as Task Scheduler should hold it while its service must stay stopped. */
44export const disabledServiceTask = (taskXml: string): string =>
45 serviceTaskIsDisabled(taskXml)
46 ? taskXml
47 : taskXml.replace(" </Settings>", " <Enabled>false</Enabled>\n </Settings>");
48​
49export function serviceUnitEnvironmentValueOf(
50 contents: string,
51 name: "T3CODE_HOME" | "T3CODE_INSTALL_BIN_DIR",
52): string | undefined {
53 const systemd = new RegExp(`^Environment=${name}=(.*)$`, "m").exec(contents)?.[1];
54 if (systemd !== undefined) return unquoteSystemdValue(systemd.trim());
55 const plist = new RegExp(`<key>${name}</key>\\s*<string>([^<]*)</string>`).exec(contents)?.[1];
56 return plist === undefined ? undefined : unescapeXmlText(plist);
58​
59export function serviceUnitBaseDirOf(contents: string): string | undefined {
60 const unix = serviceUnitEnvironmentValueOf(contents, "T3CODE_HOME");
61 if (unix !== undefined) return unix;
62 const task = /<Arguments>[^<]* "--base-dir" "([^"]*)"/.exec(contents)?.[1];
63 return task === undefined ? undefined : unquoteWindowsArgument(task);
65​
66/** The file systemd or launchd appends the service's output to. */
67export function serviceUnitLogPathOf(contents: string): string | undefined {
68 const systemd = /^StandardOutput=append:(.*)$/m.exec(contents)?.[1];
69 if (systemd !== undefined) return unquoteSystemdValue(systemd.trim());
70 const plist = /<key>StandardOutPath<\/key>\s*<string>([^<]*)<\/string>/.exec(contents)?.[1];
71 return plist === undefined ? undefined : unescapeXmlText(plist);
73​
74/** The executable an installed unit, plist, or task starts: the launcher's runtime. */
75export function serviceUnitProgramOf(contents: string): string | undefined {
76 const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1];
77 if (systemd !== undefined) return unquoteSystemdValue(systemd);
78 const plist = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(
79 contents,
80 )?.[1];
81 if (plist !== undefined) return unescapeXmlText(plist);
82 const task = /<Arguments>--headless "([^"]*)"/.exec(contents)?.[1];
83 return task === undefined ? undefined : unquoteWindowsArgument(task);
85​
86export interface PendingServiceUpdate {
87 readonly id: string;
88 readonly fromVersion: string;
89 readonly targetVersion: string;
90 readonly dbPath: string;
91 readonly status: "pending";
93​
94export type ServiceUpdateRecord = PendingServiceUpdate | ServerSelfUpdateOutcome;
95​
96export interface ServiceState {
97 readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL;
98 readonly activeVersion: string;
99 readonly update?: ServiceUpdateRecord;
101​
102export interface ServiceLauncherContext {
103 readonly protocol: SupportedLauncherProtocol;
104 readonly childVersion: string;
105 readonly update?: ServiceUpdateRecord;
107​
108export type ServiceLauncherChildMessage =
109 | {
110 readonly type: "request-update";
111 readonly targetVersion: string;
112 readonly dbPath: string;
113 }
114 | {
115 readonly type: "prepared";
116 readonly updateId: string;
117 };
118​
119export type ServiceLauncherParentMessage =
120 | {
121 readonly type: "update-accepted";
122 readonly updateId: string;
123 }
124 | {
125 readonly type: "update-rejected";
126 readonly reason: string;
127 }
128 | {
129 readonly type: "committed";
130 readonly updateId: string;
131 }
132 | {
133 /** Shut down as on SIGTERM, which Windows cannot deliver. */
134 readonly type: "stop";
135 };
136​
137const SEMVER_NUMBER = "(?:0|[1-9]\\d*)";
138const SEMVER_PRERELEASE = `(?:${SEMVER_NUMBER}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`;
139const EXACT_SERVICE_VERSION = new RegExp(
140 `^${SEMVER_NUMBER}\\.${SEMVER_NUMBER}\\.${SEMVER_NUMBER}(?:-${SEMVER_PRERELEASE}(?:\\.${SEMVER_PRERELEASE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
141);
142​
143export const isExactServiceVersion = (version: string): boolean =>
144 EXACT_SERVICE_VERSION.test(version);
145​
146const isRecord = (value: unknown): value is Record<string, unknown> =>
147 typeof value === "object" && value !== null && !Array.isArray(value);
148​
149function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined {
150 if (!isRecord(value)) return undefined;
151 const { id, fromVersion, targetVersion, status } = value;
152 if (
153 typeof id !== "string" ||
154 id.trim() === "" ||
155 typeof fromVersion !== "string" ||
156 !isExactServiceVersion(fromVersion) ||
157 typeof targetVersion !== "string" ||
158 !isExactServiceVersion(targetVersion)
159 ) {
160 return undefined;
161 }
162 if (status === "pending") {
163 return typeof value.dbPath === "string" && value.dbPath.trim() !== ""
164 ? { id, fromVersion, targetVersion, dbPath: value.dbPath, status }
165 : undefined;
166 }
167 if (
168 (status === "committed" || status === "rolled-back" || status === "failed") &&
169 (value.reason === undefined || (typeof value.reason === "string" && value.reason.trim() !== ""))
170 ) {
171 return {
172 id,
173 fromVersion,
174 targetVersion,
175 status,
176 ...(typeof value.reason === "string" ? { reason: value.reason } : {}),
177 };
178 }
179 return undefined;
181​
182export function compareExactServiceVersions(left: string, right: string): number {
183 const parse = (version: string) => {
184 const withoutBuild = version.split("+", 1)[0] ?? version;
185 const separator = withoutBuild.indexOf("-");
186 const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
187 const prerelease = separator === -1 ? undefined : withoutBuild.slice(separator + 1);
188 const [major = "0", minor = "0", patch = "0"] = core.split(".");
189 return {
190 core: [BigInt(major), BigInt(minor), BigInt(patch)] as const,
191 prerelease: prerelease?.split(".") ?? [],
192 };
193 };
194 const a = parse(left);
195 const b = parse(right);
196 for (let index = 0; index < 3; index += 1) {
197 const x = a.core[index] ?? 0n;
198 const y = b.core[index] ?? 0n;
199 if (x !== y) return x < y ? -1 : 1;
200 }
201 if (a.prerelease.length === 0 || b.prerelease.length === 0) {
202 return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
203 }
204 const count = Math.max(a.prerelease.length, b.prerelease.length);
205 for (let index = 0; index < count; index += 1) {
206 const x = a.prerelease[index];
207 const y = b.prerelease[index];
208 if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
209 if (x === y) continue;
210 const xNumeric = /^\d+$/.test(x);
211 const yNumeric = /^\d+$/.test(y);
212 if (xNumeric && yNumeric) return BigInt(x) < BigInt(y) ? -1 : 1;
213 if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
214 return x < y ? -1 : 1;
215 }
216 return 0;
218​
219export function decodeServiceState(value: unknown): ServiceState | undefined {
220 if (!isRecord(value)) return undefined;
221 const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update);
222 if (
223 // A protocol-3 installer can select a protocol-4 runtime.
224 !isSupportedLauncherProtocol(value.protocol) ||
225 typeof value.activeVersion !== "string" ||
226 !isExactServiceVersion(value.activeVersion) ||
227 (value.update !== undefined && update === undefined) ||
228 (update !== undefined &&
229 compareExactServiceVersions(update.targetVersion, update.fromVersion) <= 0) ||
230 (update?.status === "pending" && update.fromVersion !== value.activeVersion) ||
231 (update?.status === "committed" && update.targetVersion !== value.activeVersion) ||
232 ((update?.status === "rolled-back" || update?.status === "failed") &&
233 update.fromVersion !== value.activeVersion)
234 ) {
235 return undefined;
236 }
237 return {
238 protocol: SERVICE_LAUNCHER_PROTOCOL,
239 activeVersion: value.activeVersion,
240 ...(update === undefined ? {} : { update }),
241 };
243​
244export function parseServiceState(value: string): ServiceState | undefined {
245 try {
246 return decodeServiceState(JSON.parse(value) as unknown);
247 } catch {
248 return undefined;
249 }
251​
252export function parseLegacyServiceState(value: string) {
253 try {
254 const parsed: unknown = JSON.parse(value);
255 if (
256 !isRecord(parsed) ||
257 Object.keys(parsed).length !== 2 ||
258 parsed.protocol !== 2 ||
259 typeof parsed.activeVersion !== "string" ||
260 !isExactServiceVersion(parsed.activeVersion)
261 ) {
262 return undefined;
263 }
264 return { protocol: 2, activeVersion: parsed.activeVersion } as const;
265 } catch {
266 return undefined;
267 }
269​
270export function serviceStateHasPendingUpdate(value: string): boolean {
271 try {
272 const parsed: unknown = JSON.parse(value);
273 return isRecord(parsed) && isRecord(parsed.update) && parsed.update.status === "pending";
274 } catch {
275 return false;
276 }
278​
279export function serviceStateActiveVersion(value: string): string | undefined {
280 try {
281 const parsed: unknown = JSON.parse(value);
282 return isRecord(parsed) &&
283 typeof parsed.activeVersion === "string" &&
284 isExactServiceVersion(parsed.activeVersion)
285 ? parsed.activeVersion
286 : undefined;
287 } catch {
288 return undefined;
289 }
291​
292export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
293 let parsed: unknown;
294 try {
295 parsed = JSON.parse(value) as unknown;
296 } catch {
297 return undefined;
298 }
299 if (
300 !isRecord(parsed) ||
301 !isSupportedLauncherProtocol(parsed.protocol) ||
302 typeof parsed.childVersion !== "string" ||
303 !isExactServiceVersion(parsed.childVersion)
304 ) {
305 return undefined;
306 }
307 const update = parsed.update === undefined ? undefined : decodeServiceUpdate(parsed.update);
308 if (parsed.update !== undefined && update === undefined) return undefined;
309 const selectedVersion =
310 update?.status === "pending" || update?.status === "committed"
311 ? update.targetVersion
312 : update === undefined
313 ? parsed.childVersion
314 : update.fromVersion;
315 if (parsed.childVersion !== selectedVersion) {
316 return undefined;
317 }
318 return {
319 protocol: parsed.protocol,
320 childVersion: parsed.childVersion,
321 ...(update === undefined ? {} : { update }),
322 };
324​
325export function decodeServiceLauncherChildMessage(
326 value: unknown,
327): ServiceLauncherChildMessage | undefined {
328 if (!isRecord(value)) return undefined;
329 if (
330 value.type === "request-update" &&
331 typeof value.targetVersion === "string" &&
332 typeof value.dbPath === "string"
333 ) {
334 return { type: value.type, targetVersion: value.targetVersion, dbPath: value.dbPath };
335 }
336 return value.type === "prepared" && typeof value.updateId === "string"
337 ? { type: value.type, updateId: value.updateId }
338 : undefined;
340​
341export function decodeServiceLauncherParentMessage(
342 value: unknown,
343): ServiceLauncherParentMessage | undefined {
344 if (!isRecord(value)) return undefined;
345 if (value.type === "update-rejected" && typeof value.reason === "string") {
346 return { type: value.type, reason: value.reason };
347 }
348 if (value.type === "update-accepted" && typeof value.updateId === "string") {
349 return { type: value.type, updateId: value.updateId };
350 }
351 if (value.type === "stop") return { type: value.type };
352 return value.type === "committed" && typeof value.updateId === "string"
353 ? { type: value.type, updateId: value.updateId }
354 : undefined;