vimp-engine 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/client/components/controller/RoomMenu.js +38 -10
  3. package/src/client/components/model/RoomMenu.js +42 -57
  4. package/src/client/components/view/RoomMenu.js +78 -24
  5. package/src/client/lib/autoRejoin.js +161 -0
  6. package/src/client/lib/hostUnloadGuard.js +40 -15
  7. package/src/client/lib/roomLink.js +32 -0
  8. package/src/client/main.js +116 -36
  9. package/src/client/network/HostConnectionManager.js +27 -1
  10. package/src/client/network/HostController.js +1 -30
  11. package/src/client/network/HostHealthPolicy.js +14 -59
  12. package/src/client/network/PlannedHandoff.js +15 -100
  13. package/src/client/network/SessionSupervisor.js +58 -9
  14. package/src/client/network/SignalingClient.js +1 -1
  15. package/src/client/network/WebRtcManager.js +38 -3
  16. package/src/client/session/GuestSession.js +42 -7
  17. package/src/client/session/HandoffFlow.js +26 -51
  18. package/src/client/session/HostRole.js +8 -6
  19. package/src/client/session/PromotionFlow.js +28 -1
  20. package/src/client/session/RouteBoot.js +28 -9
  21. package/src/client/style.css +31 -16
  22. package/src/client/views/includes/roomMenu.pug +7 -8
  23. package/src/config/chatMessages.js +1 -1
  24. package/src/config/lobby.js +22 -23
  25. package/src/devtools/ScenarioRunner.js +1 -0
  26. package/src/devtools/invariants.js +24 -2
  27. package/src/devtools/report.js +2 -2
  28. package/src/host/HostGame.js +14 -26
  29. package/src/host/PortMachine.js +34 -17
  30. package/src/host/host.worker.js +1 -13
  31. package/src/host/meta/core/RoundManager.js +0 -21
  32. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  33. package/src/lib/capabilities.js +2 -4
  34. package/src/lib/gameConfigView.js +0 -6
@@ -6,9 +6,9 @@
6
6
  // возвращается гостем RESUME'ом к новому хосту, иначе — уход в лобби.
7
7
  //
8
8
  // Исход у передачи один, и объявляется он один раз. Дедлайн deadlineMs
9
- // отсчитывается от handoff_begin и перекрывает handoffTimeoutMs мастера с
10
- // запасом на дорогу ответа: обычно отмену объявляет мастер
11
- // (handoff_aborted), дедлайн — страховка. Медленный ответ (нет handoff_go за
9
+ // отсчитывается от handoff_begin и перекрывает ожидание мастером первой
10
+ // точки беты и его handoffTimeoutMs с запасом на дорогу ответа: обычно
11
+ // отмену объявляет мастер (handoff_aborted), дедлайн — страховка. Медленный ответ (нет handoff_go за
12
12
  // slowAfterMs) — не отказ, а повод показать «медленная связь» (onSlow):
13
13
  // поздний handoff_go до дедлайна штатно замораживает матч и шлёт финальную
14
14
  // точку — иначе бета откатила бы всех к периодической.
@@ -19,13 +19,8 @@
19
19
  // host_leaving мастеру — аварийная миграция с последней периодической
20
20
  // точкой (при обрыве сигналинга без него: обрыв WS мастер видит сам).
21
21
  //
22
- // Отложенная передача (defer, этап 8d): handoff_begin уходит на границе
23
- // раунда (HostController.awaitRoundBoundary — игра без migration.midRound
24
- // иначе отняла бы раунд у всех; у игры с midRound Worker отвечает сразу),
25
- // но не позже deferMaxMs. Пока ожидание идёт, передача уже начата: повтор
26
- // игнорируется, а дедлайн и onSlow отсчитываются от handoff_begin.
27
- // Автотриггеры (этап 9b) ожидание сокращают (hurry — жёсткая перегрузка,
28
- // скрытая вкладка) или снимают (cancelDeferred — нагрузка нормализовалась).
22
+ // handoff_begin уходит сразу, посреди раунда: финальная точка несёт дамп
23
+ // ядра, и бета продолжает раунд с того же тика.
29
24
  //
30
25
  // Без DOM: сигналинг, Worker хоста и таймеры инъектируются.
31
26
 
@@ -63,9 +58,7 @@ export default class PlannedHandoff {
63
58
  * @param {number} [options.slowAfterMs] - нет handoff_go за это время —
64
59
  * onSlow (передача продолжается).
65
60
  * @param {number} [options.deadlineMs] - общий дедлайн передачи от
66
- * handoff_begin (больше handoffTimeoutMs мастера).
67
- * @param {number} [options.deferMaxMs] - потолок ожидания границы раунда
68
- * отложенной передачи.
61
+ * handoff_begin (больше handoffBetaWaitMs + handoffTimeoutMs мастера).
69
62
  * @param {Function} [options.onSlow] - () мастер отвечает медленно.
70
63
  * @param {Function} [options.onFrozen] - ({ epoch }) матч заморожен,
71
64
  * финальная точка запрошена; epoch — эпоха преемника.
@@ -81,8 +74,7 @@ export default class PlannedHandoff {
81
74
  getRoom,
82
75
  getController,
83
76
  slowAfterMs = 3000,
84
- deadlineMs = 10000,
85
- deferMaxMs = 30000,
77
+ deadlineMs = 12000,
86
78
  onSlow = () => {},
87
79
  onFrozen = () => {},
88
80
  onReleased = () => {},
@@ -95,7 +87,6 @@ export default class PlannedHandoff {
95
87
  this._getController = getController;
96
88
  this._slowAfterMs = slowAfterMs;
97
89
  this._deadlineMs = deadlineMs;
98
- this._deferMaxMs = deferMaxMs;
99
90
  this._onSlow = onSlow;
100
91
  this._onFrozen = onFrozen;
101
92
  this._onReleasedCb = onReleased;
@@ -103,15 +94,13 @@ export default class PlannedHandoff {
103
94
  this._onLeave = onLeave;
104
95
  this._timers = timers;
105
96
 
106
- // null | 'deferred' (ждём границы раунда) | 'requested' (ждём
107
- // handoff_go) | 'frozen' (ждём host_released)
97
+ // null | 'requested' (ждём handoff_go) | 'frozen' (ждём host_released)
108
98
  this._phase = null;
109
99
  this._room = null;
110
100
  this._reason = null;
111
101
  this._stay = true;
112
102
  this._slowTimer = null;
113
103
  this._deadlineTimer = null;
114
- this._deferTimer = null;
115
104
  }
116
105
 
117
106
  // передача идёт: повторный start игнорируется
@@ -119,14 +108,9 @@ export default class PlannedHandoff {
119
108
  return this._phase !== null;
120
109
  }
121
110
 
122
- // передача ждёт границы раунда: матч ещё идёт, мастер ещё не спрошен
123
- get deferred() {
124
- return this._phase === 'deferred';
125
- }
126
-
127
- // причина идущей передачи (null — передачи нет)
128
- get reason() {
129
- return this._reason;
111
+ // матч заморожен в ожидании нового хоста
112
+ get frozen() {
113
+ return this._phase === 'frozen';
130
114
  }
131
115
 
132
116
  // идёт передача уходящего хоста (stay: false)
@@ -140,14 +124,12 @@ export default class PlannedHandoff {
140
124
  * @param {string} options.reason - 'leave' | 'handover' | 'overload' |
141
125
  * 'hidden' | 'network' (этап 9) | 'vote' (этап 10).
142
126
  * @param {boolean} [options.stay] - остаться в комнате гостем.
143
- * @param {boolean} [options.defer] - дождаться границы раунда.
144
127
  * @returns {boolean} false — передача уже идёт или вкладка не хост.
145
128
  */
146
- start({ reason, stay = true, defer = false }) {
129
+ start({ reason, stay = true }) {
147
130
  const room = this._getRoom();
148
- const controller = this._getController();
149
131
 
150
- if (this.active || !room || !controller) {
132
+ if (this.active || !room || !this._getController()) {
151
133
  return false;
152
134
  }
153
135
 
@@ -159,65 +141,11 @@ export default class PlannedHandoff {
159
141
  this._signaling.publisher.on(type, handler, this);
160
142
  }
161
143
 
162
- if (!defer) {
163
- this._begin();
164
- return true;
165
- }
166
-
167
- this._phase = 'deferred';
168
- this._deferTimer = this._timers.setTimeout(() => {
169
- this._deferTimer = null;
170
- this._getController()?.cancelRoundBoundary();
171
- this._begin();
172
- }, this._deferMaxMs);
173
- controller.awaitRoundBoundary(() => this._onBoundary());
174
-
175
- return true;
176
- }
177
-
178
- /**
179
- * Не ждать границы раунда: вопрос мастеру сразу, с новой причиной.
180
- * @param {string} [reason] - причина вместо исходной.
181
- * @returns {boolean} false — передача не в ожидании границы.
182
- */
183
- hurry(reason = this._reason) {
184
- if (this._phase !== 'deferred') {
185
- return false;
186
- }
187
-
188
- this._clearDeferTimer();
189
- this._getController()?.cancelRoundBoundary();
190
- this._reason = reason;
191
144
  this._begin();
192
145
 
193
146
  return true;
194
147
  }
195
148
 
196
- /**
197
- * Снять передачу, ждущую границы раунда, — тихо, без колбэков (мастер
198
- * ещё не спрашивался, матч не замораживался).
199
- * @returns {boolean} false — передача не в ожидании границы.
200
- */
201
- cancelDeferred() {
202
- if (this._phase !== 'deferred') {
203
- return false;
204
- }
205
-
206
- this._finish();
207
-
208
- return true;
209
- }
210
-
211
- // граница раунда наступила (у игры с midRound — сразу)
212
- _onBoundary() {
213
- if (this._phase !== 'deferred') {
214
- return;
215
- }
216
-
217
- this._clearDeferTimer();
218
- this._begin();
219
- }
220
-
221
149
  // вопрос мастеру: с этого момента идут onSlow и дедлайн
222
150
  _begin() {
223
151
  const room = this._room;
@@ -274,10 +202,9 @@ export default class PlannedHandoff {
274
202
  }
275
203
  }
276
204
 
277
- // бета не заняла комнату: эпоха прежняя, матч продолжается здесь.
278
- // Отложенная передача мастера ещё не спрашивала — отмена не про неё
205
+ // бета не заняла комнату: эпоха прежняя, матч продолжается здесь
279
206
  _onAborted(msg) {
280
- if (this._ownRoom(msg) && this._phase !== 'deferred') {
207
+ if (this._ownRoom(msg)) {
281
208
  this._cancel('aborted');
282
209
  }
283
210
  }
@@ -329,11 +256,6 @@ export default class PlannedHandoff {
329
256
  _finish() {
330
257
  this._clearSlowTimer();
331
258
 
332
- if (this._phase === 'deferred') {
333
- this._clearDeferTimer();
334
- this._getController()?.cancelRoundBoundary();
335
- }
336
-
337
259
  if (this._deadlineTimer !== null) {
338
260
  this._timers.clearTimeout(this._deadlineTimer);
339
261
  this._deadlineTimer = null;
@@ -350,13 +272,6 @@ export default class PlannedHandoff {
350
272
  this._reason = null;
351
273
  }
352
274
 
353
- _clearDeferTimer() {
354
- if (this._deferTimer !== null) {
355
- this._timers.clearTimeout(this._deferTimer);
356
- this._deferTimer = null;
357
- }
358
- }
359
-
360
275
  _clearSlowTimer() {
361
276
  if (this._slowTimer !== null) {
362
277
  this._timers.clearTimeout(this._slowTimer);
@@ -12,7 +12,8 @@ import wsports from '../../config/wsports.js';
12
12
  // принимает владелец (client/main.js) по колбэкам.
13
13
  //
14
14
  // Состояния: connecting → handshake → inGame → reconnecting → inGame | closed;
15
- // любое живое → migrating → reconnecting (новый хост) | closed.
15
+ // любое живое → migrating → reconnecting (новый хост) | closed. Вход, каналы
16
+ // которого не открылись, повторяется в connecting (joinRetries).
16
17
 
17
18
  const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
18
19
 
@@ -68,10 +69,13 @@ export default class SessionSupervisor {
68
69
  * @param {Object|null} [opts.reconnect] - null — переподключения нет
69
70
  * (solo, вкладка-хост на loopback). Иначе:
70
71
  * { createTransport() → новый транспорт с resume (уже connect'нутый),
71
- * getToken() → identity-токен для RESUME_REQUEST }.
72
+ * getToken() → identity-токен для RESUME_REQUEST,
73
+ * createJoinTransport() → новый транспорт обычного входа (повтор
74
+ * входа, каналы которого не открылись; без него такой сбой
75
+ * терминален) }.
72
76
  * @param {Object} [opts.timing] - { reconnectWindowMs, reconnectBaseDelayMs,
73
77
  * reconnectMaxDelayMs, hostSilenceMs, migrationWaitMs,
74
- * resumeSilenceGraceMs }
78
+ * resumeSilenceGraceMs, joinRetries }
75
79
  * (config/lobby.js → session).
76
80
  * @param {Object} [opts.clock] - часы и таймеры (тесты).
77
81
  */
@@ -108,8 +112,11 @@ export default class SessionSupervisor {
108
112
  this._silenceMs = timing.hostSilenceMs ?? 3000;
109
113
  this._migrationWaitMs = timing.migrationWaitMs ?? 20000;
110
114
  this._resumeSilenceGraceMs = timing.resumeSilenceGraceMs ?? 3000;
115
+ this._joinRetries = timing.joinRetries ?? 2;
111
116
 
112
117
  this._state = S.connecting;
118
+ // каналы хоть одного транспорта открывались (P2P поднималось)
119
+ this._everOpened = false;
113
120
  this._transport = null;
114
121
  this._handlers = null;
115
122
 
@@ -126,6 +133,8 @@ export default class SessionSupervisor {
126
133
  this._awaitingFrame = false;
127
134
 
128
135
  this._attempt = 0;
136
+ // повторы первого входа (каналы ещё ни разу не открывались)
137
+ this._joinAttempt = 0;
129
138
  this._attemptTimer = null;
130
139
  this._windowTimer = null;
131
140
  this._migrationTimer = null;
@@ -139,6 +148,12 @@ export default class SessionSupervisor {
139
148
  return this._state;
140
149
  }
141
150
 
151
+ // каналы транспорта открывались хоть раз: false у закрытой сессии —
152
+ // P2P так и не поднялось (несостоявшийся вход)
153
+ get everOpened() {
154
+ return this._everOpened;
155
+ }
156
+
142
157
  get transport() {
143
158
  return this._transport;
144
159
  }
@@ -552,6 +567,8 @@ export default class SessionSupervisor {
552
567
  return;
553
568
  }
554
569
 
570
+ this._everOpened = true;
571
+
555
572
  if (this._state === S.connecting) {
556
573
  this._setState(S.handshake);
557
574
  return;
@@ -605,9 +622,40 @@ export default class SessionSupervisor {
605
622
  return;
606
623
  }
607
624
 
625
+ if (this._canRetryJoin()) {
626
+ this._scheduleJoinRetry();
627
+ return;
628
+ }
629
+
608
630
  this._terminate();
609
631
  }
610
632
 
633
+ // вход, каналы которого ни разу не открылись (сбой WebRTC-согласования,
634
+ // ICE не сошёлся), повторяется свежим транспортом: такие сбои часто
635
+ // разовые. Закрытие входа после открытия каналов терминально
636
+ _canRetryJoin() {
637
+ return (
638
+ this._state === S.connecting &&
639
+ !this._everOpened &&
640
+ !this._userLeft &&
641
+ typeof this._reconnect?.createJoinTransport === 'function' &&
642
+ this._joinAttempt < this._joinRetries &&
643
+ !this._isTerminalClose()
644
+ );
645
+ }
646
+
647
+ _scheduleJoinRetry() {
648
+ this._joinAttempt += 1;
649
+
650
+ this._attemptTimer = this._clock.setTimeout(() => {
651
+ this._attemptTimer = null;
652
+
653
+ if (this._state === S.connecting && !this._userLeft) {
654
+ this._setTransport(this._reconnect.createJoinTransport());
655
+ }
656
+ }, this._backoffDelay(this._joinAttempt));
657
+ }
658
+
611
659
  // нетерминальным бывает только обрыв посреди матча, у которого есть место
612
660
  // для возврата и кому возвращаться
613
661
  _canResume() {
@@ -666,17 +714,18 @@ export default class SessionSupervisor {
666
714
  }
667
715
 
668
716
  _scheduleAttempt() {
669
- const delay = Math.min(
670
- this._maxDelayMs,
671
- this._baseDelayMs * 2 ** (this._attempt - 1),
672
- );
673
-
674
717
  this._attemptTimer = this._clock.setTimeout(
675
718
  () => this._runAttempt(),
676
- delay,
719
+ this._backoffDelay(this._attempt),
677
720
  );
678
721
  }
679
722
 
723
+ // пауза перед попыткой номер attempt (с 1): экспонента от
724
+ // reconnectBaseDelayMs, не выше reconnectMaxDelayMs
725
+ _backoffDelay(attempt) {
726
+ return Math.min(this._maxDelayMs, this._baseDelayMs * 2 ** (attempt - 1));
727
+ }
728
+
680
729
  _clearReconnectTimers() {
681
730
  if (this._attemptTimer !== null) {
682
731
  this._clock.clearTimeout(this._attemptTimer);
@@ -217,7 +217,7 @@ export default class SignalingClient {
217
217
  });
218
218
  }
219
219
 
220
- // хост → вкладка уходит (pagehide, «Leave server» без передачи): мастер
220
+ // хост → вкладка уходит (beforeunload/pagehide, Exit без передачи): мастер
221
221
  // начинает аварийную миграцию сразу, не дожидаясь обрыва WS
222
222
  hostLeaving(roomId, epoch) {
223
223
  this._send({ type: 'host_leaving', roomId, epoch });
@@ -120,6 +120,8 @@ export default class WebRtcManager {
120
120
  this._offerRetryMs = offerRetryMs;
121
121
  this._retryTimer = null;
122
122
  this._offerSent = false;
123
+ // ответ хоста принят в работу: следующие ответы на этот оффер — дубли
124
+ this._answerApplying = false;
123
125
  // свои ICE-кандидаты: повтор оффера отправляет их заново — прежние
124
126
  // ушли хосту, который оффер не принял
125
127
  this._sentCandidates = [];
@@ -149,8 +151,19 @@ export default class WebRtcManager {
149
151
  return this._iceType;
150
152
  }
151
153
 
152
- // инициирует установку соединения с текущим хостом комнаты
154
+ // инициирует установку соединения с текущим хостом комнаты. Не
155
+ // отклоняется: любой сбой установки (pc, каналы, оффер) = обрыв
156
+ // транспорта, повторы — у супервизора; уже закрытый менеджер _emitClose
157
+ // не тронет
153
158
  async connect(roomId) {
159
+ try {
160
+ await this._negotiate(roomId);
161
+ } catch (e) {
162
+ this._emitClose();
163
+ }
164
+ }
165
+
166
+ async _negotiate(roomId) {
154
167
  this._roomId = roomId;
155
168
 
156
169
  const pc = this._peerFactory({ iceServers: this._iceServers });
@@ -245,10 +258,13 @@ export default class WebRtcManager {
245
258
  return;
246
259
  }
247
260
 
261
+ // ответ хоста уже применяется (_answerApplying): эпоха ставится лишь
262
+ // после setRemoteDescription, но повторять оффер уже поздно
248
263
  if (
249
264
  msg?.code !== 'migrating' ||
250
265
  this._closed ||
251
266
  !this._offerSent ||
267
+ this._answerApplying ||
252
268
  this._epoch !== null ||
253
269
  this._retryTimer !== null
254
270
  ) {
@@ -299,11 +315,30 @@ export default class WebRtcManager {
299
315
  return;
300
316
  }
301
317
 
318
+ // дубль или опоздавший ответ: соединение уже согласовано. Флаг ловит
319
+ // дубль, пришедший, пока первый ответ ещё применяется (signalingState
320
+ // меняется лишь по завершении setRemoteDescription)
321
+ if (
322
+ this._answerApplying ||
323
+ this._pc.signalingState !== 'have-local-offer'
324
+ ) {
325
+ return;
326
+ }
327
+
328
+ this._answerApplying = true;
329
+
330
+ try {
331
+ await this._pc.setRemoteDescription(msg.sdp);
332
+ } catch (e) {
333
+ // сбой согласования = обрыв транспорта: переподключение решает
334
+ // супервизор
335
+ this._emitClose();
336
+ return;
337
+ }
338
+
302
339
  if (msg.epoch !== undefined && msg.epoch !== null) {
303
340
  this._epoch = msg.epoch;
304
341
  }
305
-
306
- await this._pc.setRemoteDescription(msg.sdp);
307
342
  }
308
343
 
309
344
  // приём удалённого ICE-кандидата (от хоста)
@@ -43,8 +43,10 @@ export default class GuestSession {
43
43
  * startSession(transport, reconnect), closeLobby(),
44
44
  * leaveRoomWith(text), chat(message), getVote() → модуль голосования }.
45
45
  * @param {Object} [deps.factories] - конструкторы (тесты).
46
- * @param {Object} [deps.timers] - { setTimeout }.
46
+ * @param {Object} [deps.timers] - { setTimeout, clearTimeout }.
47
47
  * @param {Function} [deps.now]
48
+ * @param {EventTarget|null} [deps.network] - источник события 'online'
49
+ * (window).
48
50
  */
49
51
  constructor({
50
52
  signaling,
@@ -62,6 +64,7 @@ export default class GuestSession {
62
64
  factories = {},
63
65
  timers = globalThis,
64
66
  now = () => Date.now(),
67
+ network = globalThis.window ?? null,
65
68
  }) {
66
69
  this._signaling = signaling;
67
70
  this._ctx = ctx;
@@ -81,6 +84,7 @@ export default class GuestSession {
81
84
  };
82
85
  this._timers = timers;
83
86
  this._now = now;
87
+ this._network = network;
84
88
 
85
89
  // WebRTC-транспорты к хосту комнаты, созданные этой сессией
86
90
  this._roomTransports = new WeakSet();
@@ -112,11 +116,13 @@ export default class GuestSession {
112
116
  this._ui.closeLobby();
113
117
  }
114
118
 
115
- // попытки переподключения гостя: resume к текущему хосту комнаты
119
+ // попытки переподключения гостя: resume к текущему хосту комнаты; повтор
120
+ // несостоявшегося входа — обычным рукопожатием
116
121
  reconnect() {
117
122
  return {
118
123
  createTransport: () =>
119
124
  this.openTransport(this._ctx.roomId, { resume: true }),
125
+ createJoinTransport: () => this.openTransport(this._ctx.roomId),
120
126
  getToken: () => this._getToken(),
121
127
  };
122
128
  }
@@ -143,15 +149,24 @@ export default class GuestSession {
143
149
  );
144
150
  // канал standby открывает хост только назначенной бете
145
151
  transport.publisher.on('standby', channel => this._standby.attach(channel));
146
- transport.connect(roomId).catch(() => transport.close());
152
+ // сбой установки connect сам превращает в 'close' транспорта
153
+ transport.connect(roomId);
147
154
 
148
155
  return transport;
149
156
  }
150
157
 
151
- // оффер ушёл, каналы ещё не открыты: unknownRoom в этот момент —
152
- // комнаты нет
158
+ // оффер ушёл, каналы ещё не открыты (или вход ждёт повтора после
159
+ // несостоявшейся попытки): unknownRoom в этот момент — комнаты нет
153
160
  offerPending() {
154
- const transport = this._getSupervisor()?.transport;
161
+ const supervisor = this._getSupervisor();
162
+ const transport = supervisor?.transport ?? null;
163
+
164
+ if (transport === null) {
165
+ return (
166
+ supervisor?.state === SESSION_STATES.connecting &&
167
+ !supervisor.everOpened
168
+ );
169
+ }
155
170
 
156
171
  return this._roomTransports.has(transport) && !transport.isOpen;
157
172
  }
@@ -176,6 +191,8 @@ export default class GuestSession {
176
191
  bind() {
177
192
  const publisher = this._signaling.publisher;
178
193
  let reconnectAttempt = 0;
194
+ // отложенная попытка бэкоффа (null — сигналинг жив или уже подключается)
195
+ let reconnectTimer = null;
179
196
 
180
197
  publisher.on('close', () => {
181
198
  const {
@@ -196,7 +213,25 @@ export default class GuestSession {
196
213
  );
197
214
 
198
215
  reconnectAttempt += 1;
199
- this._timers.setTimeout(() => this._signaling.connect(), delay);
216
+ // повторный close до срабатывания: попытка остаётся одна
217
+ this._timers.clearTimeout(reconnectTimer);
218
+ reconnectTimer = this._timers.setTimeout(() => {
219
+ reconnectTimer = null;
220
+ this._signaling.connect();
221
+ }, delay);
222
+ });
223
+
224
+ // сеть вернулась: ступень бэкоффа (до reconnect.maxDelay) ждать нельзя —
225
+ // место бывшего хоста у преемника держится меньше
226
+ this._network?.addEventListener('online', () => {
227
+ if (reconnectTimer === null) {
228
+ return;
229
+ }
230
+
231
+ this._timers.clearTimeout(reconnectTimer);
232
+ reconnectTimer = null;
233
+ reconnectAttempt = 0;
234
+ this._signaling.connect();
200
235
  });
201
236
 
202
237
  publisher.on('welcome', () => {