fix(desktop): republish the connections registry to renderers after every successful save (#95393)

Live-confirmed on the Phase B build: hermesDesktop.connections.save()
succeeds and the registry on disk gains the row, but the switcher menu
(fed by the renderer $connectionsRegistry snapshot) keeps painting the
stale list until reload. remove() already broadcasts
hermes:connections:changed; save() only did so on the dial-material-edit
branch, so a brand-new connection or a label rename never reached the
switcher's onChanged re-pull (or any other window).

Fix at the publish seam only: saveRegistryConnection now broadcasts a new
'saved' reason for every successful save that isn't a dial-material edit.
'saved' is a pure registry-refresh signal — the use-gateway-boot listener
explicitly ignores it (nothing moved, so no dispose/redial/forget), while
the switcher's existing onChanged listener re-pulls the snapshot.

Tests:
- electron/hardening.test.ts pins both broadcast branches in
  saveRegistryConnection (source-assertion pattern; main.ts has no exports).
- connection-switcher.test.tsx mirrors the live repro scenario
  (/tmp/mg-ab/w2_95393.py): menu before save lacks the row, Electron's
  'saved' push arrives, menu after — without reload — shows it.
This commit is contained in:
Teknium
2026-08-26 04:10:18 -07:00
parent 06be6cffbc
commit fe615a0099
5 changed files with 121 additions and 2 deletions
+28
View File
@@ -1017,3 +1017,31 @@ test('sanitizeDesktopConnectionConfig exposes secureTokenStorage and remoteToken
assert.match(returned, /\bsecureTokenStorage\b/, 'the renderer needs the secure-storage availability signal')
assert.match(returned, /\bremoteTokenPlainText\b/, 'the renderer needs the plain-text token signal')
})
// #95393: connections.save succeeded but the switcher menu (renderer
// $connectionsRegistry snapshot) never refreshed until reload. The registry
// push (broadcastConnectionsChanged) fired only on the dial-material-edit
// branch, so a brand-new connection or a label rename never reached other
// windows — or the switcher's onChanged re-pull. Mirrors the live repro at
// /tmp/mg-ab/w2_95393.py: save → menu (no reload) must include the new row.
test('saveRegistryConnection republishes the registry to renderers on EVERY successful save (#95393)', () => {
const source = readMain()
const fnStart = source.indexOf('async function saveRegistryConnection(')
assert.notEqual(fnStart, -1, 'saveRegistryConnection must exist in main.ts')
const fnEnd = source.indexOf('\nasync function ', fnStart + 1)
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
// The dial-material edit branch keeps its dispose+redial semantics…
assert.match(
body,
/broadcastConnectionsChanged\(\{ connectionId: entry\.id, reason: 'updated' \}\)/,
'a dial-material edit must still push the dispose+redial signal'
)
// …and every OTHER save (new connection, label rename) must still push a
// registry refresh, or the switcher menu paints stale until reload.
assert.match(
body,
/broadcastConnectionsChanged\(\{ connectionId: entry\.id, reason: 'saved' \}\)/,
'a non-dial-material save must republish the registry snapshot (#95393)'
)
})
+9 -1
View File
@@ -9088,6 +9088,14 @@ async function saveRegistryConnection(input: any = {}) {
if (existing && connectionDialFieldsChanged(existing, entry)) {
await stopRegistryConnectionBackends(entry.id)
broadcastConnectionsChanged({ connectionId: entry.id, reason: 'updated' })
} else {
// Every OTHER successful save (a brand-new connection, a label rename)
// must still republish the registry snapshot, or windows that didn't
// perform the save — and the switcher menu fed by $connectionsRegistry —
// keep painting the stale list until reload (#95393). 'saved' is a pure
// registry-refresh signal: no sockets moved, so listeners must not
// dispose or redial anything for it.
broadcastConnectionsChanged({ connectionId: entry.id, reason: 'saved' })
}
return sanitizeRegistryConnection(entry)
@@ -10330,7 +10338,7 @@ function sendConnectionApplied() {
// scoped to that connection. Without this, a removed remote/cloud source keeps
// its renderer WebSocket open and streaming as a ghost, and an edited one
// keeps talking to the OLD endpoint until idle-reap.
function broadcastConnectionsChanged(payload: { connectionId: string; reason: 'removed' | 'updated' }) {
function broadcastConnectionsChanged(payload: { connectionId: string; reason: 'removed' | 'saved' | 'updated' }) {
for (const win of BrowserWindow.getAllWindows()) {
const { webContents } = win
@@ -362,4 +362,79 @@ describe('ConnectionSwitcher', () => {
expect(screen.getByRole('group', { name: 'Registered gateways' }).getAttribute('aria-busy')).toBe('true')
})
// #95393: connections.save succeeded but the switcher kept painting the
// stale registry until reload. Mirrors the live repro (w2_95393.py): open
// the menu, save a new connection via the bridge, re-open the menu WITHOUT
// reload — the new row must be there. Electron now pushes a 'saved'
// onChanged for every successful save; the switcher's listener re-pulls the
// snapshot.
it('repaints the menu after a connections.save without reload (#95393)', async () => {
const before = registry([connection('local', 'This device', 'local'), connection('homelab', 'Homelab')])
const after = registry([
connection('local', 'This device', 'local'),
connection('homelab', 'Homelab'),
connection('w2-probe', 'W2Probe')
])
$connectionsRegistry.set(before)
let onChangedCallback: ((payload: { connectionId: string; reason: string }) => void) | null = null
;(window as { hermesDesktop?: unknown }).hermesDesktop = {
connections: {
list: vi.fn(async () => after),
onChanged: vi.fn((callback: (payload: { connectionId: string; reason: string }) => void) => {
onChangedCallback = callback
return () => {
onChangedCallback = null
}
})
}
}
// The real refreshConnectionsRegistry re-pulls list() and republishes the
// atom; the mock mirrors exactly that seam against Electron's current
// registry state (before the save, then after it).
let electronRegistry = before
refreshConnectionsRegistry.mockImplementation(async () => {
$connectionsRegistry.set(electronRegistry)
return electronRegistry
})
try {
render(<ConnectionSwitcher onConnect={onConnect} />)
const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' })
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
expect(screen.queryByRole('menuitemradio', { name: 'W2Probe' })).toBeNull()
fireEvent.keyDown(document, { key: 'Escape' })
// The save lands in Electron's registry…
electronRegistry = after
// …and Electron's post-save push (reason 'saved' — no dial change) is
// the ONLY signal this window gets. Pre-fix, save never emitted it.
expect(onChangedCallback).not.toBeNull()
;(onChangedCallback as unknown as (payload: { connectionId: string; reason: string }) => void)({
connectionId: 'w2-probe',
reason: 'saved'
})
await waitFor(() => expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(2))
fireEvent.pointerDown(screen.getByRole('button', { name: 'Registered gateways: This device' }), {
button: 0,
pointerType: 'mouse'
})
expect(screen.getByRole('menuitemradio', { name: 'W2Probe' })).toBeTruthy()
} finally {
refreshConnectionsRegistry.mockReset()
refreshConnectionsRegistry.mockResolvedValue(null)
delete (window as { hermesDesktop?: unknown }).hermesDesktop
}
})
})
@@ -796,6 +796,14 @@ export function useGatewayBoot({
return
}
// 'saved' is a pure registry-refresh push (new connection or label
// rename — #95393): no endpoint moved, so there is nothing to dispose,
// redial, or forget. The switcher's own onChanged listener re-pulls the
// registry snapshot for it.
if (payload.reason === 'saved') {
return
}
disposeSecondariesForConnection(payload.connectionId, { redial: payload.reason === 'updated' })
if (payload.reason !== 'updated') {
+1 -1
View File
@@ -180,7 +180,7 @@ declare global {
// materially edited so the renderer can dispose (and re-dial) the
// secondary gateways scoped to it. Optional: older Electron mains
// don't emit it.
onChanged?: (callback: (payload: { connectionId: string; reason: 'removed' | 'updated' }) => void) => () => void
onChanged?: (callback: (payload: { connectionId: string; reason: 'removed' | 'saved' | 'updated' }) => void) => () => void
}
sshConfigHosts: () => Promise<DesktopSshHostsResult>
sshResolveHost: (host: string) => Promise<DesktopSshResolveResult>