@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/brief/cli.d.ts +30 -0
- package/dist/brief/cli.d.ts.map +1 -0
- package/dist/brief/cli.js +85 -0
- package/dist/brief/collect.d.ts +17 -0
- package/dist/brief/collect.d.ts.map +1 -0
- package/dist/brief/collect.js +342 -0
- package/dist/brief/config.d.ts +40 -0
- package/dist/brief/config.d.ts.map +1 -0
- package/dist/brief/config.js +85 -0
- package/dist/brief/constants.d.ts +12 -0
- package/dist/brief/constants.d.ts.map +1 -0
- package/dist/brief/constants.js +11 -0
- package/dist/brief/docs.d.ts +53 -0
- package/dist/brief/docs.d.ts.map +1 -0
- package/dist/brief/docs.js +229 -0
- package/dist/brief/freshness.d.ts +104 -0
- package/dist/brief/freshness.d.ts.map +1 -0
- package/dist/brief/freshness.js +263 -0
- package/dist/brief/git-signals.d.ts +17 -0
- package/dist/brief/git-signals.d.ts.map +1 -0
- package/dist/brief/git-signals.js +101 -0
- package/dist/brief/index.d.ts +11 -0
- package/dist/brief/index.d.ts.map +1 -0
- package/dist/brief/index.js +5 -0
- package/dist/brief/reconcile.d.ts +75 -0
- package/dist/brief/reconcile.d.ts.map +1 -0
- package/dist/brief/reconcile.js +259 -0
- package/dist/brief/render.d.ts +67 -0
- package/dist/brief/render.d.ts.map +1 -0
- package/dist/brief/render.js +458 -0
- package/dist/brief/sanitize.d.ts +38 -0
- package/dist/brief/sanitize.d.ts.map +1 -0
- package/dist/brief/sanitize.js +48 -0
- package/dist/brief/selectors.d.ts +36 -0
- package/dist/brief/selectors.d.ts.map +1 -0
- package/dist/brief/selectors.js +75 -0
- package/dist/brief/story.d.ts +18 -0
- package/dist/brief/story.d.ts.map +1 -0
- package/dist/brief/story.js +59 -0
- package/dist/brief/types.d.ts +126 -0
- package/dist/brief/types.d.ts.map +1 -0
- package/dist/brief/types.js +11 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +199 -0
- package/dist/cli/init-args.d.ts +16 -0
- package/dist/cli/init-args.d.ts.map +1 -0
- package/dist/cli/init-args.js +24 -0
- package/dist/cli/ui-args.d.ts +23 -0
- package/dist/cli/ui-args.d.ts.map +1 -0
- package/dist/cli/ui-args.js +66 -0
- package/dist/cli/ui.d.ts +40 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +180 -0
- package/dist/coordinatorium/checkpoint.d.ts +44 -0
- package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
- package/dist/coordinatorium/checkpoint.js +124 -0
- package/dist/coordinatorium/cli.d.ts +39 -0
- package/dist/coordinatorium/cli.d.ts.map +1 -0
- package/dist/coordinatorium/cli.js +203 -0
- package/dist/coordinatorium/errors.d.ts +112 -0
- package/dist/coordinatorium/errors.d.ts.map +1 -0
- package/dist/coordinatorium/errors.js +145 -0
- package/dist/coordinatorium/index.d.ts +11 -0
- package/dist/coordinatorium/index.d.ts.map +1 -0
- package/dist/coordinatorium/index.js +5 -0
- package/dist/coordinatorium/lock.d.ts +187 -0
- package/dist/coordinatorium/lock.d.ts.map +1 -0
- package/dist/coordinatorium/lock.js +659 -0
- package/dist/coordinatorium/mission.d.ts +32 -0
- package/dist/coordinatorium/mission.d.ts.map +1 -0
- package/dist/coordinatorium/mission.js +87 -0
- package/dist/coordinatorium/state-keeper.d.ts +115 -0
- package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
- package/dist/coordinatorium/state-keeper.js +396 -0
- package/dist/coordinatorium/types.d.ts +69 -0
- package/dist/coordinatorium/types.d.ts.map +1 -0
- package/dist/coordinatorium/types.js +27 -0
- package/dist/discovery/compose.d.ts +28 -0
- package/dist/discovery/compose.d.ts.map +1 -0
- package/dist/discovery/compose.js +159 -0
- package/dist/discovery/contracts.d.ts +211 -0
- package/dist/discovery/contracts.d.ts.map +1 -0
- package/dist/discovery/contracts.js +555 -0
- package/dist/discovery/docs.d.ts +278 -0
- package/dist/discovery/docs.d.ts.map +1 -0
- package/dist/discovery/docs.js +1182 -0
- package/dist/discovery/facts.d.ts +49 -0
- package/dist/discovery/facts.d.ts.map +1 -0
- package/dist/discovery/facts.js +541 -0
- package/dist/discovery/index.d.ts +8 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +9 -0
- package/dist/discovery/orchestrate.d.ts +35 -0
- package/dist/discovery/orchestrate.d.ts.map +1 -0
- package/dist/discovery/orchestrate.js +32 -0
- package/dist/discovery/render.d.ts +36 -0
- package/dist/discovery/render.d.ts.map +1 -0
- package/dist/discovery/render.js +278 -0
- package/dist/discovery/traverse.d.ts +197 -0
- package/dist/discovery/traverse.d.ts.map +1 -0
- package/dist/discovery/traverse.js +510 -0
- package/dist/durable/assess.d.ts +105 -0
- package/dist/durable/assess.d.ts.map +1 -0
- package/dist/durable/assess.js +239 -0
- package/dist/durable/canonical.d.ts +25 -0
- package/dist/durable/canonical.d.ts.map +1 -0
- package/dist/durable/canonical.js +78 -0
- package/dist/durable/cli.d.ts +37 -0
- package/dist/durable/cli.d.ts.map +1 -0
- package/dist/durable/cli.js +141 -0
- package/dist/durable/errors.d.ts +192 -0
- package/dist/durable/errors.d.ts.map +1 -0
- package/dist/durable/errors.js +323 -0
- package/dist/durable/fs-store.d.ts +254 -0
- package/dist/durable/fs-store.d.ts.map +1 -0
- package/dist/durable/fs-store.js +1493 -0
- package/dist/durable/index.d.ts +20 -0
- package/dist/durable/index.d.ts.map +1 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/log.d.ts +33 -0
- package/dist/durable/log.d.ts.map +1 -0
- package/dist/durable/log.js +157 -0
- package/dist/durable/memory-store.d.ts +51 -0
- package/dist/durable/memory-store.d.ts.map +1 -0
- package/dist/durable/memory-store.js +302 -0
- package/dist/durable/projection.d.ts +35 -0
- package/dist/durable/projection.d.ts.map +1 -0
- package/dist/durable/projection.js +328 -0
- package/dist/durable/prune-check.d.ts +34 -0
- package/dist/durable/prune-check.d.ts.map +1 -0
- package/dist/durable/prune-check.js +72 -0
- package/dist/durable/register.d.ts +100 -0
- package/dist/durable/register.d.ts.map +1 -0
- package/dist/durable/register.js +532 -0
- package/dist/durable/retention.d.ts +49 -0
- package/dist/durable/retention.d.ts.map +1 -0
- package/dist/durable/retention.js +59 -0
- package/dist/durable/runtime.d.ts +499 -0
- package/dist/durable/runtime.d.ts.map +1 -0
- package/dist/durable/runtime.js +1288 -0
- package/dist/durable/seams.d.ts +44 -0
- package/dist/durable/seams.d.ts.map +1 -0
- package/dist/durable/seams.js +21 -0
- package/dist/durable/testing.d.ts +11 -0
- package/dist/durable/testing.d.ts.map +1 -0
- package/dist/durable/testing.js +25 -0
- package/dist/durable/types.d.ts +442 -0
- package/dist/durable/types.d.ts.map +1 -0
- package/dist/durable/types.js +75 -0
- package/dist/graph/builder.d.ts +11 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/builder.js +153 -0
- package/dist/graph/identity.d.ts +37 -0
- package/dist/graph/identity.d.ts.map +1 -0
- package/dist/graph/identity.js +46 -0
- package/dist/graph/index.d.ts +7 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +3 -0
- package/dist/graph/resolver.d.ts +41 -0
- package/dist/graph/resolver.d.ts.map +1 -0
- package/dist/graph/resolver.js +36 -0
- package/dist/graph/types.d.ts +42 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +2 -0
- package/dist/http/server.d.ts +76 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +195 -0
- package/dist/http/ui-assets.d.ts +22 -0
- package/dist/http/ui-assets.d.ts.map +1 -0
- package/dist/http/ui-assets.js +100 -0
- package/dist/ideas/cli.d.ts +33 -0
- package/dist/ideas/cli.d.ts.map +1 -0
- package/dist/ideas/cli.js +158 -0
- package/dist/ideas/format.d.ts +38 -0
- package/dist/ideas/format.d.ts.map +1 -0
- package/dist/ideas/format.js +194 -0
- package/dist/ideas/index.d.ts +6 -0
- package/dist/ideas/index.d.ts.map +1 -0
- package/dist/ideas/index.js +3 -0
- package/dist/ideas/store.d.ts +55 -0
- package/dist/ideas/store.d.ts.map +1 -0
- package/dist/ideas/store.js +262 -0
- package/dist/ideas/types.d.ts +54 -0
- package/dist/ideas/types.d.ts.map +1 -0
- package/dist/ideas/types.js +26 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/init/index.d.ts +12 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +90 -0
- package/dist/init/prompts.d.ts +3 -0
- package/dist/init/prompts.d.ts.map +1 -0
- package/dist/init/prompts.js +47 -0
- package/dist/init/scaffold.d.ts +29 -0
- package/dist/init/scaffold.d.ts.map +1 -0
- package/dist/init/scaffold.js +208 -0
- package/dist/init/types.d.ts +14 -0
- package/dist/init/types.d.ts.map +1 -0
- package/dist/init/types.js +1 -0
- package/dist/managed/index.d.ts +73 -0
- package/dist/managed/index.d.ts.map +1 -0
- package/dist/managed/index.js +222 -0
- package/dist/readmodel/dashboard.d.ts +40 -0
- package/dist/readmodel/dashboard.d.ts.map +1 -0
- package/dist/readmodel/dashboard.js +236 -0
- package/dist/readmodel/index.d.ts +3 -0
- package/dist/readmodel/index.d.ts.map +1 -0
- package/dist/readmodel/index.js +1 -0
- package/dist/readmodel/types.d.ts +104 -0
- package/dist/readmodel/types.d.ts.map +1 -0
- package/dist/readmodel/types.js +15 -0
- package/dist/root/index.d.ts +186 -0
- package/dist/root/index.d.ts.map +1 -0
- package/dist/root/index.js +430 -0
- package/dist/root/safe-write.d.ts +268 -0
- package/dist/root/safe-write.d.ts.map +1 -0
- package/dist/root/safe-write.js +966 -0
- package/dist/root/write-artifacts.d.ts +20 -0
- package/dist/root/write-artifacts.d.ts.map +1 -0
- package/dist/root/write-artifacts.js +42 -0
- package/dist/scriptorium/compile.d.ts +4 -0
- package/dist/scriptorium/compile.d.ts.map +1 -0
- package/dist/scriptorium/compile.js +185 -0
- package/dist/scriptorium/frontmatter.d.ts +13 -0
- package/dist/scriptorium/frontmatter.d.ts.map +1 -0
- package/dist/scriptorium/frontmatter.js +90 -0
- package/dist/scriptorium/index.d.ts +3 -0
- package/dist/scriptorium/index.d.ts.map +1 -0
- package/dist/scriptorium/index.js +1 -0
- package/dist/scriptorium/markdown.d.ts +31 -0
- package/dist/scriptorium/markdown.d.ts.map +1 -0
- package/dist/scriptorium/markdown.js +88 -0
- package/dist/scriptorium/types.d.ts +78 -0
- package/dist/scriptorium/types.d.ts.map +1 -0
- package/dist/scriptorium/types.js +1 -0
- package/dist/types/enforcement.d.ts +11 -0
- package/dist/types/enforcement.d.ts.map +1 -0
- package/dist/types/enforcement.js +1 -0
- package/dist/types/integrity.d.ts +33 -0
- package/dist/types/integrity.d.ts.map +1 -0
- package/dist/types/integrity.js +1 -0
- package/dist/types/project-definition.d.ts +30 -0
- package/dist/types/project-definition.d.ts.map +1 -0
- package/dist/types/project-definition.js +1 -0
- package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
- package/dist/ui/assets/index-C4NhMaq7.js +28 -0
- package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
- package/dist/ui/index.html +13 -0
- package/dist/update/cli.d.ts +8 -0
- package/dist/update/cli.d.ts.map +1 -0
- package/dist/update/cli.js +115 -0
- package/dist/update/index.d.ts +78 -0
- package/dist/update/index.d.ts.map +1 -0
- package/dist/update/index.js +285 -0
- package/dist/validate/index.d.ts +6 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +214 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +1 -0
- package/package.json +67 -4
- package/templates/.gitkeep +0 -0
- package/templates/default-instance/instruction/.cursorrules +59 -0
- package/templates/default-instance/instruction/.windsurfrules +59 -0
- package/templates/default-instance/instruction/CLAUDE.md +57 -0
- package/templates/default-instance/managed/agent.md +20 -0
- package/templates/default-instance/managed/bootstrap.md +7 -0
- package/templates/default-instance/managed/index-idea.md +5 -0
- package/templates/default-instance/managed/routing-idea.md +14 -0
- package/templates/default-instance/monolith/AGENT.md +1 -0
- package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
- package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
- package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
- package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
- package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
- package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
- package/templates/default-instance/monolith/context/maps/routing-map.md +151 -0
- package/templates/default-instance/monolith/context/maps/semantic-map.md +75 -0
- package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
- package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
- package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
- package/templates/default-instance/monolith/governance/README.md +29 -0
- package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
- package/templates/default-instance/monolith/learning/INDEX.md +48 -0
- package/templates/default-instance/monolith/learning/injection-map.md +49 -0
- package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
- package/templates/default-instance/monolith/memory/errors/README.md +26 -0
- package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
- package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
- package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
- package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
- package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
- package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
- package/templates/default-instance/monolith/memory/working/active.md +41 -0
- package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
- package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
- package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
- package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
- package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
- package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
- package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
- package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
- package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
- package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
- package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
- package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
- package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
- package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
- package/templates/default-instance/monolith/skills/INDEX.md +32 -0
- package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
- package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
- package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
- package/templates/default-instance/monolith/skills/idea-capture/SKILL.md +157 -0
- package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
- package/templates/default-instance/monolith/state.json +11 -0
- package/templates/default-instance/monolith/tests/INDEX.md +57 -0
- package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
- package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
- package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
- package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
- package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
- package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
- package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
- package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
- package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
- package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
- package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
- package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
- package/templates/default-instance/projectroot/AGENTS.md +111 -0
- package/templates/default-instance/projectroot/monolith.config.json +32 -0
- package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal cross-process file lock.
|
|
3
|
+
*
|
|
4
|
+
* Acquisition is a single `open(path, 'wx')` syscall — it either creates the
|
|
5
|
+
* file or fails with EEXIST, atomically, on every platform Node supports.
|
|
6
|
+
* That atomicity is what actually prevents two processes from both believing
|
|
7
|
+
* they own the lock at once; the lock file's body (a JSON owner record) exists only
|
|
8
|
+
* to support crash recovery.
|
|
9
|
+
*
|
|
10
|
+
* Three invariants govern every decision in this module:
|
|
11
|
+
*
|
|
12
|
+
* 1. Lock ownership is not time-based.
|
|
13
|
+
* The age of the lock file is never, by itself, a reason to take it.
|
|
14
|
+
* 2. Uncertainty never grants ownership.
|
|
15
|
+
* If we cannot reliably establish that the recorded owner has exited,
|
|
16
|
+
* the lock is treated as held — we wait, we do not recover it.
|
|
17
|
+
* 3. Timeout ends waiting, not ownership.
|
|
18
|
+
* `timeoutMs` bounds how long a *challenger* will wait to acquire the
|
|
19
|
+
* lock. It says nothing about how long the current owner may hold it.
|
|
20
|
+
*
|
|
21
|
+
* A fourth, narrower rule governs recovery specifically:
|
|
22
|
+
*
|
|
23
|
+
* 4. Recovering a dead owner's lock is itself serialized.
|
|
24
|
+
* "Read the lock, confirm its owner is dead, re-read it, and delete it
|
|
25
|
+
* if unchanged" is NOT one atomic operation — the re-read and the
|
|
26
|
+
* delete are two separate syscalls, and another process can complete
|
|
27
|
+
* an entire recovery (delete the stale lock, create its own) in the
|
|
28
|
+
* gap between them. A recovery mutex (`<lockPath>.recovery`, itself
|
|
29
|
+
* just another `open(..., 'wx')`-guarded file) makes "verify and then
|
|
30
|
+
* delete a specific stale lock" exclusive, so at most one process is
|
|
31
|
+
* ever in a position to delete a given dead owner's record.
|
|
32
|
+
*
|
|
33
|
+
* Known limitation: this is a local-filesystem lock built on POSIX rename/
|
|
34
|
+
* open semantics. It does not claim to provide distributed coordination,
|
|
35
|
+
* and its guarantees are not evaluated on network filesystems (NFS, SMB,
|
|
36
|
+
* etc.) where `open(..., 'wx')` atomicity is not always dependable.
|
|
37
|
+
*
|
|
38
|
+
* A fifth invariant governs *creating* a lock (primary or recovery mutex)
|
|
39
|
+
* specifically:
|
|
40
|
+
*
|
|
41
|
+
* 5. A lock is never published in a partially-written state.
|
|
42
|
+
* Metadata is written in full to a private staging file
|
|
43
|
+
* (`<lockPath>.creating.<pid>.<nonce>`, a name no other acquirer can
|
|
44
|
+
* ever guess or collide with; created `O_EXCL|O_NOFOLLOW` at mode `0600`
|
|
45
|
+
* through the shared safe-write primitive, SEC-001B.2) and only
|
|
46
|
+
* published — via `link()`,
|
|
47
|
+
* which is exactly as exclusive a create-if-absent primitive as
|
|
48
|
+
* `open(path, 'wx')` — once that write (and, if requested, an fsync)
|
|
49
|
+
* has fully succeeded. If anything fails first, the canonical path
|
|
50
|
+
* never existed because of this attempt; only this process's own
|
|
51
|
+
* uniquely-named staging file is ever touched by its own cleanup. See
|
|
52
|
+
* `createAndPublish` below and ADR-008 for the full argument.
|
|
53
|
+
*/
|
|
54
|
+
import { writeSync, closeSync, lstatSync, unlinkSync, fchmodSync, fsyncSync, readFileSync, readlinkSync, } from 'fs';
|
|
55
|
+
import { basename, dirname } from 'path';
|
|
56
|
+
import { randomBytes } from 'crypto';
|
|
57
|
+
import { performance } from 'perf_hooks';
|
|
58
|
+
import { LockTimeoutError, LockUnsafeError, LockCreationError, ValidationError } from './errors.js';
|
|
59
|
+
import { SafeWriteError, createExclusive, disposeOwned, openParent, publishLink, readBoundedFileStable, boundaryPathOf, trustedDirectoryBoundary, } from '../root/index.js';
|
|
60
|
+
/** A well-formed record (see `LockRecord`) is under 200 bytes; 256 B is generous. */
|
|
61
|
+
export const MAX_LOCK_BYTES = 256;
|
|
62
|
+
const DEFAULT_OPTIONS = {
|
|
63
|
+
timeoutMs: 5000,
|
|
64
|
+
retryDelayMs: 25,
|
|
65
|
+
fsyncMetadata: false,
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Merge caller options over the defaults. A missing or explicitly `undefined`
|
|
69
|
+
* value takes the default (a plain spread would let `undefined` overwrite it);
|
|
70
|
+
* a present value must be a finite, non-negative number. `NaN` would make the
|
|
71
|
+
* deadline `NaN` — every comparison against it is false, so the wait would
|
|
72
|
+
* never end — and `Infinity` would make it unbounded by construction.
|
|
73
|
+
*/
|
|
74
|
+
function resolveOptions(options) {
|
|
75
|
+
const out = { ...DEFAULT_OPTIONS };
|
|
76
|
+
for (const key of ['timeoutMs', 'retryDelayMs']) {
|
|
77
|
+
const v = options[key];
|
|
78
|
+
if (v === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
81
|
+
throw new ValidationError(`"${key}" must be a finite, non-negative number of milliseconds (got ${String(v)})`);
|
|
82
|
+
}
|
|
83
|
+
out[key] = v;
|
|
84
|
+
}
|
|
85
|
+
if (options.fsyncMetadata !== undefined)
|
|
86
|
+
out.fsyncMetadata = options.fsyncMetadata;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
function sleep(ms) {
|
|
90
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
91
|
+
}
|
|
92
|
+
/** Monotonic milliseconds. The deadline never reads the wall clock, so a clock
|
|
93
|
+
* step (NTP, manual change, VM resume) can neither extend nor cut the wait. */
|
|
94
|
+
const monotonicNow = () => performance.now();
|
|
95
|
+
const realProcOps = {
|
|
96
|
+
platform: process.platform,
|
|
97
|
+
get pid() {
|
|
98
|
+
return process.pid;
|
|
99
|
+
},
|
|
100
|
+
readText: (path) => readFileSync(path, 'utf8'),
|
|
101
|
+
readlink: (path) => readlinkSync(path),
|
|
102
|
+
kill: (pid, signal) => {
|
|
103
|
+
process.kill(pid, signal);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const NO_IDENTITY = { boot: null, pidns: null, start: null };
|
|
107
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
108
|
+
const PIDNS_RE = /^pid:\[[0-9]{1,20}\]$/;
|
|
109
|
+
const START_RE = /^[0-9]{1,20}$/;
|
|
110
|
+
/** State letter and start ticks from `/proc/<pid>/stat`. The command name is
|
|
111
|
+
* parenthesised and may itself contain spaces and parentheses, so parsing
|
|
112
|
+
* starts after the LAST `)`. */
|
|
113
|
+
function parseProcStat(text) {
|
|
114
|
+
const close = text.lastIndexOf(')');
|
|
115
|
+
if (close < 0)
|
|
116
|
+
return null;
|
|
117
|
+
const fields = text.slice(close + 2).trim().split(' ');
|
|
118
|
+
const state = fields[0];
|
|
119
|
+
const start = fields[19]; // field 22 overall; fields[0] is field 3
|
|
120
|
+
if (!state || state.length !== 1 || start === undefined || !START_RE.test(start))
|
|
121
|
+
return null;
|
|
122
|
+
return { state, start };
|
|
123
|
+
}
|
|
124
|
+
/** This process's identity, or all-`null` when it cannot be established
|
|
125
|
+
* (non-Linux, or `/proc` that does not belong to our PID namespace). */
|
|
126
|
+
export function captureIdentity(ops = realProcOps) {
|
|
127
|
+
if (ops.platform !== 'linux')
|
|
128
|
+
return NO_IDENTITY;
|
|
129
|
+
try {
|
|
130
|
+
// A /proc mounted from another PID namespace would describe other processes.
|
|
131
|
+
if (ops.readlink('/proc/self') !== String(ops.pid))
|
|
132
|
+
return NO_IDENTITY;
|
|
133
|
+
const boot = ops.readText('/proc/sys/kernel/random/boot_id').trim();
|
|
134
|
+
const pidns = ops.readlink('/proc/self/ns/pid');
|
|
135
|
+
const stat = parseProcStat(ops.readText('/proc/self/stat'));
|
|
136
|
+
return {
|
|
137
|
+
boot: UUID_RE.test(boot) ? boot : null,
|
|
138
|
+
pidns: PIDNS_RE.test(pidns) ? pidns : null,
|
|
139
|
+
start: stat ? stat.start : null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return NO_IDENTITY;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
let cachedIdentity = null;
|
|
147
|
+
function ownIdentity(ops) {
|
|
148
|
+
if (ops !== undefined && Object.keys(ops).length > 0)
|
|
149
|
+
return captureIdentity({ ...realProcOps, ...ops });
|
|
150
|
+
if (cachedIdentity === null || cachedIdentity.pid !== process.pid) {
|
|
151
|
+
cachedIdentity = { pid: process.pid, identity: captureIdentity() };
|
|
152
|
+
}
|
|
153
|
+
return cachedIdentity.identity;
|
|
154
|
+
}
|
|
155
|
+
const errno = (err) => err?.code;
|
|
156
|
+
/**
|
|
157
|
+
* Decide whether the owner named by `record` is alive, from verifiable
|
|
158
|
+
* identity rather than from `kill(pid, 0)` alone (H1, LOCK-FIX-001).
|
|
159
|
+
*
|
|
160
|
+
* A PID is only meaningful inside the PID namespace and boot that issued it, so
|
|
161
|
+
* `ESRCH` is NOT proof of death by itself: a process in another namespace
|
|
162
|
+
* sharing the filesystem would see a live owner's PID as "no such process".
|
|
163
|
+
* `dead` therefore requires the record's boot and PID namespace to equal ours,
|
|
164
|
+
* and either a different start time for that PID (it was reused) or two
|
|
165
|
+
* independent probes agreeing it does not exist (`/proc/<pid>` absent AND
|
|
166
|
+
* `ESRCH`). Everything else is `unknown`, which — like `alive` — forbids
|
|
167
|
+
* recovery. Rules are checked top to bottom; the first match decides. The age
|
|
168
|
+
* of the lock plays no part.
|
|
169
|
+
*/
|
|
170
|
+
export function judgeOwner(record, ours, ops = realProcOps) {
|
|
171
|
+
if (ours.boot === null || ours.pidns === null)
|
|
172
|
+
return { verdict: 'unknown', reason: 'no-local-identity' };
|
|
173
|
+
if (record.boot === null || record.pidns === null)
|
|
174
|
+
return { verdict: 'unknown', reason: 'weak-record' };
|
|
175
|
+
if (record.boot !== ours.boot)
|
|
176
|
+
return { verdict: 'unknown', reason: 'boot-mismatch' };
|
|
177
|
+
if (record.pidns !== ours.pidns)
|
|
178
|
+
return { verdict: 'unknown', reason: 'pidns-mismatch' };
|
|
179
|
+
if (record.pid === ops.pid)
|
|
180
|
+
return { verdict: 'alive', reason: 'same-process' };
|
|
181
|
+
let killCode;
|
|
182
|
+
try {
|
|
183
|
+
ops.kill(record.pid, 0);
|
|
184
|
+
killCode = undefined;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
killCode = errno(err) ?? 'EUNKNOWN';
|
|
188
|
+
}
|
|
189
|
+
if (killCode === 'EPERM')
|
|
190
|
+
return { verdict: 'alive', reason: 'eperm' };
|
|
191
|
+
if (killCode !== undefined && killCode !== 'ESRCH')
|
|
192
|
+
return { verdict: 'unknown', reason: 'probe-error' };
|
|
193
|
+
let statText = null;
|
|
194
|
+
try {
|
|
195
|
+
statText = ops.readText(`/proc/${record.pid}/stat`);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (errno(err) !== 'ENOENT')
|
|
199
|
+
return { verdict: 'unknown', reason: 'probe-error' };
|
|
200
|
+
}
|
|
201
|
+
if (statText === null) {
|
|
202
|
+
// Absent from /proc: dead only if kill() agrees (ESRCH). If kill() says the
|
|
203
|
+
// process exists, the oracles disagree (e.g. hidepid) — not a death signal.
|
|
204
|
+
return killCode === 'ESRCH'
|
|
205
|
+
? { verdict: 'dead', reason: 'absent-by-two-oracles' }
|
|
206
|
+
: { verdict: 'unknown', reason: 'oracle-disagreement' };
|
|
207
|
+
}
|
|
208
|
+
if (killCode === 'ESRCH')
|
|
209
|
+
return { verdict: 'unknown', reason: 'oracle-disagreement' };
|
|
210
|
+
const stat = parseProcStat(statText);
|
|
211
|
+
if (stat === null)
|
|
212
|
+
return { verdict: 'unknown', reason: 'probe-error' };
|
|
213
|
+
if (stat.state === 'Z')
|
|
214
|
+
return { verdict: 'unknown', reason: 'zombie' };
|
|
215
|
+
if (stat.state === 'X')
|
|
216
|
+
return { verdict: 'dead', reason: 'exited' };
|
|
217
|
+
if (record.start === null)
|
|
218
|
+
return { verdict: 'alive', reason: 'cannot-rule-out-reuse' };
|
|
219
|
+
return record.start !== stat.start
|
|
220
|
+
? { verdict: 'dead', reason: 'pid-reused' }
|
|
221
|
+
: { verdict: 'alive', reason: 'start-match' };
|
|
222
|
+
}
|
|
223
|
+
const RECORD_KEYS = ['v', 'pid', 'nonce', 'boot', 'pidns', 'start'];
|
|
224
|
+
function makeOwnRecord(hooks = {}) {
|
|
225
|
+
return { pid: process.pid, nonce: randomBytes(8).toString('hex'), ...ownIdentity(hooks.procOps) };
|
|
226
|
+
}
|
|
227
|
+
/** The exact bytes written as a lock file's body. */
|
|
228
|
+
export function formatLockRecord(r) {
|
|
229
|
+
return JSON.stringify({ v: 2, pid: r.pid, nonce: r.nonce, boot: r.boot, pidns: r.pidns, start: r.start });
|
|
230
|
+
}
|
|
231
|
+
/** Content identity, not liveness. Used to detect whether a lock file has
|
|
232
|
+
* changed (recovered by someone else, or released) between two reads. */
|
|
233
|
+
function sameRecord(a, b) {
|
|
234
|
+
if (a === null || b === null)
|
|
235
|
+
return a === b;
|
|
236
|
+
return a.pid === b.pid && a.nonce === b.nonce && a.boot === b.boot && a.pidns === b.pidns && a.start === b.start;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Parse a lock file's raw content. Returns null for anything that is not
|
|
240
|
+
* unambiguously a well-formed record — including an empty file, a partially
|
|
241
|
+
* written one, garbage, or the unsupported `pid:nonce` format. A null result is
|
|
242
|
+
* NOT treated as "no owner"; callers must treat it as uncertain (invariant 2).
|
|
243
|
+
*/
|
|
244
|
+
function parseLockRecord(raw) {
|
|
245
|
+
let o;
|
|
246
|
+
try {
|
|
247
|
+
o = JSON.parse(raw);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (typeof o !== 'object' || o === null || Array.isArray(o))
|
|
253
|
+
return null;
|
|
254
|
+
const keys = Object.keys(o);
|
|
255
|
+
if (keys.length !== RECORD_KEYS.length || !RECORD_KEYS.every((k) => keys.includes(k)))
|
|
256
|
+
return null;
|
|
257
|
+
const r = o;
|
|
258
|
+
const optional = (v, re) => v === null ? null : typeof v === 'string' && re.test(v) ? v : undefined;
|
|
259
|
+
const boot = optional(r.boot, UUID_RE);
|
|
260
|
+
const pidns = optional(r.pidns, PIDNS_RE);
|
|
261
|
+
const start = optional(r.start, START_RE);
|
|
262
|
+
if (r.v !== 2 || !Number.isInteger(r.pid) || r.pid <= 0 || r.pid > 2 ** 31 - 1)
|
|
263
|
+
return null;
|
|
264
|
+
if (typeof r.nonce !== 'string' || !/^[0-9a-f]{16}$/.test(r.nonce))
|
|
265
|
+
return null;
|
|
266
|
+
if (boot === undefined || pidns === undefined || start === undefined)
|
|
267
|
+
return null;
|
|
268
|
+
const parsed = { pid: r.pid, nonce: r.nonce, boot, pidns, start };
|
|
269
|
+
// Canonical form only: `JSON.parse` silently accepts duplicate keys (last one
|
|
270
|
+
// wins), whitespace, reordered keys and a trailing newline. A record is
|
|
271
|
+
// exactly what `formatLockRecord` writes, byte for byte, or it is not a record.
|
|
272
|
+
if (formatLockRecord(parsed) !== raw)
|
|
273
|
+
return null;
|
|
274
|
+
return parsed;
|
|
275
|
+
}
|
|
276
|
+
function inspectLock(boundary, path) {
|
|
277
|
+
// Unified safe read (SEC-001B.1-R): the lock is read under the boundary its
|
|
278
|
+
// staging files are written under; a symlink or hardlinked lock is `structural`.
|
|
279
|
+
const r = readBoundedFileStable(path, 'lock file', { boundary, maxBytes: MAX_LOCK_BYTES, allowEmpty: true }, true);
|
|
280
|
+
if (r.ok) {
|
|
281
|
+
const record = 'content' in r ? parseLockRecord(r.content) : null;
|
|
282
|
+
if (record)
|
|
283
|
+
return { kind: 'valid', record };
|
|
284
|
+
return { kind: 'unsafe', class: 'content', fileCode: 'FILE_UNREADABLE', detail: 'lock content is empty, malformed or of an unrecognised format; automatic recovery is not performed' };
|
|
285
|
+
}
|
|
286
|
+
// Missing, or still being released/re-acquired after the retries: not
|
|
287
|
+
// observed. The exclusive create attempt that follows is the arbiter.
|
|
288
|
+
if (r.code === 'FILE_MISSING' || r.code === 'FILE_CHANGED')
|
|
289
|
+
return { kind: 'absent' };
|
|
290
|
+
return { kind: 'unsafe', class: 'structural', fileCode: r.code, detail: r.detail };
|
|
291
|
+
}
|
|
292
|
+
/** Valid record or null. `unsafe` is deliberately collapsed to null here for
|
|
293
|
+
* the two callers that must merely refuse to act on it (invariant 2). */
|
|
294
|
+
function readLockRecord(boundary, path) {
|
|
295
|
+
const i = inspectLock(boundary, path);
|
|
296
|
+
return i.kind === 'valid' ? i.record : null;
|
|
297
|
+
}
|
|
298
|
+
/** Write `buffer` to `fd` in full, looping on short writes (a single
|
|
299
|
+
* `writeSync` call is never guaranteed to write the whole buffer). A
|
|
300
|
+
* non-positive byte count from the writer is a hard error, not something
|
|
301
|
+
* to retry forever. `writer` defaults to the real `writeSync` and is
|
|
302
|
+
* overridable only for deterministic fault injection in tests. */
|
|
303
|
+
function writeAll(fd, buffer, writer = writeSync) {
|
|
304
|
+
let offset = 0;
|
|
305
|
+
while (offset < buffer.length) {
|
|
306
|
+
const bytesWritten = writer(fd, buffer, offset, buffer.length - offset);
|
|
307
|
+
if (!(bytesWritten > 0)) {
|
|
308
|
+
throw new Error(`write returned ${bytesWritten} bytes with ${buffer.length - offset} of ${buffer.length} ` +
|
|
309
|
+
'bytes still unwritten');
|
|
310
|
+
}
|
|
311
|
+
offset += bytesWritten;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function stagingPath(lockPath, record) {
|
|
315
|
+
return `${lockPath}.creating.${record.pid}.${record.nonce}`;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Create `record`'s metadata as `lockPath`, safely.
|
|
319
|
+
*
|
|
320
|
+
* The metadata is written to a private staging file
|
|
321
|
+
* (`<lockPath>.creating.<pid>.<nonce>`) first, in full, and only
|
|
322
|
+
* *published* as `lockPath` — via `link()` — once that write (and, if
|
|
323
|
+
* `fsyncMetadata` is set, an fsync) has completely succeeded. `link()` fails
|
|
324
|
+
* with `EEXIST` if `lockPath` already exists, exactly like the
|
|
325
|
+
* `open(path, 'wx')` it replaces as the publish step, so it is exactly as
|
|
326
|
+
* exclusive a primitive — the canonical path still never has two
|
|
327
|
+
* simultaneous "creators" — but unlike writing straight into `lockPath`, a
|
|
328
|
+
* failure partway through can never leave the canonical path itself in an
|
|
329
|
+
* empty/partial state, because the canonical path was never touched by this
|
|
330
|
+
* attempt in the first place.
|
|
331
|
+
*
|
|
332
|
+
* `rename()` is deliberately not used for publication: an ordinary rename
|
|
333
|
+
* silently replaces an existing destination, which is exactly the
|
|
334
|
+
* exclusivity `open(path, 'wx')` is relied on for everywhere else in this
|
|
335
|
+
* module. Only `link()`'s EEXIST-on-existing-destination behavior
|
|
336
|
+
* preserves that.
|
|
337
|
+
*
|
|
338
|
+
* On any failure, only this process's own staging file is ever removed — and
|
|
339
|
+
* only while its recorded identity (`dev/ino`) still matches. `lockPath`
|
|
340
|
+
* itself is never touched by a failed creator, so a failed/paused creator can
|
|
341
|
+
* never delete a valid lock a different, concurrently-succeeding acquirer
|
|
342
|
+
* published in the meantime.
|
|
343
|
+
*
|
|
344
|
+
* If the publish step ends in a state that cannot be classified (`UNKNOWN`:
|
|
345
|
+
* the link may or may not have happened), the lock is confirmed ONLY by its
|
|
346
|
+
* ownership record — `lockPath` holding exactly this attempt's owner record.
|
|
347
|
+
* Otherwise the attempt fails with `publication: 'UNKNOWN'`; nothing is
|
|
348
|
+
* retried and the staging file is left in place.
|
|
349
|
+
*/
|
|
350
|
+
async function createAndPublish(parent, lockPath, record, fsyncMetadata, testHooks) {
|
|
351
|
+
const stagingName = basename(stagingPath(lockPath, record));
|
|
352
|
+
const lockName = basename(lockPath);
|
|
353
|
+
const metadata = Buffer.from(formatLockRecord(record), 'utf8');
|
|
354
|
+
const base = { lockPath, pid: record.pid, nonce: record.nonce };
|
|
355
|
+
let owned;
|
|
356
|
+
try {
|
|
357
|
+
owned = createExclusive(parent, stagingName, 0o600, { ops: testHooks.fsOps });
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
// The staging name is unique per (pid, nonce); reaching here means we
|
|
361
|
+
// could not even create our own private file. Normally nothing was
|
|
362
|
+
// created; `cleanedUp` is false only if a file exists whose identity we
|
|
363
|
+
// could not confirm.
|
|
364
|
+
const created = err instanceof SafeWriteError && err.tmp !== undefined;
|
|
365
|
+
throw new LockCreationError({
|
|
366
|
+
...base,
|
|
367
|
+
phase: 'staging-open',
|
|
368
|
+
cleanedUp: !created,
|
|
369
|
+
cause: err instanceof SafeWriteError ? (err.cause ?? err) : err,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const { fd, token } = owned;
|
|
373
|
+
const fail = async (phase, cause) => {
|
|
374
|
+
await testHooks.beforeStagingCleanupOnFailure?.();
|
|
375
|
+
const res = disposeOwned(parent, token, { ops: testHooks.fsOps });
|
|
376
|
+
throw new LockCreationError({ ...base, phase, cleanedUp: res === 'removed' || res === 'absent', cause });
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
fchmodSync(fd, 0o600); // independent of the umask
|
|
380
|
+
writeAll(fd, metadata, testHooks.writeMetadata);
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
closeSync(fd);
|
|
384
|
+
return fail('metadata-write', err);
|
|
385
|
+
}
|
|
386
|
+
if (fsyncMetadata) {
|
|
387
|
+
try {
|
|
388
|
+
fsyncSync(fd);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
closeSync(fd);
|
|
392
|
+
return fail('metadata-fsync', err);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
closeSync(fd);
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
result = publishLink(parent, token, lockName, { ops: testHooks.fsOps });
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
if (!(err instanceof SafeWriteError))
|
|
402
|
+
throw err;
|
|
403
|
+
if (err.publication === 'UNKNOWN') {
|
|
404
|
+
const seen = readLockRecord(boundaryPathOf(parent), lockPath);
|
|
405
|
+
if (seen !== null && sameRecord(seen, record)) {
|
|
406
|
+
// The record at the lock path is exactly ours: the link did happen.
|
|
407
|
+
disposeOwned(parent, token, { ops: testHooks.fsOps });
|
|
408
|
+
return 'published';
|
|
409
|
+
}
|
|
410
|
+
throw new LockCreationError({
|
|
411
|
+
...base,
|
|
412
|
+
phase: 'publish-link',
|
|
413
|
+
cleanedUp: false,
|
|
414
|
+
publication: 'UNKNOWN',
|
|
415
|
+
cause: err.cause ?? err,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
// NOT_PUBLISHED: the staging file was removed if the publish step failed
|
|
419
|
+
// (`err.tmp.removed`); an error raised before it (e.g. the parent changed)
|
|
420
|
+
// leaves it in place and is reported as `cleanedUp: false`.
|
|
421
|
+
throw new LockCreationError({
|
|
422
|
+
...base,
|
|
423
|
+
phase: 'publish-link',
|
|
424
|
+
cleanedUp: err.tmp?.removed ?? false,
|
|
425
|
+
cause: err.cause ?? err,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
// Lost race: only our own staging file is ever removed. Published:
|
|
429
|
+
// lockPath and staging are now two links to the same metadata; drop our
|
|
430
|
+
// temporary name, the canonical one remains. A failed removal is harmless.
|
|
431
|
+
disposeOwned(parent, token, { ops: testHooks.fsOps });
|
|
432
|
+
return result.outcome;
|
|
433
|
+
}
|
|
434
|
+
/** Delete `path` only if its current content is still, byte-for-byte, the
|
|
435
|
+
* record we believe we own. Never clears a lock that has changed or is
|
|
436
|
+
* already gone. This is used both for releasing a held primary lock and
|
|
437
|
+
* for releasing the internal recovery mutex. */
|
|
438
|
+
function releaseIfOwned(boundary, path, record) {
|
|
439
|
+
const current = readLockRecord(boundary, path);
|
|
440
|
+
if (sameRecord(current, record)) {
|
|
441
|
+
try {
|
|
442
|
+
unlinkSync(path);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// already gone — fine
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function recoveryMutexPath(lockPath) {
|
|
450
|
+
return `${lockPath}.recovery`;
|
|
451
|
+
}
|
|
452
|
+
/** `dev:ino` of what is at `path`, `null` if nothing is, `undefined` if that
|
|
453
|
+
* cannot be told (an error other than ENOENT). */
|
|
454
|
+
function fileIdentity(path) {
|
|
455
|
+
try {
|
|
456
|
+
const st = lstatSync(path, { bigint: true });
|
|
457
|
+
return `${st.dev}:${st.ino}`;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
return errno(err) === 'ENOENT' ? null : undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Attempt to recover a lock whose recorded owner was just confirmed dead.
|
|
465
|
+
*
|
|
466
|
+
* The read-confirm-delete sequence for a *specific* stale record is made
|
|
467
|
+
* exclusive by a recovery mutex (`<lockPath>.recovery`, itself just another
|
|
468
|
+
* `wx`-guarded file): a challenger must hold it for the entire re-verify
|
|
469
|
+
* step through the actual `unlink`. Because `open(path, 'wx')` is atomic,
|
|
470
|
+
* at most one challenger ever holds the recovery mutex for a given lock at
|
|
471
|
+
* a time — so at most one challenger is ever in a position to delete a
|
|
472
|
+
* given dead owner's record. A second challenger either fails to get the
|
|
473
|
+
* mutex (returns 'mutex-busy' — it does not block, it just backs off and
|
|
474
|
+
* lets the caller's normal wait/retry loop handle it) or, if it gets the
|
|
475
|
+
* mutex only after the first challenger has already finished, finds the
|
|
476
|
+
* lock file no longer matches what it originally observed and aborts
|
|
477
|
+
* without touching anything ('aborted'). Neither case ever deletes a lock
|
|
478
|
+
* that belongs to a new owner — see ADR-008 for the full argument.
|
|
479
|
+
*
|
|
480
|
+
* The recovery mutex itself is NEVER taken from another process based on
|
|
481
|
+
* age or liveness: if its holder crashes mid-recovery, the mutex simply
|
|
482
|
+
* stays held, and every future recovery attempt for this lock reports
|
|
483
|
+
* 'mutex-busy' until a human removes it by hand. This is a deliberate
|
|
484
|
+
* safety-over-availability trade-off (no recursive stale-mutex recovery)
|
|
485
|
+
* — it never corrupts state.json and never causes double ownership; it
|
|
486
|
+
* only means automatic crash recovery for *this* lock is unavailable
|
|
487
|
+
* until that's cleaned up.
|
|
488
|
+
*/
|
|
489
|
+
async function recoverDeadOwner(parent, lockPath, observed, fsyncMetadata, hooks) {
|
|
490
|
+
await hooks.beforeRecoveryAttempt?.();
|
|
491
|
+
const mutexPath = recoveryMutexPath(lockPath);
|
|
492
|
+
const mutexRecord = makeOwnRecord(hooks);
|
|
493
|
+
// The recovery mutex is created through the same safe staging/publish
|
|
494
|
+
// primitive as the primary lock — its semantics (never recovered by
|
|
495
|
+
// liveness or age, see module doc) are unchanged; only how it is
|
|
496
|
+
// physically created is shared code now.
|
|
497
|
+
if ((await createAndPublish(parent, mutexPath, mutexRecord, fsyncMetadata, hooks)) === 'lost-race') {
|
|
498
|
+
return { outcome: 'mutex-busy' };
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
// Re-verify from scratch now that we exclusively hold the recovery
|
|
502
|
+
// mutex — time has passed since the caller's own read, and this is
|
|
503
|
+
// the check that actually matters: nothing but the mutex holder can
|
|
504
|
+
// delete this specific stale record from here on.
|
|
505
|
+
const current = readLockRecord(boundaryPathOf(parent), lockPath);
|
|
506
|
+
if (current === null || !sameRecord(current, observed)) {
|
|
507
|
+
return { outcome: 'aborted', reason: 'lock changed or vanished while acquiring the recovery mutex' };
|
|
508
|
+
}
|
|
509
|
+
const judged = judgeOwner(current, ownIdentity(hooks.procOps), { ...realProcOps, ...hooks.procOps });
|
|
510
|
+
if (judged.verdict !== 'dead') {
|
|
511
|
+
return { outcome: 'aborted', reason: `owner no longer provably dead (${judged.verdict}: ${judged.reason})` };
|
|
512
|
+
}
|
|
513
|
+
const before = fileIdentity(lockPath);
|
|
514
|
+
await hooks.beforeStaleUnlink?.();
|
|
515
|
+
try {
|
|
516
|
+
(hooks.unlinkLock ?? unlinkSync)(lockPath);
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
// Already gone is fine. Any other failure is NOT recovery: report it so
|
|
520
|
+
// the caller backs off within its deadline instead of pretending progress.
|
|
521
|
+
if (errno(err) !== 'ENOENT')
|
|
522
|
+
return { outcome: 'unlink-failed', cause: err };
|
|
523
|
+
}
|
|
524
|
+
// A successful syscall is not proof that the lock is gone (some filesystems
|
|
525
|
+
// and fault layers report success without effect). Recovery is declared only
|
|
526
|
+
// from observation: the path is absent, or it now holds a different file.
|
|
527
|
+
const after = fileIdentity(lockPath);
|
|
528
|
+
if (after === undefined) {
|
|
529
|
+
return { outcome: 'unlink-failed', cause: new Error('could not observe the lock path after unlink') };
|
|
530
|
+
}
|
|
531
|
+
if (after !== null && after === before) {
|
|
532
|
+
return { outcome: 'unlink-failed', cause: new Error('unlink reported success but the same lock file is still present') };
|
|
533
|
+
}
|
|
534
|
+
return { outcome: 'recovered' };
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
releaseIfOwned(boundaryPathOf(parent), mutexPath, mutexRecord);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Upper bound of the delay between attempts, as a multiple of `retryDelayMs`. */
|
|
541
|
+
const BACKOFF_CAP_FACTOR = 8;
|
|
542
|
+
/** Consecutive iterations that may skip the wait after a verified recovery or a
|
|
543
|
+
* changed observation. Beyond this a pause is mandatory, so a filesystem that
|
|
544
|
+
* keeps "recovering" the same lock cannot turn the loop into a busy spin. */
|
|
545
|
+
const MAX_PROGRESS_WITHOUT_SLEEP = 3;
|
|
546
|
+
function describeFailure(err) {
|
|
547
|
+
const code = errno(err);
|
|
548
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
549
|
+
return code !== undefined && !msg.includes(code) ? `${code}: ${msg}` : msg;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Acquire `lockPath` exclusively. Waits up to `timeoutMs` for the current
|
|
553
|
+
* owner (if any) to release it, attempting liveness-gated, mutex-serialized
|
|
554
|
+
* recovery only when the owner is provably dead (`judgeOwner`). Returns a
|
|
555
|
+
* release function. `testHooks` exists solely to let tests force specific
|
|
556
|
+
* interleavings deterministically — production callers must never pass it.
|
|
557
|
+
*
|
|
558
|
+
* One monotonic deadline governs the whole call and is checked at the top of
|
|
559
|
+
* EVERY iteration, and the only `continue` sits at the tail after all
|
|
560
|
+
* bookkeeping, so no branch (recovery progress, aborted recovery, a failing
|
|
561
|
+
* `unlink`) can skip the check or the wait. Waits are a bounded exponential backoff with jitter in `[d/2, d]`,
|
|
562
|
+
* never longer than the time remaining. After a recovery the lock path is
|
|
563
|
+
* observed again rather than assumed free; at most
|
|
564
|
+
* `MAX_PROGRESS_WITHOUT_SLEEP` iterations in a row may run without waiting.
|
|
565
|
+
*
|
|
566
|
+
* The deadline bounds the waiting loop and the number of retries. It does not
|
|
567
|
+
* bound a single synchronous filesystem call that hangs inside the kernel
|
|
568
|
+
* (e.g. a stuck mount): no timer can run while it blocks.
|
|
569
|
+
*/
|
|
570
|
+
export async function acquireLock(lockPath, options = {}, testHooks = {}, parent = openParent(trustedDirectoryBoundary(dirname(lockPath)), dirname(lockPath))) {
|
|
571
|
+
// `parent` is the already-verified directory that holds the lock. State
|
|
572
|
+
// keeper passes the one it opened under the caller's write scope. When it
|
|
573
|
+
// is omitted (internal/test callers) the lock's directory itself is the
|
|
574
|
+
// trust boundary (trusted-directory semantics, ADR-012).
|
|
575
|
+
const opts = resolveOptions(options);
|
|
576
|
+
const now = testHooks.now ?? monotonicNow;
|
|
577
|
+
const random = testHooks.random ?? Math.random;
|
|
578
|
+
const wait = testHooks.sleep ?? sleep;
|
|
579
|
+
const started = now();
|
|
580
|
+
const deadline = started + opts.timeoutMs;
|
|
581
|
+
const own = makeOwnRecord(testHooks);
|
|
582
|
+
const ours = ownIdentity(testHooks.procOps);
|
|
583
|
+
const probeOps = { ...realProcOps, ...testHooks.procOps };
|
|
584
|
+
const baseDelay = Math.max(1, opts.retryDelayMs);
|
|
585
|
+
const maxDelay = baseDelay * BACKOFF_CAP_FACTOR;
|
|
586
|
+
let attempts = 0;
|
|
587
|
+
let delay = baseDelay;
|
|
588
|
+
let freeRuns = 0;
|
|
589
|
+
let lastKey = '';
|
|
590
|
+
let observation = 'lock is held';
|
|
591
|
+
let lastFailure;
|
|
592
|
+
let lastFailureText = '';
|
|
593
|
+
for (;;) {
|
|
594
|
+
if (attempts > 0 && now() >= deadline) {
|
|
595
|
+
const failure = lastFailureText ? `; last failure: ${lastFailureText}` : '';
|
|
596
|
+
throw new LockTimeoutError(`Timed out after ${opts.timeoutMs}ms (${attempts} attempts) waiting for lock at "${lockPath}" (${observation}${failure})`, { lastFailure });
|
|
597
|
+
}
|
|
598
|
+
attempts++;
|
|
599
|
+
if ((await createAndPublish(parent, lockPath, own, opts.fsyncMetadata, testHooks)) === 'published') {
|
|
600
|
+
return () => releaseIfOwned(boundaryPathOf(parent), lockPath, own);
|
|
601
|
+
}
|
|
602
|
+
// 'lost-race': the canonical lock already exists — observe it below.
|
|
603
|
+
const inspection = inspectLock(boundaryPathOf(parent), lockPath);
|
|
604
|
+
if (inspection.kind === 'unsafe' && inspection.class === 'structural') {
|
|
605
|
+
// A FIFO / oversize / unreadable lock can never become a valid one:
|
|
606
|
+
// refuse with a diagnostic instead of waiting out the timeout. The
|
|
607
|
+
// file is left exactly as found and the lock is not acquired.
|
|
608
|
+
throw new LockUnsafeError(lockPath, inspection.fileCode, inspection.detail);
|
|
609
|
+
}
|
|
610
|
+
// True only when this iteration verifiably changed the situation (a dead
|
|
611
|
+
// owner's lock was removed and observed gone); only then may the next
|
|
612
|
+
// iteration start without waiting.
|
|
613
|
+
let progressed = false;
|
|
614
|
+
// What we are waiting on, so the backoff restarts when it changes.
|
|
615
|
+
let key = inspection.kind;
|
|
616
|
+
if (inspection.kind === 'unsafe') {
|
|
617
|
+
observation = `${inspection.detail}; the file was left untouched`;
|
|
618
|
+
}
|
|
619
|
+
else if (inspection.kind === 'valid') {
|
|
620
|
+
key = `${inspection.record.pid}:${inspection.record.nonce}`;
|
|
621
|
+
const judged = judgeOwner(inspection.record, ours, probeOps);
|
|
622
|
+
observation = `owner pid ${inspection.record.pid} is ${judged.verdict} (${judged.reason})`;
|
|
623
|
+
if (judged.verdict === 'dead') {
|
|
624
|
+
const result = await recoverDeadOwner(parent, lockPath, inspection.record, opts.fsyncMetadata, testHooks);
|
|
625
|
+
if (result.outcome === 'recovered') {
|
|
626
|
+
progressed = true;
|
|
627
|
+
lastFailure = undefined;
|
|
628
|
+
lastFailureText = '';
|
|
629
|
+
}
|
|
630
|
+
else if (result.outcome === 'unlink-failed') {
|
|
631
|
+
lastFailure = result.cause;
|
|
632
|
+
lastFailureText = `could not remove the stale lock of dead owner pid ${inspection.record.pid} (${describeFailure(result.cause)})`;
|
|
633
|
+
}
|
|
634
|
+
else if (result.outcome === 'aborted') {
|
|
635
|
+
observation += `; recovery aborted: ${result.reason}`;
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
observation += '; another process holds the recovery mutex';
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// 'alive' or 'unknown': invariant 2 — do not recover.
|
|
642
|
+
}
|
|
643
|
+
// inspection absent: the file vanished — try creating it again after the
|
|
644
|
+
// same waiting/backoff discipline as a confirmed-busy lock.
|
|
645
|
+
if (key !== lastKey) {
|
|
646
|
+
delay = baseDelay;
|
|
647
|
+
lastKey = key;
|
|
648
|
+
}
|
|
649
|
+
if (progressed && freeRuns < MAX_PROGRESS_WITHOUT_SLEEP) {
|
|
650
|
+
freeRuns++;
|
|
651
|
+
continue; // the deadline check at the top still runs
|
|
652
|
+
}
|
|
653
|
+
freeRuns = 0;
|
|
654
|
+
const remaining = Math.max(0, deadline - now());
|
|
655
|
+
const jittered = delay / 2 + random() * (delay / 2);
|
|
656
|
+
await wait(Math.min(jittered, remaining));
|
|
657
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
658
|
+
}
|
|
659
|
+
}
|