segreant 0.0.0-stage → 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/LICENSE +116 -0
- package/LICENSING.md +62 -0
- package/README.md +315 -2
- package/baselines/lift-baselines.json +22 -0
- package/bin/publication-lock.mjs +748 -0
- package/bin/runtime-snapshot.mjs +266 -0
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +96 -0
- package/dist/alerts/detect.js +245 -0
- package/dist/alerts/notify.js +66 -0
- package/dist/alloc/apply.js +0 -0
- package/dist/alloc/epistemic.js +154 -0
- package/dist/alloc/exact.js +714 -0
- package/dist/alloc/rules.js +0 -0
- package/dist/billing/countermodels.js +123 -0
- package/dist/billing/epistemic.js +636 -0
- package/dist/billing/importer.js +75 -0
- package/dist/billing/mapping.js +239 -0
- package/dist/billing/openaiCosts.js +352 -0
- package/dist/billing/openaiCostsCoverage.js +108 -0
- package/dist/billing/readiness.js +68 -0
- package/dist/billing/reconcile.js +414 -0
- package/dist/billing/scope.js +68 -0
- package/dist/billing/types.js +180 -0
- package/dist/budget/allocate.js +100 -0
- package/dist/budget/capDecision.js +457 -0
- package/dist/budget/controlLock.js +71 -0
- package/dist/budget/enforceability.js +39 -0
- package/dist/budget/guard.js +313 -0
- package/dist/budget/onlineControl.js +404 -0
- package/dist/budget/recommend.js +153 -0
- package/dist/capital.js +220 -0
- package/dist/causal/assignment.js +179 -0
- package/dist/causal/design.js +161 -0
- package/dist/causal/epistemic.js +428 -0
- package/dist/causal/estimand.js +64 -0
- package/dist/causal/estimate.js +237 -0
- package/dist/causal/family.js +61 -0
- package/dist/causal/identity.js +18 -0
- package/dist/causal/inference-ledger.js +400 -0
- package/dist/causal/ledger.js +201 -0
- package/dist/causal/measurement.js +150 -0
- package/dist/causal/ope.js +297 -0
- package/dist/causal/precision.js +206 -0
- package/dist/causal/producer.js +774 -0
- package/dist/causal/protocol.js +962 -0
- package/dist/causal/qualification.js +260 -0
- package/dist/causal/records.js +416 -0
- package/dist/causal/registry.js +58 -0
- package/dist/causal/sequential.js +679 -0
- package/dist/causal/transport.js +138 -0
- package/dist/causal/types.js +10 -0
- package/dist/cli/allocCmd.js +340 -0
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +100 -0
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +1007 -0
- package/dist/cli/capitalCmd.js +65 -0
- package/dist/cli/causalCmd.js +340 -0
- package/dist/cli/connectCmd.js +466 -0
- package/dist/cli/controlCmd.js +341 -0
- package/dist/cli/diagnosticsCmd.js +41 -0
- package/dist/cli/economicCmd.js +384 -0
- package/dist/cli/egressCmd.js +248 -0
- package/dist/cli/evidenceCmd.js +157 -0
- package/dist/cli/featuresCmd.js +53 -0
- package/dist/cli/flags.js +128 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +657 -0
- package/dist/cli/launchCmd.js +90 -0
- package/dist/cli/marketCmd.js +178 -0
- package/dist/cli/opsCmd.js +335 -0
- package/dist/cli/outcomeCmd.js +120 -0
- package/dist/cli/packCmd.js +172 -0
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pluginCmd.js +145 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/retention.js +28 -0
- package/dist/cli/runCmd.js +591 -0
- package/dist/cli/showCmd.js +500 -0
- package/dist/cli/teamCmd.js +760 -0
- package/dist/cli/ui.js +128 -0
- package/dist/cli/valueCmd.js +1001 -0
- package/dist/cli.js +628 -0
- package/dist/config.js +677 -0
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +218 -0
- package/dist/connect/codex.js +320 -0
- package/dist/connect/connectors.js +285 -0
- package/dist/connect/importShared.js +283 -0
- package/dist/connect/opencode.js +214 -0
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/coverage.js +53 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +132 -0
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +997 -0
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +271 -0
- package/dist/dashboard/contracts.js +211 -0
- package/dist/dashboard/routes.js +2004 -0
- package/dist/dashboard/server.js +76 -0
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +109 -0
- package/dist/dashboard/shared-types.js +6 -0
- package/dist/dashboard/static.js +111 -0
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +138 -0
- package/dist/dashboard/web/app/components/drawer.js +284 -0
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +163 -0
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +1021 -0
- package/dist/dashboard/web/app/core/api.js +164 -0
- package/dist/dashboard/web/app/core/chain.js +40 -0
- package/dist/dashboard/web/app/core/claimLayers.js +392 -0
- package/dist/dashboard/web/app/core/claimTypes.js +222 -0
- package/dist/dashboard/web/app/core/dom.js +138 -0
- package/dist/dashboard/web/app/core/fmt.js +159 -0
- package/dist/dashboard/web/app/core/focus.js +10 -0
- package/dist/dashboard/web/app/core/generated-contract.js +211 -0
- package/dist/dashboard/web/app/core/generated-payload-contract.js +4579 -0
- package/dist/dashboard/web/app/core/generated-types.js +8 -0
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +337 -0
- package/dist/dashboard/web/app/core/signal.js +140 -0
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +362 -0
- package/dist/dashboard/web/app/views/allocation.js +278 -0
- package/dist/dashboard/web/app/views/control.js +249 -0
- package/dist/dashboard/web/app/views/data.js +136 -0
- package/dist/dashboard/web/app/views/evidence.js +553 -0
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +143 -0
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +80 -0
- package/dist/dashboard/web/app/views/value.js +534 -0
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +2151 -0
- package/dist/dashboard/web/index.html +51 -0
- package/dist/dashboard/web/styles/app.css +1336 -0
- package/dist/decision/assurance.js +425 -0
- package/dist/decision/control.js +336 -0
- package/dist/decision/countermodels.js +118 -0
- package/dist/decision/engine.js +382 -0
- package/dist/decision/epistemic.js +546 -0
- package/dist/demo/seed.js +741 -0
- package/dist/diagnostics.js +236 -0
- package/dist/economics/attribution.js +201 -0
- package/dist/economics/close.js +133 -0
- package/dist/economics/corrections.js +120 -0
- package/dist/economics/epistemic.js +164 -0
- package/dist/economics/events.js +294 -0
- package/dist/economics/fx.js +245 -0
- package/dist/economics/ledger.js +1516 -0
- package/dist/economics/money.js +177 -0
- package/dist/economics/rate.js +331 -0
- package/dist/economics/request.js +89 -0
- package/dist/economics/serialization.js +99 -0
- package/dist/egress/policy.js +192 -0
- package/dist/egress/proxyHealth.js +36 -0
- package/dist/egress/receipts.js +871 -0
- package/dist/egress/ruleValidation.js +127 -0
- package/dist/egress/transport.js +395 -0
- package/dist/epistemic/abstract.js +432 -0
- package/dist/epistemic/admissibility.js +213 -0
- package/dist/epistemic/assumption.js +134 -0
- package/dist/epistemic/claim-uses.js +202 -0
- package/dist/epistemic/claim.js +264 -0
- package/dist/epistemic/countermodel.js +278 -0
- package/dist/epistemic/dag.js +421 -0
- package/dist/epistemic/derivation.js +391 -0
- package/dist/epistemic/evidence.js +254 -0
- package/dist/epistemic/grain.js +82 -0
- package/dist/epistemic/issuance-map.js +260 -0
- package/dist/epistemic/ledger.js +1489 -0
- package/dist/epistemic/obligation.js +149 -0
- package/dist/epistemic/preservation.js +178 -0
- package/dist/epistemic/profile.js +61 -0
- package/dist/epistemic/revocation.js +65 -0
- package/dist/epistemic/scope.js +66 -0
- package/dist/epistemic/serialization.js +171 -0
- package/dist/epistemic/state.js +46 -0
- package/dist/epistemic/time.js +37 -0
- package/dist/epistemic/ux.js +104 -0
- package/dist/epistemic/witness.js +176 -0
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +36 -0
- package/dist/export/csv.js +52 -0
- package/dist/export/economic.js +105 -0
- package/dist/export/focusBilling.js +66 -0
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/completeness.js +78 -0
- package/dist/git/contribution.js +598 -0
- package/dist/git/correlate.js +286 -0
- package/dist/git/quality.js +467 -0
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/githubActionsEvidence.js +234 -0
- package/dist/guide.js +159 -0
- package/dist/judge/call.js +161 -0
- package/dist/judge/orchestrate.js +127 -0
- package/dist/judge/payload.js +47 -0
- package/dist/judge/tier.js +107 -0
- package/dist/judge/transcript.js +324 -0
- package/dist/market/consensus.js +181 -0
- package/dist/market/market.js +323 -0
- package/dist/market/refresh.js +119 -0
- package/dist/market/sources.js +236 -0
- package/dist/market/zip.js +98 -0
- package/dist/measurement/completeness.js +125 -0
- package/dist/measurement/model.js +113 -0
- package/dist/measurement/registry.js +166 -0
- package/dist/measurement/surrogate.js +325 -0
- package/dist/outcomes/contract.js +107 -0
- package/dist/outcomes/registry.js +259 -0
- package/dist/outcomes/work-unit.js +42 -0
- package/dist/pack/canonical.js +63 -0
- package/dist/pack/export.js +120 -0
- package/dist/pack/index.js +5 -0
- package/dist/pack/manifest.js +77 -0
- package/dist/pack/signature.js +69 -0
- package/dist/pack/types.js +455 -0
- package/dist/pack/verifier.js +275 -0
- package/dist/plans/detect.js +147 -0
- package/dist/plugins/contract.js +497 -0
- package/dist/plugins/host.js +333 -0
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/intake.js +137 -0
- package/dist/plugins/invocation.js +158 -0
- package/dist/plugins/isolation.js +203 -0
- package/dist/proxy/server.js +891 -0
- package/dist/proxy/stream-proposals.js +249 -0
- package/dist/proxy/usage.js +213 -0
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/research/complexity/calibration.js +110 -0
- package/dist/research/complexity/lab.js +134 -0
- package/dist/research/complexity/profile.js +78 -0
- package/dist/research/economics/budgetRisk.js +100 -0
- package/dist/research/economics/capital.js +101 -0
- package/dist/research/economics/decisionLedger.js +103 -0
- package/dist/research/economics/decomposition.js +106 -0
- package/dist/research/economics/execution.js +42 -0
- package/dist/research/economics/frontier.js +113 -0
- package/dist/research/economics/metricSafety.js +74 -0
- package/dist/research/economics/offPolicy.js +120 -0
- package/dist/research/economics/promotion.js +85 -0
- package/dist/scan/knownApps.js +113 -0
- package/dist/scan/scan.js +220 -0
- package/dist/store/allocation.js +543 -0
- package/dist/store/backup.js +368 -0
- package/dist/store/billing.js +919 -0
- package/dist/store/causal.js +2547 -0
- package/dist/store/causalLineage.js +912 -0
- package/dist/store/causalProducer.js +462 -0
- package/dist/store/db.js +2726 -0
- package/dist/store/economicReadModel.js +270 -0
- package/dist/store/ope.js +67 -0
- package/dist/store/realization.js +379 -0
- package/dist/store/rows.js +52 -0
- package/dist/store/schema.js +2218 -0
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/team/ledger-evidence.js +82 -0
- package/dist/team/rollup.js +496 -0
- package/dist/team/standardize.js +115 -0
- package/dist/util/json.js +24 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/quiet.js +18 -0
- package/dist/util/resource-limits.js +113 -0
- package/dist/util/statements.js +61 -0
- package/dist/util/time.js +7 -0
- package/dist/util/transaction.js +68 -0
- package/dist/value/anytime.js +106 -0
- package/dist/value/characterization.js +77 -0
- package/dist/value/cohort.js +212 -0
- package/dist/value/drift.js +211 -0
- package/dist/value/epistemic.js +448 -0
- package/dist/value/frontier.js +513 -0
- package/dist/value/gates.js +276 -0
- package/dist/value/instrumentationSensitivity.js +76 -0
- package/dist/value/lenses.js +398 -0
- package/dist/value/lift.js +206 -0
- package/dist/value/liftBaseline.js +355 -0
- package/dist/value/liftEfficiency.js +68 -0
- package/dist/value/marginal.js +154 -0
- package/dist/value/proposals.js +263 -0
- package/dist/value/realization.js +1159 -0
- package/dist/value/receipt.js +222 -0
- package/dist/value/receiptReconciliation.js +117 -0
- package/dist/value/reliability.js +101 -0
- package/dist/value/report.js +338 -0
- package/dist/value/selfReported.js +224 -0
- package/dist/value/sourceDepth.js +25 -0
- package/dist/value/taskType.js +46 -0
- package/dist/value/timeReclaimed.js +90 -0
- package/dist/value/usage.js +281 -0
- package/dist/version.js +20 -0
- package/docs/ALLOCATION.md +263 -0
- package/docs/ARCHITECTURE.md +452 -0
- package/docs/BILLING-EVIDENCE-IMPORT.md +274 -0
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +220 -0
- package/docs/CAUSAL-EVIDENCE-PROTOCOL.md +295 -0
- package/docs/CAUSAL-PRODUCER-CONTRACT.md +128 -0
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/COMPATIBILITY.md +64 -0
- package/docs/DATA-BOUNDARIES.md +300 -0
- package/docs/EVIDENCE-PROVENANCE.md +40 -0
- package/docs/FAQ.md +275 -0
- package/docs/FOCUS-COMPATIBILITY.md +15 -0
- package/docs/GETTING-STARTED.md +236 -0
- package/docs/GUIDE.md +830 -0
- package/docs/INTEGRATIONS.md +233 -0
- package/docs/MARKET-SOURCES.md +142 -0
- package/docs/METHODOLOGY.md +166 -0
- package/docs/NEUTRALITY.md +95 -0
- package/docs/PRIVACY.md +51 -0
- package/docs/PROVIDER-RECONCILIATION.md +260 -0
- package/docs/README.md +52 -0
- package/docs/REAL-PROVIDER-RECONCILIATION-RUNBOOK.md +44 -0
- package/docs/RELEASE-GATE.md +682 -0
- package/docs/RELEASE-PROCESS.md +68 -0
- package/docs/RELIABILITY-PERFORMANCE.md +149 -0
- package/docs/RETURN-ON-INTELLIGENCE.md +808 -0
- package/docs/SUPPORT.md +37 -0
- package/docs/THE-STANDARD.md +235 -0
- package/docs/THREAT-MODEL.md +114 -0
- package/market/consensus-fits.json +1 -0
- package/market/snapshot.json +1 -0
- package/package.json +79 -4
- package/pricing/models.json +1400 -0
- package/standalone/segreantpack-verifier.d.mts +61 -0
- package/standalone/segreantpack-verifier.mjs +715 -0
- package/web/assets/brand/mark-256.png +0 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
// Shared cross-process publication gate for the build and the supported CLI
|
|
2
|
+
// launcher. This module intentionally has no side effects on import: the
|
|
3
|
+
// package launcher can use it before resolving any generated runtime module.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// A full cross-platform test/build fan-out can legitimately queue several
|
|
18
|
+
// publication attempts behind slow filesystem/module-loader work. Keep the
|
|
19
|
+
// wait finite for a genuinely wedged live owner, but do not turn that queue into
|
|
20
|
+
// a false build failure at the old two-minute boundary.
|
|
21
|
+
export const LOCK_WAIT_MS = 300_000;
|
|
22
|
+
/**
|
|
23
|
+
* How long an owner-less runtime-snapshot tree is left before it is reaped.
|
|
24
|
+
*
|
|
25
|
+
* This constant is now the SNAPSHOT reaper's, not the lock's — see
|
|
26
|
+
* `bin/runtime-snapshot.mjs`. The two once shared it because they share a
|
|
27
|
+
* shape (owner-bearing objects judged by PID liveness, owner-less ones by age),
|
|
28
|
+
* but not a cost: nothing waits on a snapshot tree, so a generous grace there
|
|
29
|
+
* only leaves a directory on disk a while longer. Waiting is exactly what the
|
|
30
|
+
* lock does, which is why it needed its own, much shorter budget.
|
|
31
|
+
*/
|
|
32
|
+
export const LOCK_STALE_MS = 10 * 60_000;
|
|
33
|
+
/**
|
|
34
|
+
* How long a lock carrying no owner token in any form is treated as live.
|
|
35
|
+
*
|
|
36
|
+
* Reaching that state means a creator died inside a one-syscall window, so the
|
|
37
|
+
* grace only has to exceed that. It must stay well under `LOCK_WAIT_MS`: at ten
|
|
38
|
+
* minutes it exceeded the wait, and a single such directory wedged every
|
|
39
|
+
* contender until each one timed out.
|
|
40
|
+
*/
|
|
41
|
+
export const OWNERLESS_LOCK_STALE_MS = 10_000;
|
|
42
|
+
/**
|
|
43
|
+
* How long release keeps trying the atomic hand-back before removing the lock
|
|
44
|
+
* in place.
|
|
45
|
+
*
|
|
46
|
+
* It only has to outlast transient interference — an open handle, a competing
|
|
47
|
+
* reader mid-scan. It must stay far below `LOCK_WAIT_MS`, because everything
|
|
48
|
+
* queued behind this lock is waiting for exactly this budget to expire.
|
|
49
|
+
*/
|
|
50
|
+
export const RELEASE_BUDGET_MS = 15_000;
|
|
51
|
+
export const LOCK_POLL_MS = 25;
|
|
52
|
+
export const RENAME_RETRY_MS = 5_000;
|
|
53
|
+
/**
|
|
54
|
+
* How long a canonical-path filesystem error is tolerated before it is reported
|
|
55
|
+
* as itself.
|
|
56
|
+
*
|
|
57
|
+
* Contention clears in milliseconds; a full disk or a read-only tree never does,
|
|
58
|
+
* and must be reported as what it is rather than as a wait timeout naming the
|
|
59
|
+
* wrong problem. The budget only has to exceed a plausible run of interference
|
|
60
|
+
* while staying far below `LOCK_WAIT_MS`, so it is generous on purpose.
|
|
61
|
+
*/
|
|
62
|
+
export const PATH_CONTENTION_MS = 10_000;
|
|
63
|
+
export const OWNER_FILE = 'owner.json';
|
|
64
|
+
export const OWNER_QUARANTINE_FILE = '.owner-quarantine.json';
|
|
65
|
+
export const LOCK_QUARANTINE_PREFIX = '.segreant-build.lock.quarantine-';
|
|
66
|
+
|
|
67
|
+
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
68
|
+
|
|
69
|
+
function sleep(milliseconds) {
|
|
70
|
+
Atomics.wait(waitCell, 0, 0, milliseconds);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeQuarantine(path) {
|
|
74
|
+
try {
|
|
75
|
+
// Windows antivirus/indexer handles can briefly keep a just-renamed file
|
|
76
|
+
// open. Retry the generated quarantine cleanup so a successful publication
|
|
77
|
+
// does not leave a misleading lock-generation directory in the repository.
|
|
78
|
+
rmSync(path, { recursive: true, force: true, maxRetries: 20, retryDelay: LOCK_POLL_MS });
|
|
79
|
+
} catch {
|
|
80
|
+
// The canonical lock has already been moved away. Preserve an uncertain
|
|
81
|
+
// quarantine rather than risking a path-based delete of a new generation;
|
|
82
|
+
// the next acquisition can safely inspect/reclaim it by owner token.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Shared with the runtime-snapshot reaper: both subsystems decide whether a
|
|
88
|
+
* recorded owner is gone, and the EPERM case below is exactly the distinction
|
|
89
|
+
* that must not be reimplemented differently in two places.
|
|
90
|
+
*/
|
|
91
|
+
export function processIsAlive(pid) {
|
|
92
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0);
|
|
95
|
+
return true;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// EPERM means the process exists but cannot be signalled. Any other error
|
|
98
|
+
// (most importantly ESRCH) means that the owner is gone.
|
|
99
|
+
return error?.code === 'EPERM';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readOwnerFile(path) {
|
|
104
|
+
try {
|
|
105
|
+
const owner = JSON.parse(readFileSync(path, 'utf8'));
|
|
106
|
+
if (!owner || typeof owner !== 'object') return null;
|
|
107
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return null;
|
|
108
|
+
if (typeof owner.token !== 'string' || owner.token.length === 0) return null;
|
|
109
|
+
return { pid: owner.pid, token: owner.token };
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The token-bearing temp names a creator may have left behind.
|
|
117
|
+
*
|
|
118
|
+
* Shared by `inspectLock`, which reads them as a recoverable identity, and by
|
|
119
|
+
* the release abandon path, which removes them. Those two must agree on what
|
|
120
|
+
* counts as a record: a name one of them recognises and the other does not is
|
|
121
|
+
* exactly how a lock becomes unrecoverable while looking clean.
|
|
122
|
+
*/
|
|
123
|
+
function ownerTempNames(lockPath) {
|
|
124
|
+
try {
|
|
125
|
+
return readdirSync(lockPath, { withFileTypes: true })
|
|
126
|
+
.filter((entry) => entry.isFile() && /^\.owner-[0-9a-f-]+\.tmp$/i.test(entry.name))
|
|
127
|
+
.map((entry) => entry.name)
|
|
128
|
+
.sort();
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ownerQuarantineNames(lockPath) {
|
|
135
|
+
try {
|
|
136
|
+
return readdirSync(lockPath, { withFileTypes: true })
|
|
137
|
+
.filter((entry) => entry.isFile() && (
|
|
138
|
+
entry.name === OWNER_QUARANTINE_FILE || entry.name.startsWith('.owner-quarantine-')
|
|
139
|
+
))
|
|
140
|
+
.map((entry) => entry.name);
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function inspectLock(buildLock) {
|
|
147
|
+
let lockStat;
|
|
148
|
+
try {
|
|
149
|
+
lockStat = statSync(buildLock);
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ownerPath = join(buildLock, OWNER_FILE);
|
|
155
|
+
const owner = readOwnerFile(ownerPath);
|
|
156
|
+
if (owner) return { lockStat, owner, ownerPath };
|
|
157
|
+
|
|
158
|
+
// The creator writes a complete JSON owner record to a token-specific temp
|
|
159
|
+
// name before the same-directory rename to owner.json. If that creator is
|
|
160
|
+
// interrupted in the tiny interval between those operations, the lock is
|
|
161
|
+
// still recoverable: retain the token-bearing temp path as the identity and
|
|
162
|
+
// run the normal PID-aware quarantine flow. Treating it as an unknown,
|
|
163
|
+
// owner-less lock would impose the full stale age even when its process is
|
|
164
|
+
// already dead, which can wedge every queued build/reader for minutes.
|
|
165
|
+
for (const name of ownerTempNames(buildLock)) {
|
|
166
|
+
const temporaryOwner = readOwnerFile(join(buildLock, name));
|
|
167
|
+
if (temporaryOwner) return { lockStat, owner: temporaryOwner, ownerPath: join(buildLock, name) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A reclaimer may have atomically moved the owner record aside and then
|
|
171
|
+
// crashed. Keep that token-bearing record as the lock identity so a later
|
|
172
|
+
// reclaimer cannot mistake the partially recovered directory for a new one.
|
|
173
|
+
for (const name of ownerQuarantineNames(buildLock)) {
|
|
174
|
+
const quarantinedOwner = readOwnerFile(join(buildLock, name));
|
|
175
|
+
if (quarantinedOwner) {
|
|
176
|
+
return { lockStat, owner: quarantinedOwner, ownerPath: join(buildLock, name) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { lockStat, owner: null, ownerPath: null };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Tokens this process is holding RIGHT NOW, as opposed to has ever minted.
|
|
185
|
+
*
|
|
186
|
+
* `ownedByToken` proves a generation is ours. This proves we are still standing
|
|
187
|
+
* in it, and the difference is the whole of D-085: a record naming our PID
|
|
188
|
+
* under a token that is not in here was written by a call that has already
|
|
189
|
+
* returned, so it is an orphan we may reclaim; one under a token that IS in
|
|
190
|
+
* here is a lock we hold, and reclaiming it would hand the same lock to two
|
|
191
|
+
* holders at once.
|
|
192
|
+
*/
|
|
193
|
+
const heldTokens = new Set();
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Can whoever owns this generation still do anything with it?
|
|
197
|
+
*
|
|
198
|
+
* ONE QUESTION, ONE ANSWER, ASKED FROM TWO PLACES. The acquire path and the
|
|
199
|
+
* quarantine reaper both need it, and answering it as `processIsAlive` alone
|
|
200
|
+
* gets our own released generations wrong in both: a token this process has
|
|
201
|
+
* already handed back names nobody who will ever come back for it, however
|
|
202
|
+
* alive the PID is. Leaving the reaper on the older answer would repeat D-078
|
|
203
|
+
* exactly — a rule corrected in one place and left standing in the helper next
|
|
204
|
+
* door.
|
|
205
|
+
*
|
|
206
|
+
* For another process, liveness is all we can observe. For ourselves we know
|
|
207
|
+
* something stronger and cheaper: whether we are still standing in it.
|
|
208
|
+
*/
|
|
209
|
+
function ownerCanStillAct(snapshot) {
|
|
210
|
+
if (!snapshot?.owner) return false;
|
|
211
|
+
if (snapshot.owner.pid === process.pid) return heldTokens.has(snapshot.owner.token);
|
|
212
|
+
return processIsAlive(snapshot.owner.pid);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function lockIsStale(buildLock, snapshot = inspectLock(buildLock)) {
|
|
216
|
+
if (!snapshot) return true;
|
|
217
|
+
if (snapshot.owner) return !ownerCanStillAct(snapshot);
|
|
218
|
+
|
|
219
|
+
// The timer governs ONE case: a directory carrying no token at all, in any
|
|
220
|
+
// form — no owner.json, no `.owner-<token>.tmp`, no quarantined record.
|
|
221
|
+
// `inspectLock` recovers a token from all three, so reaching here means the
|
|
222
|
+
// creator died between `mkdir` and its first write. That window is a single
|
|
223
|
+
// syscall wide, and `LOCK_STALE_MS` (ten minutes) was calibrated for a
|
|
224
|
+
// different question: it is longer than `LOCK_WAIT_MS`, so one such directory
|
|
225
|
+
// parked every contender until they each timed out. An owner-bearing lock is
|
|
226
|
+
// still judged by whether its process is alive, which is the case that
|
|
227
|
+
// legitimately lasts minutes.
|
|
228
|
+
return Date.now() - snapshot.lockStat.mtimeMs > OWNERLESS_LOCK_STALE_MS;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Is this lock one we created and failed to publish?
|
|
233
|
+
*
|
|
234
|
+
* The token is the proof. A directory carrying OUR token is ours whatever state
|
|
235
|
+
* it is in, and reclaiming it is sound; a directory carrying no token, or
|
|
236
|
+
* someone else's, is not ours to touch. The old cleanup acted on the ABSENCE of
|
|
237
|
+
* a token, which is the one thing that proves nothing.
|
|
238
|
+
*/
|
|
239
|
+
function ownedByToken(snapshot, token) {
|
|
240
|
+
return snapshot?.owner?.token === token;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Is this lock owned by the process asking?
|
|
245
|
+
*
|
|
246
|
+
* PID rather than token, deliberately. `lockIsStale` judges an owner by whether
|
|
247
|
+
* its process is alive, which for our own PID is permanently true — so a lock
|
|
248
|
+
* naming us is the one lock in the world this process must never wait for,
|
|
249
|
+
* whatever generation it belongs to.
|
|
250
|
+
*
|
|
251
|
+
* A record naming our PID was written either by this process or by a dead one
|
|
252
|
+
* whose PID we inherited, and reclaiming is correct under both readings: in the
|
|
253
|
+
* first it is our own orphan, in the second its owner is demonstrably gone.
|
|
254
|
+
* `heldTokens` separates out the only case where it is not.
|
|
255
|
+
*/
|
|
256
|
+
function ownedByThisProcess(snapshot) {
|
|
257
|
+
return snapshot?.owner?.pid === process.pid && Boolean(snapshot.ownerPath);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* May this quarantined generation be collected?
|
|
262
|
+
*
|
|
263
|
+
* NOT `lockIsStale`, WHICH ANSWERS A DIFFERENT QUESTION. That one answers "may
|
|
264
|
+
* I take this canonical lock?", and only half of its answer transfers here. The
|
|
265
|
+
* owner half does: an owner who cannot still act is gone wherever its directory
|
|
266
|
+
* sits, and `ownerCanStillAct` stays the single answer to that so the reaper
|
|
267
|
+
* and the acquire path cannot drift apart — the D-078 lesson.
|
|
268
|
+
*
|
|
269
|
+
* The owner-LESS half does not transfer. `lockIsStale` gives an owner-less
|
|
270
|
+
* directory `OWNERLESS_LOCK_STALE_MS` for the reason its own comment states:
|
|
271
|
+
* the creator may have died "between `mkdir` and its first write", so the timer
|
|
272
|
+
* protects a live process about to write its record into a directory it has
|
|
273
|
+
* just made. No such process can exist at a quarantine pathname. A quarantine
|
|
274
|
+
* is only ever created by RENAMING an existing directory aside; nothing is ever
|
|
275
|
+
* `mkdir`ed there, and no creator will ever come back for a name it does not
|
|
276
|
+
* know. An owner-less quarantine is therefore garbage the instant it exists,
|
|
277
|
+
* and the timer protected nobody while delaying its collection.
|
|
278
|
+
*
|
|
279
|
+
* That delay was a real failure, not a tidiness point. `quarantineKnownLock`
|
|
280
|
+
* renames whatever is at the canonical path at the instant it acts, which need
|
|
281
|
+
* not be the generation it inspected — a contender can quarantine and remove
|
|
282
|
+
* that generation while a third process `mkdir`s a fresh empty one. The
|
|
283
|
+
* mismatch is detected and restoration attempted; when the canonical path has
|
|
284
|
+
* already been re-claimed, restoration loses the race D-092 recorded, and an
|
|
285
|
+
* EMPTY quarantine is left behind. Run `33760552077` failed on Ubuntu and
|
|
286
|
+
* Windows both because a sweeping acquisition could not collect it for ten
|
|
287
|
+
* seconds.
|
|
288
|
+
*
|
|
289
|
+
* Racing a quarantine still in progress is safe under this rule. The two paths
|
|
290
|
+
* that create one from an owner-bearing directory (`quarantineKnownLock`,
|
|
291
|
+
* `releaseOwnedGeneration`) move the token-bearing record INTO the directory
|
|
292
|
+
* first, so what they are working on always carries a record and is judged by
|
|
293
|
+
* liveness. The one path that quarantines an owner-less directory
|
|
294
|
+
* (`quarantineUnknownLock`) is itself about to delete it, and `removeQuarantine`
|
|
295
|
+
* swallows the loss.
|
|
296
|
+
*/
|
|
297
|
+
function quarantineIsCollectable(snapshot) {
|
|
298
|
+
if (!snapshot) return true;
|
|
299
|
+
if (snapshot.owner) return !ownerCanStillAct(snapshot);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function reapOrphanQuarantines(root) {
|
|
304
|
+
let entries;
|
|
305
|
+
try {
|
|
306
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
307
|
+
} catch {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
if (!entry.isDirectory() || !entry.name.startsWith(LOCK_QUARANTINE_PREFIX)) continue;
|
|
312
|
+
const path = join(root, entry.name);
|
|
313
|
+
if (quarantineIsCollectable(inspectLock(path))) removeQuarantine(path);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sameOwner(left, right) {
|
|
318
|
+
return left?.pid === right?.pid && left?.token === right?.token;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Claim `source` as `target` by rename. Answers whether the claim succeeded.
|
|
323
|
+
*
|
|
324
|
+
* THE ERRNO LIST LIVED HERE TOO. D-072 removed an enumeration of platform error
|
|
325
|
+
* codes from the acquire loop on the grounds that the list is a property of
|
|
326
|
+
* whichever kernel the job runs on, and the next platform adds an entry. It did
|
|
327
|
+
* not remove this one, and macOS supplied the entry: renaming inside a
|
|
328
|
+
* directory a contender has just unlinked answers `EINVAL`, which was not on
|
|
329
|
+
* the list, so it was rethrown — out of `releasePublicationLock`, out of
|
|
330
|
+
* `bin/segreant.mjs`, killing a CLI that had merely lost a race.
|
|
331
|
+
*
|
|
332
|
+
* The position argument is the same one. This function answers ONE question —
|
|
333
|
+
* did I manage to claim this by rename? — and every caller handles `false` by
|
|
334
|
+
* carrying on. There is no failure here that means "you claimed it", so no
|
|
335
|
+
* failure needs to be fatal. `ENOENT` and `EEXIST` are still distinguished, and
|
|
336
|
+
* not because of what they are called: both mean another process has already
|
|
337
|
+
* settled this claim, so retrying cannot change the answer. Everything else is
|
|
338
|
+
* the path being momentarily unusable, which is what the budget is for.
|
|
339
|
+
*
|
|
340
|
+
* A permanently broken filesystem now returns `false` after `RENAME_RETRY_MS`
|
|
341
|
+
* rather than throwing its exact errno. That is a real loss of diagnostic
|
|
342
|
+
* precision, and it is the right trade: acquisition still surfaces a persistent
|
|
343
|
+
* fault through `PATH_CONTENTION_MS`, and no lock helper should be able to kill
|
|
344
|
+
* the launcher over an errno nobody enumerated.
|
|
345
|
+
*/
|
|
346
|
+
function renameForQuarantine(source, target) {
|
|
347
|
+
const started = Date.now();
|
|
348
|
+
while (true) {
|
|
349
|
+
try {
|
|
350
|
+
renameSync(source, target);
|
|
351
|
+
return true;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const code = error?.code;
|
|
354
|
+
if (code === 'ENOENT' || code === 'EEXIST') return false;
|
|
355
|
+
if (Date.now() - started >= RENAME_RETRY_MS) return false;
|
|
356
|
+
sleep(LOCK_POLL_MS);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Put a quarantined generation back, if the canonical path is still free.
|
|
363
|
+
*
|
|
364
|
+
* THE ERRNO LIST WAS STILL HERE, AND LINUX SUPPLIED THE NEXT ENTRY. The comment
|
|
365
|
+
* on `renameForQuarantine` above records D-072 removing an enumeration of
|
|
366
|
+
* platform error codes "on the grounds that the list is a property of whichever
|
|
367
|
+
* kernel the job runs on, and the next platform adds an entry", and notes that
|
|
368
|
+
* it did not remove this one. This function kept
|
|
369
|
+
* `['ENOENT','EACCES','EBUSY','EPERM','EEXIST']` and rethrew everything else.
|
|
370
|
+
* Renaming a directory onto an existing NON-EMPTY directory answers `EEXIST` on
|
|
371
|
+
* Windows and `ENOTEMPTY` on Linux — POSIX permits either — so the list was
|
|
372
|
+
* complete on the platform it was written on and wrong on the one CI runs.
|
|
373
|
+
* Exact-head run `33730517441` killed a worker inside `acquirePublicationLock`
|
|
374
|
+
* with a raw `ENOTEMPTY` for losing a race it is designed to lose.
|
|
375
|
+
*
|
|
376
|
+
* THE POSITION, NOT THE ENTRY. Adding `ENOTEMPTY` would repeat the mistake with
|
|
377
|
+
* one more name. The reason no failure here may be fatal is structural: once
|
|
378
|
+
* `renameForQuarantine` moved the lock aside, THE CANONICAL PATH IS ABSENT and
|
|
379
|
+
* any contender may claim it immediately. So restoration is best-effort by
|
|
380
|
+
* construction — a failure means somebody else got there first, which is the
|
|
381
|
+
* ordinary outcome of a lost race and never a reason to kill a build. Both
|
|
382
|
+
* callers already ignore the answer and return `false` regardless; there is no
|
|
383
|
+
* failure here that means "you restored it".
|
|
384
|
+
*
|
|
385
|
+
* WHAT IS PRESERVED. A failed restore still never deletes the quarantine to make
|
|
386
|
+
* the error disappear. The generation is left intact for `reapOrphanQuarantines`,
|
|
387
|
+
* which collects it once its owner is demonstrably dead — by owner token, never
|
|
388
|
+
* by pathname.
|
|
389
|
+
*/
|
|
390
|
+
function restoreQuarantinedLock(buildLock, quarantine) {
|
|
391
|
+
try {
|
|
392
|
+
renameSync(quarantine, buildLock);
|
|
393
|
+
return true;
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function quarantineUnknownLock(root, buildLock) {
|
|
400
|
+
const quarantine = join(root, `${LOCK_QUARANTINE_PREFIX}${process.pid}-${randomUUID()}`);
|
|
401
|
+
if (!renameForQuarantine(buildLock, quarantine)) return false;
|
|
402
|
+
|
|
403
|
+
// Unknown means owner.json was absent or malformed. If a valid owner appears
|
|
404
|
+
// in the atomically quarantined object, this was a newer lock generation and
|
|
405
|
+
// it is not ours to delete. Restore it to the canonical path when possible.
|
|
406
|
+
const owner = readOwnerFile(join(quarantine, OWNER_FILE));
|
|
407
|
+
if (owner || ownerQuarantineNames(quarantine).some((name) => readOwnerFile(join(quarantine, name)))) {
|
|
408
|
+
restoreQuarantinedLock(buildLock, quarantine);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
removeQuarantine(quarantine);
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function quarantineKnownLock(root, buildLock, snapshot) {
|
|
417
|
+
if (!snapshot.owner || !snapshot.ownerPath) return false;
|
|
418
|
+
|
|
419
|
+
// First quarantine the token-bearing owner record while the canonical lock
|
|
420
|
+
// directory remains in place. That closes the stale-check -> removal window:
|
|
421
|
+
// no other process can acquire the directory until this owner identity has
|
|
422
|
+
// been claimed by an atomic same-directory rename.
|
|
423
|
+
const ownerName = OWNER_QUARANTINE_FILE;
|
|
424
|
+
const ownerQuarantine = join(buildLock, ownerName);
|
|
425
|
+
if (snapshot.ownerPath !== ownerQuarantine) {
|
|
426
|
+
// One fixed quarantine name makes the owner-record claim itself exclusive:
|
|
427
|
+
// competing stale recoverers observe the same claimed generation and wait
|
|
428
|
+
// instead of moving one another's temporary records around.
|
|
429
|
+
if (existsSync(ownerQuarantine) || !renameForQuarantine(snapshot.ownerPath, ownerQuarantine)) return false;
|
|
430
|
+
}
|
|
431
|
+
const movedOwner = readOwnerFile(ownerQuarantine);
|
|
432
|
+
if (!sameOwner(movedOwner, snapshot.owner)) {
|
|
433
|
+
try { renameSync(ownerQuarantine, snapshot.ownerPath); } catch { /* leave it for stale recovery */ }
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Now move the whole claimed lock to a unique sibling. Publication
|
|
438
|
+
// contenders can create a fresh canonical lock after this point, but they
|
|
439
|
+
// can never be affected by cleanup of this quarantined generation.
|
|
440
|
+
const quarantine = join(root, `${LOCK_QUARANTINE_PREFIX}${process.pid}-${randomUUID()}`);
|
|
441
|
+
if (!renameForQuarantine(buildLock, quarantine)) return false;
|
|
442
|
+
const quarantinedOwner = readOwnerFile(join(quarantine, ownerName));
|
|
443
|
+
if (!sameOwner(quarantinedOwner, snapshot.owner)) {
|
|
444
|
+
restoreQuarantinedLock(buildLock, quarantine);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
removeQuarantine(quarantine);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function quarantineStaleLock(root, buildLock, snapshot) {
|
|
453
|
+
return snapshot?.owner
|
|
454
|
+
? quarantineKnownLock(root, buildLock, snapshot)
|
|
455
|
+
: quarantineUnknownLock(root, buildLock);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Hand back a lock this process proved it owns.
|
|
460
|
+
*
|
|
461
|
+
* THE INVARIANT, WHICH THE OLD SHAPE VIOLATED FOUR WAYS.
|
|
462
|
+
*
|
|
463
|
+
* A process that cannot prove it owns the lock must never act as though it
|
|
464
|
+
* does, and a process that HAS proved ownership must not return while
|
|
465
|
+
* leaving that lock permanently held.
|
|
466
|
+
*
|
|
467
|
+
* Release used to be a straight line of atomic steps, each of which returned on
|
|
468
|
+
* failure. Three of those returns left the canonical directory in place still
|
|
469
|
+
* carrying OUR token — and `inspectLock` reads that record as an owner while
|
|
470
|
+
* `lockIsStale` clears it as live, because the PID it names is this process and
|
|
471
|
+
* this process is still running. No contender can ever recover such a lock:
|
|
472
|
+
* they wait out `LOCK_WAIT_MS` and report `timed out waiting for another Segreant
|
|
473
|
+
* build` about a build that moved on minutes earlier.
|
|
474
|
+
*
|
|
475
|
+
* D-077 fixed ONE of those four returns — the directory move — and did not ask
|
|
476
|
+
* about its siblings. That is the same mistake D-078 found in the acquire path
|
|
477
|
+
* (an errno list removed in one place and left in the helper next door), which
|
|
478
|
+
* makes it the defect CLASS rather than an unlucky line. So the give-up
|
|
479
|
+
* decision is no longer per-step at all: the loop below re-establishes whether
|
|
480
|
+
* the lock is still ours and keeps trying, and the only way out is that the
|
|
481
|
+
* lock is gone, or demonstrably not ours, or removed by us.
|
|
482
|
+
*
|
|
483
|
+
* THE TERMINAL FALLBACK IS SOUND ONLY BECAUSE OF THE TOKEN. Removing the
|
|
484
|
+
* directory by pathname is exactly what the atomic dance exists to avoid, and
|
|
485
|
+
* it is safe here for one reason: `ownedByToken` has just re-read the lock and
|
|
486
|
+
* found OUR token in it. That is the same proof every other step relies on. The
|
|
487
|
+
* quarantine rename only ever bought speed — freeing the canonical path sooner
|
|
488
|
+
* — and losing speed is not a reason to keep a lock forever.
|
|
489
|
+
*/
|
|
490
|
+
function releasePublicationLock(root, buildLock, token) {
|
|
491
|
+
// Dropped FIRST, and on every exit below by virtue of being dropped here: a
|
|
492
|
+
// token left in the set after release would make a later acquisition treat a
|
|
493
|
+
// genuine orphan of ours as a lock we are standing in, and refuse instead of
|
|
494
|
+
// reclaiming. Release is the moment we stop standing in it, whatever the
|
|
495
|
+
// filesystem then does.
|
|
496
|
+
heldTokens.delete(token);
|
|
497
|
+
|
|
498
|
+
const deadline = Date.now() + RELEASE_BUDGET_MS;
|
|
499
|
+
while (true) {
|
|
500
|
+
const snapshot = inspectLock(buildLock);
|
|
501
|
+
// Gone, or a newer generation someone else owns. We hold nothing, and
|
|
502
|
+
// touching it would be the pathname-based cleanup D-072 removed.
|
|
503
|
+
if (!ownedByToken(snapshot, token) || !snapshot.ownerPath) return;
|
|
504
|
+
|
|
505
|
+
if (releaseOwnedGeneration(root, buildLock, token, snapshot)) return;
|
|
506
|
+
|
|
507
|
+
if (Date.now() >= deadline) {
|
|
508
|
+
abandonOwnedGeneration(buildLock, token);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
sleep(LOCK_POLL_MS);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Take back a canonical lock that names this process, at any cost.
|
|
517
|
+
*
|
|
518
|
+
* The acquire-side twin of `releasePublicationLock`, and it exists for the same
|
|
519
|
+
* reason: waiting is not one of the outcomes. `abandonOwnedGeneration` is the
|
|
520
|
+
* same terminal step, licensed by the same proof — the owner record names our
|
|
521
|
+
* PID, and while we are alive no contender can have taken this generation,
|
|
522
|
+
* because a contender only touches a live lock it does not own after judging
|
|
523
|
+
* the owner dead.
|
|
524
|
+
*
|
|
525
|
+
* The atomic route is tried first and given the same budget release gets, so a
|
|
526
|
+
* rename losing to momentary contention is retried rather than escalated. Past
|
|
527
|
+
* it, the directory goes. Degrading the canonical path to owner-less — which
|
|
528
|
+
* `OWNERLESS_LOCK_STALE_MS` clears in ten seconds — is a real outcome. Waiting
|
|
529
|
+
* five minutes for ourselves is not an outcome at all.
|
|
530
|
+
*/
|
|
531
|
+
function reclaimOwnGeneration(root, buildLock, snapshot, ownedSince) {
|
|
532
|
+
if (quarantineKnownLock(root, buildLock, snapshot)) return true;
|
|
533
|
+
if (Date.now() - ownedSince < RELEASE_BUDGET_MS) return false;
|
|
534
|
+
abandonOwnedGeneration(buildLock, snapshot.owner.token);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Last resort: make this generation recoverable by someone else, at any cost
|
|
540
|
+
* short of touching a generation that is not ours.
|
|
541
|
+
*
|
|
542
|
+
* Reached only after `ownedByToken` has just re-read the lock and found OUR
|
|
543
|
+
* token in it. That proof is what licenses acting by pathname here: the token
|
|
544
|
+
* is a UUID minted in this process, so the directory is demonstrably our
|
|
545
|
+
* generation, and while this process is alive no other can have taken it — a
|
|
546
|
+
* contender only touches a live lock it does not own by first judging the owner
|
|
547
|
+
* dead, and `processIsAlive` says otherwise about us.
|
|
548
|
+
*
|
|
549
|
+
* Two attempts, because the first one can be swallowed:
|
|
550
|
+
*
|
|
551
|
+
* 1. Remove the directory. Best outcome — the canonical path is free at once.
|
|
552
|
+
* 2. If it is still ours, unlink every token-bearing record inside it. What
|
|
553
|
+
* makes a lock look HELD is the record, not the directory: without one,
|
|
554
|
+
* `inspectLock` reports an owner-less lock and `lockIsStale` clears it on
|
|
555
|
+
* the `OWNERLESS_LOCK_STALE_MS` timer.
|
|
556
|
+
*
|
|
557
|
+
* Step 2 is the reason this is a function rather than one call. `rmSync` is not
|
|
558
|
+
* atomic and `removeQuarantine` swallows its failures by design, so a partial
|
|
559
|
+
* removal that leaves the owner record behind would put us straight back into
|
|
560
|
+
* the permanently-held state this whole path exists to prevent. Degrading to
|
|
561
|
+
* "recoverable in ten seconds" is a real outcome; returning while holding it
|
|
562
|
+
* forever is not an outcome at all.
|
|
563
|
+
*/
|
|
564
|
+
function abandonOwnedGeneration(buildLock, token) {
|
|
565
|
+
removeQuarantine(buildLock);
|
|
566
|
+
if (!ownedByToken(inspectLock(buildLock), token)) return;
|
|
567
|
+
|
|
568
|
+
for (const name of [OWNER_FILE, ...ownerQuarantineNames(buildLock), ...ownerTempNames(buildLock)]) {
|
|
569
|
+
try {
|
|
570
|
+
rmSync(join(buildLock, name), { force: true, maxRetries: 20, retryDelay: LOCK_POLL_MS });
|
|
571
|
+
} catch {
|
|
572
|
+
// Nothing further is available. The next acquisition inspects it again.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* One attempt at the atomic hand-back. Answers whether the lock is now gone.
|
|
579
|
+
*
|
|
580
|
+
* Every `false` here means "could not finish this time", never "give up" — the
|
|
581
|
+
* caller decides that, against the invariant, and it is the caller that owns
|
|
582
|
+
* the fallback. Keeping the steps atomic still matters: a pathname read
|
|
583
|
+
* followed by recursive deletion would race a lock that changed underneath us,
|
|
584
|
+
* and the fallback is only reached after that race has been re-checked.
|
|
585
|
+
*/
|
|
586
|
+
function releaseOwnedGeneration(root, buildLock, token, snapshot) {
|
|
587
|
+
const ownerName = OWNER_QUARANTINE_FILE;
|
|
588
|
+
const ownerQuarantine = join(buildLock, ownerName);
|
|
589
|
+
if (snapshot.ownerPath !== ownerQuarantine && !renameForQuarantine(snapshot.ownerPath, ownerQuarantine)) {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
const movedOwner = readOwnerFile(ownerQuarantine);
|
|
593
|
+
if (!movedOwner || movedOwner.token !== token) {
|
|
594
|
+
// The record changed under us, so this generation may not be ours after
|
|
595
|
+
// all. Put it back and let the caller re-establish ownership from scratch.
|
|
596
|
+
try { renameSync(ownerQuarantine, snapshot.ownerPath); } catch { /* the next lap re-reads it */ }
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const quarantine = join(root, `${LOCK_QUARANTINE_PREFIX}release-${process.pid}-${randomUUID()}`);
|
|
601
|
+
if (!renameForQuarantine(buildLock, quarantine)) return false;
|
|
602
|
+
|
|
603
|
+
const quarantinedOwner = readOwnerFile(join(quarantine, ownerName));
|
|
604
|
+
if (!quarantinedOwner || quarantinedOwner.token !== token) {
|
|
605
|
+
restoreQuarantinedLock(buildLock, quarantine);
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
removeQuarantine(quarantine);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Acquire the exclusive build/reader gate rooted beside the package.
|
|
614
|
+
*
|
|
615
|
+
* Both the publisher and bin/segreant.mjs use this exact mkdir protocol. A
|
|
616
|
+
* reader therefore cannot observe the check-vs-acquire window where a build
|
|
617
|
+
* starts publication immediately after a reader sees an absent lock. Stale
|
|
618
|
+
* recovery is token- and PID-aware and never falls back to path-based deletion.
|
|
619
|
+
*/
|
|
620
|
+
export function acquirePublicationLock(root) {
|
|
621
|
+
reapOrphanQuarantines(root);
|
|
622
|
+
const buildLock = join(root, '.segreant-build.lock');
|
|
623
|
+
const waitStarted = Date.now();
|
|
624
|
+
const token = randomUUID();
|
|
625
|
+
// First moment the canonical lock was seen carrying THIS process's PID under
|
|
626
|
+
// a token we are not holding. Reset whenever it is not, so the budget bounds
|
|
627
|
+
// a consecutive run rather than a total.
|
|
628
|
+
let ownedSince = null;
|
|
629
|
+
// First moment the canonical path answered with a filesystem error that is
|
|
630
|
+
// not EEXIST. Reset on any other outcome, so the budget below bounds a
|
|
631
|
+
// CONSECUTIVE run of them rather than their total over a long wait.
|
|
632
|
+
let unusableSince = null;
|
|
633
|
+
|
|
634
|
+
while (true) {
|
|
635
|
+
let created = false;
|
|
636
|
+
try {
|
|
637
|
+
mkdirSync(buildLock);
|
|
638
|
+
created = true;
|
|
639
|
+
|
|
640
|
+
// Publish the owner record through a same-directory rename so a waiter
|
|
641
|
+
// never mistakes a half-written JSON file for an actionable owner.
|
|
642
|
+
const ownerTemp = join(buildLock, `.owner-${token}.tmp`);
|
|
643
|
+
writeFileSync(ownerTemp, JSON.stringify({ pid: process.pid, token }), 'utf8');
|
|
644
|
+
renameSync(ownerTemp, join(buildLock, OWNER_FILE));
|
|
645
|
+
|
|
646
|
+
heldTokens.add(token);
|
|
647
|
+
return () => releasePublicationLock(root, buildLock, token);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
// CONTENTION ON THE CANONICAL PATH IS NOT ACQUISITION FAILING. IT IS
|
|
650
|
+
// ACQUISITION NOT HAVING HAPPENED YET.
|
|
651
|
+
//
|
|
652
|
+
// `EEXIST` was the only contention this loop recognised, and it is one
|
|
653
|
+
// face of the condition among several. The lock is made in two steps —
|
|
654
|
+
// `mkdir` the directory, then write an owner record into it — and between
|
|
655
|
+
// them it carries no owner, which is indistinguishable from one an
|
|
656
|
+
// interrupted process abandoned. A contender that removes it there leaves
|
|
657
|
+
// the creator's write failing, and what that failure is CALLED depends on
|
|
658
|
+
// the platform and on exactly when the call lands:
|
|
659
|
+
//
|
|
660
|
+
// Windows EEXIST from mkdir while it is there, EPERM from mkdir while
|
|
661
|
+
// it sits in pending-delete, ENOENT from the write once it is
|
|
662
|
+
// gone, EPERM from the rename if it goes mid-publish.
|
|
663
|
+
// macOS EINVAL from the write into an unlinked directory.
|
|
664
|
+
//
|
|
665
|
+
// CI showed ENOENT (run `33502986214`), then EINVAL on a different runner
|
|
666
|
+
// (run `33505785655`) once the first three were handled. Enumerating
|
|
667
|
+
// errnos is the wrong shape of fix: the list is a property of the kernel
|
|
668
|
+
// this happens to run on, and the next platform adds another entry.
|
|
669
|
+
//
|
|
670
|
+
// The structural fact does not vary. THE OWNER RECORD IS WHAT MAKES THE
|
|
671
|
+
// LOCK OURS, so if we created the directory and failed to publish that
|
|
672
|
+
// record, we hold nothing — whatever the failure was called. There is no
|
|
673
|
+
// error in that position that means "you hold the lock". So the branch
|
|
674
|
+
// asks where it failed, not what the error was, and a persistent failure
|
|
675
|
+
// is separated from a transient one by whether it clears.
|
|
676
|
+
const code = error?.code;
|
|
677
|
+
|
|
678
|
+
if (created) {
|
|
679
|
+
// Clean up ONLY what carries our token. The original code quarantined
|
|
680
|
+
// `buildLock` by pathname, which is unsound here: our owner record never
|
|
681
|
+
// landed, so a pathname says nothing about whose directory it now is,
|
|
682
|
+
// and removing it takes a fresh lock from another process inside ITS own
|
|
683
|
+
// publish window. Removing the cleanup entirely was worse in a different
|
|
684
|
+
// way — a half-written `.owner-<token>.tmp` of OUR OWN is recovered by
|
|
685
|
+
// `inspectLock` as a live owner whose PID is this very process, so the
|
|
686
|
+
// next lap sees a lock owned by someone alive and waits for itself until
|
|
687
|
+
// `LOCK_WAIT_MS`. CI deadlocked eight contenders that way.
|
|
688
|
+
//
|
|
689
|
+
// The token settles it. A directory carrying our token is ours whatever
|
|
690
|
+
// state it is in; one carrying another token, or none, is not ours to
|
|
691
|
+
// touch. `quarantineKnownLock` re-verifies the record after moving it,
|
|
692
|
+
// so a generation that changed under us is restored rather than removed.
|
|
693
|
+
const snapshot = inspectLock(buildLock);
|
|
694
|
+
if (ownedByToken(snapshot, token)) quarantineKnownLock(root, buildLock, snapshot);
|
|
695
|
+
|
|
696
|
+
unusableSince ??= Date.now();
|
|
697
|
+
if (Date.now() - unusableSince > PATH_CONTENTION_MS) throw error;
|
|
698
|
+
} else if (code === 'EEXIST') {
|
|
699
|
+
// Someone holds it, or is publishing it. This is the ordinary wait and
|
|
700
|
+
// must not be bounded by the contention budget: a legitimately held lock
|
|
701
|
+
// can outlast it by minutes, and `LOCK_WAIT_MS` is its bound.
|
|
702
|
+
unusableSince = null;
|
|
703
|
+
const snapshot = inspectLock(buildLock);
|
|
704
|
+
// A LOCK NAMING THIS PROCESS IS NEVER SOMETHING TO WAIT FOR.
|
|
705
|
+
//
|
|
706
|
+
// This test used to be `ownedByToken`, and the token is minted per CALL
|
|
707
|
+
// — so a generation left behind by an EARLIER call of this same process
|
|
708
|
+
// carried a token this call had never heard of. Different token, live
|
|
709
|
+
// PID: not ours to reclaim by that test, not stale by `lockIsStale`,
|
|
710
|
+
// and therefore waited on until `LOCK_WAIT_MS`. CI run `33630894290`
|
|
711
|
+
// killed a contender at the harness window with every other worker long
|
|
712
|
+
// since exited, which leaves exactly one process alive to have owned it.
|
|
713
|
+
//
|
|
714
|
+
// The actor is the right granularity, not the generation. What made
|
|
715
|
+
// waiting futile was never the token — it was that the PID we are
|
|
716
|
+
// waiting on is our own, and `processIsAlive` will keep saying yes for
|
|
717
|
+
// as long as we are the one asking.
|
|
718
|
+
if (ownedByThisProcess(snapshot)) {
|
|
719
|
+
if (heldTokens.has(snapshot.owner.token)) {
|
|
720
|
+
// Not an orphan: we are standing in this lock. Reclaiming would
|
|
721
|
+
// hand one lock to two holders, and waiting is a deadlock with a
|
|
722
|
+
// five-minute fuse that then blames another build for it.
|
|
723
|
+
throw new Error('publication lock is already held by this process; release it before acquiring again');
|
|
724
|
+
}
|
|
725
|
+
ownedSince ??= Date.now();
|
|
726
|
+
if (reclaimOwnGeneration(root, buildLock, snapshot, ownedSince)) continue;
|
|
727
|
+
} else {
|
|
728
|
+
ownedSince = null;
|
|
729
|
+
if (lockIsStale(buildLock, snapshot) && quarantineStaleLock(root, buildLock, snapshot)) continue;
|
|
730
|
+
}
|
|
731
|
+
} else if (code === 'ENOENT') {
|
|
732
|
+
// `mkdir` cannot answer ENOENT because of anything happening at the lock
|
|
733
|
+
// path itself; the parent directory is missing. Nothing to wait for.
|
|
734
|
+
throw error;
|
|
735
|
+
} else {
|
|
736
|
+
// The path is momentarily unusable — most often a directory another
|
|
737
|
+
// process is deleting. Absorb it while it clears.
|
|
738
|
+
unusableSince ??= Date.now();
|
|
739
|
+
if (Date.now() - unusableSince > PATH_CONTENTION_MS) throw error;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (Date.now() - waitStarted >= LOCK_WAIT_MS) {
|
|
743
|
+
throw new Error(`timed out waiting for another Segreant build (${LOCK_WAIT_MS}ms)`);
|
|
744
|
+
}
|
|
745
|
+
sleep(LOCK_POLL_MS);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|