Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/util/singleton-lock.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 13 additions & 1 deletion src/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -45,6 +47,16 @@ export type WatchOptions = {

export async function runWatch(opts: WatchOptions): Promise<void> {
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();
Expand Down
62 changes: 62 additions & 0 deletions tests/singleton-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading