@tiphys/kernel 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
package/dist/src/lock.js
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { linkSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { buildRegisterDocument, casFailureLine, casWrite, ensureEnvironmentId, fleetRootForLockPath, preflightShared, readSharedExclusion, } from "./exclusion.js";
|
|
6
|
+
import { readRegularPathIfPresent } from "./fleet.js";
|
|
7
|
+
/**
|
|
8
|
+
* Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
|
|
9
|
+
* constraint C-2). One orchestrator per fleet: the lock file at
|
|
10
|
+
* state/orchestrator.lock holds a JSON lease {holderId, hostname,
|
|
11
|
+
* acquiredAt, expiresAt, durationSeconds, token} where holderId is an
|
|
12
|
+
* opaque value generated at acquire. Liveness is lease freshness only:
|
|
13
|
+
* this module never probes any running program in any way (FM-053), and
|
|
14
|
+
* the death of a holder is deliberately invisible to the lease (an
|
|
15
|
+
* unexpired lease excludes even when its holder is gone; recovery is the
|
|
16
|
+
* explicit --take-over after expiry).
|
|
17
|
+
*
|
|
18
|
+
* Mutation contract (EXT-F-01, adopted verbatim from the plan):
|
|
19
|
+
* - Every mutation (acquire, renew, release, takeover) goes through the
|
|
20
|
+
* ONE shared atomic mutation primitive, applyLeaseMutation. A mutation
|
|
21
|
+
* is decided against an observed lease state and applied only if the
|
|
22
|
+
* file still holds exactly that state; the application is confirmed by
|
|
23
|
+
* re-reading the unique per-mutation token it wrote. A confirmation
|
|
24
|
+
* showing another writer's token means the mutation lost and returns
|
|
25
|
+
* failure without retry. Ownership is not valid until the primitive
|
|
26
|
+
* completes.
|
|
27
|
+
* - Renew fails on an expired lease and succeeds only while holderId
|
|
28
|
+
* matches and expiresAt is in the future.
|
|
29
|
+
* - Takeover succeeds only if the observed lease is still the lease being
|
|
30
|
+
* replaced (compare-and-swap over the lease file content) and
|
|
31
|
+
* serializes with renew, release, and competing takeovers.
|
|
32
|
+
*
|
|
33
|
+
* Serialization mechanism: mutations are serialized through a claim file
|
|
34
|
+
* beside the lock (<lock>.mutex, created O_EXCL; FM-022's
|
|
35
|
+
* serialize-through-a-claim pattern), and inside that claim the primitive
|
|
36
|
+
* re-reads the lock, byte-compares it with the observed state, applies
|
|
37
|
+
* via O_EXCL create (absent lock, PR-006) or write-temp-then-rename, and
|
|
38
|
+
* confirms by re-reading its own token (FM-022 write-then-verify). No
|
|
39
|
+
* steal protocol exists on purpose (FM-058): a claim file left behind by
|
|
40
|
+
* a crashed mutation makes later mutations fail loudly after a bounded
|
|
41
|
+
* wait, naming the file for manual removal; the critical section is a
|
|
42
|
+
* few file operations, so this window is tiny.
|
|
43
|
+
*
|
|
44
|
+
* What the token confirmation is and is NOT (corrected per D-2; the
|
|
45
|
+
* previous wording here claimed a second safety net that does not
|
|
46
|
+
* exist). The confirmation read asserts only "my bytes are in the file
|
|
47
|
+
* now". That is last-writer-wins: it catches an intruder who applied
|
|
48
|
+
* AFTER this mutation, and it does NOT catch one who applied before and
|
|
49
|
+
* merely lost the race to write last. The O_EXCL claim file is
|
|
50
|
+
* therefore the sole serializer, and it is advisory: no handle is held
|
|
51
|
+
* on it and nothing checks ownership when it is unlinked. The apply is
|
|
52
|
+
* preceded by a second read-and-compare (stillMatches) so that a lost
|
|
53
|
+
* claim degrades to a clean loss instead of a double win, but that
|
|
54
|
+
* narrows the window rather than closing it. Deleting a live claim file
|
|
55
|
+
* can still produce two holders; that is why the CLI's remedy text now
|
|
56
|
+
* says so instead of inviting it.
|
|
57
|
+
*
|
|
58
|
+
* Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
|
|
59
|
+
* within one filesystem and one clock, the fleet home the lock file lives
|
|
60
|
+
* in. Mutations of the lock file made outside this module (manual edits)
|
|
61
|
+
* are not covered by the contract.
|
|
62
|
+
*
|
|
63
|
+
* CROSS-ENVIRONMENT EXCLUSION IS A SECOND LAYER ABOVE THIS ONE (M4-P21),
|
|
64
|
+
* and it is OFF unless the fleet home declares it. When the fleet's own
|
|
65
|
+
* `package.json` carries `tiphys.sharedExclusion`, every mutation below
|
|
66
|
+
* first asks `src/exclusion.ts` for a verdict from the shared register on
|
|
67
|
+
* the fleet's git remote, and only a won verdict reaches the local lease.
|
|
68
|
+
* With the field ABSENT, `readSharedExclusion` returns before spawning
|
|
69
|
+
* anything and every path in this module behaves exactly as it did, which
|
|
70
|
+
* is the property M4-P21 criterion 1 asserts. The refusal never touches the
|
|
71
|
+
* lock file, which is what makes the fail-closed behaviour on an
|
|
72
|
+
* unreachable register observable (criterion 7): no local lease appears.
|
|
73
|
+
*
|
|
74
|
+
* Renewal discipline (PR-203): the default lease lasts 900 seconds and
|
|
75
|
+
* the holder renews at or before half-life (renewByMs). Holdership on
|
|
76
|
+
* mutating kernel commands (spawn, teardown) is verified against the
|
|
77
|
+
* lease by M1-P4.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFAULT_LEASE_DURATION_SECONDS = 900;
|
|
80
|
+
/** Bounded wait for the mutation claim file, then fail loudly. */
|
|
81
|
+
const MUTEX_WAIT_TOTAL_MS = 5000;
|
|
82
|
+
const MUTEX_WAIT_POLL_MS = 10;
|
|
83
|
+
function parseLease(raw) {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(raw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const candidate = parsed;
|
|
92
|
+
if (typeof candidate.holderId !== "string" ||
|
|
93
|
+
candidate.holderId === "" ||
|
|
94
|
+
typeof candidate.hostname !== "string" ||
|
|
95
|
+
typeof candidate.acquiredAt !== "string" ||
|
|
96
|
+
typeof candidate.expiresAt !== "string" ||
|
|
97
|
+
typeof candidate.durationSeconds !== "number" ||
|
|
98
|
+
typeof candidate.token !== "string" ||
|
|
99
|
+
Number.isNaN(Date.parse(candidate.expiresAt))) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
export function renderLease(lease) {
|
|
105
|
+
return `${JSON.stringify(lease, null, 2)}\n`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read the current lock file state: absent, or present with raw bytes.
|
|
109
|
+
*
|
|
110
|
+
* THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN (T-008's shape in shipped
|
|
111
|
+
* code). A bare `readFileSync` here blocked FOREVER with zero output on a
|
|
112
|
+
* named pipe at the lease path, and took `lock status`, `lock acquire`,
|
|
113
|
+
* `lock renew` and `lock release` with it, while `tiphys doctor` returned in
|
|
114
|
+
* the same second against the same FIFO with "is a named pipe, not a regular
|
|
115
|
+
* file, so it was not opened". Two readers of one path, one of which
|
|
116
|
+
* established the type; this is now the same reader.
|
|
117
|
+
*
|
|
118
|
+
* A refusal THROWS rather than returning a fourth `ObservedLease` variant.
|
|
119
|
+
* The function already threw on every non-ENOENT error, so the contract its
|
|
120
|
+
* callers were written against is unchanged, and bin/tiphys.ts turns the
|
|
121
|
+
* throw into one diagnostic line and a nonzero exit.
|
|
122
|
+
*/
|
|
123
|
+
export function observeLease(lockPath) {
|
|
124
|
+
const read = readRegularPathIfPresent(lockPath);
|
|
125
|
+
if (read.kind === "absent") {
|
|
126
|
+
return { kind: "absent" };
|
|
127
|
+
}
|
|
128
|
+
if (read.kind === "refused") {
|
|
129
|
+
throw new Error(read.reason);
|
|
130
|
+
}
|
|
131
|
+
return { kind: "present", raw: read.body, lease: parseLease(read.body) };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* THE ONE EXPIRY COMPARISON IN THE KERNEL (M4-P17 criterion 2).
|
|
135
|
+
*
|
|
136
|
+
* `isExpired` below needs a whole `Lease`, and doctor's lock check does not
|
|
137
|
+
* have one: it reads the lease file defensively and holds only `holderId` and
|
|
138
|
+
* `expiresAt`, because a lease file that fails `parseLease` must still produce
|
|
139
|
+
* a diagnosis rather than nothing. Before this phase that forced doctor to
|
|
140
|
+
* carry its own `Date.parse(...) <= Date.now()`, which is a SECOND comparison
|
|
141
|
+
* of the same property, free to drift from this one. The boundary is where
|
|
142
|
+
* that drift shows: `<=` makes expiry INCLUSIVE, so a lease whose `expiresAt`
|
|
143
|
+
* is exactly the current millisecond is expired, and a second copy written
|
|
144
|
+
* with `<` disagrees for exactly one millisecond and agrees everywhere else.
|
|
145
|
+
* A disagreement that narrow is not something a reviewer finds by reading.
|
|
146
|
+
*
|
|
147
|
+
* So the comparison lives here once and both callers reach it.
|
|
148
|
+
*/
|
|
149
|
+
export function expiryHasPassed(expiresAt, nowMs) {
|
|
150
|
+
return Date.parse(expiresAt) <= nowMs;
|
|
151
|
+
}
|
|
152
|
+
export function isExpired(lease, nowMs) {
|
|
153
|
+
return expiryHasPassed(lease.expiresAt, nowMs);
|
|
154
|
+
}
|
|
155
|
+
/** The renew-by deadline (half-life of the current term), for holders. */
|
|
156
|
+
export function renewByMs(lease) {
|
|
157
|
+
return Date.parse(lease.expiresAt) - (lease.durationSeconds * 1000) / 2;
|
|
158
|
+
}
|
|
159
|
+
/** The same guarded read as observeLease, for the inside of the claim. */
|
|
160
|
+
function readCurrent(lockPath) {
|
|
161
|
+
const read = readRegularPathIfPresent(lockPath);
|
|
162
|
+
if (read.kind === "absent") {
|
|
163
|
+
return { present: false, raw: "" };
|
|
164
|
+
}
|
|
165
|
+
if (read.kind === "refused") {
|
|
166
|
+
throw new Error(read.reason);
|
|
167
|
+
}
|
|
168
|
+
return { present: true, raw: read.body };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Re-read the lock file and re-compare it against the state a mutation
|
|
172
|
+
* was decided on. Used immediately before every apply (D-2).
|
|
173
|
+
*/
|
|
174
|
+
function stillMatches(lockPath, observed) {
|
|
175
|
+
const current = readCurrent(lockPath);
|
|
176
|
+
if (observed.kind === "absent") {
|
|
177
|
+
return !current.present;
|
|
178
|
+
}
|
|
179
|
+
return current.present && current.raw === observed.raw;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The single staging path a lease rename goes through (CR-202). One
|
|
183
|
+
* fixed name is safe because staging only ever happens inside the
|
|
184
|
+
* mutation claim, and it makes strand cleanup deterministic.
|
|
185
|
+
*/
|
|
186
|
+
export function stagePathFor(lockPath) {
|
|
187
|
+
return `${lockPath}.stage`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The one shared atomic mutation primitive (EXT-F-01). Applies next (new
|
|
191
|
+
* file content, or null to remove the lock) only if the lock file still
|
|
192
|
+
* holds exactly the observed state, and confirms the application by
|
|
193
|
+
* re-reading the mutation's own token. Loses without retry otherwise.
|
|
194
|
+
*/
|
|
195
|
+
export async function applyLeaseMutation(lockPath, observed, next, token) {
|
|
196
|
+
const mutexPath = `${lockPath}.mutex`;
|
|
197
|
+
const deadline = Date.now() + MUTEX_WAIT_TOTAL_MS;
|
|
198
|
+
for (;;) {
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(mutexPath, token, { flag: "wx" });
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error.code !== "EEXIST") {
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
if (Date.now() >= deadline) {
|
|
208
|
+
// CR-204: name the lease situation first, so an operator is not
|
|
209
|
+
// nudged toward a takeover when the obstacle is a claim file and
|
|
210
|
+
// there is no lease at all.
|
|
211
|
+
// The remedy sentence is deliberately NOT part of this reason:
|
|
212
|
+
// the CLI appends it from the claimTimeout flag, so the
|
|
213
|
+
// classification is load-bearing at every layer rather than
|
|
214
|
+
// carried along as prose (CR-204).
|
|
215
|
+
// U-6: an expired lease is not a held one. Saying "lock held"
|
|
216
|
+
// here contradicted lock status in the same fleet, which calls
|
|
217
|
+
// the same lease expired, and reads as "the holder is alive"
|
|
218
|
+
// when the truth is the opposite.
|
|
219
|
+
const holder = observed.kind === "present" && observed.lease !== undefined
|
|
220
|
+
? isExpired(observed.lease, Date.now())
|
|
221
|
+
? `expired lease from ${observed.lease.holderId}`
|
|
222
|
+
: `lock held by ${observed.lease.holderId}`
|
|
223
|
+
: "no lease, no live holder";
|
|
224
|
+
return {
|
|
225
|
+
won: false,
|
|
226
|
+
claimTimeout: true,
|
|
227
|
+
reason: `${holder}; stale claim file ${mutexPath} blocking after ` +
|
|
228
|
+
`${String(MUTEX_WAIT_TOTAL_MS)}ms`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
await sleep(MUTEX_WAIT_POLL_MS);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
// CR-202: clear any stranded stage left by a mutation that died
|
|
236
|
+
// between its stage write and its rename. This runs inside the
|
|
237
|
+
// claim, so it provably cannot race a live mutation (a live one
|
|
238
|
+
// would hold the claim), which is why no age heuristic is needed
|
|
239
|
+
// or wanted. It is unconditional because the release path (unlink)
|
|
240
|
+
// and the absent-lock acquire path (O_EXCL create) never touch the
|
|
241
|
+
// stage: cleaning only in the rename branch would let a strand
|
|
242
|
+
// survive a release/acquire-only sequence indefinitely.
|
|
243
|
+
try {
|
|
244
|
+
unlinkSync(stagePathFor(lockPath));
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// No strand present, which is the normal case.
|
|
248
|
+
}
|
|
249
|
+
const current = readCurrent(lockPath);
|
|
250
|
+
if (observed.kind === "absent") {
|
|
251
|
+
if (current.present) {
|
|
252
|
+
return {
|
|
253
|
+
won: false,
|
|
254
|
+
reason: "lost: a lease appeared after this mutation observed none",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (!current.present) {
|
|
259
|
+
return {
|
|
260
|
+
won: false,
|
|
261
|
+
reason: "lost: the observed lease is gone",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
else if (current.raw !== observed.raw) {
|
|
265
|
+
return {
|
|
266
|
+
won: false,
|
|
267
|
+
reason: "lost: the lease changed after this mutation observed it",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// D-2: re-read and re-compare immediately before every apply. The
|
|
271
|
+
// claim file is the ONLY serializer, and it is advisory: no handle
|
|
272
|
+
// is held on it, and the operator remedy this CLI prints tells a
|
|
273
|
+
// human to delete it. If a claim is lost that way, another mutation
|
|
274
|
+
// can enter this section concurrently; without this second compare
|
|
275
|
+
// both could apply and both could believe they won. With it, the
|
|
276
|
+
// loser sees changed bytes and degrades to a clean loss. This
|
|
277
|
+
// narrows the window to the syscall gap; it does not remove it, and
|
|
278
|
+
// the module docs say so rather than claiming a guarantee.
|
|
279
|
+
if (!stillMatches(lockPath, observed)) {
|
|
280
|
+
return {
|
|
281
|
+
won: false,
|
|
282
|
+
reason: "lost: the lease changed while this mutation held the claim",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (next === null) {
|
|
286
|
+
unlinkSync(lockPath);
|
|
287
|
+
const confirm = readCurrent(lockPath);
|
|
288
|
+
if (confirm.present) {
|
|
289
|
+
return { won: false, reason: "lost: removal did not stick" };
|
|
290
|
+
}
|
|
291
|
+
return { won: true };
|
|
292
|
+
}
|
|
293
|
+
if (observed.kind === "absent") {
|
|
294
|
+
// D-1: publish the initial lease atomically. writeFileSync with
|
|
295
|
+
// flag "wx" is openSync(O_EXCL) followed by a SEPARATE writeSync,
|
|
296
|
+
// so the lock file's NAME becomes visible at length zero before
|
|
297
|
+
// the lease bytes land. observeLease, leaseStatus and doctor all
|
|
298
|
+
// read outside the claim, so a reader landing in that window sees
|
|
299
|
+
// an empty file and reports a healthy fleet as corrupt; it turned
|
|
300
|
+
// acceptance criterion 3's own witness red on pristine code.
|
|
301
|
+
//
|
|
302
|
+
// linkSync gives both properties at once: the stage file already
|
|
303
|
+
// holds the complete lease, and link fails with EEXIST if the
|
|
304
|
+
// lock path exists, which is exactly the atomic exclusive-create
|
|
305
|
+
// test PR-006 asks for. So exclusion is preserved and the name
|
|
306
|
+
// never exists half-published. renew and takeover were already
|
|
307
|
+
// immune because they stage then rename; this makes the absent
|
|
308
|
+
// lock path use the same discipline, which is the asymmetry the
|
|
309
|
+
// module previously left unjustified.
|
|
310
|
+
const stagePath = stagePathFor(lockPath);
|
|
311
|
+
/* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
|
|
312
|
+
measurement rather than an oversight. open(2) for writing on a FIFO
|
|
313
|
+
blocks exactly as reading one does, so this looked like a member of
|
|
314
|
+
the class this round closed. It is not: the claim-held sweep above
|
|
315
|
+
(CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
|
|
316
|
+
writes it, so a planted FIFO is gone by the time this line runs.
|
|
317
|
+
Measured at this head with a real mkfifo at
|
|
318
|
+
`state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
|
|
319
|
+
under a second and the lease is taken. A guard here would therefore
|
|
320
|
+
be code no witness can redden. */
|
|
321
|
+
writeFileSync(stagePath, next);
|
|
322
|
+
try {
|
|
323
|
+
linkSync(stagePath, lockPath);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error.code === "EEXIST") {
|
|
327
|
+
return {
|
|
328
|
+
won: false,
|
|
329
|
+
reason: "lost: a lease appeared after this mutation observed none",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
try {
|
|
336
|
+
unlinkSync(stagePath);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// The stage sweep at the top of the claim also covers this.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// The stage is ONE fixed path beside the lock (CR-202). Every
|
|
345
|
+
// stage write happens inside the claim, so no two stages can ever
|
|
346
|
+
// coexist and a fixed name needs no uniqueness to be safe; the
|
|
347
|
+
// claim-held sweep above then makes cleanup of a crash strand
|
|
348
|
+
// deterministic (exactly one possible strand path, removed
|
|
349
|
+
// unconditionally) instead of an age-based guess over a family of
|
|
350
|
+
// unique names. The CAS is untouched by this choice: the
|
|
351
|
+
// byte-compare against the observed state and the token
|
|
352
|
+
// confirmation read both still happen inside the claim, and the
|
|
353
|
+
// rename remains atomic within one directory.
|
|
354
|
+
const stagePath = stagePathFor(lockPath);
|
|
355
|
+
/* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
|
|
356
|
+
measurement rather than an oversight. open(2) for writing on a FIFO
|
|
357
|
+
blocks exactly as reading one does, so this looked like a member of
|
|
358
|
+
the class this round closed. It is not: the claim-held sweep above
|
|
359
|
+
(CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
|
|
360
|
+
writes it, so a planted FIFO is gone by the time this line runs.
|
|
361
|
+
Measured at this head with a real mkfifo at
|
|
362
|
+
`state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
|
|
363
|
+
under a second and the lease is taken. A guard here would therefore
|
|
364
|
+
be code no witness can redden. */
|
|
365
|
+
writeFileSync(stagePath, next);
|
|
366
|
+
try {
|
|
367
|
+
renameSync(stagePath, lockPath);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
try {
|
|
371
|
+
unlinkSync(stagePath);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Stage cleanup is best effort; the original error surfaces.
|
|
375
|
+
}
|
|
376
|
+
if (error.code === "ENOENT") {
|
|
377
|
+
// Another mutation swept this stage, which means it entered
|
|
378
|
+
// the critical section concurrently, which means this claim
|
|
379
|
+
// was lost (D-2). Report it as a loss rather than letting a
|
|
380
|
+
// raw ENOENT stack out of the CLI.
|
|
381
|
+
return {
|
|
382
|
+
won: false,
|
|
383
|
+
reason: "lost: the staged lease disappeared before it was published, " +
|
|
384
|
+
"which means another mutation held the claim concurrently",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const confirm = readCurrent(lockPath);
|
|
391
|
+
const confirmedLease = confirm.present ? parseLease(confirm.raw) : undefined;
|
|
392
|
+
if (confirmedLease === undefined || confirmedLease.token !== token) {
|
|
393
|
+
return {
|
|
394
|
+
won: false,
|
|
395
|
+
reason: "lost: confirmation read shows another writer's token",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return { won: true };
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
try {
|
|
402
|
+
unlinkSync(mutexPath);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Claim file already gone; nothing to release.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the fleet home's declaration. The ONLY entry into the shared
|
|
411
|
+
* layer, and the only place a fleet root is derived: a lock path is
|
|
412
|
+
* `<root>/state/orchestrator.lock` (src/fleet.ts:37), so the root is two
|
|
413
|
+
* directories up unless a caller names it. A declaration that is present
|
|
414
|
+
* and unusable is reported rather than treated as absent, because reading a
|
|
415
|
+
* typo as "off" is the failure that would make this layer green everywhere.
|
|
416
|
+
*/
|
|
417
|
+
function sharedGate(lockPath, fleetRoot) {
|
|
418
|
+
const root = fleetRoot ?? fleetRootForLockPath(lockPath);
|
|
419
|
+
const declaration = readSharedExclusion(root);
|
|
420
|
+
if (declaration.kind === "absent") {
|
|
421
|
+
return { kind: "off" };
|
|
422
|
+
}
|
|
423
|
+
if (declaration.kind === "invalid") {
|
|
424
|
+
return { kind: "invalid", reason: declaration.reason };
|
|
425
|
+
}
|
|
426
|
+
const identity = ensureEnvironmentId(root);
|
|
427
|
+
return {
|
|
428
|
+
kind: "on",
|
|
429
|
+
ctx: { config: declaration.config, envId: identity.envId, fleetRoot: root },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function invalidDeclarationOutcome(reason) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
reason: `shared exclusion is declared and unusable, refusing rather than running ` +
|
|
436
|
+
`local-only: ${reason}`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/** Ask the shared register for a verdict, without touching anything. */
|
|
440
|
+
function sharedPreflight(ctx, intent, takeover, nowMs) {
|
|
441
|
+
return preflightShared({
|
|
442
|
+
fleetRoot: ctx.fleetRoot,
|
|
443
|
+
config: ctx.config,
|
|
444
|
+
envId: ctx.envId,
|
|
445
|
+
intent,
|
|
446
|
+
takeover,
|
|
447
|
+
nowMs,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function sharedNote(ctx, preflight) {
|
|
451
|
+
return { line: preflight.line, envId: ctx.envId };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Publish the won verdict to the register. Called only AFTER the local
|
|
455
|
+
* mutation succeeded, so the two layers agree or the local one is rolled
|
|
456
|
+
* back by the caller: a register entry with no local lease behind it would
|
|
457
|
+
* exclude every environment including the one that wrote it.
|
|
458
|
+
*/
|
|
459
|
+
function sharedCommit(ctx, preflight, state, nowMs, durationSeconds) {
|
|
460
|
+
const document = buildRegisterDocument({
|
|
461
|
+
state,
|
|
462
|
+
envId: ctx.envId,
|
|
463
|
+
counter: preflight.nextCounter,
|
|
464
|
+
nowMs,
|
|
465
|
+
durationSeconds,
|
|
466
|
+
ref: ctx.config.ref,
|
|
467
|
+
...(preflight.current !== undefined && state === "held" && !preflight.takingOver
|
|
468
|
+
? { acquiredAt: preflight.current.acquiredAt }
|
|
469
|
+
: {}),
|
|
470
|
+
});
|
|
471
|
+
const outcome = casWrite(ctx.fleetRoot, ctx.config, preflight.expectedSha, document);
|
|
472
|
+
if (outcome.kind === "won") {
|
|
473
|
+
return { ok: true, sha: outcome.sha };
|
|
474
|
+
}
|
|
475
|
+
return { ok: false, reason: casFailureLine(ctx.config.ref, outcome) };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* UNDO A LOCAL LEASE MUTATION WHOSE REGISTER PUBLISH LOST.
|
|
479
|
+
*
|
|
480
|
+
* `current` is what the lock file holds NOW (the state this mutation left),
|
|
481
|
+
* and `restore` is the observation the mutation was decided against, whose
|
|
482
|
+
* raw bytes are written back verbatim so the restored file is BYTE-IDENTICAL
|
|
483
|
+
* to the one the command claimed not to have changed.
|
|
484
|
+
*
|
|
485
|
+
* The token handed to the primitive is the RESTORED lease's own token, not a
|
|
486
|
+
* fresh one: the primitive confirms an application by re-reading the token in
|
|
487
|
+
* the file it just wrote, and a fresh token would fail that confirmation
|
|
488
|
+
* against bytes that carry the old one.
|
|
489
|
+
*
|
|
490
|
+
* Returns the sentence appended to the failure line. A rollback that ITSELF
|
|
491
|
+
* loses is stated rather than swallowed, because "the local lease was
|
|
492
|
+
* restored" is a claim a later reader will act on.
|
|
493
|
+
*/
|
|
494
|
+
async function restoreLocal(lockPath, current, restore) {
|
|
495
|
+
if (restore.lease === undefined) {
|
|
496
|
+
return "; the local lease could NOT be restored: the observed lease does not parse";
|
|
497
|
+
}
|
|
498
|
+
const undone = await applyLeaseMutation(lockPath, current, restore.raw, restore.lease.token);
|
|
499
|
+
return undone.won
|
|
500
|
+
? "; the local lease was restored"
|
|
501
|
+
: `; the local lease could NOT be restored (${undone.reason}), so ${lockPath} and the register now disagree`;
|
|
502
|
+
}
|
|
503
|
+
function buildLease(nowMs, durationSeconds) {
|
|
504
|
+
return {
|
|
505
|
+
holderId: randomUUID(),
|
|
506
|
+
hostname: hostname(),
|
|
507
|
+
acquiredAt: new Date(nowMs).toISOString(),
|
|
508
|
+
expiresAt: new Date(nowMs + durationSeconds * 1000).toISOString(),
|
|
509
|
+
durationSeconds,
|
|
510
|
+
token: randomUUID(),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Acquire the lease, or take over an expired one when takeover is set.
|
|
515
|
+
* Refusals (lock held, expired without takeover, unexpired takeover)
|
|
516
|
+
* never mutate the file; only won mutations do.
|
|
517
|
+
*/
|
|
518
|
+
export async function acquireLease(lockPath, options = {}) {
|
|
519
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
520
|
+
const durationSeconds = options.durationSeconds ?? DEFAULT_LEASE_DURATION_SECONDS;
|
|
521
|
+
/* THE SHARED LAYER RUNS FIRST AND MUTATES NOTHING (M4-P21 criteria 1 and
|
|
522
|
+
7). Off unless declared, so the read below is the whole cost for every
|
|
523
|
+
fleet that has not opted in. A refusal returns here, before the local
|
|
524
|
+
lease file can be created, which is the fail-closed property: an
|
|
525
|
+
unreachable register leaves no local lock behind to be mistaken for
|
|
526
|
+
exclusion that is not there. */
|
|
527
|
+
const gate = sharedGate(lockPath, options.fleetRoot);
|
|
528
|
+
if (gate.kind === "invalid") {
|
|
529
|
+
return invalidDeclarationOutcome(gate.reason);
|
|
530
|
+
}
|
|
531
|
+
let preflight;
|
|
532
|
+
if (gate.kind === "on") {
|
|
533
|
+
preflight = sharedPreflight(gate.ctx, "acquire", options.takeover === true, nowMs);
|
|
534
|
+
if (preflight.kind === "refused") {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
reason: preflight.line,
|
|
538
|
+
shared: sharedNote(gate.ctx, preflight),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const observed = options.observed ?? observeLease(lockPath);
|
|
543
|
+
if (observed.kind === "present") {
|
|
544
|
+
if (observed.lease === undefined) {
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
reason: `lease file ${lockPath} is corrupt; inspect it manually`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (!isExpired(observed.lease, nowMs)) {
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
reason: options.takeover === true
|
|
554
|
+
? `takeover refused: lock held by ${observed.lease.holderId}, unexpired until ${observed.lease.expiresAt}`
|
|
555
|
+
: `lock held by ${observed.lease.holderId}, expires ${observed.lease.expiresAt}`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (options.takeover !== true) {
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
reason: `lease expired (holder ${observed.lease.holderId}, expired ` +
|
|
562
|
+
`${observed.lease.expiresAt}); acquire refused, takeover is ` +
|
|
563
|
+
`explicit: lock acquire --take-over`,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const lease = buildLease(nowMs, durationSeconds);
|
|
568
|
+
const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
|
|
569
|
+
if (!result.won) {
|
|
570
|
+
// CR-204: a claim-file timeout is not a held lease. Its reason
|
|
571
|
+
// already states the lease situation, so it is never re-prefixed
|
|
572
|
+
// with "lock held", and the classification travels to the caller.
|
|
573
|
+
if (result.claimTimeout === true) {
|
|
574
|
+
return { ok: false, reason: result.reason, claimTimeout: true };
|
|
575
|
+
}
|
|
576
|
+
return { ok: false, reason: `lock held (${result.reason})` };
|
|
577
|
+
}
|
|
578
|
+
if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
|
|
579
|
+
const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
|
|
580
|
+
if (!published.ok) {
|
|
581
|
+
/* The register refused after the local lease was written, so the local
|
|
582
|
+
lease is rolled back through the SAME mutation primitive. Leaving it
|
|
583
|
+
would make this environment believe it holds a fleet another
|
|
584
|
+
environment holds, which is the exact state this layer exists to
|
|
585
|
+
prevent. */
|
|
586
|
+
await applyLeaseMutation(lockPath, { kind: "present", raw: renderLease(lease), lease }, null, randomUUID());
|
|
587
|
+
const line = `shared exclusion refused acquire: ${published.reason}; the local lease was rolled back`;
|
|
588
|
+
return {
|
|
589
|
+
ok: false,
|
|
590
|
+
reason: line,
|
|
591
|
+
shared: { line, envId: gate.ctx.envId },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
ok: true,
|
|
596
|
+
lease,
|
|
597
|
+
shared: {
|
|
598
|
+
line: `${preflight.line}; register now ${published.sha}`,
|
|
599
|
+
envId: gate.ctx.envId,
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { ok: true, lease };
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Renew the lease held by holderId. Fails on an expired lease even when
|
|
607
|
+
* holderId matches (EXT-F-01: a paused holder whose lease expired cannot
|
|
608
|
+
* renew), fails on a holder mismatch, and never mutates the file on any
|
|
609
|
+
* failure. The new expiry strictly increases (a renew that does not
|
|
610
|
+
* extend the lease would be useless).
|
|
611
|
+
*/
|
|
612
|
+
export async function renewLease(lockPath, holderId, options = {}) {
|
|
613
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
614
|
+
const gate = sharedGate(lockPath, options.fleetRoot);
|
|
615
|
+
if (gate.kind === "invalid") {
|
|
616
|
+
return invalidDeclarationOutcome(gate.reason);
|
|
617
|
+
}
|
|
618
|
+
let preflight;
|
|
619
|
+
if (gate.kind === "on") {
|
|
620
|
+
preflight = sharedPreflight(gate.ctx, "renew", false, nowMs);
|
|
621
|
+
if (preflight.kind === "refused") {
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
reason: preflight.line,
|
|
625
|
+
shared: sharedNote(gate.ctx, preflight),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const observed = options.observed ?? observeLease(lockPath);
|
|
630
|
+
if (observed.kind === "absent") {
|
|
631
|
+
return { ok: false, reason: "renew refused: no lease present" };
|
|
632
|
+
}
|
|
633
|
+
if (observed.lease === undefined) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
reason: `lease file ${lockPath} is corrupt; inspect it manually`,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (observed.lease.holderId !== holderId) {
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
reason: `renew refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
if (isExpired(observed.lease, nowMs)) {
|
|
646
|
+
return {
|
|
647
|
+
ok: false,
|
|
648
|
+
reason: `renew refused: lease expired ${observed.lease.expiresAt}; an ` +
|
|
649
|
+
`expired lease cannot be renewed, re-acquire or take over instead`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const durationSeconds = options.durationSeconds ?? observed.lease.durationSeconds;
|
|
653
|
+
const newExpiresMs = Math.max(nowMs + durationSeconds * 1000, Date.parse(observed.lease.expiresAt) + 1);
|
|
654
|
+
const lease = {
|
|
655
|
+
...observed.lease,
|
|
656
|
+
expiresAt: new Date(newExpiresMs).toISOString(),
|
|
657
|
+
durationSeconds,
|
|
658
|
+
token: randomUUID(),
|
|
659
|
+
};
|
|
660
|
+
const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
|
|
661
|
+
if (!result.won) {
|
|
662
|
+
if (result.claimTimeout === true) {
|
|
663
|
+
return { ok: false, reason: result.reason, claimTimeout: true };
|
|
664
|
+
}
|
|
665
|
+
return { ok: false, reason: `renew ${result.reason}` };
|
|
666
|
+
}
|
|
667
|
+
if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
|
|
668
|
+
/* A RENEW ADVANCES THE FENCING COUNTER, and that is the whole of what
|
|
669
|
+
makes another environment's staleness judgement safe under clock skew
|
|
670
|
+
(criterion 5). The observer sees an increment, never a timestamp. */
|
|
671
|
+
const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
|
|
672
|
+
if (!published.ok) {
|
|
673
|
+
/* THE LOCAL LAYER IS ROLLED BACK, exactly as acquire's failure arm
|
|
674
|
+
does it and through the SAME primitive. src/lock.ts:537 states the
|
|
675
|
+
invariant: the two layers agree, or the local one is undone by the
|
|
676
|
+
caller. Until this round `acquireLease` was the only one of the
|
|
677
|
+
three publishing callers that honoured it, so a failed renew
|
|
678
|
+
reported failure while the lease file had ALREADY been extended
|
|
679
|
+
under a new token, and a failed release reported failure while the
|
|
680
|
+
lease file had already been DELETED. */
|
|
681
|
+
const line = `shared exclusion refused renew: ${published.reason}${await restoreLocal(lockPath, { kind: "present", raw: renderLease(lease), lease }, observed)}`;
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
reason: line,
|
|
685
|
+
shared: { line, envId: gate.ctx.envId },
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
ok: true,
|
|
690
|
+
lease,
|
|
691
|
+
shared: {
|
|
692
|
+
line: `${preflight.line}; register now ${published.sha}`,
|
|
693
|
+
envId: gate.ctx.envId,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
return { ok: true, lease };
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Release the lease held by holderId. Expiry does not block a release
|
|
701
|
+
* (an expired former holder may clean up its own lease), but a holder
|
|
702
|
+
* mismatch refuses, so a losing holder can never remove the winner's
|
|
703
|
+
* lease; and the compare-and-swap in the primitive means a release
|
|
704
|
+
* staged before a takeover completes loses to it.
|
|
705
|
+
*/
|
|
706
|
+
export async function releaseLease(lockPath, holderId, options = {}) {
|
|
707
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
708
|
+
const gate = sharedGate(lockPath, options.fleetRoot);
|
|
709
|
+
if (gate.kind === "invalid") {
|
|
710
|
+
return invalidDeclarationOutcome(gate.reason);
|
|
711
|
+
}
|
|
712
|
+
let preflight;
|
|
713
|
+
if (gate.kind === "on") {
|
|
714
|
+
/* CRITERION 8. A release by a non-holder is refused HERE, before any
|
|
715
|
+
write, so the register sha is byte-identical before and after. The
|
|
716
|
+
refusal is the register's own comparison, not a local one: a clone
|
|
717
|
+
whose local lease says it holds the fleet still loses to a register
|
|
718
|
+
that names another environment. */
|
|
719
|
+
preflight = sharedPreflight(gate.ctx, "release", false, nowMs);
|
|
720
|
+
if (preflight.kind === "refused") {
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
reason: preflight.line,
|
|
724
|
+
shared: sharedNote(gate.ctx, preflight),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const observed = options.observed ?? observeLease(lockPath);
|
|
729
|
+
if (observed.kind === "absent") {
|
|
730
|
+
return { ok: false, reason: "release refused: no lease present" };
|
|
731
|
+
}
|
|
732
|
+
if (observed.lease === undefined) {
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
reason: `lease file ${lockPath} is corrupt; inspect it manually`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (observed.lease.holderId !== holderId) {
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
reason: `release refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const result = await applyLeaseMutation(lockPath, observed, null, randomUUID());
|
|
745
|
+
if (!result.won) {
|
|
746
|
+
if (result.claimTimeout === true) {
|
|
747
|
+
return { ok: false, reason: result.reason, claimTimeout: true };
|
|
748
|
+
}
|
|
749
|
+
return { ok: false, reason: `release ${result.reason}` };
|
|
750
|
+
}
|
|
751
|
+
if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
|
|
752
|
+
const published = sharedCommit(gate.ctx, preflight, "free", nowMs, observed.lease.durationSeconds);
|
|
753
|
+
if (!published.ok) {
|
|
754
|
+
/* See renewLease above: the local lease was REMOVED before this
|
|
755
|
+
publish was attempted, and the holdership guard keys on that file's
|
|
756
|
+
PRESENCE, so leaving it removed reopens the dual-writer window
|
|
757
|
+
M1-P4 criterion 12 closed. */
|
|
758
|
+
const line = `shared exclusion could not publish the release: ${published.reason}${await restoreLocal(lockPath, { kind: "absent" }, observed)}`;
|
|
759
|
+
return {
|
|
760
|
+
ok: false,
|
|
761
|
+
reason: line,
|
|
762
|
+
shared: { line, envId: gate.ctx.envId },
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
lease: null,
|
|
768
|
+
shared: {
|
|
769
|
+
line: `${preflight.line}; register now ${published.sha}`,
|
|
770
|
+
envId: gate.ctx.envId,
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
return { ok: true, lease: null };
|
|
775
|
+
}
|
|
776
|
+
/** Report the lock state; reading only, never mutating (always safe). */
|
|
777
|
+
export function leaseStatus(lockPath, nowMs = Date.now()) {
|
|
778
|
+
const observed = observeLease(lockPath);
|
|
779
|
+
if (observed.kind === "absent") {
|
|
780
|
+
return { state: "free" };
|
|
781
|
+
}
|
|
782
|
+
if (observed.lease === undefined) {
|
|
783
|
+
return {
|
|
784
|
+
state: "corrupt",
|
|
785
|
+
detail: `lease file ${lockPath} does not parse as a lease`,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
state: isExpired(observed.lease, nowMs) ? "expired" : "held",
|
|
790
|
+
lease: observed.lease,
|
|
791
|
+
};
|
|
792
|
+
}
|