import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard"; import { OperationInProgressError, acquireOperationLock, assertNoOperationInProgress, describeOperationInProgress, isProcessAliveByDefault, parseLockRecord, readLockHolder, renderLockRecord, resetHeldLocksForTests, withOperationLock } from "../src/lib/operationLock"; /** * Взаимное исключение операций жизненного цикла. * * У проекта не было ни flock, ни mutex, ни какого-либо замка вообще, а * install-state.json им не является: это запись о состоянии, а не право на * изменение. Два одновременных reconfigure доходили до конца каждый по-своему, * и уникальные op-id тут не спасали — они разделяют только резервные копии, * тогда как /etc/hysteria/config.yaml, unit-файлы, /etc/nftables.conf и * install-state.json общие. Дальше любая из операций могла упасть и * «восстановить» состояние поверх изменений другой, отчитавшись полным успехом. * * Проверяемое здесь свойство — отказ происходит ДО первой мутации, а не в * середине транзакции. */ let dir = ""; let lockFile = ""; const DEAD_PID = 424242; const LIVE_PID = 4242; /** Живость подменяется: тест не имеет права зависеть от реальных PID системы. */ function liveness(alivePids: readonly number[]) { return (pid: number) => alivePids.includes(pid); } beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "hy2xs-lock-")); lockFile = join(dir, "hy2xs-orchestrator.lock"); resetHeldLocksForTests(); }); afterEach(async () => { disableReadOnlyGuard(); resetHeldLocksForTests(); await rm(dir, { recursive: true, force: true }); }); describe("запись замка", () => { test("разбор согласован с записью", () => { const record = { pid: 17, command: "reconfigure", startedAt: "2026-08-30T10:00:00.000Z", nonce: "n1" }; expect(parseLockRecord(renderLockRecord(record))).toEqual(record); }); // «Файл не разобрался» и «замка нет» — разные ответы. Трактовать первое как // второе означало бы пустить вторую операцию по непонятному файлу. test("непонятое содержимое не превращается в отсутствие замка", () => { expect(parseLockRecord("не json")).toBeNull(); expect(parseLockRecord("[]")).toBeNull(); expect(parseLockRecord('{"pid":0,"command":"install","nonce":"n"}')).toBeNull(); expect(parseLockRecord('{"pid":7,"command":"","nonce":"n"}')).toBeNull(); expect(parseLockRecord('{"pid":7,"command":"install"}')).toBeNull(); }); }); describe("захват и освобождение", () => { test("замок создаётся с описанием операции", async () => { const lock = await acquireOperationLock("install", { path: lockFile, pid: LIVE_PID }); const record = parseLockRecord(readFileSync(lockFile, "utf8")); expect(record?.pid).toBe(LIVE_PID); expect(record?.command).toBe("install"); expect(record?.nonce).toBe(lock.nonce); await lock.release(); expect(existsSync(lockFile)).toBe(false); }); test("второй захват при живом держателе отказывает", async () => { const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) }; const first = await acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID }); await expect( acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID + 1 }) ).rejects.toThrow(OperationInProgressError); await first.release(); }); test("отказ называет держателя, чтобы оператор понял, чего ждать", async () => { const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) }; const first = await acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID }); try { await acquireOperationLock("doctor", { ...options, pid: LIVE_PID + 1 }); throw new Error("захват обязан был отказать"); } catch (error) { expect(error).toBeInstanceOf(OperationInProgressError); expect((error as Error).message).toContain("another HY2XS operation is already in progress"); expect((error as Error).message).toContain("reconfigure"); expect((error as Error).message).toContain(String(LIVE_PID)); } await first.release(); }); test("после освобождения замок берётся снова", async () => { const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) }; const first = await acquireOperationLock("install", { ...options, pid: LIVE_PID }); await first.release(); const second = await acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID }); expect(parseLockRecord(readFileSync(lockFile, "utf8"))?.command).toBe("reconfigure"); await second.release(); }); // Замок обязан сниматься и на отказе операции: иначе первая же неудачная // установка заблокировала бы сервер до перезагрузки. test("withOperationLock снимает замок и после отказа", async () => { const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]), pid: LIVE_PID }; await expect( withOperationLock("install", async () => { expect(existsSync(lockFile)).toBe(true); throw new Error("операция упала"); }, options) ).rejects.toThrow("операция упала"); expect(existsSync(lockFile)).toBe(false); }); }); describe("замок мёртвого держателя", () => { test("переиспользуется, а не блокирует сервер навсегда", async () => { writeFileSync( lockFile, renderLockRecord({ pid: DEAD_PID, command: "install", startedAt: "", nonce: "stale" }) ); const lock = await acquireOperationLock("repair", { path: lockFile, pid: LIVE_PID, isProcessAlive: liveness([LIVE_PID]) }); expect(parseLockRecord(readFileSync(lockFile, "utf8"))?.command).toBe("repair"); await lock.release(); }); test("временный файл переиспользования не остаётся на диске", async () => { writeFileSync( lockFile, renderLockRecord({ pid: DEAD_PID, command: "install", startedAt: "", nonce: "stale" }) ); const lock = await acquireOperationLock("repair", { path: lockFile, pid: LIVE_PID, isProcessAlive: liveness([LIVE_PID]) }); await lock.release(); expect((await readdir(dir)).filter((name) => name.includes(".stale-"))).toEqual([]); }); // Непонятый файл не является доказательством отсутствия операции, поэтому // автоматически он не снимается: сомнение трактуется в пользу отказа. test("непонятый замок не переиспользуется автоматически", async () => { writeFileSync(lockFile, "мусор, а не замок\n"); await expect( acquireOperationLock("install", { path: lockFile, pid: LIVE_PID, isProcessAlive: liveness([LIVE_PID]) }) ).rejects.toThrow(/is not a valid HY2XS lock record/); expect(existsSync(lockFile)).toBe(true); }); }); describe("наблюдение за замком", () => { test("readLockHolder отличает отсутствие замка от нечитаемого", async () => { expect(await readLockHolder({ path: lockFile })).toBeNull(); writeFileSync(lockFile, "мусор\n"); const holder = await readLockHolder({ path: lockFile }); expect(holder?.command).toBe("unknown"); expect(holder?.alive).toBe(false); }); test("мёртвый держатель не считается идущей операцией", async () => { writeFileSync( lockFile, renderLockRecord({ pid: DEAD_PID, command: "install", startedAt: "", nonce: "stale" }) ); const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) }; expect(await describeOperationInProgress(options)).toBeNull(); await assertNoOperationInProgress("install preflight", options); }); test("живой держатель останавливает PHASE 0 до первой проверки", async () => { writeFileSync( lockFile, renderLockRecord({ pid: LIVE_PID, command: "reconfigure", startedAt: "", nonce: "live" }) ); const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) }; expect(await describeOperationInProgress(options)).toContain("reconfigure"); await expect(assertNoOperationInProgress("install preflight", options)).rejects.toThrow( OperationInProgressError ); }); test("собственный процесс опознаётся как живой", () => { expect(isProcessAliveByDefault(process.pid)).toBe(true); expect(isProcessAliveByDefault(0)).toBe(false); expect(isProcessAliveByDefault(-1)).toBe(false); }); }); describe("политика замка в CLI", () => { const cliSource = readFileSync(new URL("../src/cli.ts", import.meta.url), "utf8"); test("мутирующие команды выполняются под замком", () => { for (const command of ["install", "reconfigure", "repair"] as const) { expect(cliSource).toContain(`await withOperationLock("${command}", () => ${command}(options))`); } }); /** * doctor не мутирует, но обязан быть исключён против reconfigure: диагностика * в середине транзакции описывает промежуточное состояние и выдаёт * бессмысленные ошибки по временным несоответствиям. */ test("doctor исключён против мутирующих операций", () => { expect(cliSource).toContain('await withOperationLock("doctor", () => doctor(options))'); }); // Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды // целиком, поэтому backupCurrentState и applyFirewall физически недостижимы, // пока замок не взят. test("команда не вызывается мимо замка", () => { for (const call of ["await install(", "await reconfigure(", "await repair(", "await doctor("]) { expect(cliSource).not.toContain(call); } }); test("PHASE 0 отказывает сразу, а не после exec", () => { const assertAt = cliSource.indexOf('await assertNoOperationInProgress("install preflight")'); const preflightAt = cliSource.indexOf("await preflightInstall(options)"); expect(assertAt).toBeGreaterThan(-1); expect(preflightAt).toBeGreaterThan(assertAt); }); // status и diagnostics существуют в том числе для того, чтобы посмотреть на // сервер во время долгой операции: замок они брать не имеют права. test("наблюдающие команды замок не берут, но сообщают об операции", () => { expect(cliSource).not.toContain('withOperationLock("status"'); expect(cliSource).not.toContain('withOperationLock("diagnostics"'); expect(cliSource).toContain("await describeOperationInProgress()"); }); test("status показывает идущую операцию в отчёте", () => { const statusSource = readFileSync(new URL("../src/commands/status.ts", import.meta.url), "utf8"); expect(statusSource).toContain("operation_in_progress: operationInProgress"); }); }); describe("замок и read-only guard", () => { /** * Замок берётся ДО включения guard'а — так устроен cli.ts. Проверка внутри * `acquireOperationLock` существует, чтобы перенос захвата внутрь читающей * фазы отказал громко, а не записал файл молча. */ test("захват под read-only guard отказывает", async () => { enableReadOnlyGuard("тест PHASE 0"); await expect( acquireOperationLock("doctor", { path: lockFile, pid: LIVE_PID }) ).rejects.toThrow(/read-only guard violation/); expect(existsSync(lockFile)).toBe(false); }); test("наблюдение за замком под guard'ом разрешено", async () => { writeFileSync( lockFile, renderLockRecord({ pid: LIVE_PID, command: "install", startedAt: "", nonce: "live" }) ); enableReadOnlyGuard("тест PHASE 0"); expect(await describeOperationInProgress({ path: lockFile, isProcessAlive: liveness([LIVE_PID]) })) .toContain("install"); }); });