@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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/dist/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/operation-lock.ts
DELETED
|
@@ -1,680 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-HQ-root mutual exclusion for the long-running operations
|
|
3
|
-
* (`sync`, `rescue`, `reindex`).
|
|
4
|
-
*
|
|
5
|
-
* Contract:
|
|
6
|
-
* - `sync` and `rescue` share ONE per-root lock (the "operation" scope, keyed
|
|
7
|
-
* only by the root, not the command), so at most one of them runs at a time
|
|
8
|
-
* and e.g. a rescue refuses while a sync holds it. Different HQ roots are
|
|
9
|
-
* fully independent — they hash to different lock files.
|
|
10
|
-
* - `reindex` uses a SEPARATE per-root scope ("reindex"), so it is guarded
|
|
11
|
-
* against other reindexes but is NOT blocked by a held sync/rescue lock.
|
|
12
|
-
* This is deliberate: a watch-mode `hq-sync-runner` holds the "operation"
|
|
13
|
-
* lock across its entire lifetime, and a shared lock would starve a
|
|
14
|
-
* standalone `hq reindex` indefinitely (feedback_ed98d810). reindex only
|
|
15
|
-
* READS the source trees to rebuild derived artifacts (skill wrappers,
|
|
16
|
-
* overlay mirrors, the workers registry) and is idempotent + re-run after
|
|
17
|
-
* every sync pass, so decoupling it from the sync writer lock is safe.
|
|
18
|
-
* See {@link lockPathFor} for how the scope partitions the lock file.
|
|
19
|
-
* - The push watcher / watch+event-push runner is EXEMPT: it never calls in
|
|
20
|
-
* here, so it neither takes the lock nor is blocked by it (its targeted
|
|
21
|
-
* in-process push passes are likewise lock-free).
|
|
22
|
-
*
|
|
23
|
-
* ## Where the lock lives — and why
|
|
24
|
-
*
|
|
25
|
-
* `<stateDir>/locks/operation-<hash(canonicalRoot)>.lock`, where
|
|
26
|
-
* `stateDir = $HQ_STATE_DIR || ~/.hq`. This is deliberately NOT inside the HQ
|
|
27
|
-
* root:
|
|
28
|
-
* - It must never round-trip to the cloud. A lock is machine-local, per-run
|
|
29
|
-
* state; syncing it to S3 (and thence to other machines/roots) would be a
|
|
30
|
-
* correctness bug. `~/.hq` is the established machine-local state dir
|
|
31
|
-
* (journals already live there) and is never synced.
|
|
32
|
-
* - `rescue` repairs a possibly-broken HQ root; a lock that depends on the
|
|
33
|
-
* root being healthy is exactly backwards. `~/.hq` is independent of the
|
|
34
|
-
* root's health.
|
|
35
|
-
* - Keying the filename by a hash of the *canonical* root path makes the
|
|
36
|
-
* lock per-root and prevents leakage across roots, while keeping the path
|
|
37
|
-
* short and filesystem-safe.
|
|
38
|
-
*
|
|
39
|
-
* ## Atomicity, liveness, takeover
|
|
40
|
-
*
|
|
41
|
-
* - Acquisition writes the full owner payload to a same-directory temp file,
|
|
42
|
-
* fsyncs it, then publishes that complete file into the final lock name
|
|
43
|
-
* with create-if-absent semantics. Exactly one racer can publish; the loser
|
|
44
|
-
* sees EEXIST and re-evaluates.
|
|
45
|
-
* - The lock records the holder's `{ pid, command, startedAt, hqRoot }`. On
|
|
46
|
-
* EEXIST we test the recorded PID with `process.kill(pid, 0)`:
|
|
47
|
-
* * ESRCH → the holder is gone (crashed / killed -9 / stale file) →
|
|
48
|
-
* reclaim the lock IMMEDIATELY (a dead holder never makes us
|
|
49
|
-
* wait).
|
|
50
|
-
* * EPERM → the PID exists but is owned by another user → treat as ALIVE
|
|
51
|
-
* (conservative: wait rather than risk two concurrent ops).
|
|
52
|
-
* * success → alive → WAIT for the holder to release, then acquire (see
|
|
53
|
-
* "Waiting" below). The fast-refusal path is still reachable
|
|
54
|
-
* via an explicit timeout / `wait: false`.
|
|
55
|
-
*
|
|
56
|
-
* ## Waiting for a live holder (default behavior)
|
|
57
|
-
*
|
|
58
|
-
* When a LIVE holder owns the lock, acquisition WAITS by default: it polls
|
|
59
|
-
* (~2s) and acquires the instant the holder releases, rather than refusing
|
|
60
|
-
* fast. A single status line is written to stderr the first time we start
|
|
61
|
-
* waiting ("Waiting for <command> (pid N) to finish…"), never per-poll.
|
|
62
|
-
* This is what an interactive `sync` / `rescue` / `reindex` invocation wants —
|
|
63
|
-
* queue behind the running op instead of erroring out.
|
|
64
|
-
*
|
|
65
|
-
* A bounded escape exists for scripts that must not block forever:
|
|
66
|
-
* - `timeoutSec` option, or the `HQ_OP_LOCK_TIMEOUT` env var (seconds).
|
|
67
|
-
* The option wins over the env. After the bound elapses we throw
|
|
68
|
-
* {@link OperationLockedError} (exit 17) with the same clear refusal
|
|
69
|
-
* message as before.
|
|
70
|
-
* - `timeoutSec === 0` (or `HQ_OP_LOCK_TIMEOUT=0`, or `wait: false`) → do
|
|
71
|
-
* not wait at all; refuse immediately. This is the pre-wait behavior.
|
|
72
|
-
* - absent / negative / unparseable → INFINITE wait (the documented
|
|
73
|
-
* default).
|
|
74
|
-
* Stale-PID takeover is unconditional and happens BEFORE any wait — a dead
|
|
75
|
-
* holder is reclaimed at once regardless of the wait config.
|
|
76
|
-
*
|
|
77
|
-
* Ordering / scope caveats:
|
|
78
|
-
* - This is a CROSS-PROCESS mutex keyed on the holder's PID. In-process
|
|
79
|
-
* concurrent acquire is unsupported (the real consumers — sync / rescue /
|
|
80
|
-
* reindex — are separate processes), but a live same-PID holder is still
|
|
81
|
-
* treated as busy rather than reclaimed.
|
|
82
|
-
* - When several distinct processes wait on the same lock, the next one to
|
|
83
|
-
* win the O_EXCL race after a free acquires. Order is best-effort, NOT
|
|
84
|
-
* FIFO — do not depend on arrival order.
|
|
85
|
-
* - PID reuse is an inherent, un-eliminable race for any PID-based scheme: if
|
|
86
|
-
* the original holder crashed and the OS later handed its PID to an
|
|
87
|
-
* unrelated process, we conservatively read that as "still held" and
|
|
88
|
-
* refuse. We accept that false-busy over the far worse false-free, and
|
|
89
|
-
* record `startedAt`/`command` so an operator can diagnose a wedged lock.
|
|
90
|
-
*
|
|
91
|
-
* ## Release
|
|
92
|
-
*
|
|
93
|
-
* - Normal exit: the `with*` wrappers release in a `finally`.
|
|
94
|
-
* - Signals (SIGINT/SIGTERM): a one-time handler releases every held lock,
|
|
95
|
-
* then re-raises the default disposition so exit status is unchanged.
|
|
96
|
-
* - Hard crash (SIGKILL / power loss): nothing runs, but the stale-PID
|
|
97
|
-
* takeover above reclaims the lock on the next attempt.
|
|
98
|
-
* - `process.on("exit")`: a final best-effort synchronous unlink.
|
|
99
|
-
*
|
|
100
|
-
* ## Escape hatch
|
|
101
|
-
*
|
|
102
|
-
* `HQ_DISABLE_OP_LOCK=1` makes acquisition a no-op (returns a handle whose
|
|
103
|
-
* release does nothing). For emergencies and for callers that manage
|
|
104
|
-
* exclusion themselves; documented, off by default.
|
|
105
|
-
*/
|
|
106
|
-
|
|
107
|
-
import * as crypto from "crypto";
|
|
108
|
-
import * as fs from "fs";
|
|
109
|
-
import * as os from "os";
|
|
110
|
-
import * as path from "path";
|
|
111
|
-
|
|
112
|
-
/** Process exit code used when an operation is refused because the lock is held. */
|
|
113
|
-
export const OPERATION_LOCKED_EXIT = 17;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Process exit code used when an operation is refused because the lock's state
|
|
117
|
-
* directory is not writable (a permission-class fs error, not a live holder).
|
|
118
|
-
*/
|
|
119
|
-
export const OPERATION_LOCK_UNWRITABLE_EXIT = 18;
|
|
120
|
-
|
|
121
|
-
export interface LockInfo {
|
|
122
|
-
pid: number;
|
|
123
|
-
command: string;
|
|
124
|
-
/** ISO-8601 acquisition time. */
|
|
125
|
-
startedAt: string;
|
|
126
|
-
/** Canonical HQ root the lock guards (diagnostic only). */
|
|
127
|
-
hqRoot: string;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Thrown by `acquireOperationLock` when a LIVE holder owns the lock. */
|
|
131
|
-
export class OperationLockedError extends Error {
|
|
132
|
-
constructor(
|
|
133
|
-
public readonly holder: LockInfo,
|
|
134
|
-
public readonly attempted: string,
|
|
135
|
-
) {
|
|
136
|
-
super(
|
|
137
|
-
`Refusing to start "${attempted}": another HQ operation is already ` +
|
|
138
|
-
`running for this HQ root — "${holder.command}" (pid ${holder.pid}, ` +
|
|
139
|
-
`started ${holder.startedAt}). Wait for it to finish, or stop that ` +
|
|
140
|
-
`process, then retry.`,
|
|
141
|
-
);
|
|
142
|
-
this.name = "OperationLockedError";
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Thrown by `acquireOperationLock*` when the per-root lock CANNOT BE CREATED
|
|
148
|
-
* because its state directory is not writable — a permission-class fs error
|
|
149
|
-
* (`EPERM` / `EACCES` / `EROFS`) on the lock dir or its temp file, NOT a live
|
|
150
|
-
* holder. Distinct from {@link OperationLockedError}: nothing is holding the
|
|
151
|
-
* lock; this process simply cannot write there — e.g. `~/.hq` owned by another
|
|
152
|
-
* user (created by a past `sudo` run), a macOS privacy/security restriction
|
|
153
|
-
* (which surfaces as `EPERM: operation not permitted`), or a read-only volume.
|
|
154
|
-
* Callers turn this into a clear, actionable message + clean exit rather than a
|
|
155
|
-
* raw uncaught `fs` crash (HQ-CLI-2).
|
|
156
|
-
*/
|
|
157
|
-
export class OperationLockUnwritableError extends Error {
|
|
158
|
-
constructor(
|
|
159
|
-
public readonly lockDir: string,
|
|
160
|
-
public readonly cause: NodeJS.ErrnoException,
|
|
161
|
-
) {
|
|
162
|
-
super(
|
|
163
|
-
`Cannot create the HQ operation lock in "${lockDir}" ` +
|
|
164
|
-
`(${cause.code}: ${cause.message}). That directory is not writable, so ` +
|
|
165
|
-
`HQ can't guard this operation against a concurrent run. Likely causes: ` +
|
|
166
|
-
`it is owned by another user (e.g. created by a past "sudo" command), a ` +
|
|
167
|
-
`macOS privacy/security restriction, or a read-only volume. To fix: make ` +
|
|
168
|
-
`it writable (e.g. "sudo chown -R $(whoami) ~/.hq"), or point HQ at a ` +
|
|
169
|
-
`writable state directory with HQ_STATE_DIR=<dir>. To bypass the lock for ` +
|
|
170
|
-
`a single run (drops concurrency protection), set HQ_DISABLE_OP_LOCK=1.`,
|
|
171
|
-
);
|
|
172
|
-
this.name = "OperationLockUnwritableError";
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export interface LockHandle {
|
|
177
|
-
/** Absolute path of the lock file. */
|
|
178
|
-
readonly path: string;
|
|
179
|
-
/** The info written for this holder. */
|
|
180
|
-
readonly info: LockInfo;
|
|
181
|
-
/** Idempotently release the lock iff this process still owns it. */
|
|
182
|
-
release(): void;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Default poll interval while waiting on a live holder. */
|
|
186
|
-
export const DEFAULT_LOCK_POLL_MS = 2000;
|
|
187
|
-
|
|
188
|
-
/** Options controlling how `acquireOperationLock*` behaves against a LIVE holder. */
|
|
189
|
-
export interface AcquireOptions {
|
|
190
|
-
/**
|
|
191
|
-
* When a LIVE holder owns the lock: `true` (default) → WAIT-poll until it
|
|
192
|
-
* frees, then acquire; `false` → refuse immediately with
|
|
193
|
-
* {@link OperationLockedError}. A `timeoutSec` of 0 is equivalent to
|
|
194
|
-
* `wait: false`.
|
|
195
|
-
*/
|
|
196
|
-
wait?: boolean;
|
|
197
|
-
/**
|
|
198
|
-
* Bounded wait, in seconds, before giving up and throwing
|
|
199
|
-
* {@link OperationLockedError} (exit 17). Precedence: this option > the
|
|
200
|
-
* `HQ_OP_LOCK_TIMEOUT` env var > infinite. `0` → do not wait at all (refuse
|
|
201
|
-
* immediately). Negative / non-finite → treated as absent (infinite wait).
|
|
202
|
-
* Fractional values are honored (used by tests); the CLI flags accept whole
|
|
203
|
-
* seconds.
|
|
204
|
-
*/
|
|
205
|
-
timeoutSec?: number;
|
|
206
|
-
/** Poll interval in ms while waiting. Defaults to {@link DEFAULT_LOCK_POLL_MS}. */
|
|
207
|
-
pollIntervalMs?: number;
|
|
208
|
-
/**
|
|
209
|
-
* Invoked exactly ONCE, the first time we begin waiting on a live holder.
|
|
210
|
-
* Defaults to a single "Waiting for …" line on stderr. Pass a custom hook
|
|
211
|
-
* (or a no-op) to redirect/silence the status line.
|
|
212
|
-
*/
|
|
213
|
-
onWaitStart?: (holder: LockInfo, attempted: string) => void;
|
|
214
|
-
/**
|
|
215
|
-
* Lock scope — partitions the per-root mutex into independent lock files (see
|
|
216
|
-
* {@link lockPathFor}). Defaults to {@link DEFAULT_LOCK_SCOPE} ("operation"),
|
|
217
|
-
* which `sync`/`rescue` share. `reindex` passes "reindex" so it is guarded
|
|
218
|
-
* against other reindexes without being blocked by a held sync/rescue lock.
|
|
219
|
-
*/
|
|
220
|
-
scope?: string;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
interface ResolvedWaitConfig {
|
|
224
|
-
/** null → wait forever; >= 0 → wait at most this many ms (0 = no wait). */
|
|
225
|
-
timeoutMs: number | null;
|
|
226
|
-
pollMs: number;
|
|
227
|
-
onWaitStart: (holder: LockInfo, attempted: string) => void;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Default status line: a single stderr message naming the holder. */
|
|
231
|
-
function defaultOnWaitStart(holder: LockInfo, attempted: string): void {
|
|
232
|
-
process.stderr.write(
|
|
233
|
-
`Waiting for "${holder.command}" (pid ${holder.pid}) to finish before ` +
|
|
234
|
-
`starting "${attempted}"… (set HQ_OP_LOCK_TIMEOUT=<secs> to bound the wait)\n`,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Resolve the effective wait config from explicit options + the
|
|
240
|
-
* `HQ_OP_LOCK_TIMEOUT` env var. Option timeout wins over the env; `wait: false`
|
|
241
|
-
* forces a zero (no-wait) timeout.
|
|
242
|
-
*/
|
|
243
|
-
function resolveWaitConfig(opts: AcquireOptions): ResolvedWaitConfig {
|
|
244
|
-
// Parse a seconds value into ms, or null for "absent/infinite". Only a
|
|
245
|
-
// finite, non-negative number counts; everything else (NaN, Infinity, <0)
|
|
246
|
-
// means "no explicit bound".
|
|
247
|
-
const toMs = (sec: number | undefined): number | null => {
|
|
248
|
-
if (sec === undefined) return null;
|
|
249
|
-
if (!Number.isFinite(sec) || sec < 0) return null;
|
|
250
|
-
return Math.round(sec * 1000);
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
let timeoutMs: number | null;
|
|
254
|
-
if (opts.timeoutSec !== undefined) {
|
|
255
|
-
timeoutMs = toMs(opts.timeoutSec);
|
|
256
|
-
} else {
|
|
257
|
-
const envRaw = process.env.HQ_OP_LOCK_TIMEOUT;
|
|
258
|
-
timeoutMs = envRaw !== undefined && envRaw !== "" ? toMs(Number(envRaw)) : null;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// `wait: false` is shorthand for a zero-length wait (refuse immediately).
|
|
262
|
-
if (opts.wait === false) timeoutMs = 0;
|
|
263
|
-
|
|
264
|
-
const pollMs =
|
|
265
|
-
opts.pollIntervalMs && opts.pollIntervalMs > 0
|
|
266
|
-
? opts.pollIntervalMs
|
|
267
|
-
: DEFAULT_LOCK_POLL_MS;
|
|
268
|
-
|
|
269
|
-
return { timeoutMs, pollMs, onWaitStart: opts.onWaitStart ?? defaultOnWaitStart };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Block the current thread for `ms` without busy-spinning (sync consumers). */
|
|
273
|
-
function sleepSync(ms: number): void {
|
|
274
|
-
if (ms <= 0) return;
|
|
275
|
-
// Atomics.wait on a private buffer is a clean, CPU-free sleep. The value at
|
|
276
|
-
// index 0 is 0 and nothing ever notifies it, so this always sleeps the full
|
|
277
|
-
// timeout (or less if interrupted) and returns "timed-out".
|
|
278
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Non-blocking sleep for the async consumer. */
|
|
282
|
-
function sleepAsync(ms: number): Promise<void> {
|
|
283
|
-
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function stateDir(): string {
|
|
287
|
-
return process.env.HQ_STATE_DIR || path.join(os.homedir(), ".hq");
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function canonicalRoot(hqRoot: string): string {
|
|
291
|
-
const resolved = path.resolve(hqRoot);
|
|
292
|
-
try {
|
|
293
|
-
return fs.realpathSync.native(resolved);
|
|
294
|
-
} catch {
|
|
295
|
-
return resolved;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Default lock scope. `sync` and `rescue` share this one so they stay mutually
|
|
301
|
-
* exclusive with each other, keyed only by the root.
|
|
302
|
-
*/
|
|
303
|
-
export const DEFAULT_LOCK_SCOPE = "operation";
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Absolute lock path for a given HQ root and `scope`. Exported for tests.
|
|
307
|
-
*
|
|
308
|
-
* The `scope` prefixes the lock filename so callers can partition the mutex:
|
|
309
|
-
* `sync`/`rescue` use {@link DEFAULT_LOCK_SCOPE} ("operation"); `reindex` uses
|
|
310
|
-
* its own "reindex" scope so a long-lived watch-mode sync-runner (which holds
|
|
311
|
-
* the "operation" lock across its whole lifetime) can never starve a standalone
|
|
312
|
-
* `hq reindex`. Different scopes hash to different lock files and never block
|
|
313
|
-
* one another; the same scope is a real cross-process mutex.
|
|
314
|
-
*/
|
|
315
|
-
export function lockPathFor(hqRoot: string, scope: string = DEFAULT_LOCK_SCOPE): string {
|
|
316
|
-
const canon = canonicalRoot(hqRoot);
|
|
317
|
-
const key = crypto.createHash("sha1").update(canon).digest("hex").slice(0, 16);
|
|
318
|
-
return path.join(stateDir(), "locks", `${scope}-${key}.lock`);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Is `pid` a live process? `kill(pid, 0)` sends no signal; it only probes.
|
|
323
|
-
* ESRCH → no such process (dead/stale). EPERM → exists but not ours → ALIVE
|
|
324
|
-
* (conservative). Anything else → assume alive rather than risk a double-run.
|
|
325
|
-
*/
|
|
326
|
-
function pidAlive(pid: number): boolean {
|
|
327
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
328
|
-
try {
|
|
329
|
-
process.kill(pid, 0);
|
|
330
|
-
return true;
|
|
331
|
-
} catch (err) {
|
|
332
|
-
const code = (err as NodeJS.ErrnoException)?.code;
|
|
333
|
-
if (code === "ESRCH") return false;
|
|
334
|
-
return true; // EPERM (exists) or unknown → treat as alive
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function readLockInfo(p: string): LockInfo | null {
|
|
339
|
-
try {
|
|
340
|
-
const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as LockInfo;
|
|
341
|
-
if (parsed && typeof parsed.pid === "number" && typeof parsed.command === "string") {
|
|
342
|
-
return parsed;
|
|
343
|
-
}
|
|
344
|
-
return null;
|
|
345
|
-
} catch {
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function initializingLockInfo(p: string): LockInfo {
|
|
351
|
-
let mtimeMs = Date.now();
|
|
352
|
-
try {
|
|
353
|
-
const st = fs.statSync(p);
|
|
354
|
-
mtimeMs = st.mtimeMs;
|
|
355
|
-
} catch {
|
|
356
|
-
/* lock disappeared between EEXIST and stat; treat as transient busy */
|
|
357
|
-
}
|
|
358
|
-
return {
|
|
359
|
-
pid: 0,
|
|
360
|
-
command: "operation-lock writer",
|
|
361
|
-
startedAt: new Date(mtimeMs).toISOString(),
|
|
362
|
-
hqRoot: "unknown",
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ── Process-wide release plumbing ──────────────────────────────────────────
|
|
367
|
-
// Track every lock this process currently holds so the signal/exit hooks can
|
|
368
|
-
// release all of them. The hooks are installed exactly once.
|
|
369
|
-
|
|
370
|
-
const heldLocks = new Set<LockHandle>();
|
|
371
|
-
let hooksInstalled = false;
|
|
372
|
-
|
|
373
|
-
function unlinkIfOwned(p: string): void {
|
|
374
|
-
// Only remove a lock whose recorded pid is THIS process — never clobber a
|
|
375
|
-
// lock another process took over after a (hypothetical) reclaim race.
|
|
376
|
-
const info = readLockInfo(p);
|
|
377
|
-
if (info && info.pid === process.pid) {
|
|
378
|
-
try {
|
|
379
|
-
fs.unlinkSync(p);
|
|
380
|
-
} catch {
|
|
381
|
-
/* already gone — fine */
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function installHooksOnce(): void {
|
|
387
|
-
if (hooksInstalled) return;
|
|
388
|
-
hooksInstalled = true;
|
|
389
|
-
|
|
390
|
-
process.on("exit", () => {
|
|
391
|
-
for (const h of heldLocks) unlinkIfOwned(h.path);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
395
|
-
process.on(sig, () => {
|
|
396
|
-
for (const h of heldLocks) unlinkIfOwned(h.path);
|
|
397
|
-
// Re-raise with the default disposition so the exit status is the normal
|
|
398
|
-
// signal status (and a second Ctrl-C still works). Removing our listener
|
|
399
|
-
// first avoids recursing back into this handler.
|
|
400
|
-
process.removeAllListeners(sig);
|
|
401
|
-
process.kill(process.pid, sig);
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function makeHandle(p: string, info: LockInfo): LockHandle {
|
|
407
|
-
const handle: LockHandle = {
|
|
408
|
-
path: p,
|
|
409
|
-
info,
|
|
410
|
-
release() {
|
|
411
|
-
heldLocks.delete(handle);
|
|
412
|
-
unlinkIfOwned(p);
|
|
413
|
-
},
|
|
414
|
-
};
|
|
415
|
-
heldLocks.add(handle);
|
|
416
|
-
installHooksOnce();
|
|
417
|
-
return handle;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const NOOP_HANDLE_BASE = { release() {} };
|
|
421
|
-
|
|
422
|
-
/** No-op handle for the `HQ_DISABLE_OP_LOCK=1` escape hatch. */
|
|
423
|
-
function disabledHandle(hqRoot: string, command: string): LockHandle {
|
|
424
|
-
const info: LockInfo = {
|
|
425
|
-
pid: process.pid,
|
|
426
|
-
command,
|
|
427
|
-
startedAt: new Date().toISOString(),
|
|
428
|
-
hqRoot: canonicalRoot(hqRoot),
|
|
429
|
-
};
|
|
430
|
-
return { ...NOOP_HANDLE_BASE, path: "", info };
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Permission-class fs error codes that mean the lock's state directory itself is
|
|
435
|
-
* unusable for THIS process (not a transient/EEXIST race). macOS surfaces the
|
|
436
|
-
* restricted-location case as `EPERM`; Linux as `EACCES`; a read-only mount as
|
|
437
|
-
* `EROFS`.
|
|
438
|
-
*/
|
|
439
|
-
const UNWRITABLE_LOCK_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* If `err` is a permission-class fs error against the lock dir, rethrow it as an
|
|
443
|
-
* actionable {@link OperationLockUnwritableError}; otherwise rethrow it
|
|
444
|
-
* unchanged. Always throws (return type `never`).
|
|
445
|
-
*
|
|
446
|
-
* Exported for unit testing: ESM module namespaces can't be spied, so the
|
|
447
|
-
* classification decision is verified directly here rather than by mocking
|
|
448
|
-
* `fs.openSync` (see operation-lock.test.ts, HQ-CLI-2).
|
|
449
|
-
*/
|
|
450
|
-
export function rethrowLockCreateError(err: unknown, lockDir: string): never {
|
|
451
|
-
const e = err as NodeJS.ErrnoException | null;
|
|
452
|
-
if (e && typeof e.code === "string" && UNWRITABLE_LOCK_CODES.has(e.code)) {
|
|
453
|
-
throw new OperationLockUnwritableError(lockDir, e);
|
|
454
|
-
}
|
|
455
|
-
throw err;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/** Build the lock payload + ensure the locks dir exists. */
|
|
459
|
-
function prepareLock(
|
|
460
|
-
hqRoot: string,
|
|
461
|
-
command: string,
|
|
462
|
-
scope: string = DEFAULT_LOCK_SCOPE,
|
|
463
|
-
): { p: string; info: LockInfo; payload: string } {
|
|
464
|
-
const p = lockPathFor(hqRoot, scope);
|
|
465
|
-
const dir = path.dirname(p);
|
|
466
|
-
try {
|
|
467
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
468
|
-
} catch (err) {
|
|
469
|
-
// The state dir (e.g. ~/.hq) can't be created — surface it as an actionable
|
|
470
|
-
// unwritable-lock error rather than a raw mkdir crash.
|
|
471
|
-
rethrowLockCreateError(err, dir);
|
|
472
|
-
}
|
|
473
|
-
const info: LockInfo = {
|
|
474
|
-
pid: process.pid,
|
|
475
|
-
command,
|
|
476
|
-
startedAt: new Date().toISOString(),
|
|
477
|
-
hqRoot: canonicalRoot(hqRoot),
|
|
478
|
-
};
|
|
479
|
-
return { p, info, payload: JSON.stringify(info, null, 2) };
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function writeLockTemp(dir: string, payload: string): string {
|
|
483
|
-
const tmp = path.join(
|
|
484
|
-
dir,
|
|
485
|
-
`.operation-lock.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
486
|
-
);
|
|
487
|
-
let fd: number;
|
|
488
|
-
try {
|
|
489
|
-
fd = fs.openSync(tmp, "wx", 0o600);
|
|
490
|
-
} catch (err) {
|
|
491
|
-
// The locks dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
|
|
492
|
-
// root-owned or restricted ~/.hq) — surface an actionable error, not a raw
|
|
493
|
-
// `EPERM: operation not permitted, open` crash (HQ-CLI-2).
|
|
494
|
-
rethrowLockCreateError(err, dir);
|
|
495
|
-
}
|
|
496
|
-
let closed = false;
|
|
497
|
-
try {
|
|
498
|
-
fs.writeSync(fd, payload);
|
|
499
|
-
fs.fsyncSync(fd);
|
|
500
|
-
fs.closeSync(fd);
|
|
501
|
-
closed = true;
|
|
502
|
-
return tmp;
|
|
503
|
-
} catch (err) {
|
|
504
|
-
if (!closed) {
|
|
505
|
-
try {
|
|
506
|
-
fs.closeSync(fd);
|
|
507
|
-
} catch {
|
|
508
|
-
/* best-effort cleanup */
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
try {
|
|
512
|
-
fs.rmSync(tmp, { force: true });
|
|
513
|
-
} catch {
|
|
514
|
-
/* best-effort cleanup */
|
|
515
|
-
}
|
|
516
|
-
throw err;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function publishLockPayload(p: string, payload: string): boolean {
|
|
521
|
-
const dir = path.dirname(p);
|
|
522
|
-
const tmp = writeLockTemp(dir, payload);
|
|
523
|
-
try {
|
|
524
|
-
// `link` is the no-overwrite atomic publish primitive available in Node:
|
|
525
|
-
// it fails with EEXIST if the final lock name is already present, and the
|
|
526
|
-
// final name never exists until the fully-written payload is linked there.
|
|
527
|
-
fs.linkSync(tmp, p);
|
|
528
|
-
return true;
|
|
529
|
-
} catch (err) {
|
|
530
|
-
if ((err as NodeJS.ErrnoException)?.code === "EEXIST") return false;
|
|
531
|
-
throw err;
|
|
532
|
-
} finally {
|
|
533
|
-
try {
|
|
534
|
-
fs.rmSync(tmp, { force: true });
|
|
535
|
-
} catch {
|
|
536
|
-
/* best-effort cleanup */
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* One acquisition pass. Returns the {@link LockHandle} on success, or
|
|
543
|
-
* `{ busy }` naming the LIVE holder that blocked us (so the caller can decide
|
|
544
|
-
* to wait or refuse). A valid stale lock is reclaimed in-pass and never
|
|
545
|
-
* reported as busy; empty/torn locks are treated as initializing and left in
|
|
546
|
-
* place. Throws only on genuinely pathological churn or unexpected fs errors.
|
|
547
|
-
*/
|
|
548
|
-
function tryAcquireOnce(
|
|
549
|
-
p: string,
|
|
550
|
-
info: LockInfo,
|
|
551
|
-
payload: string,
|
|
552
|
-
): { handle: LockHandle } | { busy: LockInfo } {
|
|
553
|
-
// Bounded retry: each iteration is one atomic create attempt. EEXIST against
|
|
554
|
-
// a stale holder reclaims and retries; EEXIST against a live holder reports
|
|
555
|
-
// it as busy.
|
|
556
|
-
const MAX_ATTEMPTS = 5;
|
|
557
|
-
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
558
|
-
if (publishLockPayload(p, payload)) {
|
|
559
|
-
return { handle: makeHandle(p, info) };
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const holder = readLockInfo(p);
|
|
563
|
-
if (holder) {
|
|
564
|
-
if (pidAlive(holder.pid)) return { busy: holder };
|
|
565
|
-
try {
|
|
566
|
-
fs.unlinkSync(p);
|
|
567
|
-
} catch {
|
|
568
|
-
/* someone else reclaimed it first; the next publish re-evaluates */
|
|
569
|
-
}
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
return { busy: initializingLockInfo(p) };
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Pathological churn (another process reclaiming in lockstep). Surface it
|
|
577
|
-
// rather than spin forever.
|
|
578
|
-
throw new Error(
|
|
579
|
-
`Could not acquire HQ operation lock at ${p} after ${MAX_ATTEMPTS} attempts`,
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/** ms left until `deadline` (null deadline → never expires). */
|
|
584
|
-
function remainingMs(deadline: number | null): number {
|
|
585
|
-
return deadline === null ? Infinity : deadline - Date.now();
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Acquire the per-root operation lock for `command` (synchronous). Returns a
|
|
590
|
-
* {@link LockHandle} on success. Against a LIVE holder it WAITS by default
|
|
591
|
-
* (polling, blocking the thread) and acquires the moment the holder releases;
|
|
592
|
-
* pass `timeoutSec`/`wait` (or set `HQ_OP_LOCK_TIMEOUT`) to bound or disable the
|
|
593
|
-
* wait — on expiry it throws {@link OperationLockedError}. A stale lock (dead
|
|
594
|
-
* holder) is reclaimed immediately, never waited on.
|
|
595
|
-
*/
|
|
596
|
-
export function acquireOperationLock(
|
|
597
|
-
hqRoot: string,
|
|
598
|
-
command: string,
|
|
599
|
-
opts: AcquireOptions = {},
|
|
600
|
-
): LockHandle {
|
|
601
|
-
if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
|
|
602
|
-
|
|
603
|
-
const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
|
|
604
|
-
const cfg = resolveWaitConfig(opts);
|
|
605
|
-
const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
|
|
606
|
-
let announced = false;
|
|
607
|
-
|
|
608
|
-
for (;;) {
|
|
609
|
-
const res = tryAcquireOnce(p, info, payload);
|
|
610
|
-
if ("handle" in res) return res.handle;
|
|
611
|
-
|
|
612
|
-
// A live holder blocked us. Decide: refuse now, or wait and retry.
|
|
613
|
-
if (remainingMs(deadline) <= 0) throw new OperationLockedError(res.busy, command);
|
|
614
|
-
if (!announced) {
|
|
615
|
-
announced = true;
|
|
616
|
-
cfg.onWaitStart(res.busy, command);
|
|
617
|
-
}
|
|
618
|
-
sleepSync(Math.min(cfg.pollMs, remainingMs(deadline)));
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* Async counterpart to {@link acquireOperationLock}. Identical semantics, but
|
|
624
|
-
* the wait yields the event loop (via `setTimeout`) instead of blocking the
|
|
625
|
-
* thread — required for the async `sync` runner.
|
|
626
|
-
*/
|
|
627
|
-
export async function acquireOperationLockAsync(
|
|
628
|
-
hqRoot: string,
|
|
629
|
-
command: string,
|
|
630
|
-
opts: AcquireOptions = {},
|
|
631
|
-
): Promise<LockHandle> {
|
|
632
|
-
if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
|
|
633
|
-
|
|
634
|
-
const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
|
|
635
|
-
const cfg = resolveWaitConfig(opts);
|
|
636
|
-
const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
|
|
637
|
-
let announced = false;
|
|
638
|
-
|
|
639
|
-
for (;;) {
|
|
640
|
-
const res = tryAcquireOnce(p, info, payload);
|
|
641
|
-
if ("handle" in res) return res.handle;
|
|
642
|
-
|
|
643
|
-
if (remainingMs(deadline) <= 0) throw new OperationLockedError(res.busy, command);
|
|
644
|
-
if (!announced) {
|
|
645
|
-
announced = true;
|
|
646
|
-
cfg.onWaitStart(res.busy, command);
|
|
647
|
-
}
|
|
648
|
-
await sleepAsync(Math.min(cfg.pollMs, remainingMs(deadline)));
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/** Run `fn` while holding the per-root lock for `command` (async). */
|
|
653
|
-
export async function withOperationLock<T>(
|
|
654
|
-
hqRoot: string,
|
|
655
|
-
command: string,
|
|
656
|
-
fn: () => Promise<T>,
|
|
657
|
-
opts: AcquireOptions = {},
|
|
658
|
-
): Promise<T> {
|
|
659
|
-
const handle = await acquireOperationLockAsync(hqRoot, command, opts);
|
|
660
|
-
try {
|
|
661
|
-
return await fn();
|
|
662
|
-
} finally {
|
|
663
|
-
handle.release();
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/** Run `fn` while holding the per-root lock for `command` (synchronous). */
|
|
668
|
-
export function withOperationLockSync<T>(
|
|
669
|
-
hqRoot: string,
|
|
670
|
-
command: string,
|
|
671
|
-
fn: () => T,
|
|
672
|
-
opts: AcquireOptions = {},
|
|
673
|
-
): T {
|
|
674
|
-
const handle = acquireOperationLock(hqRoot, command, opts);
|
|
675
|
-
try {
|
|
676
|
-
return fn();
|
|
677
|
-
} finally {
|
|
678
|
-
handle.release();
|
|
679
|
-
}
|
|
680
|
-
}
|