@avantf/dsh-plugin-base 0.3.1 → 0.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.
Files changed (98) hide show
  1. package/README.md +29 -15
  2. package/dist/bootstrap.d.ts +3 -2
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +3 -2
  5. package/dist/compat.d.ts +2 -2
  6. package/dist/compat.js +5 -5
  7. package/dist/errors.d.ts +6 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/index.d.ts +23 -9
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +45 -22
  12. package/dist/interface.d.ts +296 -23
  13. package/dist/interface.d.ts.map +1 -1
  14. package/dist/interface.js +206 -8
  15. package/dist/interface_gate.d.ts +20 -6
  16. package/dist/interface_gate.d.ts.map +1 -1
  17. package/dist/interface_gate.js +37 -7
  18. package/dist/internal.d.ts +26 -1
  19. package/dist/internal.d.ts.map +1 -1
  20. package/dist/internal.js +27 -1
  21. package/dist/kit/family.d.ts +2 -2
  22. package/dist/kit/family.js +3 -3
  23. package/dist/kit/index.d.ts +5 -1
  24. package/dist/kit/index.d.ts.map +1 -1
  25. package/dist/kit/index.js +5 -1
  26. package/dist/kit/logger.d.ts +1 -1
  27. package/dist/kit/logger.js +1 -1
  28. package/dist/kit/prompt_files.d.ts +37 -3
  29. package/dist/kit/prompt_files.d.ts.map +1 -1
  30. package/dist/kit/prompt_files.js +45 -0
  31. package/dist/kit/typert.d.ts +2 -2
  32. package/dist/kit/wellformed.d.ts +79 -0
  33. package/dist/kit/wellformed.d.ts.map +1 -0
  34. package/dist/kit/wellformed.js +155 -0
  35. package/dist/layout.d.ts +17 -2
  36. package/dist/layout.d.ts.map +1 -1
  37. package/dist/layout.js +15 -5
  38. package/dist/lint.d.ts.map +1 -1
  39. package/dist/lint.js +2 -2
  40. package/dist/lock.d.ts +18 -0
  41. package/dist/lock.d.ts.map +1 -1
  42. package/dist/lock.js +77 -10
  43. package/dist/net.d.ts +106 -3
  44. package/dist/net.d.ts.map +1 -1
  45. package/dist/net.js +128 -30
  46. package/dist/providers/archive.d.ts.map +1 -1
  47. package/dist/providers/archive.js +108 -20
  48. package/dist/providers/model.d.ts +26 -3
  49. package/dist/providers/model.d.ts.map +1 -1
  50. package/dist/providers/model.js +159 -32
  51. package/dist/providers/npm.d.ts +5 -0
  52. package/dist/providers/npm.d.ts.map +1 -1
  53. package/dist/providers/npm.js +62 -21
  54. package/dist/provisioner.d.ts.map +1 -1
  55. package/dist/provisioner.js +128 -31
  56. package/dist/state.d.ts.map +1 -1
  57. package/dist/state.js +8 -3
  58. package/dist/tar.d.ts +7 -0
  59. package/dist/tar.d.ts.map +1 -1
  60. package/dist/tar.js +12 -5
  61. package/dist/types.d.ts +11 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/zip.d.ts.map +1 -1
  64. package/dist/zip.js +10 -1
  65. package/package.json +18 -19
  66. package/dist/artifact.js.map +0 -1
  67. package/dist/bootstrap.js.map +0 -1
  68. package/dist/cli.js.map +0 -1
  69. package/dist/compat.js.map +0 -1
  70. package/dist/conformance.js.map +0 -1
  71. package/dist/errors.js.map +0 -1
  72. package/dist/fs.js.map +0 -1
  73. package/dist/index.js.map +0 -1
  74. package/dist/integrity.js.map +0 -1
  75. package/dist/interface.js.map +0 -1
  76. package/dist/interface_gate.js.map +0 -1
  77. package/dist/internal.js.map +0 -1
  78. package/dist/kit/family.js.map +0 -1
  79. package/dist/kit/index.js.map +0 -1
  80. package/dist/kit/logger.js.map +0 -1
  81. package/dist/kit/prompt_files.js.map +0 -1
  82. package/dist/kit/typert.js.map +0 -1
  83. package/dist/layout.js.map +0 -1
  84. package/dist/lint.js.map +0 -1
  85. package/dist/lock.js.map +0 -1
  86. package/dist/manifest.js.map +0 -1
  87. package/dist/net.js.map +0 -1
  88. package/dist/package-name.js.map +0 -1
  89. package/dist/preset.js.map +0 -1
  90. package/dist/providers/archive.js.map +0 -1
  91. package/dist/providers/model.js.map +0 -1
  92. package/dist/providers/npm.js.map +0 -1
  93. package/dist/provisioner.js.map +0 -1
  94. package/dist/semver.js.map +0 -1
  95. package/dist/state.js.map +0 -1
  96. package/dist/tar.js.map +0 -1
  97. package/dist/types.js.map +0 -1
  98. package/dist/zip.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ /** A legal namespace: lowercase kebab-case, starting with a letter. */
4
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3
5
  /** The temp files {@link nodeIo.write} leaves beside a prompt file, and the age past which one
4
6
  * certainly belongs to a writer that will never rename it. Same discipline as the provisioner's
5
7
  * `fs.ts`, which sweeps `.atomic-*` the same way. */
@@ -62,13 +64,41 @@ function reason(error) {
62
64
  }
63
65
  export class PromptFiles {
64
66
  dir;
67
+ /** The validated namespace prefix, or `undefined` (pre-v3 behaviour: no prefix check). */
68
+ namespace;
69
+ /** Set when `namespace` was supplied but illegal; every `load` then degrades without touching disk. */
70
+ namespaceError;
65
71
  logger;
66
72
  io;
67
73
  constructor(options) {
74
+ if (options.namespace !== undefined && !NAMESPACE_PATTERN.test(options.namespace)) {
75
+ // A caller bug, not an environment failure. It must not throw (this module never throws — a
76
+ // throwing apply can fail a host boot), but it must also not fall back to "no prefix check",
77
+ // which would silently re-open the takeover hole. So the instance refuses to touch the prompt
78
+ // directory at all and warns on every load.
79
+ this.namespaceError = `PromptFiles: namespace ${JSON.stringify(options.namespace)} is not a legal prefix; expected lowercase kebab-case matching ${String(NAMESPACE_PATTERN)}`;
80
+ this.namespace = undefined;
81
+ }
82
+ else {
83
+ this.namespaceError = undefined;
84
+ this.namespace = options.namespace;
85
+ }
68
86
  this.dir = options.dir;
69
87
  this.logger = options.logger;
70
88
  this.io = options.io ?? nodeIo;
71
89
  }
90
+ /**
91
+ * Whether `file` belongs to this instance's namespace. `true` when no namespace was given (the
92
+ * legacy contract: the caller owns the whole directory naming convention as before).
93
+ */
94
+ ownsFile(file) {
95
+ const prefix = this.namespace;
96
+ if (prefix === undefined)
97
+ return true;
98
+ if (!file.startsWith(`${prefix}-`))
99
+ return false;
100
+ return !file.includes('/') && !file.includes('\\');
101
+ }
72
102
  /**
73
103
  * Ensure every spec's file exists with content, then return the text to inject. Ordered like the
74
104
  * manifest, so the caller can zip the result back onto its sections.
@@ -81,6 +111,21 @@ export class PromptFiles {
81
111
  source: 'default',
82
112
  wrote: false,
83
113
  });
114
+ if (this.namespaceError !== undefined) {
115
+ this.logger?.warn(`${this.namespaceError}; using the built-in defaults and not touching the prompt directory`);
116
+ return specs.map(fallbackOf);
117
+ }
118
+ if (this.namespace !== undefined && specs.some((spec) => !this.ownsFile(spec.file))) {
119
+ // Never touch disk for the offending spec: the whole point of the namespace is that a mistyped
120
+ // prefix must not read or write a file another plugin's user owns. Warn per offending file and
121
+ // degrade to the caller's fallback, exactly like the unusable-directory path.
122
+ for (const spec of specs) {
123
+ if (!this.ownsFile(spec.file)) {
124
+ this.logger?.warn(`prompt file ${spec.file} is outside the "${this.namespace}-" namespace; using the built-in default and not touching the prompt directory`);
125
+ }
126
+ }
127
+ return specs.map((spec) => (this.ownsFile(spec.file) ? this.loadOne(spec) : fallbackOf(spec)));
128
+ }
84
129
  try {
85
130
  this.io.ensureDir(this.dir);
86
131
  }
@@ -4,7 +4,7 @@
4
4
  * A DSH plugin's Remote face is hand-written here (the Typert generator runs only inside the harness
5
5
  * workspace), and both plugins independently arrived at the same conventions. The GENERATOR-level
6
6
  * parts live here once; the descriptor assembly stays per plugin because the two really differ (the
7
- * work engine adds `stream`/cancellation methods, the memory plugin has a `parameter()` helper with
7
+ * mission engine adds `stream`/cancellation methods, the memory plugin has a `parameter()` helper with
8
8
  * `acceptsUndefined`). Only the genuinely identical part is hoisted — a fake abstraction with a bag
9
9
  * of switches would be worse than the duplication it removes.
10
10
  *
@@ -20,7 +20,7 @@ import type { ZodType } from 'zod';
20
20
  * Carries BOTH members on purpose. The host's validator moved from `codec.schema.parse` (dsh 0.1.5)
21
21
  * to `codec.create()` (0.1.6), and each generation checks only its own — so a codec missing one of
22
22
  * them makes a healthy host read as an incompatible one, and the failure shows up as a REFUSED mount
23
- * rather than as a missing neighbour. Measured on the memory plugin first; the work copy had drifted
23
+ * rather than as a missing neighbour. Measured on the memory plugin first; the mission copy had drifted
24
24
  * to the one-member shape and was fixed to match. Do not "simplify" this back.
25
25
  */
26
26
  export interface StrictCodec {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Well-formed text — the ONE repair for the family's single worst serialization defect, a LONE
3
+ * SURROGATE.
4
+ *
5
+ * The defect has exactly one shape: a UTF-16 code unit in `D800–DFFF` with no partner (an unpaired
6
+ * high half, an unpaired low half, or half of an emoji). Such a string is not a valid Unicode scalar
7
+ * sequence, yet `JSON.stringify` emits it verbatim as the escape `"\ud800"`. JavaScript's own
8
+ * `JSON.parse` accepts that, so EVERY test in this repository passes while a strict parser rejects the
9
+ * whole document — measured, not theorized:
10
+ *
11
+ * - `printf '"\\ud800"' | jq .` → `jq: parse error: Invalid \uXXXX\uXXXX surrogate pair escape`;
12
+ * - Python's `json.load` accepts the document but `print` raises
13
+ * `UnicodeEncodeError: surrogates not allowed`;
14
+ * - the same half-code-unit also poisons FTS indexes, embeddings and the UI.
15
+ *
16
+ * The repair is `String.prototype.toWellFormed()` (Node ≥20, present in this repo's Node ≥22.15): it
17
+ * replaces each lone surrogate with U+FFFD (`�`). {@link wellFormedText} uses the engine's own
18
+ * implementation when it exists and an EQUIVALENT `charCodeAt` scan when it does not, so an older Node
19
+ * degrades in behaviour, never in availability ({@link scanAndRepair}).
20
+ *
21
+ * WHERE this belongs — the two boundaries where a model can see a string:
22
+ *
23
+ * - the INBOUND boundary, before a caller-supplied string is persisted or indexed (store writes,
24
+ * entity names, caller paths);
25
+ * - the OUTBOUND boundary, before a value is serialized for a model (tool results, JSON payloads,
26
+ * rendered records).
27
+ *
28
+ * This module is well-formedness ONLY, deliberately: it repairs lone surrogates and touches nothing
29
+ * else. Unicode normalization (`normalize('NFC')`) is a PERSISTENCE policy — "two spellings of the
30
+ * same text must not become two rows / two FTS terms" — and stays with the caller that owns that
31
+ * policy (mem's write entry layers it on top of {@link wellFormedText}); applying it here to data this
32
+ * process did not just write would silently rewrite history for display.
33
+ *
34
+ * Both members are PURE and type-preserving: same input, same output, no I/O, no global state, and
35
+ * every non-string value comes back untouched (see {@link wellFormedDeep}). They are part of the shared
36
+ * kit so one base release fixes every consumer; a plugin that cannot reach the base keeps a local
37
+ * fallback copy as its documented degradation path (root `AGENTS.md`).
38
+ *
39
+ * @module @avantf/dsh-plugin-base/kit/wellformed
40
+ */
41
+ /**
42
+ * Make ONE string well-formed: every lone surrogate becomes U+FFFD, every properly paired surrogate
43
+ * (a complete emoji, a CJK Extension B character) and every other code point is returned unchanged.
44
+ *
45
+ * Uses the engine's `String.prototype.toWellFormed()` when available and {@link scanAndRepair}
46
+ * otherwise; both produce the same result, so a pre-ES2024 Node never throws here.
47
+ *
48
+ * Idempotent (`wellFormedText(wellFormedText(x)) === wellFormedText(x)`) and pure — no I/O, no
49
+ * mutation, no normalization: quotes, backslashes, newlines and tabs come back byte-for-byte.
50
+ *
51
+ * @param text - the string to repair; anything else is the caller's type error (see the recursive
52
+ * {@link wellFormedDeep}, which leaves non-strings alone).
53
+ */
54
+ export declare function wellFormedText(text: string): string;
55
+ /**
56
+ * {@link wellFormedText} over a JSON-shaped value, so a whole result can be handed to a strict parser.
57
+ *
58
+ * Recursion rules, deliberately narrow — this member may repair strings but must never change a
59
+ * value's TYPE:
60
+ *
61
+ * - strings are repaired with {@link wellFormedText};
62
+ * - arrays are mapped (a new array; the input is never mutated);
63
+ * - PLAIN records are rebuilt with well-formed keys and values. "Plain" means the prototype is
64
+ * `Object.prototype` (an object literal) or `null` (`Object.create(null)`), and the object carries
65
+ * no `toJSON` custom serialization. Object KEYS are repaired too: `JSON.stringify` emits a lone
66
+ * surrogate in a property name verbatim, which breaks a strict parser exactly the same way, and a
67
+ * replacer cannot fix keys, which is why this clones;
68
+ * - EVERYTHING ELSE — numbers, booleans, `null`, `undefined`, bigints, symbols, functions, and every
69
+ * object that is not a plain record (`Date`, `RegExp`, `Map`, a class instance, anything with a
70
+ * `toJSON`) — is returned AS-IS, by identity. A `Date` must not become `{}`, which is what cloning
71
+ * its enumerable properties would do, and a class instance must not lose its prototype.
72
+ *
73
+ * Pure and type-preserving: the result is structurally identical to the input apart from lone
74
+ * surrogates, and the input value is never mutated.
75
+ *
76
+ * @param value - the JSON-shaped value to repair.
77
+ */
78
+ export declare function wellFormedDeep<T>(value: T): T;
79
+ //# sourceMappingURL=wellformed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wellformed.d.ts","sourceRoot":"","sources":["../../src/kit/wellformed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAsDH;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAInD;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAS7C"}
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Well-formed text — the ONE repair for the family's single worst serialization defect, a LONE
3
+ * SURROGATE.
4
+ *
5
+ * The defect has exactly one shape: a UTF-16 code unit in `D800–DFFF` with no partner (an unpaired
6
+ * high half, an unpaired low half, or half of an emoji). Such a string is not a valid Unicode scalar
7
+ * sequence, yet `JSON.stringify` emits it verbatim as the escape `"\ud800"`. JavaScript's own
8
+ * `JSON.parse` accepts that, so EVERY test in this repository passes while a strict parser rejects the
9
+ * whole document — measured, not theorized:
10
+ *
11
+ * - `printf '"\\ud800"' | jq .` → `jq: parse error: Invalid \uXXXX\uXXXX surrogate pair escape`;
12
+ * - Python's `json.load` accepts the document but `print` raises
13
+ * `UnicodeEncodeError: surrogates not allowed`;
14
+ * - the same half-code-unit also poisons FTS indexes, embeddings and the UI.
15
+ *
16
+ * The repair is `String.prototype.toWellFormed()` (Node ≥20, present in this repo's Node ≥22.15): it
17
+ * replaces each lone surrogate with U+FFFD (`�`). {@link wellFormedText} uses the engine's own
18
+ * implementation when it exists and an EQUIVALENT `charCodeAt` scan when it does not, so an older Node
19
+ * degrades in behaviour, never in availability ({@link scanAndRepair}).
20
+ *
21
+ * WHERE this belongs — the two boundaries where a model can see a string:
22
+ *
23
+ * - the INBOUND boundary, before a caller-supplied string is persisted or indexed (store writes,
24
+ * entity names, caller paths);
25
+ * - the OUTBOUND boundary, before a value is serialized for a model (tool results, JSON payloads,
26
+ * rendered records).
27
+ *
28
+ * This module is well-formedness ONLY, deliberately: it repairs lone surrogates and touches nothing
29
+ * else. Unicode normalization (`normalize('NFC')`) is a PERSISTENCE policy — "two spellings of the
30
+ * same text must not become two rows / two FTS terms" — and stays with the caller that owns that
31
+ * policy (mem's write entry layers it on top of {@link wellFormedText}); applying it here to data this
32
+ * process did not just write would silently rewrite history for display.
33
+ *
34
+ * Both members are PURE and type-preserving: same input, same output, no I/O, no global state, and
35
+ * every non-string value comes back untouched (see {@link wellFormedDeep}). They are part of the shared
36
+ * kit so one base release fixes every consumer; a plugin that cannot reach the base keeps a local
37
+ * fallback copy as its documented degradation path (root `AGENTS.md`).
38
+ *
39
+ * @module @avantf/dsh-plugin-base/kit/wellformed
40
+ */
41
+ /** U+FFFD REPLACEMENT CHARACTER: what a lone surrogate becomes. */
42
+ const REPLACEMENT = '\uFFFD';
43
+ /** A high surrogate: the FIRST half of an astral pair (`D800–DBFF`). */
44
+ const HIGH_MIN = 0xd800;
45
+ const HIGH_MAX = 0xdbff;
46
+ /** A low surrogate: the SECOND half of an astral pair (`DC00–DFFF`). */
47
+ const LOW_MIN = 0xdc00;
48
+ const LOW_MAX = 0xdfff;
49
+ /** The engine's own `String.prototype.toWellFormed`, or `undefined` (a pre-ES2024 runtime). */
50
+ function nativeToWellFormed() {
51
+ try {
52
+ // Typed loosely on purpose: the repo compiles against `lib: es2023` (this must also run on older
53
+ // Node), so the ES2024 member is read structurally rather than named.
54
+ const candidate = String.prototype.toWellFormed;
55
+ return typeof candidate === 'function' ? candidate : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /**
62
+ * The local equivalent of `String.prototype.toWellFormed()`: one `charCodeAt` pass that copies paired
63
+ * code units as-is and substitutes U+FFFD for each unpaired one. Not exported — it is the fallback of
64
+ * {@link wellFormedText}, and its behaviour is pinned by the same assertions as the native path (the
65
+ * test deletes `String.prototype.toWellFormed`, so both paths run the identical expectations).
66
+ */
67
+ function scanAndRepair(text) {
68
+ let out = '';
69
+ for (let index = 0; index < text.length; index += 1) {
70
+ const unit = text.charCodeAt(index);
71
+ if (unit >= HIGH_MIN && unit <= HIGH_MAX) {
72
+ const next = index + 1 < text.length ? text.charCodeAt(index + 1) : -1;
73
+ if (next >= LOW_MIN && next <= LOW_MAX) {
74
+ out += text.slice(index, index + 2); // a real pair: both units, unchanged
75
+ index += 1;
76
+ continue;
77
+ }
78
+ out += REPLACEMENT; // unpaired high: no low half follows
79
+ continue;
80
+ }
81
+ if (unit >= LOW_MIN && unit <= LOW_MAX) {
82
+ out += REPLACEMENT; // unpaired low: no high half preceded it (a paired one was consumed above)
83
+ continue;
84
+ }
85
+ out += text.slice(index, index + 1);
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * Make ONE string well-formed: every lone surrogate becomes U+FFFD, every properly paired surrogate
91
+ * (a complete emoji, a CJK Extension B character) and every other code point is returned unchanged.
92
+ *
93
+ * Uses the engine's `String.prototype.toWellFormed()` when available and {@link scanAndRepair}
94
+ * otherwise; both produce the same result, so a pre-ES2024 Node never throws here.
95
+ *
96
+ * Idempotent (`wellFormedText(wellFormedText(x)) === wellFormedText(x)`) and pure — no I/O, no
97
+ * mutation, no normalization: quotes, backslashes, newlines and tabs come back byte-for-byte.
98
+ *
99
+ * @param text - the string to repair; anything else is the caller's type error (see the recursive
100
+ * {@link wellFormedDeep}, which leaves non-strings alone).
101
+ */
102
+ export function wellFormedText(text) {
103
+ const native = nativeToWellFormed();
104
+ if (native !== undefined)
105
+ return native.call(text);
106
+ return scanAndRepair(text);
107
+ }
108
+ /**
109
+ * {@link wellFormedText} over a JSON-shaped value, so a whole result can be handed to a strict parser.
110
+ *
111
+ * Recursion rules, deliberately narrow — this member may repair strings but must never change a
112
+ * value's TYPE:
113
+ *
114
+ * - strings are repaired with {@link wellFormedText};
115
+ * - arrays are mapped (a new array; the input is never mutated);
116
+ * - PLAIN records are rebuilt with well-formed keys and values. "Plain" means the prototype is
117
+ * `Object.prototype` (an object literal) or `null` (`Object.create(null)`), and the object carries
118
+ * no `toJSON` custom serialization. Object KEYS are repaired too: `JSON.stringify` emits a lone
119
+ * surrogate in a property name verbatim, which breaks a strict parser exactly the same way, and a
120
+ * replacer cannot fix keys, which is why this clones;
121
+ * - EVERYTHING ELSE — numbers, booleans, `null`, `undefined`, bigints, symbols, functions, and every
122
+ * object that is not a plain record (`Date`, `RegExp`, `Map`, a class instance, anything with a
123
+ * `toJSON`) — is returned AS-IS, by identity. A `Date` must not become `{}`, which is what cloning
124
+ * its enumerable properties would do, and a class instance must not lose its prototype.
125
+ *
126
+ * Pure and type-preserving: the result is structurally identical to the input apart from lone
127
+ * surrogates, and the input value is never mutated.
128
+ *
129
+ * @param value - the JSON-shaped value to repair.
130
+ */
131
+ export function wellFormedDeep(value) {
132
+ if (typeof value === 'string')
133
+ return wellFormedText(value);
134
+ if (Array.isArray(value))
135
+ return value.map((item) => wellFormedDeep(item));
136
+ if (value !== null && typeof value === 'object' && isPlainRecord(value)) {
137
+ const out = {};
138
+ for (const [key, item] of Object.entries(value))
139
+ out[wellFormedText(key)] = wellFormedDeep(item);
140
+ return out;
141
+ }
142
+ return value;
143
+ }
144
+ /**
145
+ * Whether `value` is a JSON-shaped plain record: an object literal (`Object.prototype`) or a
146
+ * null-prototype map (`Object.create(null)`), with no `toJSON` custom serialization. Everything else
147
+ * is a different type and is left alone — see {@link wellFormedDeep}.
148
+ */
149
+ function isPlainRecord(value) {
150
+ const proto = Object.getPrototypeOf(value);
151
+ if (proto !== Object.prototype && proto !== null)
152
+ return false;
153
+ return typeof value.toJSON !== 'function';
154
+ }
155
+ //# sourceMappingURL=wellformed.js.map
package/dist/layout.d.ts CHANGED
@@ -17,8 +17,23 @@ export declare function quarantineRoot(home: string): string;
17
17
  export declare function stagingDir(home: string, itemId: string, sequence: number): string;
18
18
  /** A quarantine directory for one bad version directory. */
19
19
  export declare function quarantineDir(home: string, sequence: number): string;
20
- /** Is `child` inside `parent` (or equal to it)? Both are absolute and already normalised. */
21
- export declare function isInside(parent: string, child: string): boolean;
20
+ /** The platform slice {@link isInside} needs; injected so win32 semantics are testable on Linux. */
21
+ export interface PathSemantics {
22
+ parse(path: string): {
23
+ readonly root: string;
24
+ };
25
+ relative(from: string, to: string): string;
26
+ readonly sep: string;
27
+ }
28
+ /**
29
+ * Is `child` inside `parent` (or equal to it)? Both are absolute and already normalised.
30
+ *
31
+ * The root comparison comes FIRST and is what makes this fail-closed on Windows: `win32.relative`
32
+ * answers a cross-drive/UNC pair with the other drive's absolute path (`D:\evil\x`), which does not
33
+ * start with `..`, so a bare relative check called every cross-root path "inside". `pathImpl` is a
34
+ * parameter only so the win32 branch is exercised from Linux; callers pass nothing.
35
+ */
36
+ export declare function isInside(parent: string, child: string, pathImpl?: PathSemantics): boolean;
22
37
  /** Reject a `target.root` that is absolute or escapes `home` (`invalid-option`). */
23
38
  export declare function assertSafeRelativeRoot(root: string): void;
24
39
  /** Reject a provider-supplied relative path that is absolute or escapes its root. */
@@ -1 +1 @@
1
- {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAQA,qDAAqD;AACrD,eAAO,MAAM,WAAW,aAAa,CAAA;AAErC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,MAAM,CAAA;AAEnC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AACD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AACD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AACD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AACD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AACD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD;AACD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AACD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGjF;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED,6FAA6F;AAC7F,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAG/D;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQzD;AAED,qFAAqF;AACrF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQzD;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,aAAa,GAAE,MAAyB,GACvC,MAAM,CAOR"}
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAQA,qDAAqD;AACrD,eAAO,MAAM,WAAW,aAAa,CAAA;AAErC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,MAAM,CAAA;AAEnC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AACD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AACD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AACD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AACD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AACD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD;AACD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AACD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGjF;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED,oGAAoG;AACpG,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9C,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IAC1C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,GAAE,aAAwC,GAAG,OAAO,CAKnH;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQzD;AAED,qFAAqF;AACrF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQzD;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,aAAa,GAAE,MAAyB,GACvC,MAAM,CAOR"}
package/dist/layout.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * @module layout
4
4
  */
5
5
  import { createHash } from 'node:crypto';
6
- import { join, relative, sep } from 'node:path';
6
+ import { join, parse, relative, sep } from 'node:path';
7
7
  import { ProvisionError } from './errors.js';
8
8
  /** The control-plane directory name under `home`. */
9
9
  export const CONTROL_DIR = '.envinit';
@@ -45,10 +45,20 @@ export function stagingDir(home, itemId, sequence) {
45
45
  export function quarantineDir(home, sequence) {
46
46
  return join(quarantineRoot(home), `${String(Date.now())}-${String(process.pid)}-${String(sequence)}`);
47
47
  }
48
- /** Is `child` inside `parent` (or equal to it)? Both are absolute and already normalised. */
49
- export function isInside(parent, child) {
50
- const rel = relative(parent, child);
51
- return rel === '' || (!rel.startsWith('..') && !rel.startsWith(`..${sep}`));
48
+ /**
49
+ * Is `child` inside `parent` (or equal to it)? Both are absolute and already normalised.
50
+ *
51
+ * The root comparison comes FIRST and is what makes this fail-closed on Windows: `win32.relative`
52
+ * answers a cross-drive/UNC pair with the other drive's absolute path (`D:\evil\x`), which does not
53
+ * start with `..`, so a bare relative check called every cross-root path "inside". `pathImpl` is a
54
+ * parameter only so the win32 branch is exercised from Linux; callers pass nothing.
55
+ */
56
+ export function isInside(parent, child, pathImpl = { parse, relative, sep }) {
57
+ // Windows drive letters and UNC shares are case-insensitive; lowercasing a POSIX root is a no-op.
58
+ if (pathImpl.parse(parent).root.toLowerCase() !== pathImpl.parse(child).root.toLowerCase())
59
+ return false;
60
+ const rel = pathImpl.relative(parent, child);
61
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith(`..${pathImpl.sep}`));
52
62
  }
53
63
  /** Reject a `target.root` that is absolute or escapes `home` (`invalid-option`). */
54
64
  export function assertSafeRelativeRoot(root) {
@@ -1 +1 @@
1
- {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../src/lint.ts"],"names":[],"mappings":"AASA,mDAAmD;AACnD,eAAO,MAAM,aAAa,EAAE,SAAS,MAAM,EAAsF,CAAA;AAEjI,uCAAuC;AACvC,eAAO,MAAM,uBAAuB,EAAE,SAAS,MAAM,EAAgB,CAAA;AAErE,0EAA0E;AAC1E,eAAO,MAAM,yBAAyB,EAAE,SAAS,MAAM,EAA8C,CAAA;AAErG,+EAA+E;AAC/E,eAAO,MAAM,mCAAmC,EAAE,SAAS,MAAM,EAMhE,CAAA;AAED,oFAAoF;AACpF,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAAoC,CAAA;AAEjF,oCAAoC;AACpC,eAAO,MAAM,WAAW,EAAE,SAAS,MAAM,EAUxC,CAAA;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,MAAM,EAAE,CAGzF;AAED,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;IACpB,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAA;CAC1C;AAUD,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIpD;AAED,wEAAwE;AACxE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAqDD,sEAAsE;AACtE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,GAAG,UAAU,CAgJ1D;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CASzD"}
1
+ {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../src/lint.ts"],"names":[],"mappings":"AASA,mDAAmD;AACnD,eAAO,MAAM,aAAa,EAAE,SAAS,MAAM,EAAsF,CAAA;AAEjI,uCAAuC;AACvC,eAAO,MAAM,uBAAuB,EAAE,SAAS,MAAM,EAA6B,CAAA;AAElF,0EAA0E;AAC1E,eAAO,MAAM,yBAAyB,EAAE,SAAS,MAAM,EAAiC,CAAA;AAExF,+EAA+E;AAC/E,eAAO,MAAM,mCAAmC,EAAE,SAAS,MAAM,EAMhE,CAAA;AAED,oFAAoF;AACpF,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAAoC,CAAA;AAEjF,oCAAoC;AACpC,eAAO,MAAM,WAAW,EAAE,SAAS,MAAM,EAUxC,CAAA;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,MAAM,EAAE,CAGzF;AAED,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;IACpB,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAA;CAC1C;AAUD,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIpD;AAED,wEAAwE;AACxE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAqDD,sEAAsE;AACtE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,GAAG,UAAU,CAgJ1D;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CASzD"}
package/dist/lint.js CHANGED
@@ -8,9 +8,9 @@ import { assertRange } from './semver.js';
8
8
  /** The built-in kinds whose names are reserved. */
9
9
  export const BUILTIN_KINDS = ['npm-package', 'binary-archive', 'model-cache'];
10
10
  /** Item policy keys the core reads. */
11
- export const IMPLEMENTED_POLICY_KEYS = ['mirrors'];
11
+ export const IMPLEMENTED_POLICY_KEYS = ['mirrors', 'timeoutMs'];
12
12
  /** Item policy keys the surface declares but this build does not read. */
13
- export const UNIMPLEMENTED_POLICY_KEYS = ['concurrency', 'timeoutMs', 'platforms'];
13
+ export const UNIMPLEMENTED_POLICY_KEYS = ['concurrency', 'platforms'];
14
14
  /** Provision-policy keys the surface declares but this build does not read. */
15
15
  export const UNIMPLEMENTED_PROVISION_POLICY_KEYS = [
16
16
  'packumentMirrors',
package/dist/lock.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  import type { ProvisionLock } from './types.js';
2
+ /** What a waiter observed when it refused to reclaim a lock whose holder is still alive. */
3
+ export interface SlowHoldInfo {
4
+ readonly path: string;
5
+ readonly pid: number;
6
+ readonly startedAt: number;
7
+ /** How old the lock file's mtime already is — the value that crossed `staleMs`. */
8
+ readonly heldMs: number;
9
+ readonly staleMs: number;
10
+ /** How long this `acquire()` call has been waiting so far. */
11
+ readonly waitedMs: number;
12
+ }
2
13
  /** `true` when the pid exists; `EPERM` counts as alive. */
3
14
  export declare function pidIsAlive(pid: number): boolean;
4
15
  export interface DefaultLockOptions {
@@ -6,6 +17,13 @@ export interface DefaultLockOptions {
6
17
  readonly clock?: () => number;
7
18
  /** Poll interval while waiting; defaults to 25 ms. */
8
19
  readonly pollMs?: number;
20
+ /**
21
+ * Called (rate-limited) when a waiter sees a lock whose mtime crossed `staleMs` while its holder
22
+ * pid is still ALIVE — the one case the lock deliberately refuses to reclaim. A long critical
23
+ * section is legal; this is how "the lock contract is being stretched" becomes visible instead of
24
+ * being silently papered over by stealing the lock.
25
+ */
26
+ readonly onSlowHold?: (info: SlowHoldInfo) => void;
9
27
  }
10
28
  /** The default {@link ProvisionLock}. */
11
29
  export declare function defaultLock(options?: DefaultLockOptions): ProvisionLock;
@@ -1 +1 @@
1
- {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAc,aAAa,EAAE,MAAM,YAAY,CAAA;AAO3D,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAO/C;AAoBD,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,yCAAyC;AACzC,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,aAAa,CA2D3E"}
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAc,aAAa,EAAE,MAAM,YAAY,CAAA;AAO3D,4FAA4F;AAC5F,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAO/C;AAwCD,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;CACnD;AAED,yCAAyC;AACzC,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,aAAa,CAsG3E"}
package/dist/lock.js CHANGED
@@ -34,21 +34,43 @@ async function readHolder(path) {
34
34
  function sleep(ms) {
35
35
  return new Promise(resolve => setTimeout(resolve, ms));
36
36
  }
37
+ /**
38
+ * Last warning time per `(path, holder)` pair. The poll loop re-observes the same stale-but-alive
39
+ * holder every `pollMs`, and a caller may retry `acquire` — both would otherwise emit the same line
40
+ * hundreds of times. The window is one `staleMs` (at least 1 s), i.e. the same period the warning is
41
+ * about; the holder identity makes a genuinely new holder warn again immediately.
42
+ */
43
+ const slowHoldWarnedAt = new Map();
44
+ /** Bound on the rate-limit state; a lock home far past this just re-warns once more. */
45
+ const SLOW_HOLD_WARN_STATE_LIMIT = 64;
46
+ function warnSlowHold(onSlowHold, info) {
47
+ const key = `${info.path}\u0000${String(info.pid)}\u0000${String(info.startedAt)}`;
48
+ const now = Date.now();
49
+ const last = slowHoldWarnedAt.get(key);
50
+ if (last !== undefined && now - last < Math.max(1_000, info.staleMs))
51
+ return;
52
+ if (slowHoldWarnedAt.size >= SLOW_HOLD_WARN_STATE_LIMIT)
53
+ slowHoldWarnedAt.clear();
54
+ slowHoldWarnedAt.set(key, now);
55
+ onSlowHold(info);
56
+ }
37
57
  /** The default {@link ProvisionLock}. */
38
58
  export function defaultLock(options = {}) {
39
59
  const clock = options.clock ?? Date.now;
40
60
  const pollMs = options.pollMs ?? 25;
61
+ const onSlowHold = options.onSlowHold;
41
62
  return {
42
63
  async acquire(path, { timeoutMs, staleMs }) {
43
64
  // Ensure the control-plane directory exists.
44
65
  await mkdir(dirname(path), { recursive: true });
45
- const deadline = clock() + timeoutMs;
66
+ const started = clock();
67
+ const deadline = started + timeoutMs;
46
68
  for (;;) {
47
69
  const mine = { pid: process.pid, startedAt: clock() };
70
+ let handle;
48
71
  try {
49
- const handle = await open(path, 'wx');
72
+ handle = await open(path, 'wx');
50
73
  await handle.writeFile(JSON.stringify(mine), 'utf8');
51
- await handle.close();
52
74
  return {
53
75
  dispose: () => {
54
76
  // Remove the lock only while it is still ours.
@@ -62,28 +84,73 @@ export function defaultLock(options = {}) {
62
84
  };
63
85
  }
64
86
  catch (error) {
87
+ // The file we just created is OURS even though the write failed. Leaving a zero-byte
88
+ // `.lock` behind is exactly the wedge this function has to recover from, so remove it
89
+ // before rethrowing. Close first: Windows refuses to unlink an open handle.
90
+ if (handle !== undefined) {
91
+ await handle.close().catch(() => undefined);
92
+ handle = undefined;
93
+ await unlink(path).catch(() => undefined);
94
+ }
65
95
  // Only EEXIST is contention; any other error is rethrown.
66
96
  if (error.code !== 'EEXIST') {
67
97
  throw new ProvisionError('unknown-error', `无法创建锁 ${path}:${error instanceof Error ? error.message : String(error)}`);
68
98
  }
69
99
  }
100
+ finally {
101
+ // The success path returns from inside the `try`, so the handle is released here — a
102
+ // `writeFile`/`close` failure must never leak it.
103
+ if (handle !== undefined)
104
+ await handle.close().catch(() => undefined);
105
+ }
70
106
  const holder = await readHolder(path);
107
+ // The lock file's own mtime, read from the filesystem. It is compared against the WALL clock
108
+ // rather than the injectable one: `clock` drives the timeout budget and the recorded
109
+ // `startedAt`, but an injected clock that is deliberately far from wall time would make a
110
+ // fresh lock look arbitrarily old.
111
+ const info = await stat(path).then(value => value, () => undefined);
112
+ const fileStale = info !== undefined && Date.now() - info.mtimeMs > staleMs;
113
+ const heldMs = info === undefined ? 0 : Math.round(Date.now() - info.mtimeMs);
114
+ const waitedMs = Math.round(clock() - started);
71
115
  if (holder === undefined) {
72
- // A missing or unreadable holder still honours the timeout budget.
116
+ // No readable holder. mtime is the only trustworthy signal that this is a corrupt /
117
+ // half-written lock rather than a live writer caught mid-write, so a stale file is
118
+ // reclaimed and a fresh one still honours the timeout budget.
119
+ if (fileStale && await unlink(path).then(() => true, () => false))
120
+ continue;
73
121
  if (clock() >= deadline) {
74
- throw new ProvisionError('lock/timeout', `等待发布锁超时(${String(timeoutMs)} ms):${path} 存在但不是可读的锁文件`);
122
+ throw new ProvisionError('lock/timeout', `等待发布锁超时(预算 ${String(timeoutMs)} ms,已等待 ${String(waitedMs)} ms):${path} 存在但不是可读的锁文件` +
123
+ (info === undefined ? '' : `;锁文件 mtime 年龄 ${String(heldMs)} ms(staleMs ${String(staleMs)} ms)`));
75
124
  }
76
- const stillThere = await stat(path).then(() => true, () => false);
77
- if (stillThere)
125
+ if (info !== undefined)
78
126
  await sleep(pollMs);
79
127
  continue;
80
128
  }
81
- if (!pidIsAlive(holder.pid) && clock() - holder.startedAt > staleMs) {
82
- await unlink(path).catch(() => undefined);
129
+ // A readable holder. The pid is AUTHORITATIVE: a lock is reclaimed only when its holder is
130
+ // provably GONE. mtime alone must never reclaim a live holder — a critical section is allowed
131
+ // to outlive `staleMs` (a GiB-scale copy fallback on Windows takes minutes), and stealing the
132
+ // lock from a live writer is worse than making the waiter fail visibly: two writers entering
133
+ // the same snapshot directory is a correctness break, a timeout is a report. `recordedStale`
134
+ // stays the secondary signal for a dead pid, keeping recovery working under an injected clock
135
+ // or where mtime is not meaningful.
136
+ const alive = pidIsAlive(holder.pid);
137
+ const recordedStale = !alive && clock() - holder.startedAt > staleMs;
138
+ if (!alive && (recordedStale || fileStale) && await unlink(path).then(() => true, () => false))
83
139
  continue;
140
+ if (alive && fileStale && onSlowHold !== undefined) {
141
+ warnSlowHold(onSlowHold, {
142
+ path,
143
+ pid: holder.pid,
144
+ startedAt: holder.startedAt,
145
+ heldMs,
146
+ staleMs,
147
+ waitedMs,
148
+ });
84
149
  }
85
150
  if (clock() >= deadline) {
86
- throw new ProvisionError('lock/timeout', `等待发布锁超时(${String(timeoutMs)} ms):${path} 由 pid ${String(holder.pid)} 持有`);
151
+ const state = alive ? '仍存活(活体持有者,不回收)' : '已消失(尚未满足可回收条件)';
152
+ throw new ProvisionError('lock/timeout', `等待发布锁超时(预算 ${String(timeoutMs)} ms,已等待 ${String(waitedMs)} ms):${path} 由 pid ${String(holder.pid)} ` +
153
+ `持有(startedAt ${String(holder.startedAt)}),该进程${state};锁文件 mtime 年龄 ${String(heldMs)} ms(staleMs ${String(staleMs)} ms)`);
87
154
  }
88
155
  await sleep(pollMs);
89
156
  }