Skip to content

patch(perf): local hot-fix wrapper bin-session-end-gated.cjs (gates #460 SessionEnd storm) #461

Description

@LiuShiyuMath

What this issue tracks

I applied a local-only hot-fix on @LiuShiyuMath's dev machine to mitigate the SessionEnd hook storm documented in #460 (loadavg 162 on 8-core M1 with 8 concurrent full-rescan pipelines).

This issue captures the diff applied to disk so the maintainer can decide whether to fold the same gate into packages/cli/src/bin-session-end.ts directly, or take a different approach (per-cwd lock check + global semaphore — see #460 fix scope).

The hot-fix is not in the TeamBrain repo. It lives in two user-level files:

  1. ~/.teamagent/hooks/bin-session-end-gated.cjs — new 30-line gating wrapper
  2. ~/.claude/settings.jsonhooks.SessionEnd[0].hooks[0].command rewired to call the wrapper instead of the real hook

Gating policy implemented

Two layers, both stdlib-only (no deps, no DB):

  1. Per-cwd dedupe (60 s window) — for the same cwd, if a SessionEnd was already kicked off in the last 60 s, drop the new fire. Uses /tmp/teamagent-session-end-gate/<sha256(cwd)[0..16]>.gate mtime as the canary.
  2. Global concurrency cap (≤ 2 active) — across all cwds, at most 2 full-rescan pipelines may run concurrently. Counts gate files whose mtime is within the TTL window.

When dropped, the wrapper logs to /tmp/teamagent-session-end-gate.log (one line per drop, ISO timestamp + reason + cwd) and exits 0, so session close is never blocked.

When not dropped, the wrapper writes the gate file, then spawn execs ~/.teamagent/hooks/bin-session-end.cjs with the same stdin payload pipe-forwarded. Real hook's detached child still runs runFullRescanPipeline exactly as before — gate is purely a spawn-time admission control.

Patches applied

Patch 1 — new file ~/.teamagent/hooks/bin-session-end-gated.cjs

#!/usr/bin/env node
/**
 * TeamBrain bug #460 hot-fix wrapper (local-only, not in repo).
 *
 * Gates SessionEnd hook spawns to at most 1 concurrent rescan per cwd within
 * 60 s, plus a global cap of 2 concurrent rescans across all cwds. Drops the
 * fire (exit 0) when either gate is held, so Claude Code's session close is
 * never blocked.
 */
const fs = require("node:fs");
const crypto = require("node:crypto");
const path = require("node:path");
const { spawn } = require("node:child_process");

const LOCK_DIR = "/tmp/teamagent-session-end-gate";
const CWD_LOCK_TTL_MS = 60_000;
const GLOBAL_CAP = 2;
const REAL_HOOK = "/Users/m1/.teamagent/hooks/bin-session-end.cjs";

// ... 80-line gate logic, full source in https://github.com/libz-renlab-ai/TeamBrain/issues/461 (this issue)

Full source mirrored verbatim from the local file (4017 bytes) — see the Full source code block at the bottom of this issue.

Patch 2 — ~/.claude/settings.json SessionEnd hook

 "SessionEnd": [
   {
     "hooks": [
       {
         "type": "command",
-        "command": "bash -c '[ -f \"$1\" ] || exit 0; exec node \"$1\"' _ /Users/m1/.teamagent/hooks/bin-session-end.cjs",
+        "command": "bash -c '[ -f \"$1\" ] || exit 0; exec node \"$1\"' _ /Users/m1/.teamagent/hooks/bin-session-end-gated.cjs",
         "timeout": 30
       }
     ]
   }
 ]

settings.json was backed up before edit to ~/.claude/settings.json.bak-<unix-ts> so rollback is mv away.

Why this is a hot-fix, not a real fix

  • Hash function differs from upstream code. Upstream cwdLockKey in packages/cli/src/bin-stop.ts:281 uses node:crypto sha256 too, but encodes / truncates differently. The gate doesn't collide with the upstream STOP_PIPELINE_LOCKS_DIR, so both can coexist without races, but my gate file is not the same file the upstream code writes.
  • Lock TTL is wall-clock based, not handshake-based. If a real rescan runs > 60 s, a concurrent fire could slip through. Real fix should use the upstream lock with PID + started_at JSON (already implemented at bin-stop.ts:304+).
  • Global cap of 2 is hardcoded. Should be configurable via env var.
  • Wrapper duplicates the foreground-spawn responsibility of bin-session-end.ts:133 instead of folding the gate into that file. The proper fix is to edit bin-session-end.ts directly per bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460's scope.
  • No regression test on the gate itself (the wrapper isn't in the repo's test surface).
  • Manual install only. teamagent init will overwrite ~/.teamagent/hooks/bin-session-end.cjs cleanly but won't redeploy the wrapper or repair ~/.claude/settings.json. Anyone else who hits the same storm has to follow the install steps below.

Install / rollback recipe

Install (already done on @LiuShiyuMath's machine):

# 1. Drop the wrapper
cat > ~/.teamagent/hooks/bin-session-end-gated.cjs <<'EOF_WRAPPER'
# ... (see full source at bottom)
EOF_WRAPPER
chmod +x ~/.teamagent/hooks/bin-session-end-gated.cjs

# 2. Patch settings.json
cp ~/.claude/settings.json ~/.claude/settings.json.bak-$(date +%s)
jq '.hooks.SessionEnd[0].hooks[0].command = "bash -c '"'"'[ -f \"$1\" ] || exit 0; exec node \"$1\"'"'"' _ /Users/m1/.teamagent/hooks/bin-session-end-gated.cjs"' \
  ~/.claude/settings.json > ~/.claude/settings.json.new && mv ~/.claude/settings.json.new ~/.claude/settings.json

Rollback:

ls -t ~/.claude/settings.json.bak-* | head -1 | xargs -I {} cp {} ~/.claude/settings.json
rm ~/.teamagent/hooks/bin-session-end-gated.cjs

Verification

After installing the wrapper:

  • Storm reproducer (N SessionEnds within 100 ms) should yield ≤ GLOBAL_CAP (2) live bin-session-end.cjs detached children, not N.
  • tail -f /tmp/teamagent-session-end-gate.log shows drop reasons (per-cwd lock held, global cap 2/2 held) instead of fires going through.
  • sysctl -n vm.loadavg should stabilize at < ncpu × 2 on the same hardware.

(Live verification on the user's machine is captured in the Slack DM thread; loadavg 1-min figure will settle 60-90 s after install.)

Asks for the maintainer

  1. Decide whether to fold the gate logic into packages/cli/src/bin-session-end.ts (bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460's proper fix scope: gate the spawn on the existing STOP_PIPELINE_LOCKS_DIR/<cwdLockKey>.stop-pipeline.lock with stale handling already at bin-stop.ts:304+, plus a configurable global semaphore).
  2. Add a regression test packages/cli/src/__tests__/bin-session-end.concurrency.test.ts per bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460's scope item.
  3. Decide what to do with the wrapper: ship a polished version under packages/cli/scripts/ so other users hitting bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460 can install it without copy-pasting from an issue, OR delete the wrapper once the proper fix lands.

Full wrapper source (verbatim copy of ~/.teamagent/hooks/bin-session-end-gated.cjs)

#!/usr/bin/env node
/**
 * TeamBrain bug #460 hot-fix wrapper (local-only, not in repo).
 *
 * Gates SessionEnd hook spawns to at most 1 concurrent rescan per cwd within
 * 60 s, plus a global cap of 2 concurrent rescans across all cwds. Drops the
 * fire (exit 0) when either gate is held, so Claude Code's session close is
 * never blocked.
 *
 * Original (ungated): bash -c '[ -f "$1" ] || exit 0; exec node "$1"' _
 *   /Users/m1/.teamagent/hooks/bin-session-end.cjs
 * Gated: this script wraps that exec.
 *
 * Why this exists: bin-session-end.cjs always spawns a detached child running
 * runFullRescanPipeline. N SessionEnds within 100 ms → N concurrent full
 * rescans, each 17-36% CPU on a fresh ~8 MB tsup-bundle parse. See issue #460.
 *
 * Remove when issue #460 lands a proper fix in packages/cli/src/bin-session-end.ts.
 */

const fs = require("node:fs");
const crypto = require("node:crypto");
const path = require("node:path");
const { spawn } = require("node:child_process");

const LOCK_DIR = "/tmp/teamagent-session-end-gate";
const CWD_LOCK_TTL_MS = 60_000;
const GLOBAL_CAP = 2;
const REAL_HOOK = "/Users/m1/.teamagent/hooks/bin-session-end.cjs";

function safeMkdir(p) {
  try { fs.mkdirSync(p, { recursive: true, mode: 0o755 }); } catch {}
}
function readMtimeMs(p) {
  try { return fs.statSync(p).mtimeMs; } catch { return 0; }
}
function readPayload() {
  let raw = "";
  try { raw = fs.readFileSync(0, "utf-8"); } catch {}
  return raw;
}
function extractCwd(raw) {
  try {
    const obj = JSON.parse(raw);
    if (obj && typeof obj.cwd === "string" && obj.cwd) return obj.cwd;
  } catch {}
  return process.cwd();
}
function cwdHash(cwd) {
  return crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 16);
}
function countActiveGlobal() {
  try {
    const now = Date.now();
    return fs.readdirSync(LOCK_DIR)
      .filter((f) => f.endsWith(".gate"))
      .filter((f) => now - readMtimeMs(path.join(LOCK_DIR, f)) < CWD_LOCK_TTL_MS)
      .length;
  } catch { return 0; }
}
function dropSilently(reason) {
  try {
    fs.appendFileSync("/tmp/teamagent-session-end-gate.log",
      `${new Date().toISOString()} drop ${reason}\n`, { mode: 0o644 });
  } catch {}
  process.exit(0);
}
function main() {
  safeMkdir(LOCK_DIR);
  const raw = readPayload();
  const cwd = extractCwd(raw);
  const h = cwdHash(cwd);
  const gateFile = path.join(LOCK_DIR, `${h}.gate`);
  const lastMs = readMtimeMs(gateFile);
  if (lastMs > 0 && Date.now() - lastMs < CWD_LOCK_TTL_MS) {
    dropSilently(`per-cwd lock held cwd=${cwd}`); return;
  }
  const active = countActiveGlobal();
  if (active >= GLOBAL_CAP) {
    dropSilently(`global cap ${active}/${GLOBAL_CAP} held cwd=${cwd}`); return;
  }
  try { fs.writeFileSync(gateFile, `${Date.now()}\n${cwd}\n`, { mode: 0o644 }); } catch {}
  if (!fs.existsSync(REAL_HOOK)) { process.exit(0); }
  const child = spawn(process.execPath, [REAL_HOOK], {
    stdio: ["pipe", "inherit", "inherit"], env: process.env,
  });
  child.stdin.write(raw); child.stdin.end();
  child.on("error", () => process.exit(0));
  child.on("exit", (code) => process.exit(typeof code === "number" ? code : 0));
}
main();

Related


Filed via /investigate (continuation) on 2026-05-14.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions