@colyseus/core 0.17.51 → 0.17.53

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.
package/src/MatchMaker.ts CHANGED
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
2
2
 
3
3
  import { requestFromIPC, subscribeIPC, subscribeWithTimeout } from './IPC.ts';
4
4
 
5
- import { type Type, Deferred, generateId, merge, retry, MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME, REMOTE_ROOM_SHORT_TIMEOUT, type MethodName, type RemoteRoomCallReturn } from './utils/Utils.ts';
5
+ import { type Type, Deferred, generateId, merge, retry, MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME, REMOTE_ROOM_SHORT_TIMEOUT, SWEPT_CHECK_INTERVAL, SWEPT_RECEIPT_TTL, type MethodName, type RemoteRoomCallReturn } from './utils/Utils.ts';
6
6
  import { isDevMode, cacheRoomHistory, getPreviousProcessId, getRoomRestoreListKey, reloadFromCache } from './utils/DevMode.ts';
7
7
 
8
8
  import { RegisteredHandler } from './matchmaker/RegisteredHandler.ts';
@@ -174,6 +174,8 @@ export async function accept(isStandalone: boolean = false) {
174
174
  * (`stats.excludeProcess()`) by mistake due to health-check failure
175
175
  */
176
176
  stats.setAutoPersistInterval();
177
+
178
+ startSweptCheck();
177
179
  }
178
180
 
179
181
  state = MatchMakerState.READY;
@@ -774,6 +776,9 @@ export async function gracefullyShutdown(): Promise<any> {
774
776
  // unsubscribe from process id channel
775
777
  presence.unsubscribe(getProcessChannel());
776
778
 
779
+ // only now: rooms may take minutes to drain, and must stay joinable meanwhile
780
+ clearInterval(sweptCheckInterval);
781
+
777
782
  // make sure all rooms are disposed
778
783
  return Promise.all(disconnectAll(
779
784
  (isDevMode)
@@ -1026,13 +1031,24 @@ export function healthCheckProcessId(processId: string) {
1026
1031
  resolve(true)
1027
1032
 
1028
1033
  } catch (e) {
1029
- // process failed to respond - remove it from stats
1030
1034
  logger.debug(`❌ Process '${processId}' failed to respond. Cleaning it up.`);
1031
- await stats.excludeProcess(processId);
1032
1035
 
1033
- // clean-up possibly stale room ids
1034
- if (!isDevMode) {
1035
- await removeRoomsByProcessId(processId);
1036
+ try {
1037
+ // stop placing new rooms on it. if it is still alive, it re-adds itself
1038
+ // the next time it persists its stats.
1039
+ await stats.excludeProcess(processId);
1040
+
1041
+ // clean-up possibly stale room ids
1042
+ if (!isDevMode) {
1043
+ await removeRoomsByProcessId(processId);
1044
+
1045
+ // receipt goes AFTER the removal: written before, a merely stalled
1046
+ // process could restore its listings ahead of the delete
1047
+ await presence.setex(getProcessSweptKey(processId), '1', SWEPT_RECEIPT_TTL);
1048
+ }
1049
+
1050
+ } catch (e) {
1051
+ debugMatchMaking(e); // presence/driver failing must not leave the caller hanging
1036
1052
  }
1037
1053
 
1038
1054
  resolve(false);
@@ -1056,6 +1072,68 @@ async function removeRoomsByProcessId(processId: string) {
1056
1072
  await driver.cleanup(processId);
1057
1073
  }
1058
1074
 
1075
+ let sweptCheckInterval: NodeJS.Timeout | undefined;
1076
+ let isCheckingSwept = false;
1077
+
1078
+ function startSweptCheck() {
1079
+ clearInterval(sweptCheckInterval); // accept() may be called more than once
1080
+
1081
+ sweptCheckInterval = setInterval(async () => {
1082
+ if (isCheckingSwept) { return; } // a slow presence would stack them up
1083
+ isCheckingSwept = true;
1084
+
1085
+ try {
1086
+ await restoreIfSwept();
1087
+
1088
+ } catch (e) {
1089
+ debugMatchMaking(e); // a failed presence call must not crash the process
1090
+
1091
+ } finally {
1092
+ isCheckingSwept = false;
1093
+ }
1094
+ }, SWEPT_CHECK_INTERVAL);
1095
+
1096
+ sweptCheckInterval.unref(); // don't keep the process running just for this
1097
+ }
1098
+
1099
+ /**
1100
+ * A peer that failed to reach this process removed our room listings, and
1101
+ * left a receipt. If we are reading it, we were only stalled: re-create them.
1102
+ */
1103
+ async function restoreIfSwept() {
1104
+ if (!await presence.exists(getProcessSweptKey())) { return; }
1105
+
1106
+ // consume before restoring: a removal landing mid-restore leaves a new receipt
1107
+ await presence.del(getProcessSweptKey());
1108
+ await restoreMissingRoomCaches();
1109
+ }
1110
+
1111
+ /**
1112
+ * Re-create the listings of rooms running on this process that are missing
1113
+ * from the driver.
1114
+ *
1115
+ * Peers remove a process's listings when they believe it is dead. If this
1116
+ * process was only stalled, its rooms are still running, and without their
1117
+ * listings nobody could join them anymore.
1118
+ */
1119
+ export async function restoreMissingRoomCaches() {
1120
+ const roomIds = Object.keys(rooms);
1121
+ if (roomIds.length === 0) { return; }
1122
+
1123
+ // one lookup per room: 0.17 drivers have no batch lookup, and this only runs after a sweep
1124
+ const listed = await Promise.all(roomIds.map((roomId) => driver.has(roomId)));
1125
+
1126
+ const missing = roomIds
1127
+ .filter((_, i) => !listed[i])
1128
+ .map((roomId) => rooms[roomId])
1129
+ .filter((room) =>
1130
+ room?.['_internalState'] === RoomInternalState.CREATED // still here, not disposing
1131
+ );
1132
+
1133
+ debugMatchMaking('restoring %s missing room listing(s)', missing.length);
1134
+ await Promise.all(missing.map((room) => driver.persist(room['_listing'], true)));
1135
+ }
1136
+
1059
1137
  async function createRoomReferences(room: Room, init: boolean = false): Promise<boolean> {
1060
1138
  rooms[room.roomId] = room;
1061
1139
 
@@ -1212,3 +1290,7 @@ function getConcurrencyHashKey(roomName: string) {
1212
1290
  function getProcessChannel(id: string = processId) {
1213
1291
  return `p:${id}`;
1214
1292
  }
1293
+
1294
+ function getProcessSweptKey(id: string = processId) {
1295
+ return `swept:${id}`;
1296
+ }
package/src/Transport.ts CHANGED
@@ -132,7 +132,18 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
132
132
  reconnectionToken: string;
133
133
 
134
134
  // TODO: move these to ClientPrivate
135
+ /**
136
+ * Send raw bytes over the transport's reliable channel.
137
+ *
138
+ * The transport owns `data` from this call on: it may write it later (a
139
+ * socket with backpressure, permessage-deflate), so callers never mutate it
140
+ * afterwards. The same `data` may be handed to several clients.
141
+ */
135
142
  raw(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
143
+ /**
144
+ * {@link raw}, or held until the client finishes joining (or until after the
145
+ * next patch, with `afterNextPatch`). Owns `data` the same way {@link raw} does.
146
+ */
136
147
  enqueueRaw(data: Uint8Array | Buffer, options?: ISendOptions): void;
137
148
 
138
149
  /**
@@ -106,6 +106,8 @@ export class LocalPresence implements Presence {
106
106
  }
107
107
 
108
108
  public del(key: string) {
109
+ clearTimeout(this.timeouts[key]); // a pending expiry would wipe whatever is set next
110
+ delete this.timeouts[key];
109
111
  delete this.keys[key];
110
112
  delete this.data[key];
111
113
  delete this.hash[key];
@@ -41,23 +41,38 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
41
41
  public getFullState(client?: Client) {
42
42
  if (this.needFullEncode || this.encoder.root.changes.next !== undefined) {
43
43
  this.sharedOffsetCache = { offset: 1 };
44
- this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer);
44
+ // copy: the next full encode reuses the buffer (slice(): a pooled Buffer would pin 8KB)
45
+ this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer).slice();
46
+ this.fitFullEncodeBuffer(this.sharedOffsetCache.offset);
45
47
  this.needFullEncode = false;
46
48
  }
47
49
 
48
50
  if (this.hasFilters && client?.view) {
49
- return this.encoder.encodeAllView(
50
- client.view,
51
- this.sharedOffsetCache.offset,
52
- { ...this.sharedOffsetCache },
53
- this.fullEncodeBuffer
54
- );
51
+ const it = { ...this.sharedOffsetCache };
52
+ const encoded = this.encoder.encodeAllView(client.view, this.sharedOffsetCache.offset, it, this.fullEncodeBuffer);
53
+ this.fitFullEncodeBuffer(it.offset);
54
+ return encoded;
55
55
 
56
56
  } else {
57
57
  return this.fullEncodeCache;
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * Grow {@link fullEncodeBuffer} once an encode outgrew it. The encoder grows
63
+ * a buffer it is handed for that one encode only, so otherwise every full
64
+ * encode overflows again and per-view encodes read a truncated shared part.
65
+ *
66
+ * `byteLength` is the iterator's final offset, not the returned length:
67
+ * before @colyseus/schema 4.0.27, a per-view encode that overflows returns
68
+ * its bytes cut short at the old buffer size.
69
+ */
70
+ protected fitFullEncodeBuffer(byteLength: number) {
71
+ if (byteLength <= this.fullEncodeBuffer.byteLength) { return; }
72
+ this.fullEncodeBuffer = new Uint8Array(byteLength + Encoder.BUFFER_SIZE);
73
+ this.fullEncodeBuffer.set(this.fullEncodeCache);
74
+ }
75
+
61
76
  public applyPatches(clients: Client[]) {
62
77
  let numClients = clients.length;
63
78
 
@@ -119,6 +134,9 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
119
134
  const encodedChanges = this.encoder.encode(it);
120
135
 
121
136
  if (!this.hasFilters) {
137
+ // copy once for all clients: a transport may still hold it after the next encode (see Client.raw)
138
+ const frame = Buffer.from(encodedChanges);
139
+
122
140
  while (numClients--) {
123
141
  const client = clients[numClients];
124
142
 
@@ -129,7 +147,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
129
147
  continue;
130
148
  }
131
149
 
132
- client.raw(encodedChanges);
150
+ client.raw(frame);
133
151
  }
134
152
 
135
153
  } else {
@@ -154,7 +172,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
154
172
  // allow to pass the same encoded view for multiple clients
155
173
  if (encodedView === undefined) {
156
174
  encodedView = (view === SHARED_VIEW)
157
- ? encodedChanges
175
+ ? Buffer.from(encodedChanges) // encodeView already returns a copy
158
176
  : this.encoder.encodeView(client.view, sharedOffset, it);
159
177
  this.encodedViews.set(view, encodedView);
160
178
  }
@@ -44,6 +44,12 @@ export type RemoteRoomCallReturn<
44
44
  export const REMOTE_ROOM_SHORT_TIMEOUT = Number(process.env.COLYSEUS_PRESENCE_SHORT_TIMEOUT || 2000);
45
45
  export const MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME = Number(process.env.COLYSEUS_MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME || 0.5);
46
46
 
47
+ // a peer that removes this process's room listings leaves a receipt. this
48
+ // process looks for it every INTERVAL (ms) and restores them. the TTL (seconds)
49
+ // only bounds what dead processes leave behind.
50
+ export const SWEPT_CHECK_INTERVAL = Number(process.env.COLYSEUS_SWEPT_CHECK_INTERVAL || 5000);
51
+ export const SWEPT_RECEIPT_TTL = Number(process.env.COLYSEUS_SWEPT_RECEIPT_TTL || 600);
52
+
47
53
  export function generateId(length: number = 9) {
48
54
  return nanoid(length);
49
55
  }