@stim-cli/core 1.3.1 → 1.4.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/README.md +25 -1
- package/dist/index.d.mts +21 -3
- package/dist/index.mjs +156 -41
- package/dist/ownership-claim-DGxNcAcy.mjs +589 -0
- package/dist/ownership-claim.d.mts +152 -0
- package/dist/ownership-claim.mjs +2 -0
- package/dist/process-identity.d.mts +20 -0
- package/dist/process-identity.mjs +40 -0
- package/package.json +14 -1
package/README.md
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
Shared internal contracts for Stim and its cache packages.
|
|
4
4
|
|
|
5
5
|
The package contains runtime-path resolution, cache-key generation, cache
|
|
6
|
-
registration, and
|
|
6
|
+
registration, filesystem artifact storage, and identity-aware ownership claims. It is not a user-facing API.
|
|
7
|
+
|
|
8
|
+
`withDirLock` runs short synchronous, reentrant transactions with a bounded wait
|
|
9
|
+
for a live holder. It shares the claim protocol in `@stim-cli/core/ownership-claim`
|
|
10
|
+
and the identity checks in `@stim-cli/core/process-identity`; the CLI re-exports
|
|
11
|
+
those modules. Core depends on `unique-pid`, so standalone Metro and Expo cache
|
|
12
|
+
consumers receive the same process identity implementation without installing Stim.
|
|
13
|
+
|
|
14
|
+
The shared claim lives beside the visible lock directory at `<lock>.claims`.
|
|
15
|
+
While holding its exclusive claim, the short-lock adapter takes the visible
|
|
16
|
+
directory and adds a `.stim-claim-<claimId>` compatibility marker. Older callers
|
|
17
|
+
still see an occupied directory. Publication staging stays in the claim store,
|
|
18
|
+
so a losing publisher cannot prevent the visible directory's release. Only the
|
|
19
|
+
holder of the exclusive claim can recover a recognized compatibility marker;
|
|
20
|
+
release removes its own marker before removing the empty visible directory.
|
|
21
|
+
This preserves ordinary legacy mkdir contention; an older caller that deletes
|
|
22
|
+
occupied locks based on their age can still violate mutual exclusion.
|
|
23
|
+
|
|
24
|
+
A complete claim left by a proven-dead owner can be recovered immediately.
|
|
25
|
+
Age never expires a live claim. Unknown owner or child identities refuse;
|
|
26
|
+
legacy directories and visible directories left empty before marker publication
|
|
27
|
+
or after marker removal remain blocked because there is no record proving that
|
|
28
|
+
they are free. Verify the holder before manually
|
|
29
|
+
removing the named claim or legacy directory. Long operations that spawn native
|
|
30
|
+
children use an explicit claim and record the child's identity.
|
|
7
31
|
|
|
8
32
|
The npm scope remains `@stim-cli` until the `@stim` scope is available.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
//#region
|
|
1
|
+
//#region dir-lock.d.ts
|
|
2
2
|
interface DirLockOptions {
|
|
3
|
+
/** @deprecated Occupied locks are never expired. */
|
|
3
4
|
staleMs?: number;
|
|
4
5
|
waitMs?: number;
|
|
5
6
|
pollMs?: number;
|
|
6
7
|
ensureParent?: () => void;
|
|
7
8
|
}
|
|
8
|
-
declare function withDirLock<T>(lockPath: string, fn: () => T, {
|
|
9
|
+
declare function withDirLock<T>(lockPath: string, fn: () => T, { waitMs, pollMs, ensureParent }?: DirLockOptions): T;
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region quoted-path.d.ts
|
|
12
|
+
/** Quote a path for a shell command Stim prints for a human to copy and run. */
|
|
13
|
+
declare function quotedPath(path: string): string;
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region artifact-store.d.ts
|
|
16
|
+
declare function artifactIn(dir: string): string | null;
|
|
17
|
+
declare function resolveArtifact(dir: string): string | null;
|
|
18
|
+
interface StoreArtifactOptions {
|
|
19
|
+
runFile: (file: string, args: string[]) => unknown;
|
|
20
|
+
overwrite?: boolean;
|
|
21
|
+
writeMetadata?: (stagingDir: string) => void;
|
|
22
|
+
onRenameError?: (stagingDir: string) => void;
|
|
23
|
+
}
|
|
24
|
+
declare function storeArtifact(dest: string, buildPath: string, { runFile, overwrite, writeMetadata, onRenameError }: StoreArtifactOptions): string | null;
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region index.d.ts
|
|
9
27
|
declare function configDir(): string;
|
|
10
28
|
declare function workspaceSlug(projectRoot: string): string;
|
|
11
29
|
declare function workspaceId(projectRoot: string): string;
|
|
@@ -57,4 +75,4 @@ declare function readCacheManifest(file: string): CacheManifest;
|
|
|
57
75
|
declare function updateCacheManifest(file: string, mutate: (caches: Array<Record<string, unknown>>) => Array<Record<string, unknown>>): CacheManifest;
|
|
58
76
|
declare function registerCache({ dir, name, prune, note, entriesDepth, layout, replaces }: RegisterOptions): void;
|
|
59
77
|
//#endregion
|
|
60
|
-
export { BuildRunOptions, CacheManifest, CacheRegistrationMatch, DirLockOptions, METRO_NAMED_CACHE_LAYOUT, RegisterOptions, STORE_ROOT_TAG, buildCacheKey, buildCacheRoot, cacheManifestLockPath, cacheNameSegment, cachePathSetting, configDir, metroCacheRoot, readCacheManifest, registerCache, sharedStoreRoot, tagSharedStore, updateCacheManifest, withDirLock, workspaceId, workspaceLogDir, workspaceName, workspaceSlug, workspaceStateDir };
|
|
78
|
+
export { BuildRunOptions, CacheManifest, CacheRegistrationMatch, type DirLockOptions, METRO_NAMED_CACHE_LAYOUT, RegisterOptions, STORE_ROOT_TAG, type StoreArtifactOptions, artifactIn, buildCacheKey, buildCacheRoot, cacheManifestLockPath, cacheNameSegment, cachePathSetting, configDir, metroCacheRoot, quotedPath, readCacheManifest, registerCache, resolveArtifact, sharedStoreRoot, storeArtifact, tagSharedStore, updateCacheManifest, withDirLock, workspaceId, workspaceLogDir, workspaceName, workspaceSlug, workspaceStateDir };
|
package/dist/index.mjs
CHANGED
|
@@ -1,58 +1,112 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { S as quotedPath, _ as releaseClaim, r as ClaimRefusedError, x as tryAcquireClaim } from "./ownership-claim-DGxNcAcy.mjs";
|
|
2
|
+
import fs, { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
+
import path, { basename, dirname, join } from "node:path";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
|
-
//#region
|
|
6
|
-
const DEFAULT_LOCK_STALE_MS = 1e4;
|
|
6
|
+
//#region dir-lock.ts
|
|
7
7
|
const DEFAULT_LOCK_WAIT_MS = 12e3;
|
|
8
8
|
const DEFAULT_LOCK_POLL_MS = 25;
|
|
9
9
|
const lockDepths = /* @__PURE__ */ new Map();
|
|
10
|
+
const OWNER_MARKER = /^\.stim-claim-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/;
|
|
10
11
|
function sleepSync(ms) {
|
|
11
12
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
12
13
|
}
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
function createLockDirectory(lockPath) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(lockPath);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === "EEXIST") return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function removeLockDirectory(lockPath, marker) {
|
|
24
|
+
try {
|
|
25
|
+
unlinkSync(marker);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error?.code === "ENOENT") return false;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
rmdirSync(lockPath);
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ([
|
|
35
|
+
"ENOENT",
|
|
36
|
+
"ENOTEMPTY",
|
|
37
|
+
"EEXIST"
|
|
38
|
+
].includes(error?.code ?? "")) return false;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function takeLockDirectory(lockPath, claim) {
|
|
43
|
+
if (!createLockDirectory(lockPath)) {
|
|
44
|
+
let entries;
|
|
17
45
|
try {
|
|
18
|
-
|
|
19
|
-
return;
|
|
46
|
+
entries = readdirSync(lockPath);
|
|
20
47
|
} catch (error) {
|
|
21
|
-
if (error?.code
|
|
48
|
+
if (error?.code === "ENOENT") return null;
|
|
49
|
+
throw error;
|
|
22
50
|
}
|
|
23
|
-
|
|
51
|
+
const name = entries.length === 1 ? entries[0] : void 0;
|
|
52
|
+
if (!name || !OWNER_MARKER.test(name)) return null;
|
|
53
|
+
const marker = join(lockPath, name);
|
|
54
|
+
let entry;
|
|
24
55
|
try {
|
|
25
|
-
|
|
26
|
-
} catch {
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
if (ageMs > staleMs) {
|
|
30
|
-
try {
|
|
31
|
-
fs.rmSync(lockPath, {
|
|
32
|
-
recursive: true,
|
|
33
|
-
force: true
|
|
34
|
-
});
|
|
35
|
-
} catch {}
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
if (Date.now() >= deadline) {
|
|
39
|
-
const error = /* @__PURE__ */ new Error(`Timed out waiting for the lock at ${lockPath}. Another Stim process is holding it; if none is running, remove that directory.`);
|
|
40
|
-
error.code = "STIM_LOCK_TIMEOUT";
|
|
41
|
-
error.lockPath = lockPath;
|
|
56
|
+
entry = lstatSync(marker);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code === "ENOENT") return null;
|
|
42
59
|
throw error;
|
|
43
60
|
}
|
|
44
|
-
|
|
61
|
+
if (!entry.isFile() || entry.size !== 0) throw new ClaimRefusedError({
|
|
62
|
+
root: lockPath,
|
|
63
|
+
claimPath: lockPath,
|
|
64
|
+
label: "directory lock",
|
|
65
|
+
reason: `its compatibility marker ${name} is not an empty file`
|
|
66
|
+
});
|
|
67
|
+
if (!removeLockDirectory(lockPath, marker) || !createLockDirectory(lockPath)) return null;
|
|
45
68
|
}
|
|
69
|
+
const marker = join(lockPath, `.stim-claim-${claim.claimId}`);
|
|
70
|
+
writeFileSync(marker, "", { flag: "wx" });
|
|
71
|
+
return marker;
|
|
46
72
|
}
|
|
47
|
-
function
|
|
73
|
+
function acquireDirLock(lockPath, { waitMs, pollMs, ensureParent }) {
|
|
74
|
+
ensureParent?.();
|
|
75
|
+
statSync(dirname(lockPath));
|
|
76
|
+
const deadline = Date.now() + waitMs;
|
|
77
|
+
let claim;
|
|
48
78
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
for (;;) {
|
|
80
|
+
if (!claim) {
|
|
81
|
+
const attempt = tryAcquireClaim({
|
|
82
|
+
root: `${lockPath}.claims`,
|
|
83
|
+
mode: "exclusive",
|
|
84
|
+
label: "directory lock"
|
|
85
|
+
});
|
|
86
|
+
claim = attempt.acquired;
|
|
87
|
+
if (attempt.pending) releaseClaim(attempt.pending);
|
|
88
|
+
}
|
|
89
|
+
if (claim) {
|
|
90
|
+
const marker = takeLockDirectory(lockPath, claim);
|
|
91
|
+
if (marker) return {
|
|
92
|
+
claim,
|
|
93
|
+
marker
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() >= deadline) {
|
|
97
|
+
const error = /* @__PURE__ */ new Error(`Timed out waiting for the lock at ${lockPath}. Another Stim process may be holding it; if none is running, remove that directory.`);
|
|
98
|
+
error.code = "STIM_LOCK_TIMEOUT";
|
|
99
|
+
error.lockPath = lockPath;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
sleepSync(pollMs);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
releaseClaim(claim);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
54
108
|
}
|
|
55
|
-
function withDirLock(lockPath, fn, {
|
|
109
|
+
function withDirLock(lockPath, fn, { waitMs = DEFAULT_LOCK_WAIT_MS, pollMs = DEFAULT_LOCK_POLL_MS, ensureParent } = {}) {
|
|
56
110
|
const depth = lockDepths.get(lockPath) || 0;
|
|
57
111
|
if (depth > 0) {
|
|
58
112
|
lockDepths.set(lockPath, depth + 1);
|
|
@@ -62,8 +116,7 @@ function withDirLock(lockPath, fn, { staleMs = DEFAULT_LOCK_STALE_MS, waitMs = D
|
|
|
62
116
|
lockDepths.set(lockPath, (lockDepths.get(lockPath) ?? 1) - 1);
|
|
63
117
|
}
|
|
64
118
|
}
|
|
65
|
-
acquireDirLock(lockPath, {
|
|
66
|
-
staleMs,
|
|
119
|
+
const { claim, marker } = acquireDirLock(lockPath, {
|
|
67
120
|
waitMs,
|
|
68
121
|
pollMs,
|
|
69
122
|
ensureParent
|
|
@@ -73,9 +126,71 @@ function withDirLock(lockPath, fn, { staleMs = DEFAULT_LOCK_STALE_MS, waitMs = D
|
|
|
73
126
|
return fn();
|
|
74
127
|
} finally {
|
|
75
128
|
lockDepths.set(lockPath, 0);
|
|
76
|
-
|
|
129
|
+
try {
|
|
130
|
+
removeLockDirectory(lockPath, marker);
|
|
131
|
+
} catch {}
|
|
132
|
+
releaseClaim(claim);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region artifact-store.ts
|
|
137
|
+
function artifactIn(dir) {
|
|
138
|
+
if (!existsSync(dir)) return null;
|
|
139
|
+
let found;
|
|
140
|
+
try {
|
|
141
|
+
found = readdirSync(dir).find((f) => f.endsWith(".app") || f.endsWith(".apk"));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return found ? join(dir, found) : null;
|
|
146
|
+
}
|
|
147
|
+
function resolveArtifact(dir) {
|
|
148
|
+
const hit = artifactIn(dir);
|
|
149
|
+
if (!hit) return null;
|
|
150
|
+
try {
|
|
151
|
+
utimesSync(dir, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date());
|
|
152
|
+
} catch {}
|
|
153
|
+
return hit;
|
|
154
|
+
}
|
|
155
|
+
function storeArtifact(dest, buildPath, { runFile, overwrite = false, writeMetadata, onRenameError }) {
|
|
156
|
+
const existing = artifactIn(dest);
|
|
157
|
+
if (existing && !overwrite) return existing;
|
|
158
|
+
const staging = `${dest}.staging-${process.pid}`;
|
|
159
|
+
rmSync(staging, {
|
|
160
|
+
recursive: true,
|
|
161
|
+
force: true
|
|
162
|
+
});
|
|
163
|
+
mkdirSync(staging, { recursive: true });
|
|
164
|
+
try {
|
|
165
|
+
runFile("cp", [
|
|
166
|
+
"-c",
|
|
167
|
+
"-R",
|
|
168
|
+
buildPath,
|
|
169
|
+
join(staging, basename(buildPath))
|
|
170
|
+
]);
|
|
171
|
+
} catch {
|
|
172
|
+
runFile("cp", [
|
|
173
|
+
"-R",
|
|
174
|
+
buildPath,
|
|
175
|
+
join(staging, basename(buildPath))
|
|
176
|
+
]);
|
|
77
177
|
}
|
|
178
|
+
writeMetadata?.(staging);
|
|
179
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
180
|
+
rmSync(dest, {
|
|
181
|
+
recursive: true,
|
|
182
|
+
force: true
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
renameSync(staging, dest);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!onRenameError) throw error;
|
|
188
|
+
onRenameError(staging);
|
|
189
|
+
}
|
|
190
|
+
return artifactIn(dest);
|
|
78
191
|
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region index.ts
|
|
79
194
|
function configDir() {
|
|
80
195
|
return process.env.STIM_HOME || path.join(os.homedir(), ".stim");
|
|
81
196
|
}
|
|
@@ -220,4 +335,4 @@ function matchesCache(cache, match) {
|
|
|
220
335
|
return match.layout === void 0 || cache.layout === match.layout;
|
|
221
336
|
}
|
|
222
337
|
//#endregion
|
|
223
|
-
export { METRO_NAMED_CACHE_LAYOUT, STORE_ROOT_TAG, buildCacheKey, buildCacheRoot, cacheManifestLockPath, cacheNameSegment, cachePathSetting, configDir, metroCacheRoot, readCacheManifest, registerCache, sharedStoreRoot, tagSharedStore, updateCacheManifest, withDirLock, workspaceId, workspaceLogDir, workspaceName, workspaceSlug, workspaceStateDir };
|
|
338
|
+
export { METRO_NAMED_CACHE_LAYOUT, STORE_ROOT_TAG, artifactIn, buildCacheKey, buildCacheRoot, cacheManifestLockPath, cacheNameSegment, cachePathSetting, configDir, metroCacheRoot, quotedPath, readCacheManifest, registerCache, resolveArtifact, sharedStoreRoot, storeArtifact, tagSharedStore, updateCacheManifest, withDirLock, workspaceId, workspaceLogDir, workspaceName, workspaceSlug, workspaceStateDir };
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { captureProcessIdentity, inspectProcessIdentity } from "./process-identity.mjs";
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
//#region quoted-path.ts
|
|
6
|
+
/** Quote a path for a shell command Stim prints for a human to copy and run. */
|
|
7
|
+
function quotedPath(path) {
|
|
8
|
+
return `'${path.replaceAll("'", "'\\''")}'`;
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region ownership-claim.ts
|
|
12
|
+
const CLAIM_REFUSED = "STIM_CLAIM_REFUSED";
|
|
13
|
+
const CLAIM_UNAVAILABLE = "STIM_CLAIM_UNAVAILABLE";
|
|
14
|
+
const EXCLUSIVE_DIR = "exclusive";
|
|
15
|
+
const SHARED_DIR = "shared";
|
|
16
|
+
const CLAIM_SUFFIX = ".claim";
|
|
17
|
+
const CHILD_SUFFIX = ".child";
|
|
18
|
+
const STAGING_PREFIX = ".staging-";
|
|
19
|
+
const PUBLISH_ATTEMPTS = 64;
|
|
20
|
+
var ClaimRefusedError = class extends Error {
|
|
21
|
+
code = CLAIM_REFUSED;
|
|
22
|
+
claimPath;
|
|
23
|
+
reason;
|
|
24
|
+
removeCommand;
|
|
25
|
+
constructor({ claimPath, root, reason, label, blockingFile = false }) {
|
|
26
|
+
const removeCommand = blockingFile ? `mv -i ${quotedPath(claimPath)} ${quotedPath(`${claimPath}.stim-backup`)}` : claimRemoveCommand(claimPath.startsWith(root) ? claimPath : root);
|
|
27
|
+
super(blockingFile ? `The ${label} claim store is blocked by a non-directory path at ${claimPath}. Inspect it before moving it aside; its contents may be unrelated to Stim. Preserve it with the command below (an existing backup prompts before overwriting), then run the command again:\n ${removeCommand}` : `Stim cannot tell whether the ${label} claim at ${claimPath} is still held: ${reason}. It will not remove a claim it cannot prove is dead, and it will not wait on one either. If nothing is using it, remove the claim and run the command again:\n ${removeCommand}`);
|
|
28
|
+
this.claimPath = claimPath;
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
this.removeCommand = removeCommand;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var ClaimUnavailableError = class extends Error {
|
|
34
|
+
code = CLAIM_UNAVAILABLE;
|
|
35
|
+
reason;
|
|
36
|
+
remedy;
|
|
37
|
+
constructor(reason, { claimPath } = {}) {
|
|
38
|
+
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
|
+
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";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The command that clears one surveyed claim: targeted at the claim's own file when the path names one,
|
|
45
|
+
* and quoted, because this text is printed for a human to copy into a shell and a path with a space in
|
|
46
|
+
* it would otherwise name several targets.
|
|
47
|
+
*/
|
|
48
|
+
function claimRemoveCommand(path) {
|
|
49
|
+
return `rm ${path.endsWith(CLAIM_SUFFIX) || path.endsWith(CHILD_SUFFIX) ? "-f" : "-rf"} ${quotedPath(path)}`;
|
|
50
|
+
}
|
|
51
|
+
function isClaimRefusal(err) {
|
|
52
|
+
return err?.code === CLAIM_REFUSED;
|
|
53
|
+
}
|
|
54
|
+
function isClaimUnavailable(err) {
|
|
55
|
+
return err?.code === CLAIM_UNAVAILABLE;
|
|
56
|
+
}
|
|
57
|
+
/** Convert an unresolved claim, unavailable identity or inaccessible warm claim store into command recovery guidance. */
|
|
58
|
+
function claimFailure(err, retryCommand) {
|
|
59
|
+
const retry = retryCommand === null ? "retry the same Stim command" : `run \`${retryCommand}\` again`;
|
|
60
|
+
if (isClaimRefusal(err)) return {
|
|
61
|
+
code: CLAIM_REFUSED,
|
|
62
|
+
message: err.message,
|
|
63
|
+
remedy: `Run \`${err.removeCommand}\`, then ${retry}.`
|
|
64
|
+
};
|
|
65
|
+
if (isClaimUnavailable(err)) return {
|
|
66
|
+
code: CLAIM_UNAVAILABLE,
|
|
67
|
+
message: err.message,
|
|
68
|
+
remedy: `${err.remedy}, then ${retry}.`
|
|
69
|
+
};
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function exclusiveClaimDir(root) {
|
|
73
|
+
return join(root, EXCLUSIVE_DIR);
|
|
74
|
+
}
|
|
75
|
+
function sharedClaimDir(root) {
|
|
76
|
+
return join(root, SHARED_DIR);
|
|
77
|
+
}
|
|
78
|
+
function childPath(claimPath) {
|
|
79
|
+
return `${claimPath.slice(0, -6)}${CHILD_SUFFIX}`;
|
|
80
|
+
}
|
|
81
|
+
function asOwner(record) {
|
|
82
|
+
const candidate = record;
|
|
83
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
84
|
+
if (typeof candidate.pid !== "number" || !Number.isSafeInteger(candidate.pid) || candidate.pid <= 0) return null;
|
|
85
|
+
if (typeof candidate.processToken !== "string" || !candidate.processToken) return null;
|
|
86
|
+
return {
|
|
87
|
+
pid: candidate.pid,
|
|
88
|
+
processToken: candidate.processToken
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function readChild(claimPath) {
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = readFileSync(childPath(claimPath), "utf-8");
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err?.code === "ENOENT") return null;
|
|
97
|
+
return "unreadable";
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return { record: asOwner(JSON.parse(text)?.record) };
|
|
101
|
+
} catch {
|
|
102
|
+
return "unreadable";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function readClaim(path, mode) {
|
|
106
|
+
let text;
|
|
107
|
+
try {
|
|
108
|
+
text = readFileSync(path, "utf-8");
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err?.code === "ENOENT") return null;
|
|
111
|
+
return "unreadable";
|
|
112
|
+
}
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(text);
|
|
116
|
+
} catch {
|
|
117
|
+
return "unreadable";
|
|
118
|
+
}
|
|
119
|
+
const owner = asOwner(parsed?.owner);
|
|
120
|
+
if (!owner || typeof parsed.claimId !== "string" || !parsed.claimId) return "unreadable";
|
|
121
|
+
const child = readChild(path);
|
|
122
|
+
if (child === "unreadable") return "unreadable";
|
|
123
|
+
return {
|
|
124
|
+
path,
|
|
125
|
+
claimId: parsed.claimId,
|
|
126
|
+
mode,
|
|
127
|
+
owner,
|
|
128
|
+
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : "",
|
|
129
|
+
details: parsed.details && typeof parsed.details === "object" ? parsed.details : {},
|
|
130
|
+
child: child?.record ?? null,
|
|
131
|
+
childDeclared: child !== null
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether any member of the process group `pid` leads is alive. A recorded child is resolved through
|
|
136
|
+
* its group rather than through the one process, so a package manager's postinstall descendant keeps
|
|
137
|
+
* the claim after the manager exits. A pgid, unlike a ProcessRecord, carries no identity: a recycled
|
|
138
|
+
* one reads as alive, which holds a claim longer than it needs to be held and never frees a live one.
|
|
139
|
+
*/
|
|
140
|
+
function processGroupAlive(pid) {
|
|
141
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return false;
|
|
142
|
+
try {
|
|
143
|
+
process.kill(-pid, 0);
|
|
144
|
+
return true;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return err?.code === "EPERM";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function childLiveness(claimPath) {
|
|
150
|
+
const child = readChild(claimPath);
|
|
151
|
+
if (child === null) return "none";
|
|
152
|
+
if (child === "unreadable" || !child.record) return "unknown";
|
|
153
|
+
const status = inspectProcessIdentity(child.record);
|
|
154
|
+
if (status === "same") return "live";
|
|
155
|
+
if (status === "gone") return processGroupAlive(child.record.pid) ? "live" : "dead";
|
|
156
|
+
return "unknown";
|
|
157
|
+
}
|
|
158
|
+
function claimLiveness(holder) {
|
|
159
|
+
const status = inspectProcessIdentity(holder.owner);
|
|
160
|
+
if (status === "same") return "live";
|
|
161
|
+
if (status === "unknown") return "unknown";
|
|
162
|
+
const child = childLiveness(holder.path);
|
|
163
|
+
return child === "none" ? "dead" : child;
|
|
164
|
+
}
|
|
165
|
+
function refuse(root, claimPath, label, reason) {
|
|
166
|
+
throw new ClaimRefusedError({
|
|
167
|
+
claimPath,
|
|
168
|
+
root,
|
|
169
|
+
label,
|
|
170
|
+
reason
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function refuseNonDirectory(root, path, label) {
|
|
174
|
+
let blocking = null;
|
|
175
|
+
for (let level = path;; level = dirname(level)) {
|
|
176
|
+
try {
|
|
177
|
+
if (!statSync(level).isDirectory()) {
|
|
178
|
+
blocking = level;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const code = error?.code;
|
|
183
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") break;
|
|
184
|
+
}
|
|
185
|
+
if (dirname(level) === level) break;
|
|
186
|
+
}
|
|
187
|
+
if (blocking === null) throw new ClaimUnavailableError("the non-directory path could not be identified; the claim store may have changed", { claimPath: root });
|
|
188
|
+
throw new ClaimRefusedError({
|
|
189
|
+
root,
|
|
190
|
+
claimPath: blocking,
|
|
191
|
+
label,
|
|
192
|
+
reason: "the claim path is a file, not a claim directory",
|
|
193
|
+
blockingFile: true
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function names(dir) {
|
|
197
|
+
try {
|
|
198
|
+
return readdirSync(dir).toSorted();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (err?.code === "ENOENT") return [];
|
|
201
|
+
return "unreadable";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function survey(dir, mode, into) {
|
|
205
|
+
const listed = names(dir);
|
|
206
|
+
if (listed === "unreadable") {
|
|
207
|
+
into.unresolved.push({
|
|
208
|
+
path: dir,
|
|
209
|
+
reason: "its directory could not be read"
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const claims = new Set(listed.filter((name) => name.endsWith(CLAIM_SUFFIX)));
|
|
214
|
+
const orphans = listed.filter((name) => name.endsWith(CHILD_SUFFIX) && !claims.has(`${name.slice(0, -6)}${CLAIM_SUFFIX}`));
|
|
215
|
+
for (const name of orphans) into.orphans.push(join(dir, name));
|
|
216
|
+
for (const name of listed) {
|
|
217
|
+
if (!name.endsWith(CLAIM_SUFFIX)) continue;
|
|
218
|
+
const path = join(dir, name);
|
|
219
|
+
const holder = readClaim(path, mode);
|
|
220
|
+
if (holder === null) continue;
|
|
221
|
+
if (holder === "unreadable") {
|
|
222
|
+
into.unresolved.push({
|
|
223
|
+
path,
|
|
224
|
+
reason: "its record is missing, truncated or not valid JSON"
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const liveness = claimLiveness(holder);
|
|
229
|
+
if (liveness === "live") into.live.push(holder);
|
|
230
|
+
else if (liveness === "dead") into.dead.push(holder);
|
|
231
|
+
else into.unresolved.push({
|
|
232
|
+
path,
|
|
233
|
+
reason: holder.childDeclared && !holder.child ? "it spawned the process that holds it and was killed before recording which one" : "its process identity token does not decode, so the holder cannot be identified"
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (listed.filter((name) => !orphans.includes(name)).length > 0 && claims.size === 0) into.unresolved.push({
|
|
237
|
+
path: dir,
|
|
238
|
+
reason: "it holds files Stim did not write"
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** Classify every claim in a claim set without removing anything and without refusing. */
|
|
242
|
+
function readClaimSet(root) {
|
|
243
|
+
const state = {
|
|
244
|
+
live: [],
|
|
245
|
+
dead: [],
|
|
246
|
+
unresolved: [],
|
|
247
|
+
orphans: []
|
|
248
|
+
};
|
|
249
|
+
survey(exclusiveClaimDir(root), "exclusive", state);
|
|
250
|
+
survey(sharedClaimDir(root), "shared", state);
|
|
251
|
+
return state;
|
|
252
|
+
}
|
|
253
|
+
function removeOrRefuse(path, root, claimPath, label) {
|
|
254
|
+
try {
|
|
255
|
+
unlinkSync(path);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const code = err?.code;
|
|
258
|
+
if (code === "ENOENT") return;
|
|
259
|
+
refuse(root, claimPath, label, `the claim left behind could not be removed (${code || err?.message})`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function reap(holder, root, label) {
|
|
263
|
+
const again = readClaim(holder.path, holder.mode);
|
|
264
|
+
if (again === null) return true;
|
|
265
|
+
if (again === "unreadable" || again.claimId !== holder.claimId) return false;
|
|
266
|
+
removeOrRefuse(childPath(holder.path), root, holder.path, label);
|
|
267
|
+
removeOrRefuse(holder.path, root, holder.path, label);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
function tidy(dir) {
|
|
271
|
+
try {
|
|
272
|
+
rmdirSync(dir);
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
function inspectClaimSet(root, { label = "ownership" } = {}) {
|
|
276
|
+
const state = readClaimSet(root);
|
|
277
|
+
const unresolved = state.unresolved[0];
|
|
278
|
+
if (unresolved) refuse(root, unresolved.path, label, unresolved.reason);
|
|
279
|
+
const reaped = [];
|
|
280
|
+
for (const orphan of state.orphans) removeOrRefuse(orphan, root, orphan, label);
|
|
281
|
+
for (const holder of state.dead) if (reap(holder, root, label)) reaped.push(holder);
|
|
282
|
+
if (!state.live.some((holder) => holder.mode === "exclusive")) tidy(exclusiveClaimDir(root));
|
|
283
|
+
if (!state.live.some((holder) => holder.mode === "shared")) tidy(sharedClaimDir(root));
|
|
284
|
+
return {
|
|
285
|
+
exclusive: state.live.find((holder) => holder.mode === "exclusive") ?? null,
|
|
286
|
+
shared: state.live.filter((holder) => holder.mode === "shared"),
|
|
287
|
+
reaped
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function tidySet(root) {
|
|
291
|
+
tidy(exclusiveClaimDir(root));
|
|
292
|
+
tidy(sharedClaimDir(root));
|
|
293
|
+
tidy(root);
|
|
294
|
+
}
|
|
295
|
+
function removeAbandonedStaging(root) {
|
|
296
|
+
const listed = names(root);
|
|
297
|
+
if (listed === "unreadable") return;
|
|
298
|
+
for (const name of listed) if (name.startsWith(STAGING_PREFIX)) rmSync(join(root, name), {
|
|
299
|
+
recursive: true,
|
|
300
|
+
force: true
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Remove a claim set that holds nothing. Takes the set's own exclusive claim first and removes the set
|
|
305
|
+
* only through the token-verified unlink and empty-directory removals that releasing a claim performs,
|
|
306
|
+
* so a claim published between the survey that reported the set stale and this call is kept, not
|
|
307
|
+
* deleted; a set whose state cannot be established is refused rather than removed.
|
|
308
|
+
*/
|
|
309
|
+
function clearFreeClaimSet({ root, label = "ownership" }) {
|
|
310
|
+
let attempt;
|
|
311
|
+
try {
|
|
312
|
+
attempt = tryAcquireClaim({
|
|
313
|
+
root,
|
|
314
|
+
mode: "exclusive",
|
|
315
|
+
label
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (isClaimRefusal(err) || isClaimUnavailable(err)) return {
|
|
319
|
+
status: "refused",
|
|
320
|
+
reason: err.reason
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
status: "failed",
|
|
324
|
+
reason: err?.message ?? String(err)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (!attempt.acquired) {
|
|
328
|
+
if (attempt.pending) releaseClaim(attempt.pending);
|
|
329
|
+
return {
|
|
330
|
+
status: "held",
|
|
331
|
+
holder: attempt.held ?? attempt.waitingFor?.[0] ?? null
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
removeAbandonedStaging(root);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return {
|
|
338
|
+
status: "failed",
|
|
339
|
+
reason: err?.message ?? String(err)
|
|
340
|
+
};
|
|
341
|
+
} finally {
|
|
342
|
+
releaseClaim(attempt.acquired);
|
|
343
|
+
}
|
|
344
|
+
tidySet(root);
|
|
345
|
+
const remaining = names(root);
|
|
346
|
+
if (remaining === "unreadable") return {
|
|
347
|
+
status: "failed",
|
|
348
|
+
reason: "its directory could not be read"
|
|
349
|
+
};
|
|
350
|
+
const after = readClaimSet(root);
|
|
351
|
+
if (after.live.length > 0) return {
|
|
352
|
+
status: "held",
|
|
353
|
+
holder: after.live[0]
|
|
354
|
+
};
|
|
355
|
+
if (after.unresolved.length > 0) return {
|
|
356
|
+
status: "refused",
|
|
357
|
+
reason: after.unresolved[0].reason
|
|
358
|
+
};
|
|
359
|
+
if (remaining.length > 0) return {
|
|
360
|
+
status: "failed",
|
|
361
|
+
reason: `it still holds ${remaining.join(", ")}`
|
|
362
|
+
};
|
|
363
|
+
return { status: "cleared" };
|
|
364
|
+
}
|
|
365
|
+
function selfOwner() {
|
|
366
|
+
const captured = captureProcessIdentity(process.pid);
|
|
367
|
+
if (!captured.ok) throw new ClaimUnavailableError(captured.reason);
|
|
368
|
+
return {
|
|
369
|
+
pid: process.pid,
|
|
370
|
+
processToken: captured.token
|
|
371
|
+
};
|
|
372
|
+
}
|
|
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";
|
|
377
|
+
}
|
|
378
|
+
var MissingClaimStoreError = class extends Error {
|
|
379
|
+
code = "ENOENT";
|
|
380
|
+
};
|
|
381
|
+
function createClaimDir(path) {
|
|
382
|
+
try {
|
|
383
|
+
mkdirSync(path, { recursive: true });
|
|
384
|
+
return;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error?.code !== "ENOENT") throw error;
|
|
387
|
+
}
|
|
388
|
+
const missing = [];
|
|
389
|
+
for (let level = path;; level = dirname(level)) {
|
|
390
|
+
let entry;
|
|
391
|
+
try {
|
|
392
|
+
entry = lstatSync(level);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (error?.code !== "ENOENT") throw error;
|
|
395
|
+
missing.push(level);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (entry.isSymbolicLink()) try {
|
|
399
|
+
statSync(level);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error?.code === "ENOENT") throw new MissingClaimStoreError(error.message, { cause: error });
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
for (const level of missing.toReversed()) try {
|
|
407
|
+
mkdirSync(level);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error?.code !== "EEXIST") throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function publishExclusive(root, payload, claimId, label) {
|
|
413
|
+
const staging = join(root, `${STAGING_PREFIX}${claimId}`);
|
|
414
|
+
const target = exclusiveClaimDir(root);
|
|
415
|
+
try {
|
|
416
|
+
createClaimDir(staging);
|
|
417
|
+
writeFileSync(join(staging, `${claimId}${CLAIM_SUFFIX}`), payload);
|
|
418
|
+
renameSync(staging, target);
|
|
419
|
+
return join(target, `${claimId}${CLAIM_SUFFIX}`);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
rmSync(staging, {
|
|
422
|
+
recursive: true,
|
|
423
|
+
force: true
|
|
424
|
+
});
|
|
425
|
+
const code = err?.code;
|
|
426
|
+
if (contended(err)) return null;
|
|
427
|
+
if (code === "ENOTDIR") refuseNonDirectory(root, target, label);
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function publishShared(root, payload, claimId) {
|
|
432
|
+
const staging = join(root, `${STAGING_PREFIX}${claimId}`);
|
|
433
|
+
const target = join(sharedClaimDir(root), `${claimId}${CLAIM_SUFFIX}`);
|
|
434
|
+
try {
|
|
435
|
+
createClaimDir(sharedClaimDir(root));
|
|
436
|
+
writeFileSync(staging, payload);
|
|
437
|
+
renameSync(staging, target);
|
|
438
|
+
return target;
|
|
439
|
+
} catch (err) {
|
|
440
|
+
rmSync(staging, { force: true });
|
|
441
|
+
if (contended(err)) return null;
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function settleOrRelease(claim) {
|
|
446
|
+
try {
|
|
447
|
+
return inspectClaimSet(claim.root, { label: claim.label });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
releaseClaim(claim);
|
|
450
|
+
throw err;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function tryAcquireClaim({ root, mode, details = {}, label = "ownership" }) {
|
|
454
|
+
const owner = selfOwner();
|
|
455
|
+
const reaped = [];
|
|
456
|
+
for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt++) {
|
|
457
|
+
try {
|
|
458
|
+
createClaimDir(root);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
const code = err?.code;
|
|
461
|
+
if (code === "EEXIST" || code === "ENOTDIR") refuseNonDirectory(root, root, label);
|
|
462
|
+
if (contended(err)) continue;
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
const state = inspectClaimSet(root, { label });
|
|
466
|
+
reaped.push(...state.reaped);
|
|
467
|
+
if (state.exclusive) return {
|
|
468
|
+
held: state.exclusive,
|
|
469
|
+
reaped
|
|
470
|
+
};
|
|
471
|
+
const claimId = randomUUID();
|
|
472
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
473
|
+
const payload = JSON.stringify({
|
|
474
|
+
claimId,
|
|
475
|
+
mode,
|
|
476
|
+
owner,
|
|
477
|
+
startedAt,
|
|
478
|
+
details
|
|
479
|
+
});
|
|
480
|
+
const handle = (path) => ({
|
|
481
|
+
root,
|
|
482
|
+
path,
|
|
483
|
+
claimId,
|
|
484
|
+
mode,
|
|
485
|
+
owner,
|
|
486
|
+
startedAt,
|
|
487
|
+
details,
|
|
488
|
+
label
|
|
489
|
+
});
|
|
490
|
+
if (mode === "shared") {
|
|
491
|
+
const published = publishShared(root, payload, claimId);
|
|
492
|
+
if (!published) continue;
|
|
493
|
+
const claim = handle(published);
|
|
494
|
+
const settled = settleOrRelease(claim);
|
|
495
|
+
reaped.push(...settled.reaped);
|
|
496
|
+
if (!settled.exclusive) return {
|
|
497
|
+
acquired: claim,
|
|
498
|
+
reaped
|
|
499
|
+
};
|
|
500
|
+
releaseClaim(claim);
|
|
501
|
+
return {
|
|
502
|
+
held: settled.exclusive,
|
|
503
|
+
reaped
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const path = publishExclusive(root, payload, claimId, label);
|
|
507
|
+
if (!path) continue;
|
|
508
|
+
const claim = handle(path);
|
|
509
|
+
const settled = settleOrRelease(claim);
|
|
510
|
+
reaped.push(...settled.reaped);
|
|
511
|
+
if (settled.shared.length === 0) return {
|
|
512
|
+
acquired: claim,
|
|
513
|
+
reaped
|
|
514
|
+
};
|
|
515
|
+
return {
|
|
516
|
+
waitingFor: settled.shared,
|
|
517
|
+
pending: claim,
|
|
518
|
+
reaped
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const contender = inspectClaimSet(root, { label });
|
|
522
|
+
reaped.push(...contender.reaped);
|
|
523
|
+
const holder = contender.exclusive ?? (mode === "exclusive" ? contender.shared[0] : void 0);
|
|
524
|
+
if (holder) return {
|
|
525
|
+
held: holder,
|
|
526
|
+
reaped
|
|
527
|
+
};
|
|
528
|
+
refuse(root, root, label, `another process took and gave up the claim on every one of ${PUBLISH_ATTEMPTS} attempts to take it`);
|
|
529
|
+
}
|
|
530
|
+
function settleClaim(pending) {
|
|
531
|
+
const state = inspectClaimSet(pending.root, { label: pending.label });
|
|
532
|
+
if (state.shared.length === 0) return {
|
|
533
|
+
acquired: pending,
|
|
534
|
+
reaped: state.reaped
|
|
535
|
+
};
|
|
536
|
+
return {
|
|
537
|
+
waitingFor: state.shared,
|
|
538
|
+
pending,
|
|
539
|
+
reaped: state.reaped
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function releaseClaim(handle) {
|
|
543
|
+
if (!handle) return false;
|
|
544
|
+
const current = readClaim(handle.path, handle.mode);
|
|
545
|
+
if (current === null) return true;
|
|
546
|
+
if (current === "unreadable" || current.claimId !== handle.claimId) return false;
|
|
547
|
+
try {
|
|
548
|
+
rmSync(childPath(handle.path), { force: true });
|
|
549
|
+
rmSync(handle.path, { force: true });
|
|
550
|
+
} catch {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
tidy(handle.mode === "exclusive" ? exclusiveClaimDir(handle.root) : sharedClaimDir(handle.root));
|
|
554
|
+
tidy(handle.root);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Declare that this claim is about to spawn the process that does the real work, before the spawn.
|
|
559
|
+
* A claim marked this way and left without a child record reads as unresolvable rather than as free,
|
|
560
|
+
* so a parent killed between the spawn and the record cannot release a claim its child still needs.
|
|
561
|
+
*/
|
|
562
|
+
function markClaimChildPending(handle) {
|
|
563
|
+
writeChild(handle, { record: null });
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Record the spawned process that holds this claim. Spawn it with `detached: true` so it leads its own
|
|
567
|
+
* process group: the claim then reads as held while any member of that group is alive, which is what
|
|
568
|
+
* keeps a package manager's postinstall child covered after the manager itself exits.
|
|
569
|
+
*/
|
|
570
|
+
function setClaimChild(handle, record) {
|
|
571
|
+
const child = asOwner(record);
|
|
572
|
+
if (!child) throw new ClaimUnavailableError("the child process identity was not a pid and token pair");
|
|
573
|
+
writeChild(handle, { record: child });
|
|
574
|
+
}
|
|
575
|
+
function clearClaimChild(handle) {
|
|
576
|
+
rmSync(childPath(handle.path), { force: true });
|
|
577
|
+
}
|
|
578
|
+
function writeChild(handle, body) {
|
|
579
|
+
const staging = join(handle.root, `${STAGING_PREFIX}child-${handle.claimId}`);
|
|
580
|
+
try {
|
|
581
|
+
writeFileSync(staging, JSON.stringify(body));
|
|
582
|
+
renameSync(staging, childPath(handle.path));
|
|
583
|
+
} catch (err) {
|
|
584
|
+
rmSync(staging, { force: true });
|
|
585
|
+
throw err;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
export { quotedPath as S, releaseClaim as _, claimFailure as a, sharedClaimDir as b, clearClaimChild as c, inspectClaimSet as d, isClaimRefusal as f, readClaimSet as g, processGroupAlive as h, ClaimUnavailableError as i, clearFreeClaimSet as l, markClaimChildPending as m, CLAIM_UNAVAILABLE as n, claimLiveness as o, isClaimUnavailable as p, ClaimRefusedError as r, claimRemoveCommand as s, CLAIM_REFUSED as t, exclusiveClaimDir as u, setClaimChild as v, tryAcquireClaim as x, settleClaim as y };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ProcessRecord } from "./process-identity.mjs";
|
|
2
|
+
//#region ownership-claim.d.ts
|
|
3
|
+
type ClaimMode = "exclusive" | "shared";
|
|
4
|
+
interface ClaimDetails {
|
|
5
|
+
readonly [key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
interface ClaimOwner {
|
|
8
|
+
pid: number;
|
|
9
|
+
processToken: string;
|
|
10
|
+
}
|
|
11
|
+
interface ClaimHolder {
|
|
12
|
+
path: string;
|
|
13
|
+
claimId: string;
|
|
14
|
+
mode: ClaimMode;
|
|
15
|
+
owner: ClaimOwner;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
details: ClaimDetails;
|
|
18
|
+
child: ClaimOwner | null;
|
|
19
|
+
childDeclared: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface ClaimHandle {
|
|
22
|
+
root: string;
|
|
23
|
+
path: string;
|
|
24
|
+
claimId: string;
|
|
25
|
+
mode: ClaimMode;
|
|
26
|
+
owner: ClaimOwner;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
details: ClaimDetails;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
interface ClaimAttempt {
|
|
32
|
+
acquired?: ClaimHandle;
|
|
33
|
+
held?: ClaimHolder;
|
|
34
|
+
waitingFor?: ClaimHolder[];
|
|
35
|
+
pending?: ClaimHandle;
|
|
36
|
+
reaped: ClaimHolder[];
|
|
37
|
+
}
|
|
38
|
+
interface ClaimSetState {
|
|
39
|
+
exclusive: ClaimHolder | null;
|
|
40
|
+
shared: ClaimHolder[];
|
|
41
|
+
reaped: ClaimHolder[];
|
|
42
|
+
}
|
|
43
|
+
interface ClaimProblem {
|
|
44
|
+
path: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
}
|
|
47
|
+
interface ClaimSurvey {
|
|
48
|
+
live: ClaimHolder[];
|
|
49
|
+
dead: ClaimHolder[];
|
|
50
|
+
unresolved: ClaimProblem[];
|
|
51
|
+
orphans: string[];
|
|
52
|
+
}
|
|
53
|
+
interface ClaimOptions {
|
|
54
|
+
root: string;
|
|
55
|
+
mode: ClaimMode;
|
|
56
|
+
details?: ClaimDetails;
|
|
57
|
+
label?: string;
|
|
58
|
+
}
|
|
59
|
+
declare const CLAIM_REFUSED = "STIM_CLAIM_REFUSED";
|
|
60
|
+
declare const CLAIM_UNAVAILABLE = "STIM_CLAIM_UNAVAILABLE";
|
|
61
|
+
declare class ClaimRefusedError extends Error {
|
|
62
|
+
readonly code: string;
|
|
63
|
+
readonly claimPath: string;
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
readonly removeCommand: string;
|
|
66
|
+
constructor({ claimPath, root, reason, label, blockingFile }: {
|
|
67
|
+
claimPath: string;
|
|
68
|
+
root: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
label: string;
|
|
71
|
+
blockingFile?: boolean;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
declare class ClaimUnavailableError extends Error {
|
|
75
|
+
readonly code: string;
|
|
76
|
+
readonly reason: string;
|
|
77
|
+
readonly remedy: string;
|
|
78
|
+
constructor(reason: string, { claimPath }?: {
|
|
79
|
+
claimPath?: string;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The command that clears one surveyed claim: targeted at the claim's own file when the path names one,
|
|
84
|
+
* and quoted, because this text is printed for a human to copy into a shell and a path with a space in
|
|
85
|
+
* it would otherwise name several targets.
|
|
86
|
+
*/
|
|
87
|
+
declare function claimRemoveCommand(path: string): string;
|
|
88
|
+
declare function isClaimRefusal(err: unknown): err is ClaimRefusedError;
|
|
89
|
+
declare function isClaimUnavailable(err: unknown): err is ClaimUnavailableError;
|
|
90
|
+
interface ClaimFailure {
|
|
91
|
+
code: string;
|
|
92
|
+
message: string;
|
|
93
|
+
remedy: string;
|
|
94
|
+
}
|
|
95
|
+
/** Convert an unresolved claim, unavailable identity or inaccessible warm claim store into command recovery guidance. */
|
|
96
|
+
declare function claimFailure(err: unknown, retryCommand: string | null): ClaimFailure | null;
|
|
97
|
+
declare function exclusiveClaimDir(root: string): string;
|
|
98
|
+
declare function sharedClaimDir(root: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* Whether any member of the process group `pid` leads is alive. A recorded child is resolved through
|
|
101
|
+
* its group rather than through the one process, so a package manager's postinstall descendant keeps
|
|
102
|
+
* the claim after the manager exits. A pgid, unlike a ProcessRecord, carries no identity: a recycled
|
|
103
|
+
* one reads as alive, which holds a claim longer than it needs to be held and never frees a live one.
|
|
104
|
+
*/
|
|
105
|
+
declare function processGroupAlive(pid: number): boolean;
|
|
106
|
+
type Liveness = "live" | "dead" | "unknown";
|
|
107
|
+
declare function claimLiveness(holder: ClaimHolder): Liveness;
|
|
108
|
+
/** Classify every claim in a claim set without removing anything and without refusing. */
|
|
109
|
+
declare function readClaimSet(root: string): ClaimSurvey;
|
|
110
|
+
declare function inspectClaimSet(root: string, { label }?: {
|
|
111
|
+
label?: string;
|
|
112
|
+
}): ClaimSetState;
|
|
113
|
+
type ClaimSetClearance = {
|
|
114
|
+
status: "cleared";
|
|
115
|
+
} | {
|
|
116
|
+
status: "held";
|
|
117
|
+
holder: ClaimHolder | null;
|
|
118
|
+
} | {
|
|
119
|
+
status: "refused";
|
|
120
|
+
reason: string;
|
|
121
|
+
} | {
|
|
122
|
+
status: "failed";
|
|
123
|
+
reason: string;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Remove a claim set that holds nothing. Takes the set's own exclusive claim first and removes the set
|
|
127
|
+
* only through the token-verified unlink and empty-directory removals that releasing a claim performs,
|
|
128
|
+
* so a claim published between the survey that reported the set stale and this call is kept, not
|
|
129
|
+
* deleted; a set whose state cannot be established is refused rather than removed.
|
|
130
|
+
*/
|
|
131
|
+
declare function clearFreeClaimSet({ root, label }: {
|
|
132
|
+
root: string;
|
|
133
|
+
label?: string;
|
|
134
|
+
}): ClaimSetClearance;
|
|
135
|
+
declare function tryAcquireClaim({ root, mode, details, label }: ClaimOptions): ClaimAttempt;
|
|
136
|
+
declare function settleClaim(pending: ClaimHandle): ClaimAttempt;
|
|
137
|
+
declare function releaseClaim(handle: ClaimHandle | null | undefined): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Declare that this claim is about to spawn the process that does the real work, before the spawn.
|
|
140
|
+
* A claim marked this way and left without a child record reads as unresolvable rather than as free,
|
|
141
|
+
* so a parent killed between the spawn and the record cannot release a claim its child still needs.
|
|
142
|
+
*/
|
|
143
|
+
declare function markClaimChildPending(handle: ClaimHandle): void;
|
|
144
|
+
/**
|
|
145
|
+
* Record the spawned process that holds this claim. Spawn it with `detached: true` so it leads its own
|
|
146
|
+
* process group: the claim then reads as held while any member of that group is alive, which is what
|
|
147
|
+
* keeps a package manager's postinstall child covered after the manager itself exits.
|
|
148
|
+
*/
|
|
149
|
+
declare function setClaimChild(handle: ClaimHandle, record: ProcessRecord): void;
|
|
150
|
+
declare function clearClaimChild(handle: ClaimHandle): void;
|
|
151
|
+
//#endregion
|
|
152
|
+
export { CLAIM_REFUSED, CLAIM_UNAVAILABLE, ClaimAttempt, ClaimDetails, ClaimFailure, ClaimHandle, ClaimHolder, ClaimMode, ClaimOptions, ClaimOwner, ClaimProblem, ClaimRefusedError, ClaimSetClearance, ClaimSetState, ClaimSurvey, ClaimUnavailableError, claimFailure, claimLiveness, claimRemoveCommand, clearClaimChild, clearFreeClaimSet, exclusiveClaimDir, inspectClaimSet, isClaimRefusal, isClaimUnavailable, markClaimChildPending, processGroupAlive, readClaimSet, releaseClaim, setClaimChild, settleClaim, sharedClaimDir, tryAcquireClaim };
|
|
@@ -0,0 +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";
|
|
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 };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region process-identity.d.ts
|
|
2
|
+
interface ProcessRecord {
|
|
3
|
+
pid?: unknown;
|
|
4
|
+
processToken?: unknown;
|
|
5
|
+
}
|
|
6
|
+
declare function sameProcessRecord(a: ProcessRecord | null | undefined, b: ProcessRecord | null | undefined): boolean;
|
|
7
|
+
type CapturedIdentity = {
|
|
8
|
+
ok: true;
|
|
9
|
+
token: string;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
declare function captureProcessIdentity(pid: number): CapturedIdentity;
|
|
15
|
+
declare function captureProcessToken(pid: number): string | null;
|
|
16
|
+
type ProcessIdentityStatus = "same" | "different" | "gone" | "unknown";
|
|
17
|
+
declare function inspectProcessIdentity(record: ProcessRecord | null | undefined): ProcessIdentityStatus;
|
|
18
|
+
declare function waitForProcessExit(record: ProcessRecord, timeoutMs: number): Promise<boolean>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { CapturedIdentity, ProcessIdentityStatus, ProcessRecord, captureProcessIdentity, captureProcessToken, inspectProcessIdentity, sameProcessRecord, waitForProcessExit };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { capture, check, decode } from "unique-pid";
|
|
2
|
+
//#region process-identity.ts
|
|
3
|
+
function sameProcessRecord(a, b) {
|
|
4
|
+
if (!a || !b) return !a && !b;
|
|
5
|
+
return a.pid === b.pid && a.processToken === b.processToken;
|
|
6
|
+
}
|
|
7
|
+
function captureProcessIdentity(pid) {
|
|
8
|
+
const result = capture(pid);
|
|
9
|
+
if (result.ok) return {
|
|
10
|
+
ok: true,
|
|
11
|
+
token: result.value
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
reason: `${result.error.code} (${result.error.message})`
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function captureProcessToken(pid) {
|
|
19
|
+
const captured = captureProcessIdentity(pid);
|
|
20
|
+
return captured.ok ? captured.token : null;
|
|
21
|
+
}
|
|
22
|
+
function inspectProcessIdentity(record) {
|
|
23
|
+
if (!record || typeof record.processToken !== "string") return "unknown";
|
|
24
|
+
const decoded = decode(record.processToken);
|
|
25
|
+
if (!decoded.ok || decoded.value.pid !== record.pid) return "unknown";
|
|
26
|
+
const result = check(record.processToken);
|
|
27
|
+
return result.ok ? result.value : "unknown";
|
|
28
|
+
}
|
|
29
|
+
async function waitForProcessExit(record, timeoutMs) {
|
|
30
|
+
const deadline = Date.now() + timeoutMs;
|
|
31
|
+
while (Date.now() <= deadline) {
|
|
32
|
+
const identity = inspectProcessIdentity(record);
|
|
33
|
+
if (identity === "different" || identity === "gone") return true;
|
|
34
|
+
if (Date.now() >= deadline) return false;
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { captureProcessIdentity, captureProcessToken, inspectProcessIdentity, sameProcessRecord, waitForProcessExit };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stim-cli/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Shared internal cache and runtime primitives for Stim packages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,8 +21,21 @@
|
|
|
21
21
|
"types": "./dist/index.d.mts",
|
|
22
22
|
"module-sync": "./dist/index.mjs",
|
|
23
23
|
"default": "./dist/index.mjs"
|
|
24
|
+
},
|
|
25
|
+
"./ownership-claim": {
|
|
26
|
+
"types": "./dist/ownership-claim.d.mts",
|
|
27
|
+
"module-sync": "./dist/ownership-claim.mjs",
|
|
28
|
+
"default": "./dist/ownership-claim.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./process-identity": {
|
|
31
|
+
"types": "./dist/process-identity.d.mts",
|
|
32
|
+
"module-sync": "./dist/process-identity.mjs",
|
|
33
|
+
"default": "./dist/process-identity.mjs"
|
|
24
34
|
}
|
|
25
35
|
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"unique-pid": "0.1.1"
|
|
38
|
+
},
|
|
26
39
|
"engines": {
|
|
27
40
|
"node": "^20.19.4 || >=22.12.0"
|
|
28
41
|
},
|