diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts b/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts new file mode 100644 index 00000000000..d7d4418c0cf --- /dev/null +++ b/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import { + runShutdownCheckpointPersist, + type ShutdownCheckpointPersistDeps +} from './shutdown-checkpoint-persist' + +function makeDeps( + overrides: Partial = {} +): ShutdownCheckpointPersistDeps { + return { + shouldCaptureSession: () => true, + captureTerminalBuffers: vi.fn(), + captureSleepingAgentSessions: vi.fn(), + buildSessionSnapshots: () => [{ state: { activeTabId: 't1' } }] as never, + buildUiPatch: () => ({ activeView: 'workspace' }) as never, + hasDirtyOpenFiles: () => false, + isIntentionalAppRestartInProgress: () => true, + stageBeforeUnloadSync: vi.fn(), + ...overrides + } +} + +describe('runShutdownCheckpointPersist', () => { + it('does not fail the checkpoint when the sleeping-agent quit capture throws (STA-5505)', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const deps = makeDeps({ + captureSleepingAgentSessions: () => { + throw new Error('capture exploded') + } + }) + + expect(() => runShutdownCheckpointPersist(deps)).not.toThrow() + // The full snapshot must still be staged — losing at most one minute of + // resume records is strictly better than blocking the update. + expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({ + sessions: [{ state: { activeTabId: 't1' } }], + ui: { activeView: 'workspace' } + }) + vi.restoreAllMocks() + }) + + it('degrades to durable-only staging when full staging throws during an intentional restart (STA-5505)', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const stageBeforeUnloadSync = vi.fn((args: { sessions: unknown[] }) => { + if (args.sessions.length > 0) { + throw new Error('sync IPC staging failed') + } + }) + const deps = makeDeps({ stageBeforeUnloadSync }) + + expect(() => runShutdownCheckpointPersist(deps)).not.toThrow() + expect(stageBeforeUnloadSync).toHaveBeenCalledTimes(2) + expect(stageBeforeUnloadSync).toHaveBeenLastCalledWith({ + sessions: [], + ui: { activeView: 'workspace' } + }) + vi.restoreAllMocks() + }) + + it('still fails the checkpoint when staging throws and dirty editor buffers exist', () => { + const deps = makeDeps({ + hasDirtyOpenFiles: () => true, + stageBeforeUnloadSync: vi.fn(() => { + throw new Error('sync IPC staging failed') + }) + }) + + expect(() => runShutdownCheckpointPersist(deps)).toThrow('sync IPC staging failed') + }) + + it('still fails the checkpoint when staging throws outside an intentional restart', () => { + const deps = makeDeps({ + isIntentionalAppRestartInProgress: () => false, + stageBeforeUnloadSync: vi.fn(() => { + throw new Error('sync IPC staging failed') + }) + }) + + expect(() => runShutdownCheckpointPersist(deps)).toThrow('sync IPC staging failed') + }) + + it('fails the checkpoint when even durable-only staging throws', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const deps = makeDeps({ + buildSessionSnapshots: () => { + throw new Error('snapshot build failed') + }, + stageBeforeUnloadSync: vi.fn(() => { + throw new Error('durable staging failed') + }) + }) + + expect(() => runShutdownCheckpointPersist(deps)).toThrow('durable staging failed') + vi.restoreAllMocks() + }) + + it('keeps the durable-session fallback for snapshot build failures', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const deps = makeDeps({ + buildSessionSnapshots: () => { + throw new Error('snapshot build failed') + } + }) + + expect(() => runShutdownCheckpointPersist(deps)).not.toThrow() + expect(deps.stageBeforeUnloadSync).toHaveBeenCalledTimes(1) + expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({ + sessions: [], + ui: { activeView: 'workspace' } + }) + vi.restoreAllMocks() + }) + + it('rethrows snapshot build failures when dirty editor buffers exist', () => { + const deps = makeDeps({ + hasDirtyOpenFiles: () => true, + buildSessionSnapshots: () => { + throw new Error('snapshot build failed') + } + }) + + expect(() => runShutdownCheckpointPersist(deps)).toThrow('snapshot build failed') + expect(deps.stageBeforeUnloadSync).not.toHaveBeenCalled() + }) + + it('skips capture and stages empty sessions before hydration completes', () => { + const deps = makeDeps({ shouldCaptureSession: () => false }) + + runShutdownCheckpointPersist(deps) + + expect(deps.captureTerminalBuffers).not.toHaveBeenCalled() + expect(deps.captureSleepingAgentSessions).not.toHaveBeenCalled() + expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({ + sessions: [], + ui: { activeView: 'workspace' } + }) + }) +}) diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts b/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts new file mode 100644 index 00000000000..90bbb3e39fb --- /dev/null +++ b/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts @@ -0,0 +1,64 @@ +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { WorkspaceSessionHostSnapshot } from '../lib/workspace-session-host-persistence' + +export type ShutdownCheckpointStageArgs = { + sessions: WorkspaceSessionHostSnapshot[] + ui: Partial +} + +export type ShutdownCheckpointPersistDeps = { + shouldCaptureSession: () => boolean + /** Per-pane failures are already swallowed inside the capture loop. */ + captureTerminalBuffers: () => void + captureSleepingAgentSessions: () => void + buildSessionSnapshots: () => WorkspaceSessionHostSnapshot[] + buildUiPatch: () => Partial + hasDirtyOpenFiles: () => boolean + isIntentionalAppRestartInProgress: () => boolean + stageBeforeUnloadSync: (args: ShutdownCheckpointStageArgs) => void +} + +/** The synchronous body of the shutdown checkpoint: capture renderer-owned state, + * then stage everything durable through one main-process call. Throwing here fails + * the checkpoint and blocks the restart, so only unstageable data may throw. */ +export function runShutdownCheckpointPersist(deps: ShutdownCheckpointPersistDeps): void { + const shouldCaptureSession = deps.shouldCaptureSession() + if (shouldCaptureSession) { + deps.captureTerminalBuffers() + try { + deps.captureSleepingAgentSessions() + } catch (error) { + // Why: the periodic capture refreshes resume records every minute, so a failed + // quit capture costs at most that window — never strand the restart on it. + console.error('[app] Sleeping-agent quit capture failed; continuing checkpoint', error) + } + } + // Why: dirty drafts exist only in the full session snapshot, so their loss is the + // one thing this checkpoint may never trade away for an update. + const canDegradeToDurableSession = (): boolean => + deps.isIntentionalAppRestartInProgress() && !deps.hasDirtyOpenFiles() + let sessionSnapshots: WorkspaceSessionHostSnapshot[] = [] + let degraded = false + try { + sessionSnapshots = shouldCaptureSession ? deps.buildSessionSnapshots() : [] + } catch (error) { + if (!canDegradeToDurableSession()) { + throw error + } + console.error('[app] Full renderer session snapshot failed; using durable session', error) + degraded = true + } + try { + deps.stageBeforeUnloadSync({ + sessions: degraded ? [] : sessionSnapshots, + ui: deps.buildUiPatch() + }) + } catch (error) { + // A durable-only stage has nothing safer left to fall back to. + if (degraded || !canDegradeToDurableSession()) { + throw error + } + console.error('[app] Staging the full renderer session failed; using durable session', error) + deps.stageBeforeUnloadSync({ sessions: [], ui: deps.buildUiPatch() }) + } +} diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 5047feac381..68b24d0588b 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -20,6 +20,7 @@ import { createShutdownCheckpointBeforeUnloadHandler, createShutdownCheckpointGuard } from '../lib/shutdown-checkpoint-guard' +import { runShutdownCheckpointPersist } from './shutdown-checkpoint-persist' import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures' import { dispatchWindowCloseRequest } from '../components/window-close-request-coordinator' import { @@ -117,51 +118,40 @@ export function useAppSessionPersistence(): void { // two firings, PTY exit events can arrive and unmount TerminalPanes, // emptying shutdownBufferCaptures. The guard prevents the second call // from overwriting the good session data with an empty snapshot. - const shutdownCheckpoint = createShutdownCheckpointGuard(() => { - const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState()) - if (shouldCaptureSession) { - for (const capture of shutdownBufferCaptures.values()) { - try { - capture({ includeLocalBuffers: false }) - } catch { - // Don't let one pane's failure block the rest. + const shutdownCheckpoint = createShutdownCheckpointGuard(() => + runShutdownCheckpointPersist({ + shouldCaptureSession: () => shouldPersistWorkspaceSession(useAppStore.getState()), + captureTerminalBuffers: () => { + for (const capture of shutdownBufferCaptures.values()) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // Don't let one pane's failure block the rest. + } } - } + }, // Why: agent provider session ids live only in agentStatusByPaneKey, // which is in-memory. Capture them into the persisted sleeping-session // map so a daemon/session death while the app is closed can still // cold-restore via the agent's resume command (#5232). - useAppStore.getState().captureAllSleepingAgentSessions('quit') - } - // Why: re-read state after capture() calls populated scrollback buffers - // into the store via Zustand setters. The earlier read is only for the - // gating flags and would miss those updates. - const freshState = useAppStore.getState() - let sessionSnapshots: ReturnType = [] - try { - sessionSnapshots = shouldCaptureSession - ? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState) - : [] - } catch (error) { - // Why: dirty drafts exist only in the full session snapshot. - if ( - !isIntentionalAppRestartInProgress() || - freshState.openFiles.some((file) => file.isDirty) - ) { - throw error - } - console.error('[app] Full renderer session snapshot failed; using durable session', error) - window.api.app.stageBeforeUnloadSync({ - sessions: [], - ui: buildActiveViewUnloadPatch(freshState) - }) - return - } - window.api.app.stageBeforeUnloadSync({ - sessions: sessionSnapshots, - ui: buildActiveViewUnloadPatch(freshState) + captureSleepingAgentSessions: () => + useAppStore.getState().captureAllSleepingAgentSessions('quit'), + // Why: re-read state after capture() calls populated scrollback buffers + // into the store via Zustand setters. The shouldCaptureSession read is + // only for the gating flags and would miss those updates. + buildSessionSnapshots: () => { + const freshState = useAppStore.getState() + return buildWorkspaceSessionHostSnapshots( + buildWorkspaceSessionPayload(freshState), + freshState + ) + }, + buildUiPatch: () => buildActiveViewUnloadPatch(useAppStore.getState()), + hasDirtyOpenFiles: () => useAppStore.getState().openFiles.some((file) => file.isDirty), + isIntentionalAppRestartInProgress, + stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args) }) - }) + ) const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint) window.addEventListener('beforeunload', persistBeforeUnload) window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 99ef97bb211..e4add37080e 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -498,20 +498,17 @@ describe('renderer startup runtime routing', () => { expect(checkpointEnd).toBeGreaterThan(checkpointStart) const checkpointBlock = source.slice(checkpointStart, checkpointEnd) + expect(checkpointBlock).toContain('runShutdownCheckpointPersist({') expect(checkpointBlock).toContain( - 'let sessionSnapshots: ReturnType = []' + 'buildWorkspaceSessionHostSnapshots(\n buildWorkspaceSessionPayload(freshState),\n freshState\n )' + ) + expect(checkpointBlock).toContain('buildUiPatch: () => buildActiveViewUnloadPatch(') + expect(checkpointBlock).toContain('isIntentionalAppRestartInProgress') + expect(checkpointBlock).toContain( + 'useAppStore.getState().openFiles.some((file) => file.isDirty)' ) expect(checkpointBlock).toContain( - 'buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)' - ) - expect(checkpointBlock).toContain('window.api.app.stageBeforeUnloadSync({') - expect(checkpointBlock).toContain('sessions: sessionSnapshots') - expect(checkpointBlock).toContain('ui: buildActiveViewUnloadPatch(freshState)') - expect(checkpointBlock).toContain('!isIntentionalAppRestartInProgress()') - expect(checkpointBlock).toContain('freshState.openFiles.some((file) => file.isDirty)') - expect(checkpointBlock).toContain('sessions: []') - expect(checkpointBlock).toContain( - 'return\n }\n window.api.app.stageBeforeUnloadSync({\n sessions: sessionSnapshots' + 'stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args)' ) expect(source).toContain( 'window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)' diff --git a/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts b/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts index 688d396d3b8..2adf838a6d5 100644 --- a/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts +++ b/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts @@ -1,17 +1,25 @@ +// @vitest-environment happy-dom import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createShutdownCheckpointBeforeUnloadHandler, createShutdownCheckpointGuard, preventUnloadAndScheduleShutdownCheckpointReset } from './shutdown-checkpoint-guard' import { + consumeShutdownCheckpointFailureReason, ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' describe('createShutdownCheckpointGuard', () => { + afterEach(() => { + vi.restoreAllMocks() + delete (window as unknown as { api?: unknown }).api + consumeShutdownCheckpointFailureReason() + }) + it('dedupes the synthetic and native unload events in one close attempt', () => { const persist = vi.fn() const guard = createShutdownCheckpointGuard(persist) @@ -45,6 +53,37 @@ describe('createShutdownCheckpointGuard', () => { expect(persist).toHaveBeenCalledTimes(2) }) + it('publishes the failure cause and records a crash breadcrumb (STA-5505)', () => { + const recordBreadcrumb = vi.fn() + ;(window as unknown as { api: unknown }).api = { crashReports: { recordBreadcrumb } } + vi.spyOn(console, 'error').mockImplementation(() => {}) + const guard = createShutdownCheckpointGuard(() => { + throw new Error('sendSync payload rejected') + }) + + expect(guard.persistOnce()).toBe(false) + + expect(consumeShutdownCheckpointFailureReason()).toBe('sendSync payload rejected') + expect(recordBreadcrumb).toHaveBeenCalledWith({ + name: 'renderer_shutdown_checkpoint_failed', + data: { message: 'sendSync payload rejected' } + }) + }) + + it('clears a stale failure cause once a later checkpoint succeeds (STA-5505)', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const guard = createShutdownCheckpointGuard( + vi.fn().mockImplementationOnce(() => { + throw new Error('disk full') + }) + ) + + expect(guard.persistOnce()).toBe(false) + expect(guard.persistOnce()).toBe(true) + + expect(consumeShutdownCheckpointFailureReason()).toBeNull() + }) + it('reports checkpoint failure separately from the unload verdict', () => { const eventTarget = new EventTarget() const failed = vi.fn() diff --git a/src/renderer/src/lib/shutdown-checkpoint-guard.ts b/src/renderer/src/lib/shutdown-checkpoint-guard.ts index 129c67406d0..a5505894c60 100644 --- a/src/renderer/src/lib/shutdown-checkpoint-guard.ts +++ b/src/renderer/src/lib/shutdown-checkpoint-guard.ts @@ -1,13 +1,25 @@ import { + clearShutdownCheckpointFailureReason, ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, - ORCA_RENDERER_UNLOAD_PREVENTED_EVENT + ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, + publishShutdownCheckpointFailureReason } from '../../../shared/renderer-shutdown-events' +import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' export type ShutdownCheckpointGuard = { persistOnce: () => boolean reset: () => void } +// Why: without this, a reproducible checkpoint failure strands the user on an old +// build behind an error that names the symptom while the cause is swallowed (STA-5505). +function reportShutdownCheckpointFailure(error: unknown): void { + console.error('[app] Shutdown checkpoint persist failed:', error) + const message = error instanceof Error ? error.message : String(error) + publishShutdownCheckpointFailureReason(message) + recordRendererCrashBreadcrumb('renderer_shutdown_checkpoint_failed', { message }) +} + export function createShutdownCheckpointGuard(persist: () => void): ShutdownCheckpointGuard { let persisted = false return { @@ -17,12 +29,14 @@ export function createShutdownCheckpointGuard(persist: () => void): ShutdownChec } try { persist() - } catch { + } catch (error) { // Why: browser event targets swallow listener exceptions. Returning a // failure lets the caller cancel unload and keep this attempt retryable. + reportShutdownCheckpointFailure(error) return false } persisted = true + clearShutdownCheckpointFailureReason() return true }, reset(): void { diff --git a/src/shared/renderer-restart-preparation.test.ts b/src/shared/renderer-restart-preparation.test.ts index ca690ab3cc6..78a2d17dfcd 100644 --- a/src/shared/renderer-restart-preparation.test.ts +++ b/src/shared/renderer-restart-preparation.test.ts @@ -1,6 +1,10 @@ +// @vitest-environment happy-dom import { describe, expect, it, vi } from 'vitest' import type { UpdateStatus } from './update-status-types' -import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT } from './renderer-shutdown-events' +import { + ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, + publishShutdownCheckpointFailureReason +} from './renderer-shutdown-events' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart @@ -32,6 +36,24 @@ describe('prepareRendererForAppRestart', () => { expect(aborted).toHaveBeenCalledTimes(1) }) + it('names the published failure cause in the thrown checkpoint error (STA-5505)', async () => { + const eventTarget = new EventTarget() + eventTarget.addEventListener('beforeunload', (event) => { + // Mirrors the checkpoint guard: publish the cause, then fail the checkpoint. + publishShutdownCheckpointFailureReason('sendSync payload rejected') + event.currentTarget?.dispatchEvent(new Event(ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT)) + event.preventDefault() + }) + + await expect( + prepareRendererForAppRestart(eventTarget, { + startedEventName: 'restart-started', + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => Promise.resolve() + }) + ).rejects.toThrow('Renderer shutdown checkpoint was not completed: sendSync payload rejected') + }) + it('does not mistake an unrelated unload veto for checkpoint failure', async () => { const eventTarget = new EventTarget() const veto = vi.fn((event: Event) => event.preventDefault()) diff --git a/src/shared/renderer-restart-preparation.ts b/src/shared/renderer-restart-preparation.ts index 356008e0f62..b8e4d802fa1 100644 --- a/src/shared/renderer-restart-preparation.ts +++ b/src/shared/renderer-restart-preparation.ts @@ -2,7 +2,10 @@ import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, type EditorPrepareHotExitDetail } from './editor-save-events' -import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT } from './renderer-shutdown-events' +import { + consumeShutdownCheckpointFailureReason, + ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT +} from './renderer-shutdown-events' import type { UpdateStatus } from './update-status-types' export type AppRestartPrepOptions = { @@ -63,7 +66,14 @@ export async function prepareRendererForAppRestart( ) } if (checkpointFailed) { - throw new Error('Renderer shutdown checkpoint was not completed.') + // Why: the guard publishes the swallowed persist error out-of-band; naming it + // here is the only way the update-error dialog can say what actually failed. + const reason = consumeShutdownCheckpointFailureReason() + throw new Error( + reason + ? `Renderer shutdown checkpoint was not completed: ${reason}` + : 'Renderer shutdown checkpoint was not completed.' + ) } // Why: the checkpoint only stages synchronously. Navigating before that // write lands loses the session snapshot to a crash or power loss. diff --git a/src/shared/renderer-shutdown-events.ts b/src/shared/renderer-shutdown-events.ts index 3aa3dbcc25d..b55831b818b 100644 --- a/src/shared/renderer-shutdown-events.ts +++ b/src/shared/renderer-shutdown-events.ts @@ -1,3 +1,45 @@ export const ORCA_RENDERER_UNLOAD_PREVENTED_EVENT = 'orca:renderer-unload-prevented' export const ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT = 'orca:renderer-shutdown-checkpoint-failed' + +// Why a DOM attribute: the checkpoint guard runs in the renderer's main world while +// prepareRendererForAppRestart runs in the context-isolated preload world. Events +// cross worlds but their JS payloads don't; document attributes are shared platform +// state, so this is the one channel that carries the failure reason to the thrower. +export const ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE = + 'data-orca-shutdown-checkpoint-failure' + +export function publishShutdownCheckpointFailureReason(reason: string): void { + try { + globalThis.document?.documentElement?.setAttribute( + ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE, + reason + ) + } catch { + // Best-effort diagnostics; the checkpoint verdict itself is carried by the event. + } +} + +export function clearShutdownCheckpointFailureReason(): void { + try { + globalThis.document?.documentElement?.removeAttribute( + ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE + ) + } catch { + // Best-effort diagnostics only. + } +} + +/** Read and clear the published reason so a stale one can't label a later failure. */ +export function consumeShutdownCheckpointFailureReason(): string | null { + try { + const root = globalThis.document?.documentElement + const reason = root?.getAttribute(ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE) + if (reason) { + root?.removeAttribute(ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE) + } + return reason || null + } catch { + return null + } +}