@indigoai-us/hq-cloud 6.2.6 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +8 -7
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +51 -9
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +74 -2
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts +8 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +222 -198
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +35 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +39 -16
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +15 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +3 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +2 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/operation-lock.d.ts +100 -0
- package/dist/operation-lock.d.ts.map +1 -0
- package/dist/operation-lock.js +256 -0
- package/dist/operation-lock.js.map +1 -0
- package/dist/operation-lock.test.d.ts +5 -0
- package/dist/operation-lock.test.d.ts.map +1 -0
- package/dist/operation-lock.test.js +140 -0
- package/dist/operation-lock.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +91 -2
- package/src/bin/sync-runner.ts +52 -9
- package/src/cli/reindex.test.ts +45 -0
- package/src/cli/reindex.ts +36 -0
- package/src/cli/rescue.reindex.test.ts +17 -2
- package/src/cli/rescue.ts +40 -15
- package/src/cli/sync.test.ts +2 -1
- package/src/cli/sync.ts +3 -1
- package/src/operation-lock.test.ts +162 -0
- package/src/operation-lock.ts +293 -0
package/src/cli/reindex.test.ts
CHANGED
|
@@ -13,16 +13,24 @@ import * as fs from "fs";
|
|
|
13
13
|
import * as path from "path";
|
|
14
14
|
import * as os from "os";
|
|
15
15
|
import { reindex } from "./reindex.js";
|
|
16
|
+
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
16
17
|
|
|
17
18
|
describe("reindex", () => {
|
|
18
19
|
let root: string;
|
|
20
|
+
let stateDir: string;
|
|
19
21
|
|
|
20
22
|
beforeEach(() => {
|
|
21
23
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "ms-test-"));
|
|
24
|
+
// Redirect the operation lock into a throwaway dir so reindex's default
|
|
25
|
+
// locking never touches the real ~/.hq during tests.
|
|
26
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-state-"));
|
|
27
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
22
28
|
});
|
|
23
29
|
|
|
24
30
|
afterEach(() => {
|
|
25
31
|
fs.rmSync(root, { recursive: true, force: true });
|
|
32
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
33
|
+
delete process.env.HQ_STATE_DIR;
|
|
26
34
|
});
|
|
27
35
|
|
|
28
36
|
function writeSkill(rel: string): void {
|
|
@@ -124,4 +132,41 @@ describe("reindex", () => {
|
|
|
124
132
|
expect(fs.existsSync(wrapper)).toBe(true);
|
|
125
133
|
expect(fs.lstatSync(path.join(wrapper, "SKILL.md")).isSymbolicLink()).toBe(true);
|
|
126
134
|
});
|
|
135
|
+
|
|
136
|
+
// ── operation lock (mutual exclusion with sync/rescue) ──────────────────
|
|
137
|
+
|
|
138
|
+
it("refuses (OPERATION_LOCKED_EXIT) when another op holds this root's lock", () => {
|
|
139
|
+
// A live holder in another process (pid 1) → reindex must refuse fast and
|
|
140
|
+
// do no work.
|
|
141
|
+
const lp = lockPathFor(root);
|
|
142
|
+
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
143
|
+
fs.writeFileSync(
|
|
144
|
+
lp,
|
|
145
|
+
JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
146
|
+
);
|
|
147
|
+
const before = fs.existsSync(path.join(root, ".claude/skills"));
|
|
148
|
+
const { status } = reindex({ repoRoot: root });
|
|
149
|
+
expect(status).toBe(OPERATION_LOCKED_EXIT);
|
|
150
|
+
// It refused before doing any work (didn't create .claude/skills).
|
|
151
|
+
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("skipLock bypasses the lock (internal sync/rescue caller path)", () => {
|
|
155
|
+
const lp = lockPathFor(root);
|
|
156
|
+
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
157
|
+
fs.writeFileSync(
|
|
158
|
+
lp,
|
|
159
|
+
JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
160
|
+
);
|
|
161
|
+
// Even with a live holder on record, the internal caller (which already
|
|
162
|
+
// holds the lock) runs to completion.
|
|
163
|
+
const { status } = reindex({ repoRoot: root, skipLock: true });
|
|
164
|
+
expect(status).toBe(0);
|
|
165
|
+
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("releases the lock after a normal run (no leftover lock file)", () => {
|
|
169
|
+
reindex({ repoRoot: root });
|
|
170
|
+
expect(fs.existsSync(lockPathFor(root))).toBe(false);
|
|
171
|
+
});
|
|
127
172
|
});
|
package/src/cli/reindex.ts
CHANGED
|
@@ -17,10 +17,24 @@
|
|
|
17
17
|
import { spawnSync } from "child_process";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
|
+
import {
|
|
21
|
+
acquireOperationLock,
|
|
22
|
+
OperationLockedError,
|
|
23
|
+
OPERATION_LOCKED_EXIT,
|
|
24
|
+
type LockHandle,
|
|
25
|
+
} from "../operation-lock.js";
|
|
20
26
|
|
|
21
27
|
export interface ReindexOptions {
|
|
22
28
|
/** HQ root to operate on. Defaults to process.cwd(). */
|
|
23
29
|
repoRoot?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Skip the per-root operation lock. Internal callers (`sync()` / `rescue()`)
|
|
32
|
+
* already hold the lock for this root and pass `true` so reindex doesn't try
|
|
33
|
+
* to re-acquire it and refuse against their own live PID. Standalone callers
|
|
34
|
+
* (`hq reindex`, the reindex hook) leave it falsy so reindex is mutually
|
|
35
|
+
* exclusive with a running sync/rescue.
|
|
36
|
+
*/
|
|
37
|
+
skipLock?: boolean;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
export interface ReindexResult {
|
|
@@ -129,6 +143,25 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
129
143
|
}
|
|
130
144
|
const root = path.resolve(rawRoot);
|
|
131
145
|
|
|
146
|
+
// Acquire the per-root operation lock unless an internal caller (sync/rescue,
|
|
147
|
+
// which already hold it) opted out. A live holder → refuse fast with the
|
|
148
|
+
// holder's command + PID. The whole body runs inside the try so the lock is
|
|
149
|
+
// released on every exit path (the process-level signal/exit hooks are the
|
|
150
|
+
// crash backstop).
|
|
151
|
+
let opLock: LockHandle | null = null;
|
|
152
|
+
if (!opts.skipLock) {
|
|
153
|
+
try {
|
|
154
|
+
opLock = acquireOperationLock(root, "reindex");
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err instanceof OperationLockedError) {
|
|
157
|
+
warn(err.message);
|
|
158
|
+
return { status: OPERATION_LOCKED_EXIT };
|
|
159
|
+
}
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
|
|
132
165
|
fs.mkdirSync(path.join(root, ".claude", "skills"), { recursive: true });
|
|
133
166
|
|
|
134
167
|
// --- Build (namespace, src_rel) pairs -------------------------------------
|
|
@@ -363,4 +396,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
363
396
|
}
|
|
364
397
|
|
|
365
398
|
return { status: 0 };
|
|
399
|
+
} finally {
|
|
400
|
+
opLock?.release();
|
|
401
|
+
}
|
|
366
402
|
}
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* runs, and ./reindex.js is mocked to a spy so we assert the call without
|
|
8
8
|
* touching disk.
|
|
9
9
|
*/
|
|
10
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import * as path from "path";
|
|
11
14
|
|
|
12
15
|
vi.mock("./rescue-core.js", () => ({
|
|
13
16
|
runRescue: vi.fn(() => ({ status: 0 })),
|
|
@@ -21,16 +24,28 @@ import { reindex } from "./reindex.js";
|
|
|
21
24
|
import { rescue } from "./rescue.js";
|
|
22
25
|
|
|
23
26
|
describe("rescue → reindex", () => {
|
|
27
|
+
let stateDir: string;
|
|
28
|
+
|
|
24
29
|
beforeEach(() => {
|
|
25
30
|
vi.clearAllMocks();
|
|
26
31
|
(runRescue as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0 });
|
|
32
|
+
// rescue() now takes the per-root operation lock; redirect it to a tmp dir.
|
|
33
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "rescue-reindex-state-"));
|
|
34
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
39
|
+
delete process.env.HQ_STATE_DIR;
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
it("refreshes via reindex after a successful rescue", () => {
|
|
30
43
|
const r = rescue({ hqRoot: "/tmp/hq", assumeYes: true });
|
|
31
44
|
expect(r.status).toBe(0);
|
|
32
45
|
expect(reindex).toHaveBeenCalledTimes(1);
|
|
33
|
-
|
|
46
|
+
// skipLock: rescue already holds the per-root operation lock, so the
|
|
47
|
+
// internal reindex must not try to re-acquire it.
|
|
48
|
+
expect(reindex).toHaveBeenCalledWith({ repoRoot: "/tmp/hq", skipLock: true });
|
|
34
49
|
});
|
|
35
50
|
|
|
36
51
|
it("does NOT run reindex on a dry-run", () => {
|
package/src/cli/rescue.ts
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { reindex } from "./reindex.js";
|
|
16
16
|
import { runRescue } from "./rescue-core.js";
|
|
17
|
+
import {
|
|
18
|
+
acquireOperationLock,
|
|
19
|
+
OperationLockedError,
|
|
20
|
+
OPERATION_LOCKED_EXIT,
|
|
21
|
+
} from "../operation-lock.js";
|
|
17
22
|
|
|
18
23
|
export interface RescueOptions {
|
|
19
24
|
/** HQ root to operate on. Passed as `--hq-root`. Defaults to the script's
|
|
@@ -91,21 +96,41 @@ export function buildRescueArgs(opts: RescueOptions = {}): string[] {
|
|
|
91
96
|
* confirmation prompt (unless `assumeYes` is set).
|
|
92
97
|
*/
|
|
93
98
|
export function rescue(opts: RescueOptions = {}): RescueResult {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
reindex({ repoRoot: opts.hqRoot });
|
|
106
|
-
} catch {
|
|
107
|
-
// best-effort
|
|
99
|
+
// Rescue is mutually exclusive with sync/reindex on this HQ root. Key the
|
|
100
|
+
// lock on the same root the rescue script resolves (cwd when --hq-root is
|
|
101
|
+
// omitted). Rescue is never the exempt push watcher, so it always locks.
|
|
102
|
+
const lockRoot = opts.hqRoot ?? process.cwd();
|
|
103
|
+
let handle;
|
|
104
|
+
try {
|
|
105
|
+
handle = acquireOperationLock(lockRoot, "rescue");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof OperationLockedError) {
|
|
108
|
+
process.stderr.write(err.message + "\n");
|
|
109
|
+
return { status: OPERATION_LOCKED_EXIT };
|
|
108
110
|
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const args = buildRescueArgs(opts);
|
|
115
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
116
|
+
if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
|
|
117
|
+
const { status } = runRescue(args, { env });
|
|
118
|
+
// A successful, non-dry-run rescue re-lays-down core/, so refresh the
|
|
119
|
+
// generated skill wrappers, personal-overlay mirrors, and workers registry.
|
|
120
|
+
// Best-effort + idempotent — never overrides the rescue's own exit status.
|
|
121
|
+
// repoRoot falls back to process.cwd() (reindex's default) when hqRoot is
|
|
122
|
+
// omitted, matching the rescue script's own cwd-based default. `skipLock`
|
|
123
|
+
// because we already hold the per-root lock — reindex's own acquire would
|
|
124
|
+
// otherwise see our live PID and refuse.
|
|
125
|
+
if (status === 0 && !opts.dryRun) {
|
|
126
|
+
try {
|
|
127
|
+
reindex({ repoRoot: opts.hqRoot, skipLock: true });
|
|
128
|
+
} catch {
|
|
129
|
+
// best-effort
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { status };
|
|
133
|
+
} finally {
|
|
134
|
+
handle.release();
|
|
109
135
|
}
|
|
110
|
-
return { status };
|
|
111
136
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -123,7 +123,8 @@ describe("sync", () => {
|
|
|
123
123
|
|
|
124
124
|
it("runs reindex against hqRoot after a sync that downloaded files", async () => {
|
|
125
125
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
126
|
-
|
|
126
|
+
// skipLock: the surrounding sync run already holds the per-root lock.
|
|
127
|
+
expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
|
|
127
128
|
});
|
|
128
129
|
|
|
129
130
|
it("skips reindex when skipReindex is set", async () => {
|
package/src/cli/sync.ts
CHANGED
|
@@ -1338,7 +1338,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1338
1338
|
shrinkResult.cleanRemoved > 0;
|
|
1339
1339
|
if (!options.skipReindex && changedOnDisk) {
|
|
1340
1340
|
try {
|
|
1341
|
-
|
|
1341
|
+
// skipLock: the surrounding sync run already holds this root's operation
|
|
1342
|
+
// lock; reindex re-acquiring would refuse against our own live PID.
|
|
1343
|
+
reindex({ repoRoot: hqRoot, skipLock: true });
|
|
1342
1344
|
} catch {
|
|
1343
1345
|
// best-effort: a post-sync refresh failure never fails the sync
|
|
1344
1346
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the per-HQ-root operation mutex.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import {
|
|
11
|
+
acquireOperationLock,
|
|
12
|
+
withOperationLockSync,
|
|
13
|
+
lockPathFor,
|
|
14
|
+
OperationLockedError,
|
|
15
|
+
OPERATION_LOCKED_EXIT,
|
|
16
|
+
type LockInfo,
|
|
17
|
+
} from "./operation-lock.js";
|
|
18
|
+
|
|
19
|
+
/** A PID that is guaranteed dead: spawn a node that exits immediately, reuse its pid. */
|
|
20
|
+
function deadPid(): number {
|
|
21
|
+
const r = spawnSync(process.execPath, ["-e", ""], { stdio: "ignore" });
|
|
22
|
+
if (!r.pid) throw new Error("could not spawn to obtain a dead pid");
|
|
23
|
+
return r.pid;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeLock(p: string, info: Partial<LockInfo>): void {
|
|
27
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
28
|
+
const full: LockInfo = {
|
|
29
|
+
pid: 1,
|
|
30
|
+
command: "sync",
|
|
31
|
+
startedAt: new Date(0).toISOString(),
|
|
32
|
+
hqRoot: "/x",
|
|
33
|
+
...info,
|
|
34
|
+
};
|
|
35
|
+
fs.writeFileSync(p, JSON.stringify(full));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("operation-lock", () => {
|
|
39
|
+
let stateDir: string;
|
|
40
|
+
let rootA: string;
|
|
41
|
+
let rootB: string;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-oplock-state-"));
|
|
45
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
46
|
+
delete process.env.HQ_DISABLE_OP_LOCK;
|
|
47
|
+
rootA = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootA-"));
|
|
48
|
+
rootB = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootB-"));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
53
|
+
fs.rmSync(rootA, { recursive: true, force: true });
|
|
54
|
+
fs.rmSync(rootB, { recursive: true, force: true });
|
|
55
|
+
delete process.env.HQ_STATE_DIR;
|
|
56
|
+
delete process.env.HQ_DISABLE_OP_LOCK;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("the lock path is under the state dir, keyed per canonical root", () => {
|
|
60
|
+
const a = lockPathFor(rootA);
|
|
61
|
+
const b = lockPathFor(rootB);
|
|
62
|
+
expect(a.startsWith(path.join(stateDir, "locks"))).toBe(true);
|
|
63
|
+
expect(a).not.toBe(b); // different roots → different lock files
|
|
64
|
+
// canonical: a trailing-slash variant maps to the same lock
|
|
65
|
+
expect(lockPathFor(rootA + path.sep)).toBe(a);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("acquires, writes holder info, and releases (file gone after release)", () => {
|
|
69
|
+
const h = acquireOperationLock(rootA, "sync");
|
|
70
|
+
expect(fs.existsSync(h.path)).toBe(true);
|
|
71
|
+
const info = JSON.parse(fs.readFileSync(h.path, "utf8")) as LockInfo;
|
|
72
|
+
expect(info.pid).toBe(process.pid);
|
|
73
|
+
expect(info.command).toBe("sync");
|
|
74
|
+
h.release();
|
|
75
|
+
expect(fs.existsSync(h.path)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("refuses fast with the holder's command + pid when a LIVE process holds it", () => {
|
|
79
|
+
// Simulate a DIFFERENT live process holding the lock. PID 1 (init/systemd)
|
|
80
|
+
// is always alive and is never our own pid, so kill(1,0) reports alive and
|
|
81
|
+
// the same-process reclaim path does not apply.
|
|
82
|
+
writeLock(lockPathFor(rootA), { pid: 1, command: "rescue" });
|
|
83
|
+
expect(() => acquireOperationLock(rootA, "sync")).toThrowError(OperationLockedError);
|
|
84
|
+
try {
|
|
85
|
+
acquireOperationLock(rootA, "sync");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const err = e as OperationLockedError;
|
|
88
|
+
expect(err.holder.command).toBe("rescue");
|
|
89
|
+
expect(err.holder.pid).toBe(1);
|
|
90
|
+
expect(err.message).toContain("rescue");
|
|
91
|
+
expect(err.message).toContain("pid 1");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reclaims a stale lock whose holder PID is dead (takeover)", () => {
|
|
96
|
+
const stale = deadPid();
|
|
97
|
+
writeLock(lockPathFor(rootA), { pid: stale, command: "sync" });
|
|
98
|
+
// The dead holder must not block us.
|
|
99
|
+
const h = acquireOperationLock(rootA, "rescue");
|
|
100
|
+
const info = JSON.parse(fs.readFileSync(h.path, "utf8")) as LockInfo;
|
|
101
|
+
expect(info.pid).toBe(process.pid); // we took it over
|
|
102
|
+
expect(info.command).toBe("rescue");
|
|
103
|
+
h.release();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reclaims a torn/unreadable lock file", () => {
|
|
107
|
+
const p = lockPathFor(rootA);
|
|
108
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
109
|
+
fs.writeFileSync(p, "{ this is not valid json");
|
|
110
|
+
const h = acquireOperationLock(rootA, "reindex");
|
|
111
|
+
expect(fs.existsSync(h.path)).toBe(true);
|
|
112
|
+
h.release();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("different HQ roots are independent — both may hold concurrently", () => {
|
|
116
|
+
const a = acquireOperationLock(rootA, "sync");
|
|
117
|
+
const b = acquireOperationLock(rootB, "rescue"); // must NOT refuse
|
|
118
|
+
expect(fs.existsSync(a.path)).toBe(true);
|
|
119
|
+
expect(fs.existsSync(b.path)).toBe(true);
|
|
120
|
+
expect(a.path).not.toBe(b.path);
|
|
121
|
+
a.release();
|
|
122
|
+
b.release();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("the same root is mutually exclusive across different commands", () => {
|
|
126
|
+
// A live sync in ANOTHER process holds the root (pid 1 stands in for it).
|
|
127
|
+
const p = lockPathFor(rootA);
|
|
128
|
+
writeLock(p, { pid: 1, command: "sync" });
|
|
129
|
+
// Neither rescue nor reindex may acquire while that sync holds it.
|
|
130
|
+
expect(() => acquireOperationLock(rootA, "rescue")).toThrowError(OperationLockedError);
|
|
131
|
+
expect(() => acquireOperationLock(rootA, "reindex")).toThrowError(OperationLockedError);
|
|
132
|
+
// Once that sync finishes (its lock is gone), the next command acquires.
|
|
133
|
+
fs.unlinkSync(p);
|
|
134
|
+
const h2 = acquireOperationLock(rootA, "reindex");
|
|
135
|
+
expect(fs.existsSync(h2.path)).toBe(true);
|
|
136
|
+
h2.release();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("withOperationLockSync releases even when the body throws", () => {
|
|
140
|
+
const p = lockPathFor(rootA);
|
|
141
|
+
expect(() =>
|
|
142
|
+
withOperationLockSync(rootA, "reindex", () => {
|
|
143
|
+
expect(fs.existsSync(p)).toBe(true); // held during the body
|
|
144
|
+
throw new Error("boom");
|
|
145
|
+
}),
|
|
146
|
+
).toThrow("boom");
|
|
147
|
+
expect(fs.existsSync(p)).toBe(false); // released on the way out
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("HQ_DISABLE_OP_LOCK=1 makes acquisition a no-op", () => {
|
|
151
|
+
process.env.HQ_DISABLE_OP_LOCK = "1";
|
|
152
|
+
// Even with a live holder on record, the escape hatch acquires without error.
|
|
153
|
+
writeLock(lockPathFor(rootA), { pid: process.pid, command: "sync" });
|
|
154
|
+
const h = acquireOperationLock(rootA, "rescue");
|
|
155
|
+
expect(h.path).toBe(""); // no real lock file written
|
|
156
|
+
h.release(); // no-op, no throw
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("OPERATION_LOCKED_EXIT is a stable non-zero code", () => {
|
|
160
|
+
expect(OPERATION_LOCKED_EXIT).toBe(17);
|
|
161
|
+
});
|
|
162
|
+
});
|