Saturday, February 29, 2020

Cancel an in-progress web socket call

I'm working on a React application where a new web socket connection is created each time a long-running query request is made (and closes when the request completes).  This is done purposefully as the use case for the application doesn't require the connection to stay open constantly.  A problem that appeared was when a request was made, the user decided to change the input parameters and make another new request; sometimes the first web socket call returns first with the incorrect results displayed on the screen.

Environment:
React v16.2.0

The Bug
The bug was because the original web socket request was never closed prior to making another one.  Below is the original utility function that does all the web socket calls, where a new web socket client connection is instantiated each time:
wsUtils.js
/**
 * Sets up WebSocket connection, opens, processes message received, and updates the UI based on type and data returned.
 * @param {String} requestMessage - A string in JSON format: the parameters required to process the query
 * @param {function} updateHandler - Function executed when data is retrieved
 */
const connectWebSocket = (requestMessage, updateHandler) => {
  const socket = new WebSocket(`wss://cherryshoetech.com/wsapi/`);

  // socket event handlers ... 
  ...
  // end of socket event handlers ...
}

Possible Solutions
1. Have a websocket module manage the one web socket client connection// the one websocket client that can be instantiated at one time.  The websocket module contains the socket variable along with two functions to manage setting the socket and closing the socket.  The connectWebSocket utility function called will use these imported functions to close the socket each time a new connection is called, then set it after the connection is made.  The close socket event handler needs to handle when the event code was the user requested closure, and to not do anything when this happens.

websocket.js
let websocket = null;

/**
 * Retrieves the websocket
 */
const retrieveWebsocket = () => {
  return websocket;
};

/**
 * Saves the websocket
 * @param {Object} socket 
 */
const saveWebsocket = socket => {
  websocket = socket;
};

module.exports = {
  retrieveWebsocket,
  saveWebsocket,
};

wsUtils.js
import { retrieveWebsocket, saveWebsocket } from 'websocket';

const WEBSOCKET_USER_CLOSURE = 4002;

const connectWebSocket = (requestMessage, updateHandler) => {
  // close socket and reset if necessary.  This is to protect in the
  // scenarios where:
  // 1. User submits request before the prior request is finished.
  // 2. User navigates away from the page before the query is returned, canceling
  // the current query.  
  // If these scenarios are not accounted for, it's possible a "navigated away from" query's
  // response could come back and be displayed as the result, when a user put in a subsequent query.
  let socket = retrieveWebsocket();
  if (socket != null) {
    socket.close(WEBSOCKET_USER_CLOSURE, "User requested closure");
    saveWebsocket(null);
  }

  socket = new WebSocket(`wss://cherryshoetech.com/wsapi/`);
  saveWebsocket(socket);

  // socket event handlers ... 
  ...
  socket.onclose = event => {
    if (event.code === WEBSOCKET_NORMAL_CLOSURE) {
      ...
    } else if (event.code === WEBSOCKET_USER_CLOSURE) {
      // do nothing.  this is the event type called by managing component
      console.log(`WebSocket Closed (${event.reason})`);
    }
    else {
      ...
    }
  };
  ...
  // end of socket event handlers ...
}



2. Have each component that needs a web socket client connection manage the web socket.  Solve with a Higher Order Component. The HOC contains the socket variable along with two functions to manage setting the socket and closing the socket.  The connectWebSocket utility function called from within CherryShoeComponentWithSocket will use these functions passed as parameters to close the socket each time a new connection is called, then set it after the connection is made.  The close socket event handler needs to handle when the event code was the user requested closure, and to not do anything when this happens.

withSocketHOC.js
import React from 'react';
const WEBSOCKET_USER_CLOSURE = 4002;

/**
 * This is a higher order component for any component that needs to manage its own client
 * web socket.
 * @param {Object} WrappedComponent 
 */
function withSocketHOC(WrappedComponent) {
  return class WithSocketHOC extends React.Component {
    constructor(props) {
      super(props);

      // client web socket managed by component
      this.socket = null;

      this.setSocket = this.setSocket.bind(this);
      this.closeSocket = this.closeSocket.bind(this);
    }

    componentWillUnmount() {
      // Clean up client web socket managed by this component
      this.closeSocket();
    }

    /**
     * Sets the web socket managed by this component.
     * @param {Object} socket
     */
    setSocket(socket) {
      this.socket = socket;
    }

    /**
     * If the socket managed by this component is instantiated, close and reset it.
     */
    closeSocket() {
      if (this.socket != null) {
        this.socket.close(WEBSOCKET_USER_CLOSURE, "User requested closure");
        this.setSocket(null);
      }
    }

    render() {
      const props = {
        setSocket: this.setSocket,
        closeSocket: this.closeSocket,
        ...this.props,
      };
      return <WrappedComponent {...props}/>;
    }
  };
}

export {
  withSocketHOC,
};

cherryShoeComponent.js
import { withSocketHOC } from 'withSocketHOC';

class CherryShoeComponent extends Component {
...
}

const CherryShoeComponentWithSocket = withSocketHOC(connect(mapStateToProps)(CherryShoeComponent));
export default CherryShoeComponentWithSocket;

wsUtils.js
const WEBSOCKET_USER_CLOSURE = 4002;

/**
 * Sets up WebSocket connection, opens, processes message received, and updates the UI based on type and data returned.
 * @param {function} setSocketHandler - Function executed to set socket managed by calling component
 * @param {function} closeSocketHandler - Function executed to close and reset socket managed by
 *   calling component
 * @param {String} requestMessage - A string in JSON format: the parameters required to process the query
 * @param {function} updateHandler - Function executed when data is retrieved
 */
const connectWebSocket = (requestMessage, setSocketHandler, closeSocketHandler, updateHandler) => {
  // close socket and reset if necessary.  This is to protect in the
  // scenarios where:
  // 1. User submits request before the prior request is finished.
  // 2. User navigates away from the page before the query is returned, canceling
  // the current query.  
  // If these scenarios are not accounted for, it's possible a "navigated away from" query's
  // response could come back and be displayed as the result, when a user put in a subsequent query.
  closeSocketHandler();

  const socket = new WebSocket(`wss://cherryshoetech.com/wsapi/`);

  // set socket managed by calling component
  setSocketHandler(socket);

  // socket event handlers ... 
  ...
  socket.onclose = event => {
    if (event.code === WEBSOCKET_NORMAL_CLOSURE) {
      ...
    } else if (event.code === WEBSOCKET_USER_CLOSURE) {
      // do nothing.  this is the event type called by managing component
      console.log(`WebSocket Closed (${event.reason})`);
    }
    else {
      ...
    }
  };
  ...
  // end of socket event handlers ...
}

For both possible solutions the only backend Node.js change I had to make was to print the reason code.
wssUtils.js
 /**
 * For each server, Sets up a WebSocketServer and WebSocket event handlers for that WebSocketServer.
 * @param {Object} server - The server
 */
const websocketserver = server => {
  const wss = new WebSocketServer({server: server});

  wss.on('connection', ws => {
    // socket event handlers ... 
    ...
      ws.on('close', function (ws, code) {
        console.info(`websocket connection close; code: ${code}`);
      });
    ...
    // end of socket event handlers ...
  });
};


No comments:

Post a Comment

I appreciate your time in leaving a comment!