fix(desktop): retire old-name renderer sockets and widen the deletion gate for renames

Follow-ups on top of the salvaged rename lifecycle:

- Retire both local renderer socket scopes for the OLD profile name before
  the rename PATCH (rename-profile-dialog), mirroring the delete-path fix
  from #88638 — a retained socket otherwise treats the rename's backend
  teardown as a transient drop and its reconnect respawns the old-name
  backend, whose ensure_hermes_home() recreates the directory the rename
  just moved (#45474).
- Hold the profile deletion gate for rename requests too, so a concurrent
  renderer reconnect entering ensureBackend() mid-rename cannot respawn the
  old-name backend.
- Regression tests for retire-before-rename ordering and the
  validation-rejected path.
This commit is contained in:
Teknium
2026-08-17 16:03:05 -07:00
parent 69abe41f36
commit c6a0789bc5
3 changed files with 67 additions and 1 deletions
+2 -1
View File
@@ -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)
@@ -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(<RenameProfileDialog currentName="selena" onClose={vi.fn()} open />)
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(<RenameProfileDialog currentName="selena" onClose={vi.fn()} open />)
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()
})
@@ -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')