diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index a6b708726..a20c85387 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5419,19 +5419,24 @@ function profileNameFromDeleteRequest(request) { return name.toLowerCase() } +// 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 +// backend whose ensure_hermes_home() recreates the deleted profile directory. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { - return + return null } if (profile === primaryProfileKey()) { writeActiveDesktopProfile('default') await teardownPrimaryBackendAndWait() - return + return profile } await teardownPoolBackendAndWait(profile) + return profile } async function startHermes() { @@ -6465,10 +6470,15 @@ ipcMain.handle('hermes:api', async (_event, request) => { return rerouted } - await prepareProfileDeleteRequest(request) + const tornDownProfile = await prepareProfileDeleteRequest(request) const profile = request?.profile - const connection = await ensureBackend(profile) + // After tearing down a backend for profile deletion, route to the primary + // backend instead of spawning a fresh pool backend. A freshly spawned + // backend calls ensure_hermes_home() which recreates the profile directory, + // defeating the deletion and leaving a zombie process. + const routeProfile = tornDownProfile ? null : profile + const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { globalRemote: globalRemoteActive(), diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.cjs new file mode 100644 index 000000000..a982072bd --- /dev/null +++ b/apps/desktop/electron/profile-delete-respawn.test.cjs @@ -0,0 +1,66 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const ELECTRON_DIR = __dirname + +function readElectronFile(name) { + return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') +} + +// --------------------------------------------------------------------------- +// prepareProfileDeleteRequest must return the torn-down profile name so the +// caller can skip ensureBackend for that profile (issue #52279). +// --------------------------------------------------------------------------- + +test('prepareProfileDeleteRequest returns the torn-down profile name', () => { + const source = readElectronFile('main.cjs') + + // Locate the function definition and its closing brace. + const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') + assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found') + + // The function must contain "return profile" (pool and primary paths). + const fnBody = source.slice(fnStart, fnStart + 800) + const returnProfileCount = (fnBody.match(/return profile/g) || []).length + assert.ok( + returnProfileCount >= 2, + `expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}` + ) + + // The early-exit guard must return null (not void/undefined). + assert.match( + fnBody, + /return null/, + 'early-exit guard should return null, not undefined' + ) +}) + +test('hermes:api handler routes profile-delete requests to the primary backend', () => { + const source = readElectronFile('main.cjs') + + // The handler must capture prepareProfileDeleteRequest's return value. + assert.match( + source, + /const tornDownProfile = await prepareProfileDeleteRequest\(request\)/, + 'handler should capture the return value of prepareProfileDeleteRequest' + ) + + // The handler must use the return value to skip ensureBackend for the + // torn-down profile, routing to the primary (null) instead. + assert.match( + source, + /const routeProfile = tornDownProfile \? null : profile/, + 'handler should route to primary backend when a profile was just torn down' + ) + + // ensureBackend must be called with the conditional route profile. + assert.match( + source, + /const connection = await ensureBackend\(routeProfile\)/, + 'handler should pass routeProfile (not raw profile) to ensureBackend' + ) +})