@pilotspace/add 1.15.0 → 1.16.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/CHANGELOG.md +64 -0
- package/README.md +71 -4
- package/agents/add-advisor.md +31 -0
- package/agents/add-build.md +29 -0
- package/agents/add-design.md +32 -0
- package/agents/add-persona.md +31 -0
- package/agents/add-verify.md +34 -0
- package/bin/cli.js +581 -46
- package/docs/08-step-6-verify.md +7 -0
- package/package.json +2 -1
- package/skill/add/SKILL.md +3 -2
- package/skill/add/advisor.md +2 -0
- package/skill/add/loop.md +2 -2
- package/skill/add/phases/0-setup.md +3 -3
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/6-verify.md +3 -1
- package/skill/add/report-template.md +1 -0
- package/skill/add/run.md +1 -0
- package/skill/add/streams.md +7 -0
- package/tooling/add.py +103 -4
- package/tooling/add_engine/constants.py +9 -0
- package/tooling/add_engine/io_state.py +14 -0
- package/tooling/templates/TASK.md.tmpl +3 -0
- package/tooling/templates/personas/_template.md.tmpl +22 -2
package/bin/cli.js
CHANGED
|
@@ -40,7 +40,7 @@ function parseArgs(argv) {
|
|
|
40
40
|
// hint only echoes flags the user actually chose (shortest true command).
|
|
41
41
|
const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
|
|
42
42
|
yes: false, nonInteractive: false, global: false, globalData: false,
|
|
43
|
-
fromGlobalData: false, ruleFile: false };
|
|
43
|
+
fromGlobalData: false, ruleFile: false, lockTimeout: null };
|
|
44
44
|
for (let i = 0; i < argv.length; i++) {
|
|
45
45
|
const a = argv[i];
|
|
46
46
|
if (a === "--force") args.force = true;
|
|
@@ -72,6 +72,14 @@ function parseArgs(argv) {
|
|
|
72
72
|
if (v == null || v.startsWith("--")) fail(a + " requires a value");
|
|
73
73
|
if (a === "--stage") args.stage = v; else args.name = v;
|
|
74
74
|
}
|
|
75
|
+
// --lock-timeout <seconds>: (--global only) opt into a bounded wait for a LIVE contended
|
|
76
|
+
// home lock before failing "update_in_progress" (default null = today's immediate fail-fast;
|
|
77
|
+
// a STALE lock always self-heals regardless). SAME "requires a value" idiom as --stage/--name.
|
|
78
|
+
else if (a === "--lock-timeout") {
|
|
79
|
+
const v = argv[++i];
|
|
80
|
+
if (v == null || v.startsWith("--")) fail(a + " requires a value");
|
|
81
|
+
args.lockTimeout = Number(v);
|
|
82
|
+
}
|
|
75
83
|
else if (a.startsWith("--")) warn("ignoring unknown flag " + a);
|
|
76
84
|
else args._.push(a);
|
|
77
85
|
}
|
|
@@ -719,6 +727,14 @@ async function cmdInit(args) {
|
|
|
719
727
|
"from; run `init --global-data` on a source checkout first");
|
|
720
728
|
}
|
|
721
729
|
}
|
|
730
|
+
// Project-scope lock (project-scope-install-lock): keyed on chosenTarget's FINAL value (after
|
|
731
|
+
// any interactive redirect above) — acquired BEFORE the as_global sub-block, held through the
|
|
732
|
+
// function's end. Independent of, and acquired BEFORE, the home-scoped acquireUpdateLock that
|
|
733
|
+
// installGlobal() below nests inside (M11 — never the reverse nesting).
|
|
734
|
+
const addDir = path.join(chosenTarget, ".add");
|
|
735
|
+
acquireProjectLock(addDir); // registers its own process.on("exit", release) — no explicit
|
|
736
|
+
// release()/finally needed at the call site (mirrors
|
|
737
|
+
// acquireUpdateLock's own usage at cmdUpdateGlobal)
|
|
722
738
|
// OPT-IN global home, BEFORE the per-project drop (fail-closed if the home is unwritable
|
|
723
739
|
// or its registry is corrupt — the package + the self-contained default stay usable).
|
|
724
740
|
if (args.global) installGlobal(args, chosenTarget);
|
|
@@ -735,6 +751,7 @@ async function cmdInit(args) {
|
|
|
735
751
|
// tasks, or archive (user data). Pure file-copy (npm <-> pip parity with _installer.py).
|
|
736
752
|
const MANAGED = [
|
|
737
753
|
["skill/add", [".claude", "skills", "add"], false],
|
|
754
|
+
["agents", [".claude", "agents"], false],
|
|
738
755
|
["tooling", [".add", "tooling"], true],
|
|
739
756
|
["docs", [".add", "docs"], false],
|
|
740
757
|
["personas-teacher", [".add", "personas-teacher"], false],
|
|
@@ -742,10 +759,42 @@ const MANAGED = [
|
|
|
742
759
|
// Optional managed trees: an ENHANCEMENT the persona phase reads, not core runtime. The real
|
|
743
760
|
// package always ships these (guarded by test_packaging); a malformed/older package missing one
|
|
744
761
|
// must NOT abort the install — the core lands and the optional tree is soft-skipped. Twin of
|
|
745
|
-
// _installer.py:OPTIONAL. Design-for-failure.
|
|
746
|
-
|
|
762
|
+
// _installer.py:OPTIONAL. Design-for-failure. `agents` joins here (roster-install-drift): the
|
|
763
|
+
// phase-agent roster is a spawn-acceleration enhancement, not core runtime.
|
|
764
|
+
const OPTIONAL = new Set(["personas-teacher", "agents"]);
|
|
747
765
|
const STAMP_FILE = ".add-version";
|
|
748
766
|
const LOCK_FILE = ".update.lock"; // the `update --global` home lock (never user-data)
|
|
767
|
+
const LOCK_STALE_DEFAULT = 600; // seconds (10 min); ADD_LOCK_STALE_SECONDS env-overridable
|
|
768
|
+
const LOCK_POLL_INTERVAL_MS = 50; // ms between polls while waiting out a --lock-timeout
|
|
769
|
+
const LOCK_TICKET_STALE_SECONDS = 5; // a leaked per-generation reclaim ticket (its own holder
|
|
770
|
+
// crashed between winning it and its own best-effort
|
|
771
|
+
// cleanup) self-heals after this long — deliberately far
|
|
772
|
+
// shorter than LOCK_STALE_DEFAULT's own 600s: a ticket's
|
|
773
|
+
// own critical section is a small, fixed handful of
|
|
774
|
+
// syscalls (close/stat/unlink), microseconds under normal
|
|
775
|
+
// operation, so a multi-second margin is generous, not
|
|
776
|
+
// tight (global-lock-followups' own leaked-ticket-livelock
|
|
777
|
+
// fix — independent of, not shared with, acquireProjectLock's
|
|
778
|
+
// own PROJECT_LOCK_TICKET_STALE_SECONDS below).
|
|
779
|
+
const PROJECT_LOCK_FILE = ".install.lock"; // the project-scope init()/update() lock (never user-data)
|
|
780
|
+
const PROJECT_LOCK_STALE_DEFAULT = 120; // seconds (2 min); ADD_PROJECT_LOCK_STALE_SECONDS env-overridable —
|
|
781
|
+
// deliberately SHORTER than LOCK_STALE_DEFAULT's own 600s (see
|
|
782
|
+
// _installer.py's _PROJECT_LOCK_STALE_DEFAULT for the reasoning)
|
|
783
|
+
const PROJECT_LOCK_TICKET_STALE_SECONDS = 5; // a leaked per-generation reclaim ticket self-heals
|
|
784
|
+
// after this long — independent of, but numerically
|
|
785
|
+
// identical to, acquireUpdateLock's own
|
|
786
|
+
// LOCK_TICKET_STALE_SECONDS: a ticket's own critical
|
|
787
|
+
// section is the same small, fixed handful of
|
|
788
|
+
// syscalls regardless of which lock it guards
|
|
789
|
+
// (project-scope-install-lock's own
|
|
790
|
+
// leaked-ticket-wedge fix)
|
|
791
|
+
|
|
792
|
+
// A synchronous sleep via Atomics.wait on a throwaway SharedArrayBuffer — builtin, no new
|
|
793
|
+
// dependency; Node's MAIN thread (unlike a browser's) is allowed to block on Atomics.wait.
|
|
794
|
+
function sleepSync(ms) {
|
|
795
|
+
const ia = new Int32Array(new SharedArrayBuffer(4));
|
|
796
|
+
Atomics.wait(ia, 0, 0, ms);
|
|
797
|
+
}
|
|
749
798
|
|
|
750
799
|
function pkgVersion() {
|
|
751
800
|
try { return require(path.join(PKG_ROOT, "package.json")).version; }
|
|
@@ -782,29 +831,93 @@ function treeFiles(root) {
|
|
|
782
831
|
return out;
|
|
783
832
|
}
|
|
784
833
|
|
|
834
|
+
// Crash-safe stage-then-swap: project-scope-atomic-reconcile (TASK.md v1). Replaces the
|
|
835
|
+
// old wipe-then-copy (rmSync(dest) then cpSync onto dest directly) with a stage-into-a-
|
|
836
|
+
// sibling + two-rename commit, so dest is NEVER observed half-old/half-new (a random
|
|
837
|
+
// partial mix) — the achievable guarantee is "never observed half-composed", not "never
|
|
838
|
+
// observed absent for an instant" (a single-syscall atomic replace of an EXISTING
|
|
839
|
+
// non-empty directory is not portable; the sub-instant window between the two commit
|
|
840
|
+
// renames is closed by the NEXT call's own self-heal, not this one).
|
|
841
|
+
//
|
|
785
842
|
// Returns a file-level roll-up of the heal: { restored, refreshed } where `restored` = a
|
|
786
|
-
// file in the FINAL tree whose relative path was ABSENT before the
|
|
843
|
+
// file in the FINAL tree whose relative path was ABSENT before the call (fresh or
|
|
787
844
|
// partially-gutted trees heal these), `refreshed` = a final file that was PRESENT before
|
|
788
845
|
// (re-materialized). Orphans (present before, gone after) are swept, counted as neither.
|
|
789
846
|
// Pure observation — copy semantics unchanged. Mirror of _installer.py:_clean_replace.
|
|
790
847
|
function cleanReplaceTree(src, dest, stripTests) {
|
|
791
848
|
if (!fs.existsSync(src)) fail("missing packaged source: " + src);
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
849
|
+
const destParent = path.dirname(dest);
|
|
850
|
+
const destName = path.basename(dest);
|
|
851
|
+
fs.mkdirSync(destParent, { recursive: true });
|
|
852
|
+
|
|
853
|
+
// -- self-heal -- (before this call's own work): recover/discard whatever a PRIOR,
|
|
854
|
+
// interrupted call left behind. A stale backup found while dest is absent is the last
|
|
855
|
+
// known-good tree — restore it first (a cheap rename), minimizing how long dest stays
|
|
856
|
+
// broken; any other stale sibling (a stage, or a backup found while dest is present) is
|
|
857
|
+
// discarded outright — its content is never merged or reused.
|
|
858
|
+
const siblings = fs.readdirSync(destParent);
|
|
859
|
+
const tmpStales = siblings.filter((n) => n.startsWith(destName + ".add-tmp-"));
|
|
860
|
+
const bakStales = siblings.filter((n) => n.startsWith(destName + ".add-bak-"))
|
|
861
|
+
.map((n) => path.join(destParent, n))
|
|
862
|
+
.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs);
|
|
863
|
+
let remainingBaks = bakStales;
|
|
864
|
+
if (!fs.existsSync(dest) && bakStales.length > 0) {
|
|
865
|
+
const winner = bakStales[bakStales.length - 1]; // most-recently-modified is authoritative
|
|
866
|
+
fs.renameSync(winner, dest);
|
|
867
|
+
remainingBaks = bakStales.slice(0, -1);
|
|
868
|
+
}
|
|
869
|
+
for (const stale of tmpStales.map((n) => path.join(destParent, n)).concat(remainingBaks)) {
|
|
870
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const before = treeFiles(dest); // snapshot BEFORE this call's own work
|
|
874
|
+
|
|
875
|
+
// -- stage -- : copy src into a fresh, uniquely-named sibling of dest, in dest's own
|
|
876
|
+
// parent (same filesystem, so the commit renames below are a genuine atomic move). dest
|
|
877
|
+
// itself is never opened for writing or deletion during this step.
|
|
878
|
+
const staged = fs.mkdtempSync(path.join(destParent, destName + ".add-tmp-"));
|
|
879
|
+
try {
|
|
880
|
+
fs.cpSync(src, staged, { recursive: true });
|
|
881
|
+
if (stripTests) {
|
|
882
|
+
fs.rmSync(path.join(staged, "__pycache__"), { recursive: true, force: true });
|
|
883
|
+
for (const entry of fs.readdirSync(staged)) {
|
|
884
|
+
if (/^test_.*\.py$/.test(entry)) fs.rmSync(path.join(staged, entry), { force: true });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} catch (e) {
|
|
888
|
+
fs.rmSync(staged, { recursive: true, force: true }); // dest untouched — it was never opened
|
|
889
|
+
throw e;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// -- commit -- : two same-parent renames, NEITHER targets an already-existing name.
|
|
893
|
+
const token = path.basename(staged).slice((destName + ".add-tmp-").length);
|
|
894
|
+
let bak = null;
|
|
895
|
+
if (fs.existsSync(dest)) {
|
|
896
|
+
bak = path.join(destParent, destName + ".add-bak-" + token);
|
|
897
|
+
try {
|
|
898
|
+
fs.renameSync(dest, bak); // (a) vacate dest, aside
|
|
899
|
+
} catch (e) {
|
|
900
|
+
fs.rmSync(staged, { recursive: true, force: true }); // dest untouched — the rename never happened
|
|
901
|
+
throw e;
|
|
800
902
|
}
|
|
801
903
|
}
|
|
904
|
+
try {
|
|
905
|
+
fs.renameSync(staged, dest); // (b) land the new generation
|
|
906
|
+
} catch (e) {
|
|
907
|
+
if (bak !== null) fs.renameSync(bak, dest); // roll back: restore the original
|
|
908
|
+
fs.rmSync(staged, { recursive: true, force: true });
|
|
909
|
+
throw e;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// -- sweep -- : remove the backup sibling — ONLY after (b) has landed the new dest.
|
|
913
|
+
if (bak !== null) fs.rmSync(bak, { recursive: true, force: true });
|
|
914
|
+
|
|
802
915
|
let restored = 0, refreshed = 0;
|
|
803
916
|
for (const f of treeFiles(dest)) (before.has(f) ? refreshed++ : restored++);
|
|
804
917
|
return { restored: restored, refreshed: refreshed };
|
|
805
918
|
}
|
|
806
919
|
|
|
807
|
-
const TREE_LABEL = { "skill/add": "skill", "tooling": "tooling", "docs": "docs", "personas-teacher": "personas" };
|
|
920
|
+
const TREE_LABEL = { "skill/add": "skill", "agents": "agents", "tooling": "tooling", "docs": "docs", "personas-teacher": "personas" };
|
|
808
921
|
|
|
809
922
|
// Per managed tree: "missing" (dest absent OR empty) or "present".
|
|
810
923
|
function managedStatus(target) {
|
|
@@ -918,7 +1031,7 @@ function reconcileGlobal(home, claudeDir, noSkill) {
|
|
|
918
1031
|
// --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
|
|
919
1032
|
// Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
|
|
920
1033
|
// one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
|
|
921
|
-
const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE]; // managed trees + meta +
|
|
1034
|
+
const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE, PROJECT_LOCK_FILE]; // managed trees + meta + both locks
|
|
922
1035
|
|
|
923
1036
|
// data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
|
|
924
1037
|
function dataKey(projectAbspath) {
|
|
@@ -928,28 +1041,102 @@ function dataKey(projectAbspath) {
|
|
|
928
1041
|
return base + "-" + digest;
|
|
929
1042
|
}
|
|
930
1043
|
|
|
931
|
-
// A top-level .add/ entry is user-data unless it is a managed tree
|
|
1044
|
+
// A top-level .add/ entry is user-data unless it is a managed tree, a transient artifact, a
|
|
1045
|
+
// scratch-staging sibling left by an interrupted persist/restore/clean-replace call (the shared
|
|
1046
|
+
// .add-tmp-/.add-bak- infix convention — crash-safe-persist-restore v1 §3 M11), or a
|
|
1047
|
+
// per-generation reclaim-ticket sibling a lock's own stale-reclaim mechanism may transiently (or,
|
|
1048
|
+
// if leaked by a crash, semi-persistently) leave next to it (the .reclaim-<inode> infix
|
|
1049
|
+
// convention — project-scope-install-lock / global-lock-followups' leaked-ticket self-heal fix).
|
|
932
1050
|
function isUserData(name) {
|
|
933
1051
|
if (DATA_EXCLUDE.includes(name)) return false;
|
|
934
1052
|
if (name.startsWith("scope-snapshot")) return false;
|
|
935
1053
|
if (name.includes("pre-archive-bak")) return false;
|
|
936
1054
|
if (name.endsWith(".bak.json")) return false;
|
|
1055
|
+
if (name.includes(".add-tmp-") || name.includes(".add-bak-")) return false;
|
|
1056
|
+
if (name.includes(".reclaim-")) return false;
|
|
937
1057
|
return true;
|
|
938
1058
|
}
|
|
939
1059
|
|
|
940
|
-
//
|
|
941
|
-
//
|
|
1060
|
+
// Best-effort removal of a scratch sibling (staging dir/file, or a whole-tree backup dir);
|
|
1061
|
+
// tolerates it already being gone. A sweep failure here is hygiene, not a correctness gap —
|
|
1062
|
+
// self-heal simply retries it on the next call touching this target.
|
|
1063
|
+
function sweepScratch(p) {
|
|
1064
|
+
try { fs.rmSync(p, { recursive: true, force: true }); } catch (_e) { /* best-effort */ }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Crash-safe clean-replace of a project's USER-DATA into <home>/data/<key>: self-heal any
|
|
1068
|
+
// scratch sibling left by an earlier interrupted call, then a WHOLE-TREE stage-then-commit
|
|
1069
|
+
// (mirrors cleanReplaceTree's own pattern) — dest is never opened for writing or deletion until
|
|
1070
|
+
// its replacement has FULLY landed. true=persisted, false=skipped (no .add or no user-data — an
|
|
1071
|
+
// honest skip, dest — including any EXISTING stale snapshot — left completely untouched).
|
|
1072
|
+
// Throws if the data dir can't be written.
|
|
942
1073
|
function persistData(home, projectAbspath) {
|
|
1074
|
+
const key = dataKey(projectAbspath);
|
|
1075
|
+
const dataRoot = path.join(home, "data");
|
|
1076
|
+
const dest = path.join(dataRoot, key);
|
|
1077
|
+
|
|
1078
|
+
// step 0 -- self-heal: a stale backup found while dest is currently ABSENT means a PRIOR call
|
|
1079
|
+
// crashed between its two commit renames -- restore it first (the newest wins a >1 tie-break,
|
|
1080
|
+
// a defensive case, not an expected path). Then sweep every remaining scratch sibling for this
|
|
1081
|
+
// key unconditionally (never merged/reused).
|
|
1082
|
+
if (fs.existsSync(dataRoot)) {
|
|
1083
|
+
const siblings = fs.readdirSync(dataRoot);
|
|
1084
|
+
const tmpStale = siblings.filter((n) => n.startsWith(key + ".add-tmp-"));
|
|
1085
|
+
let bakStale = siblings.filter((n) => n.startsWith(key + ".add-bak-"));
|
|
1086
|
+
if (!fs.existsSync(dest) && bakStale.length > 0) {
|
|
1087
|
+
let newest = bakStale[0];
|
|
1088
|
+
let newestMtime = fs.statSync(path.join(dataRoot, newest)).mtimeMs;
|
|
1089
|
+
for (const n of bakStale.slice(1)) {
|
|
1090
|
+
const t = fs.statSync(path.join(dataRoot, n)).mtimeMs;
|
|
1091
|
+
if (t > newestMtime) { newest = n; newestMtime = t; }
|
|
1092
|
+
}
|
|
1093
|
+
fs.renameSync(path.join(dataRoot, newest), dest);
|
|
1094
|
+
bakStale = bakStale.filter((n) => n !== newest);
|
|
1095
|
+
}
|
|
1096
|
+
for (const n of bakStale) sweepScratch(path.join(dataRoot, n));
|
|
1097
|
+
for (const n of tmpStale) sweepScratch(path.join(dataRoot, n));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
943
1100
|
const addDir = path.join(projectAbspath, ".add");
|
|
944
1101
|
if (!fs.existsSync(addDir)) return false;
|
|
945
1102
|
const entries = fs.readdirSync(addDir).filter(isUserData);
|
|
946
|
-
if (entries.length === 0) return false;
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1103
|
+
if (entries.length === 0) return false; // UNCHANGED: an existing stale snapshot is left as-is
|
|
1104
|
+
|
|
1105
|
+
// step 1 -- stage: copy every filtered entry into a fresh, uniquely-named sibling of dest, IN
|
|
1106
|
+
// home/data/. dest itself is never opened for writing during this step.
|
|
1107
|
+
fs.mkdirSync(dataRoot, { recursive: true });
|
|
1108
|
+
const staged = fs.mkdtempSync(path.join(dataRoot, key + ".add-tmp-"));
|
|
1109
|
+
try {
|
|
1110
|
+
for (const e of entries) {
|
|
1111
|
+
fs.cpSync(path.join(addDir, e), path.join(staged, e), { recursive: true });
|
|
1112
|
+
}
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
sweepScratch(staged);
|
|
1115
|
+
throw e;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// step 2 -- commit: two same-parent renames, neither ever targets an already-existing name.
|
|
1119
|
+
const backup = path.join(dataRoot, key + ".add-bak-" + crypto.randomBytes(6).toString("hex"));
|
|
1120
|
+
let asideLanded = false;
|
|
1121
|
+
if (fs.existsSync(dest)) {
|
|
1122
|
+
try {
|
|
1123
|
+
fs.renameSync(dest, backup);
|
|
1124
|
+
asideLanded = true;
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
sweepScratch(staged);
|
|
1127
|
+
throw e;
|
|
1128
|
+
}
|
|
952
1129
|
}
|
|
1130
|
+
try {
|
|
1131
|
+
fs.renameSync(staged, dest);
|
|
1132
|
+
} catch (e) {
|
|
1133
|
+
if (asideLanded) fs.renameSync(backup, dest); // roll back: dest ends where it started
|
|
1134
|
+
sweepScratch(staged);
|
|
1135
|
+
throw e;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// step 3 -- sweep: the old backup is removed only now that the new dest has landed.
|
|
1139
|
+
if (asideLanded) sweepScratch(backup);
|
|
953
1140
|
return true;
|
|
954
1141
|
}
|
|
955
1142
|
|
|
@@ -974,27 +1161,71 @@ function isSymlink(p) {
|
|
|
974
1161
|
}
|
|
975
1162
|
|
|
976
1163
|
// Restore USER-DATA from <home>/data/<key> into <project>/.add — the NON-DESTRUCTIVE inverse of
|
|
977
|
-
// persistData
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
1164
|
+
// persistData, crash-safe via a PER-ENTRY stage-then-commit (.add/ is a SHARED directory most of
|
|
1165
|
+
// which this function must leave alone, so — unlike persistData's self-owned whole-tree dest —
|
|
1166
|
+
// only ONE entry stages/commits at a time). FILL-GAPS by default (write only ABSENT entries);
|
|
1167
|
+
// force overwrites a present entry, writing a <name>.bak sidecar of the original FIRST (the SAME
|
|
1168
|
+
// permanent, already-contracted sidecar name as before this task — never the new transient
|
|
1169
|
+
// staging marker). Copies only isUserData entries; DEREFERENCES symlinks to content (cpSync
|
|
1170
|
+
// dereference). true if >=1 restored, false if nothing to restore. Throws on an unwritable dest
|
|
1171
|
+
// -> restore_failed. Mirror of _installer.py:_restore_data.
|
|
981
1172
|
function restoreData(home, projectAbspath, force) {
|
|
1173
|
+
const addDir = path.join(projectAbspath, ".add");
|
|
982
1174
|
const src = path.join(home, "data", dataKey(projectAbspath));
|
|
1175
|
+
|
|
1176
|
+
// step 0 -- self-heal: sweep any stale per-entry staging sibling left by an earlier
|
|
1177
|
+
// INTERRUPTED call, unconditionally (never merged/reused/completed) -- the untouched snapshot
|
|
1178
|
+
// at src lets THIS call's own ordinary fill-gaps-or-force logic re-derive the correct end
|
|
1179
|
+
// state; no backup-recovery step is needed here (unlike persistData's step 0).
|
|
1180
|
+
if (fs.existsSync(addDir)) {
|
|
1181
|
+
for (const n of fs.readdirSync(addDir)) {
|
|
1182
|
+
if (n.includes(".add-tmp-")) sweepScratch(path.join(addDir, n));
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
983
1186
|
if (!fs.existsSync(src)) return false;
|
|
984
1187
|
const entries = fs.readdirSync(src).filter(isUserData).sort();
|
|
985
1188
|
if (entries.length === 0) return false;
|
|
986
|
-
const addDir = path.join(projectAbspath, ".add");
|
|
987
1189
|
fs.mkdirSync(addDir, { recursive: true });
|
|
988
1190
|
let restored = false;
|
|
989
1191
|
for (const e of entries) {
|
|
990
1192
|
const dest = path.join(addDir, e);
|
|
991
|
-
|
|
992
|
-
|
|
1193
|
+
const existed = fs.existsSync(dest) || isSymlink(dest);
|
|
1194
|
+
if (existed && !force) continue; // fill-gaps: never clobber; no staging begins
|
|
1195
|
+
|
|
1196
|
+
// step 1b -- stage: copy this ONE entry into a fresh, uniquely-named sibling of dest, IN
|
|
1197
|
+
// .add/ -- dest's own name is not opened for writing during this step. A reserved unique
|
|
1198
|
+
// name is claimed via mkdtempSync, then either kept as a dir (cpSync merges into the empty
|
|
1199
|
+
// dir) or freed via rmdirSync for a plain-file copy.
|
|
1200
|
+
const staged = fs.mkdtempSync(path.join(addDir, e + ".add-tmp-"));
|
|
1201
|
+
try {
|
|
1202
|
+
const srcEntry = path.join(src, e);
|
|
1203
|
+
if (fs.statSync(srcEntry).isDirectory()) {
|
|
1204
|
+
fs.cpSync(srcEntry, staged, { recursive: true, dereference: true });
|
|
1205
|
+
} else {
|
|
1206
|
+
fs.rmdirSync(staged);
|
|
1207
|
+
fs.cpSync(srcEntry, staged, { dereference: true }); // deref a symlink source
|
|
1208
|
+
}
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
sweepScratch(staged);
|
|
1211
|
+
throw err;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// step 1c -- commit:
|
|
1215
|
+
if (existed) {
|
|
993
1216
|
const bak = dest + ".bak";
|
|
994
|
-
if (fs.existsSync(bak) || isSymlink(bak))
|
|
995
|
-
fs.renameSync(dest, bak);
|
|
1217
|
+
if (fs.existsSync(bak) || isSymlink(bak)) sweepScratch(bak); // a stale .bak: replace, don't merge
|
|
1218
|
+
fs.renameSync(dest, bak); // back up the original BEFORE replacing
|
|
1219
|
+
try {
|
|
1220
|
+
fs.renameSync(staged, dest);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
fs.renameSync(bak, dest); // roll back: this entry ends where it started
|
|
1223
|
+
sweepScratch(staged);
|
|
1224
|
+
throw err;
|
|
1225
|
+
}
|
|
1226
|
+
} else {
|
|
1227
|
+
fs.renameSync(staged, dest); // fill-gaps: single rename, no backup needed
|
|
996
1228
|
}
|
|
997
|
-
fs.cpSync(path.join(src, e), dest, { recursive: true, dereference: true }); // deref symlinks
|
|
998
1229
|
restored = true;
|
|
999
1230
|
}
|
|
1000
1231
|
return restored;
|
|
@@ -1060,9 +1291,14 @@ function cmdPruneData(args) {
|
|
|
1060
1291
|
|
|
1061
1292
|
// init --global: install the managed layer ONCE to the shared home + register this project,
|
|
1062
1293
|
// fail-closed BEFORE the per-project drop. Returns the resolved target for the normal drop.
|
|
1294
|
+
// Serialized under the SAME home lock update --global uses (global-lock-followups M3) — a lock
|
|
1295
|
+
// failure aborts BEFORE any home/registry write and BEFORE the per-project drop (dropFiles,
|
|
1296
|
+
// called by cmdInit right after this returns), matching the all-or-nothing precedent every
|
|
1297
|
+
// other as_global-path failure already has.
|
|
1063
1298
|
function installGlobal(args, chosenTarget) {
|
|
1064
1299
|
const home = resolveGlobalHome(process.env);
|
|
1065
1300
|
const claudeDir = claudeSkillsDir(process.env);
|
|
1301
|
+
acquireUpdateLock(home, { timeout: args.lockTimeout }, process.env);
|
|
1066
1302
|
try { reconcileGlobal(home, claudeDir, args.noSkill); } // home_unwritable
|
|
1067
1303
|
catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
|
|
1068
1304
|
writeStamp(home, pkgVersion(), "global");
|
|
@@ -1090,25 +1326,318 @@ function validRegistryPath(p) {
|
|
|
1090
1326
|
catch (_e) { return false; }
|
|
1091
1327
|
}
|
|
1092
1328
|
|
|
1093
|
-
// Serialize `update --global`
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1329
|
+
// Serialize `update --global` (and, since global-lock-followups, `init --global`) with an
|
|
1330
|
+
// EXCLUSIVE lockfile (O_CREAT|O_EXCL via the "wx" flag) — the SAME mechanism as the pip twin
|
|
1331
|
+
// (_installer.py:_update_lock's os.open(O_EXCL)), so a pip-held lock blocks an npm run and
|
|
1332
|
+
// vice-versa.
|
|
1333
|
+
//
|
|
1334
|
+
// Held AND fresh (age <= ADD_LOCK_STALE_SECONDS, env-overridable, default 600s) -> today's
|
|
1335
|
+
// byte-identical behavior: `timeout` null/0 fails "update_in_progress" immediately; timeout=N>0
|
|
1336
|
+
// polls up to N seconds before failing.
|
|
1337
|
+
//
|
|
1338
|
+
// Held AND stale (age > threshold) -> self-heals: unlinks the stale lockfile and retries the
|
|
1339
|
+
// create. The "wx" create remains the SOLE mutual-exclusion primitive — staleness only ever
|
|
1340
|
+
// decides whether to retry, never bypasses exclusivity. A clock-skewed FUTURE mtime is a
|
|
1341
|
+
// negative age, so it is NEVER treated as stale.
|
|
1342
|
+
//
|
|
1343
|
+
// A successful acquire (fresh or reclaimed) stamps the file "<PID> <ISO ts>\n" — informational
|
|
1344
|
+
// ONLY, never read to decide staleness; a stamp-write failure is swallowed.
|
|
1345
|
+
//
|
|
1346
|
+
// KNOWN PROBLEM this shape works around: fail() calls process.exit(1) DIRECTLY (skips any
|
|
1347
|
+
// pending finally/loop state) — so this retry/self-heal loop uses `continue`/`break` for its
|
|
1348
|
+
// OWN internal control flow and calls fail() **at most once**, only AFTER the loop has
|
|
1349
|
+
// genuinely exhausted every retry/self-heal/wait attempt — never from inside the loop body.
|
|
1350
|
+
//
|
|
1351
|
+
// Released on process exit (normal completion OR fail()'s process.exit), so it never outlives
|
|
1352
|
+
// the run; a hard crash may leave a stale lock — the NEXT acquire self-heals it automatically.
|
|
1353
|
+
function acquireUpdateLock(home, { timeout = null } = {}, env = process.env) {
|
|
1098
1354
|
fs.mkdirSync(home, { recursive: true });
|
|
1099
1355
|
const lockPath = path.join(home, LOCK_FILE);
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1356
|
+
const staleAfterMs = Number(env.ADD_LOCK_STALE_SECONDS || LOCK_STALE_DEFAULT) * 1000;
|
|
1357
|
+
const deadline = timeout ? (Date.now() + timeout * 1000) : null; // null/0 = byte-identical fail-fast
|
|
1358
|
+
|
|
1359
|
+
let fd = null;
|
|
1360
|
+
let timedOut = false;
|
|
1361
|
+
while (fd === null) {
|
|
1362
|
+
try {
|
|
1363
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1364
|
+
break;
|
|
1365
|
+
} catch (e) {
|
|
1366
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
1367
|
+
}
|
|
1368
|
+
let st;
|
|
1369
|
+
try { st = fs.statSync(lockPath); }
|
|
1370
|
+
catch (_e) { continue; } // vanished between the failed open and the stat — retry the create
|
|
1371
|
+
let reclaimed = false;
|
|
1372
|
+
if (Date.now() - st.mtimeMs > staleAfterMs) {
|
|
1373
|
+
// Identity-verified reclaim (fixes a TOCTOU race: an unconditional unlink-by-path let a
|
|
1374
|
+
// second racer delete a FIRST racer's already-recreated, live lock). A NAIVE "rename to a
|
|
1375
|
+
// quarantine name" does NOT fix this — a rename is JUST as identity-blind as an unlink: it
|
|
1376
|
+
// operates on whatever currently sits at `lockPath`, so a delayed racer's rename can just
|
|
1377
|
+
// as easily steal a WINNER's brand-new fresh file (empirically reproduced while building
|
|
1378
|
+
// this fix; that attempt made the race MEASURABLY WORSE, not better, by adding an extra
|
|
1379
|
+
// syscall to the vulnerable window). The actual fix: gate entry to the reclaim itself
|
|
1380
|
+
// behind a SEPARATE, per-GENERATION exclusive ticket, keyed to the CURRENT stale file's
|
|
1381
|
+
// own inode number (`st.ino` — stable for this file's lifetime, and a FRESH replacement
|
|
1382
|
+
// file always gets a NEW inode). Two racers that observe the SAME stale file compute the
|
|
1383
|
+
// IDENTICAL ticket name and race an exclusive create on IT — only one wins; every loser
|
|
1384
|
+
// backs off WITHOUT ever touching `lockPath` itself, so nobody can ever unlink/steal a
|
|
1385
|
+
// generation they didn't win the ticket for.
|
|
1386
|
+
const ticketPath = lockPath + ".reclaim-" + st.ino;
|
|
1387
|
+
let ticketFd = null;
|
|
1388
|
+
try {
|
|
1389
|
+
ticketFd = fs.openSync(ticketPath, "wx");
|
|
1390
|
+
} catch (_e) { /* LOST the ticket outright — checked below before giving up on it */ }
|
|
1391
|
+
if (ticketFd === null) {
|
|
1392
|
+
// LOST the per-generation reclaim ticket outright. Before treating this as "someone
|
|
1393
|
+
// else legitimately owns reclaiming THIS stale file right now," check whether THAT
|
|
1394
|
+
// ticket is itself orphaned — leaked by a process that crashed between winning it and
|
|
1395
|
+
// its own best-effort cleanup below. Left unchecked, a leaked ticket wedges this
|
|
1396
|
+
// generation's reclaim FOREVER: the ticket's name is deterministically keyed to
|
|
1397
|
+
// `lockPath`'s own (unchanging) inode, so every future contender recomputes the
|
|
1398
|
+
// IDENTICAL ticket path, loses the identical EEXIST race, and — pre-fix — `continue`d
|
|
1399
|
+
// straight back to the top of this loop without ever reaching the `deadline` check
|
|
1400
|
+
// below: an unbounded livelock no `--lock-timeout` could ever interrupt (found by a
|
|
1401
|
+
// fresh adversarial verify pass; independently reproduced here against the unmodified
|
|
1402
|
+
// code via both a direct call and a real `node cli.js` subprocess, both still spinning
|
|
1403
|
+
// well past their own declared timeout budget).
|
|
1404
|
+
let tst = null;
|
|
1405
|
+
try { tst = fs.statSync(ticketPath); } catch (_e) { /* vanished — retry the create below */ }
|
|
1406
|
+
if (tst === null) {
|
|
1407
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {}
|
|
1408
|
+
} else if (Date.now() - tst.mtimeMs > LOCK_TICKET_STALE_SECONDS * 1000) {
|
|
1409
|
+
// The ticket ITSELF is orphaned — reclaim it with the IDENTICAL identity-verified
|
|
1410
|
+
// discipline already used for the main lock just above, one level down: re-stat
|
|
1411
|
+
// immediately before unlinking and compare inode, so a ticket some THIRD, still-
|
|
1412
|
+
// legitimately-in-flight reclaimer freshly (re)created in the gap between our stat
|
|
1413
|
+
// and our unlink is never destroyed. A plain unconditional unlink-by-path here would
|
|
1414
|
+
// reopen the IDENTICAL TOCTOU hole this whole ticket mechanism exists to close, one
|
|
1415
|
+
// level down (e.g. a naive "just unlink if old" would let racer A destroy racer C's
|
|
1416
|
+
// brand-new, not-yet-stale ticket for the SAME generation, so A and C would BOTH
|
|
1417
|
+
// believe they are the sole reclaimer — the exact double-hold bug this ticket
|
|
1418
|
+
// mechanism was built to prevent, reintroduced one level down).
|
|
1419
|
+
let currentTino = null;
|
|
1420
|
+
try { currentTino = fs.statSync(ticketPath).ino; } catch (_e) { /* already gone */ }
|
|
1421
|
+
if (currentTino === tst.ino) {
|
|
1422
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // already gone — harmless
|
|
1423
|
+
}
|
|
1424
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {} // someone else won it
|
|
1425
|
+
}
|
|
1426
|
+
// else: the ticket is live and fresh — a genuine, currently-in-flight reclaimer;
|
|
1427
|
+
// ticketFd stays null and falls through to the shared deadline check below (previously
|
|
1428
|
+
// an unconditional `continue` back to the top of this same branch — now routed the same
|
|
1429
|
+
// as any other non-progress iteration, never a special case that could spin unboundedly
|
|
1430
|
+
// on its own).
|
|
1431
|
+
}
|
|
1432
|
+
if (ticketFd !== null) {
|
|
1433
|
+
try {
|
|
1434
|
+
fs.closeSync(ticketFd);
|
|
1435
|
+
// Winning the ticket proves we are the SOLE reclaimer for the generation we observed
|
|
1436
|
+
// (`st.ino`) — it does NOT prove `lockPath` is STILL that generation right now. An
|
|
1437
|
+
// arbitrarily long scheduling gap can separate "we judged st.ino stale" from "we act on
|
|
1438
|
+
// it," and in that gap this SAME path can already have cycled through a full, unrelated
|
|
1439
|
+
// reclaim by someone else (that old inode gone, a fresh one created and now actively
|
|
1440
|
+
// held — empirically observed while building this fix: a late-scheduled racer's ticket
|
|
1441
|
+
// for a long-superseded inode still "won" trivially, since nothing else was contesting
|
|
1442
|
+
// that specific stale ticket name any more, and its UNCONDITIONAL unlink then blindly
|
|
1443
|
+
// destroyed the CURRENT live holder's fresh file). Re-stat immediately before mutating
|
|
1444
|
+
// and compare inodes: only remove the file if it is STILL, right now, the exact
|
|
1445
|
+
// generation we ticketed for; otherwise our ticket is moot — leave the (unrelated,
|
|
1446
|
+
// currently live) file completely alone and let the loop's own open/EEXIST/age logic
|
|
1447
|
+
// re-evaluate reality fresh on the next iteration.
|
|
1448
|
+
let currentIno = null;
|
|
1449
|
+
try { currentIno = fs.statSync(lockPath).ino; } catch (_e) { /* already gone */ }
|
|
1450
|
+
if (currentIno === st.ino) {
|
|
1451
|
+
try { fs.unlinkSync(lockPath); } catch (_e) {} // already gone — harmless
|
|
1452
|
+
}
|
|
1453
|
+
} finally {
|
|
1454
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // best-effort cleanup of our own ticket
|
|
1455
|
+
}
|
|
1456
|
+
reclaimed = true;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (reclaimed) {
|
|
1460
|
+
continue; // retry the create immediately (self-heal)
|
|
1461
|
+
}
|
|
1462
|
+
if (deadline !== null && Date.now() < deadline) {
|
|
1463
|
+
sleepSync(LOCK_POLL_INTERVAL_MS);
|
|
1464
|
+
continue; // keep polling a LIVE holder — or a still-contested ticket — until the deadline.
|
|
1465
|
+
// This check is now ALWAYS reachable on every non-reclaiming iteration (the
|
|
1466
|
+
// fix's other half): a wedged/leaked ticket can no longer bypass it by looping
|
|
1467
|
+
// back to the top of the `while` unconditionally, so an explicit --lock-timeout
|
|
1468
|
+
// is honored even while a stale ticket is being self-healed above.
|
|
1469
|
+
}
|
|
1470
|
+
timedOut = true;
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
if (timedOut) {
|
|
1474
|
+
fail("update_in_progress: another `update --global` is already running — retry shortly " +
|
|
1475
|
+
"(remove " + lockPath + " if it is stale)");
|
|
1476
|
+
return () => {}; // unreachable once fail() exits — keeps the function's return shape honest
|
|
1477
|
+
}
|
|
1478
|
+
try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
|
|
1479
|
+
catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
|
|
1480
|
+
const release = () => {
|
|
1481
|
+
try { fs.closeSync(fd); } catch (_e) {}
|
|
1482
|
+
try { fs.unlinkSync(lockPath); } catch (_e) {}
|
|
1483
|
+
};
|
|
1484
|
+
process.on("exit", release); // covers normal completion AND fail()'s process.exit
|
|
1485
|
+
return release;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// project-scope lock (project-scope-install-lock) — serializes cmdInit()/cmdUpdate() (non-
|
|
1489
|
+
// `--global` path) against the SAME target's own .add/ tree. A NEW, INDEPENDENT primitive that
|
|
1490
|
+
// mirrors (but never calls into or shares code with) acquireUpdateLock's own proven shape:
|
|
1491
|
+
// different function, different file, different default threshold, zero shared code (the two
|
|
1492
|
+
// locks guard genuinely different-shaped resources — see _installer.py:_project_lock).
|
|
1493
|
+
//
|
|
1494
|
+
// Held AND fresh -> fails IMMEDIATELY with "install_in_progress". UNLIKE acquireUpdateLock,
|
|
1495
|
+
// there is NO bounded-wait/poll mode (a live contention never waits, never polls — M7).
|
|
1496
|
+
//
|
|
1497
|
+
// Held AND stale (age > ADD_PROJECT_LOCK_STALE_SECONDS, default 120s) -> self-heals: unlinks
|
|
1498
|
+
// the stale lockfile and retries the create EXACTLY once before falling through to fail-fast.
|
|
1499
|
+
// A clock-skewed FUTURE mtime is NEVER treated as stale.
|
|
1500
|
+
//
|
|
1501
|
+
// KNOWN PROBLEM this shape works around: fail() calls process.exit(1) DIRECTLY (skips any
|
|
1502
|
+
// pending finally) — release is wired via process.on("exit", release), never a plain
|
|
1503
|
+
// try/finally at the call site (mirrors acquireUpdateLock's own already-solved precedent).
|
|
1504
|
+
//
|
|
1505
|
+
// If addDir did not exist yet (a virgin target — the lock file needs somewhere to live), it is
|
|
1506
|
+
// created here; on release, an addDir THIS call created is removed again iff it is still
|
|
1507
|
+
// completely empty (the lock file was its only occupant) — e.g. an --global failure (a held
|
|
1508
|
+
// home lock, an unwritable home) that aborts before the per-project drop leaves NOTHING behind,
|
|
1509
|
+
// exactly as before this lock existed. A non-empty addDir (the real drop landed, or it
|
|
1510
|
+
// pre-existed) is never touched.
|
|
1511
|
+
function acquireProjectLock(addDir, env = process.env) {
|
|
1512
|
+
const createdDir = !fs.existsSync(addDir);
|
|
1513
|
+
fs.mkdirSync(addDir, { recursive: true });
|
|
1514
|
+
const lockPath = path.join(addDir, PROJECT_LOCK_FILE);
|
|
1515
|
+
const staleAfterMs = Number(env.ADD_PROJECT_LOCK_STALE_SECONDS || PROJECT_LOCK_STALE_DEFAULT) * 1000;
|
|
1516
|
+
|
|
1517
|
+
let fd = null;
|
|
1518
|
+
try {
|
|
1519
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
1522
|
+
let st = null;
|
|
1523
|
+
try { st = fs.statSync(lockPath); } catch (_e) { /* vanished — treat as reclaimable now */ }
|
|
1524
|
+
if (st === null) {
|
|
1525
|
+
// vanished between the failed open and the stat — nothing to quarantine; retry a plain
|
|
1526
|
+
// create directly (safe unconditionally: O_EXCL is still the sole arbiter — this can
|
|
1527
|
+
// never clobber a legitimate concurrent holder's fresh file, it can only succeed if the
|
|
1528
|
+
// path is genuinely vacant right now).
|
|
1529
|
+
try {
|
|
1530
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1531
|
+
} catch (e2) {
|
|
1532
|
+
if (!e2 || e2.code !== "EEXIST") throw e2;
|
|
1533
|
+
}
|
|
1534
|
+
} else if (Date.now() - st.mtimeMs > staleAfterMs) {
|
|
1535
|
+
// Identity-verified reclaim (fixes a TOCTOU race: an unconditional unlink-by-path let a
|
|
1536
|
+
// second racer delete a FIRST racer's already-recreated, live lock). A NAIVE "rename to a
|
|
1537
|
+
// quarantine name" does NOT fix this — a rename is JUST as identity-blind as an unlink: it
|
|
1538
|
+
// operates on whatever currently sits at `lockPath`, so a delayed racer's rename can just
|
|
1539
|
+
// as easily steal a WINNER's brand-new fresh file (empirically reproduced while building
|
|
1540
|
+
// this fix; that attempt made the race MEASURABLY WORSE, not better, by adding an extra
|
|
1541
|
+
// syscall to the vulnerable window). The actual fix: gate entry to the reclaim itself
|
|
1542
|
+
// behind a SEPARATE, per-GENERATION exclusive ticket, keyed to the CURRENT stale file's
|
|
1543
|
+
// own inode number (`st.ino` — stable for this file's lifetime, and a FRESH replacement
|
|
1544
|
+
// file always gets a NEW inode). Two racers that observe the SAME stale file compute the
|
|
1545
|
+
// IDENTICAL ticket name and race an exclusive create on IT — only one wins; every loser
|
|
1546
|
+
// backs off WITHOUT ever touching `lockPath` itself, so nobody can ever unlink/steal a
|
|
1547
|
+
// generation they didn't win the ticket for.
|
|
1548
|
+
const ticketPath = lockPath + ".reclaim-" + st.ino;
|
|
1549
|
+
let ticketFd = null;
|
|
1550
|
+
try {
|
|
1551
|
+
ticketFd = fs.openSync(ticketPath, "wx");
|
|
1552
|
+
} catch (_e) {
|
|
1553
|
+
// LOST the per-generation reclaim ticket outright -- checked below before treating this
|
|
1554
|
+
// as "someone else legitimately owns reclaiming THIS stale file right now."
|
|
1555
|
+
}
|
|
1556
|
+
if (ticketFd === null) {
|
|
1557
|
+
// LOST the ticket outright. A leaked ticket -- its own holder crashed between winning
|
|
1558
|
+
// it and its own best-effort cleanup below -- would otherwise wedge this generation's
|
|
1559
|
+
// reclaim PERMANENTLY: the ticket's name is deterministically keyed to `lockPath`'s own
|
|
1560
|
+
// (unchanging) inode, so every future contender recomputes the IDENTICAL ticket path and
|
|
1561
|
+
// loses the identical EEXIST race forever (found by a fresh adversarial verify pass;
|
|
1562
|
+
// independently reproduced here against the unmodified code).
|
|
1563
|
+
let tst = null;
|
|
1564
|
+
try { tst = fs.statSync(ticketPath); } catch (_e) { /* vanished — retry the create below */ }
|
|
1565
|
+
if (tst === null) {
|
|
1566
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {}
|
|
1567
|
+
} else if (Date.now() - tst.mtimeMs > PROJECT_LOCK_TICKET_STALE_SECONDS * 1000) {
|
|
1568
|
+
// The ticket ITSELF is orphaned — reclaim it with the IDENTICAL identity-verified
|
|
1569
|
+
// discipline already used for the main lock just above, one level down: re-stat
|
|
1570
|
+
// immediately before unlinking and compare inode, so a ticket some THIRD, still-
|
|
1571
|
+
// legitimately-in-flight reclaimer freshly (re)created in the gap between our stat and
|
|
1572
|
+
// our unlink is never destroyed. A plain unconditional unlink-by-path here would
|
|
1573
|
+
// reopen the IDENTICAL TOCTOU hole this whole ticket mechanism exists to close, one
|
|
1574
|
+
// level down (e.g. a naive "just unlink if old" would let racer A destroy racer C's
|
|
1575
|
+
// brand-new, not-yet-stale ticket for the SAME generation, so A and C would BOTH
|
|
1576
|
+
// believe they are the sole reclaimer — the exact double-hold bug this ticket
|
|
1577
|
+
// mechanism was built to prevent, reintroduced one level down). Exactly one extra
|
|
1578
|
+
// self-heal attempt — never a second, matching M7's own "no poll, ever" discipline
|
|
1579
|
+
// already governing the main lock's own reclaim above.
|
|
1580
|
+
let currentTino = null;
|
|
1581
|
+
try { currentTino = fs.statSync(ticketPath).ino; } catch (_e) { /* already gone */ }
|
|
1582
|
+
if (currentTino === tst.ino) {
|
|
1583
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // already gone — harmless
|
|
1584
|
+
}
|
|
1585
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {} // someone else won it
|
|
1586
|
+
}
|
|
1587
|
+
// else: the ticket is live and fresh — a genuine, currently-in-flight reclaimer;
|
|
1588
|
+
// ticketFd stays null, falls through to the SAME fail-fast below (M7 — this lock never
|
|
1589
|
+
// polls a live holder, whether it is the main lock or a contested ticket).
|
|
1590
|
+
}
|
|
1591
|
+
if (ticketFd !== null) {
|
|
1592
|
+
try {
|
|
1593
|
+
fs.closeSync(ticketFd);
|
|
1594
|
+
// Winning the ticket proves we are the SOLE reclaimer for the generation we observed
|
|
1595
|
+
// (`st.ino`) — it does NOT prove `lockPath` is STILL that generation right now. An
|
|
1596
|
+
// arbitrarily long scheduling gap can separate "we judged st.ino stale" from "we act
|
|
1597
|
+
// on it," and in that gap this SAME path can already have cycled through a full,
|
|
1598
|
+
// unrelated reclaim by someone else (that old inode gone, a fresh one created and now
|
|
1599
|
+
// actively held — empirically observed while building this fix: a late-scheduled
|
|
1600
|
+
// racer's ticket for a long-superseded inode still "won" trivially, since nothing else
|
|
1601
|
+
// was contesting that specific stale ticket name any more, and its UNCONDITIONAL
|
|
1602
|
+
// unlink then blindly destroyed the CURRENT live holder's fresh file). Re-stat
|
|
1603
|
+
// immediately before mutating and compare inodes: only remove the file if it is
|
|
1604
|
+
// STILL, right now, the exact generation we ticketed for; otherwise our ticket is
|
|
1605
|
+
// moot — leave the (unrelated, currently live) file completely alone and let the
|
|
1606
|
+
// single retry below observe reality fresh (EEXIST -> fail-fast, per M7 — this lock
|
|
1607
|
+
// never polls a live holder).
|
|
1608
|
+
let currentIno = null;
|
|
1609
|
+
try { currentIno = fs.statSync(lockPath).ino; } catch (_e) { /* already gone */ }
|
|
1610
|
+
if (currentIno === st.ino) {
|
|
1611
|
+
try { fs.unlinkSync(lockPath); } catch (_e) {} // already gone — harmless
|
|
1612
|
+
}
|
|
1613
|
+
} finally {
|
|
1614
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // best-effort cleanup of our own ticket
|
|
1615
|
+
}
|
|
1616
|
+
try {
|
|
1617
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1618
|
+
} catch (e2) {
|
|
1619
|
+
if (!e2 || e2.code !== "EEXIST") throw e2;
|
|
1620
|
+
// someone else created a fresh file at the just-vacated path before we did (e.g. a
|
|
1621
|
+
// brand-new, never-raced contender's own first attempt landed in the gap) -> fail-fast,
|
|
1622
|
+
// exactly once — never a second reclaim attempt, never a poll/wait (M7).
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (fd === null) {
|
|
1627
|
+
fail("install_in_progress: another install/update is already running against " +
|
|
1628
|
+
path.dirname(addDir) + " — retry shortly (remove " + lockPath + " if stale)");
|
|
1629
|
+
return () => {}; // unreachable once fail() exits — keeps the function's return shape honest
|
|
1106
1630
|
}
|
|
1107
|
-
throw e;
|
|
1108
1631
|
}
|
|
1632
|
+
try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
|
|
1633
|
+
catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
|
|
1109
1634
|
const release = () => {
|
|
1110
1635
|
try { fs.closeSync(fd); } catch (_e) {}
|
|
1111
1636
|
try { fs.unlinkSync(lockPath); } catch (_e) {}
|
|
1637
|
+
if (createdDir) {
|
|
1638
|
+
try { if (fs.readdirSync(addDir).length === 0) fs.rmdirSync(addDir); } catch (_e) {}
|
|
1639
|
+
// non-empty (the real drop landed) or already gone — leave it alone either way
|
|
1640
|
+
}
|
|
1112
1641
|
};
|
|
1113
1642
|
process.on("exit", release); // covers normal completion AND fail()'s process.exit
|
|
1114
1643
|
return release;
|
|
@@ -1120,7 +1649,9 @@ function cmdUpdateGlobal(args) {
|
|
|
1120
1649
|
if (!fs.existsSync(path.join(home, STAMP_FILE))) {
|
|
1121
1650
|
fail("no_global_home: no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
|
|
1122
1651
|
}
|
|
1123
|
-
|
|
1652
|
+
// exclusive; self-heals a stale lock; a LIVE lock -> update_in_progress (immediate, or after
|
|
1653
|
+
// waiting up to --lock-timeout seconds); released on process exit
|
|
1654
|
+
acquireUpdateLock(home, { timeout: args.lockTimeout }, process.env);
|
|
1124
1655
|
// Read the registry BEFORE refreshing the home — a corrupt registry fails closed with ZERO
|
|
1125
1656
|
// writes (never a silent empty-list no-op), leaving the file for the user to fix or delete.
|
|
1126
1657
|
let reg;
|
|
@@ -1176,6 +1707,10 @@ function cmdUpdate(args) {
|
|
|
1176
1707
|
else log("ADD update available: project on " + cur + ", package is " + version + ". Run `update`.");
|
|
1177
1708
|
return;
|
|
1178
1709
|
}
|
|
1710
|
+
// Project-scope lock (project-scope-install-lock): acquired AFTER the read-only --check
|
|
1711
|
+
// report (a JS-only carve-out — see TASK.md §0/M3), held from here through the function's
|
|
1712
|
+
// end — INCLUDING the same-version no-op check below, so a retried call re-evaluates it fresh.
|
|
1713
|
+
acquireProjectLock(addDir);
|
|
1179
1714
|
// same-version no-op ONLY when nothing is missing — a missing managed tree HEALS
|
|
1180
1715
|
// even at the current version (heal-reconcile).
|
|
1181
1716
|
const status = managedStatus(target);
|