From 4d5567f4cf824470096b23309b01d02f80e881e5 Mon Sep 17 00:00:00 2001 From: akihidem Date: Tue, 9 Jun 2026 12:06:19 +0900 Subject: [PATCH] fix(watch): single-instance lock to prevent duplicate daemons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 複数 tmux shell + autostart のレースで cogsync watch が多重起動し deepwork を二重計上していた。runWatch 冒頭に O_EXCL pidfile ロックを追加(--once 診断は対象外)。stale な pidfile は process.kill(pid,0) の死活判定で回収。ロックはシグナルを横取りせず exit クリーンアップのみに徹し、watch 終了時の deepwork 保存を奪わない。テスト5件。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/singleton-lock.ts | 85 ++++++++++++++++++++++++++++++++++++ src/watch.ts | 14 +++++- tests/singleton-lock.test.ts | 62 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/util/singleton-lock.ts create mode 100644 tests/singleton-lock.test.ts diff --git a/src/util/singleton-lock.ts b/src/util/singleton-lock.ts new file mode 100644 index 0000000..8b0c8b8 --- /dev/null +++ b/src/util/singleton-lock.ts @@ -0,0 +1,85 @@ +/** + * util: single-instance lock(O_EXCL pidfile) + * + * cogsync watch のような常駐プロセスを「1 マシン 1 本」に制限する。 + * 起動方法(bashrc / systemd / 手動)に依らず多重起動を防ぐため watch 本体でロックを取る。 + * 外部依存(flock 等)は使わず、O_EXCL での pidfile 作成(アトミック)+死活チェックで + * stale(保持者が死亡したまま残ったロック)を自動回収する。 + */ + +import { openSync, writeSync, closeSync, readFileSync, unlinkSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export type LockHandle = { + readonly path: string; + /** ロックファイルを削除して解放する(多重呼び出し安全)。 */ + release: () => void; +}; + +/** 指定 pid が生存しているか。ESRCH=不在、EPERM=存在(権限なし)。 */ +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); // signal 0 は存在確認のみ(実際にはシグナルを送らない) + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** + * single-instance ロックを取得する。 + * - 取れたら LockHandle を返す(呼び出し側はそのまま処理を続ける)。 + * - 既に生存中の他プロセスが保持していれば null(呼び出し側は終了する想定)。 + * stale な pidfile は回収して取り直す。アトミックな O_EXCL なので競合しても取得は最大 1 本。 + */ +export function acquireSingleInstanceLock(lockPath: string): LockHandle | null { + mkdirSync(dirname(lockPath), { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const fd = openSync(lockPath, "wx"); // wx = O_CREAT|O_EXCL: 既存なら EEXIST で失敗 + writeSync(fd, String(process.pid)); + closeSync(fd); + return { path: lockPath, release: makeReleaser(lockPath) }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + // 既存ロックあり: 保持者の生死を確認する + let holderPid = 0; + try { + holderPid = Number(readFileSync(lockPath, "utf8").trim()); + } catch { + holderPid = 0; // 読めない/壊れている → stale 扱い + } + if (isProcessAlive(holderPid)) return null; // 本当に稼働中 → 取得失敗 + // stale: 回収して次の attempt で取り直す(競合で先に消えていても OK) + try { + unlinkSync(lockPath); + } catch { + /* already gone */ + } + } + } + // 2 回試しても取れない(激しい競合)→ 安全側に倒して取得失敗扱い + return null; +} + +function makeReleaser(lockPath: string): () => void { + let released = false; + const remove = (): void => { + if (released) return; + released = true; + try { + // 自分の pid が書いた場合だけ消す(死亡後に別プロセスが取り直したロックを誤って消さない)。 + const pid = Number(readFileSync(lockPath, "utf8").trim()); + if (pid === process.pid) unlinkSync(lockPath); + } catch { + /* 既に無ければ何もしない */ + } + }; + // 注意: SIGINT/SIGTERM ハンドラはここで登録しない。登録すると呼び出し側の終了処理 + // (例: watch の stop = deepwork 保存)より先に発火し、process.exit() で保存を奪う回帰になる。 + // シグナルは呼び出し側に委ね、本ロックは「プロセス終了時にロックファイルを消す」のみに徹する。 + // exit イベントは正常終了・明示 process.exit の双方で発火する(同期処理のみ可)。 + process.once("exit", remove); + return remove; +} diff --git a/src/watch.ts b/src/watch.ts index feaa624..1831c79 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -31,9 +31,11 @@ import { } from "./infer/work_state.ts"; import { advise, type Advice } from "./coach/advise.ts"; import { createDesktopNotifier, type NotifyRequest } from "./notify/desktop.ts"; -import { JsonStore } from "./state/store.ts"; +import { JsonStore, defaultStatePath } from "./state/store.ts"; import { isPhaseStale } from "./coach/phase.ts"; import type { CogsyncConfig } from "./config.ts"; +import { dirname, join } from "node:path"; +import { acquireSingleInstanceLock } from "./util/singleton-lock.ts"; type FiredKey = string; @@ -45,6 +47,16 @@ export type WatchOptions = { export async function runWatch(opts: WatchOptions): Promise { const { config } = opts; + // 常駐モードは 1 マシン 1 本に制限する(--once 診断は対象外)。起動方法(bashrc / + // systemd / 手動)に依らず、多重起動による deepwork 二重計上・通知重複を防ぐため、 + // watch 本体で O_EXCL pidfile ロックを取る。 + if (!opts.once) { + const lockPath = join(dirname(defaultStatePath()), "watch.lock"); + if (!acquireSingleInstanceLock(lockPath)) { + console.error("[cogsync] 別の cogsync watch が既に稼働中のため終了します(多重起動防止)。"); + return; + } + } const pollingSec = opts.pollingSecOverride ?? config.observers.ccusage.pollingSec; const notifier = createDesktopNotifier(config.notify.tone); const store = new JsonStore(); diff --git a/tests/singleton-lock.test.ts b/tests/singleton-lock.test.ts new file mode 100644 index 0000000..b5db46e --- /dev/null +++ b/tests/singleton-lock.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { mkdtempSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { acquireSingleInstanceLock } from "../src/util/singleton-lock.ts"; + +function tmpLockPath(): string { + const dir = mkdtempSync(join(tmpdir(), "cogsync-lock-")); + return join(dir, "watch.lock"); +} + +test("acquireSingleInstanceLock: 1本目は取得・2本目は null(稼働中の自 pid がロックを保持)", () => { + const p = tmpLockPath(); + const a = acquireSingleInstanceLock(p); + assert.ok(a, "1本目は取得できる"); + assert.equal(readFileSync(p, "utf8").trim(), String(process.pid), "pidfile に自 pid を書く"); + + const b = acquireSingleInstanceLock(p); + assert.equal(b, null, "生存ロックがあれば2本目は取得失敗(null)"); + + a!.release(); + assert.equal(existsSync(p), false, "release でロックファイルが消える"); +}); + +test("acquireSingleInstanceLock: stale pidfile(死亡 pid)は回収して取得できる", () => { + const p = tmpLockPath(); + writeFileSync(p, "2147483646"); // 実在しない巨大 pid = 死亡扱い + const a = acquireSingleInstanceLock(p); + assert.ok(a, "stale ロックは回収して取得できる"); + assert.equal(readFileSync(p, "utf8").trim(), String(process.pid)); + a!.release(); +}); + +test("acquireSingleInstanceLock: 壊れた pidfile も stale 扱いで回収", () => { + const p = tmpLockPath(); + writeFileSync(p, "not-a-pid\n"); + const a = acquireSingleInstanceLock(p); + assert.ok(a, "数値でない pidfile は stale 扱いで回収"); + a!.release(); +}); + +test("acquireSingleInstanceLock: release は冪等(二重呼び出しで投げない)", () => { + const p = tmpLockPath(); + const a = acquireSingleInstanceLock(p); + assert.ok(a); + a!.release(); + assert.doesNotThrow(() => a!.release(), "2回目の release は no-op"); +}); + +test("acquireSingleInstanceLock: SIGINT/SIGTERM リスナーを追加しない(呼び出し側の終了保存を奪わない)", () => { + // 回帰防止: ロックがシグナルを横取りして process.exit すると、呼び出し側(watch)の + // 終了時 deepwork 保存が走らなくなる。ロックは exit クリーンアップのみで完結すべき。 + const p = tmpLockPath(); + const beforeInt = process.listenerCount("SIGINT"); + const beforeTerm = process.listenerCount("SIGTERM"); + const a = acquireSingleInstanceLock(p); + assert.ok(a); + assert.equal(process.listenerCount("SIGINT"), beforeInt, "SIGINT リスナーを足さない"); + assert.equal(process.listenerCount("SIGTERM"), beforeTerm, "SIGTERM リスナーを足さない"); + a!.release(); +});