@axpecter/lync 2.3.2 → 2.3.3

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/README.md CHANGED
@@ -16,7 +16,7 @@ Schemas, packets, queries, groups, validation, rate limiting. Every send batches
16
16
  Wally — add to your `wally.toml`:
17
17
 
18
18
  ```toml
19
- Lync = "axp3cter/lync@2.3.2"
19
+ Lync = "axp3cter/lync@2.3.3"
20
20
  ```
21
21
 
22
22
  npm (roblox-ts):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axpecter/lync",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security",
5
5
  "main": "src/init.luau",
6
6
  "types": "src/index.d.ts",
@@ -152,15 +152,13 @@ function Packet.define<T>(
152
152
  local payload = scratch.buff
153
153
  local payloadLen = scratch.cursor
154
154
  local server = Transport.server()
155
+ local getChannel = if isUnreliable
156
+ then server.getUnreliableChannel
157
+ else server.getReliableChannel
155
158
  -- Channel.writeBatchEncoded is hot-swapped by enableStats; re-read per call.
156
159
  local writeBatchEncoded = Channel.writeBatchEncoded
157
160
  for i = 1, count do
158
- writeBatchEncoded(
159
- server.getChannel(players[i], isUnreliable),
160
- reg,
161
- payload,
162
- payloadLen
163
- )
161
+ writeBatchEncoded(getChannel(players[i]), reg, payload, payloadLen)
164
162
  end
165
163
  Shared.releaseScratch()
166
164
  bumpFires()
@@ -44,10 +44,10 @@ local function writeQueryTracked(
44
44
  ): ()
45
45
  if Channel.statsEnabled() then
46
46
  local before = ch.cursor
47
- Channel.writeQuery(ch, reg.id, corrId, codec, data)
47
+ Channel.writeQuery(ch, reg.id, corrId, codec, data, reg.name)
48
48
  reg.bytesSent += ch.cursor - before
49
49
  else
50
- Channel.writeQuery(ch, reg.id, corrId, codec, data)
50
+ Channel.writeQuery(ch, reg.id, corrId, codec, data, reg.name)
51
51
  end
52
52
  end
53
53
 
@@ -17,11 +17,16 @@ local _freeThread: thread? = nil
17
17
 
18
18
  -- Private ----------------------------------------------------------------
19
19
 
20
+ -- pcall wrapper: a handler error must not consume the recycled thread, otherwise
21
+ -- every subsequent fire pays a coroutine.create until process restart.
20
22
  local function passer(fn: (...any) -> (), ...): ()
21
23
  local thread = _freeThread
22
24
  _freeThread = nil
23
- fn(...)
25
+ local ok, err = pcall(fn, ...)
24
26
  _freeThread = thread
27
+ if not ok then
28
+ task.spawn(error, err)
29
+ end
25
30
  end
26
31
 
27
32
  local function yielder(): ()
@@ -7,7 +7,7 @@ local Types = require(script.Parent.Parent.Types)
7
7
 
8
8
  -- Constants --------------------------------------------------------------
9
9
 
10
- local DEFAULT_MAX = 262144
10
+ local DEFAULT_MAX = 1048576
11
11
  local INITIAL_BUF = 1024
12
12
 
13
13
  -- State ------------------------------------------------------------------
@@ -53,7 +53,7 @@ local function makeBoolArray(
53
53
  write = function(ch: Types.ChannelState, value: { boolean }): ()
54
54
  local len = #value
55
55
  varintWrite(ch, len)
56
- packBoolArray(ch, value, len)
56
+ packBoolArray(ch, value, len, if len > 0 then boolBytes(len) else 0)
57
57
  end,
58
58
 
59
59
  read = function(src: buffer, pos: number, _refs: { Instance }?): ({ boolean }, number)
@@ -69,7 +69,7 @@ local function makeBoolArray(
69
69
  Log.error(`payload {byteCount}B exceeds remaining buffer`)
70
70
  end
71
71
  local result = table.create(len, false)
72
- unpackBoolArray(src, dataStart, len, result)
72
+ unpackBoolArray(src, dataStart, len, result, byteCount)
73
73
  return result, lenBytes + byteCount
74
74
  end,
75
75
  }
@@ -157,6 +157,11 @@ local function makeGenericArray(
157
157
  if len == 0 then
158
158
  return {}, lenBytes
159
159
  end
160
+ -- Every element occupies at least 1 byte; len > remaining is
161
+ -- unambiguously corrupt and would OOM table.create otherwise.
162
+ if len > bufLen(src) - (pos + lenBytes) then
163
+ Log.error(`array length {len} exceeds remaining buffer`)
164
+ end
160
165
 
161
166
  local result = table.create(len)
162
167
  local total = lenBytes
@@ -415,22 +420,28 @@ function Array.deltaArray(
415
420
  if cLen == 0 then
416
421
  Log.error("truncated deltaArray change count")
417
422
  end
418
- -- changeCount is also bounded by newLen: every index appears at most once.
419
423
  if changeCount > newLen then
420
424
  Log.error(`deltaArray changeCount {changeCount} exceeds newLen {newLen}`)
421
425
  end
422
426
  total += cLen
423
427
 
428
+ -- The writer marks every appended index (cachedLen+1 .. newLen)
429
+ -- as changed; the reader must enforce this so the wire can't
430
+ -- ship a holey baseline that breaks #result downstream.
431
+ local appendedNeeded = if newLen > cachedLen then newLen - cachedLen else 0
432
+ local appendedSeen = 0
433
+
424
434
  for _ = 1, changeCount do
425
435
  local idx, iLen = varintRead(src, pos + total)
426
436
  if iLen == 0 then
427
437
  Log.error("truncated deltaArray index")
428
438
  end
429
- -- Reject out-of-range idx so the wire can't poison the cache with
430
- -- arbitrary keys that survive truncation.
431
439
  if idx < 1 or idx > newLen then
432
440
  Log.error(`deltaArray index {idx} out of range [1, {newLen}]`)
433
441
  end
442
+ if idx > cachedLen then
443
+ appendedSeen += 1
444
+ end
434
445
  total += iLen
435
446
  local v, vc = element.read(src, pos + total, refs)
436
447
  if vc == 0 then
@@ -440,6 +451,12 @@ function Array.deltaArray(
440
451
  result[idx] = v
441
452
  end
442
453
 
454
+ if appendedSeen ~= appendedNeeded then
455
+ Log.error(
456
+ `deltaArray PATCH grew length to {newLen} but only {appendedSeen}/{appendedNeeded} appended indices supplied`
457
+ )
458
+ end
459
+
443
460
  for i = newLen + 1, cachedLen do
444
461
  result[i] = nil
445
462
  end
@@ -21,9 +21,11 @@ local DM_DIFF = 2
21
21
  --[[
22
22
  Depth-stacked key scratch. A nested Map.write (Map<K, Map<K2, V>>) would
23
23
  clobber a single shared array mid-iteration; the stack gives each frame
24
- its own slot. Mirrors Shared.acquireScratch / releaseScratch.
24
+ its own slot. _keyTails parallels _keyStacks because `#keys` after
25
+ `keys[count] = k` is undefined when count < the prior count.
25
26
  ]]
26
27
  local _keyStacks: { { any } } = {}
28
+ local _keyTails: { number } = {}
27
29
  local _keyDepth = 0
28
30
 
29
31
  -- Private ----------------------------------------------------------------
@@ -73,23 +75,23 @@ local function sortKeys(keys: { any }, canSortDirect: boolean): ()
73
75
  end
74
76
  end
75
77
 
76
- --[[
77
- Acquire a scratch keys array, populate it from `value`, nil any leftover
78
- tail entries from a prior reuse, then sort. Returns the keys array and
79
- the populated count. Caller MUST releaseKeys() after consuming the keys.
80
- ]]
81
78
  local function collectAndSortKeys(value: { [any]: any }, canSortDirect: boolean): ({ any }, number)
82
79
  local keys = acquireKeys()
80
+ local prevTail = _keyTails[_keyDepth] or 0
83
81
  local count = 0
84
82
  for k in value do
85
83
  count += 1
86
84
  keys[count] = k
87
85
  end
88
- local prevTail = #keys
89
- for i = count + 1, prevTail do
90
- keys[i] = nil
86
+ if count < prevTail then
87
+ for i = count + 1, prevTail do
88
+ keys[i] = nil
89
+ end
90
+ end
91
+ _keyTails[_keyDepth] = count
92
+ if count > 1 then
93
+ sortKeys(keys, canSortDirect)
91
94
  end
92
- sortKeys(keys, canSortDirect)
93
95
  return keys, count
94
96
  end
95
97
 
@@ -163,6 +165,11 @@ function Map.map(
163
165
  if payloadBytes > bufLen(src) - (pos + lenBytes) then
164
166
  Log.error(`payload {payloadBytes}B exceeds remaining buffer`)
165
167
  end
168
+ elseif count > 0 then
169
+ -- Variable-size: each pair is at least 2 bytes (key + value).
170
+ if count * 2 > bufLen(src) - (pos + lenBytes) then
171
+ Log.error(`map count {count} exceeds remaining buffer`)
172
+ end
166
173
  end
167
174
 
168
175
  local result: { [any]: any } = {}
@@ -419,14 +426,19 @@ function Map.deltaMap(
419
426
  local result: { [any]: any } = table.clone(cached)
420
427
  local total = 1
421
428
 
429
+ local cachedSize = 0
430
+ for _ in cached do
431
+ cachedSize += 1
432
+ end
433
+
422
434
  local removeCount, rcLen = varintRead(src, pos + total)
423
435
  if rcLen == 0 then
424
436
  Log.error("truncated deltaMap remove count")
425
437
  end
426
- -- Both counts bounded by maxCount; otherwise a malicious DIFF
427
- -- could spin the loop arbitrarily long before erroring on
428
- -- buffer overrun.
429
438
  enforceMaxCount(removeCount, maxCount, "removeCount")
439
+ if removeCount > cachedSize then
440
+ Log.error(`deltaMap removeCount {removeCount} exceeds cached size {cachedSize}`)
441
+ end
430
442
  total += rcLen
431
443
  for _ = 1, removeCount do
432
444
  local k, kc = keyCodec.read(src, pos + total, refs)
@@ -442,6 +454,10 @@ function Map.deltaMap(
442
454
  Log.error("truncated deltaMap change count")
443
455
  end
444
456
  enforceMaxCount(changeCount, maxCount, "changeCount")
457
+ -- Joint bound: post-DIFF size can't exceed maxCount either.
458
+ if maxCount and (cachedSize - removeCount + changeCount) > maxCount then
459
+ Log.error(`deltaMap DIFF would grow map past maxCount {maxCount}`)
460
+ end
445
461
  total += ccLen
446
462
  for _ = 1, changeCount do
447
463
  local k, kc = keyCodec.read(src, pos + total, refs)
@@ -97,7 +97,9 @@ end
97
97
  --[[
98
98
  Stack-based scratch ChannelState pool. Re-entrant codec serialization
99
99
  (a codec writing into scratch that triggers another codec's scratch
100
- acquire) works without aliasing.
100
+ acquire) works without aliasing. Full scrub on reuse so any codec that
101
+ reads framing fields (lastId/singleMode/itemCount/countPos) on scratch
102
+ sees a clean state, not stale bytes from the previous owner.
101
103
  ]]
102
104
  function Shared.acquireScratch(): Types.ChannelState
103
105
  _scratchDepth += 1
@@ -105,6 +107,15 @@ function Shared.acquireScratch(): Types.ChannelState
105
107
  if ch then
106
108
  ch.cursor = 0
107
109
  ch.refCount = 0
110
+ ch.lastId = -1
111
+ ch.countPos = -1
112
+ ch.itemCount = 0
113
+ ch.singleMode = false
114
+ ch.singlePos = 0
115
+ ch.currentPacket = nil
116
+ if next(ch.deltas) ~= nil then
117
+ table.clear(ch.deltas)
118
+ end
108
119
  return ch
109
120
  end
110
121
  ch = Channel.create()
@@ -171,25 +182,27 @@ function Shared.extractFields(
171
182
  return dataKeys, dataCodecs, boolKeys
172
183
  end
173
184
 
185
+ -- `byteCount` is `(boolCount + 7) // 8`; callers with it cached pass it through.
174
186
  function Shared.packBools(
175
187
  ch: Types.ChannelState,
176
188
  value: { [string]: any },
177
189
  boolKeys: { string },
178
- boolCount: number
190
+ boolCount: number,
191
+ byteCount: number?
179
192
  ): ()
180
193
  if boolCount == 0 then
181
194
  return
182
195
  end
183
196
 
184
- local byteCount = (boolCount + 7) // 8
197
+ local bc = byteCount or (boolCount + 7) // 8
185
198
  local cursor = ch.cursor
186
- if cursor + byteCount > ch.size then
187
- alloc(ch, byteCount)
199
+ if cursor + bc > ch.size then
200
+ alloc(ch, bc)
188
201
  end
189
202
 
190
203
  local idx = 1
191
204
  local b = ch.buff
192
- for byteIdx = 0, byteCount - 1 do
205
+ for byteIdx = 0, bc - 1 do
193
206
  local packed = 0
194
207
  local remaining = boolCount - idx + 1
195
208
  local limit = if remaining >= 8 then 8 else remaining
@@ -202,27 +215,32 @@ function Shared.packBools(
202
215
  writeu8(b, cursor + byteIdx, packed)
203
216
  end
204
217
 
205
- ch.cursor = cursor + byteCount
218
+ ch.cursor = cursor + bc
206
219
  end
207
220
 
208
221
  --[[
209
222
  Integer-indexed bool-array pack. Split from packBools to skip the
210
223
  per-bit string-key getter on the bool-array hot path.
211
224
  ]]
212
- function Shared.packBoolArray(ch: Types.ChannelState, value: { boolean }, count: number): ()
225
+ function Shared.packBoolArray(
226
+ ch: Types.ChannelState,
227
+ value: { boolean },
228
+ count: number,
229
+ byteCount: number?
230
+ ): ()
213
231
  if count == 0 then
214
232
  return
215
233
  end
216
234
 
217
- local byteCount = (count + 7) // 8
235
+ local bc = byteCount or (count + 7) // 8
218
236
  local cursor = ch.cursor
219
- if cursor + byteCount > ch.size then
220
- alloc(ch, byteCount)
237
+ if cursor + bc > ch.size then
238
+ alloc(ch, bc)
221
239
  end
222
240
 
223
241
  local idx = 1
224
242
  local b = ch.buff
225
- for byteIdx = 0, byteCount - 1 do
243
+ for byteIdx = 0, bc - 1 do
226
244
  local packed = 0
227
245
  local remaining = count - idx + 1
228
246
  local limit = if remaining >= 8 then 8 else remaining
@@ -235,7 +253,7 @@ function Shared.packBoolArray(ch: Types.ChannelState, value: { boolean }, count:
235
253
  writeu8(b, cursor + byteIdx, packed)
236
254
  end
237
255
 
238
- ch.cursor = cursor + byteCount
256
+ ch.cursor = cursor + bc
239
257
  end
240
258
 
241
259
  function Shared.unpackBools(
@@ -243,15 +261,16 @@ function Shared.unpackBools(
243
261
  pos: number,
244
262
  boolKeys: { string },
245
263
  boolCount: number,
246
- result: { [string]: any }
264
+ result: { [string]: any },
265
+ byteCount: number?
247
266
  ): number
248
267
  if boolCount == 0 then
249
268
  return 0
250
269
  end
251
270
 
252
- local byteCount = (boolCount + 7) // 8
271
+ local bc = byteCount or (boolCount + 7) // 8
253
272
  local idx = 1
254
- for byteIdx = 0, byteCount - 1 do
273
+ for byteIdx = 0, bc - 1 do
255
274
  local byte = readu8(src, pos + byteIdx)
256
275
  local remaining = boolCount - idx + 1
257
276
  local limit = if remaining >= 8 then 8 else remaining
@@ -260,7 +279,7 @@ function Shared.unpackBools(
260
279
  idx += 1
261
280
  end
262
281
  end
263
- return byteCount
282
+ return bc
264
283
  end
265
284
 
266
285
  -- Fills `result[1..count]` in place.
@@ -268,15 +287,16 @@ function Shared.unpackBoolArray(
268
287
  src: buffer,
269
288
  pos: number,
270
289
  count: number,
271
- result: { boolean }
290
+ result: { boolean },
291
+ byteCount: number?
272
292
  ): number
273
293
  if count == 0 then
274
294
  return 0
275
295
  end
276
296
 
277
- local byteCount = (count + 7) // 8
297
+ local bc = byteCount or (count + 7) // 8
278
298
  local idx = 1
279
- for byteIdx = 0, byteCount - 1 do
299
+ for byteIdx = 0, bc - 1 do
280
300
  local byte = readu8(src, pos + byteIdx)
281
301
  local remaining = count - idx + 1
282
302
  local limit = if remaining >= 8 then 8 else remaining
@@ -285,7 +305,7 @@ function Shared.unpackBoolArray(
285
305
  idx += 1
286
306
  end
287
307
  end
288
- return byteCount
308
+ return bc
289
309
  end
290
310
 
291
311
  return table.freeze(Shared)
@@ -165,7 +165,7 @@ function Struct.struct(schema: { [string]: Types.InternalCodec<any> }): Types.In
165
165
  result[dataKeys[i]] = directReads[i](b, off + offsets[i])
166
166
  end
167
167
  if boolCount > 0 then
168
- unpackBools(b, off + fixedSize, boolKeys, boolCount, result)
168
+ unpackBools(b, off + fixedSize, boolKeys, boolCount, result, boolBytes)
169
169
  end
170
170
  return result
171
171
  end,
@@ -181,7 +181,7 @@ function Struct.struct(schema: { [string]: Types.InternalCodec<any> }): Types.In
181
181
  end
182
182
  ch.cursor = cursor + fixedSize
183
183
  if boolCount > 0 then
184
- packBools(ch, value, boolKeys, boolCount)
184
+ packBools(ch, value, boolKeys, boolCount, boolBytes)
185
185
  end
186
186
  end,
187
187
 
@@ -191,7 +191,7 @@ function Struct.struct(schema: { [string]: Types.InternalCodec<any> }): Types.In
191
191
  result[dataKeys[i]] = directReads[i](src, pos + offsets[i])
192
192
  end
193
193
  if boolCount > 0 then
194
- unpackBools(src, pos + fixedSize, boolKeys, boolCount, result)
194
+ unpackBools(src, pos + fixedSize, boolKeys, boolCount, result, boolBytes)
195
195
  end
196
196
  return result, totalFixed
197
197
  end,
@@ -213,7 +213,7 @@ function Struct.struct(schema: { [string]: Types.InternalCodec<any> }): Types.In
213
213
  dataCodecs[i].write(ch, value[dataKeys[i]])
214
214
  end
215
215
  if boolCount > 0 then
216
- packBools(ch, value, boolKeys, boolCount)
216
+ packBools(ch, value, boolKeys, boolCount, boolBytes)
217
217
  end
218
218
  end,
219
219
 
@@ -226,7 +226,7 @@ function Struct.struct(schema: { [string]: Types.InternalCodec<any> }): Types.In
226
226
  total += consumed
227
227
  end
228
228
  if boolCount > 0 then
229
- total += unpackBools(src, pos + total, boolKeys, boolCount, result)
229
+ total += unpackBools(src, pos + total, boolKeys, boolCount, result, boolBytes)
230
230
  end
231
231
  return result, total
232
232
  end,
@@ -316,7 +316,7 @@ function Struct.deltaStruct(
316
316
  refs: { Instance }?
317
317
  ): number
318
318
  if hasBoolSeg and segIdx == boolSegIdx then
319
- return unpackBools(src, pos, boolKeys, boolCount, target)
319
+ return unpackBools(src, pos, boolKeys, boolCount, target, boolBytes)
320
320
  end
321
321
  local v, consumed = dataCodecs[segIdx].read(src, pos, refs)
322
322
  target[dataKeys[segIdx]] = v
@@ -361,7 +361,7 @@ function Struct.deltaStruct(
361
361
  prev = cur
362
362
  end
363
363
  if hasBoolSeg then
364
- packBools(scratch, value, boolKeys, boolCount)
364
+ packBools(scratch, value, boolKeys, boolCount, boolBytes)
365
365
  segOff[boolSegIdx] = prev
366
366
  segLen[boolSegIdx] = scratch.cursor - prev
367
367
  end
@@ -117,11 +117,24 @@ function Bitfield.define(schema: { [string]: FieldSpec }): Types.InternalCodec<a
117
117
  local packed = 0
118
118
  for i = 1, fieldCount do
119
119
  local v = value[fieldKeys[i]]
120
- if fieldTypes[i] == TYPE_BOOL then
120
+ local ft = fieldTypes[i]
121
+ if ft == TYPE_BOOL then
121
122
  if v then
122
123
  packed = bor(packed, lshift(1, fieldOffsets[i]))
123
124
  end
125
+ elseif ft == TYPE_UINT then
126
+ local mask = fieldMasks[i]
127
+ if v < 0 or v > mask or v % 1 ~= 0 then
128
+ Log.error(`field "{fieldKeys[i]}" value {v} out of uint range [0, {mask}]`)
129
+ end
130
+ packed = bor(packed, lshift(v, fieldOffsets[i]))
124
131
  else
132
+ local signBit = fieldSignBit[i]
133
+ if v < -signBit or v >= signBit or v % 1 ~= 0 then
134
+ Log.error(
135
+ `field "{fieldKeys[i]}" value {v} out of int range [{-signBit}, {signBit - 1}]`
136
+ )
137
+ end
125
138
  packed = bor(packed, lshift(band(v, fieldMasks[i]), fieldOffsets[i]))
126
139
  end
127
140
  end
@@ -18,7 +18,6 @@ local TS_FULL = 3
18
18
 
19
19
  local INITIAL_BUF = 1024
20
20
  local HEADER_BYTE = 1
21
- local FRAME_MSB = Constants.FRAME_MSB
22
21
  local U8_MAX = Constants.U8_MAX
23
22
  local U16_MAX = Constants.U16_MAX
24
23
  local MAX_BATCH_ITEMS = Constants.MAX_BATCH_ITEMS
@@ -50,7 +49,6 @@ local EMPTY_REFS = table.freeze({}) :: { Instance }
50
49
  local alloc = Base.alloc
51
50
  local ensure = Base.ensure
52
51
  local band = bit32.band
53
- local bor = bit32.bor
54
52
  local bufCopy = buffer.copy
55
53
  local writeu8 = buffer.writeu8
56
54
  local writeu16 = buffer.writeu16
@@ -58,15 +56,7 @@ local writef64 = buffer.writef64
58
56
  local round = math.round
59
57
  local osClock = os.clock
60
58
 
61
- local function writeTimestamp(b: buffer, off: number, mode: number): ()
62
- if mode == TS_FRAME then
63
- writeu8(b, off, _frameCounter)
64
- elseif mode == TS_OFFSET then
65
- writeu16(b, off, _offsetMs)
66
- elseif mode == TS_FULL then
67
- writef64(b, off, _clock)
68
- end
69
- end
59
+ -- id is in [1, 127] (Registry.MAX_ID), so id + 128 == bor(FRAME_MSB, id).
70
60
 
71
61
  --[[
72
62
  Single -> multi upgrade. Patches the previously-emitted single-item
@@ -111,25 +101,47 @@ local function spliceEncoded(ch: Types.ChannelState, payload: buffer, payloadLen
111
101
  end
112
102
 
113
103
  --[[
114
- Hot-swapped by enableStats() so the per-item dispatch avoids a runtime
115
- branch on the stats flag. itemCount bumps AFTER codec.write so a
116
- throwing codec leaves the count consistent with the buffer.
104
+ Hot-swapped by enableStats(). A throwing codec must rewind cursor + refs:
105
+ the next flush sends `[0, ch.cursor)`, so a partial write past `cursor`
106
+ desyncs the receiver from the truncation point onward.
117
107
  ]]
118
108
  local writeBatchPlain: (Types.ChannelState, Types.Registration, any) -> ()
119
109
  local writeBatchStats: (Types.ChannelState, Types.Registration, any) -> ()
120
110
 
111
+ local function rewindOnThrow(ch: Types.ChannelState, cursor: number, refCount: number): ()
112
+ ch.cursor = cursor
113
+ if ch.refCount > refCount then
114
+ local refs = ch.refs
115
+ for i = refCount + 1, ch.refCount do
116
+ refs[i] = nil
117
+ end
118
+ ch.refCount = refCount
119
+ end
120
+ end
121
+
121
122
  writeBatchPlain = function(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
122
123
  openOrUpgrade(ch, reg)
123
- reg.codec.write(ch, data)
124
+ local rewindCursor = ch.cursor
125
+ local rewindRefCount = ch.refCount
126
+ local ok, err = pcall(reg.codec.write, ch, data)
127
+ if not ok then
128
+ rewindOnThrow(ch, rewindCursor, rewindRefCount)
129
+ error(err, 0)
130
+ end
124
131
  ch.itemCount += 1
125
132
  end
126
133
 
127
134
  writeBatchStats = function(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
128
135
  openOrUpgrade(ch, reg)
129
- local before = ch.cursor
130
- reg.codec.write(ch, data)
136
+ local rewindCursor = ch.cursor
137
+ local rewindRefCount = ch.refCount
138
+ local ok, err = pcall(reg.codec.write, ch, data)
139
+ if not ok then
140
+ rewindOnThrow(ch, rewindCursor, rewindRefCount)
141
+ error(err, 0)
142
+ end
131
143
  ch.itemCount += 1
132
- reg.bytesSent += ch.cursor - before
144
+ reg.bytesSent += ch.cursor - rewindCursor
133
145
  end
134
146
 
135
147
  --[[
@@ -146,7 +158,12 @@ writeBatchEncodedPlain = function(
146
158
  payloadLen: number
147
159
  ): ()
148
160
  openOrUpgrade(ch, reg)
149
- spliceEncoded(ch, payload, payloadLen)
161
+ local rewindCursor = ch.cursor
162
+ local ok, err = pcall(spliceEncoded, ch, payload, payloadLen)
163
+ if not ok then
164
+ ch.cursor = rewindCursor
165
+ error(err, 0)
166
+ end
150
167
  ch.itemCount += 1
151
168
  end
152
169
 
@@ -157,7 +174,12 @@ writeBatchEncodedStats = function(
157
174
  payloadLen: number
158
175
  ): ()
159
176
  openOrUpgrade(ch, reg)
160
- spliceEncoded(ch, payload, payloadLen)
177
+ local rewindCursor = ch.cursor
178
+ local ok, err = pcall(spliceEncoded, ch, payload, payloadLen)
179
+ if not ok then
180
+ ch.cursor = rewindCursor
181
+ error(err, 0)
182
+ end
161
183
  ch.itemCount += 1
162
184
  reg.bytesSent += payloadLen
163
185
  end
@@ -241,48 +263,114 @@ function Channel.fullReset(ch: Types.ChannelState): ()
241
263
  Channel.reset(ch)
242
264
  ch.prevDump = nil
243
265
  ch.prevDumpLen = 0
244
- table.clear(ch.deltas)
266
+ if next(ch.deltas) ~= nil then
267
+ table.clear(ch.deltas)
268
+ end
245
269
  end
246
270
 
247
271
  --[[
248
- Resolve the batch-open closure for a given timestamp mode and packet
249
- name. Returned function seals the prior batch, writes the single-mode
250
- header [0x80 | id] + optional timestamp, and records the cursor so
251
- writeBatch can upgrade single -> multi later.
272
+ Per-mode openFn variants. Each seals the prior batch, writes the
273
+ single-mode header [0x80 | id] + optional timestamp, and records the
274
+ cursor so writeBatch can upgrade single -> multi later.
252
275
  ]]
253
- function Channel.resolveOpenFn(
254
- timestampMode: number,
255
- name: string
256
- ): (ch: Types.ChannelState, id: number) -> ()
257
- local headerLen = HEADER_BYTE + TS_BYTES[timestampMode]
258
-
276
+ local function makeOpenNoTs(name: string): (Types.ChannelState, number) -> ()
259
277
  return function(ch: Types.ChannelState, id: number): ()
260
- --[[
261
- Inline sealCount: this is the only call site that can take the
262
- multi-mode branch on a non-fresh channel.
263
- ]]
264
278
  if ch.lastId >= 0 and not ch.singleMode then
265
279
  writeu16(ch.buff, ch.countPos, ch.itemCount)
266
280
  end
281
+ ch.currentPacket = name
282
+ ch.lastId = id
283
+ ch.itemCount = 0
284
+ local cursor = ch.cursor
285
+ if cursor + 1 > ch.size then
286
+ alloc(ch, 1)
287
+ end
288
+ writeu8(ch.buff, cursor, id + 128)
289
+ ch.singleMode = true
290
+ ch.singlePos = cursor
291
+ ch.cursor = cursor + 1
292
+ end
293
+ end
267
294
 
295
+ local function makeOpenFrameTs(name: string): (Types.ChannelState, number) -> ()
296
+ return function(ch: Types.ChannelState, id: number): ()
297
+ if ch.lastId >= 0 and not ch.singleMode then
298
+ writeu16(ch.buff, ch.countPos, ch.itemCount)
299
+ end
268
300
  ch.currentPacket = name
269
301
  ch.lastId = id
270
302
  ch.itemCount = 0
303
+ local cursor = ch.cursor
304
+ if cursor + 2 > ch.size then
305
+ alloc(ch, 2)
306
+ end
307
+ local b = ch.buff
308
+ writeu8(b, cursor, id + 128)
309
+ writeu8(b, cursor + 1, _frameCounter)
310
+ ch.singleMode = true
311
+ ch.singlePos = cursor
312
+ ch.cursor = cursor + 2
313
+ end
314
+ end
271
315
 
316
+ local function makeOpenOffsetTs(name: string): (Types.ChannelState, number) -> ()
317
+ return function(ch: Types.ChannelState, id: number): ()
318
+ if ch.lastId >= 0 and not ch.singleMode then
319
+ writeu16(ch.buff, ch.countPos, ch.itemCount)
320
+ end
321
+ ch.currentPacket = name
322
+ ch.lastId = id
323
+ ch.itemCount = 0
272
324
  local cursor = ch.cursor
273
- if cursor + headerLen > ch.size then
274
- alloc(ch, headerLen)
325
+ if cursor + 3 > ch.size then
326
+ alloc(ch, 3)
275
327
  end
276
328
  local b = ch.buff
277
- writeu8(b, cursor, bor(FRAME_MSB, id))
278
- writeTimestamp(b, cursor + 1, timestampMode)
329
+ writeu8(b, cursor, id + 128)
330
+ writeu16(b, cursor + 1, _offsetMs)
331
+ ch.singleMode = true
332
+ ch.singlePos = cursor
333
+ ch.cursor = cursor + 3
334
+ end
335
+ end
279
336
 
337
+ local function makeOpenFullTs(name: string): (Types.ChannelState, number) -> ()
338
+ return function(ch: Types.ChannelState, id: number): ()
339
+ if ch.lastId >= 0 and not ch.singleMode then
340
+ writeu16(ch.buff, ch.countPos, ch.itemCount)
341
+ end
342
+ ch.currentPacket = name
343
+ ch.lastId = id
344
+ ch.itemCount = 0
345
+ local cursor = ch.cursor
346
+ if cursor + 9 > ch.size then
347
+ alloc(ch, 9)
348
+ end
349
+ local b = ch.buff
350
+ writeu8(b, cursor, id + 128)
351
+ writef64(b, cursor + 1, _clock)
280
352
  ch.singleMode = true
281
353
  ch.singlePos = cursor
282
- ch.cursor = cursor + headerLen
354
+ ch.cursor = cursor + 9
283
355
  end
284
356
  end
285
357
 
358
+ function Channel.resolveOpenFn(
359
+ timestampMode: number,
360
+ name: string
361
+ ): (ch: Types.ChannelState, id: number) -> ()
362
+ if timestampMode == TS_NONE then
363
+ return makeOpenNoTs(name)
364
+ end
365
+ if timestampMode == TS_FRAME then
366
+ return makeOpenFrameTs(name)
367
+ end
368
+ if timestampMode == TS_OFFSET then
369
+ return makeOpenOffsetTs(name)
370
+ end
371
+ return makeOpenFullTs(name)
372
+ end
373
+
286
374
  Channel.writeBatch = writeBatchPlain
287
375
  Channel.writeBatchEncoded = writeBatchEncodedPlain
288
376
 
@@ -315,7 +403,8 @@ function Channel.writeQuery(
315
403
  id: number,
316
404
  correlationId: number,
317
405
  codec: Types.InternalCodec<any>?,
318
- data: any
406
+ data: any,
407
+ name: string?
319
408
  ): ()
320
409
  if ch.lastId >= 0 and not ch.singleMode then
321
410
  writeu16(ch.buff, ch.countPos, ch.itemCount)
@@ -327,9 +416,14 @@ function Channel.writeQuery(
327
416
  here would never be observed before the next reset overwrites it.
328
417
  ]]
329
418
  ch.lastId = -1
419
+ -- Without this, an alloc overflow inside the payload codec would log
420
+ -- the previous packet's name (whichever _openFn last touched currentPacket).
421
+ if name then
422
+ ch.currentPacket = name
423
+ end
330
424
 
331
425
  local hasPayload = codec ~= nil and data ~= nil
332
- local headerByte = if hasPayload then id else bor(FRAME_MSB, id)
426
+ local headerByte = if hasPayload then id else id + 128
333
427
 
334
428
  local cursor = ch.cursor
335
429
  if cursor + 1 > ch.size then
@@ -20,23 +20,24 @@ local _maxSize = DEFAULT_SIZE
20
20
 
21
21
  local Pool = {}
22
22
 
23
- -- fullReset wipes the prior owner's XOR baseline and delta caches.
24
23
  function Pool.acquire(): Types.ChannelState
25
24
  if _depth > 0 then
26
25
  local ch = _stack[_depth]
27
26
  _stack[_depth] = nil
28
27
  _depth -= 1
29
- Channel.fullReset(ch)
30
28
  return ch
31
29
  end
32
30
  return Channel.create()
33
31
  end
34
32
 
35
- -- Above the cap, drop on the floor; the GC reclaims the buffer.
33
+ -- Scrub on release so the pool never holds the prior owner's Instance refs
34
+ -- or XOR baseline buffer; otherwise a player who disconnects mid-frame leaves
35
+ -- those rooted in the pool until the slot is re-acquired (potentially never).
36
36
  function Pool.release(ch: Types.ChannelState): ()
37
37
  if _depth >= _maxSize then
38
38
  return
39
39
  end
40
+ Channel.fullReset(ch)
40
41
  _depth += 1
41
42
  _stack[_depth] = ch
42
43
  end
@@ -209,6 +209,7 @@ function Reader.process(
209
209
  local regName = reg.name
210
210
  -- Lync.nothing reads zero bytes; bypass the pos/consumed gates that assume forward progress.
211
211
  local isZeroSize = sizeMeta == 0
212
+ local accepted = 0
212
213
 
213
214
  for _ = 1, count do
214
215
  if not isZeroSize and pos >= incomingLen then
@@ -249,15 +250,14 @@ function Reader.process(
249
250
  value = runReceive(value, regName, player)
250
251
  end
251
252
 
252
- if statsEnabled then
253
- reg.recvFires += 1
254
- end
253
+ accepted += 1
255
254
 
256
255
  -- tsValue is nil when mode == TS_NONE; pass through to skip a per-item branch.
257
256
  regSignal:fire(value, player, tsValue)
258
257
  end
259
258
 
260
259
  if statsEnabled then
260
+ reg.recvFires += accepted
261
261
  reg.bytesReceived += (pos - frameStart)
262
262
  end
263
263
  elseif kind == KIND_REQUEST or kind == KIND_RESPONSE then
@@ -32,8 +32,6 @@ local xorApply = Channel.xorApply
32
32
  local sealAndDump = Channel.sealAndDump
33
33
  local resetCh = Channel.reset
34
34
 
35
- type StatsField = "bytesSent" | "bytesReceived"
36
-
37
35
  local function getOrCreateChannel(
38
36
  map: { [Player]: Types.ChannelState },
39
37
  player: Player
@@ -46,16 +44,26 @@ local function getOrCreateChannel(
46
44
  return ch
47
45
  end
48
46
 
49
- -- Skip the table read when stats are off; the hot path needs no branch.
50
- local function addStat(player: Player, field: StatsField, byteLen: number): ()
51
- if statsEnabled() then
52
- local ps = _playerStats[player]
53
- if ps then
54
- ps[field] += byteLen
55
- end
47
+ -- Hot-swapped at Server.start; stats-off path is a tail-callable noop.
48
+ local function noopStat(_player: Player, _byteLen: number): () end
49
+
50
+ local function realBytesSent(player: Player, byteLen: number): ()
51
+ local ps = _playerStats[player]
52
+ if ps then
53
+ ps.bytesSent += byteLen
56
54
  end
57
55
  end
58
56
 
57
+ local function realBytesReceived(player: Player, byteLen: number): ()
58
+ local ps = _playerStats[player]
59
+ if ps then
60
+ ps.bytesReceived += byteLen
61
+ end
62
+ end
63
+
64
+ local addBytesSent: (Player, number) -> () = noopStat
65
+ local addBytesReceived: (Player, number) -> () = noopStat
66
+
59
67
  local function flushReliable(player: Player): ()
60
68
  local rCh = _reliableChannels[player]
61
69
  if not rCh or rCh.cursor == 0 then
@@ -69,7 +77,7 @@ local function flushReliable(player: Player): ()
69
77
 
70
78
  Bridge.fireClient(player, xored, refs)
71
79
  resetCh(rCh)
72
- addStat(player, "bytesSent", byteLen)
80
+ addBytesSent(player, byteLen)
73
81
  end
74
82
 
75
83
  local function flushUnreliable(player: Player): ()
@@ -81,7 +89,7 @@ local function flushUnreliable(player: Player): ()
81
89
  local snapshot, refs, byteLen = sealAndDump(uCh)
82
90
  Bridge.fireClientUnreliable(player, snapshot, refs)
83
91
  resetCh(uCh)
84
- addStat(player, "bytesSent", byteLen)
92
+ addBytesSent(player, byteLen)
85
93
  end
86
94
 
87
95
  local function flushPlayer(player: Player): ()
@@ -116,7 +124,7 @@ local function handleIncomingReliable(player: Player, data: any, refs: any?): ()
116
124
  _prevIncomingLen[player] = nil
117
125
  end
118
126
 
119
- addStat(player, "bytesReceived", incomingLen)
127
+ addBytesReceived(player, incomingLen)
120
128
  end
121
129
 
122
130
  local function handleIncomingUnreliable(player: Player, data: any, refs: any?): ()
@@ -127,7 +135,7 @@ local function handleIncomingUnreliable(player: Player, data: any, refs: any?):
127
135
 
128
136
  local incomingLen = buffer.len(incoming)
129
137
  Reader.process(incoming, incomingLen, refs, player, true)
130
- addStat(player, "bytesReceived", incomingLen)
138
+ addBytesReceived(player, incomingLen)
131
139
  end
132
140
 
133
141
  local function clearPlayerState(player: Player): ()
@@ -161,6 +169,15 @@ function Server.getChannel(player: Player, isUnreliable: boolean): Types.Channel
161
169
  )
162
170
  end
163
171
 
172
+ -- Pre-resolved variants for broadcast: skip the per-audience-member isUnreliable branch.
173
+ function Server.getReliableChannel(player: Player): Types.ChannelState
174
+ return getOrCreateChannel(_reliableChannels, player)
175
+ end
176
+
177
+ function Server.getUnreliableChannel(player: Player): Types.ChannelState
178
+ return getOrCreateChannel(_unreliableChannels, player)
179
+ end
180
+
164
181
  function Server.flush(): ()
165
182
  if Channel.hasTimestamps() then
166
183
  Channel.updateTimestamp()
@@ -182,6 +199,11 @@ function Server.start(): ()
182
199
  Bridge.reliable().OnServerEvent:Connect(handleIncomingReliable)
183
200
  Bridge.unreliable().OnServerEvent:Connect(handleIncomingUnreliable)
184
201
 
202
+ if statsEnabled() then
203
+ addBytesSent = realBytesSent
204
+ addBytesReceived = realBytesReceived
205
+ end
206
+
185
207
  Players.PlayerAdded:Connect(function(player: Player)
186
208
  if statsEnabled() then
187
209
  _playerStats[player] = { bytesSent = 0, bytesReceived = 0 }