@stim-cli/core 1.6.0 → 1.8.0-rc.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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { S as quotedPath, _ as releaseClaim, r as ClaimRefusedError, x as tryAcquireClaim } from "./ownership-claim-DGxNcAcy.mjs";
1
+ import { S as quotedPath, _ as releaseClaim, r as ClaimRefusedError, x as tryAcquireClaim } from "./ownership-claim-Cn3NHqBS.mjs";
2
2
  import fs, { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path, { basename, dirname, join } from "node:path";
@@ -11,12 +11,20 @@ const OWNER_MARKER = /^\.stim-claim-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-
11
11
  function sleepSync(ms) {
12
12
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13
13
  }
14
+ /**
15
+ * Whether Windows refused a call on the lock directory because another process is removing it at that
16
+ * moment. NTFS answers ERROR_ACCESS_DENIED, which Node reports as EPERM, until that removal completes,
17
+ * where POSIX answers EEXIST or ENOENT: https://github.com/appandflow/stim/issues/883.
18
+ */
19
+ function removalInFlight(error) {
20
+ return process.platform === "win32" && error?.code === "EPERM";
21
+ }
14
22
  function createLockDirectory(lockPath) {
15
23
  try {
16
24
  mkdirSync(lockPath);
17
25
  return true;
18
26
  } catch (error) {
19
- if (error?.code === "EEXIST") return false;
27
+ if (error?.code === "EEXIST" || removalInFlight(error)) return false;
20
28
  throw error;
21
29
  }
22
30
  }
@@ -24,7 +32,7 @@ function removeLockDirectory(lockPath, marker) {
24
32
  try {
25
33
  unlinkSync(marker);
26
34
  } catch (error) {
27
- if (error?.code === "ENOENT") return false;
35
+ if (error?.code === "ENOENT" || removalInFlight(error)) return false;
28
36
  throw error;
29
37
  }
30
38
  try {
@@ -36,6 +44,7 @@ function removeLockDirectory(lockPath, marker) {
36
44
  "ENOTEMPTY",
37
45
  "EEXIST"
38
46
  ].includes(error?.code ?? "")) return false;
47
+ if (removalInFlight(error)) return false;
39
48
  throw error;
40
49
  }
41
50
  }
@@ -45,7 +54,7 @@ function takeLockDirectory(lockPath, claim) {
45
54
  try {
46
55
  entries = readdirSync(lockPath);
47
56
  } catch (error) {
48
- if (error?.code === "ENOENT") return null;
57
+ if (error?.code === "ENOENT" || removalInFlight(error)) return null;
49
58
  throw error;
50
59
  }
51
60
  const name = entries.length === 1 ? entries[0] : void 0;
@@ -55,7 +64,7 @@ function takeLockDirectory(lockPath, claim) {
55
64
  try {
56
65
  entry = lstatSync(marker);
57
66
  } catch (error) {
58
- if (error?.code === "ENOENT") return null;
67
+ if (error?.code === "ENOENT" || removalInFlight(error)) return null;
59
68
  throw error;
60
69
  }
61
70
  if (!entry.isFile() || entry.size !== 0) throw new ClaimRefusedError({
@@ -212,7 +221,7 @@ function workspaceLogDir(projectRoot) {
212
221
  function cachePathSetting(key) {
213
222
  try {
214
223
  const value = JSON.parse(fs.readFileSync(path.join(configDir(), "config.json"), "utf-8"))?.caches?.[key];
215
- return typeof value === "string" && value.startsWith("/") ? value : null;
224
+ return typeof value === "string" && path.isAbsolute(value) ? value : null;
216
225
  } catch {
217
226
  return null;
218
227
  }
@@ -2,6 +2,7 @@ import { captureProcessIdentity, inspectProcessIdentity } from "./process-identi
2
2
  import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { debuglog } from "node:util";
5
6
  //#region quoted-path.ts
6
7
  /** Quote a path for a shell command Stim prints for a human to copy and run. */
7
8
  function quotedPath(path) {
@@ -17,6 +18,9 @@ const CLAIM_SUFFIX = ".claim";
17
18
  const CHILD_SUFFIX = ".child";
18
19
  const STAGING_PREFIX = ".staging-";
19
20
  const PUBLISH_ATTEMPTS = 64;
21
+ const WIN32_SETTLE_MS = 250;
22
+ const WIN32_SETTLE_STEP_MS = 5;
23
+ const debug = debuglog("stim:claim");
20
24
  var ClaimRefusedError = class extends Error {
21
25
  code = CLAIM_REFUSED;
22
26
  claimPath;
@@ -37,7 +41,7 @@ var ClaimUnavailableError = class extends Error {
37
41
  constructor(reason, { claimPath } = {}) {
38
42
  super(claimPath ? `Stim could not write the ownership claim at ${claimPath}: ${reason}.` : `Stim could not record a process identity, so it cannot take an ownership claim: ${reason}.`);
39
43
  this.reason = reason;
40
- this.remedy = claimPath ? `Restore write access to the existing claim store at ${quotedPath(claimPath)} (check parent permissions, symlink targets, mount access and sandbox rules)` : "Reinstall Stim so the unique-pid native module for this platform is present";
44
+ this.remedy = claimPath ? `Restore write access to the existing claim store at ${quotedPath(claimPath)} (check parent permissions, an antivirus or endpoint agent holding its files, symlink targets, mount access and sandbox rules)` : "Reinstall Stim so the unique-pid native module for this platform is present";
41
45
  }
42
46
  };
43
47
  /**
@@ -88,10 +92,40 @@ function asOwner(record) {
88
92
  processToken: candidate.processToken
89
93
  };
90
94
  }
95
+ function sleepSync(ms) {
96
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
97
+ }
98
+ /**
99
+ * Run a read, listing or removal and resolve the one answer Windows leaves ambiguous. NTFS answers
100
+ * ERROR_ACCESS_DENIED, which Node reports as EPERM, for a name whose removal by another process is still
101
+ * in flight, and for every name below it, where POSIX answers ENOENT; measured to settle within 134 ms
102
+ * with 12 processes on 4 cores (https://github.com/appandflow/stim/issues/883). The call is reissued in
103
+ * small steps for at most WIN32_SETTLE_MS: a result or ENOENT is the settled answer, and an EPERM that
104
+ * outlasts the window is a real denial, thrown exactly as a POSIX EACCES is. This bounds the resolution
105
+ * of an ambiguous OS answer; it never waits on a holder.
106
+ */
107
+ function settledAnswer(path, call) {
108
+ if (process.platform !== "win32") return call();
109
+ const started = Date.now();
110
+ for (;;) {
111
+ try {
112
+ const result = call();
113
+ if (Date.now() > started) debug("%s settled after %d ms", path, Date.now() - started);
114
+ return result;
115
+ } catch (err) {
116
+ const code = err?.code;
117
+ if (code !== "EPERM" || Date.now() - started >= WIN32_SETTLE_MS) {
118
+ if (code === "EPERM") debug("%s still answers EPERM after %d ms", path, Date.now() - started);
119
+ throw err;
120
+ }
121
+ }
122
+ sleepSync(WIN32_SETTLE_STEP_MS);
123
+ }
124
+ }
91
125
  function readChild(claimPath) {
92
126
  let text;
93
127
  try {
94
- text = readFileSync(childPath(claimPath), "utf-8");
128
+ text = settledAnswer(childPath(claimPath), () => readFileSync(childPath(claimPath), "utf-8"));
95
129
  } catch (err) {
96
130
  if (err?.code === "ENOENT") return null;
97
131
  return "unreadable";
@@ -105,7 +139,7 @@ function readChild(claimPath) {
105
139
  function readClaim(path, mode) {
106
140
  let text;
107
141
  try {
108
- text = readFileSync(path, "utf-8");
142
+ text = settledAnswer(path, () => readFileSync(path, "utf-8"));
109
143
  } catch (err) {
110
144
  if (err?.code === "ENOENT") return null;
111
145
  return "unreadable";
@@ -195,7 +229,7 @@ function refuseNonDirectory(root, path, label) {
195
229
  }
196
230
  function names(dir) {
197
231
  try {
198
- return readdirSync(dir).toSorted();
232
+ return settledAnswer(dir, () => readdirSync(dir)).toSorted();
199
233
  } catch (err) {
200
234
  if (err?.code === "ENOENT") return [];
201
235
  return "unreadable";
@@ -252,7 +286,7 @@ function readClaimSet(root) {
252
286
  }
253
287
  function removeOrRefuse(path, root, claimPath, label) {
254
288
  try {
255
- unlinkSync(path);
289
+ settledAnswer(path, () => unlinkSync(path));
256
290
  } catch (err) {
257
291
  const code = err?.code;
258
292
  if (code === "ENOENT") return;
@@ -370,10 +404,12 @@ function selfOwner() {
370
404
  processToken: captured.token
371
405
  };
372
406
  }
373
- function contended(error) {
374
- if (error instanceof MissingClaimStoreError) return false;
375
- const code = error?.code;
376
- return code === "EEXIST" || code === "ENOTEMPTY" || code === "ENOENT" || code === "EINVAL";
407
+ function contention(error) {
408
+ if (error instanceof MissingClaimStoreError) return null;
409
+ const { code, syscall } = error ?? {};
410
+ if (code === "EPERM" && process.platform === "win32") return { denied: syscall !== "rename" };
411
+ if (code === "EEXIST" || code === "ENOTEMPTY" || code === "ENOENT" || code === "EINVAL") return { denied: false };
412
+ return null;
377
413
  }
378
414
  var MissingClaimStoreError = class extends Error {
379
415
  code = "ENOENT";
@@ -409,6 +445,12 @@ function createClaimDir(path) {
409
445
  if (error?.code !== "EEXIST") throw error;
410
446
  }
411
447
  }
448
+ function removeStaging(staging, { recursive }) {
449
+ settledAnswer(staging, () => rmSync(staging, {
450
+ recursive,
451
+ force: true
452
+ }));
453
+ }
412
454
  function publishExclusive(root, payload, claimId, label) {
413
455
  const staging = join(root, `${STAGING_PREFIX}${claimId}`);
414
456
  const target = exclusiveClaimDir(root);
@@ -418,12 +460,10 @@ function publishExclusive(root, payload, claimId, label) {
418
460
  renameSync(staging, target);
419
461
  return join(target, `${claimId}${CLAIM_SUFFIX}`);
420
462
  } catch (err) {
421
- rmSync(staging, {
422
- recursive: true,
423
- force: true
424
- });
463
+ removeStaging(staging, { recursive: true });
425
464
  const code = err?.code;
426
- if (contended(err)) return null;
465
+ const why = contention(err);
466
+ if (why) return why;
427
467
  if (code === "ENOTDIR") refuseNonDirectory(root, target, label);
428
468
  throw err;
429
469
  }
@@ -437,8 +477,9 @@ function publishShared(root, payload, claimId) {
437
477
  renameSync(staging, target);
438
478
  return target;
439
479
  } catch (err) {
440
- rmSync(staging, { force: true });
441
- if (contended(err)) return null;
480
+ removeStaging(staging, { recursive: false });
481
+ const why = contention(err);
482
+ if (why) return why;
442
483
  throw err;
443
484
  }
444
485
  }
@@ -450,17 +491,32 @@ function settleOrRelease(claim) {
450
491
  throw err;
451
492
  }
452
493
  }
494
+ /**
495
+ * On Windows, removal in progress can answer EPERM or ENOENT while a releaser removes the store.
496
+ * A spinning contender can outpace 64 attempts; yielding lets that removal finish. A real denial
497
+ * still spends at most PUBLISH_ATTEMPTS of these before it is reported.
498
+ */
499
+ function yieldToRemoval() {
500
+ sleepSync(WIN32_SETTLE_STEP_MS);
501
+ }
453
502
  function tryAcquireClaim({ root, mode, details = {}, label = "ownership" }) {
454
503
  const owner = selfOwner();
455
504
  const reaped = [];
505
+ let denied = true;
506
+ const retry = (why) => {
507
+ denied &&= why.denied;
508
+ if (process.platform === "win32") yieldToRemoval();
509
+ };
456
510
  for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt++) {
457
511
  try {
458
512
  createClaimDir(root);
459
513
  } catch (err) {
460
514
  const code = err?.code;
461
515
  if (code === "EEXIST" || code === "ENOTDIR") refuseNonDirectory(root, root, label);
462
- if (contended(err)) continue;
463
- throw err;
516
+ const why = contention(err);
517
+ if (!why) throw err;
518
+ retry(why);
519
+ continue;
464
520
  }
465
521
  const state = inspectClaimSet(root, { label });
466
522
  reaped.push(...state.reaped);
@@ -489,7 +545,10 @@ function tryAcquireClaim({ root, mode, details = {}, label = "ownership" }) {
489
545
  });
490
546
  if (mode === "shared") {
491
547
  const published = publishShared(root, payload, claimId);
492
- if (!published) continue;
548
+ if (typeof published !== "string") {
549
+ retry(published);
550
+ continue;
551
+ }
493
552
  const claim = handle(published);
494
553
  const settled = settleOrRelease(claim);
495
554
  reaped.push(...settled.reaped);
@@ -504,7 +563,10 @@ function tryAcquireClaim({ root, mode, details = {}, label = "ownership" }) {
504
563
  };
505
564
  }
506
565
  const path = publishExclusive(root, payload, claimId, label);
507
- if (!path) continue;
566
+ if (typeof path !== "string") {
567
+ retry(path);
568
+ continue;
569
+ }
508
570
  const claim = handle(path);
509
571
  const settled = settleOrRelease(claim);
510
572
  reaped.push(...settled.reaped);
@@ -525,6 +587,7 @@ function tryAcquireClaim({ root, mode, details = {}, label = "ownership" }) {
525
587
  held: holder,
526
588
  reaped
527
589
  };
590
+ if (denied) throw new ClaimUnavailableError(`access was denied on every one of ${PUBLISH_ATTEMPTS} attempts to publish it`, { claimPath: root });
528
591
  refuse(root, root, label, `another process took and gave up the claim on every one of ${PUBLISH_ATTEMPTS} attempts to take it`);
529
592
  }
530
593
  function settleClaim(pending) {
@@ -1,2 +1,2 @@
1
- import { _ as releaseClaim, a as claimFailure, b as sharedClaimDir, c as clearClaimChild, d as inspectClaimSet, f as isClaimRefusal, g as readClaimSet, h as processGroupAlive, i as ClaimUnavailableError, l as clearFreeClaimSet, m as markClaimChildPending, n as CLAIM_UNAVAILABLE, o as claimLiveness, p as isClaimUnavailable, r as ClaimRefusedError, s as claimRemoveCommand, t as CLAIM_REFUSED, u as exclusiveClaimDir, v as setClaimChild, x as tryAcquireClaim, y as settleClaim } from "./ownership-claim-DGxNcAcy.mjs";
1
+ import { _ as releaseClaim, a as claimFailure, b as sharedClaimDir, c as clearClaimChild, d as inspectClaimSet, f as isClaimRefusal, g as readClaimSet, h as processGroupAlive, i as ClaimUnavailableError, l as clearFreeClaimSet, m as markClaimChildPending, n as CLAIM_UNAVAILABLE, o as claimLiveness, p as isClaimUnavailable, r as ClaimRefusedError, s as claimRemoveCommand, t as CLAIM_REFUSED, u as exclusiveClaimDir, v as setClaimChild, x as tryAcquireClaim, y as settleClaim } from "./ownership-claim-Cn3NHqBS.mjs";
2
2
  export { CLAIM_REFUSED, CLAIM_UNAVAILABLE, ClaimRefusedError, ClaimUnavailableError, claimFailure, claimLiveness, claimRemoveCommand, clearClaimChild, clearFreeClaimSet, exclusiveClaimDir, inspectClaimSet, isClaimRefusal, isClaimUnavailable, markClaimChildPending, processGroupAlive, readClaimSet, releaseClaim, setClaimChild, settleClaim, sharedClaimDir, tryAcquireClaim };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/core",
3
- "version": "1.6.0",
3
+ "version": "1.8.0-rc.1",
4
4
  "description": "Shared internal cache and runtime primitives for Stim packages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "unique-pid": "0.1.1"
38
38
  },
39
39
  "engines": {
40
- "node": "^20.19.4 || >=22.12.0"
40
+ "node": ">=22.12.0"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "tsdown"