@junghanacs/entwurf 0.41.2 → 0.50.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 +4 -2
- package/BASELINE.md +110 -1
- package/CHANGELOG.md +328 -0
- package/CONTRIBUTING.md +1 -1
- package/ENTWURF-CLI.json +1229 -0
- package/ENTWURF.json +284 -0
- package/FAQ.md +4 -4
- package/README.md +125 -184
- package/VERIFY.md +2 -2
- package/docs/external-mcp-host.md +3 -3
- package/docs/setup-clean-host.md +37 -12
- package/mcp/entwurf-bridge/dist/ENTWURF.json +284 -0
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +46 -196
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +21 -25
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +498 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +23 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +68 -21
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -60
- package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
- package/mcp/entwurf-bridge/src/index.ts +149 -327
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
- package/native/win32-x64/manifest.json +1 -1
- package/package.json +8 -4
- package/pi/pi-durable/bootstrap.mjs +179 -6
- package/pi/pi-durable/carrier/runtime.js +108 -9
- package/pi/pi-durable/carrier/session-name.js +11 -0
- package/pi/pi-durable/carrier/sessions.js +96 -20
- package/pi/pi-durable/overlay/upstream-pin.json +11 -5
- package/pi-extensions/lib/agent-guidance.ts +341 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +25 -31
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +725 -54
- package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
- package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
- package/pi-extensions/lib/herdr-resume-call.ts +810 -0
- package/pi-extensions/lib/meta-session.ts +13 -8
- package/pi-extensions/lib/mux-fresh-call.ts +23 -26
- package/pi-extensions/lib/mux-resume-call.ts +95 -23
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
- package/pi-extensions/lib/pi-durable-location.ts +446 -0
- package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
- package/pi-extensions/lib/remote-pair-send.ts +7 -4
- package/pi-extensions/lib/resume-launch-identity.ts +31 -12
- package/pi-extensions/meta-bridge-pi-durable.ts +52 -79
- package/run.sh +417 -654
- package/scripts/build-bridge.sh +10 -0
- package/scripts/check-capability-bundle-reach.ts +86 -0
- package/scripts/check-claude-launch.ts +341 -0
- package/scripts/check-entwurf-self-address.ts +3 -3
- package/scripts/check-entwurf-v2-surface.ts +94 -98
- package/scripts/check-fresh-call-dispatch.ts +70 -9
- package/scripts/check-fresh-cut-gate.sh +23 -9
- package/scripts/check-gate-qualification.ts +24 -17
- package/scripts/check-herdr-activation.ts +301 -23
- package/scripts/check-herdr-fresh-call.ts +161 -25
- package/scripts/check-herdr-plugin-build.ts +173 -1
- package/scripts/check-herdr-plugin.ts +28 -2
- package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
- package/scripts/check-install-surface.ts +1236 -6
- package/scripts/check-meta-doctor-oracle.sh +1 -1
- package/scripts/check-meta-facts.ts +20 -1
- package/scripts/check-mux-resume-call.ts +48 -3
- package/scripts/check-peer-facts.ts +39 -2
- package/scripts/cli-help.mjs +222 -0
- package/scripts/dev-bin.sh +39 -2
- package/scripts/herdr-activation.mjs +28 -6
- package/scripts/herdr-operator-link.mjs +389 -0
- package/scripts/herdr-plugin-activate.mjs +5 -0
- package/scripts/herdr-plugin-deactivate.mjs +18 -5
- package/scripts/herdr-rails.mjs +43 -7
- package/scripts/herdr-runtime.mjs +27 -0
- package/scripts/meta-bridge-fresh-cut.ts +1 -27
- package/scripts/meta-bridge-prune.ts +1 -1
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/agent-guidance.json +160 -0
- package/scripts/mutants/bridge-boot-resume.json +6 -8
- package/scripts/mutants/capability-bundle-reach.json +124 -0
- package/scripts/mutants/claude-launch.json +142 -0
- package/scripts/mutants/cli-help-source.json +420 -0
- package/scripts/mutants/control-send-receipt.json +21 -4
- package/scripts/mutants/dev-gate-dispatch.json +109 -0
- package/scripts/mutants/fresh-call-dispatch.json +38 -2
- package/scripts/mutants/fresh-cut.json +15 -0
- package/scripts/mutants/herdr-activation.json +128 -0
- package/scripts/mutants/herdr-fresh-call.json +278 -5
- package/scripts/mutants/herdr-operator-link.json +318 -0
- package/scripts/mutants/herdr-plugin-build.json +55 -0
- package/scripts/mutants/herdr-plugin.json +17 -0
- package/scripts/mutants/herdr-resume-call.json +288 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
- package/scripts/mutants/meta-facts.json +15 -0
- package/scripts/mutants/mux-fresh-call.json +14 -3
- package/scripts/mutants/mux-resume-call.json +89 -3
- package/scripts/mutants/peer-facts.json +15 -0
- package/scripts/mutants/pi-durable.json +996 -6
- package/scripts/mutants/remote-pair-send.json +21 -2
- package/scripts/mutants/self-address.json +10 -10
- package/scripts/mutants/source-install.json +35 -0
- package/scripts/mutants/v2-surface.json +50 -64
- package/scripts/mutants/v2-visible-resume.json +266 -6
- package/scripts/peer-facts.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +66 -0
- package/scripts/smoke-meta-prune.sh +23 -0
- package/scripts/smoke-omp-fresh-live.ts +12 -3
- package/scripts/smoke-omp-receive-live.ts +7 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
- package/team.json.example +39 -0
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-durable OWNER FENCE (#157 B3, pre-release fence) — the claim an owned pi-durable open takes inside its
|
|
3
|
+
* native session directory D BEFORE the native lease (proper-lockfile's `${D}.lock`), and carries through
|
|
4
|
+
* storage open and close.
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS IS. Three fixed names inside D, never derived from the session id and never under the SDK's
|
|
7
|
+
* `session.sqlite` prefix:
|
|
8
|
+
* - `entwurf-owner.json` — the claim: one regular file, replaced by tmp+rename, read no-follow;
|
|
9
|
+
* - `entwurf-owner.tx` — the transaction mutex S_D, created `wx`; its body is evidence only;
|
|
10
|
+
* - `entwurf-owner.json.tmp-<pid>-<hex>` — the atomic temp a write renames into place.
|
|
11
|
+
* Every claim write — the first creation of an absent claim included — happens inside ONE S_D hold, on a
|
|
12
|
+
* decision read inside that hold; an owner's own transition also requires the generation it last wrote.
|
|
13
|
+
* The holder unlinks only the tx it created, in `finally`. A tx it did not create (another holder, or crash
|
|
14
|
+
* residue) is never removed, taken over or aged out: there is no TTL, no mtime rule and no cleanup verb.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT GUARANTEES. Mutual exclusion among PARTICIPATING owned openers only. It does not prove that no
|
|
17
|
+
* foreign lease exists, and it does not make a legacy or non-participating opener safe (an opener with
|
|
18
|
+
* neither a fence nor a lease is invisible to it). A `closed` claim is a participating owner's end receipt in
|
|
19
|
+
* one of two rows; it is not "untouched since", not dormancy and not public-resume evidence, and neither is
|
|
20
|
+
* `firstParticipation`, which records the first participation attempt for D, not a completed enrollment.
|
|
21
|
+
*
|
|
22
|
+
* SCHEMA. A new admission writes schema 2, whose `selected` also carries `profile`: the agent directory exactly as
|
|
23
|
+
* the owned open's `getAgentDir()` returned it, the value its `directory` was joined from. A schema 1 claim (the
|
|
24
|
+
* pre-profile build: `selected` is `{n, directory}`) is still read, and every admission row judges it as before; its
|
|
25
|
+
* profile is never inferred, and it is replaced only by the next admission's own claim, never rewritten to migrate.
|
|
26
|
+
* Schema 2 alone knows `reserved` with its `reservation`, and `adoptedFrom`; an older reader refuses schema 2, so the
|
|
27
|
+
* rollout is complete only once every participating reader is this one.
|
|
28
|
+
*
|
|
29
|
+
* RESERVATION (#157 B3 I-2). A parent reserves one citizen's D for one child (`reservePiDurableSession`), the child
|
|
30
|
+
* adopts exactly that reservation (`createPiDurableOwnerFence({adoption})`), and only the parent withdraws its own
|
|
31
|
+
* (`withdrawPiDurableReservation`). The reserve and the adoption take record-arm(gid), then S_D(R), on the record and
|
|
32
|
+
* C4 pair read inside the arm hold; the withdrawal takes S_D only. A parent's per-target lock is its caller's outer
|
|
33
|
+
* scope and is never taken here. The claim and S_D live in R, the physical D; the SDK's lease is `${S}.lock`, S being
|
|
34
|
+
* the donor's own selection, and every lease snapshot is taken there. `observePiDurableReservation` is a read, never a
|
|
35
|
+
* watch.
|
|
36
|
+
*
|
|
37
|
+
* DEATH POLICY stays in meta-session.ts (Hard Rule 14): `classifyMarkerOwner` over `processStartKey` and
|
|
38
|
+
* `probePidExistence`, plus `isPlausibleOwnerPid`. A live or uncertain owner — this very process included — is
|
|
39
|
+
* refused; only a proven-dead owner on this host is reclaimed, and an unattributed lease that is present is
|
|
40
|
+
* never presumed to be the dead owner's.
|
|
41
|
+
*
|
|
42
|
+
* The owned app (pi/pi-durable/src/experimental/durable/sessions.ts) declares the `SessionFence` and
|
|
43
|
+
* `FenceTicket` shapes this module implements, type-only and without importing Entwurf; the packaged
|
|
44
|
+
* bootstrap builds one fence from the compiled twin of this file and hands it to every owned open.
|
|
45
|
+
*/
|
|
46
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
47
|
+
import * as fs from "node:fs";
|
|
48
|
+
import * as os from "node:os";
|
|
49
|
+
import * as path from "node:path";
|
|
50
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
51
|
+
import { acquireLock, releaseLock } from "./entwurf-v2-lock.js";
|
|
52
|
+
import { classifyMarkerOwner, defaultMetaRecordArmLocksDir, isPlausibleOwnerPid, probePidExistence, processStartKey, readStoreRecordFile, requireAttachIncarnation, requireGardenId, } from "./meta-session.js";
|
|
53
|
+
import { verifyPiDurableLocation } from "./pi-durable-location.js";
|
|
54
|
+
export const PI_DURABLE_OWNER_CLAIM = "entwurf-owner.json";
|
|
55
|
+
export const PI_DURABLE_OWNER_TX = "entwurf-owner.tx";
|
|
56
|
+
export const PI_DURABLE_OWNER_TMP_PREFIX = `${PI_DURABLE_OWNER_CLAIM}.tmp-`;
|
|
57
|
+
/** The schema a new admission writes: `selected` is `{n, directory, profile}`. */
|
|
58
|
+
export const PI_DURABLE_OWNER_SCHEMA_VERSION = 2;
|
|
59
|
+
/** The pre-profile schema, still read and never written: `selected` is `{n, directory}`. */
|
|
60
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
61
|
+
/** The bounded wait for S_D: 20 attempts 100 ms apart, then `tx-held`. */
|
|
62
|
+
const TX_ATTEMPTS = 20;
|
|
63
|
+
const TX_DELAY_MS = 100;
|
|
64
|
+
const CAUSE_MAX = 512;
|
|
65
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
66
|
+
export class PiDurableOwnerFenceError extends Error {
|
|
67
|
+
reason;
|
|
68
|
+
holderState;
|
|
69
|
+
written;
|
|
70
|
+
constructor(reason, message, context = {}) {
|
|
71
|
+
super(`${reason}: ${message}`);
|
|
72
|
+
this.name = "PiDurableOwnerFenceError";
|
|
73
|
+
this.reason = reason;
|
|
74
|
+
if (context.holderState !== undefined)
|
|
75
|
+
this.holderState = context.holderState;
|
|
76
|
+
if (context.written !== undefined)
|
|
77
|
+
this.written = context.written;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function refuse(reason, message, context) {
|
|
81
|
+
return new PiDurableOwnerFenceError(reason, message, context);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A release failure that came AFTER this call's own decision write committed keeps its reason, message and cause; it
|
|
85
|
+
* only gains what was written. Any other error, or one that already names a write, is returned as it is.
|
|
86
|
+
*/
|
|
87
|
+
function afterWrite(error, written) {
|
|
88
|
+
if (written !== undefined &&
|
|
89
|
+
error instanceof PiDurableOwnerFenceError &&
|
|
90
|
+
error.written === undefined &&
|
|
91
|
+
(error.reason === "tx-release-failed" || error.reason === "record-arm-release-failed")) {
|
|
92
|
+
Object.assign(error, { written });
|
|
93
|
+
}
|
|
94
|
+
return error;
|
|
95
|
+
}
|
|
96
|
+
function writtenOf(claim) {
|
|
97
|
+
return { resource: claim.resource, state: claim.state, generation: claim.generation, ownerPid: claim.owner.pid };
|
|
98
|
+
}
|
|
99
|
+
function messageOf(error) {
|
|
100
|
+
return error instanceof Error ? error.message : String(error);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The fence an owned open takes. Building it reads and writes nothing; every check happens at `admit`. With an
|
|
104
|
+
* adoption, `admit` adopts exactly that reservation or refuses; it never falls back to an ordinary admission.
|
|
105
|
+
*/
|
|
106
|
+
export function createPiDurableOwnerFence(options = {}) {
|
|
107
|
+
if (options.adoption === undefined)
|
|
108
|
+
return { admit: (request) => admitOwner(request) };
|
|
109
|
+
const adoption = requireAdoption(options.adoption);
|
|
110
|
+
return { admit: (request) => adoptOwner(request, adoption) };
|
|
111
|
+
}
|
|
112
|
+
// ── The claim's schema and its state × lease × end table ─────────────────────────────────────────
|
|
113
|
+
const NATIVE_STATES = ["fenced", "leased", "storage-open", "closed", "close-failed"];
|
|
114
|
+
/** Schema 2's states; schema 1 knows only the native ones. */
|
|
115
|
+
const STATES = [...NATIVE_STATES, "reserved"];
|
|
116
|
+
const NATIVE_LEASES = ["absent", "present-attributed"];
|
|
117
|
+
const LEASES = ["never-held", "held", "released", "failed", "unknown"];
|
|
118
|
+
const STORAGE_ENDS = ["closed", "never-opened", "failed", "unknown"];
|
|
119
|
+
const PARTICIPATIONS = ["fresh", "unarmed-existing"];
|
|
120
|
+
const CLAIM_KEYS = [
|
|
121
|
+
"schemaVersion",
|
|
122
|
+
"resource",
|
|
123
|
+
"selected",
|
|
124
|
+
"state",
|
|
125
|
+
"owner",
|
|
126
|
+
"generation",
|
|
127
|
+
"firstParticipation",
|
|
128
|
+
"lease",
|
|
129
|
+
"writtenAt",
|
|
130
|
+
];
|
|
131
|
+
const CLAIM_OPTIONAL_KEYS = ["reclaimedFrom", "end"];
|
|
132
|
+
/** Only schema 2 carries a reservation or an adoption. */
|
|
133
|
+
const CLAIM_OPTIONAL_KEYS_V2 = [...CLAIM_OPTIONAL_KEYS, "reservation", "adoptedFrom"];
|
|
134
|
+
const RESERVATION_KEYS = ["gardenId", "n", "cwd", "resource", "profile", "attachIncarnation", "nativeLease", "from"];
|
|
135
|
+
function isObject(value) {
|
|
136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
function hasExactKeys(value, required, optional = []) {
|
|
139
|
+
const keys = Object.keys(value);
|
|
140
|
+
return (required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key)));
|
|
141
|
+
}
|
|
142
|
+
function nonEmpty(value) {
|
|
143
|
+
return typeof value === "string" && value.length > 0;
|
|
144
|
+
}
|
|
145
|
+
function isTime(value) {
|
|
146
|
+
return nonEmpty(value) && Number.isFinite(Date.parse(value));
|
|
147
|
+
}
|
|
148
|
+
function isOwner(value) {
|
|
149
|
+
return (isObject(value) &&
|
|
150
|
+
hasExactKeys(value, ["pid", "startKey", "hostname"]) &&
|
|
151
|
+
isPlausibleOwnerPid(value.pid) &&
|
|
152
|
+
nonEmpty(value.startKey) &&
|
|
153
|
+
nonEmpty(value.hostname));
|
|
154
|
+
}
|
|
155
|
+
function isEnd(value) {
|
|
156
|
+
return (isObject(value) &&
|
|
157
|
+
hasExactKeys(value, ["at", "storage", "lease"], ["envCleanup", "cause"]) &&
|
|
158
|
+
isTime(value.at) &&
|
|
159
|
+
STORAGE_ENDS.includes(value.storage) &&
|
|
160
|
+
LEASES.includes(value.lease) &&
|
|
161
|
+
(value.envCleanup === undefined || value.envCleanup === "failed") &&
|
|
162
|
+
(value.cause === undefined || (typeof value.cause === "string" && value.cause.length <= CAUSE_MAX)));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The complete table (anything else is a malformed claim):
|
|
166
|
+
* reserved never-held — (schema 2 only)
|
|
167
|
+
* fenced never-held —
|
|
168
|
+
* leased held —
|
|
169
|
+
* storage-open held —
|
|
170
|
+
* closed released closed | never-opened
|
|
171
|
+
* closed never-held never-opened
|
|
172
|
+
* close-failed released | failed | unknown failed | unknown; closed or never-opened only when the release
|
|
173
|
+
* failed or is unknown
|
|
174
|
+
* `end.lease` repeats the claim's `lease`; an env-cleanup failure never rides a storage that was never opened.
|
|
175
|
+
*/
|
|
176
|
+
function combinationValid(state, lease, end) {
|
|
177
|
+
if (state === "reserved")
|
|
178
|
+
return lease === "never-held" && end === undefined;
|
|
179
|
+
if (state === "fenced")
|
|
180
|
+
return lease === "never-held" && end === undefined;
|
|
181
|
+
if (state === "leased" || state === "storage-open")
|
|
182
|
+
return lease === "held" && end === undefined;
|
|
183
|
+
if (end === undefined || end.lease !== lease)
|
|
184
|
+
return false;
|
|
185
|
+
if (end.envCleanup !== undefined && end.storage === "never-opened")
|
|
186
|
+
return false;
|
|
187
|
+
if (state === "closed") {
|
|
188
|
+
if (lease === "released")
|
|
189
|
+
return end.storage === "closed" || end.storage === "never-opened";
|
|
190
|
+
return lease === "never-held" && end.storage === "never-opened";
|
|
191
|
+
}
|
|
192
|
+
if (lease !== "released" && lease !== "failed" && lease !== "unknown")
|
|
193
|
+
return false;
|
|
194
|
+
if (end.storage === "failed" || end.storage === "unknown")
|
|
195
|
+
return true;
|
|
196
|
+
return lease !== "released";
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A claim body exactly as the schema and the table allow, or a named refusal; never a default. A body read from D
|
|
200
|
+
* is `claim-unreadable`; a body about to be written is `transition-invalid`.
|
|
201
|
+
*/
|
|
202
|
+
function requireClaim(value, where, reason = "claim-unreadable") {
|
|
203
|
+
const bad = (what) => refuse(reason, `${where}: ${what}`);
|
|
204
|
+
if (!isObject(value))
|
|
205
|
+
throw bad("not a JSON object");
|
|
206
|
+
const v2 = value.schemaVersion === PI_DURABLE_OWNER_SCHEMA_VERSION;
|
|
207
|
+
if (!v2 && value.schemaVersion !== LEGACY_SCHEMA_VERSION)
|
|
208
|
+
throw bad("unknown schemaVersion");
|
|
209
|
+
const optional = v2 ? CLAIM_OPTIONAL_KEYS_V2 : CLAIM_OPTIONAL_KEYS;
|
|
210
|
+
if (!hasExactKeys(value, CLAIM_KEYS, optional))
|
|
211
|
+
throw bad("its keys are not exactly the claim's keys");
|
|
212
|
+
if (!nonEmpty(value.resource) || !path.isAbsolute(value.resource))
|
|
213
|
+
throw bad("resource is not an absolute path");
|
|
214
|
+
const selected = value.selected;
|
|
215
|
+
if (!isObject(selected) ||
|
|
216
|
+
!hasExactKeys(selected, v2 ? ["n", "directory", "profile"] : ["n", "directory"]) ||
|
|
217
|
+
!nonEmpty(selected.n) ||
|
|
218
|
+
!nonEmpty(selected.directory) ||
|
|
219
|
+
!path.isAbsolute(selected.directory)) {
|
|
220
|
+
throw bad(v2 ? "selected is not {n, directory, profile}" : "selected is not {n, directory}");
|
|
221
|
+
}
|
|
222
|
+
// Schema 2's profile is the value its directory was joined from, as it was read; schema 1 has none to infer.
|
|
223
|
+
if (v2) {
|
|
224
|
+
const profile = selected.profile;
|
|
225
|
+
if (!nonEmpty(profile) || !path.isAbsolute(profile))
|
|
226
|
+
throw bad("selected.profile is not an absolute path");
|
|
227
|
+
const n = selected.n;
|
|
228
|
+
const directory = selected.directory;
|
|
229
|
+
const joined = path.join(profile, "experimental", "durable-sessions", path.basename(path.dirname(directory)), n);
|
|
230
|
+
if (joined !== directory)
|
|
231
|
+
throw bad("selected.directory is not joined from selected.profile and selected.n");
|
|
232
|
+
}
|
|
233
|
+
const states = v2 ? STATES : NATIVE_STATES;
|
|
234
|
+
if (!states.includes(value.state))
|
|
235
|
+
throw bad("unknown state");
|
|
236
|
+
if (!isOwner(value.owner))
|
|
237
|
+
throw bad("owner is not {pid, startKey, hostname} of a plausible pid");
|
|
238
|
+
if (typeof value.generation !== "string" || !HEX32.test(value.generation))
|
|
239
|
+
throw bad("generation is not 32 hex");
|
|
240
|
+
const first = value.firstParticipation;
|
|
241
|
+
if (!isObject(first) ||
|
|
242
|
+
!hasExactKeys(first, ["at", "generation", "from"]) ||
|
|
243
|
+
!isTime(first.at) ||
|
|
244
|
+
typeof first.generation !== "string" ||
|
|
245
|
+
!HEX32.test(first.generation) ||
|
|
246
|
+
!PARTICIPATIONS.includes(first.from)) {
|
|
247
|
+
throw bad("firstParticipation is not {at, generation, from}");
|
|
248
|
+
}
|
|
249
|
+
const reclaimed = value.reclaimedFrom;
|
|
250
|
+
if (reclaimed !== undefined &&
|
|
251
|
+
!(isObject(reclaimed) &&
|
|
252
|
+
hasExactKeys(reclaimed, ["generation", "state", "owner", "leaseOutcome"]) &&
|
|
253
|
+
typeof reclaimed.generation === "string" &&
|
|
254
|
+
HEX32.test(reclaimed.generation) &&
|
|
255
|
+
states.includes(reclaimed.state) &&
|
|
256
|
+
isOwner(reclaimed.owner) &&
|
|
257
|
+
LEASES.includes(reclaimed.leaseOutcome))) {
|
|
258
|
+
throw bad("reclaimedFrom is not {generation, state, owner, leaseOutcome}");
|
|
259
|
+
}
|
|
260
|
+
if (!LEASES.includes(value.lease))
|
|
261
|
+
throw bad("unknown lease outcome");
|
|
262
|
+
if (value.end !== undefined && !isEnd(value.end))
|
|
263
|
+
throw bad("end is not {at, storage, lease, envCleanup?, cause?}");
|
|
264
|
+
if (!isTime(value.writtenAt))
|
|
265
|
+
throw bad("writtenAt is not a time");
|
|
266
|
+
const claim = value;
|
|
267
|
+
if (!combinationValid(claim.state, claim.lease, claim.end)) {
|
|
268
|
+
throw bad(`state ${claim.state} with lease ${claim.lease} and end ${JSON.stringify(claim.end ?? null)} is invalid`);
|
|
269
|
+
}
|
|
270
|
+
// Exactly a `reserved` claim carries a reservation (#157 B3); an adoption rides only an owned state.
|
|
271
|
+
if ((claim.state === "reserved") !== (value.reservation !== undefined)) {
|
|
272
|
+
throw bad(`a ${claim.state} claim ${claim.state === "reserved" ? "lacks" : "carries"} a reservation`);
|
|
273
|
+
}
|
|
274
|
+
if (value.reservation !== undefined)
|
|
275
|
+
requireReservation(value.reservation, claim, bad);
|
|
276
|
+
if (value.adoptedFrom !== undefined && (claim.state === "reserved" || !isAdoptedFrom(value.adoptedFrom))) {
|
|
277
|
+
throw bad("adoptedFrom is not {generation, owner} on an owned state");
|
|
278
|
+
}
|
|
279
|
+
return claim;
|
|
280
|
+
}
|
|
281
|
+
function isAdoptedFrom(value) {
|
|
282
|
+
return (isObject(value) &&
|
|
283
|
+
hasExactKeys(value, ["generation", "owner"]) &&
|
|
284
|
+
typeof value.generation === "string" &&
|
|
285
|
+
HEX32.test(value.generation) &&
|
|
286
|
+
isOwner(value.owner));
|
|
287
|
+
}
|
|
288
|
+
/** Whether a native row's own lease outcome makes a present lease that owner's: held, or held and never seen released. */
|
|
289
|
+
function leaseAttributed(lease) {
|
|
290
|
+
return lease === "held" || lease === "unknown";
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* A reservation exactly as the reserve writes it: it names the claim's resource, session and profile; its donor is ONE
|
|
294
|
+
* flat native row whose selection is the claim's; a present lease is attributed only to a donor that held one.
|
|
295
|
+
*/
|
|
296
|
+
function requireReservation(value, claim, bad) {
|
|
297
|
+
if (!isObject(value) || !hasExactKeys(value, RESERVATION_KEYS))
|
|
298
|
+
throw bad("reservation is not the reservation's keys");
|
|
299
|
+
try {
|
|
300
|
+
requireGardenId(value.gardenId);
|
|
301
|
+
if (requireAttachIncarnation(value.attachIncarnation) === null)
|
|
302
|
+
throw new Error("attachIncarnation is null");
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
throw bad(`reservation: ${messageOf(error)}`);
|
|
306
|
+
}
|
|
307
|
+
if (!nonEmpty(value.cwd) || !path.isAbsolute(value.cwd))
|
|
308
|
+
throw bad("reservation.cwd is not an absolute path");
|
|
309
|
+
if (value.resource !== claim.resource || value.n !== claim.selected.n || value.profile !== claim.selected.profile) {
|
|
310
|
+
throw bad("reservation does not name the claim's resource, session and profile");
|
|
311
|
+
}
|
|
312
|
+
if (!NATIVE_LEASES.includes(value.nativeLease))
|
|
313
|
+
throw bad("reservation.nativeLease is unknown");
|
|
314
|
+
const from = value.from;
|
|
315
|
+
if (!isObject(from) ||
|
|
316
|
+
!hasExactKeys(from, ["generation", "state", "owner", "lease", "selected"], ["end"]) ||
|
|
317
|
+
typeof from.generation !== "string" ||
|
|
318
|
+
!HEX32.test(from.generation) ||
|
|
319
|
+
!NATIVE_STATES.includes(from.state) ||
|
|
320
|
+
!isOwner(from.owner) ||
|
|
321
|
+
!LEASES.includes(from.lease) ||
|
|
322
|
+
(from.end !== undefined && !isEnd(from.end))) {
|
|
323
|
+
throw bad("reservation.from is not one flat native owner row");
|
|
324
|
+
}
|
|
325
|
+
// The reserve selects nothing itself: the donor's selection IS the claim's.
|
|
326
|
+
const selected = from.selected;
|
|
327
|
+
if (!isObject(selected) ||
|
|
328
|
+
!hasExactKeys(selected, ["n", "directory", "profile"]) ||
|
|
329
|
+
selected.n !== claim.selected.n ||
|
|
330
|
+
selected.directory !== claim.selected.directory ||
|
|
331
|
+
selected.profile !== claim.selected.profile) {
|
|
332
|
+
throw bad("reservation.from.selected is not the claim's selected");
|
|
333
|
+
}
|
|
334
|
+
const donor = from;
|
|
335
|
+
if (!combinationValid(donor.state, donor.lease, donor.end))
|
|
336
|
+
throw bad("reservation.from is not a row the table allows");
|
|
337
|
+
const attributable = leaseAttributed(donor.lease);
|
|
338
|
+
if (value.nativeLease === "present-attributed" && !attributable) {
|
|
339
|
+
throw bad("reservation.nativeLease is present-attributed for a donor that held no lease");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// ── Reads, writes and the S_D hold ───────────────────────────────────────────────────────────────
|
|
343
|
+
/** The claim of D, or undefined when there is none; a symlink, a non-regular entry or a bad body is refused. */
|
|
344
|
+
function readClaim(resource) {
|
|
345
|
+
const file = path.join(resource, PI_DURABLE_OWNER_CLAIM);
|
|
346
|
+
let text;
|
|
347
|
+
try {
|
|
348
|
+
text = readStoreRecordFile(file);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (error.code === "ENOENT")
|
|
352
|
+
return undefined;
|
|
353
|
+
throw refuse("claim-unreadable", `${file}: ${messageOf(error)}`);
|
|
354
|
+
}
|
|
355
|
+
let parsed;
|
|
356
|
+
try {
|
|
357
|
+
parsed = JSON.parse(text);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
throw refuse("claim-unreadable", `${file}: ${messageOf(error)}`);
|
|
361
|
+
}
|
|
362
|
+
return requireClaim(parsed, file);
|
|
363
|
+
}
|
|
364
|
+
/** tmp+rename, mode 0600, fsync'd before the rename. Only a body the schema and the table accept is written. */
|
|
365
|
+
function writeClaim(resource, body) {
|
|
366
|
+
requireClaim(JSON.parse(JSON.stringify(body)), "the claim about to be written", "transition-invalid");
|
|
367
|
+
const tmp = path.join(resource, `${PI_DURABLE_OWNER_TMP_PREFIX}${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
368
|
+
// Ownership of the temp is its creation by THIS call: a `wx` that failed (EEXIST included) made nothing here.
|
|
369
|
+
let created = false;
|
|
370
|
+
try {
|
|
371
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
372
|
+
created = true;
|
|
373
|
+
try {
|
|
374
|
+
fs.writeSync(fd, `${JSON.stringify(body, null, "\t")}\n`);
|
|
375
|
+
fs.fsyncSync(fd);
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
fs.closeSync(fd);
|
|
379
|
+
}
|
|
380
|
+
fs.renameSync(tmp, path.join(resource, PI_DURABLE_OWNER_CLAIM));
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
// Only the temp this call created is its to remove; a file that was already there is left as it is.
|
|
384
|
+
if (created)
|
|
385
|
+
fs.rmSync(tmp, { force: true });
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Whether `${D}.lock` (the lease this opener would take) exists in any form; an unreadable answer throws. */
|
|
390
|
+
function leasePresent(directory) {
|
|
391
|
+
try {
|
|
392
|
+
fs.lstatSync(`${directory}.lock`);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (error.code === "ENOENT")
|
|
397
|
+
return false;
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** What a busy tx says about its holder, as evidence only; nothing is decided from it. */
|
|
402
|
+
function txEvidence(tx) {
|
|
403
|
+
try {
|
|
404
|
+
return readStoreRecordFile(tx).trim().slice(0, CAUSE_MAX) || "(empty)";
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
return `(unreadable: ${error.code ?? messageOf(error)})`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function enterTx(tx) {
|
|
411
|
+
for (let attempt = 1;; attempt++) {
|
|
412
|
+
let fd;
|
|
413
|
+
try {
|
|
414
|
+
fd = fs.openSync(tx, "wx", 0o600);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (error.code !== "EEXIST")
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
if (fd !== undefined) {
|
|
421
|
+
try {
|
|
422
|
+
const evidence = {
|
|
423
|
+
pid: process.pid,
|
|
424
|
+
startKey: processStartKey(process.pid),
|
|
425
|
+
hostname: os.hostname(),
|
|
426
|
+
enteredAt: new Date().toISOString(),
|
|
427
|
+
};
|
|
428
|
+
fs.writeSync(fd, `${JSON.stringify(evidence)}\n`);
|
|
429
|
+
const { dev, ino } = fs.fstatSync(fd);
|
|
430
|
+
return { dev, ino };
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
// This tx was created by this call a moment ago, so it is this actor's own to remove.
|
|
434
|
+
fs.rmSync(tx, { force: true });
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
fs.closeSync(fd);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (attempt >= TX_ATTEMPTS) {
|
|
442
|
+
throw refuse("tx-held", `${tx} is held (${txEvidence(tx)}); a tx this fence did not create is never removed, taken over or aged out`);
|
|
443
|
+
}
|
|
444
|
+
await sleep(TX_DELAY_MS);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Unlink the tx this actor created, and only that one. A failure is reported; it does not prove the file remains (it
|
|
449
|
+
* may be gone, or another actor's), so the report says what a remaining file does rather than that one remains.
|
|
450
|
+
*/
|
|
451
|
+
function leaveTx(tx, held, during) {
|
|
452
|
+
try {
|
|
453
|
+
const now = fs.lstatSync(tx);
|
|
454
|
+
if (now.dev !== held.dev || now.ino !== held.ino) {
|
|
455
|
+
throw new Error("the transaction file is no longer the one this owner created");
|
|
456
|
+
}
|
|
457
|
+
fs.unlinkSync(tx);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
throw refuse("tx-release-failed", `${tx}: ${messageOf(error)}${during === undefined ? "" : `; the step had already failed: ${messageOf(during)}`}; if that transaction file remains, it fails closed until an operator removes it`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** One S_D hold around a synchronous decision read and write. */
|
|
464
|
+
async function withTx(resource, step) {
|
|
465
|
+
const tx = path.join(resource, PI_DURABLE_OWNER_TX);
|
|
466
|
+
const held = await enterTx(tx);
|
|
467
|
+
let stepError;
|
|
468
|
+
try {
|
|
469
|
+
return step();
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
stepError = error;
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
leaveTx(tx, held, stepError);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function newGeneration() {
|
|
480
|
+
return randomBytes(16).toString("hex");
|
|
481
|
+
}
|
|
482
|
+
function boundedCause(cause) {
|
|
483
|
+
return cause === undefined ? {} : { cause: cause.slice(0, CAUSE_MAX) };
|
|
484
|
+
}
|
|
485
|
+
// ── Admission ────────────────────────────────────────────────────────────────────────────────────
|
|
486
|
+
function requireAdmission(request) {
|
|
487
|
+
if (!isObject(request) ||
|
|
488
|
+
!nonEmpty(request.directory) ||
|
|
489
|
+
!path.isAbsolute(request.directory) ||
|
|
490
|
+
!nonEmpty(request.selectedN) ||
|
|
491
|
+
typeof request.created !== "boolean" ||
|
|
492
|
+
!nonEmpty(request.profile) ||
|
|
493
|
+
!path.isAbsolute(request.profile)) {
|
|
494
|
+
throw refuse("admission-invalid", "an admission names an absolute directory, its session id, whether it was created and the absolute profile it was joined from");
|
|
495
|
+
}
|
|
496
|
+
return request;
|
|
497
|
+
}
|
|
498
|
+
function thisOwner() {
|
|
499
|
+
const pid = process.pid;
|
|
500
|
+
if (!isPlausibleOwnerPid(pid))
|
|
501
|
+
throw refuse("owner-pid-implausible", `pid ${pid} cannot own a session`);
|
|
502
|
+
const startKey = processStartKey(pid);
|
|
503
|
+
if (startKey === "")
|
|
504
|
+
throw refuse("owner-start-key-unreadable", `the start key of pid ${pid} cannot be read`);
|
|
505
|
+
return { pid, startKey, hostname: os.hostname() };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* The admission table, inside the S_D hold, after D is chosen and before `lockfile.lock`:
|
|
509
|
+
* absent + created by this call → fenced, from "fresh"
|
|
510
|
+
* absent + D existed, or `closed` → `${D}.lock` present (any age): legacy-lease-present, nothing
|
|
511
|
+
* written; absent: fenced (a `closed` claim's firstParticipation kept)
|
|
512
|
+
* resource ≠ realpath(D) → claim-resource-mismatch, never read as absent
|
|
513
|
+
* an owner on another host → owner-other-host
|
|
514
|
+
* an owner live or uncertain (this pid too)→ owner-live / owner-uncertain
|
|
515
|
+
* a dead owner, unattributed lease → `${D}.lock` present: lease-unattributed; absent: reclaim
|
|
516
|
+
* a dead owner, attributed lease → reclaim (the library's stale takeover is admissible only here)
|
|
517
|
+
* a dead reservation's parent → as a dead owner, its lease attributed only by its donor's snapshot
|
|
518
|
+
* and only for that snapshot's own selection
|
|
519
|
+
* A differing `selected` (another N or another spelling of D) with an equal resource is an alias, judged by the
|
|
520
|
+
* rows above.
|
|
521
|
+
*/
|
|
522
|
+
async function admitOwner(request) {
|
|
523
|
+
const { directory, selectedN, created, profile } = requireAdmission(request);
|
|
524
|
+
let resource;
|
|
525
|
+
try {
|
|
526
|
+
resource = fs.realpathSync(directory);
|
|
527
|
+
if (!fs.statSync(resource).isDirectory())
|
|
528
|
+
throw new Error("not a directory");
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
throw refuse("directory-invalid", `${directory}: ${messageOf(error)}`);
|
|
532
|
+
}
|
|
533
|
+
const owner = thisOwner();
|
|
534
|
+
const written = await withTx(resource, () => {
|
|
535
|
+
const read = readClaim(resource);
|
|
536
|
+
if (read !== undefined && read.resource !== resource) {
|
|
537
|
+
throw refuse("claim-resource-mismatch", `the claim in ${directory} names ${read.resource}, not ${resource}`);
|
|
538
|
+
}
|
|
539
|
+
// Only a claim for THIS resource is judged below; a mismatched one was refused above, never read as absent.
|
|
540
|
+
const claim = read;
|
|
541
|
+
const at = new Date().toISOString();
|
|
542
|
+
const generation = newGeneration();
|
|
543
|
+
const fenced = (firstParticipation, reclaimedFrom) => ({
|
|
544
|
+
schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
|
|
545
|
+
resource,
|
|
546
|
+
selected: { n: selectedN, directory, profile },
|
|
547
|
+
state: "fenced",
|
|
548
|
+
owner,
|
|
549
|
+
generation,
|
|
550
|
+
firstParticipation,
|
|
551
|
+
...(reclaimedFrom === undefined ? {} : { reclaimedFrom }),
|
|
552
|
+
lease: "never-held",
|
|
553
|
+
writtenAt: at,
|
|
554
|
+
});
|
|
555
|
+
// `selected` is never compared: an alias of D (another N, another spelling) is judged by the same rows.
|
|
556
|
+
if (claim === undefined || claim.state === "closed") {
|
|
557
|
+
// An unarmed or ended D: a lease seen here is nobody this fence can name, so its stale takeover is never invoked.
|
|
558
|
+
if ((claim !== undefined || !created) && leasePresent(directory)) {
|
|
559
|
+
throw refuse("legacy-lease-present", `${directory}.lock exists while no participating owner holds it`);
|
|
560
|
+
}
|
|
561
|
+
const from = created ? "fresh" : "unarmed-existing";
|
|
562
|
+
const body = fenced(claim?.firstParticipation ?? { at, generation, from });
|
|
563
|
+
writeClaim(resource, body);
|
|
564
|
+
return body;
|
|
565
|
+
}
|
|
566
|
+
if (claim.owner.hostname !== owner.hostname) {
|
|
567
|
+
throw refuse("owner-other-host", `${directory} is claimed by pid ${claim.owner.pid} on ${claim.owner.hostname}`);
|
|
568
|
+
}
|
|
569
|
+
const verdict = classifyMarkerOwner(claim.owner.startKey, {
|
|
570
|
+
currentStartKey: processStartKey(claim.owner.pid),
|
|
571
|
+
pidExists: probePidExistence(claim.owner.pid),
|
|
572
|
+
});
|
|
573
|
+
const proven = verdict === "dead";
|
|
574
|
+
if (!proven) {
|
|
575
|
+
throw refuse(verdict === "live" ? "owner-live" : "owner-uncertain", `${directory} is ${claim.state} under pid ${claim.owner.pid}, which is ${verdict}`);
|
|
576
|
+
}
|
|
577
|
+
// A dead owner whose recorded lease is attributable to it: held, or held and its release never observed.
|
|
578
|
+
const attributed = claim.lease === "held" || claim.lease === "unknown";
|
|
579
|
+
// A dead reservation's parent never held a lease: only its donor's snapshot attributes one, and only to the very
|
|
580
|
+
// selection that snapshot was taken on; another spelling's sidecar is not that evidence (#157 B3).
|
|
581
|
+
const snapshot = claim.reservation;
|
|
582
|
+
const reservedAttributed = snapshot?.nativeLease === "present-attributed" && snapshot.from.selected.directory === directory;
|
|
583
|
+
if (!(attributed || reservedAttributed) && leasePresent(directory)) {
|
|
584
|
+
throw refuse("lease-unattributed", `${directory}.lock exists, and the dead owner's claim (${claim.state}, lease ${claim.lease}) does not make it that owner's`);
|
|
585
|
+
}
|
|
586
|
+
const body = fenced(claim.firstParticipation, {
|
|
587
|
+
generation: claim.generation,
|
|
588
|
+
state: claim.state,
|
|
589
|
+
owner: claim.owner,
|
|
590
|
+
leaseOutcome: claim.lease,
|
|
591
|
+
});
|
|
592
|
+
writeClaim(resource, body);
|
|
593
|
+
return body;
|
|
594
|
+
});
|
|
595
|
+
return new OwnerTicket(resource, written);
|
|
596
|
+
}
|
|
597
|
+
// ── An admitted owner's transitions ──────────────────────────────────────────────────────────────
|
|
598
|
+
const UNLEASED_END = { storage: "never-opened", lease: "never-held" };
|
|
599
|
+
function requireEnd(end) {
|
|
600
|
+
if (!isObject(end) ||
|
|
601
|
+
!hasExactKeys(end, ["storage", "lease"], ["envCleanup", "cause"]) ||
|
|
602
|
+
!STORAGE_ENDS.includes(end.storage) ||
|
|
603
|
+
!LEASES.includes(end.lease) ||
|
|
604
|
+
(end.envCleanup !== undefined && end.envCleanup !== "failed") ||
|
|
605
|
+
(end.cause !== undefined && typeof end.cause !== "string")) {
|
|
606
|
+
throw refuse("transition-invalid", "an end is {storage, lease, envCleanup?, cause?}");
|
|
607
|
+
}
|
|
608
|
+
return end;
|
|
609
|
+
}
|
|
610
|
+
class OwnerTicket {
|
|
611
|
+
storageOpenAttempted = false;
|
|
612
|
+
#resource;
|
|
613
|
+
/** The body this owner last wrote; its generation is what the next transition requires. */
|
|
614
|
+
#claim;
|
|
615
|
+
#ended = false;
|
|
616
|
+
constructor(resource, claim) {
|
|
617
|
+
this.#resource = resource;
|
|
618
|
+
this.#claim = claim;
|
|
619
|
+
}
|
|
620
|
+
async leased() {
|
|
621
|
+
await this.#write(["fenced"], "leased", "held", undefined);
|
|
622
|
+
}
|
|
623
|
+
async storageOpened() {
|
|
624
|
+
if (!this.storageOpenAttempted) {
|
|
625
|
+
throw refuse("transition-invalid", "storage-open without an attempted storage open");
|
|
626
|
+
}
|
|
627
|
+
await this.#write(["leased"], "storage-open", "held", undefined);
|
|
628
|
+
}
|
|
629
|
+
async endUnleased() {
|
|
630
|
+
await this.#end(["fenced"], "closed", UNLEASED_END);
|
|
631
|
+
}
|
|
632
|
+
async closed(end) {
|
|
633
|
+
const checked = this.#checkEnd(end);
|
|
634
|
+
if (checked.storage === "closed" && this.#claim.state !== "storage-open") {
|
|
635
|
+
throw refuse("transition-invalid", "a storage close is recorded only for an opened storage");
|
|
636
|
+
}
|
|
637
|
+
await this.#end(["leased", "storage-open"], "closed", checked);
|
|
638
|
+
}
|
|
639
|
+
async closeFailed(end) {
|
|
640
|
+
await this.#end(["leased", "storage-open"], "close-failed", this.#checkEnd(end));
|
|
641
|
+
}
|
|
642
|
+
#checkEnd(end) {
|
|
643
|
+
const checked = requireEnd(end);
|
|
644
|
+
if (checked.storage === "never-opened" && this.storageOpenAttempted) {
|
|
645
|
+
throw refuse("transition-invalid", "the storage open was attempted, so this end is not never-opened");
|
|
646
|
+
}
|
|
647
|
+
return checked;
|
|
648
|
+
}
|
|
649
|
+
async #end(from, to, end) {
|
|
650
|
+
await this.#write(from, to, end.lease, end);
|
|
651
|
+
this.#ended = true;
|
|
652
|
+
}
|
|
653
|
+
async #write(from, to, lease, end) {
|
|
654
|
+
if (this.#ended)
|
|
655
|
+
throw refuse("transition-invalid", "this owner's claim has already ended");
|
|
656
|
+
if (!from.includes(this.#claim.state)) {
|
|
657
|
+
throw refuse("transition-invalid", `${this.#claim.state} → ${to} is not a transition`);
|
|
658
|
+
}
|
|
659
|
+
const last = this.#claim;
|
|
660
|
+
this.#claim = await withTx(this.#resource, () => {
|
|
661
|
+
const current = readClaim(this.#resource);
|
|
662
|
+
if (current === undefined) {
|
|
663
|
+
throw refuse("claim-missing", `the claim this owner wrote in ${this.#resource} is gone`);
|
|
664
|
+
}
|
|
665
|
+
if (current.generation !== last.generation) {
|
|
666
|
+
throw refuse("generation-mismatch", `the claim in ${this.#resource} is generation ${current.generation}, not ${last.generation} this owner wrote`);
|
|
667
|
+
}
|
|
668
|
+
const at = new Date().toISOString();
|
|
669
|
+
const body = {
|
|
670
|
+
schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
|
|
671
|
+
resource: last.resource,
|
|
672
|
+
selected: last.selected,
|
|
673
|
+
state: to,
|
|
674
|
+
owner: last.owner,
|
|
675
|
+
generation: newGeneration(),
|
|
676
|
+
firstParticipation: last.firstParticipation,
|
|
677
|
+
...(last.adoptedFrom === undefined ? {} : { adoptedFrom: last.adoptedFrom }),
|
|
678
|
+
lease,
|
|
679
|
+
...(end === undefined
|
|
680
|
+
? {}
|
|
681
|
+
: {
|
|
682
|
+
end: {
|
|
683
|
+
at,
|
|
684
|
+
storage: end.storage,
|
|
685
|
+
lease: end.lease,
|
|
686
|
+
...(end.envCleanup === undefined ? {} : { envCleanup: end.envCleanup }),
|
|
687
|
+
...boundedCause(end.cause),
|
|
688
|
+
},
|
|
689
|
+
}),
|
|
690
|
+
writtenAt: at,
|
|
691
|
+
};
|
|
692
|
+
writeClaim(this.#resource, body);
|
|
693
|
+
return body;
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const META_ROOT_CHILDREN = {
|
|
698
|
+
sessionsDir: "meta-sessions",
|
|
699
|
+
mailboxDir: "meta-mailbox",
|
|
700
|
+
sendersDir: "meta-senders",
|
|
701
|
+
receiversDir: "meta-receivers",
|
|
702
|
+
};
|
|
703
|
+
function standardRoots(metaRoot) {
|
|
704
|
+
return {
|
|
705
|
+
sessionsDir: path.join(metaRoot, META_ROOT_CHILDREN.sessionsDir),
|
|
706
|
+
mailboxDir: path.join(metaRoot, META_ROOT_CHILDREN.mailboxDir),
|
|
707
|
+
sendersDir: path.join(metaRoot, META_ROOT_CHILDREN.sendersDir),
|
|
708
|
+
receiversDir: path.join(metaRoot, META_ROOT_CHILDREN.receiversDir),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/** ONE meta root whose standard children are exactly the four roots given; any other shape is refused, never merged. */
|
|
712
|
+
function requireMetaRoot(roots) {
|
|
713
|
+
const fields = Object.keys(META_ROOT_CHILDREN);
|
|
714
|
+
const absolute = (field) => {
|
|
715
|
+
const root = isObject(roots) ? roots[field] : undefined;
|
|
716
|
+
return nonEmpty(root) && path.isAbsolute(root);
|
|
717
|
+
};
|
|
718
|
+
if (!fields.every(absolute))
|
|
719
|
+
throw refuse("reservation-invalid", "a reservation names four absolute meta roots");
|
|
720
|
+
const given = roots;
|
|
721
|
+
const metaRoot = path.dirname(given.sessionsDir);
|
|
722
|
+
const standard = standardRoots(metaRoot);
|
|
723
|
+
const split = fields.filter((field) => given[field] !== standard[field]);
|
|
724
|
+
if (split.length > 0) {
|
|
725
|
+
throw refuse("reserve-meta-root-split", `${split.join(", ")}: not the standard children of ${metaRoot}`);
|
|
726
|
+
}
|
|
727
|
+
return metaRoot;
|
|
728
|
+
}
|
|
729
|
+
function requireReservationGardenId(value) {
|
|
730
|
+
try {
|
|
731
|
+
return requireGardenId(value);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
throw refuse("reservation-invalid", messageOf(error));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function requireAdoption(value) {
|
|
738
|
+
if (!isObject(value) ||
|
|
739
|
+
!hasExactKeys(value, ["gardenId", "metaRoot", "sourceGeneration"]) ||
|
|
740
|
+
!nonEmpty(value.metaRoot) ||
|
|
741
|
+
!path.isAbsolute(value.metaRoot) ||
|
|
742
|
+
typeof value.sourceGeneration !== "string" ||
|
|
743
|
+
!HEX32.test(value.sourceGeneration)) {
|
|
744
|
+
throw refuse("reservation-invalid", "an adoption is exactly {gardenId, metaRoot, sourceGeneration}");
|
|
745
|
+
}
|
|
746
|
+
const metaRoot = value.metaRoot;
|
|
747
|
+
return {
|
|
748
|
+
gardenId: requireReservationGardenId(value.gardenId),
|
|
749
|
+
metaRoot,
|
|
750
|
+
sourceGeneration: value.sourceGeneration,
|
|
751
|
+
roots: standardRoots(metaRoot),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/** Takes the record-arm lock of `gardenId` at once; a busy lock refuses with nothing read or written. */
|
|
755
|
+
function takeRecordArm(gardenId, roots) {
|
|
756
|
+
const acquired = acquireLock(gardenId, { dir: defaultMetaRecordArmLocksDir(roots.receiversDir) });
|
|
757
|
+
if (!acquired.ok)
|
|
758
|
+
throw refuse("record-arm-busy", `${acquired.conflict.detail}; nothing was read or written`);
|
|
759
|
+
return acquired.claim;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Releases the record-arm lock, after S_D. Returns what the caller throws: the step's own error when the release
|
|
763
|
+
* answered `released`, else a named release failure carrying it, and the write that step had committed if it names
|
|
764
|
+
* one. A clean step whose release answered returns undefined.
|
|
765
|
+
*/
|
|
766
|
+
function leaveRecordArm(arm, during) {
|
|
767
|
+
let released;
|
|
768
|
+
try {
|
|
769
|
+
released = releaseLock(arm);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
released = `threw: ${messageOf(error)}`;
|
|
773
|
+
}
|
|
774
|
+
if (released === "released")
|
|
775
|
+
return during;
|
|
776
|
+
const failed = during === undefined ? "" : `; the step had already failed: ${messageOf(during)}`;
|
|
777
|
+
const written = during instanceof PiDurableOwnerFenceError ? during.written : undefined;
|
|
778
|
+
return refuse("record-arm-release-failed", `the record-arm lock of ${arm.gardenId} answered ${released}${failed}`, written === undefined ? undefined : { written });
|
|
779
|
+
}
|
|
780
|
+
/** The record and C4 pair read NOW, inside the caller's arm hold (P1), or a named refusal. */
|
|
781
|
+
function freshPair(gardenId, roots) {
|
|
782
|
+
const { sessionsDir, receiversDir } = roots;
|
|
783
|
+
const verdict = verifyPiDurableLocation({ gardenId, sessionsDir, receiversDir });
|
|
784
|
+
if (!verdict.ok) {
|
|
785
|
+
const reason = verdict.reason === "binding-mismatch" ? "binding-mismatch" : "profile-record-unbound";
|
|
786
|
+
throw refuse(reason, `${gardenId}: ${verdict.reason}: ${verdict.detail}`);
|
|
787
|
+
}
|
|
788
|
+
return { record: verdict.record, location: verdict.location };
|
|
789
|
+
}
|
|
790
|
+
/** The current holder's liveness by the shared death policy; only `dead` lets a reserve take D over. */
|
|
791
|
+
function holderVerdict(holder) {
|
|
792
|
+
return classifyMarkerOwner(holder.startKey, {
|
|
793
|
+
currentStartKey: processStartKey(holder.pid),
|
|
794
|
+
pidExists: probePidExistence(holder.pid),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
/** Reserve rows 3–4: the current holder of a non-closed claim, a reservation's parent included; its host first. */
|
|
798
|
+
function requireHolderGone(claim, resource, hostname) {
|
|
799
|
+
const where = `${resource} is ${claim.state} under pid ${claim.owner.pid} on ${claim.owner.hostname}`;
|
|
800
|
+
const otherHost = claim.owner.hostname !== hostname;
|
|
801
|
+
const verdict = otherHost ? "other-host" : holderVerdict(claim.owner);
|
|
802
|
+
// The holder's state rides beside the reason: a live reservation parent is not a running citizen (S3 joint review D4).
|
|
803
|
+
const context = { holderState: claim.state };
|
|
804
|
+
if (verdict === "other-host")
|
|
805
|
+
throw refuse("owner-other-host", where, context);
|
|
806
|
+
if (verdict !== "dead") {
|
|
807
|
+
throw refuse(verdict === "live" ? "owner-live" : "owner-uncertain", `${where}: ${verdict}`, context);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/** The reservation a `reserved` claim carries; the reader guarantees one. */
|
|
811
|
+
function reservationOf(claim) {
|
|
812
|
+
if (claim.reservation === undefined) {
|
|
813
|
+
throw refuse("claim-unreadable", `the ${claim.state} claim in ${claim.resource} carries no reservation`);
|
|
814
|
+
}
|
|
815
|
+
return claim.reservation;
|
|
816
|
+
}
|
|
817
|
+
/** The native donor: a `reserved` claim's flat `from`, or a native claim's own row. Never a reservation's parent. */
|
|
818
|
+
function donorOf(claim) {
|
|
819
|
+
if (claim.state === "reserved")
|
|
820
|
+
return reservationOf(claim).from;
|
|
821
|
+
return {
|
|
822
|
+
generation: claim.generation,
|
|
823
|
+
state: claim.state,
|
|
824
|
+
owner: claim.owner,
|
|
825
|
+
lease: claim.lease,
|
|
826
|
+
...(claim.end === undefined ? {} : { end: claim.end }),
|
|
827
|
+
selected: claim.selected,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function reclaimOf(claim) {
|
|
831
|
+
return { generation: claim.generation, state: claim.state, owner: claim.owner, leaseOutcome: claim.lease };
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* P2–P6 on the native donor, on the pair read in this hold (P1); P2 is the claim's resource row before this. The donor
|
|
835
|
+
* is the incarnation C4's binding names, on this host; its selection is the record's session, joined from its own raw
|
|
836
|
+
* profile, and still reaches R. Returns that profile. A schema 1 donor recorded none, and none is ever inferred.
|
|
837
|
+
*/
|
|
838
|
+
function requireProfile(donor, pair, resource) {
|
|
839
|
+
const { record, location } = pair;
|
|
840
|
+
const { owner, selected } = donor;
|
|
841
|
+
if (owner.hostname !== os.hostname())
|
|
842
|
+
throw refuse("profile-other-host", `the donor was owned on ${owner.hostname}`);
|
|
843
|
+
if (owner.pid !== location.ownerPid || owner.startKey !== location.ownerStartKey) {
|
|
844
|
+
throw refuse("profile-unattributed", `pid ${owner.pid} is not the incarnation ${record.gardenId}'s binding names`);
|
|
845
|
+
}
|
|
846
|
+
if (selected.n !== record.nativeSessionId) {
|
|
847
|
+
throw refuse("profile-n-mismatch", `the donor selected ${selected.n}, the record names ${record.nativeSessionId}`);
|
|
848
|
+
}
|
|
849
|
+
const recorded = selected.profile;
|
|
850
|
+
if (!nonEmpty(recorded))
|
|
851
|
+
throw refuse("profile-unattributed", "the donor's schema 1 claim records no profile");
|
|
852
|
+
const cwdHash = createHash("sha256").update(record.cwd).digest("hex").slice(0, 24);
|
|
853
|
+
if (path.join(recorded, "experimental", "durable-sessions", cwdHash, selected.n) !== selected.directory) {
|
|
854
|
+
throw refuse("profile-layout-mismatch", `${selected.directory} is not joined from ${recorded} and ${record.cwd}`);
|
|
855
|
+
}
|
|
856
|
+
let real;
|
|
857
|
+
try {
|
|
858
|
+
real = fs.realpathSync(selected.directory);
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
throw refuse("profile-alias-moved", `${selected.directory}: ${messageOf(error)}`);
|
|
862
|
+
}
|
|
863
|
+
if (real !== resource)
|
|
864
|
+
throw refuse("profile-alias-moved", `${selected.directory} now reaches ${real}, not ${resource}`);
|
|
865
|
+
return recorded;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* A parent's reservation of one pi-durable citizen's D for one child (#157 B3): record-arm(gid), then S_D(R), on the
|
|
869
|
+
* record and C4 pair read inside the arm hold. Both are released before this returns, so before any launch; the
|
|
870
|
+
* caller's own per-target lock, if it holds one, stays its outer scope and is never taken here. An eligibility
|
|
871
|
+
* refusal writes nothing. A release that fails AFTER the reservation was written is a different failure: the
|
|
872
|
+
* reservation exists, and the error names it in `written` (its reason and message stay the release's own). Either
|
|
873
|
+
* way its caller must not launch, and nothing here withdraws, retries or repairs.
|
|
874
|
+
*/
|
|
875
|
+
export async function reservePiDurableSession(request) {
|
|
876
|
+
if (!isObject(request))
|
|
877
|
+
throw refuse("reservation-invalid", "a reserve names a garden id and four meta roots");
|
|
878
|
+
const gardenId = requireReservationGardenId(request.gardenId);
|
|
879
|
+
const metaRoot = requireMetaRoot(request.roots);
|
|
880
|
+
const roots = standardRoots(metaRoot);
|
|
881
|
+
const owner = thisOwner();
|
|
882
|
+
const arm = takeRecordArm(gardenId, roots);
|
|
883
|
+
let reserved;
|
|
884
|
+
try {
|
|
885
|
+
reserved = await reserveInPair(gardenId, roots, metaRoot, owner);
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
throw leaveRecordArm(arm, error);
|
|
889
|
+
}
|
|
890
|
+
const failure = leaveRecordArm(arm, undefined);
|
|
891
|
+
if (failure !== undefined) {
|
|
892
|
+
const { resource, generation } = reserved;
|
|
893
|
+
throw afterWrite(failure, { resource, state: "reserved", generation, ownerPid: owner.pid });
|
|
894
|
+
}
|
|
895
|
+
return reserved;
|
|
896
|
+
}
|
|
897
|
+
/** The reserve's rows and write, inside S_D(R), on the pair read in this arm hold. Rows decide before P-rules. */
|
|
898
|
+
async function reserveInPair(gardenId, roots, metaRoot, owner) {
|
|
899
|
+
const pair = freshPair(gardenId, roots);
|
|
900
|
+
const { record, location } = pair;
|
|
901
|
+
const resource = location.nativeDirectory;
|
|
902
|
+
// Set once the write returned: a release failing after it must name the reservation it left (joint review D3).
|
|
903
|
+
let committed;
|
|
904
|
+
const written = await withTx(resource, () => {
|
|
905
|
+
const claim = readClaim(resource);
|
|
906
|
+
if (claim === undefined)
|
|
907
|
+
throw refuse("reserve-unarmed", `${resource} holds no claim; a reserve never enrolls`);
|
|
908
|
+
if (claim.resource !== resource) {
|
|
909
|
+
throw refuse("claim-resource-mismatch", `the claim in ${resource} names ${claim.resource}`);
|
|
910
|
+
}
|
|
911
|
+
if (claim.state !== "closed")
|
|
912
|
+
requireHolderGone(claim, resource, owner.hostname);
|
|
913
|
+
if (claim.state === "closed" && claim.end?.storage !== "closed") {
|
|
914
|
+
throw refuse("reserve-not-clean", `${resource} closed without its storage ever opening`);
|
|
915
|
+
}
|
|
916
|
+
// P1–P6 judge the native donor: a dead reservation's flat `from`, never its parent.
|
|
917
|
+
const donor = donorOf(claim);
|
|
918
|
+
const profile = requireProfile(donor, pair, resource);
|
|
919
|
+
// The snapshot is taken on S, the donor's own selection: the SDK's lease is `${S}.lock`, never `${R}.lock`.
|
|
920
|
+
const sidecar = leasePresent(donor.selected.directory);
|
|
921
|
+
let nativeLease = "absent";
|
|
922
|
+
if (claim.state === "closed") {
|
|
923
|
+
if (sidecar)
|
|
924
|
+
throw refuse("legacy-lease-present", `${donor.selected.directory}.lock exists after a clean close`);
|
|
925
|
+
}
|
|
926
|
+
else if (claim.state === "reserved") {
|
|
927
|
+
const before = reservationOf(claim).nativeLease;
|
|
928
|
+
if (before === "absent" && sidecar) {
|
|
929
|
+
throw refuse("lease-appeared", `${donor.selected.directory}.lock appeared after its reservation saw none`);
|
|
930
|
+
}
|
|
931
|
+
if (before === "present-attributed" && sidecar)
|
|
932
|
+
nativeLease = "present-attributed";
|
|
933
|
+
}
|
|
934
|
+
else if (leaseAttributed(claim.lease)) {
|
|
935
|
+
if (sidecar)
|
|
936
|
+
nativeLease = "present-attributed";
|
|
937
|
+
}
|
|
938
|
+
else if (sidecar) {
|
|
939
|
+
throw refuse("lease-unattributed", `${donor.selected.directory}.lock exists, and the dead owner never held it`);
|
|
940
|
+
}
|
|
941
|
+
const body = {
|
|
942
|
+
schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
|
|
943
|
+
resource,
|
|
944
|
+
selected: donor.selected,
|
|
945
|
+
state: "reserved",
|
|
946
|
+
owner,
|
|
947
|
+
generation: newGeneration(),
|
|
948
|
+
firstParticipation: claim.firstParticipation,
|
|
949
|
+
...(claim.state === "closed" ? {} : { reclaimedFrom: reclaimOf(claim) }),
|
|
950
|
+
reservation: {
|
|
951
|
+
gardenId: record.gardenId,
|
|
952
|
+
n: record.nativeSessionId,
|
|
953
|
+
cwd: record.cwd,
|
|
954
|
+
resource,
|
|
955
|
+
profile,
|
|
956
|
+
attachIncarnation: location.attachIncarnation,
|
|
957
|
+
nativeLease,
|
|
958
|
+
from: donor,
|
|
959
|
+
},
|
|
960
|
+
lease: "never-held",
|
|
961
|
+
writtenAt: new Date().toISOString(),
|
|
962
|
+
};
|
|
963
|
+
writeClaim(resource, body);
|
|
964
|
+
committed = writtenOf(body);
|
|
965
|
+
return body;
|
|
966
|
+
}).catch((error) => {
|
|
967
|
+
throw afterWrite(error, committed);
|
|
968
|
+
});
|
|
969
|
+
return {
|
|
970
|
+
gardenId: record.gardenId,
|
|
971
|
+
metaRoot,
|
|
972
|
+
resource,
|
|
973
|
+
generation: written.generation,
|
|
974
|
+
profile: reservationOf(written).profile,
|
|
975
|
+
cwd: record.cwd,
|
|
976
|
+
owner,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* A launched child's adoption of exactly one reservation (#157 B3), inside record-arm(gid) then S_D(R), never the
|
|
981
|
+
* parent's per-target lock, on the pair read in this hold. Every row must hold; a refusal writes nothing and takes no
|
|
982
|
+
* lease. The admission's directory is resolved inside S_D, never before it. The ticket returns only after both locks
|
|
983
|
+
* are released, so the native lease and storage come after them.
|
|
984
|
+
*/
|
|
985
|
+
async function adoptOwner(request, adoption) {
|
|
986
|
+
const admission = requireAdmission(request);
|
|
987
|
+
const owner = thisOwner();
|
|
988
|
+
const arm = takeRecordArm(adoption.gardenId, adoption.roots);
|
|
989
|
+
let adopted;
|
|
990
|
+
try {
|
|
991
|
+
adopted = await adoptInPair(admission, adoption, owner);
|
|
992
|
+
}
|
|
993
|
+
catch (error) {
|
|
994
|
+
throw leaveRecordArm(arm, error);
|
|
995
|
+
}
|
|
996
|
+
const failure = leaveRecordArm(arm, undefined);
|
|
997
|
+
if (failure !== undefined)
|
|
998
|
+
throw failure;
|
|
999
|
+
return new OwnerTicket(adopted.resource, adopted);
|
|
1000
|
+
}
|
|
1001
|
+
/** The adoption's rows and write, inside S_D(R), on the pair read in this arm hold. */
|
|
1002
|
+
async function adoptInPair(admission, adoption, owner) {
|
|
1003
|
+
const { record, location } = freshPair(adoption.gardenId, adoption.roots);
|
|
1004
|
+
const resource = location.nativeDirectory;
|
|
1005
|
+
const { directory, selectedN, profile } = admission;
|
|
1006
|
+
return withTx(resource, () => {
|
|
1007
|
+
const claim = readClaim(resource);
|
|
1008
|
+
if (claim !== undefined && claim.resource !== resource) {
|
|
1009
|
+
throw refuse("claim-resource-mismatch", `the claim in ${resource} names ${claim.resource}`);
|
|
1010
|
+
}
|
|
1011
|
+
if (claim === undefined || claim.state !== "reserved") {
|
|
1012
|
+
throw refuse("adopt-not-reserved", `${resource} holds no reservation to adopt`);
|
|
1013
|
+
}
|
|
1014
|
+
const reservation = reservationOf(claim);
|
|
1015
|
+
if (claim.generation !== adoption.sourceGeneration) {
|
|
1016
|
+
throw refuse("adopt-generation-mismatch", `${resource} is at ${claim.generation}, not ${adoption.sourceGeneration}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (record.attachIncarnation !== reservation.attachIncarnation) {
|
|
1019
|
+
throw refuse("adopt-binding-changed", `${record.gardenId} was bound again after its reservation`);
|
|
1020
|
+
}
|
|
1021
|
+
// D as the admission names it NOW, in this hold: a path resolved before S_D is not this decision's evidence.
|
|
1022
|
+
let real;
|
|
1023
|
+
try {
|
|
1024
|
+
real = fs.realpathSync(directory);
|
|
1025
|
+
}
|
|
1026
|
+
catch (error) {
|
|
1027
|
+
throw refuse("directory-invalid", `${directory}: ${messageOf(error)}`);
|
|
1028
|
+
}
|
|
1029
|
+
const expected = [adoption.gardenId, record.nativeSessionId, record.cwd, real];
|
|
1030
|
+
const named = [reservation.gardenId, reservation.n, reservation.cwd, reservation.resource];
|
|
1031
|
+
if (expected.some((value, index) => value !== named[index])) {
|
|
1032
|
+
throw refuse("adopt-reservation-mismatch", `the reservation names ${named.join(", ")}, not ${expected.join(", ")}`);
|
|
1033
|
+
}
|
|
1034
|
+
const cwdHash = createHash("sha256").update(record.cwd).digest("hex").slice(0, 24);
|
|
1035
|
+
const joined = path.join(profile, "experimental", "durable-sessions", cwdHash, record.nativeSessionId);
|
|
1036
|
+
if (profile !== reservation.profile || directory !== claim.selected.directory || joined !== directory) {
|
|
1037
|
+
throw refuse("adopt-profile-mismatch", `${directory} with profile ${profile} is not the reserved selection`);
|
|
1038
|
+
}
|
|
1039
|
+
if (claim.owner.hostname !== owner.hostname) {
|
|
1040
|
+
throw refuse("owner-other-host", `the reservation in ${resource} was taken on ${claim.owner.hostname}`);
|
|
1041
|
+
}
|
|
1042
|
+
if (reservation.nativeLease === "absent" && leasePresent(directory)) {
|
|
1043
|
+
throw refuse("adopt-lease-appeared", `${directory}.lock appeared after the reservation saw none`);
|
|
1044
|
+
}
|
|
1045
|
+
const body = {
|
|
1046
|
+
schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
|
|
1047
|
+
resource,
|
|
1048
|
+
selected: { n: selectedN, directory, profile },
|
|
1049
|
+
state: "fenced",
|
|
1050
|
+
owner,
|
|
1051
|
+
generation: newGeneration(),
|
|
1052
|
+
firstParticipation: claim.firstParticipation,
|
|
1053
|
+
adoptedFrom: { generation: claim.generation, owner: claim.owner },
|
|
1054
|
+
lease: "never-held",
|
|
1055
|
+
writtenAt: new Date().toISOString(),
|
|
1056
|
+
};
|
|
1057
|
+
writeClaim(resource, body);
|
|
1058
|
+
return body;
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
function sameOwner(a, b) {
|
|
1062
|
+
return a.pid === b.pid && a.startKey === b.startKey && a.hostname === b.hostname;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* The parent's withdrawal of its OWN reservation, once its launch is definitely none-created (its caller's judgement
|
|
1066
|
+
* from the rail's own outcome). One S_D hold and no record or C4 read: only `reserved` + this owner + exactly g_r is
|
|
1067
|
+
* withdrawn, back to the donor's own row under a new generation. Nothing of the reservation, the reserve's reclaim or
|
|
1068
|
+
* any earlier history is carried; anything else writes nothing and refuses `withdraw-not-own`. A release that fails
|
|
1069
|
+
* after the donor's row was written names that row in `written` (joint review D1): the reservation is gone then, and
|
|
1070
|
+
* only the release's residue is left.
|
|
1071
|
+
*/
|
|
1072
|
+
export async function withdrawPiDurableReservation(reservation) {
|
|
1073
|
+
const owner = thisOwner();
|
|
1074
|
+
const resource = reservation.resource;
|
|
1075
|
+
let committed;
|
|
1076
|
+
await withTx(resource, () => {
|
|
1077
|
+
const claim = readClaim(resource);
|
|
1078
|
+
if (claim?.state !== "reserved")
|
|
1079
|
+
throw refuse("withdraw-not-own", `${resource} is not reserved`);
|
|
1080
|
+
if (claim.generation !== reservation.generation) {
|
|
1081
|
+
throw refuse("withdraw-not-own", `${resource} is at ${claim.generation}, not ${reservation.generation}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (!sameOwner(claim.owner, owner)) {
|
|
1084
|
+
throw refuse("withdraw-not-own", `${resource} is reserved by pid ${claim.owner.pid}, not this process`);
|
|
1085
|
+
}
|
|
1086
|
+
const donor = reservationOf(claim).from;
|
|
1087
|
+
const body = {
|
|
1088
|
+
schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
|
|
1089
|
+
resource: claim.resource,
|
|
1090
|
+
selected: donor.selected,
|
|
1091
|
+
state: donor.state,
|
|
1092
|
+
owner: donor.owner,
|
|
1093
|
+
generation: newGeneration(),
|
|
1094
|
+
firstParticipation: claim.firstParticipation,
|
|
1095
|
+
lease: donor.lease,
|
|
1096
|
+
...(donor.end === undefined ? {} : { end: donor.end }),
|
|
1097
|
+
writtenAt: new Date().toISOString(),
|
|
1098
|
+
};
|
|
1099
|
+
writeClaim(resource, body);
|
|
1100
|
+
committed = writtenOf(body);
|
|
1101
|
+
}).catch((error) => {
|
|
1102
|
+
throw afterWrite(error, committed);
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* `adopted`: any state carrying `adoptedFrom.generation === g_r` (an adoption write, not readiness or success);
|
|
1107
|
+
* `not-yet-adopted`: still `reserved` at g_r; `lost`: any other claim the strict reader accepted. A claim that could
|
|
1108
|
+
* not be read, is gone, or names another resource is `unobserved` by name, never `lost`.
|
|
1109
|
+
*/
|
|
1110
|
+
export function observePiDurableReservation(reservation) {
|
|
1111
|
+
let claim;
|
|
1112
|
+
try {
|
|
1113
|
+
claim = readClaim(reservation.resource);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
if (error instanceof PiDurableOwnerFenceError) {
|
|
1117
|
+
return { class: "unobserved", reason: error.reason, detail: error.message };
|
|
1118
|
+
}
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
if (claim === undefined) {
|
|
1122
|
+
return { class: "unobserved", reason: "claim-missing", detail: `${reservation.resource} holds no claim` };
|
|
1123
|
+
}
|
|
1124
|
+
if (claim.resource !== reservation.resource) {
|
|
1125
|
+
return { class: "unobserved", reason: "claim-resource-mismatch", detail: `the claim names ${claim.resource}` };
|
|
1126
|
+
}
|
|
1127
|
+
if (claim.adoptedFrom?.generation === reservation.generation)
|
|
1128
|
+
return { class: "adopted" };
|
|
1129
|
+
if (claim.state === "reserved" && claim.generation === reservation.generation)
|
|
1130
|
+
return { class: "not-yet-adopted" };
|
|
1131
|
+
return { class: "lost" };
|
|
1132
|
+
}
|