Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-8722] - Improve weblish retry behavior #11067

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
22 changes: 21 additions & 1 deletion packages/manager/src/components/ErrorState/ErrorState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Grid from '@mui/material/Unstable_Grid2';
import { SxProps, Theme, styled, useTheme } from '@mui/material/styles';
import * as React from 'react';

import { Button } from 'src/components/Button/Button';
import { Typography } from 'src/components/Typography';

import { SvgIconProps } from '../SvgIcon';
Expand All @@ -28,10 +29,19 @@ export interface ErrorStateProps {
* Styles applied to the error text
*/
typographySx?: SxProps<Theme>;
/**
* Optional Action Button: Text
*/
actionButtonText?: string;
/**
* Optional Action Button: Click handler
*/
onActionButtonClick?: ()=>void;
plisiecki1 marked this conversation as resolved.
Show resolved Hide resolved
}

export const ErrorState = (props: ErrorStateProps) => {
const { CustomIcon, compact, typographySx } = props;
const { CustomIcon, compact, typographySx,
actionButtonText, onActionButtonClick } = props;
const theme = useTheme();

const sxIcon = {
Expand Down Expand Up @@ -72,6 +82,16 @@ export const ErrorState = (props: ErrorStateProps) => {
) : (
<div style={{ textAlign: 'center' }}>{props.errorText}</div>
)}
{actionButtonText ? (
<div style={{ textAlign: 'center' }}>
<Button
title={actionButtonText}
onClick={() => { onActionButtonClick?.() }}
>
{actionButtonText}
</Button>
</div>
): ( null )}
</Grid>
</ErrorStateRoot>
);
Expand Down
42 changes: 40 additions & 2 deletions packages/manager/src/features/Lish/Lish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,48 @@ import type { Tab } from 'src/components/Tabs/TabLinkList';

const AUTH_POLLING_INTERVAL = 2000;

export class RetryLimiter {
maxTries: number;
perTimeWindowMs: number;
retryTimes: number[];

constructor(maxTries: number, perTimeWindowMs: number) {
this.maxTries = maxTries;
this.perTimeWindowMs = perTimeWindowMs;
this.retryTimes = [];
}
retryAllowed(): boolean {
const now = Date.now();
this.retryTimes.push(now);
const cutOffTime = now-this.perTimeWindowMs;
while (this.retryTimes.length && this.retryTimes[0] < cutOffTime)
this.retryTimes.shift();
return this.retryTimes.length < this.maxTries;
}
reset(): void {
this.retryTimes = [];
}
}

export function formatError(errObj: any, defaultError: string): string {
let error = defaultError;
if (typeof errObj === 'string') {
error = errObj;
} else if (errObj?.reason) {
error = `${errObj?.reason}`;
} else if (errObj?.errors?.[0]?.reason) {
error = `Error code: ${errObj.errors[0].reason}`;
}
if (errObj?.grn) {
error = `${error} (${errObj?.grn})`;
}
return error;
plisiecki1 marked this conversation as resolved.
Show resolved Hide resolved
}

const Lish = () => {
const history = useHistory();

const { isLoading: isMakingInitalRequests } = useInitialRequests();
const { isLoading: isMakingInitialRequests } = useInitialRequests();

const { linodeId, type } = useParams<{ linodeId: string; type: string }>();
const id = Number(linodeId);
Expand All @@ -44,7 +82,7 @@ const Lish = () => {
refetch,
} = useLinodeLishQuery(id);

const isLoading = isLinodeLoading || isTokenLoading || isMakingInitalRequests;
const isLoading = isLinodeLoading || isTokenLoading || isMakingInitialRequests;

React.useEffect(() => {
const interval = setInterval(checkAuthentication, AUTH_POLLING_INTERVAL);
Expand Down
125 changes: 90 additions & 35 deletions packages/manager/src/features/Lish/Weblish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';

import { CircleProgress } from 'src/components/CircleProgress';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { formatError, RetryLimiter } from 'src/features/Lish/Lish';

import type { Linode } from '@linode/api-v4/lib/linodes';
import type { LinodeLishData } from '@linode/api-v4/lib/linodes';
Expand All @@ -16,15 +17,19 @@ interface Props extends Pick<LinodeLishData, 'weblish_url' | 'ws_protocols'> {
interface State {
error: string;
renderingLish: boolean;
setFocus: boolean;
}

export class Weblish extends React.Component<Props, State> {
mounted: boolean = false;
socket: WebSocket;
socket: WebSocket | null;
retryLimiter: RetryLimiter = new RetryLimiter(3, 60000);
lastMessage: string = '';

state: State = {
error: '',
renderingLish: true,
setFocus: false,
};
terminal: Terminal;

Expand All @@ -35,16 +40,17 @@ export class Weblish extends React.Component<Props, State> {

componentDidUpdate(prevProps: Props) {
/*
* If we have a new token, refresh the webosocket connection
* If we have a new token, refresh the websocket connection
* and console with the new token
*/
if (
this.props.weblish_url !== prevProps.weblish_url ||
JSON.stringify(this.props.ws_protocols) !==
JSON.stringify(prevProps.ws_protocols)
) {
this.socket.close();
this.terminal.dispose();
this.socket?.close();
this.terminal?.dispose();
this.setState({ renderingLish: false });
this.connect();
}
}
Expand All @@ -56,13 +62,59 @@ export class Weblish extends React.Component<Props, State> {
connect() {
const { weblish_url, ws_protocols } = this.props;

this.socket = new WebSocket(weblish_url, ws_protocols);
/* When this.socket != origSocket, the socket from this connect()
* call has been closed and possibly replaced by a new socket. */
const origSocket = new WebSocket(weblish_url, ws_protocols);
this.socket = origSocket;

this.lastMessage = '';
this.setState({error: ''});

this.socket.addEventListener('open', () => {
if (!this.mounted) {
return;
}
this.setState({ renderingLish: true }, () => this.renderTerminal());
this.setState({ renderingLish: true }, () => this.renderTerminal(origSocket));
});

this.socket.addEventListener('close', (evt) => {
if (this.socket != origSocket) {
plisiecki1 marked this conversation as resolved.
Show resolved Hide resolved
return;
}
this.socket = null;
this.terminal?.dispose();
this.setState({ renderingLish: false });
if (!this.mounted) {
return;
}

let parsed = null;
if (evt?.reason) {
try {
parsed = JSON.parse(evt.reason);
} catch {
}
}
if (parsed === null) {
try {
parsed = JSON.parse(this.lastMessage);
} catch {
}
}
plisiecki1 marked this conversation as resolved.
Show resolved Hide resolved

if (this.retryLimiter.retryAllowed()) {
if (parsed?.errors?.[0]?.reason === "expired" ||
(parsed?.type === "error" &&
typeof parsed?.reason === "string" &&
parsed?.reason.toLowerCase() === "your session has expired.")) {
hana-akamai marked this conversation as resolved.
Show resolved Hide resolved
const { refreshToken } = this.props;
refreshToken();
} else {
this.connect();
}
} else {
this.setState({error: formatError(parsed, "Unexpected WebSocket close")});
}
plisiecki1 marked this conversation as resolved.
Show resolved Hide resolved
});
}

Expand All @@ -74,6 +126,11 @@ export class Weblish extends React.Component<Props, State> {
<ErrorState
errorText={error}
typographySx={(theme) => ({ color: theme.palette.common.white })}
actionButtonText='Retry Connection'
onActionButtonClick={()=>{
this.retryLimiter.reset();
this.props.refreshToken();
}}
/>
);
}
Expand Down Expand Up @@ -102,10 +159,25 @@ export class Weblish extends React.Component<Props, State> {
);
}

renderTerminal() {
const { linode, refreshToken } = this.props;
renderTerminal(origSocket: WebSocket) {
const { linode } = this.props;
const { group, label } = linode;

if (this.socket === null) {
return;
}
const socket: WebSocket = this.socket;

if (socket != origSocket) {
return;
}
plisiecki1 marked this conversation as resolved.
Show resolved Hide resolved

/* The socket might have already started to fail by the time we
* get here. Leave handling for the close handler. */
if (socket.readyState !== socket.OPEN) {
return;
}

this.terminal = new Terminal({
cols: 80,
cursorBlink: true,
Expand All @@ -114,33 +186,25 @@ export class Weblish extends React.Component<Props, State> {
screenReaderMode: true,
});

this.terminal.onData((data: string) => this.socket.send(data));
this.setState({ setFocus: true }, () => this.terminal.focus());

this.terminal.onData((data: string) => socket.send(data));
const terminalDiv = document.getElementById('terminal');
this.terminal.open(terminalDiv as HTMLElement);

this.terminal.writeln('\x1b[32mLinode Lish Console\x1b[m');

this.socket.addEventListener('message', (evt) => {
let data;

socket.addEventListener('message', (evt) => {
/*
* data is either going to be command line strings
* or it's going to look like {type: 'error', reason: 'thing failed'}
* the latter can be JSON parsed and the other cannot
*
* The actual handling of errors will be done in the 'close'
* handler. Allow the error to be rendered in the terminal in
* case it is actually valid session content that is not
* then followed by a 'close' message.
*/
try {
data = JSON.parse(evt.data);
} catch {
data = evt.data;
}

if (
data?.type === 'error' &&
data?.reason?.toLowerCase() === 'your session has expired.'
) {
refreshToken();
return;
}
this.lastMessage = evt.data;

try {
this.terminal.write(evt.data);
Expand All @@ -157,15 +221,6 @@ export class Weblish extends React.Component<Props, State> {
}
});

this.socket.addEventListener('close', () => {
this.terminal.dispose();
if (!this.mounted) {
return;
}

this.setState({ renderingLish: false });
});

const linodeLabel = group ? `${group}/${label}` : label;
window.document.title = `${linodeLabel} - Linode Lish Console`;
}
Expand Down