fix(hub,web): serialize notification settings updates

This commit is contained in:
2026-08-05 11:16:17 +08:00
parent 896e5d3dac
commit dac45edc41
8 changed files with 128 additions and 29 deletions
+39 -4
View File
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path'
import type { NotificationCopyConfig } from '../push/notificationCopy'
const settingsWriteQueues = new Map<string, Promise<unknown>>()
export interface Settings {
machineId?: string
machineIdConfirmedByServer?: boolean
@@ -60,10 +62,20 @@ export async function readSettingsOrThrow(settingsFile: string): Promise<Setting
return settings
}
/**
* Write settings to file atomically (temp file + rename)
*/
export async function writeSettings(settingsFile: string, settings: Settings): Promise<void> {
async function serializeSettingsWrite<T>(settingsFile: string, operation: () => Promise<T>): Promise<T> {
const previous = settingsWriteQueues.get(settingsFile) ?? Promise.resolve()
const current = previous.catch(() => {}).then(operation)
settingsWriteQueues.set(settingsFile, current)
try {
return await current
} finally {
if (settingsWriteQueues.get(settingsFile) === current) {
settingsWriteQueues.delete(settingsFile)
}
}
}
async function writeSettingsUnlocked(settingsFile: string, settings: Settings): Promise<void> {
const dir = dirname(settingsFile)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true, mode: 0o700 })
@@ -73,3 +85,26 @@ export async function writeSettings(settingsFile: string, settings: Settings): P
await writeFile(tmpFile, JSON.stringify(settings, null, 2))
await rename(tmpFile, settingsFile)
}
/**
* Write settings to file atomically (temp file + rename).
* Writes are serialized per file so they cannot reuse the same temp path.
*/
export async function writeSettings(settingsFile: string, settings: Settings): Promise<void> {
await serializeSettingsWrite(settingsFile, () => writeSettingsUnlocked(settingsFile, settings))
}
/**
* Serialize a complete settings read-modify-write operation per file.
*/
export async function mutateSettings<T>(
settingsFile: string,
mutate: (settings: Settings) => T | Promise<T>
): Promise<T> {
return await serializeSettingsWrite(settingsFile, async () => {
const settings = await readSettingsOrThrow(settingsFile)
const result = await mutate(settings)
await writeSettingsUnlocked(settingsFile, settings)
return result
})
}
+31 -19
View File
@@ -9,14 +9,12 @@
* tunnel — there is no shared-key fallback.
*/
import { readSettings, writeSettings, type Settings } from '../config/settings'
import { mutateSettings, readSettings } from '../config/settings'
type FetchRelayAuth = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
async function issueRelayAuthKey(
async function requestRelayAuthKey(
apiDomain: string,
settingsFile: string,
settings: Settings | null,
fetchRelayAuth: FetchRelayAuth
): Promise<string> {
const resp = await fetchRelayAuth(`https://${apiDomain}/issue`, {
@@ -41,10 +39,6 @@ async function issueRelayAuthKey(
if (typeof data.key !== 'string' || !data.key) {
throw new Error(`Relay at ${apiDomain} returned an invalid key response.`)
}
// settings === null means the file exists but is unparseable; don't clobber it
if (settings !== null) {
await writeSettings(settingsFile, { ...settings, relayAuthKey: data.key })
}
console.log('[Tunnel] Obtained per-hub relay auth key')
return data.key
}
@@ -64,7 +58,18 @@ export async function resolveRelayAuthKey(
return settings.relayAuthKey
}
return issueRelayAuthKey(apiDomain, settingsFile, settings, fetchRelayAuth)
const issuedKey = await requestRelayAuthKey(apiDomain, fetchRelayAuth)
// An unreadable file must never be replaced with a partial settings object.
if (settings === null) {
return issuedKey
}
return await mutateSettings(settingsFile, (latestSettings) => {
if (latestSettings.relayAuthKey) {
return latestSettings.relayAuthKey
}
latestSettings.relayAuthKey = issuedKey
return issuedKey
})
}
export async function refreshRejectedRelayAuthKey(
@@ -80,17 +85,24 @@ export async function refreshRejectedRelayAuthKey(
)
}
const settings = await readSettings(settingsFile)
if (settings === null) {
throw new Error(`Cannot refresh relay auth while ${settingsFile} is unreadable.`)
}
if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) {
return settings.relayAuthKey
const currentKey = await mutateSettings(settingsFile, (settings) => {
if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) {
return settings.relayAuthKey
}
delete settings.relayAuthKey
return null
})
if (currentKey) {
return currentKey
}
const clearedSettings = { ...settings }
delete clearedSettings.relayAuthKey
await writeSettings(settingsFile, clearedSettings)
console.warn('[Tunnel] Relay auth key rejected; requesting a replacement')
return issueRelayAuthKey(apiDomain, settingsFile, clearedSettings, fetchRelayAuth)
const issuedKey = await requestRelayAuthKey(apiDomain, fetchRelayAuth)
return await mutateSettings(settingsFile, (settings) => {
if (settings.relayAuthKey) {
return settings.relayAuthKey
}
settings.relayAuthKey = issuedKey
return issuedKey
})
}
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Hono } from 'hono'
import type { WebAppEnv } from '../middleware/auth'
import { resolveRelayAuthKey } from '../../tunnel/relayAuth'
import { createNotificationCopyRoutes } from './notificationCopy'
let dir: string
@@ -105,6 +106,37 @@ describe('PUT /api/notification-copy', () => {
expect(settings.notificationCopy).toEqual({ ready: { title: '', body: '' } })
})
it('preserves a concurrent relay key update', async () => {
let releaseIssue: () => void = () => {}
let issueStarted: () => void = () => {}
const started = new Promise<void>((resolve) => {
issueStarted = resolve
})
const release = new Promise<void>((resolve) => {
releaseIssue = resolve
})
const relayKey = resolveRelayAuthKey('relay.example.com', join(dir, 'settings.json'), async () => {
issueStarted()
await release
return Response.json({ key: 'relay-key' })
})
await started
const app = await createApp('default')
const res = await app.request('/api/notification-copy', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ready: { title: 'Hey', body: '{agentName}' } })
})
expect(res.status).toBe(200)
releaseIssue()
await relayKey
const settings = await readSettings()
expect(settings.relayAuthKey).toBe('relay-key')
expect(settings.notificationCopy).toEqual({ ready: { title: 'Hey', body: '{agentName}' } })
})
it('rejects title over 500 chars', async () => {
const app = await createApp('default')
const res = await app.request('/api/notification-copy', {
+4 -5
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import { getSettingsFile, readSettingsOrThrow, writeSettings } from '../../config/settings'
import { getSettingsFile, mutateSettings, readSettingsOrThrow } from '../../config/settings'
import {
COPY_KEYS,
DEFAULT_COPY,
@@ -41,8 +41,6 @@ export function createNotificationCopyRoutes(dataDir: string): Hono<WebAppEnv> {
return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400)
}
// Read-modify-write: preserve every other settings.json key.
const settings = await readSettingsOrThrow(settingsFile)
const copy: NotificationCopyConfig = {}
for (const key of COPY_KEYS) {
const template = parsed.data[key]
@@ -50,8 +48,9 @@ export function createNotificationCopyRoutes(dataDir: string): Hono<WebAppEnv> {
copy[key as CopyKey] = template
}
}
settings.notificationCopy = copy
await writeSettings(settingsFile, settings)
await mutateSettings(settingsFile, (settings) => {
settings.notificationCopy = copy
})
return c.json({
copy,
defaults: DEFAULT_COPY
+1
View File
@@ -944,6 +944,7 @@ export default {
'settings.notifications.copy.resetDefault': 'Reset to default',
'settings.notifications.copy.save': 'Save copy',
'settings.notifications.copy.saved': 'Copy saved',
'settings.notifications.copy.saveError': 'Failed to save notification copy',
'settings.notifications.copy.testPushNote': 'The test push button always sends fixed copy.',
'settings.notifications.copy.permissionRequest': 'Permission request',
'settings.notifications.copy.ready': 'Session ready',
+1
View File
@@ -943,6 +943,7 @@ export default {
'settings.notifications.copy.resetDefault': '恢复默认',
'settings.notifications.copy.save': '保存文案',
'settings.notifications.copy.saved': '文案已保存',
'settings.notifications.copy.saveError': '保存推送文案失败',
'settings.notifications.copy.testPushNote': '测试推送按钮始终发送固定文案。',
'settings.notifications.copy.permissionRequest': '权限请求',
'settings.notifications.copy.ready': '会话就绪',
@@ -155,6 +155,16 @@ describe('SettingsNotificationsPage', () => {
})
})
it('reports a rejected notification copy save', async () => {
updateNotificationCopy.mockRejectedValueOnce(new Error('write failed'))
renderPage()
await screen.findByText('Push notification copy')
fireEvent.click(screen.getByRole('button', { name: 'Save copy' }))
expect(await screen.findByText('Failed to save notification copy')).toBeTruthy()
})
it('keeps copy editors collapsed and prefills defaults when opened', async () => {
renderPage()
const readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ })
+10 -1
View File
@@ -87,6 +87,7 @@ export default function SettingsNotificationsPage() {
const [testPushLabel, setTestPushLabel] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [copySaved, setCopySaved] = useState(false)
const [copySaveError, setCopySaveError] = useState<string | null>(null)
const [openCopyBlock, setOpenCopyBlock] = useState<CopyKey | null>(null)
const isAdmin = Boolean(token) && getNamespace(token) === 'default'
@@ -142,12 +143,20 @@ export default function SettingsNotificationsPage() {
if (!api) throw new Error('API unavailable')
return await api.updateNotificationCopy(copy)
},
onMutate: () => {
setCopySaveError(null)
},
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.notificationCopy, data)
setDraft(resolveEffectiveCopy(data.copy, data.defaults))
setCopySaveError(null)
setCopySaved(true)
setTimeout(() => setCopySaved(false), 3000)
},
onError: () => {
setCopySaved(false)
setCopySaveError(t('settings.notifications.copy.saveError'))
},
})
const handleToggle = (key: ToggleKey) => (checked: boolean) => {
@@ -297,7 +306,7 @@ export default function SettingsNotificationsPage() {
})}
<div className="flex min-h-14 items-center justify-between gap-3 px-3 py-3">
<span className="text-xs text-[var(--app-hint)]" aria-live="polite">
{copySaved ? t('settings.notifications.copy.saved') : ''}
{copySaveError ?? (copySaved ? t('settings.notifications.copy.saved') : '')}
</span>
<button
type="button"