iterate-harness 2.4.1 → 2.6.0
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/lib/bootstrap.js +243 -41
- package/package.json +1 -1
package/lib/bootstrap.js
CHANGED
|
@@ -59,6 +59,20 @@ const DOWNLOAD_TOTAL_TIMEOUT_MS = 60000;
|
|
|
59
59
|
const BOOTSTRAP_TIMEOUT_MS = 900000;
|
|
60
60
|
const CURL_MAX_TIME_SECONDS = 240;
|
|
61
61
|
|
|
62
|
+
// A `python --version` probe or a `python -m venv` step must never hang the
|
|
63
|
+
// wrapper forever (broken py-launcher on Windows, wedged NFS home, ...).
|
|
64
|
+
const PYTHON_PROBE_TIMEOUT_MS = 30000;
|
|
65
|
+
const STEP_TIMEOUT_MS = 600000;
|
|
66
|
+
|
|
67
|
+
// Advisory lock serializing concurrent bootstraps (parallel `ih` runs, npm
|
|
68
|
+
// postinstall racing the first invocation): two installers writing the same
|
|
69
|
+
// venv corrupt it. A live bootstrap is capped at BOOTSTRAP_TIMEOUT_MS, so a
|
|
70
|
+
// lock untouched for longer belongs to a crashed/killed installer.
|
|
71
|
+
const LOCK_FILE_NAME = "bootstrap.lock";
|
|
72
|
+
const LOCK_STALE_MS = BOOTSTRAP_TIMEOUT_MS + 5 * 60 * 1000;
|
|
73
|
+
const LOCK_POLL_INTERVAL_MS = 400;
|
|
74
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = BOOTSTRAP_TIMEOUT_MS;
|
|
75
|
+
|
|
62
76
|
// Integrity: every release publishes a `<artifact>.sha256` sidecar next to the
|
|
63
77
|
// artifact. Any bytes this wrapper downloads itself are hashed and compared
|
|
64
78
|
// against that sidecar before pip is allowed to install them.
|
|
@@ -171,6 +185,23 @@ function assertHttpsInstallSource(url, allowHttp) {
|
|
|
171
185
|
}
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
// Resolve a `Location` header against the URL it came from and enforce the
|
|
189
|
+
// plain-HTTP gate on the result. Two failure modes are guarded here:
|
|
190
|
+
// - a malformed Location (e.g. `http://[::`) used to throw an uncaught
|
|
191
|
+
// TypeError inside the response callback and crash the whole wrapper;
|
|
192
|
+
// - an https→http redirect silently bypassed the --allow-http gate that the
|
|
193
|
+
// original install URL was checked against.
|
|
194
|
+
function resolveRedirectUrl(location, fromUrl, allowHttp) {
|
|
195
|
+
let next;
|
|
196
|
+
try {
|
|
197
|
+
next = new URL(String(location), String(fromUrl)).toString();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw new BootstrapError(`invalid redirect from ${fromUrl}: ${error.message}`);
|
|
200
|
+
}
|
|
201
|
+
assertHttpsInstallSource(next, allowHttp);
|
|
202
|
+
return next;
|
|
203
|
+
}
|
|
204
|
+
|
|
174
205
|
// ---------------------------------------------------------------------------
|
|
175
206
|
// Pure helpers (unit-tested in test/bootstrap.test.js)
|
|
176
207
|
// ---------------------------------------------------------------------------
|
|
@@ -272,7 +303,7 @@ function sha256File(filePath) {
|
|
|
272
303
|
}
|
|
273
304
|
|
|
274
305
|
// Small text fetch with the same redirect/timeout discipline as downloadFile.
|
|
275
|
-
function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal) {
|
|
306
|
+
function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal, allowHttp) {
|
|
276
307
|
const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
|
|
277
308
|
const totalTimeout = totalTimeoutMs === undefined ? DOWNLOAD_TOTAL_TIMEOUT_MS : totalTimeoutMs;
|
|
278
309
|
return new Promise((resolve, reject) => {
|
|
@@ -321,19 +352,23 @@ function fetchChecksumText(url, redirectBudget, totalTimeoutMs, signal) {
|
|
|
321
352
|
fail(new BootstrapError(`invalid checksum URL ${url}: ${error.message}`));
|
|
322
353
|
return;
|
|
323
354
|
}
|
|
355
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
356
|
+
fail(new BootstrapError(`unsupported checksum protocol ${parsed.protocol} in ${url}`));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
324
359
|
const client = parsed.protocol === "http:" ? http : https;
|
|
325
360
|
request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
|
|
326
361
|
const status = response.statusCode || 0;
|
|
327
362
|
if (status >= 300 && status < 400 && response.headers.location) {
|
|
328
363
|
response.resume();
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
);
|
|
364
|
+
let nextUrl;
|
|
365
|
+
try {
|
|
366
|
+
nextUrl = resolveRedirectUrl(response.headers.location, url, allowHttp);
|
|
367
|
+
} catch (redirectError) {
|
|
368
|
+
fail(redirectError);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
finish(fetchChecksumText(nextUrl, budget - 1, totalTimeout, signal, allowHttp));
|
|
337
372
|
return;
|
|
338
373
|
}
|
|
339
374
|
if (status < 200 || status >= 300) {
|
|
@@ -467,15 +502,24 @@ function runtimeHomeDir(env) {
|
|
|
467
502
|
return path.join(os.homedir(), RUNTIME_DIR_NAME);
|
|
468
503
|
}
|
|
469
504
|
|
|
470
|
-
function detectPython(env) {
|
|
505
|
+
function detectPython(env, options) {
|
|
506
|
+
const probeTimeoutMs =
|
|
507
|
+
options && options.probeTimeoutMs !== undefined
|
|
508
|
+
? options.probeTimeoutMs
|
|
509
|
+
: PYTHON_PROBE_TIMEOUT_MS;
|
|
471
510
|
const errors = [];
|
|
472
511
|
for (const candidate of pythonCandidates(process.platform, env || process.env)) {
|
|
473
512
|
const probe = spawnSync(candidate.command, [...candidate.preArgs, "--version"], {
|
|
474
513
|
encoding: "utf8",
|
|
475
514
|
windowsHide: true,
|
|
515
|
+
timeout: probeTimeoutMs,
|
|
476
516
|
});
|
|
477
517
|
if (probe.error) {
|
|
478
|
-
errors.push(
|
|
518
|
+
errors.push(
|
|
519
|
+
probe.error.code === "ETIMEDOUT"
|
|
520
|
+
? `${candidate.command}: probing --version timed out after ${Math.round(probeTimeoutMs / 1000)}s`
|
|
521
|
+
: `${candidate.command}: not found`
|
|
522
|
+
);
|
|
479
523
|
continue;
|
|
480
524
|
}
|
|
481
525
|
const stdout = `${probe.stdout || ""}${probe.stderr || ""}`;
|
|
@@ -509,19 +553,34 @@ function detectPython(env) {
|
|
|
509
553
|
const CERT_VERIFY_FAILURE_MARKER = "CERTIFICATE_VERIFY_FAILED";
|
|
510
554
|
|
|
511
555
|
function runStep(command, args, options) {
|
|
556
|
+
const timeoutMs =
|
|
557
|
+
options && options.timeout !== undefined ? options.timeout : STEP_TIMEOUT_MS;
|
|
512
558
|
const result = spawnSync(command, args, {
|
|
513
559
|
stdio: ["ignore", "pipe", "pipe"],
|
|
514
560
|
windowsHide: true,
|
|
515
561
|
maxBuffer: 16 * 1024 * 1024,
|
|
562
|
+
timeout: timeoutMs,
|
|
516
563
|
...options,
|
|
517
564
|
});
|
|
518
565
|
const capturedOutput = `${result.stdout || ""}${result.stderr || ""}`;
|
|
519
566
|
if (capturedOutput) {
|
|
520
567
|
process.stderr.write(capturedOutput);
|
|
521
568
|
}
|
|
569
|
+
if (result.error && result.error.code === "ETIMEDOUT") {
|
|
570
|
+
throw new BootstrapError(
|
|
571
|
+
`${command} ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
522
574
|
if (result.error) {
|
|
523
575
|
throw new BootstrapError(`failed to run ${command}: ${result.error.message}`);
|
|
524
576
|
}
|
|
577
|
+
if (result.status === null) {
|
|
578
|
+
// Killed by a signal (not a numeric exit): "exited with code null" would
|
|
579
|
+
// send users hunting for a nonexistent exit code.
|
|
580
|
+
throw new BootstrapError(
|
|
581
|
+
`${command} ${args.join(" ")} was terminated by signal ${result.signal || "unknown"}`
|
|
582
|
+
);
|
|
583
|
+
}
|
|
525
584
|
if (result.status !== 0) {
|
|
526
585
|
if (capturedOutput.includes(CERT_VERIFY_FAILURE_MARKER)) {
|
|
527
586
|
throw certVerifyFailure(command, args, result.status);
|
|
@@ -552,8 +611,10 @@ function runPipStepAsync(python, args, signal) {
|
|
|
552
611
|
}
|
|
553
612
|
process.stderr.write(text);
|
|
554
613
|
};
|
|
555
|
-
|
|
556
|
-
|
|
614
|
+
// On some spawn failures the stdio streams are null; the 'error' handler
|
|
615
|
+
// below still reports the failure, but attaching must not throw first.
|
|
616
|
+
if (child.stdout) child.stdout.on("data", forwardOutput);
|
|
617
|
+
if (child.stderr) child.stderr.on("data", forwardOutput);
|
|
557
618
|
const fail = (error) => {
|
|
558
619
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
559
620
|
if (!child.killed) child.kill();
|
|
@@ -606,11 +667,12 @@ function installCandidates(version, env) {
|
|
|
606
667
|
// Artifact download fallback (Node-side TLS — survives broken pip trust stores)
|
|
607
668
|
// ---------------------------------------------------------------------------
|
|
608
669
|
|
|
609
|
-
function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
670
|
+
function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal, allowHttp) {
|
|
610
671
|
const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
|
|
611
672
|
const totalTimeout = totalTimeoutMs === undefined ? DOWNLOAD_TOTAL_TIMEOUT_MS : totalTimeoutMs;
|
|
612
673
|
return new Promise((resolve, reject) => {
|
|
613
674
|
let request = null;
|
|
675
|
+
let file = null;
|
|
614
676
|
let timer = null;
|
|
615
677
|
let onAbort = null;
|
|
616
678
|
let settled = false;
|
|
@@ -626,7 +688,12 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
|
626
688
|
settled = true;
|
|
627
689
|
if (timer) clearTimeout(timer);
|
|
628
690
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
691
|
+
// Tear down the in-flight file stream (a timed-out/aborted download
|
|
692
|
+
// would otherwise leak the fd) and never leave a truncated artifact
|
|
693
|
+
// behind that a later run could mistake for a complete one.
|
|
694
|
+
if (file) file.destroy();
|
|
629
695
|
if (request) request.destroy(error);
|
|
696
|
+
try { fs.rmSync(dest, { force: true }); } catch { /* best-effort */ }
|
|
630
697
|
reject(error);
|
|
631
698
|
};
|
|
632
699
|
timer = setTimeout(() => {
|
|
@@ -655,13 +722,23 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
|
655
722
|
fail(new BootstrapError(`invalid download URL ${url}: ${error.message}`));
|
|
656
723
|
return;
|
|
657
724
|
}
|
|
725
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
726
|
+
fail(new BootstrapError(`unsupported download protocol ${parsed.protocol} in ${url}`));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
658
729
|
const client = parsed.protocol === "http:" ? http : https;
|
|
659
730
|
request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
|
|
660
731
|
const status = response.statusCode || 0;
|
|
661
732
|
if (status >= 300 && status < 400 && response.headers.location) {
|
|
662
733
|
response.resume();
|
|
663
|
-
|
|
664
|
-
|
|
734
|
+
let nextUrl;
|
|
735
|
+
try {
|
|
736
|
+
nextUrl = resolveRedirectUrl(response.headers.location, url, allowHttp);
|
|
737
|
+
} catch (redirectError) {
|
|
738
|
+
fail(redirectError);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
finish(downloadFile(nextUrl, dest, budget - 1, totalTimeout, signal, allowHttp));
|
|
665
742
|
return;
|
|
666
743
|
}
|
|
667
744
|
if (status < 200 || status >= 300) {
|
|
@@ -669,8 +746,13 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
|
669
746
|
fail(new BootstrapError(`downloading ${url} failed with HTTP ${status}`));
|
|
670
747
|
return;
|
|
671
748
|
}
|
|
672
|
-
|
|
749
|
+
file = fs.createWriteStream(dest);
|
|
673
750
|
response.pipe(file);
|
|
751
|
+
// A socket reset mid-body emits on the response, not the file: without
|
|
752
|
+
// this handler the download would stall until the total timeout fired.
|
|
753
|
+
response.on("error", (responseError) => {
|
|
754
|
+
fail(responseError);
|
|
755
|
+
});
|
|
674
756
|
file.on("finish", () => {
|
|
675
757
|
file.close((closeError) => {
|
|
676
758
|
if (closeError) {
|
|
@@ -681,7 +763,6 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
|
681
763
|
});
|
|
682
764
|
});
|
|
683
765
|
file.on("error", (fileError) => {
|
|
684
|
-
file.destroy();
|
|
685
766
|
fail(fileError);
|
|
686
767
|
});
|
|
687
768
|
});
|
|
@@ -692,12 +773,17 @@ function downloadFile(url, dest, redirectBudget, totalTimeoutMs, signal) {
|
|
|
692
773
|
});
|
|
693
774
|
}
|
|
694
775
|
|
|
695
|
-
|
|
776
|
+
// `--proto-redir` pins the protocols curl may follow a redirect to: by
|
|
777
|
+
// default only https, so a 302 to plain http cannot bypass the --allow-http
|
|
778
|
+
// gate the original URL was checked against (curl -L would happily fetch it).
|
|
779
|
+
function curlDownload(url, dest, spawnFn, allowHttp) {
|
|
696
780
|
const runner = spawnFn || spawnSync;
|
|
697
781
|
const result = runner("curl", [
|
|
698
782
|
"-fsSL",
|
|
699
783
|
"--max-time",
|
|
700
784
|
String(CURL_MAX_TIME_SECONDS),
|
|
785
|
+
"--proto-redir",
|
|
786
|
+
allowHttp ? "=https,http" : "=https",
|
|
701
787
|
"-o",
|
|
702
788
|
dest,
|
|
703
789
|
url,
|
|
@@ -720,10 +806,18 @@ function curlDownload(url, dest, spawnFn) {
|
|
|
720
806
|
|
|
721
807
|
async function downloadTarballTo(url, dest, deps) {
|
|
722
808
|
const signal = deps && deps.signal;
|
|
809
|
+
const allowHttp = Boolean(deps && deps.allowHttp);
|
|
723
810
|
const nodeDownload =
|
|
724
811
|
(deps && deps.nodeDownload) ||
|
|
725
812
|
((downloadUrl, downloadDest) =>
|
|
726
|
-
downloadFile(
|
|
813
|
+
downloadFile(
|
|
814
|
+
downloadUrl,
|
|
815
|
+
downloadDest,
|
|
816
|
+
undefined,
|
|
817
|
+
DOWNLOAD_TOTAL_TIMEOUT_MS,
|
|
818
|
+
signal,
|
|
819
|
+
allowHttp
|
|
820
|
+
));
|
|
727
821
|
const curlDownloadFn = deps && deps.curlDownload ? deps.curlDownload : curlDownload;
|
|
728
822
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
729
823
|
const untrack = trackDownload(dest);
|
|
@@ -736,7 +830,7 @@ async function downloadTarballTo(url, dest, deps) {
|
|
|
736
830
|
{
|
|
737
831
|
name: "curl",
|
|
738
832
|
run: async () => {
|
|
739
|
-
const outcome = curlDownloadFn(url, dest);
|
|
833
|
+
const outcome = curlDownloadFn(url, dest, undefined, allowHttp);
|
|
740
834
|
if (!outcome.ok) {
|
|
741
835
|
throw outcome.error;
|
|
742
836
|
}
|
|
@@ -785,10 +879,10 @@ async function installHarness(options) {
|
|
|
785
879
|
const runPipStep = options.runStepFn || runPipStepAsync;
|
|
786
880
|
const downloader = options.downloader
|
|
787
881
|
? options.downloader
|
|
788
|
-
: (url, dest) => downloadTarballTo(url, dest, { signal });
|
|
882
|
+
: (url, dest) => downloadTarballTo(url, dest, { signal, allowHttp });
|
|
789
883
|
const checksumFetcher = options.checksumFetcher
|
|
790
884
|
? options.checksumFetcher
|
|
791
|
-
: (url) => fetchChecksumText(url, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal);
|
|
885
|
+
: (url) => fetchChecksumText(url, undefined, DOWNLOAD_TOTAL_TIMEOUT_MS, signal, allowHttp);
|
|
792
886
|
|
|
793
887
|
if (!candidates || candidates.length === 0) {
|
|
794
888
|
throw new BootstrapError("no install candidate URLs were provided");
|
|
@@ -854,22 +948,98 @@ async function installRemoteArtifact(
|
|
|
854
948
|
signal
|
|
855
949
|
) {
|
|
856
950
|
assertHttpsInstallSource(url, allowHttp);
|
|
951
|
+
let primaryError;
|
|
857
952
|
try {
|
|
858
953
|
await runStepFn(python, pipInstallArgs(url), signal);
|
|
859
954
|
return;
|
|
860
|
-
} catch (
|
|
955
|
+
} catch (error) {
|
|
956
|
+
primaryError = error;
|
|
861
957
|
process.stderr.write(
|
|
862
958
|
`[iterate-harness] pip install from ${url} failed; retrying via direct download ...\n`
|
|
863
959
|
);
|
|
864
960
|
}
|
|
865
961
|
const cachePath = downloadCachePath(homeDir, version, artifactExtensionFor(url));
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
962
|
+
try {
|
|
963
|
+
await downloader(url, cachePath);
|
|
964
|
+
// These are bytes we fetched ourselves, so we own their integrity: check
|
|
965
|
+
// the published SHA256 sidecar before handing the file to pip.
|
|
966
|
+
await verifyDownloadedArtifact(url, cachePath, checksumFetcher, noVerify);
|
|
967
|
+
} catch (downloadError) {
|
|
968
|
+
// Keep the primary pip failure in the message: "all install candidates
|
|
969
|
+
// failed" is useless if it only reports the fallback's error.
|
|
970
|
+
throw new BootstrapError(
|
|
971
|
+
`pip install from ${url} failed (${primaryError.message}); ` +
|
|
972
|
+
`the direct-download fallback failed too: ${downloadError.message}`
|
|
973
|
+
);
|
|
974
|
+
}
|
|
870
975
|
await runStepFn(python, pipInstallArgs(cachePath), signal);
|
|
871
976
|
}
|
|
872
977
|
|
|
978
|
+
// Serialize concurrent bootstraps with an atomically-created lock file
|
|
979
|
+
// (`open(..., "wx")` is O_EXCL — no check-then-create race). A lock whose mtime
|
|
980
|
+
// is older than LOCK_STALE_MS belongs to a crashed installer and is reaped;
|
|
981
|
+
// a live waiter polls until the holder releases or LOCK_ACQUIRE_TIMEOUT_MS
|
|
982
|
+
// elapses. The release callback only deletes a lock still bearing OUR token,
|
|
983
|
+
// so it can never free a lock that was stale-reaped and re-taken meanwhile.
|
|
984
|
+
async function acquireBootstrapLock(homeDir, options) {
|
|
985
|
+
const opts = options || {};
|
|
986
|
+
const pollIntervalMs =
|
|
987
|
+
opts.pollIntervalMs !== undefined ? opts.pollIntervalMs : LOCK_POLL_INTERVAL_MS;
|
|
988
|
+
const acquireTimeoutMs =
|
|
989
|
+
opts.acquireTimeoutMs !== undefined ? opts.acquireTimeoutMs : LOCK_ACQUIRE_TIMEOUT_MS;
|
|
990
|
+
const staleMs = opts.staleMs !== undefined ? opts.staleMs : LOCK_STALE_MS;
|
|
991
|
+
fs.mkdirSync(homeDir, { recursive: true });
|
|
992
|
+
const lockPath = path.join(homeDir, LOCK_FILE_NAME);
|
|
993
|
+
const token = `${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString("hex")}`;
|
|
994
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
995
|
+
for (;;) {
|
|
996
|
+
try {
|
|
997
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
998
|
+
try {
|
|
999
|
+
fs.writeSync(fd, token);
|
|
1000
|
+
} finally {
|
|
1001
|
+
fs.closeSync(fd);
|
|
1002
|
+
}
|
|
1003
|
+
return () => {
|
|
1004
|
+
try {
|
|
1005
|
+
if (fs.readFileSync(lockPath, "utf8") === token) {
|
|
1006
|
+
fs.rmSync(lockPath, { force: true });
|
|
1007
|
+
}
|
|
1008
|
+
} catch {
|
|
1009
|
+
/* best-effort: a leftover stale lock is reaped by the next waiter */
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (error.code !== "EEXIST") {
|
|
1014
|
+
throw new BootstrapError(`cannot create bootstrap lock ${lockPath}: ${error.message}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
const stat = fs.statSync(lockPath);
|
|
1019
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
1020
|
+
process.stderr.write(
|
|
1021
|
+
`[iterate-harness] removing stale bootstrap lock ${lockPath}\n`
|
|
1022
|
+
);
|
|
1023
|
+
fs.rmSync(lockPath, { force: true });
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
} catch (statError) {
|
|
1027
|
+
if (statError.code === "ENOENT") {
|
|
1028
|
+
// Holder released between our open and the stat — retry immediately.
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
throw new BootstrapError(`cannot stat bootstrap lock ${lockPath}: ${statError.message}`);
|
|
1032
|
+
}
|
|
1033
|
+
if (Date.now() > deadline) {
|
|
1034
|
+
throw new BootstrapError(
|
|
1035
|
+
`timed out waiting for the bootstrap lock ${lockPath}; ` +
|
|
1036
|
+
"if no other `ih`/npm install is running, remove that file and retry"
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
873
1043
|
async function bootstrap(homeDir, version, env, options) {
|
|
874
1044
|
const opts = options || {};
|
|
875
1045
|
fs.mkdirSync(homeDir, { recursive: true });
|
|
@@ -946,9 +1116,17 @@ async function ensureRuntime(env, options) {
|
|
|
946
1116
|
? fs.readFileSync(stampPath, "utf8")
|
|
947
1117
|
: "";
|
|
948
1118
|
|
|
949
|
-
|
|
1119
|
+
// Bootstrap is needed when the stamp does not match THIS wrapper's version
|
|
1120
|
+
// OR when the venv's ih entry point is gone even though the stamp survived
|
|
1121
|
+
// (user deleted/corrupted the venv) — self-heal instead of demanding
|
|
1122
|
+
// `rm -rf` on every run.
|
|
1123
|
+
const stampOutdated = needsBootstrap(stampContent, version);
|
|
1124
|
+
const ihMissing = !fs.existsSync(executable.ih);
|
|
1125
|
+
|
|
1126
|
+
if (!skipInstall && (stampOutdated || ihMissing)) {
|
|
950
1127
|
if (opts.interactive) {
|
|
951
|
-
// Interactive install wizard: ask the user before downloading.
|
|
1128
|
+
// Interactive install wizard: ask the user before downloading. Asked
|
|
1129
|
+
// before taking the bootstrap lock so a waiting user never holds it.
|
|
952
1130
|
const confirmed = await ui.askYesNo(
|
|
953
1131
|
`Install iterate-harness v${version} into ${homeDir}?`,
|
|
954
1132
|
true
|
|
@@ -958,18 +1136,36 @@ async function ensureRuntime(env, options) {
|
|
|
958
1136
|
"Skipped installing iterate-harness. Re-run `ih` whenever you are ready."
|
|
959
1137
|
);
|
|
960
1138
|
}
|
|
961
|
-
ui.frameSection("Installing", [
|
|
962
|
-
`\x1b[36m◆\x1b[0m Version: v${version}`,
|
|
963
|
-
`\x1b[36m◆\x1b[0m Runtime: ${homeDir}`,
|
|
964
|
-
]);
|
|
965
1139
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1140
|
+
// Concurrent `ih` runs (or postinstall racing the first run) must not
|
|
1141
|
+
// bootstrap into the same venv at once — serialize on the lock file and
|
|
1142
|
+
// re-check the stamp afterwards (another process may have finished while
|
|
1143
|
+
// we waited).
|
|
1144
|
+
const releaseLock = await acquireBootstrapLock(homeDir);
|
|
1145
|
+
try {
|
|
1146
|
+
const currentStamp = fs.existsSync(stampPath)
|
|
1147
|
+
? fs.readFileSync(stampPath, "utf8")
|
|
1148
|
+
: "";
|
|
1149
|
+
if (!needsBootstrap(currentStamp, version) && fs.existsSync(executable.ih)) {
|
|
1150
|
+
ui.info(`iterate-harness ${version} was installed by another process; reusing it.`);
|
|
1151
|
+
} else {
|
|
1152
|
+
if (opts.interactive) {
|
|
1153
|
+
ui.frameSection("Installing", [
|
|
1154
|
+
`\x1b[36m◆\x1b[0m Version: v${version}`,
|
|
1155
|
+
`\x1b[36m◆\x1b[0m Runtime: ${homeDir}`,
|
|
1156
|
+
]);
|
|
1157
|
+
}
|
|
1158
|
+
await bootstrap(homeDir, version, environment, opts);
|
|
1159
|
+
if (opts.interactive) {
|
|
1160
|
+
ui.frameSection("Done", [
|
|
1161
|
+
`\x1b[32m✓\x1b[0m iterate-harness v${version} installed`,
|
|
1162
|
+
` Run \x1b[36mih --help\x1b[0m to see all commands.`,
|
|
1163
|
+
` Try \x1b[36mih status\x1b[0m or \x1b[36mih iterate --help\x1b[0m to get started.`,
|
|
1164
|
+
]);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
} finally {
|
|
1168
|
+
releaseLock();
|
|
973
1169
|
}
|
|
974
1170
|
}
|
|
975
1171
|
|
|
@@ -1082,6 +1278,7 @@ module.exports = {
|
|
|
1082
1278
|
BootstrapError,
|
|
1083
1279
|
CancelledError,
|
|
1084
1280
|
DEFAULT_ARTIFACT_EXT,
|
|
1281
|
+
LOCK_FILE_NAME,
|
|
1085
1282
|
MAX_DOWNLOAD_REDIRECTS,
|
|
1086
1283
|
MIN_PYTHON_MAJOR,
|
|
1087
1284
|
MIN_PYTHON_MINOR,
|
|
@@ -1089,8 +1286,11 @@ module.exports = {
|
|
|
1089
1286
|
HOME_ENV_VAR,
|
|
1090
1287
|
INSTALL_URL_ENV_VAR,
|
|
1091
1288
|
SKIP_INSTALL_ENV_VAR,
|
|
1289
|
+
STEP_TIMEOUT_MS,
|
|
1290
|
+
PYTHON_PROBE_TIMEOUT_MS,
|
|
1092
1291
|
WHEEL_ASSET_SUFFIX,
|
|
1093
1292
|
CHECKSUM_ASSET_SUFFIX,
|
|
1293
|
+
acquireBootstrapLock,
|
|
1094
1294
|
artifactExtensionFor,
|
|
1095
1295
|
checksumAssetUrl,
|
|
1096
1296
|
detectPython,
|
|
@@ -1100,6 +1300,8 @@ module.exports = {
|
|
|
1100
1300
|
downloadTarballTo,
|
|
1101
1301
|
fetchChecksumText,
|
|
1102
1302
|
parseChecksum,
|
|
1303
|
+
resolveRedirectUrl,
|
|
1304
|
+
runStep,
|
|
1103
1305
|
sha256File,
|
|
1104
1306
|
verifyDownloadedArtifact,
|
|
1105
1307
|
ensureRuntime,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "npm distribution wrapper for iterate-harness — installs the Python harness (ih CLI) into a managed venv on install and exposes it as a global command",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|