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

Socket connection is dropped on link download #4436

Open
volodymyr-ilnytskyi-tfs opened this issue Jul 29, 2022 · 8 comments
Open

Socket connection is dropped on link download #4436

volodymyr-ilnytskyi-tfs opened this issue Jul 29, 2022 · 8 comments
Labels
unable to reproduce We were unable to reproduce the issue

Comments

@volodymyr-ilnytskyi-tfs

Describe the bug
Socket connection drops after download is initiated via hidden href link download in chrome. Chome : Version 103.0.5060.134 (Official Build) (64-bit), but it was reproducable with any recent version

To Reproduce

I've added code in main.js (see below) that will log to console all socket events to console and extra logic that checks each sent message text === "download" resulting a hidden link to be added and clicked which initiates download of index.html with browser default file open dialog.

// Sends a chat message
  const sendMessage = () => {
    let message = $inputMessage.val();
    // Prevent markup from being injected into the message
    message = cleanInput(message);
    // if there is a non-empty message and a socket connection
    if (message && connected) {
      $inputMessage.val('');
      addChatMessage({ username, message });
      // tell server to execute 'new message' and send along one parameter
      socket.emit('new message', message);

      if(message === 'download'){
        const a = document.createElement('a');
        a.href = "/index.html";
        a.download = `arm64.pkg`;
        //a.target = '_blank';
        a.click();
        a.remove();
      }
    }
  }
  1. Use Chat sample from this socketio repository https://github.com/socketio/socket.io/tree/main/examples/chat
  2. change version of socket.io to 4.5.1 and npm install
  3. launch server (npm start)
  4. launch 2 chat windows in chrome, open dev tools and observe socket events are working (e.g. when typeing you will see events are comming to connected clients)
  5. initiate download from one of clients
  6. observe that client that initiated download has dropped ws connection and doesn't receive any socket notification before it reconnects

WORKAROUNDS:

  1. use a.target = '_blank'; - but this creates negative UX by opening dialog in another window
  2. use empty target iframe for downloads <iframe src="about:blank" name="iframe_for_file_downloads"></iframe>

Socket.IO server/client version: 4.5.1

Client:
full main.js code

$(function() {
  const FADE_TIME = 150; // ms
  const TYPING_TIMER_LENGTH = 400; // ms
  const COLORS = [
    '#e21400', '#91580f', '#f8a700', '#f78b00',
    '#58dc00', '#287b00', '#a8f07a', '#4ae8c4',
    '#3b88eb', '#3824aa', '#a700ff', '#d300e7'
  ];

  // Initialize variables
  const $window = $(window);
  const $usernameInput = $('.usernameInput'); // Input for username
  const $messages = $('.messages');           // Messages area
  const $inputMessage = $('.inputMessage');   // Input message input box

  const $loginPage = $('.login.page');        // The login page
  const $chatPage = $('.chat.page');          // The chatroom page

  const socket = io();
  socket.onAny((eventName, ...args) => {
    console.log(eventName, args);
 });
  // Prompt for setting a username
  let username;
  let connected = false;
  let typing = false;
  let lastTypingTime;
  let $currentInput = $usernameInput.focus();

  const addParticipantsMessage = (data) => {
    let message = '';
    if (data.numUsers === 1) {
      message += `there's 1 participant`;
    } else {
      message += `there are ${data.numUsers} participants`;
    }
    log(message);
  }

  // Sets the client's username
  const setUsername = () => {
    username = cleanInput($usernameInput.val().trim());

    // If the username is valid
    if (username) {
      $loginPage.fadeOut();
      $chatPage.show();
      $loginPage.off('click');
      $currentInput = $inputMessage.focus();

      // Tell the server your username
      socket.emit('add user', username);
    }
  }

  // Sends a chat message
  const sendMessage = () => {
    let message = $inputMessage.val();
    // Prevent markup from being injected into the message
    message = cleanInput(message);
    // if there is a non-empty message and a socket connection
    if (message && connected) {
      $inputMessage.val('');
      addChatMessage({ username, message });
      // tell server to execute 'new message' and send along one parameter
      socket.emit('new message', message);

      if(message === 'download'){
        const a = document.createElement('a');
        a.href = "/index.html";
        a.download = `index.html`;
        //a.target = '_blank';
        a.click();
        a.remove();
      }
    }
  }

  // Log a message
  const log = (message, options) => {
    const $el = $('<li>').addClass('log').text(message);
    addMessageElement($el, options);
  }

  // Adds the visual chat message to the message list
  const addChatMessage = (data, options = {}) => {
    // Don't fade the message in if there is an 'X was typing'
    const $typingMessages = getTypingMessages(data);
    if ($typingMessages.length !== 0) {
      options.fade = false;
      $typingMessages.remove();
    }

    const $usernameDiv = $('<span class="username"/>')
      .text(data.username)
      .css('color', getUsernameColor(data.username));
    const $messageBodyDiv = $('<span class="messageBody">')
      .text(data.message);

    const typingClass = data.typing ? 'typing' : '';
    const $messageDiv = $('<li class="message"/>')
      .data('username', data.username)
      .addClass(typingClass)
      .append($usernameDiv, $messageBodyDiv);

    addMessageElement($messageDiv, options);
  }

  // Adds the visual chat typing message
  const addChatTyping = (data) => {
    data.typing = true;
    data.message = 'is typing';
    addChatMessage(data);
  }

  // Removes the visual chat typing message
  const removeChatTyping = (data) => {
    getTypingMessages(data).fadeOut(function () {
      $(this).remove();
    });
  }

  // Adds a message element to the messages and scrolls to the bottom
  // el - The element to add as a message
  // options.fade - If the element should fade-in (default = true)
  // options.prepend - If the element should prepend
  //   all other messages (default = false)
  const addMessageElement = (el, options) => {
    const $el = $(el);
    // Setup default options
    if (!options) {
      options = {};
    }
    if (typeof options.fade === 'undefined') {
      options.fade = true;
    }
    if (typeof options.prepend === 'undefined') {
      options.prepend = false;
    }

    // Apply options
    if (options.fade) {
      $el.hide().fadeIn(FADE_TIME);
    }
    if (options.prepend) {
      $messages.prepend($el);
    } else {
      $messages.append($el);
    }

    $messages[0].scrollTop = $messages[0].scrollHeight;
  }

  // Prevents input from having injected markup
  const cleanInput = (input) => {
    return $('<div/>').text(input).html();
  }

  // Updates the typing event
  const updateTyping = () => {
    if (connected) {
      if (!typing) {
        typing = true;
        socket.emit('typing');
      }
      lastTypingTime = (new Date()).getTime();

      setTimeout(() => {
        const typingTimer = (new Date()).getTime();
        const timeDiff = typingTimer - lastTypingTime;
        if (timeDiff >= TYPING_TIMER_LENGTH && typing) {
          socket.emit('stop typing');
          typing = false;
        }
      }, TYPING_TIMER_LENGTH);
    }
  }

  // Gets the 'X is typing' messages of a user
  const getTypingMessages = (data) => {
    return $('.typing.message').filter(function (i) {
      return $(this).data('username') === data.username;
    });
  }

  // Gets the color of a username through our hash function
  const getUsernameColor = (username) => {
    // Compute hash code
    let hash = 7;
    for (let i = 0; i < username.length; i++) {
      hash = username.charCodeAt(i) + (hash << 5) - hash;
    }
    // Calculate color
    const index = Math.abs(hash % COLORS.length);
    return COLORS[index];
  }

  // Keyboard events

  $window.keydown(event => {
    // Auto-focus the current input when a key is typed
    if (!(event.ctrlKey || event.metaKey || event.altKey)) {
      $currentInput.focus();
    }
    // When the client hits ENTER on their keyboard
    if (event.which === 13) {
      if (username) {
        sendMessage();
        socket.emit('stop typing');
        typing = false;
      } else {
        setUsername();
      }
    }
  });

  $inputMessage.on('input', () => {
    updateTyping();
  });

  // Click events

  // Focus input when clicking anywhere on login page
  $loginPage.click(() => {
    $currentInput.focus();
  });

  // Focus input when clicking on the message input's border
  $inputMessage.click(() => {
    $inputMessage.focus();
  });

  // Socket events

  // Whenever the server emits 'login', log the login message
  socket.on('login', (data) => {
    connected = true;
    // Display the welcome message
    const message = 'Welcome to Socket.IO Chat – ';
    log(message, {
      prepend: true
    });
    addParticipantsMessage(data);
  });

  // Whenever the server emits 'new message', update the chat body
  socket.on('new message', (data) => {
    addChatMessage(data);
  });

  // Whenever the server emits 'user joined', log it in the chat body
  socket.on('user joined', (data) => {
    log(`${data.username} joined`);
    addParticipantsMessage(data);
  });

  // Whenever the server emits 'user left', log it in the chat body
  socket.on('user left', (data) => {
    log(`${data.username} left`);
    addParticipantsMessage(data);
    removeChatTyping(data);
  });

  // Whenever the server emits 'typing', show the typing message
  socket.on('typing', (data) => {
    addChatTyping(data);
  });

  // Whenever the server emits 'stop typing', kill the typing message
  socket.on('stop typing', (data) => {
    removeChatTyping(data);
  });

  socket.on('disconnect', () => {
    log('you have been disconnected');
  });

  socket.io.on('reconnect', () => {
    log('you have been reconnected');
    if (username) {
      socket.emit('add user', username);
    }
  });

  socket.io.on('reconnect_error', () => {
    log('attempt to reconnect has failed');
  });

});

Expected behavior
Socket connection is not dropped on download

Platform:

  • Device: [e.g. Dell xps 13 9360]
  • OS: [e.g. windows 11]

Additional context

@volodymyr-ilnytskyi-tfs volodymyr-ilnytskyi-tfs added the to triage Waiting to be triaged by a member of the team label Jul 29, 2022
@volodymyr-ilnytskyi-tfs volodymyr-ilnytskyi-tfs changed the title Socket connection is dropped on hidden link download Socket connection is dropped on link download Jul 29, 2022
@volodymyr-ilnytskyi-tfs
Copy link
Author

Hello! Could someone please answer this bug or recommend how to properly fix it

@CapLek
Copy link

CapLek commented Aug 10, 2022

Hi Volodymyr,

I have a similar issue and found out that everything should work with socket.io-client version 3.0.5. This client version should work with the latest socket.io server version.

@andrewhawk1ns
Copy link

Noticing this bug as well, still persisting on 4.5.3, our socket disconnects once a download link is clicked

@DanielPower
Copy link

I'm experiencing this as well with client 4.6.1.

@robertsutherland
Copy link

Same issue with client 4.5.4.

@haneenmahd
Copy link

Does anyone have a working demo where I can test this out?

darrachequesne added a commit to socketio/socket.io-fiddle that referenced this issue Jun 20, 2023
@darrachequesne
Copy link
Member

Hi! I wasn't able to reproduce the issue: https://github.com/socketio/socket.io-fiddle/tree/issues/socket.io/4436

Does setting closeOnBeforeunload: false have an impact?

Reference: https://socket.io/docs/v4/client-options/#closeonbeforeunload

@darrachequesne darrachequesne added unable to reproduce We were unable to reproduce the issue and removed to triage Waiting to be triaged by a member of the team labels Jun 20, 2023
@sladdky
Copy link

sladdky commented Jun 25, 2023

Ran into the same problem today and later found this issue.

Problem is with files that are on different host and unable to open in a browser. I.e. 'zip, exe, msi, ...'

https://github.com/sladdky/socket.io-fiddle

  • Fixes for now could be adding target="_blank" (but it might be blocked anyway as popup)
  • Having the downloaded file on the same origin.
  • Or right after link click add this code (the transport seems to drop without firing any error|exit|whatever event)
     ....
     a.download = ''
     a.click()
     socket.disconnect() //call disconnect first, client still thinks it's connected
     socket.connect()
    

darrachequesne added a commit to socketio/engine.io-client that referenced this issue Jun 28, 2023
Silently closing the connection when receiving a "beforeunload" event
is problematic, because it is emitted:

- when downloading a file from another host

Related: socketio/socket.io#4436

- when the user already has a listener for the "beforeunload" event
(i.e. "are you sure you want to leave this page?")

Related:

- #661
- #658
- socketio/socket.io#4065

That's why the `closeOnBeforeunload` option will now default to false.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
unable to reproduce We were unable to reproduce the issue
Projects
None yet
Development

No branches or pull requests

8 participants