approval-md 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -914
- package/SPEC.md +101 -13
- package/cli.js +134 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +72 -0
- package/dist/src/channels/contract.js +161 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +648 -56
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +29 -1
- package/dist/src/cli/execute.js +387 -21
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +26 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +338 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +153 -12
- package/dist/src/core/audit.js +415 -20
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +766 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +364 -6
- package/dist/src/core/gate.js +802 -27
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +130 -22
- package/dist/src/core/log.js +473 -50
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +59 -11
- package/dist/src/core/sender-identity.js +79 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +166 -0
- package/dist/src/serve/archive.js +363 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +102 -6
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One transport per gate, enforced locally (APRV-424, review finding 1).
|
|
3
|
+
*
|
|
4
|
+
* `core/channel-owner.ts` answers "does another INSTANCE on this machine hold
|
|
5
|
+
* this bot", keyed on the instance id, and `getWebhookInfo` answers "what is
|
|
6
|
+
* the Bot API delivering this bot's updates to". Between those two answers
|
|
7
|
+
* there was a hole, and it is the one this file closes: two processes started
|
|
8
|
+
* in the SAME gate, one `approval up` long-polling and one `approval channel
|
|
9
|
+
* telegram webhook`, pass both checks. The registry compares instance ids and
|
|
10
|
+
* both processes are the same instance; `getWebhookInfo` is empty when the
|
|
11
|
+
* poller is the one already running. Each then runs its own dispatch cycle
|
|
12
|
+
* over its own `DispatchState` against one log, so every pending request
|
|
13
|
+
* reaches the phone twice under two nonces and only one of the two copies can
|
|
14
|
+
* resolve a tap.
|
|
15
|
+
*
|
|
16
|
+
* ## What the lease is
|
|
17
|
+
*
|
|
18
|
+
* A lockfile in the gate's own derived state directory:
|
|
19
|
+
*
|
|
20
|
+
* ```
|
|
21
|
+
* <instance home>/.approval/daemon/telegram-transport.lock
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* `daemon/` because that is where this runtime already keeps per-gate runtime
|
|
25
|
+
* state that a clone must not inherit (`core/live-draw.ts`'s socket lives
|
|
26
|
+
* beside it, and `.gitignore` holds the whole directory). It carries the
|
|
27
|
+
* holder's pid, which transport it is running, and when it started, and it is
|
|
28
|
+
* created with `O_EXCL` so two processes racing cannot both believe they took
|
|
29
|
+
* it.
|
|
30
|
+
*
|
|
31
|
+
* ## Per PROCESS, and validated for liveness
|
|
32
|
+
*
|
|
33
|
+
* The holder is a pid, so:
|
|
34
|
+
*
|
|
35
|
+
* - **A second process refuses.** It reads the holder, sees a live pid, and is
|
|
36
|
+
* told which transport is running and under which pid. The refusal is the
|
|
37
|
+
* whole point: a channel that ran both transports would deliver twice.
|
|
38
|
+
* - **A dead holder is reclaimed.** A crash, a `kill -9`, a laptop that slept
|
|
39
|
+
* through a power cycle: the lockfile outlives the process, and refusing
|
|
40
|
+
* forever over a file nobody owns would be an outage with no fault behind
|
|
41
|
+
* it. `process.kill(pid, 0)` is the probe; `EPERM` counts as alive, because
|
|
42
|
+
* a pid that belongs to somebody else is still a pid in use.
|
|
43
|
+
* - **The same pid is never in conflict with itself.** One process holds one
|
|
44
|
+
* transport, and the in-process half of that rule is
|
|
45
|
+
* `TelegramChannel.claimTransport`, which refuses a second claim on one
|
|
46
|
+
* channel object. A re-take by the holding pid rewrites the record rather
|
|
47
|
+
* than refusing, so a supervisor that restarts its own channel part inside
|
|
48
|
+
* one process (`approval up`) is not fighting its own lockfile.
|
|
49
|
+
*
|
|
50
|
+
* A recycled pid is the acknowledged limit: a long-dead holder whose number
|
|
51
|
+
* has been reused by an unrelated program reads as alive, and the operator
|
|
52
|
+
* gets a refusal naming a pid that is not a gate. That is the safe direction
|
|
53
|
+
* of the error (a refusal an operator can clear by deleting a named file,
|
|
54
|
+
* rather than two pollers on one log), and it is the same trade
|
|
55
|
+
* `core/live-draw.ts` makes when it validates the answering daemon's pid.
|
|
56
|
+
*
|
|
57
|
+
* ## Why an I/O failure is a refusal
|
|
58
|
+
*
|
|
59
|
+
* Every other local record in this runtime is a cache whose absence is the
|
|
60
|
+
* status quo (`channel-owner.ts` says so at length). This one is not: it is
|
|
61
|
+
* the only thing standing between a gate and two live transports, so a
|
|
62
|
+
* directory it cannot create or a file it cannot write is a state in which
|
|
63
|
+
* exclusion cannot be promised, and the stricter path is to say so and stop.
|
|
64
|
+
* The refusal names the path, which is a thing an operator can fix.
|
|
65
|
+
*
|
|
66
|
+
* Nothing here is evidence. It is never read by an enforcement path, it
|
|
67
|
+
* widens no permission, and the only thing it can produce is a refusal to
|
|
68
|
+
* start.
|
|
69
|
+
*/
|
|
70
|
+
import { spawnSync } from "node:child_process";
|
|
71
|
+
import { randomBytes } from "node:crypto";
|
|
72
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
73
|
+
import { join, resolve } from "node:path";
|
|
74
|
+
import { instanceHomeFor } from "./instance.js";
|
|
75
|
+
/** The lockfile's basename inside the gate's `daemon/` directory. */
|
|
76
|
+
export const CHANNEL_LEASE_FILE = "telegram-transport.lock";
|
|
77
|
+
/** The only format version this build writes, and the only one it reads. */
|
|
78
|
+
export const CHANNEL_LEASE_VERSION = 1;
|
|
79
|
+
/** How many times a take retries around a torn or reclaimed lockfile. */
|
|
80
|
+
export const CHANNEL_LEASE_ATTEMPTS = 5;
|
|
81
|
+
/** How long a take waits before re-reading a lockfile it could not parse. */
|
|
82
|
+
export const CHANNEL_LEASE_RETRY_MS = 20;
|
|
83
|
+
/**
|
|
84
|
+
* The sibling file that serializes TAKE-OVERS of an existing lockfile.
|
|
85
|
+
*
|
|
86
|
+
* The lease itself is taken with `O_EXCL`, which settles the ordinary race by
|
|
87
|
+
* itself: exactly one of two processes creating a file that is not there wins.
|
|
88
|
+
* Reclaiming a lockfile that IS there cannot be settled that way, and the
|
|
89
|
+
* first version of this module proved it. Two processes both read the same
|
|
90
|
+
* dead holder, the first unlinked it and created its own live lease, and the
|
|
91
|
+
* second, resuming inside its own read-then-unlink, deleted that live lease
|
|
92
|
+
* and created its own. Both then believed they held the gate, which is the
|
|
93
|
+
* exact state this file exists to rule out.
|
|
94
|
+
*
|
|
95
|
+
* So a take-over happens inside a critical section of its own, entered with
|
|
96
|
+
* the same `O_EXCL` primitive, and what happens inside it is a re-read: the
|
|
97
|
+
* record must still be the very one that was judged dead (same pid, same
|
|
98
|
+
* `started_at`) or the take-over is abandoned. The loser of that race finds a
|
|
99
|
+
* LIVE record on its next pass and refuses, which is the right answer.
|
|
100
|
+
*/
|
|
101
|
+
export const CHANNEL_LEASE_RECLAIM_SUFFIX = ".reclaim";
|
|
102
|
+
/**
|
|
103
|
+
* How old a reclaim lock has to be before its age is worth saying out loud.
|
|
104
|
+
*
|
|
105
|
+
* It DECIDES NOTHING, and that is the third review's first finding. An earlier
|
|
106
|
+
* version evicted a reclaim lock on age alone, which handed the section to a
|
|
107
|
+
* second process while the first was still inside it: the first then renamed
|
|
108
|
+
* its lease over the second's and deleted the second's lock on its way out.
|
|
109
|
+
* What decides an eviction now is the same liveness probe a lease holder is
|
|
110
|
+
* judged by, because "five seconds have passed" says nothing about whether
|
|
111
|
+
* anybody is still in the room.
|
|
112
|
+
*/
|
|
113
|
+
export const CHANNEL_LEASE_RECLAIM_STALE_MS = 5_000;
|
|
114
|
+
/**
|
|
115
|
+
* How much later than the lease a process may have started and still be its
|
|
116
|
+
* holder.
|
|
117
|
+
*
|
|
118
|
+
* `started_at` is written just after the holder is running, so the holder's
|
|
119
|
+
* own start time is at or before it. A pid whose process started AFTER the
|
|
120
|
+
* lease was written is a pid the operating system handed to somebody else,
|
|
121
|
+
* which is the case this tolerance exists to detect rather than to forgive.
|
|
122
|
+
* The slack covers the coarse clock the fallback probe reports (`ps -o
|
|
123
|
+
* lstart=` has one-second resolution) and small clock adjustments.
|
|
124
|
+
*/
|
|
125
|
+
export const CHANNEL_LEASE_START_TOLERANCE_MS = 2_000;
|
|
126
|
+
/**
|
|
127
|
+
* Why a lease could not be taken. Frozen, per SPEC.md §11.1 invariant 6.
|
|
128
|
+
*
|
|
129
|
+
* Two of the three name the transport that already holds the gate, because
|
|
130
|
+
* that is what decides the repair: stop the poller, or stop the webhook runner
|
|
131
|
+
* (which also removes the registration as it exits). The third is the state in
|
|
132
|
+
* which no promise can be made at all.
|
|
133
|
+
*/
|
|
134
|
+
export const CHANNEL_LEASE_REFUSAL_CODES = [
|
|
135
|
+
/** A long-poll listener (`approval channel telegram listen`, `approval up`) holds it. */
|
|
136
|
+
"telegram-poller-running",
|
|
137
|
+
/** A webhook runner holds it. Same code the Bot API probe uses, same repair. */
|
|
138
|
+
"webhook-registered",
|
|
139
|
+
/** The lockfile could not be created, read or written, so exclusion cannot be promised. */
|
|
140
|
+
"telegram-lease-unavailable",
|
|
141
|
+
];
|
|
142
|
+
/** The gate's `daemon/` directory for `logPath`, derived and never configured. */
|
|
143
|
+
export function channelLeaseDirFor(logPath) {
|
|
144
|
+
return join(instanceHomeFor(resolve(logPath)), "daemon");
|
|
145
|
+
}
|
|
146
|
+
/** Where the Telegram transport lease for `logPath` lives. */
|
|
147
|
+
export function channelLeasePathFor(logPath) {
|
|
148
|
+
return join(channelLeaseDirFor(logPath), CHANNEL_LEASE_FILE);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* When the process with this pid started, or `null` when it cannot be learned.
|
|
152
|
+
*
|
|
153
|
+
* Two sources, in order of cost. On Linux `/proc/<pid>` is created with the
|
|
154
|
+
* process and carries its start time, which needs no subprocess. Everywhere
|
|
155
|
+
* else `ps -o lstart=` answers the same question; it costs one short-lived
|
|
156
|
+
* child, paid once per lease take (which happens once per process start), and
|
|
157
|
+
* a `ps` that is missing, slow or unhelpful returns `null` rather than
|
|
158
|
+
* failing.
|
|
159
|
+
*
|
|
160
|
+
* `null` is the fail-CLOSED direction here: a holder whose start time cannot
|
|
161
|
+
* be compared is judged by its pid alone, exactly as before this probe
|
|
162
|
+
* existed, and a live pid then still refuses.
|
|
163
|
+
*/
|
|
164
|
+
export function processStartedAt(pid) {
|
|
165
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
166
|
+
return null;
|
|
167
|
+
if (process.platform === "linux") {
|
|
168
|
+
try {
|
|
169
|
+
return statSync(`/proc/${String(pid)}`).mtime;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
177
|
+
encoding: "utf8",
|
|
178
|
+
timeout: 2_000,
|
|
179
|
+
windowsHide: true,
|
|
180
|
+
});
|
|
181
|
+
if (run.status !== 0)
|
|
182
|
+
return null;
|
|
183
|
+
const text = (run.stdout ?? "").trim();
|
|
184
|
+
if (text.length === 0)
|
|
185
|
+
return null;
|
|
186
|
+
const when = new Date(text);
|
|
187
|
+
return Number.isNaN(when.getTime()) ? null : when;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Ask the operating system about one pid. The default {@link ChannelLeaseOptions.probe}. */
|
|
194
|
+
export function probePid(pid) {
|
|
195
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
196
|
+
return { running: false, foreign: false, startedAt: null };
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
process.kill(pid, 0);
|
|
200
|
+
}
|
|
201
|
+
catch (cause) {
|
|
202
|
+
const code = cause.code;
|
|
203
|
+
if (code === "ESRCH")
|
|
204
|
+
return { running: false, foreign: false, startedAt: null };
|
|
205
|
+
if (code === "EPERM")
|
|
206
|
+
return { running: true, foreign: true, startedAt: processStartedAt(pid) };
|
|
207
|
+
// Anything else is unexpected, and the stricter reading of an unexpected
|
|
208
|
+
// answer is that the holder is there.
|
|
209
|
+
return { running: true, foreign: false, startedAt: processStartedAt(pid) };
|
|
210
|
+
}
|
|
211
|
+
return { running: true, foreign: false, startedAt: processStartedAt(pid) };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Is `pid` a running process?
|
|
215
|
+
*
|
|
216
|
+
* Kept for the callers that only want the coarse answer. The lease itself asks
|
|
217
|
+
* {@link probePid}, because a pid alone cannot tell a holder from a number the
|
|
218
|
+
* operating system has since handed to somebody else.
|
|
219
|
+
*/
|
|
220
|
+
export function pidIsAlive(pid) {
|
|
221
|
+
return probePid(pid).running;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Why a lease stopped being its holder's.
|
|
225
|
+
*
|
|
226
|
+
* Three ways, and the operator is told which: the process is gone, the number
|
|
227
|
+
* now belongs to somebody this process cannot signal, or the number is in use
|
|
228
|
+
* by a process that started after the lease was written and therefore cannot
|
|
229
|
+
* be the one that wrote it.
|
|
230
|
+
*/
|
|
231
|
+
export const CHANNEL_LEASE_RECLAIM_REASONS = ["gone", "foreign", "recycled"];
|
|
232
|
+
/** Is this holder still the process that wrote the lease? */
|
|
233
|
+
function judgeHolder(holder, probe) {
|
|
234
|
+
const found = probe(holder.pid);
|
|
235
|
+
if (!found.running)
|
|
236
|
+
return { held: false, reason: "gone" };
|
|
237
|
+
if (found.foreign)
|
|
238
|
+
return { held: false, reason: "foreign" };
|
|
239
|
+
if (found.startedAt !== null) {
|
|
240
|
+
const wrote = Date.parse(holder.startedAt);
|
|
241
|
+
if (!Number.isNaN(wrote) &&
|
|
242
|
+
found.startedAt.getTime() > wrote + CHANNEL_LEASE_START_TOLERANCE_MS) {
|
|
243
|
+
return { held: false, reason: "recycled" };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { held: true };
|
|
247
|
+
}
|
|
248
|
+
/** How a reclaim is reported to the operator. */
|
|
249
|
+
export function reclaimDetail(holder, reason) {
|
|
250
|
+
const why = reason === "gone"
|
|
251
|
+
? "that process is gone"
|
|
252
|
+
: reason === "foreign"
|
|
253
|
+
? "that pid now belongs to a process this one cannot signal, so it is not the holder"
|
|
254
|
+
: "that pid belongs to a process that started after the lease was written, so the number has been reused";
|
|
255
|
+
return `the telegram transport lease was held by pid ${String(holder.pid)} in ${holder.mode} mode since ${holder.startedAt}, and ${why}; reclaimed`;
|
|
256
|
+
}
|
|
257
|
+
/** Synchronous sleep with no dependency and no busy-spin. `core/log.ts`'s. */
|
|
258
|
+
function sleepSync(ms) {
|
|
259
|
+
const buffer = new Int32Array(new SharedArrayBuffer(4));
|
|
260
|
+
Atomics.wait(buffer, 0, 0, ms);
|
|
261
|
+
}
|
|
262
|
+
function parseHolder(raw) {
|
|
263
|
+
if (typeof raw !== "object" || raw === null)
|
|
264
|
+
return null;
|
|
265
|
+
const record = raw;
|
|
266
|
+
if (record["version"] !== CHANNEL_LEASE_VERSION)
|
|
267
|
+
return null;
|
|
268
|
+
const pid = record["pid"];
|
|
269
|
+
const mode = record["mode"];
|
|
270
|
+
const startedAt = record["started_at"];
|
|
271
|
+
const instanceHome = record["instance_home"];
|
|
272
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
273
|
+
return null;
|
|
274
|
+
if (mode !== "poll" && mode !== "webhook")
|
|
275
|
+
return null;
|
|
276
|
+
if (typeof startedAt !== "string" || startedAt.length === 0)
|
|
277
|
+
return null;
|
|
278
|
+
if (typeof instanceHome !== "string")
|
|
279
|
+
return null;
|
|
280
|
+
return { pid, mode, startedAt, instanceHome };
|
|
281
|
+
}
|
|
282
|
+
function readHolder(path) {
|
|
283
|
+
let raw;
|
|
284
|
+
try {
|
|
285
|
+
raw = readFileSync(path, "utf8");
|
|
286
|
+
}
|
|
287
|
+
catch (cause) {
|
|
288
|
+
return cause.code === "ENOENT" ? { state: "absent" } : { state: "torn" };
|
|
289
|
+
}
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(raw);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return { state: "torn" };
|
|
296
|
+
}
|
|
297
|
+
const holder = parseHolder(parsed);
|
|
298
|
+
return holder === null ? { state: "torn" } : { state: "held", holder };
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* {@link readHolder}, re-read while it comes back unreadable.
|
|
302
|
+
*
|
|
303
|
+
* A record this build cannot parse is either a lockfile a crash tore in half
|
|
304
|
+
* or one a process is writing right now, and for as long as that write takes
|
|
305
|
+
* the two look alike. So an unreadable file is re-read before it is judged,
|
|
306
|
+
* and only one that stays unreadable is reported as torn.
|
|
307
|
+
*/
|
|
308
|
+
function readHolderSettled(path) {
|
|
309
|
+
let read = readHolder(path);
|
|
310
|
+
for (let attempt = 1; read.state === "torn" && attempt < CHANNEL_LEASE_ATTEMPTS; attempt += 1) {
|
|
311
|
+
sleepSync(CHANNEL_LEASE_RETRY_MS);
|
|
312
|
+
read = readHolder(path);
|
|
313
|
+
}
|
|
314
|
+
return read;
|
|
315
|
+
}
|
|
316
|
+
/** What the lockfile for `logPath` currently says, or `null`. Diagnostics only. */
|
|
317
|
+
export function readChannelLease(logPath) {
|
|
318
|
+
const read = readHolder(channelLeasePathFor(logPath));
|
|
319
|
+
return read.state === "held" ? read.holder : null;
|
|
320
|
+
}
|
|
321
|
+
/** The code a refusal takes, given which transport holds the gate. */
|
|
322
|
+
function codeFor(mode) {
|
|
323
|
+
return mode === "webhook" ? "webhook-registered" : "telegram-poller-running";
|
|
324
|
+
}
|
|
325
|
+
function refusalMessage(holder, wanted, path) {
|
|
326
|
+
const held = holder.mode === "webhook"
|
|
327
|
+
? `a webhook runner (\`approval channel telegram webhook\`) is already receiving this gate's telegram updates`
|
|
328
|
+
: `a long-poll listener (\`approval channel telegram listen\` or \`approval up\`) is already receiving this gate's telegram updates`;
|
|
329
|
+
const same = holder.mode === wanted;
|
|
330
|
+
const why = same
|
|
331
|
+
? "Two of them on one log both put every pending request on the phone, each under its own nonce, and only the copy whose process holds the delivery can resolve a tap"
|
|
332
|
+
: "Long polling and a webhook are alternatives per bot: both processes would put every pending request on the phone under their own nonce, only one of the two copies could resolve a tap, and the Bot API refuses getUpdates outright while a webhook is set";
|
|
333
|
+
return `${held} in ${holder.mode} mode, as pid ${String(holder.pid)}, since ${holder.startedAt}. ${why}. Stop that process (a clean stop removes its lease, and a webhook runner removes its registration as it exits), or give this instance its own bot with \`approval setup channel telegram\`. The lease is ${path}; a lease whose process is gone is reclaimed automatically, so deleting it by hand is only for a pid that no longer names a gate`;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Take the gate's Telegram transport lease in `mode`, or say who holds it.
|
|
337
|
+
*
|
|
338
|
+
* Synchronous, like every other lockfile in this runtime: the take happens
|
|
339
|
+
* once per process before anything is delivered, and a caller that had to
|
|
340
|
+
* await it would be a caller that could interleave two takes in one process.
|
|
341
|
+
*/
|
|
342
|
+
export function takeChannelLease(logPath, mode, options = {}) {
|
|
343
|
+
const pid = options.pid ?? process.pid;
|
|
344
|
+
const now = options.now ?? (() => new Date());
|
|
345
|
+
const coarse = options.alive;
|
|
346
|
+
const probe = options.probe ??
|
|
347
|
+
(coarse === undefined
|
|
348
|
+
? probePid
|
|
349
|
+
: (candidate) => ({
|
|
350
|
+
running: coarse(candidate),
|
|
351
|
+
foreign: false,
|
|
352
|
+
startedAt: null,
|
|
353
|
+
}));
|
|
354
|
+
const path = channelLeasePathFor(logPath);
|
|
355
|
+
const instanceHome = instanceHomeFor(resolve(logPath));
|
|
356
|
+
const unavailable = (detail) => ({
|
|
357
|
+
ok: false,
|
|
358
|
+
code: "telegram-lease-unavailable",
|
|
359
|
+
holder: null,
|
|
360
|
+
message: `the telegram transport lease ${path} could not be taken (${detail}). This lease is the only thing that keeps one gate from running a poller and a webhook at once, so a gate that cannot hold one does not start: make that directory writable, or point --log at the instance this process is meant to serve`,
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
mkdirSync(channelLeaseDirFor(logPath), { recursive: true, mode: 0o700 });
|
|
364
|
+
}
|
|
365
|
+
catch (cause) {
|
|
366
|
+
return unavailable(cause instanceof Error ? cause.message : String(cause));
|
|
367
|
+
}
|
|
368
|
+
let reclaimed = null;
|
|
369
|
+
let reclaimedBecause = null;
|
|
370
|
+
for (let attempt = 0; attempt < CHANNEL_LEASE_ATTEMPTS; attempt += 1) {
|
|
371
|
+
const body = `${JSON.stringify({
|
|
372
|
+
version: CHANNEL_LEASE_VERSION,
|
|
373
|
+
pid,
|
|
374
|
+
mode,
|
|
375
|
+
started_at: now().toISOString(),
|
|
376
|
+
instance_home: instanceHome,
|
|
377
|
+
}, null, 2)}\n`;
|
|
378
|
+
// The ordinary case, and the only one `O_EXCL` can settle on its own:
|
|
379
|
+
// nothing holds the gate, and exactly one of any number of processes
|
|
380
|
+
// creating this file wins.
|
|
381
|
+
const created = createExclusive(path, body);
|
|
382
|
+
if (created === "created") {
|
|
383
|
+
return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
|
|
384
|
+
}
|
|
385
|
+
if (created !== "exists")
|
|
386
|
+
return unavailable(created.detail);
|
|
387
|
+
const read = readHolderSettled(path);
|
|
388
|
+
if (read.state === "absent")
|
|
389
|
+
continue;
|
|
390
|
+
if (read.state === "held" && read.holder.pid === pid) {
|
|
391
|
+
// This process already holds the gate. One process runs one transport,
|
|
392
|
+
// and the in-process guard against two is `claimTransport`; rewriting
|
|
393
|
+
// the record keeps it true rather than making a supervisor fight its own
|
|
394
|
+
// lockfile across a restart of its channel part. Written through a temp
|
|
395
|
+
// and a rename, so a reader never sees half a record.
|
|
396
|
+
const rewritten = writeAtomic(path, body, pid);
|
|
397
|
+
if (rewritten !== null)
|
|
398
|
+
return unavailable(rewritten);
|
|
399
|
+
return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
|
|
400
|
+
}
|
|
401
|
+
if (read.state === "held") {
|
|
402
|
+
const verdict = judgeHolder(read.holder, probe);
|
|
403
|
+
if (verdict.held) {
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
code: codeFor(read.holder.mode),
|
|
407
|
+
holder: read.holder,
|
|
408
|
+
message: refusalMessage(read.holder, mode, path),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
options.beforeTakeOver?.();
|
|
412
|
+
const taken = takeOver(path, body, pid, { kind: "holder", holder: read.holder }, probe, {
|
|
413
|
+
...(options.note === undefined ? {} : { note: options.note }),
|
|
414
|
+
...(options.insideTakeOver === undefined ? {} : { inside: options.insideTakeOver }),
|
|
415
|
+
});
|
|
416
|
+
if (taken === "taken") {
|
|
417
|
+
reclaimed = read.holder;
|
|
418
|
+
reclaimedBecause = verdict.reason;
|
|
419
|
+
return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
|
|
420
|
+
}
|
|
421
|
+
if (taken === "changed" || taken === "busy") {
|
|
422
|
+
sleepSync(CHANNEL_LEASE_RETRY_MS);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
return unavailable(taken.detail);
|
|
426
|
+
}
|
|
427
|
+
// Unreadable after {@link CHANNEL_LEASE_ATTEMPTS} reads: nobody is
|
|
428
|
+
// finishing a write, so this is a lockfile a crash tore in half, and
|
|
429
|
+
// refusing forever over bytes nobody can read would be an outage with no
|
|
430
|
+
// fault behind it. Replaced rather than deleted-then-created, so the path
|
|
431
|
+
// is never absent and no third process can slip in through the gap.
|
|
432
|
+
const replaced = takeOver(path, body, pid, { kind: "torn" }, probe, {
|
|
433
|
+
...(options.note === undefined ? {} : { note: options.note }),
|
|
434
|
+
...(options.insideTakeOver === undefined ? {} : { inside: options.insideTakeOver }),
|
|
435
|
+
});
|
|
436
|
+
if (replaced === "taken") {
|
|
437
|
+
return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
|
|
438
|
+
}
|
|
439
|
+
if (replaced === "changed" || replaced === "busy") {
|
|
440
|
+
sleepSync(CHANNEL_LEASE_RETRY_MS);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
return unavailable(replaced.detail);
|
|
444
|
+
}
|
|
445
|
+
return unavailable(`${String(CHANNEL_LEASE_ATTEMPTS)} attempts raced another process taking it over. Something else is reclaiming this gate's transport lease and has not finished: ${path}${CHANNEL_LEASE_RECLAIM_SUFFIX} is that take-over in progress. This process refuses rather than force it, because forcing a critical section is how two transports end up running at once; if that file has no process behind it and this keeps happening, remove it`);
|
|
446
|
+
}
|
|
447
|
+
/** `O_EXCL` create, the one race the file system settles by itself. */
|
|
448
|
+
function createExclusive(path, body) {
|
|
449
|
+
try {
|
|
450
|
+
const handle = openSync(path, "wx", 0o600);
|
|
451
|
+
try {
|
|
452
|
+
writeFileSync(handle, body, { encoding: "utf8" });
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
closeSync(handle);
|
|
456
|
+
}
|
|
457
|
+
return "created";
|
|
458
|
+
}
|
|
459
|
+
catch (cause) {
|
|
460
|
+
if (cause.code === "EEXIST")
|
|
461
|
+
return "exists";
|
|
462
|
+
return { detail: cause instanceof Error ? cause.message : String(cause) };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Write `path` whole, by rename, never leaving it absent or half-written.
|
|
467
|
+
*
|
|
468
|
+
* `rename` replaces the destination in one step, so a reader either sees the
|
|
469
|
+
* old record or the new one. Returns `null` on success and the detail of the
|
|
470
|
+
* failure otherwise.
|
|
471
|
+
*/
|
|
472
|
+
function writeAtomic(path, body, pid) {
|
|
473
|
+
temporaryCounter += 1;
|
|
474
|
+
const temporary = `${path}.${String(pid)}.${String(temporaryCounter)}.${String(Date.now())}.tmp`;
|
|
475
|
+
try {
|
|
476
|
+
writeFileSync(temporary, body, { encoding: "utf8", mode: 0o600 });
|
|
477
|
+
renameSync(temporary, path);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
catch (cause) {
|
|
481
|
+
try {
|
|
482
|
+
unlinkSync(temporary);
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
/* the temp may never have been created; its absence is the desired state */
|
|
486
|
+
}
|
|
487
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
let temporaryCounter = 0;
|
|
491
|
+
function readReclaimLock(lockPath) {
|
|
492
|
+
let raw;
|
|
493
|
+
try {
|
|
494
|
+
raw = readFileSync(lockPath, "utf8");
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
let parsed;
|
|
500
|
+
try {
|
|
501
|
+
parsed = JSON.parse(raw);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
507
|
+
return null;
|
|
508
|
+
const record = parsed;
|
|
509
|
+
const pid = record["pid"];
|
|
510
|
+
const nonce = record["nonce"];
|
|
511
|
+
const at = record["at"];
|
|
512
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
513
|
+
return null;
|
|
514
|
+
if (typeof nonce !== "string" || nonce.length === 0)
|
|
515
|
+
return null;
|
|
516
|
+
return { pid, nonce, at: typeof at === "number" ? at : 0 };
|
|
517
|
+
}
|
|
518
|
+
/** Does the lock on disk still name this entry? */
|
|
519
|
+
function holdsSection(lockPath, pid, nonce) {
|
|
520
|
+
const lock = readReclaimLock(lockPath);
|
|
521
|
+
return lock !== null && lock.pid === pid && lock.nonce === nonce;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Replace a lockfile somebody else wrote, inside a critical section.
|
|
525
|
+
*
|
|
526
|
+
* The section is the whole of the fix for the interleaving in APRV-424's
|
|
527
|
+
* second review: two processes that had both read one dead holder used to take
|
|
528
|
+
* turns deleting each other's live lease. Inside it the record is read AGAIN
|
|
529
|
+
* and must still be exactly what was judged (the same pid and the same
|
|
530
|
+
* `started_at`), so the second reclaimer of a pair abandons its take-over,
|
|
531
|
+
* finds a live record on the next pass, and refuses.
|
|
532
|
+
*
|
|
533
|
+
* ## The section is owned, not merely occupied (third review, finding 1)
|
|
534
|
+
*
|
|
535
|
+
* Holding the section is not enough; a process has to still be holding it at
|
|
536
|
+
* the moment it acts. The first version aged a reclaim lock out after five
|
|
537
|
+
* seconds and unlinked it on the way out unconditionally, which gave the
|
|
538
|
+
* section away and then took somebody else's:
|
|
539
|
+
*
|
|
540
|
+
* A enters, re-reads, and stalls. B ages A's lock out, enters, reclaims, and
|
|
541
|
+
* renames its own lease in. A resumes and renames over B's live lease, and
|
|
542
|
+
* A's `finally` deletes B's reclaim lock for good measure. Two holders, again.
|
|
543
|
+
*
|
|
544
|
+
* Three changes close it, and all three are about identity:
|
|
545
|
+
*
|
|
546
|
+
* - The lock carries this process's pid AND a nonce, so one entry can be told
|
|
547
|
+
* from another by the same process.
|
|
548
|
+
* - The ownership is re-checked immediately before the rename. A process that
|
|
549
|
+
* lost the section abandons the take-over (`busy`) and meets the new
|
|
550
|
+
* holder's live record on its next pass.
|
|
551
|
+
* - The `finally` removes the lock only while it still names this entry.
|
|
552
|
+
*
|
|
553
|
+
* And the age-out is no longer a decision: a reclaim lock is evicted only when
|
|
554
|
+
* its own process is GONE, judged by the same probe a lease holder is judged
|
|
555
|
+
* by. The age is reported and nothing more, because "five seconds have passed"
|
|
556
|
+
* says nothing about whether anybody is still in the room.
|
|
557
|
+
*
|
|
558
|
+
* `busy` means another process holds the section right now: the caller waits
|
|
559
|
+
* and comes round again rather than forcing it, because forcing a critical
|
|
560
|
+
* section is the same as not having one.
|
|
561
|
+
*/
|
|
562
|
+
function takeOver(path, body, pid, expect, probe, options = {}) {
|
|
563
|
+
const lockPath = `${path}${CHANNEL_LEASE_RECLAIM_SUFFIX}`;
|
|
564
|
+
const nonce = randomBytes(8).toString("hex");
|
|
565
|
+
const entered = createExclusive(lockPath, `${JSON.stringify({ pid, nonce, at: Date.now() })}\n`);
|
|
566
|
+
if (entered !== "created") {
|
|
567
|
+
if (entered !== "exists")
|
|
568
|
+
return entered;
|
|
569
|
+
const held = readReclaimLock(lockPath);
|
|
570
|
+
if (held === null) {
|
|
571
|
+
// Unreadable or half written: it may be a lock being entered right now,
|
|
572
|
+
// and it names no process, so there is no liveness question to ask about
|
|
573
|
+
// it. Age may not answer one either (see the constant above), so waiting
|
|
574
|
+
// is the only safe answer; the attempt budget bounds it and the refusal
|
|
575
|
+
// names the file.
|
|
576
|
+
return "busy";
|
|
577
|
+
}
|
|
578
|
+
if (probe(held.pid).running)
|
|
579
|
+
return "busy";
|
|
580
|
+
// Its process is gone, so it died inside the section. The age says how
|
|
581
|
+
// long ago that was and decides nothing: a lock five seconds old whose
|
|
582
|
+
// process is alive is a slow reclaim, and evicting it is how two holders
|
|
583
|
+
// happen.
|
|
584
|
+
const age = Date.now() - held.at;
|
|
585
|
+
options.note?.(`a telegram transport reclaim lock left by pid ${String(held.pid)} ${String(Math.max(age, 0))}ms ago has no process behind it; evicted`);
|
|
586
|
+
try {
|
|
587
|
+
unlinkSync(lockPath);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
/* somebody else cleared it first, which is the same outcome */
|
|
591
|
+
}
|
|
592
|
+
return "busy";
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
const fresh = readHolderSettled(path);
|
|
596
|
+
if (fresh.state === "absent") {
|
|
597
|
+
// The record went away while this process was entering the section: the
|
|
598
|
+
// holder released it, or another take-over finished. Create rather than
|
|
599
|
+
// rename, so a fresh taker that got there first is not overwritten.
|
|
600
|
+
const created = createExclusive(path, body);
|
|
601
|
+
if (created === "created")
|
|
602
|
+
return "taken";
|
|
603
|
+
return created === "exists" ? "changed" : created;
|
|
604
|
+
}
|
|
605
|
+
if (expect.kind === "torn") {
|
|
606
|
+
if (fresh.state !== "torn")
|
|
607
|
+
return "changed";
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
if (fresh.state !== "held")
|
|
611
|
+
return "changed";
|
|
612
|
+
// Identity is the pid AND the instant it wrote, so a reused pid holding
|
|
613
|
+
// a fresh lease is not mistaken for the dead holder that was judged.
|
|
614
|
+
if (fresh.holder.pid !== expect.holder.pid)
|
|
615
|
+
return "changed";
|
|
616
|
+
if (fresh.holder.startedAt !== expect.holder.startedAt)
|
|
617
|
+
return "changed";
|
|
618
|
+
}
|
|
619
|
+
options.inside?.();
|
|
620
|
+
// The last thing before the write: is this entry still the one that holds
|
|
621
|
+
// the section? A process that stalled long enough to be evicted must not
|
|
622
|
+
// rename over the lease that replaced it.
|
|
623
|
+
if (!holdsSection(lockPath, pid, nonce))
|
|
624
|
+
return "busy";
|
|
625
|
+
const written = writeAtomic(path, body, pid);
|
|
626
|
+
return written === null ? "taken" : { detail: written };
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
// Only ours, and only while it is still ours: unlinking unconditionally is
|
|
630
|
+
// how a stalled process hands the next holder's section to a third.
|
|
631
|
+
if (holdsSection(lockPath, pid, nonce)) {
|
|
632
|
+
try {
|
|
633
|
+
unlinkSync(lockPath);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
/* advisory: a lock whose process is gone is evicted by the next taker */
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function leaseFor(path, mode, pid) {
|
|
642
|
+
let released = false;
|
|
643
|
+
return {
|
|
644
|
+
path,
|
|
645
|
+
mode,
|
|
646
|
+
pid,
|
|
647
|
+
release: () => {
|
|
648
|
+
if (released)
|
|
649
|
+
return;
|
|
650
|
+
released = true;
|
|
651
|
+
const read = readHolder(path);
|
|
652
|
+
if (read.state !== "held" || read.holder.pid !== pid)
|
|
653
|
+
return;
|
|
654
|
+
try {
|
|
655
|
+
unlinkSync(path);
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
/* advisory: a lease that could not be removed is reclaimed by the next taker's liveness probe */
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=channel-lease.js.map
|