diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 792dc9f2a7..892f34b259 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -248,6 +248,7 @@ import { profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import { prepareProfileRenameLifecycle, profileRenameFromRequest } from './profile-rename-routing' import { buildSidebarSessionSliceParams, fetchPrimaryProfileSessions, @@ -265,7 +266,6 @@ import { } from './remote-liveness' import { missingRendererAssets } from './renderer-bundle' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' -import { prepareProfileRenameLifecycle, profileRenameFromRequest } from './profile-rename-routing' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -13260,6 +13260,7 @@ async function handleHermesApiRequest(request) { // primary rename the lifecycle has already made `default` the temporary // primary until the PATCH settles, so the request routes there. const apiRoute = resolveProfileApiRequest(profile, request.path, profileRouteOptions(profile, request)) + const routeProfile = profileRename ? profileRename.routeProfile : resolveRouteProfile(tornDownProfile, apiRoute.backendProfile) diff --git a/apps/desktop/src/app/profiles/rename-profile-dialog.test.tsx b/apps/desktop/src/app/profiles/rename-profile-dialog.test.tsx new file mode 100644 index 0000000000..7f4089f790 --- /dev/null +++ b/apps/desktop/src/app/profiles/rename-profile-dialog.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import { renameProfile } from '@/hermes' +import { retireLocalProfileGateways } from '@/store/gateway' + +import { RenameProfileDialog } from './rename-profile-dialog' + +// Pins the rename half of the deleted-profile-resurrection class (#88638 fixed +// the delete half): a retained renderer socket for the OLD profile name must be +// retired BEFORE the rename PATCH tears down its backend, or the socket's +// reconnect loop respawns the old-name backend and recreates the directory the +// rename just moved. + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +vi.mock('@/hermes', () => ({ + renameProfile: vi.fn(async () => ({ name: 'renamed', ok: true, path: '/x' })) +})) + +vi.mock('@/store/gateway', () => ({ + retireLocalProfileGateways: vi.fn() +})) + +it('retires the old-name local gateways before issuing the rename', async () => { + const order: string[] = [] + + vi.mocked(retireLocalProfileGateways).mockImplementationOnce(() => { + order.push('retire') + }) + vi.mocked(renameProfile).mockImplementationOnce(async () => { + order.push('rename') + + return { name: 'renamed', ok: true, path: '/x' } + }) + + render() + + fireEvent.change(screen.getByLabelText(/new name/i), { target: { value: 'renamed' } }) + fireEvent.click(screen.getByRole('button', { name: /^rename$/i })) + + await waitFor(() => expect(renameProfile).toHaveBeenCalledWith('selena', 'renamed')) + expect(retireLocalProfileGateways).toHaveBeenCalledWith('selena') + expect(order).toEqual(['retire', 'rename']) +}) + +it('does not retire gateways when validation rejects the submit', async () => { + render() + + fireEvent.change(screen.getByLabelText(/new name/i), { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: /^rename$/i })) + + await waitFor(() => expect(screen.getByText('Name is required.')).toBeTruthy()) + expect(retireLocalProfileGateways).not.toHaveBeenCalled() + expect(renameProfile).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx index 7973472260..c6cb6f83e3 100644 --- a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +++ b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx @@ -16,6 +16,7 @@ import { renameProfile } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle } from '@/lib/icons' import { slug } from '@/lib/sanitize' +import { retireLocalProfileGateways } from '@/store/gateway' import { isValidProfileName } from './create-profile-dialog' @@ -72,6 +73,11 @@ export function RenameProfileDialog({ setError(null) try { + // A retained renderer socket for the old name would treat the rename's + // backend teardown as a transient drop and redial, resurrecting the + // old-name backend whose ensure_hermes_home() recreates the directory + // the rename just moved (same class as the delete path, #88638). + retireLocalProfileGateways(currentName) await renameProfile(currentName, trimmed) await onRenamed?.(trimmed) setStatus('done')