From f8d06646f04813bc2d7a7a6e1ef154ef2855884e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Tue, 28 May 2024 11:43:26 +0200 Subject: [PATCH] Handle unexpected shutdown in Electron app --- .../teleterm/src/services/tshdEvents/index.ts | 4 ++ .../TopBar/Connections/Connections.story.tsx | 31 +++++++++++ .../src/ui/Vnet/VnetConnectionItem.tsx | 5 +- .../teleterm/src/ui/Vnet/VnetSliderStep.tsx | 16 ++++-- .../teleterm/src/ui/Vnet/vnetContext.test.tsx | 52 ++++++++++++++++++- .../teleterm/src/ui/Vnet/vnetContext.tsx | 44 ++++++++++++++-- web/packages/teleterm/src/ui/appContext.ts | 33 +++++++++++- web/packages/teleterm/src/ui/tshdEvents.ts | 14 +++++ web/packages/teleterm/src/ui/types.ts | 21 ++++++++ 9 files changed, 210 insertions(+), 10 deletions(-) diff --git a/web/packages/teleterm/src/services/tshdEvents/index.ts b/web/packages/teleterm/src/services/tshdEvents/index.ts index 84c2df2d0fd52..6b6d5a2edb60d 100644 --- a/web/packages/teleterm/src/services/tshdEvents/index.ts +++ b/web/packages/teleterm/src/services/tshdEvents/index.ts @@ -223,6 +223,10 @@ function createService(logger: Logger): { getUsageReportingSettings: (call, callback) => { processEvent('getUsageReportingSettings', call, callback); }, + + reportUnexpectedVnetShutdown: (call, callback) => { + processEvent('reportUnexpectedVnetShutdown', call, callback); + }, }; return { service, setupTshdEventContextBridgeService }; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx index 47fe727ab873f..21b2654f8e32f 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx @@ -150,6 +150,37 @@ export function VnetError() { ); } +export function VnetUnexpectedShutdown() { + const appContext = new MockAppContext(); + prepareAppContext(appContext); + + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.vnet.start = () => { + setTimeout(() => { + appContext.unexpectedVnetShutdownListener({ + error: 'lorem ipsum dolor sit amet', + }); + }, 0); + return new MockedUnaryCall({}); + }; + + return ( + + + + + + + + ); +} + export function WithScroll() { const appContext = new MockAppContext(); prepareAppContext(appContext); diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx index 46079ff3403ab..50d626ce8ffc6 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx @@ -86,7 +86,10 @@ const VnetConnectionItemBase = forwardRef( startAttempt.status === 'processing' || stopAttempt.status === 'processing'; const indicatorStatus = - startAttempt.status === 'error' || stopAttempt.status === 'error' + startAttempt.status === 'error' || + stopAttempt.status === 'error' || + (status.value === 'stopped' && + status.reason.value === 'unexpected-shutdown') ? 'error' : status.value === 'running' ? 'on' diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx index 59c4c904a85aa..04f653d8a8071 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx @@ -77,9 +77,19 @@ export const VnetSliderStep = (props: StepComponentProps) => { Could not stop VNet: {stopAttempt.statusText} )} - {status.value === 'stopped' && ( - VNet automatically authenticates connections to TCP apps. - )} + {status.value === 'stopped' && + (status.reason.value === 'unexpected-shutdown' ? ( + + VNet unexpectedly shut down:{' '} + {status.reason.errorMessage || + 'no direct reason was given, please check logs'} + . + + ) : ( + + VNet automatically authenticates connections to TCP apps. + + ))} {status.value === 'running' && diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx index bf42c27fd3120..21360ee5e1872 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx @@ -24,7 +24,12 @@ import { IAppContext } from 'teleterm/ui/types'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; -import { VnetContextProvider, useVnetContext } from './vnetContext'; +import { + VnetContextProvider, + VnetStatus, + VnetStoppedReason, + useVnetContext, +} from './vnetContext'; describe('autostart', () => { it('starts VNet if turned on', async () => { @@ -143,6 +148,51 @@ describe('autostart', () => { }); }); +it('registers a callback for unexpected shutdown', async () => { + const appContext = new MockAppContext(); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + + const { result } = renderHook(() => useVnetContext(), { + wrapper: createWrapper(Wrapper, { appContext }), + }); + + await waitFor( + () => expect(result.current.startAttempt.status).toEqual('success'), + { interval: 5 } + ); + + // Trigger unexpected shutdown. + act(() => { + appContext.unexpectedVnetShutdownListener({ + error: 'lorem ipsum dolor sit amet', + }); + }); + + await waitFor( + () => { + expect(result.current.status.value).toEqual('stopped'); + }, + { interval: 5 } + ); + + const status = result.current.status as Extract< + VnetStatus, + { value: 'stopped' } + >; + expect(status.reason.value).toEqual('unexpected-shutdown'); + const reason = status.reason as Extract< + VnetStoppedReason, + { value: 'unexpected-shutdown' } + >; + expect(reason.errorMessage).toEqual('lorem ipsum dolor sit amet'); +}); + const Wrapper = (props: PropsWithChildren<{ appContext: IAppContext }>) => ( {props.children} diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index 5fe3d82e306ac..4db7891fc3310 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -49,13 +49,23 @@ export type VnetContext = { stopAttempt: Attempt; }; -export type VnetStatus = { value: 'running' } | { value: 'stopped' }; +export type VnetStatus = + | { value: 'running' } + | { value: 'stopped'; reason: VnetStoppedReason }; + +export type VnetStoppedReason = + | { value: 'regular-shutdown-or-not-started' } + | { value: 'unexpected-shutdown'; errorMessage: string }; export const VnetContext = createContext(null); export const VnetContextProvider: FC = props => { - const [status, setStatus] = useState({ value: 'stopped' }); - const { vnet, mainProcessClient } = useAppContext(); + const [status, setStatus] = useState({ + value: 'stopped', + reason: { value: 'regular-shutdown-or-not-started' }, + }); + const appCtx = useAppContext(); + const { vnet, mainProcessClient, notificationsService } = appCtx; const isWorkspaceStateInitialized = useStoreSelector( 'workspacesService', useCallback(state => state.isInitialized, []) @@ -80,7 +90,10 @@ export const VnetContextProvider: FC = props => { const [stopAttempt, stop] = useAsync( useCallback(async () => { await vnet.stop({}); - setStatus({ value: 'stopped' }); + setStatus({ + value: 'stopped', + reason: { value: 'regular-shutdown-or-not-started' }, + }); setAppState({ autoStart: false }); }, [vnet, setAppState]) ); @@ -106,6 +119,29 @@ export const VnetContextProvider: FC = props => { handleAutoStart(); }, [isWorkspaceStateInitialized]); + useEffect( + function handleUnexpectedShutdown() { + const removeListener = appCtx.addUnexpectedVnetShutdownListener( + ({ error }) => { + setStatus({ + value: 'stopped', + reason: { value: 'unexpected-shutdown', errorMessage: error }, + }); + + notificationsService.notifyError({ + title: 'VNet has unexpectedly shut down', + description: error + ? `Reason: ${error}` + : 'No reason was given, check the logs for more details.', + }); + } + ); + + return removeListener; + }, + [appCtx, notificationsService] + ); + return ( void { + this._unexpectedVnetShutdownListener = listener; + + return () => { + this._unexpectedVnetShutdownListener = undefined; + }; + } + + /** + * unexpectedVnetShutdownListener gets called by tshd events service when it gets a report about + * said shutdown from tsh daemon. + * + * The communication between tshd events service and VnetContext is done through a callback on + * AppContext. That's because tshd events service lives outside of React but within the same + * process (renderer). + */ + // To force callsites to use addUnexpectedVnetShutdownListener instead of setting the property + // directly on appContext, we use a getter which exposes a private property. + get unexpectedVnetShutdownListener(): UnexpectedVnetShutdownListener { + return this._unexpectedVnetShutdownListener; + } + private subscribeToDeepLinkLaunch() { this.mainProcessClient.subscribeToDeepLinkLaunch(result => { this.deepLinksService.launchDeepLink(result).catch(error => { diff --git a/web/packages/teleterm/src/ui/tshdEvents.ts b/web/packages/teleterm/src/ui/tshdEvents.ts index 3817982d76c0c..c48e48faa1481 100644 --- a/web/packages/teleterm/src/ui/tshdEvents.ts +++ b/web/packages/teleterm/src/ui/tshdEvents.ts @@ -18,10 +18,13 @@ import { TshdEventContextBridgeService } from 'teleterm/types'; import { IAppContext } from 'teleterm/ui/types'; +import Logger from 'teleterm/logger'; export function createTshdEventsContextBridgeService( ctx: IAppContext ): TshdEventContextBridgeService { + const logger = new Logger('tshd events UI'); + return { relogin: async ({ request, onRequestCancelled }) => { await ctx.reloginService.relogin(request, onRequestCancelled); @@ -87,5 +90,16 @@ export function createTshdEventsContextBridgeService( }, }; }, + + reportUnexpectedVnetShutdown: async ({ request }) => { + if (!ctx.unexpectedVnetShutdownListener) { + logger.warn( + `Dropping unexpected VNet shutdown event, no listener present; error: ${request.error}` + ); + } else { + ctx.unexpectedVnetShutdownListener(request); + } + return {}; + }, }; } diff --git a/web/packages/teleterm/src/ui/types.ts b/web/packages/teleterm/src/ui/types.ts index ae1c243007c07..e68c0412c5372 100644 --- a/web/packages/teleterm/src/ui/types.ts +++ b/web/packages/teleterm/src/ui/types.ts @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import * as tshdEventsApi from 'gen-proto-ts/teleport/lib/teleterm/v1/tshd_events_service_pb'; import { MainProcessClient, @@ -65,4 +66,24 @@ export interface IAppContext { vnet: VnetClient; pullInitialState(): Promise; + + /** + * addUnexpectedVnetShutdownListener sets the listener and returns a cleanup function. + */ + addUnexpectedVnetShutdownListener: ( + listener: UnexpectedVnetShutdownListener + ) => () => void; + /** + * unexpectedVnetShutdownListener gets called by tshd events service when it gets a report about + * said shutdown from tsh daemon. + * + * The communication between tshd events service and VnetContext is done through a callback on + * AppContext. That's because tshd events service lives outside of React but within the same + * process (renderer). + */ + unexpectedVnetShutdownListener: UnexpectedVnetShutdownListener | undefined; } + +export type UnexpectedVnetShutdownListener = ( + request: tshdEventsApi.ReportUnexpectedVnetShutdownRequest +) => void;