@nissyai/desktop 0.2.36 → 0.2.38
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/nissy.mjs +195 -1
- package/package.json +4 -4
- package/src/cli.mjs +169 -0
- package/src/resolve.d.mts +14 -0
- package/src/store.d.mts +38 -0
- package/src/store.mjs +977 -0
package/src/store.mjs
ADDED
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The versioned application store.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* The npm channel used to update itself by running `npm i -g`. That cannot be made
|
|
7
|
+
* safe. npm's global reify renames the installed package to a hidden directory before
|
|
8
|
+
* unpacking the replacement, and rolls that rename back only on a best-effort basis. On
|
|
9
|
+
* Windows the running `nissy.exe` is locked, so the unpack fails, the rollback fails
|
|
10
|
+
* too, and the package is gone from npm's view — an update that ends with no
|
|
11
|
+
* application installed at all. That has happened to a real machine.
|
|
12
|
+
*
|
|
13
|
+
* The replacement never lets anything mutate the copy that is running. Each version
|
|
14
|
+
* lives in its own directory and a pointer says which one is current:
|
|
15
|
+
*
|
|
16
|
+
* <cacheRoot>/store/
|
|
17
|
+
* versions/0.2.35/ one extracted application per version
|
|
18
|
+
* versions/0.2.36/
|
|
19
|
+
* current -> pointer at the version that runs
|
|
20
|
+
* previous -> pointer at the one before, kept for rollback
|
|
21
|
+
* pending.json a staged version waiting for a restart
|
|
22
|
+
* health.json the last version that started successfully
|
|
23
|
+
*
|
|
24
|
+
* Updating is: download beside the running copy, verify it, then move a pointer. The
|
|
25
|
+
* worst outcome of any failure is "still on the old version", because the old version
|
|
26
|
+
* was never touched.
|
|
27
|
+
*
|
|
28
|
+
* WHAT LIVES HERE
|
|
29
|
+
*
|
|
30
|
+
* Path resolution, the small JSON state files, and the DECISIONS — seed, apply, roll
|
|
31
|
+
* back, prune — as pure functions. The filesystem work and the platform-specific
|
|
32
|
+
* pointer mechanics (a directory junction on Windows, the `~/Applications` bundle on
|
|
33
|
+
* macOS) stay in the launcher, so every rule below is unit-testable without a store on
|
|
34
|
+
* disk.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
appendFileSync,
|
|
39
|
+
closeSync,
|
|
40
|
+
openSync,
|
|
41
|
+
readSync,
|
|
42
|
+
copyFileSync,
|
|
43
|
+
existsSync,
|
|
44
|
+
mkdirSync,
|
|
45
|
+
readFileSync,
|
|
46
|
+
linkSync,
|
|
47
|
+
readdirSync,
|
|
48
|
+
realpathSync,
|
|
49
|
+
rmSync,
|
|
50
|
+
statSync,
|
|
51
|
+
symlinkSync,
|
|
52
|
+
writeFileSync,
|
|
53
|
+
} from "node:fs";
|
|
54
|
+
import { basename, join } from "node:path";
|
|
55
|
+
|
|
56
|
+
import { cacheRootSegments } from "./resolve.mjs";
|
|
57
|
+
|
|
58
|
+
/** Subdirectory of the per-user cache root that holds the whole store. */
|
|
59
|
+
export const STORE_DIRNAME = "store";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Written last inside a version directory, and required before that version is
|
|
63
|
+
* considered usable.
|
|
64
|
+
*
|
|
65
|
+
* This replaces an earlier staging-plus-rename. Renaming a freshly populated
|
|
66
|
+
* 450 MB tree fails with EPERM on Windows often enough to matter: the seed hard
|
|
67
|
+
* links from a just-extracted copy, so Defender is still scanning those inodes,
|
|
68
|
+
* and a directory containing an open handle cannot be renamed. Observed on a
|
|
69
|
+
* real machine seeding two published builds back to back. Writing in place and
|
|
70
|
+
* marking completion at the end removes the rename entirely rather than
|
|
71
|
+
* retrying it, and gives the same guarantee: a directory without this file was
|
|
72
|
+
* never finished and is treated as absent.
|
|
73
|
+
*/
|
|
74
|
+
export const SEED_COMPLETE_MARKER = ".seed-complete";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve every path in the store for a host.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ platform: NodeJS.Platform, home: string, env: NodeJS.ProcessEnv, cacheName?: string }} input
|
|
80
|
+
* @returns {{ root: string, versionsDir: string, currentLink: string, previousLink: string, pendingFile: string, healthFile: string }}
|
|
81
|
+
*/
|
|
82
|
+
export function storeLayout({ platform, home, env, cacheName }) {
|
|
83
|
+
const root = join(
|
|
84
|
+
...cacheRootSegments({ platform, home, env, ...(cacheName ? { cacheName } : {}) }),
|
|
85
|
+
STORE_DIRNAME,
|
|
86
|
+
);
|
|
87
|
+
return {
|
|
88
|
+
root,
|
|
89
|
+
versionsDir: join(root, "versions"),
|
|
90
|
+
currentLink: join(root, "current"),
|
|
91
|
+
previousLink: join(root, "previous"),
|
|
92
|
+
pendingFile: join(root, "pending.json"),
|
|
93
|
+
healthFile: join(root, "health.json"),
|
|
94
|
+
logFile: join(root, "store.log"),
|
|
95
|
+
lockDir: join(root, "store.lock"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Directory for one version. Version strings come from a package.json we published, but
|
|
101
|
+
* this path is joined into the filesystem, so anything that could climb out of the
|
|
102
|
+
* store is refused rather than sanitised — a silently rewritten version number would be
|
|
103
|
+
* worse than a hard failure.
|
|
104
|
+
*
|
|
105
|
+
* @param {{ versionsDir: string }} layout
|
|
106
|
+
* @param {string} version
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function versionDir(layout, version) {
|
|
110
|
+
if (!isSafeVersion(version)) {
|
|
111
|
+
throw new Error(`unsafe version string: ${JSON.stringify(version)}`);
|
|
112
|
+
}
|
|
113
|
+
return join(layout.versionsDir, version);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Semver-ish, and free of anything that means something to a path.
|
|
117
|
+
*
|
|
118
|
+
* The leading character may not be a dot. Nothing publishes a version starting
|
|
119
|
+
* with one, and the store's own bookkeeping lives in dotfiles — `.seed-complete`
|
|
120
|
+
* is a name a "version" could otherwise claim. Mirrored in the app's
|
|
121
|
+
* `npm-store.ts`; `npm-store.parity.test.ts` fails if the two drift, which is
|
|
122
|
+
* how this rule got tightened. */
|
|
123
|
+
export function isSafeVersion(version) {
|
|
124
|
+
return (
|
|
125
|
+
typeof version === "string" &&
|
|
126
|
+
/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version) &&
|
|
127
|
+
!version.includes("..")
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── version comparison ────────────────────────────────────────────────────
|
|
132
|
+
//
|
|
133
|
+
// MIRRORS `compareVersions` in apps/desktop/src/main/features/auto-update/npx-update.ts.
|
|
134
|
+
// The launcher and the app are published together but share no runtime code, so this
|
|
135
|
+
// parallel copy is deliberate — the same reason `cacheRootSegments` is duplicated. The
|
|
136
|
+
// test suite pins the same cases as the app's, so a divergence fails somewhere.
|
|
137
|
+
|
|
138
|
+
/** @returns {{ core: [number, number, number], pre: number | null } | null} */
|
|
139
|
+
function parseVersion(version) {
|
|
140
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?$/u.exec(String(version).trim());
|
|
141
|
+
if (m === null) return null;
|
|
142
|
+
const core = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
143
|
+
const pre = m[4] === undefined ? null : Number(m[4]);
|
|
144
|
+
if (!core.every(Number.isSafeInteger)) return null;
|
|
145
|
+
if (pre !== null && !Number.isSafeInteger(pre)) return null;
|
|
146
|
+
return { core, pre };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* -1 / 0 / 1. A release outranks a prerelease of the same core. An unparseable version
|
|
151
|
+
* sorts oldest, so a version we cannot read never wins a comparison and never triggers
|
|
152
|
+
* a seed or an update.
|
|
153
|
+
*/
|
|
154
|
+
export function compareVersions(a, b) {
|
|
155
|
+
const pa = parseVersion(a);
|
|
156
|
+
const pb = parseVersion(b);
|
|
157
|
+
if (pa === null && pb === null) return 0;
|
|
158
|
+
if (pa === null) return -1;
|
|
159
|
+
if (pb === null) return 1;
|
|
160
|
+
for (let i = 0; i < 3; i += 1) {
|
|
161
|
+
if (pa.core[i] !== pb.core[i]) return pa.core[i] < pb.core[i] ? -1 : 1;
|
|
162
|
+
}
|
|
163
|
+
if (pa.pre === pb.pre) return 0;
|
|
164
|
+
if (pa.pre === null) return 1; // release > prerelease
|
|
165
|
+
if (pb.pre === null) return -1;
|
|
166
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function isNewer(candidate, than) {
|
|
170
|
+
return compareVersions(candidate, than) > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── state files ───────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** Read and parse a JSON file, or null when absent/unreadable/malformed. */
|
|
176
|
+
function readJson(path) {
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = readFileSync(path, "utf8");
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const parsed = JSON.parse(raw);
|
|
185
|
+
return parsed !== null && typeof parsed === "object" ? parsed : null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function writeJson(path, value) {
|
|
192
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
193
|
+
writeFileSync(path, JSON.stringify(value), "utf8");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* How many launches a newly applied version gets to prove it starts.
|
|
198
|
+
*
|
|
199
|
+
* Three, matching systemd-boot's default boot counting. ChromeOS uses five;
|
|
200
|
+
* Android leaves the count to the bootloader. The shape is the same everywhere:
|
|
201
|
+
* a bounded number of attempts, then an automatic return to the last version
|
|
202
|
+
* known to work.
|
|
203
|
+
*/
|
|
204
|
+
export const DEFAULT_TRIES = 3;
|
|
205
|
+
|
|
206
|
+
/** @returns {{ version: string, triesLeft: number } | null} */
|
|
207
|
+
export function readPending(layout) {
|
|
208
|
+
const p = readJson(layout.pendingFile);
|
|
209
|
+
if (!p || !isSafeVersion(p.version)) return null;
|
|
210
|
+
// A pending file written before tries existed is treated as having a full
|
|
211
|
+
// allowance rather than none, so an in-flight update is not rolled back by
|
|
212
|
+
// the upgrade that introduced the counter.
|
|
213
|
+
const triesLeft = Number.isInteger(p.triesLeft) ? p.triesLeft : DEFAULT_TRIES;
|
|
214
|
+
return { version: p.version, triesLeft };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function writePending(layout, version, triesLeft = DEFAULT_TRIES) {
|
|
218
|
+
writeJson(layout.pendingFile, {
|
|
219
|
+
version,
|
|
220
|
+
triesLeft,
|
|
221
|
+
stagedAt: new Date().toISOString(),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Record one attempt at starting the pending version. */
|
|
226
|
+
export function consumeTry(layout) {
|
|
227
|
+
const pending = readPending(layout);
|
|
228
|
+
if (pending === null) return null;
|
|
229
|
+
const next = { ...pending, triesLeft: pending.triesLeft - 1 };
|
|
230
|
+
writeJson(layout.pendingFile, { ...next, stagedAt: new Date().toISOString() });
|
|
231
|
+
return next;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function clearPending(layout) {
|
|
235
|
+
try {
|
|
236
|
+
rmSync(layout.pendingFile, { force: true });
|
|
237
|
+
} catch {
|
|
238
|
+
/* best-effort: a stale pending is re-evaluated, not fatal */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @returns {{ version: string } | null} */
|
|
243
|
+
export function readHealth(layout) {
|
|
244
|
+
const h = readJson(layout.healthFile);
|
|
245
|
+
return h && isSafeVersion(h.version) ? { version: h.version } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function writeHealth(layout, version) {
|
|
249
|
+
writeJson(layout.healthFile, { version, confirmedAt: new Date().toISOString() });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Version directories present, unsorted. */
|
|
253
|
+
export function listVersions(layout) {
|
|
254
|
+
try {
|
|
255
|
+
return readdirSync(layout.versionsDir, { withFileTypes: true })
|
|
256
|
+
.filter(
|
|
257
|
+
(e) =>
|
|
258
|
+
e.isDirectory() &&
|
|
259
|
+
isSafeVersion(e.name) &&
|
|
260
|
+
isCompleteVersion(join(layout.versionsDir, e.name)),
|
|
261
|
+
)
|
|
262
|
+
.map((e) => e.name);
|
|
263
|
+
} catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── the decisions ─────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Should the npm payload be copied into the store as a version?
|
|
272
|
+
*
|
|
273
|
+
* The subtle case, and the reason this is not simply "they differ": after a
|
|
274
|
+
* self-update the store is AHEAD of npm. The npm payload is whatever was last
|
|
275
|
+
* installed by `npm i -g` and goes stale the moment the app updates itself. Seeding on
|
|
276
|
+
* inequality would therefore downgrade every self-updated user back to their install
|
|
277
|
+
* version on the next launch.
|
|
278
|
+
*
|
|
279
|
+
* So: seed when there is no current version at all (first run, or a store that lost
|
|
280
|
+
* its pointer), or when npm genuinely carries something NEWER — which is what happens
|
|
281
|
+
* when a user runs `npm i -g` themselves to jump forward or to repair a broken store.
|
|
282
|
+
*
|
|
283
|
+
* @param {{ currentVersion: string | null, payloadVersion: string, versionExists: boolean }} input
|
|
284
|
+
* @returns {boolean}
|
|
285
|
+
*/
|
|
286
|
+
export function shouldSeedFromPayload({ currentVersion, payloadVersion, versionExists }) {
|
|
287
|
+
if (!isSafeVersion(payloadVersion)) return false;
|
|
288
|
+
if (currentVersion === null) return true;
|
|
289
|
+
if (!isNewer(payloadVersion, currentVersion)) return false;
|
|
290
|
+
// Newer, but already extracted by an earlier run — point at it, don't re-extract.
|
|
291
|
+
return !versionExists;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** What the launcher should do about the store before it spawns anything. */
|
|
295
|
+
export const STORE_ACTION = {
|
|
296
|
+
/** Nothing to do; launch `current`. */
|
|
297
|
+
launch: "launch",
|
|
298
|
+
/** Copy/extract the npm payload in, then point `current` at it. */
|
|
299
|
+
seed: "seed",
|
|
300
|
+
/** A staged version is ready; point `current` at it before launching. */
|
|
301
|
+
applyPending: "apply-pending",
|
|
302
|
+
/** The last applied version never reported a healthy start; go back. */
|
|
303
|
+
rollback: "rollback",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The single decision the launcher makes at startup.
|
|
308
|
+
*
|
|
309
|
+
* Order matters and encodes the safety rules:
|
|
310
|
+
*
|
|
311
|
+
* 1. **Rollback wins.** A `current` that was applied from `pending` but never wrote
|
|
312
|
+
* `health.json` did not start. Launching it again would loop a user into a version
|
|
313
|
+
* that cannot run, which is the one failure worse than not updating.
|
|
314
|
+
* 2. **Then seeding**, so a store with no pointer — or one a user has just repaired
|
|
315
|
+
* with `npm i -g` — becomes launchable before anything else is considered.
|
|
316
|
+
* 3. **Then a pending apply.** The user chose "later", or the app staged an update and
|
|
317
|
+
* the process was killed before it restarted itself; either way the next launch
|
|
318
|
+
* picks it up, the way Velopack applies on startup.
|
|
319
|
+
*
|
|
320
|
+
* @param {{
|
|
321
|
+
* currentVersion: string | null,
|
|
322
|
+
* previousVersion: string | null,
|
|
323
|
+
* pending: { version: string } | null,
|
|
324
|
+
* health: { version: string } | null,
|
|
325
|
+
* payloadVersion: string,
|
|
326
|
+
* versionExists: (version: string) => boolean,
|
|
327
|
+
* }} input
|
|
328
|
+
* @returns {{ action: string, version: string | null, reason: string }}
|
|
329
|
+
*/
|
|
330
|
+
export function decideStartupAction({
|
|
331
|
+
currentVersion,
|
|
332
|
+
previousVersion,
|
|
333
|
+
pending,
|
|
334
|
+
health,
|
|
335
|
+
payloadVersion,
|
|
336
|
+
versionExists,
|
|
337
|
+
}) {
|
|
338
|
+
// 1. Did the version we last switched to fail to start, repeatedly?
|
|
339
|
+
// `pending` still naming the CURRENT version means the swap happened but the
|
|
340
|
+
// healthy-start handshake never completed. One failure is not enough to act
|
|
341
|
+
// on — a machine can be rebooted mid-launch — so this waits for the attempt
|
|
342
|
+
// allowance to run out, the way systemd-boot, ChromeOS and Android A/B all
|
|
343
|
+
// do. Every one of those requires a POSITIVE signal from userspace and
|
|
344
|
+
// bounds the number of attempts; none treat "the process was created" as
|
|
345
|
+
// proof that anything started.
|
|
346
|
+
if (
|
|
347
|
+
pending !== null &&
|
|
348
|
+
currentVersion !== null &&
|
|
349
|
+
pending.version === currentVersion &&
|
|
350
|
+
(health === null || health.version !== currentVersion) &&
|
|
351
|
+
pending.triesLeft <= 0 &&
|
|
352
|
+
previousVersion !== null &&
|
|
353
|
+
previousVersion !== currentVersion &&
|
|
354
|
+
versionExists(previousVersion)
|
|
355
|
+
) {
|
|
356
|
+
return {
|
|
357
|
+
action: STORE_ACTION.rollback,
|
|
358
|
+
version: previousVersion,
|
|
359
|
+
reason: `${currentVersion} was applied but never confirmed a start in ${DEFAULT_TRIES} attempts; going back to ${previousVersion}`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 2. No launchable current, or npm is genuinely ahead.
|
|
364
|
+
if (
|
|
365
|
+
shouldSeedFromPayload({
|
|
366
|
+
currentVersion,
|
|
367
|
+
payloadVersion,
|
|
368
|
+
versionExists: versionExists(payloadVersion),
|
|
369
|
+
})
|
|
370
|
+
) {
|
|
371
|
+
return {
|
|
372
|
+
action: STORE_ACTION.seed,
|
|
373
|
+
version: payloadVersion,
|
|
374
|
+
reason:
|
|
375
|
+
currentVersion === null
|
|
376
|
+
? `no current version; seeding ${payloadVersion} from the npm payload`
|
|
377
|
+
: `npm payload ${payloadVersion} is newer than ${currentVersion}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// A current version that npm is ahead of, but whose directory already exists, just
|
|
382
|
+
// needs the pointer moved.
|
|
383
|
+
if (
|
|
384
|
+
currentVersion !== null &&
|
|
385
|
+
isNewer(payloadVersion, currentVersion) &&
|
|
386
|
+
versionExists(payloadVersion)
|
|
387
|
+
) {
|
|
388
|
+
return {
|
|
389
|
+
action: STORE_ACTION.applyPending,
|
|
390
|
+
version: payloadVersion,
|
|
391
|
+
reason: `npm payload ${payloadVersion} is already extracted; pointing current at it`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 3. A staged version waiting for this restart.
|
|
396
|
+
if (
|
|
397
|
+
pending !== null &&
|
|
398
|
+
pending.version !== currentVersion &&
|
|
399
|
+
versionExists(pending.version)
|
|
400
|
+
) {
|
|
401
|
+
return {
|
|
402
|
+
action: STORE_ACTION.applyPending,
|
|
403
|
+
version: pending.version,
|
|
404
|
+
reason: `applying staged version ${pending.version}`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { action: STORE_ACTION.launch, version: currentVersion, reason: "current is up to date" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Version directories safe to delete: everything that is neither current, nor the
|
|
413
|
+
* retained previous, nor a staged pending. Pruning is only ever done AFTER a healthy
|
|
414
|
+
* start, so a version that has not yet proved itself is never the thing removed.
|
|
415
|
+
*
|
|
416
|
+
* @param {{ versions: readonly string[], currentVersion: string | null, previousVersion: string | null, pending: { version: string } | null }} input
|
|
417
|
+
* @returns {string[]}
|
|
418
|
+
*/
|
|
419
|
+
export function prunableVersions({ versions, currentVersion, previousVersion, pending }) {
|
|
420
|
+
const keep = new Set(
|
|
421
|
+
[currentVersion, previousVersion, pending?.version].filter((v) => typeof v === "string"),
|
|
422
|
+
);
|
|
423
|
+
return versions.filter((v) => !keep.has(v)).sort();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Whether this launch should record a healthy start — i.e. `current` is the version
|
|
428
|
+
* `pending` staged and nothing has confirmed it yet. Recording it clears the rollback
|
|
429
|
+
* arming above.
|
|
430
|
+
*/
|
|
431
|
+
export function shouldConfirmHealth({ currentVersion, pending, health }) {
|
|
432
|
+
if (currentVersion === null) return false;
|
|
433
|
+
if (pending === null || pending.version !== currentVersion) return false;
|
|
434
|
+
return health === null || health.version !== currentVersion;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
// ── the filesystem operations ─────────────────────────────────────────────
|
|
439
|
+
//
|
|
440
|
+
// Windows only for now. `current` is a directory JUNCTION, which is the one
|
|
441
|
+
// pointer that gives a STABLE path — `<store>/current/app/nissy.exe` — for the
|
|
442
|
+
// autostart Run key and the protocol handler to reference across updates.
|
|
443
|
+
//
|
|
444
|
+
// Verified on Windows 11 before this was built: a junction can be created
|
|
445
|
+
// unelevated, re-pointed WHILE a process launched through it is running, that
|
|
446
|
+
// process survives the swap, and the next launch resolves to the new target.
|
|
447
|
+
// The whole swap design rests on that, so it was measured rather than assumed.
|
|
448
|
+
|
|
449
|
+
/** The version `current` resolves to, or null when there is no usable pointer. */
|
|
450
|
+
export function readCurrentVersion(layout) {
|
|
451
|
+
try {
|
|
452
|
+
const resolved = realpathSync(layout.currentLink);
|
|
453
|
+
const name = basename(resolved);
|
|
454
|
+
return isSafeVersion(name) ? name : null;
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Same, for the retained rollback target. */
|
|
461
|
+
export function readPreviousVersion(layout) {
|
|
462
|
+
try {
|
|
463
|
+
const name = basename(realpathSync(layout.previousLink));
|
|
464
|
+
return isSafeVersion(name) ? name : null;
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Re-point a junction, replacing whatever is there. */
|
|
471
|
+
function repoint(linkPath, targetDir) {
|
|
472
|
+
try {
|
|
473
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
474
|
+
} catch {
|
|
475
|
+
/* nothing there, or already gone */
|
|
476
|
+
}
|
|
477
|
+
symlinkSync(targetDir, linkPath, "junction");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Point `current` at `version`, demoting the outgoing one to `previous`.
|
|
482
|
+
*
|
|
483
|
+
* Not atomic — a crash between the unlink and the create leaves no `current` at
|
|
484
|
+
* all. That is survivable by construction: the startup decision treats a missing
|
|
485
|
+
* current as "seed", so the next launch repairs it from the npm payload rather
|
|
486
|
+
* than failing.
|
|
487
|
+
*/
|
|
488
|
+
export function pointCurrentAt(layout, version) {
|
|
489
|
+
const target = versionDir(layout, version);
|
|
490
|
+
if (!isCompleteVersion(target)) throw new Error(`version ${version} is not in the store`);
|
|
491
|
+
const outgoing = readCurrentVersion(layout);
|
|
492
|
+
if (outgoing !== null && outgoing !== version) {
|
|
493
|
+
repoint(layout.previousLink, versionDir(layout, outgoing));
|
|
494
|
+
}
|
|
495
|
+
repoint(layout.currentLink, target);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Populate `dest` from `src` using hard links, falling back to a copy per file.
|
|
500
|
+
*
|
|
501
|
+
* Measured on the real 0.2.35 payload (2,318 files, 450 MB): 2.0s hard-linked
|
|
502
|
+
* against 36.5s copied. A copy would put a 36-second stall in front of the
|
|
503
|
+
* first launch after every `npm i -g`, which is a worse experience than the
|
|
504
|
+
* problem the store is solving.
|
|
505
|
+
*
|
|
506
|
+
* Hard links also cost no extra disk — both names point at the same blocks —
|
|
507
|
+
* and they are the RIGHT semantics here, not just the fast ones. npm replaces
|
|
508
|
+
* files rather than editing them in place, so a later `npm i -g` unlinks its
|
|
509
|
+
* name and writes a new one, leaving the store's copy of the old version
|
|
510
|
+
* intact. That is precisely the independence the store needs.
|
|
511
|
+
*
|
|
512
|
+
* The fallback covers a store on a different volume from the npm prefix
|
|
513
|
+
* (EXDEV) and any filesystem without hard links.
|
|
514
|
+
*/
|
|
515
|
+
function linkTree(src, dest) {
|
|
516
|
+
mkdirSync(dest, { recursive: true });
|
|
517
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
518
|
+
const from = join(src, entry.name);
|
|
519
|
+
const to = join(dest, entry.name);
|
|
520
|
+
if (entry.isDirectory()) {
|
|
521
|
+
linkTree(from, to);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
linkSync(from, to);
|
|
526
|
+
} catch {
|
|
527
|
+
copyFileSync(from, to);
|
|
528
|
+
}
|
|
529
|
+
// Do NOT mark these read-only as a defence against writing through the
|
|
530
|
+
// link. On Windows the attribute belongs to the FILE, not the link, so it
|
|
531
|
+
// would also mark npm's copy read-only — and Microsoft documents that the
|
|
532
|
+
// other links then *display* the old value, which is simply untrue. ACLs
|
|
533
|
+
// are worse: a hard link has no security descriptor of its own at all. Nix
|
|
534
|
+
// gets read-only stores by chmod plus a read-only bind mount, neither of
|
|
535
|
+
// which has an NTFS equivalent, and pnpm — the closest analogue to this —
|
|
536
|
+
// does not use permissions either. It documents the hazard and offers
|
|
537
|
+
// copy-on-write instead.
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Copy an extracted application into the store as `version`.
|
|
543
|
+
*
|
|
544
|
+
* Completion is signalled by a marker file written last, NOT by renaming a
|
|
545
|
+
* staged directory into place. The rename is the obvious design and it does not
|
|
546
|
+
* work here: the seed hard links from a tree npm has just extracted, Defender
|
|
547
|
+
* still holds handles on those inodes, and a directory with an open handle
|
|
548
|
+
* cannot be renamed. It failed with EPERM on a real machine. Go hit the same
|
|
549
|
+
* wall and abandoned rename-into-place in 1.16 for a `.partial` marker; this is
|
|
550
|
+
* the same idea with the sense inverted.
|
|
551
|
+
*
|
|
552
|
+
* So an interrupted seed leaves an unmarked directory, which `isCompleteVersion`
|
|
553
|
+
* treats as absent and the next seed clears.
|
|
554
|
+
*/
|
|
555
|
+
export function seedVersion(layout, version, sourceDir, exeSegments = null) {
|
|
556
|
+
const target = versionDir(layout, version);
|
|
557
|
+
if (isCompleteVersion(target)) return target;
|
|
558
|
+
// An unmarked directory is the wreckage of an interrupted seed. Clear it
|
|
559
|
+
// rather than linking on top of a half-populated tree.
|
|
560
|
+
rmSync(target, { recursive: true, force: true });
|
|
561
|
+
mkdirSync(layout.versionsDir, { recursive: true });
|
|
562
|
+
try {
|
|
563
|
+
linkTree(sourceDir, target);
|
|
564
|
+
if (exeSegments !== null) {
|
|
565
|
+
const exe = join(target, ...exeSegments);
|
|
566
|
+
if (!looksExecutable(exe)) {
|
|
567
|
+
throw new Error(`seeded version ${version} has no runnable executable at ${exe}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Written LAST, and only once the executable has been checked: a version
|
|
571
|
+
// that cannot run must never become eligible for `current`.
|
|
572
|
+
writeFileSync(join(target, SEED_COMPLETE_MARKER), new Date().toISOString());
|
|
573
|
+
} catch (err) {
|
|
574
|
+
// Leave nothing usable and nothing large behind.
|
|
575
|
+
rmSync(target, { recursive: true, force: true });
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
578
|
+
return target;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Whether a file is plausibly a Windows executable.
|
|
583
|
+
*
|
|
584
|
+
* Reads the two-byte DOS header rather than trusting the extension. This is not
|
|
585
|
+
* a signature check and is not meant to be: it catches truncation, a failed
|
|
586
|
+
* download written to the right name, and a file replaced by text — the
|
|
587
|
+
* failures that actually happen. Anything subtler is the digest's job, upstream.
|
|
588
|
+
*
|
|
589
|
+
* Cheap on purpose: two bytes and a stat, on the launch path.
|
|
590
|
+
*/
|
|
591
|
+
export function looksExecutable(filePath) {
|
|
592
|
+
let fd;
|
|
593
|
+
try {
|
|
594
|
+
if (statSync(filePath).size < 1024) return false; // no real exe is this small
|
|
595
|
+
fd = openSync(filePath, "r");
|
|
596
|
+
const head = Buffer.alloc(2);
|
|
597
|
+
if (readSync(fd, head, 0, 2, 0) !== 2) return false;
|
|
598
|
+
return head[0] === 0x4d && head[1] === 0x5a; // "MZ"
|
|
599
|
+
} catch {
|
|
600
|
+
return false;
|
|
601
|
+
} finally {
|
|
602
|
+
if (fd !== undefined) {
|
|
603
|
+
try {
|
|
604
|
+
closeSync(fd);
|
|
605
|
+
} catch {
|
|
606
|
+
/* nothing to do */
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** A version directory that finished seeding. Anything else does not exist as
|
|
613
|
+
* far as the rest of the store is concerned. */
|
|
614
|
+
export function isCompleteVersion(dir) {
|
|
615
|
+
return existsSync(join(dir, SEED_COMPLETE_MARKER));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Delete version directories that are no longer referenced. Best-effort: a
|
|
619
|
+
* directory still holding a running executable is locked on Windows and is
|
|
620
|
+
* simply left for the next launch. */
|
|
621
|
+
export function pruneVersions(layout, versions) {
|
|
622
|
+
for (const v of versions) {
|
|
623
|
+
try {
|
|
624
|
+
rmSync(versionDir(layout, v), { recursive: true, force: true });
|
|
625
|
+
} catch {
|
|
626
|
+
/* locked or vanished — try again next time */
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── diagnostics ───────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
/** Keep the log bounded. It is append-only across every launch, forever. */
|
|
634
|
+
const LOG_MAX_BYTES = 128 * 1024;
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Append a line to the store's own log.
|
|
638
|
+
*
|
|
639
|
+
* ALWAYS written, not gated behind a debug flag. The store degrades silently by
|
|
640
|
+
* design — every failure falls back to running the npm payload in place — which
|
|
641
|
+
* means a store that never works looks exactly like a store that is working
|
|
642
|
+
* fine. Without a durable record, we would ship it, see updates still applying
|
|
643
|
+
* through the old path, and conclude it was doing its job.
|
|
644
|
+
*
|
|
645
|
+
* Never throws: a launch must not fail because a log could not be written.
|
|
646
|
+
*/
|
|
647
|
+
export function storeLog(layout, message) {
|
|
648
|
+
try {
|
|
649
|
+
mkdirSync(layout.root, { recursive: true });
|
|
650
|
+
try {
|
|
651
|
+
if (statSync(layout.logFile).size > LOG_MAX_BYTES) {
|
|
652
|
+
writeFileSync(layout.logFile, `--- truncated ${new Date().toISOString()} ---
|
|
653
|
+
`);
|
|
654
|
+
}
|
|
655
|
+
} catch {
|
|
656
|
+
/* no log yet */
|
|
657
|
+
}
|
|
658
|
+
appendFileSync(layout.logFile, `${new Date().toISOString()} ${message}
|
|
659
|
+
`);
|
|
660
|
+
} catch {
|
|
661
|
+
/* diagnostics must never break a launch */
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── the launch lock ───────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
/** How long a lock may be held before another launcher treats it as abandoned. */
|
|
668
|
+
const LOCK_STALE_MS = 2 * 60 * 1000;
|
|
669
|
+
/** How long to wait for a lock before giving up and running in place. */
|
|
670
|
+
const LOCK_WAIT_MS = 5000;
|
|
671
|
+
|
|
672
|
+
function lockIsStale(lockDir) {
|
|
673
|
+
try {
|
|
674
|
+
if (Date.now() - statSync(lockDir).mtimeMs > LOCK_STALE_MS) return true;
|
|
675
|
+
const pid = Number(readFileSync(join(lockDir, "pid"), "utf8").trim());
|
|
676
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
677
|
+
try {
|
|
678
|
+
process.kill(pid, 0);
|
|
679
|
+
return false; // holder is alive
|
|
680
|
+
} catch {
|
|
681
|
+
return true; // holder is gone
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
return true; // unreadable lock is not a lock
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Run `fn` while holding an exclusive lock on the store, or return
|
|
690
|
+
* `LOCK_UNAVAILABLE` if another launcher holds it.
|
|
691
|
+
*
|
|
692
|
+
* Two launchers really can run at once: the login item firing while someone
|
|
693
|
+
* types `nissy`, or a double-click. Without this, one process can be mid-seed
|
|
694
|
+
* while the other re-points `current` at a directory that is still being
|
|
695
|
+
* copied into.
|
|
696
|
+
*
|
|
697
|
+
* `mkdir` is the primitive because it is atomic on every filesystem we care
|
|
698
|
+
* about and needs no dependency. A crashed holder leaves the directory behind,
|
|
699
|
+
* so a lock whose PID is dead, or which is simply old, is taken over.
|
|
700
|
+
*/
|
|
701
|
+
export const LOCK_UNAVAILABLE = Symbol("store-lock-unavailable");
|
|
702
|
+
|
|
703
|
+
export function withStoreLock(layout, fn, { waitMs = LOCK_WAIT_MS, now = Date.now } = {}) {
|
|
704
|
+
// The lock is a directory inside the store root, so the root has to exist
|
|
705
|
+
// before `mkdir` can distinguish "somebody holds it" (EEXIST) from "there is
|
|
706
|
+
// nowhere to put it" (ENOENT).
|
|
707
|
+
try {
|
|
708
|
+
mkdirSync(layout.root, { recursive: true });
|
|
709
|
+
} catch {
|
|
710
|
+
return LOCK_UNAVAILABLE;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const deadline = now() + waitMs;
|
|
714
|
+
for (;;) {
|
|
715
|
+
try {
|
|
716
|
+
mkdirSync(layout.lockDir, { recursive: false });
|
|
717
|
+
break; // acquired
|
|
718
|
+
} catch (err) {
|
|
719
|
+
// ONLY EEXIST means another launcher holds it. Anything else (the store
|
|
720
|
+
// root is a file, the directory is not writable) means the store itself
|
|
721
|
+
// is unusable, and retrying cannot fix it. Treating those as contention
|
|
722
|
+
// is an infinite loop that hangs the launcher and leaves the user unable
|
|
723
|
+
// to start the app at all.
|
|
724
|
+
if (!err || err.code !== "EEXIST") return LOCK_UNAVAILABLE;
|
|
725
|
+
// Checked before the takeover, so no branch of this loop can outlive the
|
|
726
|
+
// deadline.
|
|
727
|
+
if (now() >= deadline) return LOCK_UNAVAILABLE;
|
|
728
|
+
if (lockIsStale(layout.lockDir)) {
|
|
729
|
+
storeLog(layout, "taking over an abandoned store lock");
|
|
730
|
+
try {
|
|
731
|
+
rmSync(layout.lockDir, { recursive: true, force: true });
|
|
732
|
+
} catch {
|
|
733
|
+
return LOCK_UNAVAILABLE; // cannot clear it; do not spin on it
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
// Busy-wait without a dependency. The launcher is synchronous up to this
|
|
738
|
+
// point and the wait is bounded at a few seconds.
|
|
739
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 120);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
writeFileSync(join(layout.lockDir, "pid"), String(process.pid));
|
|
744
|
+
} catch {
|
|
745
|
+
/* the directory is the lock; the pid is only for staleness */
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
return fn();
|
|
749
|
+
} finally {
|
|
750
|
+
try {
|
|
751
|
+
rmSync(layout.lockDir, { recursive: true, force: true });
|
|
752
|
+
} catch {
|
|
753
|
+
/* a leftover lock is reclaimed as stale */
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Decide what the store needs, do it, and return the executable to launch.
|
|
760
|
+
*
|
|
761
|
+
* Returns null when the store cannot be used for ANY reason — an unwritable
|
|
762
|
+
* cache root, a filesystem without junctions, a half-built store we cannot
|
|
763
|
+
* repair. The launcher then runs the npm payload in place, exactly as it did
|
|
764
|
+
* before the store existed. That fallback is the whole safety story: a bug in
|
|
765
|
+
* here must degrade to the old behaviour, never to a user who cannot start the
|
|
766
|
+
* app.
|
|
767
|
+
*
|
|
768
|
+
* @param {{
|
|
769
|
+
* platform: NodeJS.Platform,
|
|
770
|
+
* home: string,
|
|
771
|
+
* env: NodeJS.ProcessEnv,
|
|
772
|
+
* cacheName?: string,
|
|
773
|
+
* payloadVersion: string,
|
|
774
|
+
* payloadRoot: string,
|
|
775
|
+
* exeSegments: readonly string[],
|
|
776
|
+
* log?: (message: string) => void,
|
|
777
|
+
* }} input
|
|
778
|
+
* @returns {{ exePath: string, version: string, action: string } | null}
|
|
779
|
+
*/
|
|
780
|
+
export function prepareStore({
|
|
781
|
+
platform,
|
|
782
|
+
home,
|
|
783
|
+
env,
|
|
784
|
+
cacheName,
|
|
785
|
+
payloadVersion,
|
|
786
|
+
payloadRoot,
|
|
787
|
+
exeSegments,
|
|
788
|
+
log = () => {},
|
|
789
|
+
}) {
|
|
790
|
+
// Windows only. macOS keeps its existing ~/Applications install, which already
|
|
791
|
+
// swaps a whole bundle and does not suffer the locked-executable problem this
|
|
792
|
+
// store exists to solve.
|
|
793
|
+
if (platform !== "win32") return null;
|
|
794
|
+
|
|
795
|
+
let layout = null;
|
|
796
|
+
try {
|
|
797
|
+
layout = storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) });
|
|
798
|
+
const currentVersion = readCurrentVersion(layout);
|
|
799
|
+
const previousVersion = readPreviousVersion(layout);
|
|
800
|
+
const pending = readPending(layout);
|
|
801
|
+
const health = readHealth(layout);
|
|
802
|
+
|
|
803
|
+
const decision = decideStartupAction({
|
|
804
|
+
currentVersion,
|
|
805
|
+
previousVersion,
|
|
806
|
+
pending,
|
|
807
|
+
health,
|
|
808
|
+
payloadVersion,
|
|
809
|
+
versionExists: (v) => isSafeVersion(v) && isCompleteVersion(versionDir(layout, v)),
|
|
810
|
+
});
|
|
811
|
+
log(`store: ${decision.action} - ${decision.reason}`);
|
|
812
|
+
storeLog(layout, `${decision.action}: ${decision.reason}`);
|
|
813
|
+
|
|
814
|
+
// Only the mutating branches need the lock; a plain launch reads and goes.
|
|
815
|
+
const mutating = decision.action !== STORE_ACTION.launch;
|
|
816
|
+
const apply = () => {
|
|
817
|
+
switch (decision.action) {
|
|
818
|
+
case STORE_ACTION.seed:
|
|
819
|
+
seedVersion(layout, decision.version, payloadRoot, exeSegments);
|
|
820
|
+
pointCurrentAt(layout, decision.version);
|
|
821
|
+
// A freshly seeded version is the one we are about to run; nothing is
|
|
822
|
+
// pending against it.
|
|
823
|
+
clearPending(layout);
|
|
824
|
+
writeHealth(layout, decision.version);
|
|
825
|
+
break;
|
|
826
|
+
case STORE_ACTION.applyPending:
|
|
827
|
+
// Re-checked at the point of use, not only at seed time: the files may
|
|
828
|
+
// have been damaged since, and pointing `current` at a broken version
|
|
829
|
+
// is what strands a user outside the app.
|
|
830
|
+
if (!looksExecutable(join(versionDir(layout, decision.version), ...exeSegments))) {
|
|
831
|
+
storeLog(layout, `refusing to apply ${decision.version}: no runnable executable`);
|
|
832
|
+
rmSync(versionDir(layout, decision.version), { recursive: true, force: true });
|
|
833
|
+
clearPending(layout);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
pointCurrentAt(layout, decision.version);
|
|
837
|
+
// Deliberately NOT confirmed here: health is written only once the
|
|
838
|
+
// version has actually started, which is what arms the rollback below
|
|
839
|
+
// if it never does.
|
|
840
|
+
writePending(layout, decision.version);
|
|
841
|
+
break;
|
|
842
|
+
case STORE_ACTION.rollback:
|
|
843
|
+
pointCurrentAt(layout, decision.version);
|
|
844
|
+
// Clearing `pending` is what disarms the rollback, so it cannot fire
|
|
845
|
+
// again next launch. Health is deliberately NOT written: this version
|
|
846
|
+
// has not started yet in this session, and recording a start we did not
|
|
847
|
+
// observe would put a lie in the state file that later diagnosis reads.
|
|
848
|
+
clearPending(layout);
|
|
849
|
+
break;
|
|
850
|
+
case STORE_ACTION.launch:
|
|
851
|
+
default:
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
if (mutating) {
|
|
857
|
+
const held = withStoreLock(layout, apply);
|
|
858
|
+
if (held === LOCK_UNAVAILABLE) {
|
|
859
|
+
// Another launcher is seeding or swapping. Doing it concurrently is how
|
|
860
|
+
// a half-copied version gets pointed at, so this one steps back and
|
|
861
|
+
// runs the npm payload in place for this launch only.
|
|
862
|
+
storeLog(layout, "another launcher holds the store lock; running in place");
|
|
863
|
+
log("store: busy; running the npm payload in place");
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const version = readCurrentVersion(layout);
|
|
869
|
+
if (version === null) return null;
|
|
870
|
+
const exePath = join(layout.currentLink, ...exeSegments);
|
|
871
|
+
if (!existsSync(exePath)) {
|
|
872
|
+
storeLog(layout, `no executable at ${exePath}; running the npm payload in place`);
|
|
873
|
+
log(`store: no executable at ${exePath}; running the npm payload in place`);
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Is this launch an ATTEMPT at an unproven version? It is whenever `pending`
|
|
878
|
+
// still names what we are about to run and no health record agrees. Spend
|
|
879
|
+
// one of its attempts now, before the spawn — a launch that never comes back
|
|
880
|
+
// to confirm must still have cost something, or a version that hard-crashes
|
|
881
|
+
// on startup would retry for ever.
|
|
882
|
+
let awaitingConfirmation = false;
|
|
883
|
+
const afterPending = readPending(layout);
|
|
884
|
+
const afterHealth = readHealth(layout);
|
|
885
|
+
if (
|
|
886
|
+
afterPending !== null &&
|
|
887
|
+
afterPending.version === version &&
|
|
888
|
+
(afterHealth === null || afterHealth.version !== version)
|
|
889
|
+
) {
|
|
890
|
+
// If another launcher holds the lock, skip the decrement rather than wait:
|
|
891
|
+
// losing an attempt is the safe direction to err in, spending one twice is
|
|
892
|
+
// not.
|
|
893
|
+
const held = withStoreLock(layout, () => consumeTry(layout), { waitMs: 0 });
|
|
894
|
+
if (held !== LOCK_UNAVAILABLE) {
|
|
895
|
+
awaitingConfirmation = true;
|
|
896
|
+
storeLog(layout, `attempt at ${version}; ${held === null ? "?" : held.triesLeft} left`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return { exePath, version, action: decision.action, awaitingConfirmation };
|
|
900
|
+
} catch (err) {
|
|
901
|
+
const detail = (err && err.stack) || String(err);
|
|
902
|
+
// Recorded, not just whispered behind a debug flag: a store that always
|
|
903
|
+
// falls back is indistinguishable from one that works.
|
|
904
|
+
if (layout !== null) storeLog(layout, `FAILED, running the npm payload in place
|
|
905
|
+
${detail}`);
|
|
906
|
+
log(`store unavailable (${err && err.message}); running the npm payload in place`);
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Record that the current version started, and prune what is no longer referenced.
|
|
913
|
+
*
|
|
914
|
+
* `confirm: false` does the pruning only. Use it on the ordinary launch, where
|
|
915
|
+
* there is nothing to confirm and no reason to wait around for a signal.
|
|
916
|
+
*
|
|
917
|
+
* WHAT COUNTS AS A START is the whole question, and the honest answer today is
|
|
918
|
+
* "the process was still alive a few seconds later" — see HEALTH_WAIT_MS in the
|
|
919
|
+
* launcher. That is weaker than the systems this design borrows from. ChromeOS
|
|
920
|
+
* waits 45 seconds and then has userspace run `chromeos-setgoodkernel`; Android
|
|
921
|
+
* A/B verifies the whole partition with dm-verity before zygote and only then
|
|
922
|
+
* marks the slot successful; systemd renames the boot-counter file at the end of
|
|
923
|
+
* a successful boot. All three take a positive signal from the thing that
|
|
924
|
+
* booted, not from whatever launched it.
|
|
925
|
+
*
|
|
926
|
+
* A short liveness check does catch the failure that actually happens — an
|
|
927
|
+
* executable that dies immediately on a missing DLL or a truncated download —
|
|
928
|
+
* and the attempt counter bounds everything it misses. The app calling this
|
|
929
|
+
* itself, once it has a window and a working database, is the real fix and is
|
|
930
|
+
* the next piece of work.
|
|
931
|
+
*
|
|
932
|
+
* Pruning is deliberately last and best-effort: the outgoing version's
|
|
933
|
+
* executable may still be locked by a process that has not exited yet.
|
|
934
|
+
*/
|
|
935
|
+
export function confirmHealthyStart({
|
|
936
|
+
platform,
|
|
937
|
+
home,
|
|
938
|
+
env,
|
|
939
|
+
cacheName,
|
|
940
|
+
confirm = true,
|
|
941
|
+
log = () => {},
|
|
942
|
+
}) {
|
|
943
|
+
if (platform !== "win32") return;
|
|
944
|
+
try {
|
|
945
|
+
const layout = storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) });
|
|
946
|
+
const currentVersion = readCurrentVersion(layout);
|
|
947
|
+
if (currentVersion === null) return;
|
|
948
|
+
const pending = readPending(layout);
|
|
949
|
+
withStoreLock(layout, () => {
|
|
950
|
+
if (confirm && shouldConfirmHealth({ currentVersion, pending, health: readHealth(layout) })) {
|
|
951
|
+
writeHealth(layout, currentVersion);
|
|
952
|
+
clearPending(layout);
|
|
953
|
+
storeLog(layout, `${currentVersion} started; confirmed`);
|
|
954
|
+
log(`store: ${currentVersion} started; confirmed`);
|
|
955
|
+
}
|
|
956
|
+
const dead = prunableVersions({
|
|
957
|
+
versions: listVersions(layout),
|
|
958
|
+
currentVersion,
|
|
959
|
+
previousVersion: readPreviousVersion(layout),
|
|
960
|
+
pending: readPending(layout),
|
|
961
|
+
});
|
|
962
|
+
if (dead.length > 0) storeLog(layout, `pruning ${dead.join(", ")}`);
|
|
963
|
+
pruneVersions(layout, dead);
|
|
964
|
+
});
|
|
965
|
+
} catch (err) {
|
|
966
|
+
// Never let bookkeeping affect a launch that has already happened - but do
|
|
967
|
+
// leave a trace, so a store that silently stops confirming is findable.
|
|
968
|
+
try {
|
|
969
|
+
storeLog(
|
|
970
|
+
storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) }),
|
|
971
|
+
`confirm/prune failed: ${(err && err.message) || err}`,
|
|
972
|
+
);
|
|
973
|
+
} catch {
|
|
974
|
+
/* nothing further to do */
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|