summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/actioncable_connection_monitor.js
blob: fc4e436c7fb34568363c98129590775c06981ac7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/* eslint-disable no-restricted-globals */

import { logger } from '@rails/actioncable';

// This is based on https://github.com/rails/rails/blob/5a477890c809d4a17dc0dede43c6b8cef81d8175/actioncable/app/javascript/action_cable/connection_monitor.js
// so that we can take advantage of the improved reconnection logic. We can remove this once we upgrade @rails/actioncable to a version that includes this.

// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting
// revival reconnections if things go astray. Internal class, not intended for direct user manipulation.

const now = () => new Date().getTime();

const secondsSince = (time) => (now() - time) / 1000;
class ConnectionMonitor {
  constructor(connection) {
    this.visibilityDidChange = this.visibilityDidChange.bind(this);
    this.connection = connection;
    this.reconnectAttempts = 0;
  }

  start() {
    if (!this.isRunning()) {
      this.startedAt = now();
      delete this.stoppedAt;
      this.startPolling();
      addEventListener('visibilitychange', this.visibilityDidChange);
      logger.log(
        `ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`,
      );
    }
  }

  stop() {
    if (this.isRunning()) {
      this.stoppedAt = now();
      this.stopPolling();
      removeEventListener('visibilitychange', this.visibilityDidChange);
      logger.log('ConnectionMonitor stopped');
    }
  }

  isRunning() {
    return this.startedAt && !this.stoppedAt;
  }

  recordPing() {
    this.pingedAt = now();
  }

  recordConnect() {
    this.reconnectAttempts = 0;
    this.recordPing();
    delete this.disconnectedAt;
    logger.log('ConnectionMonitor recorded connect');
  }

  recordDisconnect() {
    this.disconnectedAt = now();
    logger.log('ConnectionMonitor recorded disconnect');
  }

  // Private

  startPolling() {
    this.stopPolling();
    this.poll();
  }

  stopPolling() {
    clearTimeout(this.pollTimeout);
  }

  poll() {
    this.pollTimeout = setTimeout(() => {
      this.reconnectIfStale();
      this.poll();
    }, this.getPollInterval());
  }

  getPollInterval() {
    const { staleThreshold, reconnectionBackoffRate } = this.constructor;
    const backoff = (1 + reconnectionBackoffRate) ** Math.min(this.reconnectAttempts, 10);
    const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate;
    const jitter = jitterMax * Math.random();
    return staleThreshold * 1000 * backoff * (1 + jitter);
  }

  reconnectIfStale() {
    if (this.connectionIsStale()) {
      logger.log(
        `ConnectionMonitor detected stale connection. reconnectAttempts = ${
          this.reconnectAttempts
        }, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${
          this.constructor.staleThreshold
        } s`,
      );
      this.reconnectAttempts += 1;
      if (this.disconnectedRecently()) {
        logger.log(
          `ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(
            this.disconnectedAt,
          )} s`,
        );
      } else {
        logger.log('ConnectionMonitor reopening');
        this.connection.reopen();
      }
    }
  }

  get refreshedAt() {
    return this.pingedAt ? this.pingedAt : this.startedAt;
  }

  connectionIsStale() {
    return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
  }

  disconnectedRecently() {
    return (
      this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold
    );
  }

  visibilityDidChange() {
    if (document.visibilityState === 'visible') {
      setTimeout(() => {
        if (this.connectionIsStale() || !this.connection.isOpen()) {
          logger.log(
            `ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`,
          );
          this.connection.reopen();
        }
      }, 200);
    }
  }
}

ConnectionMonitor.staleThreshold = 6; // Server::Connections::BEAT_INTERVAL * 2 (missed two pings)
ConnectionMonitor.reconnectionBackoffRate = 0.15;

export default ConnectionMonitor;