mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
fix(desktop): release profile backends before delete (#42613)
This commit is contained in:
parent
f6416f50fc
commit
c6dc2fcd21
3 changed files with 92 additions and 4 deletions
|
|
@ -4295,20 +4295,31 @@ async function teardownPrimaryBackendAndWait() {
|
|||
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
||||
resetHermesConnection()
|
||||
|
||||
if (!dying) {
|
||||
await waitForBackendExit(dying)
|
||||
}
|
||||
|
||||
async function waitForBackendExit(child, timeoutMs = 5000) {
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
dying.kill('SIGKILL')
|
||||
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||
forceKillProcessTree(child.pid)
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
resolve()
|
||||
}, 5000)
|
||||
dying.once('exit', () => {
|
||||
}, timeoutMs)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
|
|
@ -4500,12 +4511,70 @@ function stopPoolBackend(profile) {
|
|||
}
|
||||
}
|
||||
|
||||
async function teardownPoolBackendAndWait(profile) {
|
||||
const entry = backendPool.get(profile)
|
||||
if (!entry) return
|
||||
backendPool.delete(profile)
|
||||
|
||||
if (entry.process && !entry.process.killed) {
|
||||
try {
|
||||
entry.process.kill('SIGTERM')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
await waitForBackendExit(entry.process)
|
||||
}
|
||||
|
||||
function stopAllPoolBackends() {
|
||||
for (const profile of [...backendPool.keys()]) {
|
||||
stopPoolBackend(profile)
|
||||
}
|
||||
}
|
||||
|
||||
function profileNameFromDeleteRequest(request) {
|
||||
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
let raw = ''
|
||||
try {
|
||||
raw = decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = raw.trim()
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
if (name.toLowerCase() === 'default') {
|
||||
return 'default'
|
||||
}
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
async function prepareProfileDeleteRequest(request) {
|
||||
const profile = profileNameFromDeleteRequest(request)
|
||||
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (profile === primaryProfileKey()) {
|
||||
writeActiveDesktopProfile('default')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
return
|
||||
}
|
||||
|
||||
await teardownPoolBackendAndWait(profile)
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
// Latched-failure short-circuit: once bootstrap has failed in this
|
||||
// process, every subsequent startHermes() call re-throws the same error
|
||||
|
|
@ -5124,6 +5193,8 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||
return rerouted
|
||||
}
|
||||
|
||||
await prepareProfileDeleteRequest(request)
|
||||
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
|
|
|
|||
|
|
@ -1010,6 +1010,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
print(f"✓ Removed {wrapper_path}")
|
||||
|
||||
# 4. Remove profile directory
|
||||
remove_error: Exception | None = None
|
||||
try:
|
||||
def _make_writable(func, path, exc):
|
||||
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed.
|
||||
|
|
@ -1056,6 +1057,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
print(f"✓ Removed {profile_dir}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not remove {profile_dir}: {e}")
|
||||
remove_error = e
|
||||
|
||||
# 5. Clear active_profile if it pointed to this profile
|
||||
try:
|
||||
|
|
@ -1066,6 +1068,9 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if remove_error is not None:
|
||||
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
|
||||
|
||||
print(f"\nProfile '{canon}' deleted.")
|
||||
return profile_dir
|
||||
|
||||
|
|
|
|||
|
|
@ -442,6 +442,18 @@ class TestDeleteProfile:
|
|||
with pytest.raises(FileNotFoundError):
|
||||
delete_profile("nonexistent", yes=True)
|
||||
|
||||
def test_rmtree_failure_raises(self, profile_env):
|
||||
profile_dir = create_profile("coder", no_alias=True)
|
||||
set_active_profile("coder")
|
||||
|
||||
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
|
||||
patch("hermes_cli.profiles.shutil.rmtree", side_effect=PermissionError("locked")):
|
||||
with pytest.raises(RuntimeError, match="Could not remove profile directory"):
|
||||
delete_profile("coder", yes=True)
|
||||
|
||||
assert profile_dir.is_dir()
|
||||
assert get_active_profile() == "default"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestListProfiles
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue