perf(desktop): bound long-running app resource use
Persist backend ownership for reliable cleanup, park inactive panes, and
evict unreferenced transcripts so Desktop stays responsive over long sessions.
💘 Generated with Crush
Assisted-by: Crush:gpt-5.6
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
backendCommandMatches,
|
||||
type BackendIdentity,
|
||||
createBackendOwnership,
|
||||
createBackendShutdownCoordinator,
|
||||
parseBackendOwnership
|
||||
} from './backend-ownership'
|
||||
|
||||
function memoryStore(initial = '') {
|
||||
let contents = initial
|
||||
|
||||
return {
|
||||
read: () => contents,
|
||||
value: () => contents,
|
||||
write: (next: string) => {
|
||||
contents = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function identity(overrides: Partial<BackendIdentity> = {}): BackendIdentity {
|
||||
return {
|
||||
nonce: 'nonce-42',
|
||||
pid: 42,
|
||||
profile: 'default',
|
||||
startMarker: 'os-start-123',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function ownershipEntry(overrides: Partial<BackendIdentity> = {}) {
|
||||
return { command: 'hermes serve --port 0', ...identity(overrides) }
|
||||
}
|
||||
|
||||
function stored(entries: object[]): string {
|
||||
return JSON.stringify({ backends: entries })
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
|
||||
const promise = new Promise<void>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createOwnership(store = memoryStore(), overrides: Partial<Parameters<typeof createBackendOwnership>[0]> = {}) {
|
||||
return createBackendOwnership({
|
||||
matchesIdentity: async () => true,
|
||||
stop: () => {},
|
||||
store,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
test('claim persists the caller-supplied exact identity before resolving', async () => {
|
||||
const store = memoryStore()
|
||||
const ownership = createOwnership(store)
|
||||
const claim = ownershipEntry()
|
||||
|
||||
assert.deepEqual(await ownership.claim(claim), claim)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [claim])
|
||||
})
|
||||
|
||||
test('incomplete claims and persisted records are rejected', async () => {
|
||||
const store = memoryStore(
|
||||
stored([
|
||||
ownershipEntry(),
|
||||
{ ...ownershipEntry({ pid: 43 }), startMarker: '' },
|
||||
{ ...ownershipEntry({ pid: 44 }), nonce: undefined },
|
||||
{ ...ownershipEntry({ pid: 45 }), profile: undefined }
|
||||
])
|
||||
)
|
||||
|
||||
const ownership = createOwnership(store)
|
||||
|
||||
await assert.rejects(ownership.claim({ ...ownershipEntry(), startMarker: '' }), /complete process identity/)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [ownershipEntry()])
|
||||
})
|
||||
|
||||
test('failed persistence awaits asynchronous cleanup of the exact identity', async () => {
|
||||
const cleanup = deferred()
|
||||
const stop = vi.fn(() => cleanup.promise)
|
||||
const expected = new Error('disk full')
|
||||
const claim = ownershipEntry({ pid: 43 })
|
||||
|
||||
const ownership = createOwnership(memoryStore(), {
|
||||
stop,
|
||||
store: {
|
||||
read: () => null,
|
||||
write: () => {
|
||||
throw expected
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let rejected = false
|
||||
|
||||
const result = ownership.claim(claim).catch(error => {
|
||||
rejected = true
|
||||
throw error
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.equal(rejected, false)
|
||||
assert.deepEqual(stop.mock.calls, [[claim]])
|
||||
|
||||
cleanup.resolve()
|
||||
await assert.rejects(result, expected)
|
||||
assert.equal(rejected, true)
|
||||
})
|
||||
|
||||
test('startup reap drops a confirmed PID reuse mismatch without stopping it', async () => {
|
||||
const entry = ownershipEntry()
|
||||
const store = memoryStore(stored([entry]))
|
||||
const matchesIdentity = vi.fn(async () => false)
|
||||
const stop = vi.fn()
|
||||
const ownership = createOwnership(store, { matchesIdentity, stop })
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.deepEqual(matchesIdentity.mock.calls, [[entry]])
|
||||
assert.equal(stop.mock.calls.length, 0)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [])
|
||||
})
|
||||
|
||||
test('startup reap preserves records when exact identity probing is uncertain or fails', async () => {
|
||||
const uncertain = ownershipEntry({ pid: 50, nonce: 'uncertain' })
|
||||
const failed = ownershipEntry({ pid: 51, nonce: 'failed' })
|
||||
const store = memoryStore(stored([uncertain, failed]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
matchesIdentity: async entry => {
|
||||
if (entry.pid === failed.pid) {
|
||||
throw new Error('process table unavailable')
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
stop
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.equal(stop.mock.calls.length, 0)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [uncertain, failed])
|
||||
})
|
||||
|
||||
test('startup reap passes the full confirmed identity to stop', async () => {
|
||||
const entry = ownershipEntry({ pid: 52 })
|
||||
const store = memoryStore(stored([entry]))
|
||||
const stop = vi.fn()
|
||||
const ownership = createOwnership(store, { stop })
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [52])
|
||||
assert.deepEqual(stop.mock.calls, [[entry]])
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [])
|
||||
})
|
||||
|
||||
test('startup reap preserves failed stops for the next launch', async () => {
|
||||
const entry = ownershipEntry({ pid: 53 })
|
||||
const store = memoryStore(stored([entry]))
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
stop: () => {
|
||||
throw new Error('permission denied')
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
||||
})
|
||||
|
||||
test('release removes only the exact identity rather than every record for its PID', () => {
|
||||
const oldProcess = ownershipEntry({ nonce: 'old', startMarker: 'start-old' })
|
||||
const reusedPid = ownershipEntry({ nonce: 'new', startMarker: 'start-new' })
|
||||
const store = memoryStore(stored([oldProcess, reusedPid]))
|
||||
const ownership = createOwnership(store)
|
||||
|
||||
ownership.release(oldProcess)
|
||||
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [reusedPid])
|
||||
})
|
||||
|
||||
test('backend identity check matches only serve and dashboard invocation shapes', () => {
|
||||
assert.equal(backendCommandMatches('/venv/bin/hermes serve --port 0'), true)
|
||||
assert.equal(backendCommandMatches('python -m hermes_cli.main dashboard --no-open'), true)
|
||||
assert.equal(backendCommandMatches('/venv/bin/hermes --profile work serve --port 0'), true)
|
||||
assert.equal(backendCommandMatches('"C:\\Hermes Runtime\\hermes.exe" dashboard --no-open'), true)
|
||||
assert.equal(backendCommandMatches('hermes chat --query serve'), false)
|
||||
assert.equal(backendCommandMatches('unrelated dashboard'), false)
|
||||
})
|
||||
|
||||
test('shutdown coordinator returns one promise and awaits teardown exactly once', async () => {
|
||||
const completion = deferred()
|
||||
const teardown = vi.fn(() => completion.promise)
|
||||
const coordinator = createBackendShutdownCoordinator(teardown)
|
||||
|
||||
const first = coordinator.run()
|
||||
const second = coordinator.run()
|
||||
|
||||
assert.equal(first, second)
|
||||
assert.equal(coordinator.hasStarted(), true)
|
||||
await Promise.resolve()
|
||||
assert.equal(teardown.mock.calls.length, 1)
|
||||
|
||||
let finished = false
|
||||
first.then(() => {
|
||||
finished = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.equal(finished, false)
|
||||
|
||||
completion.resolve()
|
||||
await second
|
||||
assert.equal(finished, true)
|
||||
assert.equal(coordinator.run(), first)
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
export interface BackendIdentity {
|
||||
nonce: string
|
||||
pid: number
|
||||
profile: string
|
||||
startMarker: string
|
||||
}
|
||||
|
||||
export interface BackendOwnershipEntry extends BackendIdentity {
|
||||
command?: string
|
||||
}
|
||||
|
||||
export interface BackendOwnershipStore {
|
||||
read: () => string | null
|
||||
write: (contents: string) => void
|
||||
}
|
||||
|
||||
export interface BackendOwnershipDeps {
|
||||
matchesIdentity: (identity: BackendIdentity) => Promise<boolean | undefined>
|
||||
stop: (identity: BackendIdentity) => Promise<void> | void
|
||||
store: BackendOwnershipStore
|
||||
}
|
||||
|
||||
export interface BackendClaim extends BackendIdentity {
|
||||
command?: string
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function isCompleteIdentity(value: unknown): value is BackendIdentity {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Partial<BackendIdentity>
|
||||
|
||||
return (
|
||||
Number.isInteger(candidate.pid) &&
|
||||
Number(candidate.pid) > 0 &&
|
||||
isNonEmptyString(candidate.startMarker) &&
|
||||
isNonEmptyString(candidate.nonce) &&
|
||||
isNonEmptyString(candidate.profile)
|
||||
)
|
||||
}
|
||||
|
||||
function identitiesMatch(left: BackendIdentity, right: BackendIdentity): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.startMarker === right.startMarker &&
|
||||
left.nonce === right.nonce &&
|
||||
left.profile === right.profile
|
||||
)
|
||||
}
|
||||
|
||||
export function parseBackendOwnership(contents: unknown): BackendOwnershipEntry[] {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(String(contents ?? ''))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const values = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { backends?: unknown }).backends)
|
||||
? (parsed as { backends: unknown[] }).backends
|
||||
: []
|
||||
|
||||
const entries: BackendOwnershipEntry[] = []
|
||||
|
||||
for (const value of values) {
|
||||
if (!isCompleteIdentity(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidate = value as BackendOwnershipEntry
|
||||
|
||||
const entry: BackendOwnershipEntry = {
|
||||
nonce: candidate.nonce,
|
||||
pid: candidate.pid,
|
||||
profile: candidate.profile,
|
||||
startMarker: candidate.startMarker
|
||||
}
|
||||
|
||||
if (typeof candidate.command === 'string') {
|
||||
entry.command = candidate.command
|
||||
}
|
||||
|
||||
if (!entries.some(existing => identitiesMatch(existing, entry))) {
|
||||
entries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export function serializeBackendOwnership(entries: BackendOwnershipEntry[]): string {
|
||||
return `${JSON.stringify({ backends: entries }, null, 2)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent ownership for local backend roots.
|
||||
*
|
||||
* Claiming is asynchronous so a failed persistence transaction can await child
|
||||
* cleanup before reporting failure to the caller.
|
||||
*/
|
||||
export function createBackendOwnership(deps: BackendOwnershipDeps) {
|
||||
const read = () => parseBackendOwnership(deps.store.read())
|
||||
const write = (entries: BackendOwnershipEntry[]) => deps.store.write(serializeBackendOwnership(entries))
|
||||
|
||||
return {
|
||||
async claim(claim: BackendClaim): Promise<BackendOwnershipEntry> {
|
||||
if (!isCompleteIdentity(claim)) {
|
||||
throw new Error('Cannot own a backend without a complete process identity.')
|
||||
}
|
||||
|
||||
const entry: BackendOwnershipEntry = {
|
||||
nonce: claim.nonce,
|
||||
pid: claim.pid,
|
||||
profile: claim.profile,
|
||||
startMarker: claim.startMarker
|
||||
}
|
||||
|
||||
if (typeof claim.command === 'string') {
|
||||
entry.command = claim.command
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = read().filter(candidate => candidate.pid !== entry.pid)
|
||||
write([...entries, entry])
|
||||
} catch (error) {
|
||||
try {
|
||||
await deps.stop(entry)
|
||||
} catch {
|
||||
// Persistence remains the claim failure even if cleanup also fails.
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
return entry
|
||||
},
|
||||
|
||||
release(identity: BackendIdentity): void {
|
||||
if (!isCompleteIdentity(identity)) {
|
||||
throw new Error('Cannot release a backend without a complete process identity.')
|
||||
}
|
||||
|
||||
const entries = read()
|
||||
const next = entries.filter(entry => !identitiesMatch(entry, identity))
|
||||
|
||||
if (next.length !== entries.length) {
|
||||
write(next)
|
||||
}
|
||||
},
|
||||
|
||||
async reapOrphans(): Promise<number[]> {
|
||||
const entries = read()
|
||||
const survivors: BackendOwnershipEntry[] = []
|
||||
const reaped: number[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
let matches: boolean | undefined
|
||||
|
||||
try {
|
||||
matches = await deps.matchesIdentity(entry)
|
||||
} catch {
|
||||
survivors.push(entry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (matches === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (matches !== true) {
|
||||
survivors.push(entry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.stop(entry)
|
||||
reaped.push(entry.pid)
|
||||
} catch {
|
||||
// Preserve failed ownership so a later startup can retry it.
|
||||
survivors.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
write(survivors)
|
||||
|
||||
return reaped
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
write([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backendCommandMatches(command: unknown): boolean {
|
||||
return /(?:^|[\s/\\"])(?:hermes(?:\.exe)?|hermes_cli\.main|hermes_cli[/\\]main\.py)"?(?:\s+(?:--profile|-p)\s+\S+)?\s+(?:serve|dashboard)(?:\s|$)/i.test(
|
||||
String(command ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/** Coordinates all quit paths so asynchronous backend teardown runs once. */
|
||||
export function createBackendShutdownCoordinator(teardown: () => Promise<void> | void) {
|
||||
let completion: Promise<void> | undefined
|
||||
|
||||
return {
|
||||
run(): Promise<void> {
|
||||
if (!completion) {
|
||||
completion = Promise.resolve().then(teardown)
|
||||
}
|
||||
|
||||
return completion
|
||||
},
|
||||
hasStarted(): boolean {
|
||||
return completion !== undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
+338
-44
@@ -37,6 +37,11 @@ import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { isReauthRequiredError, waitForHermesReady } from './backend-health'
|
||||
import {
|
||||
backendCommandMatches,
|
||||
createBackendOwnership,
|
||||
createBackendShutdownCoordinator
|
||||
} from './backend-ownership'
|
||||
import {
|
||||
canImportHermesCli,
|
||||
execProbeSync,
|
||||
@@ -467,7 +472,7 @@ if (IS_WINDOWS) {
|
||||
|
||||
try {
|
||||
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
|
||||
app.exit(0)
|
||||
void exitAfterBackendShutdown(0)
|
||||
} catch (error) {
|
||||
console.error(`[hermes] --no-sandbox relaunch failed: ${error?.message || error}`)
|
||||
}
|
||||
@@ -655,6 +660,7 @@ const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'conne
|
||||
const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json')
|
||||
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
|
||||
const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
|
||||
const DESKTOP_BACKEND_OWNERSHIP_PATH = path.join(app.getPath('userData'), 'backend-ownership.json')
|
||||
// active-profile.json records which Hermes profile the desktop launches its
|
||||
// local backend as. When set, startHermes() passes `hermes --profile <name>
|
||||
// dashboard …`, which deterministically pins HERMES_HOME (see
|
||||
@@ -1122,6 +1128,7 @@ const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDL
|
||||
// killing one to honor the soft cap would abort a running agent.
|
||||
const POOL_KEEPALIVE_FRESH_MS = 90_000
|
||||
let poolIdleReaper = null
|
||||
let backendOrphanReapPromise = null
|
||||
// Auto-reload budget for renderer crashes, shared by EVERY window (primary,
|
||||
// secondary session, instance) so a crash loop anywhere is suppressed after
|
||||
// the same budget instead of reloading per-window forever. A deterministic
|
||||
@@ -2885,6 +2892,226 @@ function forceKillProcessTree(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeBackendOwnership(contents) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_BACKEND_OWNERSHIP_PATH), { recursive: true })
|
||||
const tempPath = `${DESKTOP_BACKEND_OWNERSHIP_PATH}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, contents, { encoding: 'utf8', mode: 0o600 })
|
||||
fs.renameSync(tempPath, DESKTOP_BACKEND_OWNERSHIP_PATH)
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function execText(command, args) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout: 3000 }), (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(String(stdout || '').trim())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function processStartMarker(pid) {
|
||||
if (process.platform === 'linux') {
|
||||
const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8')
|
||||
const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)
|
||||
|
||||
if (!/^\d+$/.test(fields[19] || '')) {
|
||||
throw new Error(`Invalid /proc start marker for PID ${pid}`)
|
||||
}
|
||||
|
||||
return `linux:${fields[19]}`
|
||||
}
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
const ticks = await execText('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks`
|
||||
])
|
||||
|
||||
if (!/^\d+$/.test(ticks)) {
|
||||
throw new Error(`Invalid Windows start marker for PID ${pid}`)
|
||||
}
|
||||
|
||||
return `win:${ticks}`
|
||||
}
|
||||
|
||||
const started = await execText('ps', ['-p', String(pid), '-o', 'lstart='])
|
||||
|
||||
if (!started) {
|
||||
throw new Error(`Missing process start marker for PID ${pid}`)
|
||||
}
|
||||
|
||||
return `ps:${started}`
|
||||
}
|
||||
|
||||
async function backendCommandForPid(pid) {
|
||||
try {
|
||||
const command = IS_WINDOWS ? 'powershell.exe' : 'ps'
|
||||
|
||||
const args = IS_WINDOWS
|
||||
? ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`]
|
||||
: ['-p', String(pid), '-o', 'command=']
|
||||
|
||||
return (await execText(command, args)) || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function processIdentityMatches(identity) {
|
||||
try {
|
||||
return (await processStartMarker(identity.pid)) === identity.startMarker
|
||||
} catch (error) {
|
||||
return error?.code === 'ENOENT' || error?.code === 'ESRCH' ? false : undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function backendIdentityMatches(identity) {
|
||||
const processMatches = await processIdentityMatches(identity)
|
||||
|
||||
if (processMatches !== true) {
|
||||
return processMatches
|
||||
}
|
||||
|
||||
const command = await backendCommandForPid(identity.pid)
|
||||
|
||||
return command === null ? undefined : backendCommandMatches(command)
|
||||
}
|
||||
|
||||
async function stopOwnedBackend(identity) {
|
||||
if ((await processIdentityMatches(identity)) !== true) {
|
||||
return
|
||||
}
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
forceKillProcessTree(identity.pid)
|
||||
} else {
|
||||
try {
|
||||
process.kill(-identity.pid, 'SIGTERM')
|
||||
} catch {
|
||||
try {
|
||||
process.kill(identity.pid, 'SIGTERM')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 1500
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if ((await processIdentityMatches(identity)) !== true) {
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
// Revalidate immediately before escalation so PID reuse cannot target a
|
||||
// replacement process.
|
||||
if ((await processIdentityMatches(identity)) === true) {
|
||||
try {
|
||||
process.kill(-identity.pid, 'SIGKILL')
|
||||
} catch {
|
||||
process.kill(identity.pid, 'SIGKILL')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
const remaining = await processIdentityMatches(identity)
|
||||
|
||||
if (remaining !== false) {
|
||||
throw new Error(`Backend PID ${identity.pid} did not stop cleanly.`)
|
||||
}
|
||||
}
|
||||
|
||||
const backendOwnership = createBackendOwnership({
|
||||
matchesIdentity: backendIdentityMatches,
|
||||
stop: stopOwnedBackend,
|
||||
store: {
|
||||
read: () => {
|
||||
try {
|
||||
return fs.readFileSync(DESKTOP_BACKEND_OWNERSHIP_PATH, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
write: writeBackendOwnership
|
||||
}
|
||||
})
|
||||
|
||||
let desktopParentStartMarkerPromise = null
|
||||
|
||||
function desktopParentStartMarker() {
|
||||
desktopParentStartMarkerPromise ??= processStartMarker(process.pid)
|
||||
|
||||
return desktopParentStartMarkerPromise
|
||||
}
|
||||
|
||||
async function claimBackendChild(child, command, profile, nonce) {
|
||||
try {
|
||||
const identity = await backendOwnership.claim({
|
||||
command,
|
||||
nonce,
|
||||
pid: child.pid,
|
||||
profile,
|
||||
startMarker: await processStartMarker(child.pid)
|
||||
})
|
||||
|
||||
child.hermesBackendIdentity = identity
|
||||
|
||||
return identity
|
||||
} catch (error) {
|
||||
stopBackendChild(child)
|
||||
await waitForBackendExit(child)
|
||||
throw new Error(`Could not persist ownership for the Hermes backend: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function releaseBackendChild(child) {
|
||||
const identity = child?.hermesBackendIdentity
|
||||
|
||||
if (!identity) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
backendOwnership.release(identity)
|
||||
} catch (error) {
|
||||
rememberLog(`Could not release backend ownership for PID ${identity.pid}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function reapOrphanedBackendsOnce() {
|
||||
if (!backendOrphanReapPromise) {
|
||||
backendOrphanReapPromise = backendOwnership
|
||||
.reapOrphans()
|
||||
.then(pids => {
|
||||
if (pids.length) {
|
||||
rememberLog(`Reaped orphaned desktop backend PID(s): ${pids.join(', ')}`)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
backendOrphanReapPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return backendOrphanReapPromise
|
||||
}
|
||||
|
||||
// Before handing off the update on Windows, the desktop MUST stop every backend
|
||||
// it spawned and WAIT for the venv shim to actually unlock. The old code did
|
||||
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
|
||||
@@ -7350,6 +7577,7 @@ const desktopInstallationId = loadOrCreateInstallationId(DESKTOP_INSTALLATION_PA
|
||||
const sshBootstrapCoordinator = createBootstrapCoordinator()
|
||||
|
||||
let sshQuitTeardownDone = false
|
||||
let backendQuitTeardownDone = false
|
||||
|
||||
function sshScopeKey(profile) {
|
||||
return connectionScopeKey(profile) || ''
|
||||
@@ -8050,42 +8278,52 @@ function sendConnectionApplied() {
|
||||
}
|
||||
|
||||
async function waitForBackendExit(child, timeoutMs = 5000) {
|
||||
if (!child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
const exited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||
forceKillProcessTree(child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
// POSIX: SIGKILL the whole group (pgid==pid, start_new_session) so
|
||||
// MCP grandchildren die with the backend. Fall back to the child.
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
} catch {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
const wait = delay =>
|
||||
new Promise<void>(resolve => {
|
||||
if (exited()) {
|
||||
resolve()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
const timer = setTimeout(resolve, delay)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await wait(timeoutMs)
|
||||
|
||||
if (exited()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||
forceKillProcessTree(child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
} catch {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Await the escalation as well; do not let shutdown or failed adoption race
|
||||
// a still-running backend.
|
||||
await wait(1000)
|
||||
}
|
||||
|
||||
// The profile the primary (window) backend runs as. readActiveDesktopProfile()
|
||||
@@ -8142,8 +8380,13 @@ async function ensureBackend(profile) {
|
||||
remoteBaseUrl: null
|
||||
}
|
||||
|
||||
entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => {
|
||||
backendPool.delete(key)
|
||||
entry.connectionPromise = spawnPoolBackend(key, entry).catch(async error => {
|
||||
if (backendPool.get(key) === entry) {
|
||||
backendPool.delete(key)
|
||||
}
|
||||
|
||||
stopBackendChild(entry.process)
|
||||
await waitForBackendExit(entry.process)
|
||||
throw error
|
||||
})
|
||||
backendPool.set(key, entry)
|
||||
@@ -8227,6 +8470,7 @@ function startPoolIdleReaper() {
|
||||
// local-spawn portion of startHermes() but without the boot-progress UI,
|
||||
// bootstrap, or remote handling (those belong to the primary backend only).
|
||||
async function spawnPoolBackend(profile, entry) {
|
||||
await reapOrphanedBackendsOnce()
|
||||
// A profile may point at its OWN remote backend (connection.json
|
||||
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
|
||||
// In either case there is no local child to spawn — we just verify the
|
||||
@@ -8285,6 +8529,9 @@ async function spawnPoolBackend(profile, entry) {
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const parentStartMarker = await desktopParentStartMarker()
|
||||
const backendNonce = crypto.randomBytes(16).toString('hex')
|
||||
|
||||
const child = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
@@ -8302,11 +8549,11 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
// Our PID so the backend's parent-death watchdog self-exits if we die
|
||||
// uncleanly (crash / SIGKILL / update handoff) instead of leaking a
|
||||
// serving backend + its MCP child subtree. See web_server.py
|
||||
// _start_parent_death_watchdog.
|
||||
// Exact parent identity lets the backend self-exit after an unclean
|
||||
// Desktop death without mistaking a reused PID for its owner.
|
||||
HERMES_PARENT_PID: String(process.pid),
|
||||
HERMES_PARENT_START_MARKER: parentStartMarker,
|
||||
HERMES_PARENT_NONCE: backendNonce,
|
||||
HERMES_WEB_DIST: webDist,
|
||||
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
|
||||
},
|
||||
@@ -8317,6 +8564,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
|
||||
entry.process = child
|
||||
entry.token = token
|
||||
await claimBackendChild(child, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce)
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
child.stderr.on('data', rememberLog)
|
||||
@@ -8330,11 +8578,13 @@ async function spawnPoolBackend(profile, entry) {
|
||||
|
||||
child.once('error', error => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
||||
releaseBackendChild(child)
|
||||
backendPool.delete(profile)
|
||||
rejectStart?.(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||
releaseBackendChild(child)
|
||||
backendPool.delete(profile)
|
||||
|
||||
if (!ready) {
|
||||
@@ -8420,6 +8670,26 @@ function stopAllPoolBackends() {
|
||||
}
|
||||
}
|
||||
|
||||
const backendShutdown = createBackendShutdownCoordinator(async () => {
|
||||
const primary = backendConnectionState.invalidate()
|
||||
const pooled = [...backendPool.values()].map(entry => entry.process).filter(Boolean)
|
||||
|
||||
stopBackendChild(primary)
|
||||
stopAllPoolBackends()
|
||||
|
||||
if (poolIdleReaper) {
|
||||
clearInterval(poolIdleReaper)
|
||||
poolIdleReaper = null
|
||||
}
|
||||
|
||||
await Promise.all([waitForBackendExit(primary), ...pooled.map(child => waitForBackendExit(child))])
|
||||
})
|
||||
|
||||
async function exitAfterBackendShutdown(code) {
|
||||
await backendShutdown.run()
|
||||
app.exit(code)
|
||||
}
|
||||
|
||||
// Returns the profile name whose backend was torn down, or null when the
|
||||
// request is not a profile-delete. The caller uses this to skip ensureBackend
|
||||
// for the just-torn-down profile — otherwise ensureBackend respawns a pool
|
||||
@@ -8455,6 +8725,8 @@ async function prepareProfileDeleteRequest(request) {
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
await reapOrphanedBackendsOnce()
|
||||
|
||||
// Latched-failure short-circuit: once bootstrap has failed in this
|
||||
// process, every subsequent startHermes() call re-throws the same error
|
||||
// without re-running install.ps1. This prevents the renderer's
|
||||
@@ -8578,6 +8850,10 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
const profile = primaryProfileKey()
|
||||
const parentStartMarker = await desktopParentStartMarker()
|
||||
const backendNonce = crypto.randomBytes(16).toString('hex')
|
||||
|
||||
const hermesProcess = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
@@ -8600,11 +8876,11 @@ async function startHermes() {
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
// Our PID so the backend's parent-death watchdog self-exits if we die
|
||||
// uncleanly (crash / SIGKILL / update handoff) instead of leaking a
|
||||
// serving backend + its MCP child subtree. See web_server.py
|
||||
// _start_parent_death_watchdog.
|
||||
// Exact parent identity lets the backend self-exit after an unclean
|
||||
// Desktop death without mistaking a reused PID for its owner.
|
||||
HERMES_PARENT_PID: String(process.pid),
|
||||
HERMES_PARENT_START_MARKER: parentStartMarker,
|
||||
HERMES_PARENT_NONCE: backendNonce,
|
||||
HERMES_WEB_DIST: webDist,
|
||||
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
|
||||
},
|
||||
@@ -8613,10 +8889,13 @@ async function startHermes() {
|
||||
})
|
||||
)
|
||||
|
||||
await claimBackendChild(hermesProcess, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce)
|
||||
const processOwner = backendConnectionState.attachProcess(connectionAttempt, hermesProcess)
|
||||
|
||||
if (!processOwner) {
|
||||
stopBackendChild(hermesProcess)
|
||||
await waitForBackendExit(hermesProcess)
|
||||
releaseBackendChild(hermesProcess)
|
||||
throw new Error('Hermes backend start was superseded by a newer connection attempt.')
|
||||
}
|
||||
|
||||
@@ -8630,6 +8909,8 @@ async function startHermes() {
|
||||
})
|
||||
|
||||
hermesProcess.once('error', error => {
|
||||
releaseBackendChild(hermesProcess)
|
||||
|
||||
if (!backendConnectionState.clearForCurrentProcess(processOwner)) {
|
||||
rememberLog(`Ignoring stale Hermes backend error: ${error.message}`)
|
||||
rejectBackendStart?.(new Error('Hermes backend start was superseded by a newer connection attempt.'))
|
||||
@@ -8651,6 +8932,8 @@ async function startHermes() {
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
hermesProcess.once('exit', (code, signal) => {
|
||||
releaseBackendChild(hermesProcess)
|
||||
|
||||
if (!backendConnectionState.clearForCurrentProcess(processOwner)) {
|
||||
rememberLog(`Ignoring stale Hermes backend exit (${signal || code})`)
|
||||
|
||||
@@ -8741,11 +9024,15 @@ async function startHermes() {
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
})().catch(error => {
|
||||
})().catch(async error => {
|
||||
if (!backendConnectionState.clearPromiseForAttempt(connectionAttempt)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const failedProcess = backendConnectionState.invalidate()
|
||||
stopBackendChild(failedProcess)
|
||||
await waitForBackendExit(failedProcess)
|
||||
|
||||
if (error instanceof FirstRunSetupResetError) {
|
||||
throw error
|
||||
}
|
||||
@@ -9992,7 +10279,7 @@ function createWindow() {
|
||||
|
||||
try {
|
||||
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
|
||||
app.exit(0)
|
||||
void exitAfterBackendShutdown(0)
|
||||
} catch (err) {
|
||||
rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`)
|
||||
}
|
||||
@@ -12750,6 +13037,14 @@ app.on('before-quit', event => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!backendQuitTeardownDone) {
|
||||
event.preventDefault()
|
||||
void backendShutdown.run().finally(() => {
|
||||
backendQuitTeardownDone = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) {
|
||||
event.preventDefault()
|
||||
sshBootstrapCoordinator.cancelAll()
|
||||
@@ -12824,8 +13119,7 @@ app.on('before-quit', event => {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
|
||||
stopBackendChild(backendConnectionState.getProcess())
|
||||
stopAllPoolBackends()
|
||||
void backendShutdown.run()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
@@ -184,7 +184,13 @@ registry.registerMany([
|
||||
// NO minHeight: a tool panel drags all the way down to its collapsed
|
||||
// header (the sash floors it at COLLAPSED_ZONE_PX and folds the zone to
|
||||
// its rail there). A real floor left a sliver of unusable terminal.
|
||||
data: { placement: 'bottom', height: '20vh', maxHeight: '80vh', revealOnPreset: true },
|
||||
data: {
|
||||
placement: 'bottom',
|
||||
height: '20vh',
|
||||
maxHeight: '80vh',
|
||||
revealOnPreset: true,
|
||||
lifecycleKeepAlive: true
|
||||
},
|
||||
render: () => <WiredPane part="terminal" />
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
setCurrentServiceTier,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
@@ -377,6 +378,10 @@ function assistantError(id: string, error: string): ChatMessage {
|
||||
return { id, role: 'assistant', parts: [], error, pending: false }
|
||||
}
|
||||
|
||||
function transcriptForCache(id: string): ChatMessage[] {
|
||||
return [userMessage(`${id}-user`, id), assistantText(`${id}-assistant`, `reply ${id}`)]
|
||||
}
|
||||
|
||||
interface ViewHarnessProps {
|
||||
activeSessionId: string | null
|
||||
onReady: (cache: Cache) => void
|
||||
@@ -405,6 +410,7 @@ describe('useSessionStateCache — cross-thread error isolation', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$messages.set([])
|
||||
$sessionStates.set({})
|
||||
})
|
||||
|
||||
it('does not leak a failed turn into another thread on switch', () => {
|
||||
@@ -475,6 +481,27 @@ describe('useSessionStateCache — cross-thread error isolation', () => {
|
||||
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts the oldest warm transcript with its reverse ownership while retaining lightweight state', () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId={null} onReady={value => (cache = value)} selectedStoredSessionId={null} />)
|
||||
|
||||
act(() => {
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
cache.updateSessionState(
|
||||
`runtime-${index}`,
|
||||
state => ({ ...state, messages: transcriptForCache(`message-${index}`) }),
|
||||
`stored-${index}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
expect(cache.sessionStateByRuntimeIdRef.current.has('runtime-0')).toBe(false)
|
||||
expect(cache.runtimeIdByStoredSessionIdRef.current.has('stored-0')).toBe(false)
|
||||
expect($sessionStates.get()['runtime-0']).toMatchObject({ storedSessionId: 'stored-0', busy: false })
|
||||
expect($sessionStates.get()['runtime-0']?.messages).toEqual([])
|
||||
expect(cache.getRuntimeIdForStoredSession('stored-24')).toBe('runtime-24')
|
||||
})
|
||||
|
||||
it('only returns a runtime whose cached state owns the requested stored session', () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId={null} onReady={value => (cache = value)} selectedStoredSessionId={null} />)
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { publishSessionState } from '@/store/session-states'
|
||||
import { $sessionTiles, publishSessionState, releaseSessionTranscript } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
import { SessionStateCache } from '../session-state-cache'
|
||||
|
||||
import { chatMessageArraysEquivalent } from './use-session-actions/utils'
|
||||
|
||||
@@ -54,6 +55,7 @@ export function useSessionStateCache({
|
||||
setMessages
|
||||
}: SessionStateCacheOptions) {
|
||||
const busy = useStore($busy)
|
||||
const sessionTiles = useStore($sessionTiles)
|
||||
const activeSessionIdRef = useRef<string | null>(activeSessionId)
|
||||
const selectedStoredSessionIdRef = useRef<string | null>(selectedStoredSessionId)
|
||||
|
||||
@@ -81,8 +83,35 @@ export function useSessionStateCache({
|
||||
selectedStoredSessionIdRef.current = selectedStoredSessionId
|
||||
}
|
||||
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||
const sessionStateByRuntimeIdRef = useRef<SessionStateCache>(null!)
|
||||
|
||||
if (sessionStateByRuntimeIdRef.current === null) {
|
||||
sessionStateByRuntimeIdRef.current = new SessionStateCache({
|
||||
isReferenced: (runtimeId, state) =>
|
||||
runtimeId === activeSessionIdRef.current ||
|
||||
state.storedSessionId === selectedStoredSessionIdRef.current ||
|
||||
$sessionTiles
|
||||
.get()
|
||||
.some(
|
||||
tile =>
|
||||
tile.runtimeId === runtimeId ||
|
||||
(state.storedSessionId !== null && tile.storedSessionId === state.storedSessionId)
|
||||
),
|
||||
onEvict: (runtimeId, state) => {
|
||||
// Ownership is removed with the transcript, but only if both sides still
|
||||
// describe this exact binding. A recycled runtime must not erase its
|
||||
// new owner's reverse entry.
|
||||
if (state.storedSessionId && runtimeIdByStoredSessionIdRef.current.get(state.storedSessionId) === runtimeId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(state.storedSessionId)
|
||||
}
|
||||
|
||||
releaseSessionTranscript(runtimeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sessionStateCache = sessionStateByRuntimeIdRef.current
|
||||
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
|
||||
const viewSyncRafRef = useRef<number | null>(null)
|
||||
// Runtime id whose transcript currently occupies `$messages` — lets the
|
||||
@@ -94,58 +123,62 @@ export function useSessionStateCache({
|
||||
setMutableRef(busyRef, busy)
|
||||
}, [busy, busyRef])
|
||||
|
||||
const ensureSessionState = useCallback((sessionId: string, storedSessionId?: string | null) => {
|
||||
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
|
||||
const ensureSessionState = useCallback(
|
||||
(sessionId: string, storedSessionId?: string | null) => {
|
||||
const existing = sessionStateCache.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
if (storedSessionId !== undefined && storedSessionId !== existing.storedSessionId) {
|
||||
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
|
||||
// state object rather than mutating in place — updateSessionState needs
|
||||
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
|
||||
const updated = { ...existing, storedSessionId }
|
||||
if (existing) {
|
||||
if (storedSessionId !== undefined && storedSessionId !== existing.storedSessionId) {
|
||||
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
|
||||
// state object rather than mutating in place — updateSessionState needs
|
||||
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
|
||||
const updated = { ...existing, storedSessionId }
|
||||
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, updated)
|
||||
// Drop the obsolete stored→runtime reverse mapping as soon as the id
|
||||
// rotates (e.g. auto-compression forks a continuation). Leaving the
|
||||
// stale key lets getRuntimeIdForStoredSession resolve the old stored id
|
||||
// to this runtime, which the compression route-follow logic relies on
|
||||
// being absent. The rotation signal was previously emitted centrally
|
||||
// from handleTransition (session-states.ts), but updateSessionState
|
||||
// now skips publishSessionState (and thus handleTransition) when the
|
||||
// updater is a no-op — fire it here so the route-follow effect still
|
||||
// tracks compression without needing a dummy state write.
|
||||
if (existing.storedSessionId && existing.storedSessionId !== storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(existing.storedSessionId)
|
||||
|
||||
// Drop the obsolete stored→runtime reverse mapping as soon as the id
|
||||
// rotates (e.g. auto-compression forks a continuation). Leaving the
|
||||
// stale key lets getRuntimeIdForStoredSession resolve the old stored id
|
||||
// to this runtime, which the compression route-follow logic relies on
|
||||
// being absent. The rotation signal was previously emitted centrally
|
||||
// from handleTransition (session-states.ts), but updateSessionState
|
||||
// now skips publishSessionState (and thus handleTransition) when the
|
||||
// updater is a no-op — fire it here so the route-follow effect still
|
||||
// tracks compression without needing a dummy state write.
|
||||
if (existing.storedSessionId && existing.storedSessionId !== storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(existing.storedSessionId)
|
||||
|
||||
// A rotation event needs a real next id — a null/cleared stored id
|
||||
// is a detach, not a rotation the route-follow effect should chase.
|
||||
if (storedSessionId && sessionId === $activeSessionId.get()) {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: storedSessionId,
|
||||
previousStoredSessionId: existing.storedSessionId,
|
||||
runtimeSessionId: sessionId
|
||||
})
|
||||
// A rotation event needs a real next id — a null/cleared stored id
|
||||
// is a detach, not a rotation the route-follow effect should chase.
|
||||
if (storedSessionId && sessionId === $activeSessionId.get()) {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: storedSessionId,
|
||||
previousStoredSessionId: existing.storedSessionId,
|
||||
runtimeSessionId: sessionId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
sessionStateCache.set(sessionId, updated)
|
||||
}
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
}
|
||||
return sessionStateCache.get(sessionId)!
|
||||
}
|
||||
|
||||
return sessionStateByRuntimeIdRef.current.get(sessionId)!
|
||||
}
|
||||
const created = createClientSessionState(storedSessionId ?? null)
|
||||
|
||||
const created = createClientSessionState(storedSessionId ?? null)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, created)
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
}
|
||||
sessionStateCache.set(sessionId, created)
|
||||
|
||||
return created
|
||||
}, [])
|
||||
return created
|
||||
},
|
||||
[sessionStateCache]
|
||||
)
|
||||
|
||||
const resetViewSync = useCallback(() => {
|
||||
// Drop any RAF-pending transcript stage so a backgrounded turn cannot
|
||||
@@ -299,7 +332,7 @@ export function useSessionStateCache({
|
||||
return previous
|
||||
}
|
||||
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
sessionStateCache.set(sessionId, next)
|
||||
// Crash-survivable turn progress: journal the running turn's visible
|
||||
// tail (throttled localStorage write; cleared the moment the turn
|
||||
// settles) so a renderer/app death mid-turn can be recovered on resume.
|
||||
@@ -308,24 +341,32 @@ export function useSessionStateCache({
|
||||
// (watchdog, settle grace, unread marker, compression id rotation) inside
|
||||
// publishSessionState — no manual transition call needed.
|
||||
publishSessionState(sessionId, next)
|
||||
sessionStateCache.prune()
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
},
|
||||
[ensureSessionState, syncSessionStateToView]
|
||||
[ensureSessionState, sessionStateCache, syncSessionStateToView]
|
||||
)
|
||||
|
||||
const getRuntimeIdForStoredSession = useCallback((storedSessionId: string): string | null => {
|
||||
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
useEffect(() => {
|
||||
sessionStateCache.prune()
|
||||
}, [activeSessionId, selectedStoredSessionId, sessionStateCache, sessionTiles])
|
||||
|
||||
if (!runtimeId) {
|
||||
return null
|
||||
}
|
||||
const getRuntimeIdForStoredSession = useCallback(
|
||||
(storedSessionId: string): string | null => {
|
||||
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
|
||||
const runtimeState = sessionStateByRuntimeIdRef.current.get(runtimeId)
|
||||
if (!runtimeId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
|
||||
}, [])
|
||||
const runtimeState = sessionStateCache.get(runtimeId)
|
||||
|
||||
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
|
||||
},
|
||||
[sessionStateCache]
|
||||
)
|
||||
|
||||
return {
|
||||
activeSessionIdRef,
|
||||
@@ -334,7 +375,7 @@ export function useSessionStateCache({
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
sessionStateByRuntimeIdRef: sessionStateByRuntimeIdRef as MutableRefObject<Map<string, ClientSessionState>>,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $sessionStates, $sessionTiles, releaseSessionTranscript } from '@/store/session-states'
|
||||
|
||||
import { SessionStateCache } from './session-state-cache'
|
||||
|
||||
function transcript(id: string, text = id): ChatMessage[] {
|
||||
return [
|
||||
{ id: `${id}-user`, role: 'user', parts: [{ type: 'text', text }] },
|
||||
{ id: `${id}-assistant`, role: 'assistant', parts: [{ type: 'text', text: `reply ${text}` }] }
|
||||
]
|
||||
}
|
||||
|
||||
function settled(storedSessionId: string, text = storedSessionId): ClientSessionState {
|
||||
return { ...createClientSessionState(storedSessionId), messages: transcript(storedSessionId, text) }
|
||||
}
|
||||
|
||||
describe('SessionStateCache', () => {
|
||||
beforeEach(() => {
|
||||
$sessionStates.set({})
|
||||
$sessionTiles.set([])
|
||||
})
|
||||
|
||||
it('bounds warm settled transcripts by LRU count and cleans ownership atomically', () => {
|
||||
const owners = new Map<string, string>()
|
||||
const evicted: string[] = []
|
||||
|
||||
const cache = new SessionStateCache(
|
||||
{
|
||||
isReferenced: () => false,
|
||||
onEvict: (runtimeId, state) => {
|
||||
if (state.storedSessionId && owners.get(state.storedSessionId) === runtimeId) {
|
||||
owners.delete(state.storedSessionId)
|
||||
}
|
||||
|
||||
evicted.push(runtimeId)
|
||||
}
|
||||
},
|
||||
{ maxBytes: Number.POSITIVE_INFINITY, maxCount: 2 }
|
||||
)
|
||||
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
owners.set(`stored-${id}`, `runtime-${id}`)
|
||||
cache.set(`runtime-${id}`, settled(`stored-${id}`))
|
||||
}
|
||||
|
||||
// A read makes A warmer than B, so B is the oldest when pruning.
|
||||
cache.get('runtime-a')
|
||||
cache.prune()
|
||||
|
||||
expect([...cache.keys()].sort()).toEqual(['runtime-a', 'runtime-c'])
|
||||
expect(evicted).toEqual(['runtime-b'])
|
||||
expect(owners.has('stored-b')).toBe(false)
|
||||
|
||||
// A recycled reverse mapping is not owned by the evicted runtime and must
|
||||
// survive cleanup.
|
||||
owners.set('stored-a', 'runtime-new-owner')
|
||||
cache.set('runtime-d', settled('stored-d'))
|
||||
owners.set('stored-d', 'runtime-d')
|
||||
cache.prune()
|
||||
expect(owners.get('stored-a')).toBe('runtime-new-owner')
|
||||
})
|
||||
|
||||
it('uses transcript bytes as well as count', () => {
|
||||
const evicted: string[] = []
|
||||
|
||||
const cache = new SessionStateCache(
|
||||
{ isReferenced: () => false, onEvict: runtimeId => evicted.push(runtimeId) },
|
||||
{ maxBytes: 600, maxCount: 10 }
|
||||
)
|
||||
|
||||
cache.set('small', settled('small', 'x'))
|
||||
cache.set('large', settled('large', 'x'.repeat(500)))
|
||||
cache.prune()
|
||||
|
||||
expect(evicted).toEqual(['small', 'large'])
|
||||
expect(cache.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['active', (state: ClientSessionState) => state, true],
|
||||
['tiled', (state: ClientSessionState) => state, true],
|
||||
['busy', (state: ClientSessionState) => ({ ...state, busy: true }), false],
|
||||
['awaiting', (state: ClientSessionState) => ({ ...state, awaitingResponse: true }), false],
|
||||
['needs input', (state: ClientSessionState) => ({ ...state, needsInput: true }), false]
|
||||
])('never evicts %s transcripts', (_label, decorate, referenced) => {
|
||||
const protectedState = decorate(settled('protected'))
|
||||
|
||||
const cache = new SessionStateCache(
|
||||
{
|
||||
isReferenced: runtimeId => referenced && runtimeId === 'protected',
|
||||
onEvict: () => undefined
|
||||
},
|
||||
{ maxBytes: 0, maxCount: 0 }
|
||||
)
|
||||
|
||||
cache.set('protected', protectedState)
|
||||
cache.prune()
|
||||
|
||||
expect(cache.get('protected')).toBe(protectedState)
|
||||
})
|
||||
|
||||
it('keeps unsaved drafts and pending messages out of the eviction pool', () => {
|
||||
const draft = { ...createClientSessionState(null), messages: transcript('draft') }
|
||||
const pending = settled('pending')
|
||||
pending.messages = [{ id: 'pending-assistant', role: 'assistant', parts: [], pending: true }]
|
||||
|
||||
const cache = new SessionStateCache(
|
||||
{ isReferenced: () => false, onEvict: () => undefined },
|
||||
{ maxBytes: 0, maxCount: 0 }
|
||||
)
|
||||
|
||||
cache.set('draft', draft)
|
||||
cache.set('pending', pending)
|
||||
cache.prune()
|
||||
|
||||
expect(cache.has('draft')).toBe(true)
|
||||
expect(cache.has('pending')).toBe(true)
|
||||
})
|
||||
|
||||
it('retains lightweight status while releasing an evicted transcript', () => {
|
||||
const state = { ...settled('stored'), needsInput: false }
|
||||
$sessionStates.set({ runtime: state })
|
||||
|
||||
releaseSessionTranscript('runtime')
|
||||
|
||||
expect($sessionStates.get().runtime).toMatchObject({ storedSessionId: 'stored', busy: false, needsInput: false })
|
||||
expect($sessionStates.get().runtime.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ClientSessionState } from '../types'
|
||||
|
||||
export const DEFAULT_WARM_SESSION_TRANSCRIPT_COUNT = 24
|
||||
export const DEFAULT_WARM_SESSION_TRANSCRIPT_BYTES = 32 * 1024 * 1024
|
||||
|
||||
interface SessionStateCacheLimits {
|
||||
maxBytes?: number
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
interface SessionStateCacheCallbacks {
|
||||
isReferenced: (runtimeId: string, state: ClientSessionState) => boolean
|
||||
onEvict: (runtimeId: string, state: ClientSessionState) => void
|
||||
}
|
||||
|
||||
function transcriptBytes(state: ClientSessionState): number {
|
||||
if (state.messages.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// JS strings occupy two bytes per UTF-16 code unit. JSON also accounts for
|
||||
// ids, part tags, tool payloads, attachment metadata, and error text without
|
||||
// retaining a second serialized copy in the cache.
|
||||
return JSON.stringify(state.messages).length * 2
|
||||
}
|
||||
|
||||
function hasDraftOrInFlightMessage(state: ClientSessionState): boolean {
|
||||
return state.messages.some(message => message.pending === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime state map whose settled, unreferenced transcripts form a weighted
|
||||
* LRU. Live/visible states and unsaved drafts are outside both limits.
|
||||
*/
|
||||
export class SessionStateCache extends Map<string, ClientSessionState> {
|
||||
readonly #callbacks: SessionStateCacheCallbacks
|
||||
readonly #maxBytes: number
|
||||
readonly #maxCount: number
|
||||
readonly #recency = new Map<string, number>()
|
||||
#clock = 0
|
||||
|
||||
constructor(callbacks: SessionStateCacheCallbacks, limits: SessionStateCacheLimits = {}) {
|
||||
super()
|
||||
this.#callbacks = callbacks
|
||||
this.#maxBytes = limits.maxBytes ?? DEFAULT_WARM_SESSION_TRANSCRIPT_BYTES
|
||||
this.#maxCount = limits.maxCount ?? DEFAULT_WARM_SESSION_TRANSCRIPT_COUNT
|
||||
}
|
||||
|
||||
override get(runtimeId: string): ClientSessionState | undefined {
|
||||
const state = super.get(runtimeId)
|
||||
|
||||
if (state) {
|
||||
this.#touch(runtimeId)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
override set(runtimeId: string, state: ClientSessionState): this {
|
||||
super.set(runtimeId, state)
|
||||
this.#touch(runtimeId)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
override delete(runtimeId: string): boolean {
|
||||
this.#recency.delete(runtimeId)
|
||||
|
||||
return super.delete(runtimeId)
|
||||
}
|
||||
|
||||
override clear(): void {
|
||||
this.#recency.clear()
|
||||
super.clear()
|
||||
}
|
||||
|
||||
prune(): void {
|
||||
const candidates: Array<{ bytes: number; runtimeId: string; state: ClientSessionState; touched: number }> = []
|
||||
let bytes = 0
|
||||
|
||||
for (const [runtimeId, state] of this.entries()) {
|
||||
if (!this.#isWarmSettled(runtimeId, state)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const weight = transcriptBytes(state)
|
||||
candidates.push({ bytes: weight, runtimeId, state, touched: this.#recency.get(runtimeId) ?? 0 })
|
||||
bytes += weight
|
||||
}
|
||||
|
||||
let count = candidates.length
|
||||
|
||||
if (count <= this.#maxCount && bytes <= this.#maxBytes) {
|
||||
return
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.touched - b.touched)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (count <= this.#maxCount && bytes <= this.#maxBytes) {
|
||||
break
|
||||
}
|
||||
|
||||
// References and activity can change between insertion and pruning.
|
||||
const current = super.get(candidate.runtimeId)
|
||||
|
||||
if (current !== candidate.state || !this.#isWarmSettled(candidate.runtimeId, current)) {
|
||||
continue
|
||||
}
|
||||
|
||||
super.delete(candidate.runtimeId)
|
||||
this.#recency.delete(candidate.runtimeId)
|
||||
count -= 1
|
||||
bytes -= candidate.bytes
|
||||
this.#callbacks.onEvict(candidate.runtimeId, candidate.state)
|
||||
}
|
||||
}
|
||||
|
||||
#isWarmSettled(runtimeId: string, state: ClientSessionState): boolean {
|
||||
return (
|
||||
Boolean(state.storedSessionId) &&
|
||||
state.messages.length > 0 &&
|
||||
!state.busy &&
|
||||
!state.awaitingResponse &&
|
||||
!state.needsInput &&
|
||||
!hasDraftOrInFlightMessage(state) &&
|
||||
!this.#callbacks.isReferenced(runtimeId, state)
|
||||
)
|
||||
}
|
||||
|
||||
#touch(runtimeId: string): void {
|
||||
this.#clock += 1
|
||||
this.#recency.set(runtimeId, this.#clock)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildGroups,
|
||||
firstVisibleGroupIndex,
|
||||
HIDDEN_TRANSCRIPT_RENDER_BUDGET,
|
||||
LIVE_TAIL_MIN_GROUPS,
|
||||
LIVE_TAIL_PARTS,
|
||||
liveTailStart,
|
||||
type MessageGroup,
|
||||
resolveThreadScrollTarget
|
||||
resolveThreadScrollTarget,
|
||||
transcriptPaneBudget
|
||||
} from './list'
|
||||
|
||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
||||
@@ -15,6 +17,14 @@ import {
|
||||
const signature = (rows: [string, string, number][]) =>
|
||||
rows.map(([id, role, weight], index) => `${index}:${id}:${role}:${weight}`).join('\n')
|
||||
|
||||
describe('transcriptPaneBudget', () => {
|
||||
it('uses a fixed live-tail budget while hidden instead of charging every mounted transcript', () => {
|
||||
expect(transcriptPaneBudget(1, true)).toBe(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
|
||||
expect(transcriptPaneBudget(4, true)).toBe(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
|
||||
expect(transcriptPaneBudget(1, false)).toBeGreaterThan(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGroups', () => {
|
||||
it('returns no groups for an empty signature', () => {
|
||||
expect(buildGroups('')).toEqual([])
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'react'
|
||||
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { usePaneLifecycle } from '@/components/pane-shell/pane-visibility'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { messagePaintWeight } from '@/lib/render-weight'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -96,6 +97,15 @@ const MIN_VISIBLE_GROUPS = 8
|
||||
// interruptibly, so the only thing a smaller budget changes is how much work
|
||||
// blocks the click-to-paint path.
|
||||
const FIRST_PAINT_BUDGET = 20
|
||||
// A hot-hidden transcript is retained for instant tab return, but keeping its
|
||||
// full scrollback mounted defeats the bounded pane cache. Preserve only the
|
||||
// live tail while hidden; revealing it resumes stepped backfill.
|
||||
export const HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40
|
||||
|
||||
export const transcriptPaneBudget = (mountedPanes: number, hidden: boolean): number =>
|
||||
hidden
|
||||
? HIDDEN_TRANSCRIPT_RENDER_BUDGET
|
||||
: Math.max(Math.ceil(RENDER_BUDGET / Math.max(1, mountedPanes)), RENDER_BUDGET / 4)
|
||||
// Units the backfill adds per committed step (see the backfill effect). ~8-15
|
||||
// ordinary turns or 1-2 tool-heavy ones per frame — big enough to fill a page
|
||||
// in ~10 frames, small enough that no single commit approaches a frame budget.
|
||||
@@ -354,8 +364,10 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
}, [])
|
||||
|
||||
const mountedPanes = useStore($mountedTranscriptPanes)
|
||||
// This pane's share of the render budget — see $mountedTranscriptPanes.
|
||||
const paneBudget = Math.max(Math.ceil(RENDER_BUDGET / Math.max(1, mountedPanes)), RENDER_BUDGET / 4)
|
||||
const paneLifecycle = usePaneLifecycle()
|
||||
// Hidden panes retain only a live-tail budget. Visible panes share the normal
|
||||
// screen budget; a reveal backfills older rows in bounded transition steps.
|
||||
const paneBudget = transcriptPaneBudget(mountedPanes, paneLifecycle === 'hot-hidden')
|
||||
|
||||
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
|
||||
|
||||
@@ -379,6 +391,10 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
setBudgetSessionKey(sessionKey)
|
||||
setHadGroups(hasGroups)
|
||||
setRenderBudget(FIRST_PAINT_BUDGET)
|
||||
} else if (renderBudget > paneBudget) {
|
||||
// Apply the hidden budget during render so React never first commits the
|
||||
// stale full transcript after this pane moves to the background.
|
||||
setRenderBudget(paneBudget)
|
||||
} else if (hadGroups !== hasGroups) {
|
||||
setHadGroups(hasGroups)
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { emptyPaneLifecycleState, reconcilePaneLifecycle } from './pane-lifecycle'
|
||||
|
||||
const visit = (state: ReturnType<typeof emptyPaneLifecycleState>, activeId: string, paneIds: string[]) =>
|
||||
reconcilePaneLifecycle(state, { activeId, paneIds })
|
||||
|
||||
describe('per-zone pane lifecycle', () => {
|
||||
it('keeps a small recent hidden set and parks older panes', () => {
|
||||
let state = emptyPaneLifecycleState()
|
||||
|
||||
state = visit(state, 'a', ['a', 'b', 'c', 'd'])
|
||||
state = visit(state, 'b', ['a', 'b', 'c', 'd'])
|
||||
state = visit(state, 'c', ['a', 'b', 'c', 'd'])
|
||||
state = visit(state, 'd', ['a', 'b', 'c', 'd'])
|
||||
|
||||
expect(state.entries).toMatchObject({
|
||||
a: { lifecycle: 'parked' },
|
||||
b: { lifecycle: 'hot-hidden' },
|
||||
c: { lifecycle: 'hot-hidden' },
|
||||
d: { lifecycle: 'visible' }
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks recency independently for each zone state', () => {
|
||||
const zoneA = visit(visit(emptyPaneLifecycleState(), 'a', ['a', 'b']), 'b', ['a', 'b'])
|
||||
const zoneB = visit(emptyPaneLifecycleState(), 'x', ['x', 'y'])
|
||||
|
||||
expect(zoneA.entries.a.lifecycle).toBe('hot-hidden')
|
||||
expect(zoneA.entries.b.lifecycle).toBe('visible')
|
||||
expect(zoneB.entries.x.lifecycle).toBe('visible')
|
||||
expect(zoneB.entries.y).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a hidden terminal alive outside the normal cap', () => {
|
||||
let state = emptyPaneLifecycleState()
|
||||
const paneIds = ['terminal', 'a', 'b', 'c']
|
||||
|
||||
const reconcile = (activeId: string) => {
|
||||
state = reconcilePaneLifecycle(state, {
|
||||
activeId,
|
||||
hotHiddenCap: 1,
|
||||
keepAlive: id => id === 'terminal',
|
||||
paneIds
|
||||
})
|
||||
}
|
||||
|
||||
reconcile('terminal')
|
||||
reconcile('a')
|
||||
reconcile('b')
|
||||
reconcile('c')
|
||||
|
||||
expect(state.entries.terminal.lifecycle).toBe('hot-hidden')
|
||||
expect(state.entries.b.lifecycle).toBe('hot-hidden')
|
||||
expect(state.entries.a.lifecycle).toBe('parked')
|
||||
})
|
||||
|
||||
it('forgets panes that leave a zone and remounts a parked pane when selected', () => {
|
||||
let state = visit(emptyPaneLifecycleState(), 'a', ['a', 'b', 'c', 'd'])
|
||||
|
||||
for (const active of ['b', 'c', 'd']) {
|
||||
state = visit(state, active, ['a', 'b', 'c', 'd'])
|
||||
}
|
||||
|
||||
expect(state.entries.a.lifecycle).toBe('parked')
|
||||
|
||||
state = visit(state, 'a', ['a', 'b', 'c'])
|
||||
|
||||
expect(state.entries.a.lifecycle).toBe('visible')
|
||||
expect(state.entries.d).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
export type PaneLifecycle = 'visible' | 'hot-hidden' | 'parked'
|
||||
|
||||
export const DEFAULT_HOT_HIDDEN_PANE_CAP = 2
|
||||
|
||||
interface PaneLifecycleEntry {
|
||||
lifecycle: PaneLifecycle
|
||||
lastVisible: number
|
||||
}
|
||||
|
||||
export interface PaneLifecycleState {
|
||||
clock: number
|
||||
entries: Record<string, PaneLifecycleEntry>
|
||||
}
|
||||
|
||||
export const emptyPaneLifecycleState = (): PaneLifecycleState => ({ clock: 0, entries: {} })
|
||||
|
||||
interface ReconcilePaneLifecycleOptions {
|
||||
activeId: string
|
||||
hotHiddenCap?: number
|
||||
keepAlive?: (id: string) => boolean
|
||||
paneIds: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile one zone's mounted pane cache.
|
||||
*
|
||||
* The foreground pane is visible, the most recently visible inactive panes stay
|
||||
* hot up to a small cap, and the rest park (unmount). Explicit keep-alive panes
|
||||
* such as the terminal remain hot outside that cap so hiding UI never kills the
|
||||
* stateful resource they host.
|
||||
*/
|
||||
export function reconcilePaneLifecycle(
|
||||
previous: PaneLifecycleState,
|
||||
{ activeId, hotHiddenCap = DEFAULT_HOT_HIDDEN_PANE_CAP, keepAlive = () => false, paneIds }: ReconcilePaneLifecycleOptions
|
||||
): PaneLifecycleState {
|
||||
const present = new Set(paneIds)
|
||||
const entries: Record<string, PaneLifecycleEntry> = {}
|
||||
let clock = previous.clock
|
||||
|
||||
for (const id of paneIds) {
|
||||
const prior = previous.entries[id]
|
||||
|
||||
if (prior) {
|
||||
entries[id] = { ...prior, lifecycle: 'parked' }
|
||||
}
|
||||
}
|
||||
|
||||
if (present.has(activeId)) {
|
||||
const prior = previous.entries[activeId]
|
||||
|
||||
if (!prior || prior.lifecycle !== 'visible') {
|
||||
clock += 1
|
||||
}
|
||||
|
||||
entries[activeId] = { lifecycle: 'visible', lastVisible: clock }
|
||||
}
|
||||
|
||||
const inactive = paneIds
|
||||
.filter(id => id !== activeId && entries[id])
|
||||
.sort((a, b) => entries[b].lastVisible - entries[a].lastVisible)
|
||||
|
||||
for (const id of inactive.filter(keepAlive)) {
|
||||
entries[id] = { ...entries[id], lifecycle: 'hot-hidden' }
|
||||
}
|
||||
|
||||
for (const id of inactive.filter(id => !keepAlive(id)).slice(0, Math.max(0, hotHiddenCap))) {
|
||||
entries[id] = { ...entries[id], lifecycle: 'hot-hidden' }
|
||||
}
|
||||
|
||||
return { clock, entries }
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { PaneLifecycle } from './pane-lifecycle'
|
||||
|
||||
/** Marks a mounted-but-hidden pane layer (an inactive tab in a stack). */
|
||||
export const PANE_HIDDEN_ATTR = 'data-pane-hidden'
|
||||
|
||||
@@ -28,6 +30,12 @@ export const PaneVisibleContext = createContext(true)
|
||||
|
||||
export const usePaneVisible = (): boolean => useContext(PaneVisibleContext)
|
||||
|
||||
/** Lifecycle face for expensive descendants. Outside a pane tree the surface is
|
||||
* visible; hot-hidden panes stay mounted but can lower their render budget. */
|
||||
export const PaneLifecycleContext = createContext<PaneLifecycle>('visible')
|
||||
|
||||
export const usePaneLifecycle = (): PaneLifecycle => useContext(PaneLifecycleContext)
|
||||
|
||||
/** Fallback group key for a surface rendered outside the layout tree (secondary
|
||||
* windows, plain routes) — one bucket, since there are no sibling zones there
|
||||
* to tell apart. */
|
||||
|
||||
@@ -59,6 +59,10 @@ interface PaneChrome extends PaneSizing {
|
||||
/** Spawn corner for `placement: 'floating'` (default `'top-right'`). The
|
||||
* pane also TRACKS that corner's edges when the window resizes. */
|
||||
anchor?: FloatingAnchor
|
||||
/** Keep this pane mounted when hidden even after the zone's bounded hot
|
||||
* cache fills. Reserved for stateful resources whose lifetime must not track
|
||||
* tab visibility (for example terminal PTYs). */
|
||||
lifecycleKeepAlive?: boolean
|
||||
/** No Close in the tab menu — the one surface the app can't lose (the
|
||||
* main workspace). Session tiles share `placement: 'main'` but close. */
|
||||
uncloseable?: boolean
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
|
||||
|
||||
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -32,7 +32,8 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
import { useWindowControlsOverlap } from '../../geometry'
|
||||
import { hiddenPaneProps, PaneGroupContext, PaneVisibleContext } from '../../pane-visibility'
|
||||
import { emptyPaneLifecycleState, reconcilePaneLifecycle } from '../../pane-lifecycle'
|
||||
import { hiddenPaneProps, PaneGroupContext, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility'
|
||||
import type { DropPosition, GroupNode } from '../model'
|
||||
import {
|
||||
$dropHint,
|
||||
@@ -212,29 +213,24 @@ export function TreeGroup({
|
||||
const active = paneFor(activeId)
|
||||
const isEmpty = node.panes.length === 0
|
||||
|
||||
// KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted —
|
||||
// an inactive tab merely hides (visibility), it does not unmount. Remounting
|
||||
// on every tab switch re-measured and re-scrolled the content from scratch
|
||||
// (the thread visibly layout-shifted each time a session tab was revisited).
|
||||
// Lazy on purpose: a pane first mounts when first activated, so a
|
||||
// boot-restored tab stack doesn't resume every session up front.
|
||||
const everActivePanesRef = useRef<Set<string>>(new Set())
|
||||
// BOUNDED KEEP-ALIVE: the active pane is visible, a small per-zone LRU stays
|
||||
// hot-hidden, and older panes park (unmount). This preserves fast tab
|
||||
// round-trips without letting a long-lived zone pin every transcript it has
|
||||
// ever visited. Stateful resources can opt out of parking (the terminal keeps
|
||||
// its PTY alive while hidden). Lazy remains deliberate: restored background
|
||||
// tabs have no lifecycle entry and do not mount until first activation.
|
||||
const lifecycleRef = useRef(emptyPaneLifecycleState())
|
||||
|
||||
useEffect(() => {
|
||||
if (!node.minimized && !isEmpty) {
|
||||
everActivePanesRef.current.add(activeId)
|
||||
}
|
||||
if (!node.minimized && !isEmpty) {
|
||||
lifecycleRef.current = reconcilePaneLifecycle(lifecycleRef.current, {
|
||||
activeId,
|
||||
keepAlive: id => Boolean(paneChrome(paneFor(id)).lifecycleKeepAlive),
|
||||
paneIds: shown
|
||||
})
|
||||
}
|
||||
|
||||
// Prune panes that left the zone (closed / moved to another group), so a
|
||||
// long-lived zone doesn't pin stale ids forever.
|
||||
for (const id of everActivePanesRef.current) {
|
||||
if (!node.panes.includes(id)) {
|
||||
everActivePanesRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id))
|
||||
const paneLifecycle = lifecycleRef.current.entries
|
||||
const keptPanes = shown.filter(id => paneLifecycle[id] && paneLifecycle[id].lifecycle !== 'parked')
|
||||
|
||||
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
|
||||
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
|
||||
@@ -593,8 +589,8 @@ export function TreeGroup({
|
||||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* Body: the zone's pane content — every kept (ever-active) pane stays
|
||||
mounted in an absolute layer; only the active one is visible.
|
||||
{/* Body: the zone's pane content — the active pane and bounded hot-hidden
|
||||
cache stay mounted in absolute layers; parked panes are unmounted.
|
||||
`visibility` (not display) keeps the hidden pane's layout box, so
|
||||
scroll positions and measurements survive the round-trip — which also
|
||||
makes a hidden layer's rect identical to the visible one's, hence the
|
||||
@@ -627,11 +623,13 @@ export function TreeGroup({
|
||||
// Reload remounts the contribution (effects re-run, state
|
||||
// resets) while the layer — and every other tab — stays.
|
||||
<PaneGroupContext.Provider value={node.id}>
|
||||
<PaneVisibleContext.Provider value={isActive}>
|
||||
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
|
||||
<ContribRender render={pane.render} />
|
||||
</ContribBoundary>
|
||||
</PaneVisibleContext.Provider>
|
||||
<PaneLifecycleContext.Provider value={paneLifecycle[paneId]?.lifecycle ?? 'visible'}>
|
||||
<PaneVisibleContext.Provider value={isActive}>
|
||||
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
|
||||
<ContribRender render={pane.render} />
|
||||
</ContribBoundary>
|
||||
</PaneVisibleContext.Provider>
|
||||
</PaneLifecycleContext.Provider>
|
||||
</PaneGroupContext.Provider>
|
||||
) : (
|
||||
isActive && (
|
||||
|
||||
@@ -8,8 +8,8 @@ import { $sessionStates, $sessionTiles, closeSessionTile, publishSessionState }
|
||||
* The closed-tile leak: gateway events keep publishing for sessions whose
|
||||
* surface is gone, and every parked transcript taxes every later publish (map
|
||||
* spread + the status projections run per entry per message delta). A settled
|
||||
* state nothing references must leave the map; everything a surface still
|
||||
* needs must stay.
|
||||
* state nothing references must release its transcript; lightweight status
|
||||
* stays so sidebar projections remain available.
|
||||
*/
|
||||
|
||||
const state = (storedId: string, patch: Partial<ReturnType<typeof createClientSessionState>> = {}) => ({
|
||||
@@ -28,13 +28,14 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('publish-time eviction', () => {
|
||||
it('evicts a settling session no surface references, keeping its unread dot', () => {
|
||||
it('releases an unreferenced settled transcript while keeping status and its unread dot', () => {
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeUndefined()
|
||||
expect($sessionStates.get()['rt-1']?.messages).toEqual([])
|
||||
expect($sessionStates.get()['rt-1']).toMatchObject({ storedSessionId: 'stored-1', busy: false })
|
||||
// The settle transition still fired: the sidebar's unread marker landed.
|
||||
expect($unreadFinishedSessionIds.get()).toContain('stored-1')
|
||||
})
|
||||
@@ -100,8 +101,9 @@ describe('closeSessionTile eviction', () => {
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
|
||||
// ... and its settle publish is what evicts it.
|
||||
// ... and its settle publish releases only the heavy transcript.
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
expect($sessionStates.get()['rt-1']).toBeUndefined()
|
||||
expect($sessionStates.get()['rt-1']?.messages).toEqual([])
|
||||
expect($sessionStates.get()['rt-1']).toMatchObject({ storedSessionId: 'stored-1', busy: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,16 +6,39 @@ import { $layoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import type { SessionTile } from '@/store/session-states'
|
||||
import {
|
||||
$sessionStates,
|
||||
blankDraftTile,
|
||||
focusedSessionNeedsRoute,
|
||||
markSelectionRestore,
|
||||
orderTilesByTree,
|
||||
releaseSessionTranscript,
|
||||
selectionHomesToWorkspace
|
||||
} from '@/store/session-states'
|
||||
|
||||
const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
|
||||
const tilePane = (id: string) => `session-tile:${id}`
|
||||
|
||||
describe('releaseSessionTranscript', () => {
|
||||
afterEach(() => {
|
||||
$sessionStates.set({})
|
||||
})
|
||||
|
||||
it('normalizes legacy state whose messages field is undefined', () => {
|
||||
const legacy = { busy: false, storedSessionId: 'stored' } as ClientSessionState
|
||||
$sessionStates.set({ runtime: legacy })
|
||||
|
||||
expect(() => releaseSessionTranscript('runtime')).not.toThrow()
|
||||
expect($sessionStates.get().runtime).toEqual({ ...legacy, messages: [] })
|
||||
})
|
||||
|
||||
it('ignores a legacy undefined state without throwing', () => {
|
||||
$sessionStates.set({ runtime: undefined } as unknown as Record<string, ClientSessionState>)
|
||||
|
||||
expect(() => releaseSessionTranscript('runtime')).not.toThrow()
|
||||
expect($sessionStates.get()).toHaveProperty('runtime', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderTilesByTree', () => {
|
||||
it('no-ops (null) without a tree or below two tiles', () => {
|
||||
expect(orderTilesByTree(null, [tile('a'), tile('b')])).toBeNull()
|
||||
|
||||
@@ -228,15 +228,13 @@ function evictable(runtimeId: string, state: ClientSessionState): boolean {
|
||||
* is updated independently by the caller, so the visual path stays live
|
||||
* without the store churn.
|
||||
*
|
||||
* A settled state nothing references is EVICTED instead of republished:
|
||||
* gateway events keep flowing for sessions whose tile was closed mid-turn,
|
||||
* and parking each one's full transcript here forever is the leak that made
|
||||
* the app crawl after a day of tile use — every entry taxes every later
|
||||
* publish (map spread + the status-set projections). Transition side effects
|
||||
* still fire, so the closed session's settle keeps its unread dot. Only an
|
||||
* entry already in the map is evicted — a FIRST publish always lands, because
|
||||
* a resume can publish its idle state a beat before `$activeSessionId` /
|
||||
* the tile's runtime binding points at it. */
|
||||
* A settled state nothing references releases its transcript instead of
|
||||
* republishing it. Gateway events keep flowing for sessions whose tile was
|
||||
* closed mid-turn, and parking each one's full transcript here forever is the
|
||||
* leak that made the app crawl after a day of tile use. Transition side
|
||||
* effects still fire, so lightweight status and the unread dot survive. A
|
||||
* FIRST publish always lands in full because a resume can publish its idle
|
||||
* state a beat before `$activeSessionId` / the tile binding points at it. */
|
||||
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
|
||||
const current = $sessionStates.get()
|
||||
const prev = current[runtimeId] ?? null
|
||||
@@ -247,8 +245,7 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
|
||||
|
||||
if (prev && evictable(runtimeId, state)) {
|
||||
handleTransition(prev, state, runtimeId)
|
||||
const { [runtimeId]: _dropped, ...rest } = current
|
||||
$sessionStates.set(rest)
|
||||
releaseSessionTranscript(runtimeId, state)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -257,6 +254,30 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
|
||||
handleTransition(prev, state, runtimeId)
|
||||
}
|
||||
|
||||
/** Keep the cheap status projection for a cold session while releasing its
|
||||
* transcript. Unread completion is stored separately, so it survives too. */
|
||||
export function releaseSessionTranscript(runtimeId: string, state?: ClientSessionState) {
|
||||
const current = $sessionStates.get()
|
||||
|
||||
if (!(runtimeId in current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const retained = state ?? current[runtimeId]
|
||||
|
||||
// Older persisted snapshots can contain an undefined state or omit the
|
||||
// messages field. Treat either shape as already cold instead of throwing
|
||||
// while memory pressure is being relieved.
|
||||
if (!retained) {
|
||||
return
|
||||
}
|
||||
|
||||
const lightweight =
|
||||
Array.isArray(retained.messages) && retained.messages.length === 0 ? retained : { ...retained, messages: [] }
|
||||
|
||||
$sessionStates.set({ ...current, [runtimeId]: lightweight })
|
||||
}
|
||||
|
||||
export function dropSessionState(runtimeId: string) {
|
||||
// Disarm the watchdog — a dropped runtime must not fire a stale clear later.
|
||||
// Settle-grace entries are keyed by stored id and self-expire; leave them so
|
||||
|
||||
+133
-18
@@ -137,6 +137,94 @@ except ImportError:
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _process_start_marker(pid: int) -> str:
|
||||
"""Return a cross-runtime marker for the current incarnation of ``pid``.
|
||||
|
||||
``ProcessLookupError`` means the process is absent. Other failures are left
|
||||
distinct so callers can fail safe rather than killing a healthy backend.
|
||||
"""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
stat_line = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise ProcessLookupError(pid) from exc
|
||||
|
||||
# The command in field 2 may contain spaces or parentheses. Splitting
|
||||
# after its final ')' leaves field 3 at index zero and field 22 at 19.
|
||||
fields = stat_line.rsplit(")", 1)[1].strip().split()
|
||||
if len(fields) < 20 or not fields[19].isdigit():
|
||||
raise OSError(f"invalid /proc stat data for PID {pid}")
|
||||
return f"linux:{fields[19]}"
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
process_query_limited_information = 0x1000
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetProcessTimes.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.FILETIME),
|
||||
ctypes.POINTER(wintypes.FILETIME),
|
||||
ctypes.POINTER(wintypes.FILETIME),
|
||||
ctypes.POINTER(wintypes.FILETIME),
|
||||
]
|
||||
kernel32.GetProcessTimes.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
||||
if not handle:
|
||||
error = ctypes.get_last_error()
|
||||
if error in (87, 1168): # invalid parameter / not found
|
||||
raise ProcessLookupError(pid)
|
||||
raise OSError(error, f"OpenProcess failed for PID {pid}")
|
||||
|
||||
creation = wintypes.FILETIME()
|
||||
exit_time = wintypes.FILETIME()
|
||||
kernel = wintypes.FILETIME()
|
||||
user = wintypes.FILETIME()
|
||||
try:
|
||||
if not kernel32.GetProcessTimes(
|
||||
handle,
|
||||
ctypes.byref(creation),
|
||||
ctypes.byref(exit_time),
|
||||
ctypes.byref(kernel),
|
||||
ctypes.byref(user),
|
||||
):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(error, f"GetProcessTimes failed for PID {pid}")
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
filetime = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
||||
return f"win:{filetime + 504911232000000000}"
|
||||
|
||||
result = subprocess.run(
|
||||
["ps", "-p", str(pid), "-o", "lstart="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
marker = result.stdout.strip()
|
||||
if result.returncode == 0 and marker:
|
||||
return f"ps:{marker}"
|
||||
if result.returncode == 1 and not marker:
|
||||
raise ProcessLookupError(pid)
|
||||
raise OSError(f"ps could not inspect PID {pid}: {result.stderr.strip()}")
|
||||
|
||||
|
||||
def _valid_parent_start_marker(marker: str) -> bool:
|
||||
prefix, separator, value = marker.partition(":")
|
||||
if not separator or not value or value != value.strip():
|
||||
return False
|
||||
if prefix in ("linux", "win"):
|
||||
return value.isdigit()
|
||||
return prefix == "ps"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
@@ -17901,53 +17989,80 @@ def _maybe_open_browser(
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
|
||||
def _is_serve_orphaned(desktop_pid: int, pid_exists=None) -> bool:
|
||||
"""True when the Desktop process that owns this serve backend is gone.
|
||||
def _is_serve_orphaned(
|
||||
desktop_pid: int,
|
||||
expected_start_marker: Optional[str] = None,
|
||||
*,
|
||||
pid_exists=None,
|
||||
process_start_marker=None,
|
||||
) -> bool:
|
||||
"""True when the exact Desktop process that owns this backend is gone.
|
||||
|
||||
``HERMES_PARENT_PID`` is the Electron Desktop PID, not necessarily this
|
||||
Python process's immediate PPID. On Windows the venv ``hermes.exe`` launcher
|
||||
introduces one or more shim processes, so comparing ``os.getppid()`` to the
|
||||
Electron PID incorrectly treats a healthy backend as orphaned and exits 0.
|
||||
Probe the recorded Desktop PID directly instead.
|
||||
|
||||
Any liveness-probe failure is fail-safe: keep serving rather than killing a
|
||||
backend whose owner could not be conclusively shown to be dead.
|
||||
New Desktop versions also provide the owner's process-start marker. This
|
||||
prevents a recycled PID from keeping an orphan alive. Older versions remain
|
||||
compatible through the PID-only probe. Any inconclusive probe failure is
|
||||
fail-safe: keep serving rather than killing a backend whose owner could not
|
||||
be conclusively shown to be dead.
|
||||
"""
|
||||
try:
|
||||
if expected_start_marker is not None:
|
||||
probe = process_start_marker or _process_start_marker
|
||||
return probe(int(desktop_pid)) != expected_start_marker
|
||||
|
||||
if pid_exists is None:
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
pid_exists = _pid_exists
|
||||
return not bool(pid_exists(int(desktop_pid)))
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _start_parent_death_watchdog() -> None:
|
||||
"""Exit when the desktop parent that spawned this backend dies.
|
||||
"""Exit when the exact desktop parent that spawned this backend dies.
|
||||
|
||||
The desktop passes its own PID via HERMES_PARENT_PID. When that process
|
||||
vanishes (crash, SIGKILL, update handoff exiting before it reaps us) this
|
||||
orphaned backend would otherwise keep serving forever and leak its MCP
|
||||
child subtree. os._exit propagates to the MCP watchdogs parented here.
|
||||
|
||||
No-op for standalone `hermes serve` (env unset). Poll interval tunable via
|
||||
HERMES_SERVE_WATCHDOG_POLL_S.
|
||||
The desktop passes its PID and, in newer versions, its process-start marker
|
||||
plus a per-spawn nonce. The marker distinguishes a live owner from PID reuse;
|
||||
the nonce makes partial/mixed-version identity plumbing fail safe. Legacy
|
||||
Desktop versions that provide only ``HERMES_PARENT_PID`` retain PID-only
|
||||
tracking.
|
||||
"""
|
||||
raw = os.environ.get("HERMES_PARENT_PID")
|
||||
if not raw:
|
||||
return
|
||||
raw_pid = os.environ.get("HERMES_PARENT_PID")
|
||||
start_marker = os.environ.get("HERMES_PARENT_START_MARKER")
|
||||
nonce = os.environ.get("HERMES_PARENT_NONCE")
|
||||
|
||||
try:
|
||||
desktop_pid = int(raw)
|
||||
desktop_pid = int(raw_pid or "")
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if desktop_pid <= 0:
|
||||
return
|
||||
|
||||
has_marker = start_marker is not None
|
||||
has_nonce = nonce is not None
|
||||
if has_marker != has_nonce:
|
||||
return
|
||||
if has_marker and (
|
||||
not _valid_parent_start_marker(start_marker or "")
|
||||
or not nonce
|
||||
or nonce != nonce.strip()
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
poll = max(0.5, float(os.environ.get("HERMES_SERVE_WATCHDOG_POLL_S", "2.0")))
|
||||
except (TypeError, ValueError):
|
||||
poll = 2.0
|
||||
|
||||
def _loop() -> None:
|
||||
while not _is_serve_orphaned(desktop_pid):
|
||||
while not _is_serve_orphaned(desktop_pid, start_marker):
|
||||
time.sleep(poll)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user