@avantf/dsh-plugin-base 0.3.1 → 0.4.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/README.md +29 -15
- package/dist/bootstrap.d.ts +3 -2
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +3 -2
- package/dist/compat.d.ts +2 -2
- package/dist/compat.d.ts.map +1 -1
- package/dist/compat.js +24 -7
- package/dist/errors.d.ts +6 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +23 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -22
- package/dist/interface.d.ts +296 -23
- package/dist/interface.d.ts.map +1 -1
- package/dist/interface.js +206 -8
- package/dist/interface_gate.d.ts +20 -6
- package/dist/interface_gate.d.ts.map +1 -1
- package/dist/interface_gate.js +37 -7
- package/dist/internal.d.ts +26 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +27 -1
- package/dist/kit/family.d.ts +2 -2
- package/dist/kit/family.js +3 -3
- package/dist/kit/index.d.ts +5 -1
- package/dist/kit/index.d.ts.map +1 -1
- package/dist/kit/index.js +5 -1
- package/dist/kit/logger.d.ts +1 -1
- package/dist/kit/logger.js +1 -1
- package/dist/kit/prompt_files.d.ts +37 -3
- package/dist/kit/prompt_files.d.ts.map +1 -1
- package/dist/kit/prompt_files.js +45 -0
- package/dist/kit/typert.d.ts +2 -2
- package/dist/kit/wellformed.d.ts +79 -0
- package/dist/kit/wellformed.d.ts.map +1 -0
- package/dist/kit/wellformed.js +155 -0
- package/dist/layout.d.ts +17 -2
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +15 -5
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +2 -2
- package/dist/lock.d.ts +18 -0
- package/dist/lock.d.ts.map +1 -1
- package/dist/lock.js +77 -10
- package/dist/net.d.ts +106 -3
- package/dist/net.d.ts.map +1 -1
- package/dist/net.js +128 -30
- package/dist/providers/archive.d.ts.map +1 -1
- package/dist/providers/archive.js +108 -20
- package/dist/providers/model.d.ts +26 -3
- package/dist/providers/model.d.ts.map +1 -1
- package/dist/providers/model.js +159 -32
- package/dist/providers/npm.d.ts +5 -0
- package/dist/providers/npm.d.ts.map +1 -1
- package/dist/providers/npm.js +62 -21
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +128 -31
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +8 -3
- package/dist/tar.d.ts +7 -0
- package/dist/tar.d.ts.map +1 -1
- package/dist/tar.js +12 -5
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/zip.d.ts.map +1 -1
- package/dist/zip.js +10 -1
- package/package.json +20 -20
- package/dist/artifact.js.map +0 -1
- package/dist/bootstrap.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/compat.js.map +0 -1
- package/dist/conformance.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/integrity.js.map +0 -1
- package/dist/interface.js.map +0 -1
- package/dist/interface_gate.js.map +0 -1
- package/dist/internal.js.map +0 -1
- package/dist/kit/family.js.map +0 -1
- package/dist/kit/index.js.map +0 -1
- package/dist/kit/logger.js.map +0 -1
- package/dist/kit/prompt_files.js.map +0 -1
- package/dist/kit/typert.js.map +0 -1
- package/dist/layout.js.map +0 -1
- package/dist/lint.js.map +0 -1
- package/dist/lock.js.map +0 -1
- package/dist/manifest.js.map +0 -1
- package/dist/net.js.map +0 -1
- package/dist/package-name.js.map +0 -1
- package/dist/preset.js.map +0 -1
- package/dist/providers/archive.js.map +0 -1
- package/dist/providers/model.js.map +0 -1
- package/dist/providers/npm.js.map +0 -1
- package/dist/provisioner.js.map +0 -1
- package/dist/semver.js.map +0 -1
- package/dist/state.js.map +0 -1
- package/dist/tar.js.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/zip.js.map +0 -1
package/dist/kit/prompt_files.js
CHANGED
|
@@ -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
|
}
|
package/dist/kit/typert.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
21
|
-
export
|
|
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. */
|
package/dist/layout.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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) {
|
package/dist/lint.d.ts.map
CHANGED
|
@@ -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,
|
|
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', '
|
|
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;
|
package/dist/lock.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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',
|
|
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
|
-
|
|
77
|
-
if (stillThere)
|
|
125
|
+
if (info !== undefined)
|
|
78
126
|
await sleep(pollMs);
|
|
79
127
|
continue;
|
|
80
128
|
}
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
}
|