Files
Spicy SharkandBellman edb331a451 fix(session-start): bound the stabilization helper lifetime and batch the darwin identity probe (#4384)
* fix(session-start): bound the stabilization helper lifetime and batch the darwin identity probe

The detached --stabilize-legacy-tool-failure-state helper only left its loop
when the quiet window had passed and no other plugin version was live. Sessions
on an older version can stay open for days, so the helper (one per .omc root)
never exited and kept rescanning the occupancy registry every 5 s.

- Give the helper a hard lifetime: max(10 x stabilization window, 60 s), at
  most 5 min with the 30 s production window. At the deadline the helper stops
  waiting and returns. The loop still purges legacy artifacts on every
  iteration whenever any exist, as before; the deadline adds no extra purge.
  Trade-off: an older-version session that keeps writing legacy artifacts after
  the deadline leaves them on disk until the next SessionStart in that root
  starts a new helper, whereas before the helper scrubbed them within about
  250 ms for as long as that session lived. An optional fifth argument can
  shorten the cap; the cap also defaults when the function is called without it.
- On darwin, resolve process start identities with one
  `ps -p pid1,pid2,... -o pid=,lstart=` per scan instead of one spawn per
  registry record, like the win32 batch. ps exits 1 when none of the pids exist
  (some BSD builds also when only some are gone), so status 0 and 1 are both
  parsed; any other failure keeps the records. The Linux /proc path and the
  rescan intervals are unchanged.

Applied to both the plugin scripts and the standalone hook templates.
Inventory regenerated for the changed sources.

Fixes #4382

* fix(session-start): stop the stabilization helper from purging after its lifetime ends

The helper checked its hard lifetime only at the top of the loop. The cleanup
it ran inside an iteration waited up to 2.5 s for the PostToolUseFailure state
lock on its own, so a lock that freed up after the deadline but before that
wait ran out still let the helper delete legacy artifacts past its lifetime.

- Bound the cleanup lock wait by the time left in the lifetime.
- Check the deadline again once the lock is held: past it, release the lock
  and leave the artifacts for the next SessionStart in that root.
- Callers without a lifetime (SessionStart and the deferred purge child) keep
  the 2.5 s wait and purge whenever they hold the lock.

Applied to both the plugin script and the standalone hook template, with a
regression test for each that holds the lock past a shortened lifetime.
Inventory regenerated for the changed sources.

* fix(session-start): bound the stabilization helper's worker lock wait by its lifetime

The helper took its cleanup-worker lock with the lock library's default wait
of about 3 s and no overall timeout. When the state directory already existed,
or appeared at the deadline, the loop left the state-directory wait without
checking the lifetime, so a held worker lock kept the helper alive past it.

- Check the deadline at the top of the state-directory wait, before leaving it.
- Pass the time left in the lifetime as the acquisition's overall timeout; a
  spent lifetime fails the acquisition at once and the helper exits.
- Check the deadline again once the worker lock is held and release it without
  doing any work when it has passed.
- Other parameters of the lock acquisition keep their defaults.

Applied to both the plugin script and the standalone hook template, with a
regression test for each that holds the worker lock past a 200 ms lifetime.
Inventory regenerated for the changed sources.

* fix(session-start): bound the stabilization helper's plugin occupancy probes by its lifetime

The helper scanned for older plugin versions without a probe timeout, so a slow
Darwin batched ps (2 s) or PowerShell host (3 s) could run past the helper's
hard lifetime, before the wait loop and again while the cleanup-worker lock was
held.

- Pass the time left in the lifetime as the probe timeout of every older-plugin
  scan on the helper's path. The probe never exceeds its platform default, and a
  spent lifetime skips it.
- A probe that times out or has no time left identifies nothing, so every
  registry record is kept and its root counts as occupied: nothing is purged,
  the worker lock is released and the helper exits.
- Callers without a lifetime keep the default probe timeout. The darwin scan
  stays a single batched ps.

Applied to both the plugin script and the standalone hook template, with a
regression test for each that delays the post-lock darwin ps under a shortened
lifetime and asserts a timely exit with the worker lock released. Inventory
regenerated for the changed sources.

* chore(inventory): refresh graph for current dev

* chore(inventory): refresh graph for current dev

---------

Co-authored-by: Bellman <54757707+Yeachan-Heo@users.noreply.github.com>
2026-10-15 13:11:50 +01:00

180 lines
8.5 KiB
JavaScript

import { existsSync, readdirSync, lstatSync, readFileSync, unlinkSync, mkdirSync, writeFileSync, renameSync } from 'fs';
import { join, resolve } from 'path';
import { createHash, randomUUID } from 'crypto';
import { spawnSync } from 'child_process';
const NAME = /^[a-f0-9]{64}\.json$/;
/** Resolve paths for identity comparisons; only Windows has case-insensitive paths. */
export function pathIdentity(path) {
const normalized = resolve(path);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function identity(pid, probeTimeoutMs = 3000) {
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
if (process.platform === 'linux') {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
const close = stat.lastIndexOf(')');
return close < 0 ? null : (stat.substring(close + 2).split(' ')[19] || null);
} catch { return null; }
}
if (process.platform === 'darwin') {
try {
if (probeTimeoutMs <= 0) return null;
const timeout = Math.max(1, Math.min(2000, Math.floor(probeTimeoutMs)));
const result = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout });
if (result.status !== 0) return null;
const value = new Date(result.stdout.trim()).getTime();
return Number.isFinite(value) ? String(value) : null;
} catch { return null; }
}
if (process.platform === 'win32') {
try {
if (probeTimeoutMs <= 0) return null;
const timeout = Math.max(1, Math.min(3000, Math.floor(probeTimeoutMs)));
const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', `$p=Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks`], { encoding: 'utf8', timeout, windowsHide: true });
const ticks = result.stdout?.trim().match(/^\d+$/)?.[0];
return ticks ? `ticks:${ticks}` : null;
} catch { return null; }
}
return null;
}
function identities(pids, probeTimeoutMs = 3000) {
const validPids = [...new Set(pids.filter(pid => Number.isSafeInteger(pid) && pid > 0))];
if (validPids.length === 0 || probeTimeoutMs <= 0) return new Map();
if (process.platform === 'win32') {
try {
const filter = validPids.join(',');
const command = `$items=Get-Process -Id ${filter} -ErrorAction SilentlyContinue | Select-Object Id,@{Name='StartTicks';Expression={[string]$_.StartTime.ToUniversalTime().Ticks}}; $items | ConvertTo-Json -Compress`;
const timeout = Math.max(1, Math.min(3000, Math.floor(probeTimeoutMs)));
const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', command], { encoding: 'utf8', timeout, windowsHide: true });
if (result.status !== 0) return new Map();
const parsed = JSON.parse(result.stdout?.trim() || 'null');
const items = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
return new Map(items
.map(item => [Number(item?.Id), item?.StartTicks])
.filter(([pid, ticks]) => validPids.includes(pid) && typeof ticks === 'string' && /^\d+$/.test(ticks))
.map(([pid, ticks]) => [pid, `ticks:${ticks}`]));
} catch { return new Map(); }
}
if (process.platform === 'darwin') {
// One `ps` for every pid instead of one spawn per registry record. ps prints
// the live pids and exits 1 when none of them exist (some BSD builds also
// when only some are gone), so a missing pid is simply absent from the map.
try {
const timeout = Math.max(1, Math.min(2000, Math.floor(probeTimeoutMs)));
const result = spawnSync('ps', ['-p', validPids.join(','), '-o', 'pid=,lstart='], { encoding: 'utf8', timeout });
if (result.error || (result.status !== 0 && result.status !== 1)) return new Map();
const found = new Map();
for (const line of (result.stdout ?? '').split('\n')) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
const pid = Number(match?.[1]);
const value = match ? new Date(match[2]).getTime() : NaN;
if (validPids.includes(pid) && Number.isFinite(value)) found.set(pid, String(value));
}
return found;
} catch { return new Map(); }
}
const deadline = Date.now() + probeTimeoutMs;
const result = new Map();
for (const pid of validPids) {
const remaining = deadline - Date.now();
if (remaining <= 0) break;
const start = identity(pid, remaining);
if (start) result.set(pid, start);
}
return result;
}
function alive(pid) {
try { process.kill(pid, 0); return true; }
catch (error) { return error?.code === 'EPERM'; }
}
function registryDir(configDir) { return join(configDir, '.omc', 'cache-occupancy'); }
function fileName(root, pid, start) { return `${createHash('sha256').update(`${root}\0${pid}\0${start}`).digest('hex')}.json`; }
// Issue #3995: on win32 every identity probe is a PowerShell host (~1.6-2.0s),
// so SessionStart must not need two. publishCacheOccupancy() accepts an
// explicit precomputed identity for `pid` (resolved by the caller in the ONE
// batched readOccupiedPluginRoots() host) and skips its own probe entirely;
// readOccupiedPluginRoots() accepts a previously returned identities map to
// verify records without spawning a second host (pids absent from the map are
// kept conservatively — the record survives unless proven stale otherwise).
export function publishCacheOccupancy(pluginRoot, configDir, pid = process.ppid, precomputedIdentity, probeTimeoutMs = 3000) {
const root = pathIdentity(pluginRoot || '');
const start = typeof precomputedIdentity === 'string' && precomputedIdentity.length > 0
? precomputedIdentity
: precomputedIdentity === null ? null : identity(pid, probeTimeoutMs);
if (!root || !start) return false;
const dir = registryDir(configDir);
const target = join(dir, fileName(root, pid, start));
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(temp, JSON.stringify({ version: 1, pid, processStartIdentity: start, pluginRoot: root, updatedAt: new Date().toISOString() }), { mode: 0o600 });
renameSync(temp, target);
return true;
} catch { try { unlinkSync(temp); } catch {} return false; }
}
export function readOccupiedPluginRoots(configDir, options = {}) {
const probeBudget = Number.isFinite(options.probeTimeoutMs) ? Math.max(0, options.probeTimeoutMs) : undefined;
const probeDeadline = probeBudget === undefined ? Infinity : Date.now() + probeBudget;
const remainingProbeMs = () => probeBudget === undefined ? 3000 : Math.max(0, probeDeadline - Date.now());
const dir = registryDir(configDir);
let names = [];
let unavailable = false;
try { names = readdirSync(dir).filter(name => NAME.test(name)); }
catch (error) { if (error?.code !== 'ENOENT') unavailable = true; }
const roots = new Set();
const records = [];
for (const name of names) {
const path = join(dir, name);
let regularFile;
try { regularFile = lstatSync(path).isFile(); }
catch (error) {
if (error?.code !== 'ENOENT') unavailable = true;
continue;
}
if (!regularFile) continue;
let record;
let content;
try { content = readFileSync(path, 'utf8'); }
catch (error) {
if (error?.code !== 'ENOENT') unavailable = true;
continue;
}
try { record = JSON.parse(content); } catch { try { unlinkSync(path); } catch { /* best effort */ } continue; }
const age = Date.now() - Date.parse(record?.updatedAt);
if (record?.version !== 1 || !Number.isSafeInteger(record?.pid) || !record?.processStartIdentity || !record?.pluginRoot || !Number.isFinite(Date.parse(record?.updatedAt)) || age < -300000 || !alive(record.pid)) {
try { unlinkSync(path); } catch {}
continue;
}
records.push({ path, record });
}
// A precomputed map (same-process reuse, issue #3995) verifies records
// without a second PowerShell host; the fresh batched probe runs otherwise.
const currentIdentities = Array.isArray(options.identities)
? new Map(options.identities)
: (options.identities instanceof Map
? options.identities
: identities(
[...(Array.isArray(options.includePids) ? options.includePids : []), ...records.map(({ record }) => record.pid)],
remainingProbeMs(),
));
for (const { path, record } of records) {
const current = currentIdentities.get(record.pid);
if (current && current !== record.processStartIdentity) {
try { unlinkSync(path); } catch {}
continue;
}
roots.add(pathIdentity(record.pluginRoot));
}
return { roots, unavailable, identities: currentIdentities };
}