clearcote 0.40.1 → 0.41.1
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/README.md +8 -1
- package/dist/cli-commands.d.ts +2 -4
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +11 -7
- package/dist/cli-commands.js.map +1 -1
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +5 -2
- package/dist/cloud.js.map +1 -1
- package/dist/colorscheme.d.ts +27 -0
- package/dist/colorscheme.d.ts.map +1 -0
- package/dist/colorscheme.js +20 -0
- package/dist/colorscheme.js.map +1 -0
- package/dist/docker.d.ts +22 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +146 -34
- package/dist/docker.js.map +1 -1
- package/dist/download.d.ts +37 -1
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +609 -29
- package/dist/download.js.map +1 -1
- package/dist/fonts.d.ts +84 -3
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +486 -12
- package/dist/fonts.js.map +1 -1
- package/dist/geometry.d.ts +3 -1
- package/dist/geometry.d.ts.map +1 -1
- package/dist/geometry.js +6 -2
- package/dist/geometry.js.map +1 -1
- package/dist/humanize.d.ts +11 -0
- package/dist/humanize.d.ts.map +1 -1
- package/dist/humanize.js +152 -30
- package/dist/humanize.js.map +1 -1
- package/dist/index.d.ts +30 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -21
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +1 -0
- package/dist/isolated.d.ts.map +1 -1
- package/dist/isolated.js +2 -0
- package/dist/isolated.js.map +1 -1
- package/dist/launchopts.d.ts +21 -0
- package/dist/launchopts.d.ts.map +1 -1
- package/dist/launchopts.js +46 -0
- package/dist/launchopts.js.map +1 -1
- package/dist/motion.d.ts +23 -0
- package/dist/motion.d.ts.map +1 -1
- package/dist/motion.js +43 -0
- package/dist/motion.js.map +1 -1
- package/dist/personaenv.d.ts +33 -0
- package/dist/personaenv.d.ts.map +1 -0
- package/dist/personaenv.js +117 -0
- package/dist/personaenv.js.map +1 -0
- package/dist/warnings.d.ts +9 -1
- package/dist/warnings.d.ts.map +1 -1
- package/dist/warnings.js +32 -4
- package/dist/warnings.js.map +1 -1
- package/dist/widevine.d.ts +10 -0
- package/dist/widevine.d.ts.map +1 -1
- package/dist/widevine.js +18 -0
- package/dist/widevine.js.map +1 -1
- package/dist/winlaunch.d.ts +2 -1
- package/dist/winlaunch.d.ts.map +1 -1
- package/dist/winlaunch.js +2 -1
- package/dist/winlaunch.js.map +1 -1
- package/docker-seccomp.json +909 -0
- package/package.json +2 -1
package/dist/download.js
CHANGED
|
@@ -11,14 +11,17 @@
|
|
|
11
11
|
// fingerprint. Lets you stay current without bumping the SDK. Falls back
|
|
12
12
|
// to the pinned release if GitHub is unreachable.
|
|
13
13
|
// A hash mismatch (either mode) is always a hard failure and the partial download is deleted.
|
|
14
|
-
import {
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
16
|
-
import { chmodSync, closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
+
import { chmodSync, closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
|
|
17
17
|
import { rm } from "node:fs/promises";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { Readable } from "node:stream";
|
|
21
21
|
import { pipeline } from "node:stream/promises";
|
|
22
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { Worker } from "node:worker_threads";
|
|
22
25
|
import extract from "extract-zip";
|
|
23
26
|
import { RELEASE, REPO, SIGNING_KEY_FPR, platformRelease, CATALOG_URL, CATALOG_FALLBACK } from "./release.js";
|
|
24
27
|
/**
|
|
@@ -336,11 +339,26 @@ export function writeManifest(base, browserDir) {
|
|
|
336
339
|
}
|
|
337
340
|
};
|
|
338
341
|
walk(browserDir);
|
|
342
|
+
// Written to a temporary file and renamed into place, so no reader ever sees half a manifest.
|
|
343
|
+
const final = path.join(base, MANIFEST);
|
|
344
|
+
const tmp = `${final}.tmp-${randomBytes(4).toString("hex")}`;
|
|
339
345
|
try {
|
|
340
|
-
writeFileSync(
|
|
346
|
+
writeFileSync(tmp, JSON.stringify({ files }));
|
|
347
|
+
for (let attempt = 0;; attempt++) {
|
|
348
|
+
try {
|
|
349
|
+
renameSync(tmp, final);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
// Windows: a reader has the old one open this instant
|
|
354
|
+
if (process.platform !== "win32" || attempt >= 9 || errCode(e) !== "EPERM")
|
|
355
|
+
throw e;
|
|
356
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
341
359
|
}
|
|
342
360
|
catch {
|
|
343
|
-
|
|
361
|
+
rmSync(tmp, { force: true }); // a manifest is an optimisation, never a reason to fail an install
|
|
344
362
|
}
|
|
345
363
|
}
|
|
346
364
|
/** Return `"<file> — <problem>"` for every missing / truncated entry. */
|
|
@@ -437,27 +455,563 @@ export function checkInstall(exe) {
|
|
|
437
455
|
* A damaged tree returns null (after wiping it) so the caller re-downloads: the `.verified` marker
|
|
438
456
|
* only records that the archive hashed correctly AT INSTALL TIME, and files can be eaten later.
|
|
439
457
|
*/
|
|
440
|
-
export function cachedBinary(base, binary, quiet) {
|
|
458
|
+
export function cachedBinary(base, binary, quiet, repair = true, full) {
|
|
441
459
|
if (!existsSync(path.join(base, ".verified")))
|
|
442
460
|
return null;
|
|
443
461
|
const browserDir = path.join(base, "browser");
|
|
444
462
|
const cached = findFile(browserDir, binary);
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (!problems.length)
|
|
463
|
+
// The binary itself can be gone too: antivirus quarantine, or browser/ deleted by hand.
|
|
464
|
+
const problems = cached ? verifyInstall(browserDir, base, full) : [`${binary} — missing`];
|
|
465
|
+
if (cached && !problems.length)
|
|
449
466
|
return cached;
|
|
467
|
+
// `repair` also moves the damaged tree out of the way; false when not holding the install lock, so
|
|
468
|
+
// only the process that will re-install touches it.
|
|
469
|
+
if (!repair)
|
|
470
|
+
return null;
|
|
450
471
|
log(quiet, `cached browser is damaged (${problems[0]}) — re-downloading`);
|
|
451
|
-
|
|
472
|
+
// Moved aside first: if a running browser still holds its files, this stops here with "close it", with the
|
|
473
|
+
// tree still marked, instead of leaving an unmarked tree that no later install can move.
|
|
474
|
+
moveAside(base, browserDir);
|
|
452
475
|
for (const marker of [".verified", MANIFEST])
|
|
453
476
|
rmSync(path.join(base, marker), { force: true });
|
|
454
477
|
return null;
|
|
455
478
|
}
|
|
456
|
-
|
|
479
|
+
// Install lock. One protocol, the same in Python (download.py), Node (download.ts) and .NET
|
|
480
|
+
// (Download.cs) -- change all three together. Any mix of processes installing one build into
|
|
481
|
+
// <cache>/<tag> takes turns:
|
|
482
|
+
// 1. The lock is the file <tag>/.install-lock, created with O_CREAT|O_EXCL (atomic on every OS and in
|
|
483
|
+
// all three languages; Node has no flock). It holds JSON: pid, start, host, boot, pidns, nonce, sdk,
|
|
484
|
+
// created. "start" is the process start time where the SDK can read it ("linux:" from /proc; "win:"
|
|
485
|
+
// the creation time, Python and .NET), else null; "created" is when the lock was written (ms, Unix).
|
|
486
|
+
// 2. The holder touches the file (mtime) every 5 s while it works, from a thread of its own.
|
|
487
|
+
// 3. Waiters poll every 0.25 s. A record from this machine (same host, boot id and PID namespace) is stale
|
|
488
|
+
// when its pid is dead, when that pid now belongs to a newer process (another start time; on Windows
|
|
489
|
+
// created after the lock), or -- only without a boot id (Windows, macOS) -- when it was written before
|
|
490
|
+
// the machine last started. A pid confirmed to be the holder is never stale, however old its heartbeat.
|
|
491
|
+
// When that cannot be confirmed, and for any other lock: stale once it (mtime + content) has not changed
|
|
492
|
+
// for 120 s by the waiter's own clock -- 10 s when not valid JSON or its mtime is over 15 min old -- so
|
|
493
|
+
// clocks that disagree do not matter. A stale lock is broken while holding <tag>/.install-lock.break
|
|
494
|
+
// (also O_EXCL; a breaker unchanged for 30 s by the waiter's own clock is removed): if the lock is still
|
|
495
|
+
// exactly what was judged stale, delete it; then delete the breaker.
|
|
496
|
+
// 4. The holder re-checks the cache and uses a verified build if one is there now; a tree marked
|
|
497
|
+
// .verified that fails the check (files or the binary gone) is moved aside by the holder and only then
|
|
498
|
+
// unmarked -- if its files are in use, the install stops at once ("close it and try again"). Otherwise
|
|
499
|
+
// it downloads and extracts into <tag>/.tmp-<nonce>/. Then, only while the lock is still its own: it
|
|
500
|
+
// uses a tree that appeared meanwhile and passes the full check, else moves browser/ aside
|
|
501
|
+
// (<tag>/.trash-*; after taking over a holder not confirmed dead, it waits within the install wait while
|
|
502
|
+
// that tree is still in use), moves its tree to <tag>/browser, checks the lock and writes .manifest.json
|
|
503
|
+
// (temporary file, then rename), checks the lock once more and only then writes .verified. A tree that
|
|
504
|
+
// passes the check is never moved or written over by an install. Leftovers (.tmp-*, .trash-*, a breaker
|
|
505
|
+
// unchanged throughout the install) are swept.
|
|
506
|
+
// 5. Release deletes the lock only while it still holds our nonce. Waiting gives up after 30 min with an
|
|
507
|
+
// error that names the holder (pid, host) and the lock file.
|
|
508
|
+
export const INSTALL_LOCK = ".install-lock";
|
|
509
|
+
/** Install-lock timings in ms (see the protocol above). Tests may shorten them. */
|
|
510
|
+
export const installLockTiming = {
|
|
511
|
+
pollMs: 250, heartbeatMs: 5_000, staleMs: 120_000, unreadableMs: 10_000, ancientMs: 15 * 60_000, breakerStaleMs: 30_000, waitMs: 30 * 60_000,
|
|
512
|
+
inUsePollMs: 1_000,
|
|
513
|
+
};
|
|
514
|
+
/** Test seam: called right before an install writes .verified. Left empty in production. */
|
|
515
|
+
export const installTestHooks = {};
|
|
516
|
+
const BOOT_MARGIN_MS = 5 * 60_000; // the clock can still be corrected in the first minutes after a start
|
|
517
|
+
const errCode = (e) => e?.code;
|
|
518
|
+
function bootId() {
|
|
519
|
+
try {
|
|
520
|
+
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || null;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function pidNamespace() {
|
|
527
|
+
try {
|
|
528
|
+
return readlinkSync("/proc/self/ns/pid");
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** When this machine last started (ms, Unix time), or null. */
|
|
535
|
+
function bootTimeMs() {
|
|
536
|
+
try {
|
|
537
|
+
const line = readFileSync("/proc/stat", "utf8").split("\n").find((l) => l.startsWith("btime "));
|
|
538
|
+
if (line)
|
|
539
|
+
return Number(line.split(/\s+/)[1]) * 1000;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
/* not Linux */
|
|
543
|
+
}
|
|
544
|
+
const up = os.uptime();
|
|
545
|
+
return up > 0 ? Date.now() - up * 1000 : null;
|
|
546
|
+
}
|
|
547
|
+
/** A marker that differs once `pid` belongs to another process: its start time from /proc (Linux). */
|
|
548
|
+
function processStart(pid) {
|
|
549
|
+
try {
|
|
550
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
551
|
+
const f = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/);
|
|
552
|
+
return f[19] ? `linux:${f[19]}` : null;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function pidAlive(pid) {
|
|
559
|
+
try {
|
|
560
|
+
process.kill(pid, 0); // signal 0: an existence check, also on Windows
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
return errCode(e) === "EPERM"; // exists, but not ours to signal
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function readLock(p) {
|
|
568
|
+
let mtimeMs;
|
|
569
|
+
let raw;
|
|
570
|
+
try {
|
|
571
|
+
mtimeMs = statSync(p).mtimeMs;
|
|
572
|
+
raw = readFileSync(p, "utf8");
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
return { state: errCode(e) === "ENOENT" ? "gone" : "busy", rec: null, mtimeMs: 0, raw: null };
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
const rec = JSON.parse(raw);
|
|
579
|
+
if (rec && typeof rec === "object" && !Array.isArray(rec))
|
|
580
|
+
return { state: "ok", rec: rec, mtimeMs, raw };
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
/* not (yet) valid JSON */
|
|
584
|
+
}
|
|
585
|
+
return { state: "invalid", rec: null, mtimeMs, raw };
|
|
586
|
+
}
|
|
587
|
+
/** What a waiter compares to tell whether the lock changed since it last looked. */
|
|
588
|
+
const lockKey = (s) => `${s.state}\u0000${s.mtimeMs}\u0000${s.raw ?? ""}`;
|
|
589
|
+
/** The record is from this machine (same host, boot id and PID namespace) and names a usable pid. */
|
|
590
|
+
function fromHere(rec) {
|
|
591
|
+
const { pid, host } = rec;
|
|
592
|
+
return (typeof host === "string" && host.toLowerCase() === os.hostname().toLowerCase() &&
|
|
593
|
+
(rec.boot ?? null) === bootId() && (rec.pidns ?? null) === pidNamespace() &&
|
|
594
|
+
typeof pid === "number" && Number.isInteger(pid) && pid > 0 && pid < 2 ** 31);
|
|
595
|
+
}
|
|
596
|
+
/** For a record from this machine: "gone", "alive" (certainly still the holder) or "unknown". Node cannot
|
|
597
|
+
* read another process's start time on Windows, so there a live pid is always "unknown". */
|
|
598
|
+
function ownerState(rec) {
|
|
599
|
+
const created = typeof rec.created === "number" ? rec.created : null;
|
|
600
|
+
const boot = bootTimeMs();
|
|
601
|
+
// written before this machine last started (a boot id, where there is one, tells that already)
|
|
602
|
+
if ((rec.boot ?? null) === null && created !== null && boot !== null && created < boot - BOOT_MARGIN_MS)
|
|
603
|
+
return "gone";
|
|
604
|
+
if (!pidAlive(rec.pid))
|
|
605
|
+
return "gone";
|
|
606
|
+
const now = processStart(rec.pid);
|
|
607
|
+
if (now?.startsWith("linux:")) {
|
|
608
|
+
if (typeof rec.start === "string" && rec.start.startsWith("linux:"))
|
|
609
|
+
return now === rec.start ? "alive" : "gone";
|
|
610
|
+
return "unknown";
|
|
611
|
+
}
|
|
612
|
+
return "unknown";
|
|
613
|
+
}
|
|
614
|
+
/** `unchangedForMs`: how long the lock has stayed exactly as it is, by this process's own clock. */
|
|
615
|
+
function lockStale(rec, mtimeMs, unchangedForMs) {
|
|
616
|
+
if (rec && fromHere(rec)) {
|
|
617
|
+
const state = ownerState(rec);
|
|
618
|
+
if (state !== "unknown")
|
|
619
|
+
return state === "gone";
|
|
620
|
+
}
|
|
621
|
+
const quick = !rec || Date.now() - mtimeMs > installLockTiming.ancientMs;
|
|
622
|
+
return unchangedForMs >= (quick ? Math.min(installLockTiming.staleMs, installLockTiming.unreadableMs) : installLockTiming.staleMs);
|
|
623
|
+
}
|
|
624
|
+
function fileKey(p) {
|
|
625
|
+
try {
|
|
626
|
+
const st = statSync(p);
|
|
627
|
+
return `${st.mtimeMs}\u0000${st.size}`;
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** A breaker is held for milliseconds: one that stays exactly the same for the breaker window, by this
|
|
634
|
+
* process's own clock, was left by a process that died holding it. Its timestamp alone proves nothing. */
|
|
635
|
+
function clearStaleBreaker(breaker, watch) {
|
|
636
|
+
const key = fileKey(breaker);
|
|
637
|
+
const now = performance.now();
|
|
638
|
+
if (key === null || key !== watch.key) {
|
|
639
|
+
watch.key = key;
|
|
640
|
+
watch.at = now;
|
|
641
|
+
}
|
|
642
|
+
else if (now - watch.at >= installLockTiming.breakerStaleMs) {
|
|
643
|
+
rmSync(breaker, { force: true });
|
|
644
|
+
watch.key = null;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Delete the lock if it is still exactly what was judged stale, under the breaker file. True when the lock is gone afterwards. */
|
|
648
|
+
function breakStaleLock(p, judged, watch) {
|
|
649
|
+
const breaker = `${p}.break`;
|
|
650
|
+
try {
|
|
651
|
+
closeSync(openSync(breaker, "wx"));
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
try {
|
|
655
|
+
clearStaleBreaker(breaker, watch);
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
/* gone meanwhile */
|
|
659
|
+
}
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const s = readLock(p);
|
|
664
|
+
if (lockKey(s) === judged) {
|
|
665
|
+
unlinkSync(p);
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return s.state === "gone";
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
rmSync(breaker, { force: true });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const SDK_NAMES = { python: "Python", node: "Node", dotnet: ".NET" };
|
|
678
|
+
function lockHolder(rec) {
|
|
679
|
+
if (!rec || !rec.pid)
|
|
680
|
+
return "a process that left no details";
|
|
681
|
+
const sdk = SDK_NAMES[String(rec.sdk)] ?? String(rec.sdk ?? "unknown");
|
|
682
|
+
return `process ${String(rec.pid)} on ${typeof rec.host === "string" && rec.host ? rec.host : "an unknown machine"} (Clearcote ${sdk} SDK)`;
|
|
683
|
+
}
|
|
684
|
+
function waitWords(ms) {
|
|
685
|
+
const [n, unit] = ms < 120_000 ? [Math.max(1, Math.round(ms / 1000)), "second"] : [Math.round(ms / 60_000), "minute"];
|
|
686
|
+
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
687
|
+
}
|
|
688
|
+
// The heartbeat runs in a worker thread, so a long synchronous step on the main thread (reading the whole
|
|
689
|
+
// tree on Windows, extraction, a large delete) cannot stop it.
|
|
690
|
+
const HEARTBEAT_WORKER = `
|
|
691
|
+
const { workerData } = require("node:worker_threads");
|
|
692
|
+
const { readFileSync, utimesSync } = require("node:fs");
|
|
693
|
+
setInterval(() => {
|
|
694
|
+
try {
|
|
695
|
+
if (JSON.parse(readFileSync(workerData.path, "utf8")).nonce !== workerData.nonce) return;
|
|
696
|
+
const now = new Date();
|
|
697
|
+
utimesSync(workerData.path, now, now);
|
|
698
|
+
} catch {
|
|
699
|
+
/* next beat */
|
|
700
|
+
}
|
|
701
|
+
}, workerData.everyMs);
|
|
702
|
+
`;
|
|
703
|
+
function startHeartbeat(p, nonce, beat) {
|
|
704
|
+
let timer;
|
|
705
|
+
let stopped = false;
|
|
706
|
+
// A worker that cannot start, fails or exits early: beat from this thread's timer instead.
|
|
707
|
+
const fallback = () => {
|
|
708
|
+
if (stopped || timer)
|
|
709
|
+
return;
|
|
710
|
+
timer = setInterval(beat, installLockTiming.heartbeatMs);
|
|
711
|
+
timer.unref();
|
|
712
|
+
};
|
|
713
|
+
let worker;
|
|
714
|
+
try {
|
|
715
|
+
const source = installTestHooks.heartbeatWorkerSource ?? HEARTBEAT_WORKER;
|
|
716
|
+
worker = new Worker(source, { eval: true, workerData: { path: p, nonce, everyMs: installLockTiming.heartbeatMs } });
|
|
717
|
+
worker.on("error", fallback);
|
|
718
|
+
worker.on("exit", fallback);
|
|
719
|
+
worker.unref();
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
fallback();
|
|
723
|
+
}
|
|
724
|
+
return async () => {
|
|
725
|
+
stopped = true;
|
|
726
|
+
if (timer)
|
|
727
|
+
clearInterval(timer);
|
|
728
|
+
await worker?.terminate().catch(() => 0);
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function holdLock(p, nonce, tookOver) {
|
|
732
|
+
let lost = false;
|
|
733
|
+
const held = () => {
|
|
734
|
+
if (lost)
|
|
735
|
+
return false;
|
|
736
|
+
const s = readLock(p);
|
|
737
|
+
if (s.state !== "busy" && s.rec?.nonce !== nonce)
|
|
738
|
+
lost = true;
|
|
739
|
+
return !lost;
|
|
740
|
+
};
|
|
741
|
+
const beat = () => {
|
|
742
|
+
if (!held())
|
|
743
|
+
return;
|
|
744
|
+
try {
|
|
745
|
+
const now = new Date();
|
|
746
|
+
utimesSync(p, now, now);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
/* next beat */
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
const stopHeartbeat = startHeartbeat(p, nonce, beat);
|
|
753
|
+
return {
|
|
754
|
+
path: p,
|
|
755
|
+
nonce,
|
|
756
|
+
held,
|
|
757
|
+
beat,
|
|
758
|
+
tookOver,
|
|
759
|
+
async release() {
|
|
760
|
+
await stopHeartbeat();
|
|
761
|
+
for (let i = 0; i < 40; i++) { // another process may be reading the file this instant (Windows refuses the delete)
|
|
762
|
+
const s = readLock(p);
|
|
763
|
+
if (s.state === "gone" || (s.state !== "busy" && s.rec?.nonce !== nonce))
|
|
764
|
+
return;
|
|
765
|
+
if (s.state === "ok") {
|
|
766
|
+
try {
|
|
767
|
+
unlinkSync(p);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
catch (e) {
|
|
771
|
+
if (errCode(e) === "ENOENT")
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
await sleep(50);
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** Wait for, then take, the install lock of build directory `base` (see the protocol above). */
|
|
781
|
+
export async function acquireInstallLock(base, opts = {}) {
|
|
782
|
+
mkdirSync(base, { recursive: true });
|
|
783
|
+
const p = path.join(base, INSTALL_LOCK);
|
|
784
|
+
const nonce = randomBytes(16).toString("hex");
|
|
785
|
+
const body = JSON.stringify({
|
|
786
|
+
pid: process.pid, start: processStart(process.pid), host: os.hostname(), boot: bootId(), pidns: pidNamespace(), nonce, sdk: "node", created: Date.now(),
|
|
787
|
+
});
|
|
788
|
+
const wait = opts.timeoutMs ?? installLockTiming.waitMs;
|
|
789
|
+
const deadline = performance.now() + wait;
|
|
790
|
+
let told = false;
|
|
791
|
+
let refused = 0;
|
|
792
|
+
let seen = ""; // the lock as last read, and since when (our clock) it has looked like that
|
|
793
|
+
let seenAt = 0;
|
|
794
|
+
const breakerWatch = { key: null, at: 0 };
|
|
795
|
+
let tookOver = false;
|
|
796
|
+
for (;;) {
|
|
797
|
+
let fd = null;
|
|
798
|
+
try {
|
|
799
|
+
fd = openSync(p, "wx");
|
|
800
|
+
}
|
|
801
|
+
catch (e) {
|
|
802
|
+
const c = errCode(e);
|
|
803
|
+
// Windows: the previous lock file is still being deleted
|
|
804
|
+
if (c !== "EEXIST" && !(process.platform === "win32" && (c === "EPERM" || c === "EACCES" || c === "EBUSY")))
|
|
805
|
+
throw e;
|
|
806
|
+
if (c !== "EEXIST")
|
|
807
|
+
refused++;
|
|
808
|
+
}
|
|
809
|
+
if (fd !== null) {
|
|
810
|
+
try {
|
|
811
|
+
writeSync(fd, body);
|
|
812
|
+
}
|
|
813
|
+
catch (e) {
|
|
814
|
+
closeSync(fd);
|
|
815
|
+
rmSync(p, { force: true });
|
|
816
|
+
throw e;
|
|
817
|
+
}
|
|
818
|
+
closeSync(fd);
|
|
819
|
+
return holdLock(p, nonce, tookOver);
|
|
820
|
+
}
|
|
821
|
+
tookOver = false;
|
|
822
|
+
const s = readLock(p);
|
|
823
|
+
if (s.state !== "gone")
|
|
824
|
+
refused = 0;
|
|
825
|
+
else if (refused > 40)
|
|
826
|
+
throw new Error(`Clearcote cannot create ${p}: permission denied`); // not a race: a real error
|
|
827
|
+
const now = performance.now();
|
|
828
|
+
const key = lockKey(s);
|
|
829
|
+
if (key !== seen) {
|
|
830
|
+
seen = key;
|
|
831
|
+
seenAt = now;
|
|
832
|
+
}
|
|
833
|
+
if ((s.state === "ok" || s.state === "invalid") && lockStale(s.rec, s.mtimeMs, now - seenAt) && breakStaleLock(p, key, breakerWatch)) {
|
|
834
|
+
log(opts.quiet ?? true, `removed an abandoned install lock (${lockHolder(s.rec)})`);
|
|
835
|
+
tookOver = !(s.rec && fromHere(s.rec) && ownerState(s.rec) === "gone");
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (now >= deadline) {
|
|
839
|
+
const here = s.state === "ok" && s.rec && fromHere(s.rec) ? ownerState(s.rec) : null;
|
|
840
|
+
const note = here === "alive" ? ", which is still running" : here === "unknown" ? "; that process number may now belong to another program" : "";
|
|
841
|
+
throw new Error(`Gave up after ${waitWords(wait)} waiting for another program to finish installing the Clearcote browser in\n ${base}\n` +
|
|
842
|
+
`The other installer is ${lockHolder(s.rec)}${note}. If no Clearcote program is installing this build, delete this file and try again:\n ${p}`);
|
|
843
|
+
}
|
|
844
|
+
if (!told && s.state === "ok") {
|
|
845
|
+
log(opts.quiet ?? true, `another program is installing this browser build (${lockHolder(s.rec)}); waiting for it`);
|
|
846
|
+
told = true;
|
|
847
|
+
}
|
|
848
|
+
await sleep(installLockTiming.pollMs);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/** Another process took the lock over, or changed the build meanwhile: check again under the lock. */
|
|
852
|
+
class LockLost extends Error {
|
|
853
|
+
}
|
|
854
|
+
/** Synchronous sleep for the short Windows retries below. */
|
|
855
|
+
const pause = (ms) => {
|
|
856
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
857
|
+
};
|
|
858
|
+
function renameDir(src, dst) {
|
|
859
|
+
for (let attempt = 0;; attempt++) {
|
|
860
|
+
try {
|
|
861
|
+
installTestHooks.beforeRename?.(src, dst);
|
|
862
|
+
renameSync(src, dst);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
catch (e) {
|
|
866
|
+
// Windows: a scanner can hold a file in the fresh tree for a moment
|
|
867
|
+
const c = errCode(e);
|
|
868
|
+
if (process.platform !== "win32" || attempt >= 9 || !(c === "EPERM" || c === "EACCES" || c === "EBUSY"))
|
|
869
|
+
throw e;
|
|
870
|
+
pause(200);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/** Move a tree that must be replaced out of the way, then delete it if nothing is using it. */
|
|
875
|
+
/** A tree that must be moved aside is in use (Windows will not move a folder whose files are open). */
|
|
876
|
+
class InUseError extends Error {
|
|
877
|
+
}
|
|
878
|
+
function moveAside(base, browserDir) {
|
|
879
|
+
const trash = path.join(base, `.trash-${randomBytes(6).toString("hex")}`);
|
|
880
|
+
try {
|
|
881
|
+
renameDir(browserDir, trash);
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
if (errCode(e) === "ENOENT")
|
|
885
|
+
return;
|
|
886
|
+
throw new InUseError(`Clearcote cannot replace the browser files in\n ${browserDir}\nbecause another program is still using them (${e.message}).\n` +
|
|
887
|
+
"Close every program that uses this Clearcote browser, then try again.");
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
rmSync(trash, { recursive: true, force: true });
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
/* what is still in use stays until the next install sweeps it */
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Move an unverified browser/ aside. After taking over a holder that was not confirmed dead (it was suspended),
|
|
898
|
+
* its half-installed tree can stay in use until it resumes and finds the lock gone: wait for that, within the
|
|
899
|
+
* install wait, instead of failing. In any other case an in-use tree fails at once.
|
|
900
|
+
*/
|
|
901
|
+
async function moveAsideWhenFree(base, browserDir, lock, quiet) {
|
|
902
|
+
const deadline = performance.now() + installLockTiming.waitMs;
|
|
903
|
+
let told = false;
|
|
904
|
+
for (;;) {
|
|
905
|
+
try {
|
|
906
|
+
moveAside(base, browserDir);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
catch (e) {
|
|
910
|
+
if (!(e instanceof InUseError) || !lock.tookOver || performance.now() >= deadline)
|
|
911
|
+
throw e;
|
|
912
|
+
if (!lock.held())
|
|
913
|
+
throw new LockLost();
|
|
914
|
+
if (!told) {
|
|
915
|
+
log(quiet, `waiting for the installer that lost the lock to let go of ${browserDir}`);
|
|
916
|
+
told = true;
|
|
917
|
+
}
|
|
918
|
+
await sleep(installLockTiming.inUsePollMs);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Remove what an install that stopped part-way left behind. Only called by the lock holder. Returns the
|
|
924
|
+
* breaker file's state now (and when, by this process's clock). Given its state at the start of the install,
|
|
925
|
+
* removes a breaker that has not changed since, once that is the breaker window or more: one left by a
|
|
926
|
+
* process killed while it broke a stale lock.
|
|
927
|
+
*/
|
|
928
|
+
function sweepLeftovers(base, asset, breakerSeen) {
|
|
929
|
+
const breaker = path.join(base, `${INSTALL_LOCK}.break`);
|
|
930
|
+
const seen = { key: fileKey(breaker), at: performance.now() };
|
|
931
|
+
let names = [];
|
|
932
|
+
try {
|
|
933
|
+
names = readdirSync(base);
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
return seen;
|
|
937
|
+
}
|
|
938
|
+
for (const name of names) {
|
|
939
|
+
if (name === ".incoming" || name.startsWith(".tmp-") || name.startsWith(".trash-") || name.startsWith(`${MANIFEST}.tmp-`)) {
|
|
940
|
+
try {
|
|
941
|
+
rmSync(path.join(base, name), { recursive: true, force: true });
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
/* best-effort */
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (breakerSeen && seen.key !== null && seen.key === breakerSeen.key && seen.at - breakerSeen.at >= installLockTiming.breakerStaleMs) {
|
|
949
|
+
try {
|
|
950
|
+
rmSync(breaker, { force: true });
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
/* best-effort */
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (asset) {
|
|
957
|
+
try {
|
|
958
|
+
rmSync(path.join(base, asset), { force: true }); // where earlier versions downloaded the archive
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
/* best-effort */
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return seen;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Download + verify a resolved release into `base`, returning the extracted browser-binary path.
|
|
968
|
+
*
|
|
969
|
+
* Holds `base`'s install lock: a second process that missed the cache at the same moment waits, then uses
|
|
970
|
+
* the tree the first one finished instead of downloading over it.
|
|
971
|
+
*/
|
|
457
972
|
async function fetchAndVerify(rel, base, opts) {
|
|
973
|
+
const binary = rel.binary || "chrome.exe";
|
|
974
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
975
|
+
const lock = await acquireInstallLock(base, { quiet: opts.quiet ?? false });
|
|
976
|
+
try {
|
|
977
|
+
const cached = cachedBinary(base, binary, opts.quiet);
|
|
978
|
+
if (cached) {
|
|
979
|
+
log(opts.quiet, `installed by another process meanwhile: ${cached}`);
|
|
980
|
+
return cached;
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
return await installLocked(rel, base, opts, lock);
|
|
984
|
+
}
|
|
985
|
+
catch (e) {
|
|
986
|
+
if (!(e instanceof LockLost) && lock.held())
|
|
987
|
+
throw e;
|
|
988
|
+
}
|
|
989
|
+
log(opts.quiet, "another process took over or changed this install; checking again");
|
|
990
|
+
}
|
|
991
|
+
finally {
|
|
992
|
+
await lock.release();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
throw new Error(`Clearcote could not install ${rel.tag}: other processes kept taking over the install in ${base}`);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* The install itself; the caller holds the install lock. Everything is written under `base/.tmp-<nonce>`
|
|
999
|
+
* first, and the finished tree is moved into place only while the lock is still ours.
|
|
1000
|
+
*/
|
|
1001
|
+
async function installLocked(rel, base, opts, lock) {
|
|
1002
|
+
const breakerSeen = sweepLeftovers(base, rel.asset);
|
|
1003
|
+
const tmp = path.join(base, `.tmp-${lock.nonce}`);
|
|
1004
|
+
mkdirSync(tmp, { recursive: true });
|
|
1005
|
+
try {
|
|
1006
|
+
return await installInto(rel, base, tmp, opts, lock, breakerSeen);
|
|
1007
|
+
}
|
|
1008
|
+
finally {
|
|
1009
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => { });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function installInto(rel, base, tmp, opts, lock, breakerSeen) {
|
|
458
1013
|
const browserDir = path.join(base, "browser");
|
|
459
|
-
|
|
460
|
-
const zipPath = path.join(base, rel.asset);
|
|
1014
|
+
const zipPath = path.join(tmp, rel.asset);
|
|
461
1015
|
log(opts.quiet, `fetching Clearcote ${rel.version} (${rel.tag}${rel.unpinned ? ", latest" : ""}, ~${(rel.size / 1e6).toFixed(0)} MB)`);
|
|
462
1016
|
await downloadTo(rel.url, zipPath, rel.size, opts.quiet);
|
|
463
1017
|
log(opts.quiet, "verifying SHA-256");
|
|
@@ -470,7 +1024,7 @@ async function fetchAndVerify(rel, base, opts) {
|
|
|
470
1024
|
if (rel.unpinned && rel.ascUrl) {
|
|
471
1025
|
const sumsBody = await fetchText(`https://github.com/${REPO}/releases/download/${rel.tag}/SHA256SUMS.txt`).catch(() => "");
|
|
472
1026
|
if (sumsBody) {
|
|
473
|
-
const verdict = await gpgVerifyAsync(rel, sumsBody,
|
|
1027
|
+
const verdict = await gpgVerifyAsync(rel, sumsBody, tmp, opts.quiet);
|
|
474
1028
|
if (verdict === "failed") {
|
|
475
1029
|
await rm(zipPath, { force: true });
|
|
476
1030
|
throw new Error(`Clearcote ${rel.tag}: GPG signature verification FAILED against the pinned key ${SIGNING_KEY_FPR} — refusing to use it.`);
|
|
@@ -480,25 +1034,25 @@ async function fetchAndVerify(rel, base, opts) {
|
|
|
480
1034
|
}
|
|
481
1035
|
}
|
|
482
1036
|
log(opts.quiet, "extracting");
|
|
483
|
-
|
|
484
|
-
// Extract to a sibling temp dir, then atomically move it into place so `browser/` only ever
|
|
1037
|
+
// Extract next to the target, then move the finished tree into place so `browser/` only ever
|
|
485
1038
|
// appears once fully written (no partial tree a launch could race), and — on Windows — we can
|
|
486
1039
|
// pre-scan the finished tree before any launch (below).
|
|
487
|
-
const incoming = path.join(
|
|
488
|
-
await rm(incoming, { recursive: true, force: true });
|
|
1040
|
+
const incoming = path.join(tmp, "browser");
|
|
489
1041
|
mkdirSync(incoming, { recursive: true });
|
|
490
1042
|
if (rel.asset.endsWith(".tar.xz") || rel.archive === "tar.xz") {
|
|
491
|
-
// Node has no stdlib xz; the system `tar` (always present on Linux) auto-detects .xz.
|
|
492
|
-
|
|
1043
|
+
// Node has no stdlib xz; the system `tar` (always present on Linux) auto-detects .xz. Async, so the
|
|
1044
|
+
// lock's heartbeat keeps running during a long extraction.
|
|
1045
|
+
await promisify(execFile)("tar", ["-xf", zipPath, "-C", incoming]);
|
|
493
1046
|
}
|
|
494
1047
|
else {
|
|
495
1048
|
await extract(zipPath, { dir: incoming });
|
|
496
1049
|
}
|
|
497
|
-
|
|
1050
|
+
await rm(zipPath, { force: true }); // reclaim ~250 MB; keep only the extracted tree
|
|
498
1051
|
const binaryName = rel.binary || "chrome.exe";
|
|
499
|
-
const
|
|
500
|
-
if (!
|
|
1052
|
+
const found = findFile(incoming, binaryName);
|
|
1053
|
+
if (!found)
|
|
501
1054
|
throw new Error(`Clearcote archive verified but ${binaryName} was not found inside it.`);
|
|
1055
|
+
let exe = found;
|
|
502
1056
|
if (rel.exeSha256) {
|
|
503
1057
|
const exeHash = await sha256File(exe);
|
|
504
1058
|
if (exeHash.toLowerCase() !== rel.exeSha256.toLowerCase()) {
|
|
@@ -525,16 +1079,42 @@ async function fetchAndVerify(rel, base, opts) {
|
|
|
525
1079
|
}
|
|
526
1080
|
}
|
|
527
1081
|
}
|
|
1082
|
+
// Move the tree into place only while the lock is still ours, and never over a tree that passes the full
|
|
1083
|
+
// check: one that appeared since the cache check (another installer finished it) may already be running,
|
|
1084
|
+
// so use it. One marked verified that fails the check is damaged, and moved aside (we hold the lock).
|
|
1085
|
+
lock.beat();
|
|
1086
|
+
if (!lock.held())
|
|
1087
|
+
throw new LockLost();
|
|
1088
|
+
const done = cachedBinary(base, binaryName, opts.quiet, true, true);
|
|
1089
|
+
if (done) {
|
|
1090
|
+
log(opts.quiet, `installed by another process meanwhile: ${done}`);
|
|
1091
|
+
return done;
|
|
1092
|
+
}
|
|
1093
|
+
if (existsSync(path.join(base, ".verified")))
|
|
1094
|
+
throw new LockLost(); // marked verified meanwhile by another process: start over
|
|
1095
|
+
rmSync(path.join(base, MANIFEST), { force: true });
|
|
1096
|
+
if (existsSync(browserDir))
|
|
1097
|
+
await moveAsideWhenFree(base, browserDir, lock, opts.quiet); // not verified: an install that stopped part-way
|
|
1098
|
+
renameDir(incoming, browserDir);
|
|
1099
|
+
exe = path.join(browserDir, path.relative(incoming, exe));
|
|
528
1100
|
if (process.platform === "win32") {
|
|
529
1101
|
// Pre-scan so real-time AV finishes with the freshly-extracted binaries before the first launch
|
|
530
1102
|
// — closes the chrome_elf.dll scan race that otherwise poisons the path (see warmFiles).
|
|
531
1103
|
warmFiles(browserDir);
|
|
532
1104
|
}
|
|
533
1105
|
// Record the finished tree BEFORE the .verified marker, so a launch never sees "verified" with
|
|
534
|
-
// no manifest to check it against
|
|
1106
|
+
// no manifest to check it against -- and only while the lock is still ours, so a late manifest never
|
|
1107
|
+
// lands on a tree the new holder is putting in place.
|
|
1108
|
+
if (!lock.held())
|
|
1109
|
+
throw new LockLost();
|
|
535
1110
|
writeManifest(base, browserDir);
|
|
1111
|
+
// Mark it verified only while the lock is still ours: a process that took the lock over meanwhile then
|
|
1112
|
+
// finds an unverified tree it may replace, not a verified one it must leave alone.
|
|
1113
|
+
installTestHooks.beforeVerified?.();
|
|
1114
|
+
if (!lock.held())
|
|
1115
|
+
throw new LockLost();
|
|
536
1116
|
writeFileSync(path.join(base, ".verified"), `${rel.sha256}\n`);
|
|
537
|
-
|
|
1117
|
+
sweepLeftovers(base, undefined, breakerSeen);
|
|
538
1118
|
log(opts.quiet, `ready: ${exe}`);
|
|
539
1119
|
return exe;
|
|
540
1120
|
}
|
|
@@ -678,7 +1258,7 @@ export async function ensureVersion(selector, opts = {}) {
|
|
|
678
1258
|
}
|
|
679
1259
|
const base = path.join(opts.cacheDir || defaultCacheRoot(), plan.rel.tag);
|
|
680
1260
|
{
|
|
681
|
-
const cached = cachedBinary(base, plan.rel.binary || "chrome", opts.quiet);
|
|
1261
|
+
const cached = cachedBinary(base, plan.rel.binary || "chrome", opts.quiet, false);
|
|
682
1262
|
if (cached)
|
|
683
1263
|
return cached;
|
|
684
1264
|
}
|
|
@@ -757,7 +1337,7 @@ export async function proEnsureBinary(licenseKey, opts = {}) {
|
|
|
757
1337
|
};
|
|
758
1338
|
const base = path.join(opts.cacheDir || defaultCacheRoot(), rel.tag);
|
|
759
1339
|
{
|
|
760
|
-
const cached = cachedBinary(base, rel.binary || "chrome.exe", opts.quiet);
|
|
1340
|
+
const cached = cachedBinary(base, rel.binary || "chrome.exe", opts.quiet, false);
|
|
761
1341
|
if (cached)
|
|
762
1342
|
return cached;
|
|
763
1343
|
}
|
|
@@ -785,7 +1365,7 @@ export async function ensureBinary(opts = {}) {
|
|
|
785
1365
|
}
|
|
786
1366
|
const base = path.join(cacheRoot, rel.tag);
|
|
787
1367
|
{
|
|
788
|
-
const cached = cachedBinary(base, rel.binary || "chrome.exe", opts.quiet);
|
|
1368
|
+
const cached = cachedBinary(base, rel.binary || "chrome.exe", opts.quiet, false);
|
|
789
1369
|
if (cached)
|
|
790
1370
|
return cached;
|
|
791
1371
|
}
|