From 7c2f71315b826a3fd27fb89ef70fe02459635d44 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Tue, 19 May 2026 09:30:07 +0900 Subject: [PATCH] fix(hooks): use unique tmp suffix in writeWarnState (ecc-context-monitor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the previous commit's `writeBridgeAtomic` fix on the companion `writeWarnState` in `ecc-context-monitor.js`. Same shape: fixed `${target}.tmp` → `${target}.${process.pid}.${randomNonce}.tmp`, plus best-effort cleanup of the tmp file on `renameSync` failure (throws original error after cleanup). `writeWarnState` debounces the context-monitor's threshold alarms (`COST_NOTICE_USD`, `COST_WARNING_USD`, `COST_CRITICAL_USD`, plus the context-remaining and loop-detection ones). Without unique suffixes, two PostToolUse subprocesses racing on the warn-state file produce either a corrupted JSON debounce-state on disk or an ENOENT throw that the hook catches and swallows — either way the next warn-state read returns the default `{callsSinceWarn: 0, lastSeverity: null}` and the threshold alarms re-fire or stop firing erratically. Users see warning messages flicker or vanish; debounce no longer works. Three call sites in this repo now share the same atomic-write contract: - `writeBridgeAtomic` (scripts/lib/session-bridge.js) — primary - `writeCostWarningIfChanged` (scripts/hooks/ecc-metrics-bridge.js) — cost cache - `writeWarnState` (this file) — debounce state `yarn lint` clean. Regression test covering both `writeBridgeAtomic` and `writeWarnState` under concurrent load lands in the next commit. --- scripts/hooks/ecc-context-monitor.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/ecc-context-monitor.js b/scripts/hooks/ecc-context-monitor.js index 8ddb36dc..5058f9bf 100644 --- a/scripts/hooks/ecc-context-monitor.js +++ b/scripts/hooks/ecc-context-monitor.js @@ -9,6 +9,7 @@ 'use strict'; +const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -61,15 +62,30 @@ function readWarnState(sessionId) { } /** - * Write debounce state. + * Write debounce state atomically (unique-suffix tmp then rename). + * + * The tmp path includes `process.pid` plus a random nonce so concurrent + * PostToolUse subprocesses writing to the same session's warn-state + * file do not clobber each other's tmp mid-write. Without the unique + * suffix, two writers race over a shared `${target}.tmp` and produce + * either a corrupted payload or an ENOENT throw on the second rename. + * + * Same pattern as `writeBridgeAtomic` in `scripts/lib/session-bridge.js` + * and `writeCostWarningIfChanged` in `scripts/hooks/ecc-metrics-bridge.js`. + * * @param {string} sessionId * @param {object} state */ function writeWarnState(sessionId, state) { const target = getWarnPath(sessionId); - const tmp = `${target}.tmp`; + const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`; fs.writeFileSync(tmp, JSON.stringify(state), 'utf8'); - fs.renameSync(tmp, target); + try { + fs.renameSync(tmp, target); + } catch (err) { + try { fs.unlinkSync(tmp); } catch { /* ignore */ } + throw err; + } } /**