appmesh 3.1.5 → 3.1.7

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.
@@ -7,12 +7,24 @@ import os from 'os'
7
7
  import { once } from 'events'
8
8
  import { v1 as uuidv1 } from 'uuid'
9
9
  import msgpack from 'msgpack-lite'
10
- import AppMeshClient, { AppMeshError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE, _resolveUid, _resolveGid, _resolveUserName, _resolveGroupName } from './appmesh.js'
10
+ import AppMeshClient, {
11
+ AppMeshError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE,
12
+ _resolveUid, _resolveGid, _resolveUserName, _resolveGroupName,
13
+ } from './appmesh.js'
11
14
 
12
15
  // Constants
13
16
  const TCP_BLOCK_SIZE = 16 * 1024 - 128 // TLS-optimized chunk size
14
17
  const TCP_MESSAGE_MAGIC = 0x07C707F8 // must match TCP_MESSAGE_MAGIC in src/common/Utility.h
15
18
  const TCP_MESSAGE_HEADER_LENGTH = 8 // 4-byte magic + 4-byte body length, big-endian
19
+ const TCP_MAX_BLOCK_SIZE = 1024 * 1024 * 1024 // 1 GB, matches the daemon and the other SDKs
20
+
21
+ /** Case-insensitive header lookup (the daemon's header case is not contractual). */
22
+ const _headerValue = (headers, name) => {
23
+ if (!headers) return undefined
24
+ if (headers[name] !== undefined) return headers[name]
25
+ const key = Object.keys(headers).find(k => k.toLowerCase() === name.toLowerCase())
26
+ return key !== undefined ? headers[key] : undefined
27
+ }
16
28
  const ENCODING_UTF8 = 'utf-8'
17
29
  const HTTP_USER_AGENT_TCP = 'appmesh/nodejs/tcp'
18
30
  const HTTP_HEADER_KEY_X_SEND_FILE_SOCKET = 'X-Send-File-Socket'
@@ -25,7 +37,8 @@ const HTTP_HEADER_KEY_X_TARGET_HOST = 'X-Target-Host'
25
37
  /**
26
38
  * TCP transport for secure TLS connections and framed App Mesh messages.
27
39
  *
28
- * @param {Object|false|null} [sslConfig=null] - TLS config; same tri-state as AppMeshClient (null=default CA, object=custom ca/cert/key, false=insecure).
40
+ * @param {Object|false|null} [sslConfig=null] - TLS config; same tri-state as AppMeshClient
41
+ * (null=default CA, object=custom ca/cert/key, false=insecure).
29
42
  */
30
43
  class TCPTransport {
31
44
  /**
@@ -118,6 +131,10 @@ class TCPTransport {
118
131
  this.socket = null
119
132
  }
120
133
  this.receiveBuffer = Buffer.alloc(0)
134
+ // Drop the read pump with its socket: destroying the socket resolves any
135
+ // pending waiter via the 'close' listener, and a later connect() re-arms a
136
+ // fresh pump bound to the new socket.
137
+ this._readPump = null
121
138
  }
122
139
 
123
140
  /**
@@ -156,7 +173,10 @@ class TCPTransport {
156
173
  // Read message header (magic + length)
157
174
  while (this.receiveBuffer.length < TCP_MESSAGE_HEADER_LENGTH) {
158
175
  const chunk = await this._readChunk()
159
- if (!chunk) return null
176
+ if (!chunk) {
177
+ this.close()
178
+ return null
179
+ }
160
180
  this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk])
161
181
  }
162
182
 
@@ -173,10 +193,20 @@ class TCPTransport {
173
193
  return Buffer.alloc(0)
174
194
  }
175
195
 
196
+ // Reject oversized frames before buffering them (a corrupt or hostile
197
+ // 8-byte header must not drive the accumulation loop below).
198
+ if (length > TCP_MAX_BLOCK_SIZE) {
199
+ this.close()
200
+ throw new Error(`TCP message size ${length} exceeds maximum ${TCP_MAX_BLOCK_SIZE}`)
201
+ }
202
+
176
203
  // Read message body
177
204
  while (this.receiveBuffer.length < TCP_MESSAGE_HEADER_LENGTH + length) {
178
205
  const chunk = await this._readChunk()
179
- if (!chunk) return null
206
+ if (!chunk) {
207
+ this.close()
208
+ return null
209
+ }
180
210
  this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk])
181
211
  }
182
212
 
@@ -186,35 +216,55 @@ class TCPTransport {
186
216
  }
187
217
 
188
218
  /**
189
- * Read a chunk from the socket
219
+ * Read a chunk from the socket.
220
+ *
221
+ * A single set of listeners stays attached for the socket's lifetime and this
222
+ * method just takes from an internal queue. Attaching/detaching a 'data'
223
+ * listener per read pauses the socket between reads; the pause churn
224
+ * deadlocks with the daemon's backpressure-aware file streaming on fast
225
+ * local transfers (both sides end up waiting: the kernel queues drain, the
226
+ * daemon waits for window space, and the paused socket never resumes).
190
227
  * @private
191
228
  */
192
229
  _readChunk () {
193
- return new Promise((resolve, reject) => {
194
- const onData = data => {
195
- this.socket.removeListener('data', onData)
196
- this.socket.removeListener('end', onEnd)
197
- this.socket.removeListener('error', onError)
198
- resolve(data)
230
+ if (!this._readPump) {
231
+ const pump = {
232
+ waiters: [],
233
+ buffered: [],
234
+ ended: false,
235
+ error: null
199
236
  }
200
-
201
- const onEnd = () => {
202
- this.socket.removeListener('data', onData)
203
- this.socket.removeListener('end', onEnd)
204
- this.socket.removeListener('error', onError)
205
- resolve(null)
237
+ const deliver = (value) => {
238
+ const next = pump.waiters.shift()
239
+ if (next) {
240
+ if (value instanceof Error) next.reject(value)
241
+ else next.resolve(value)
242
+ } else {
243
+ pump.buffered.push(value)
244
+ }
206
245
  }
207
-
208
- const onError = err => {
209
- this.socket.removeListener('data', onData)
210
- this.socket.removeListener('end', onEnd)
211
- this.socket.removeListener('error', onError)
212
- reject(err)
246
+ const finish = (err) => {
247
+ if (err && !pump.error) pump.error = err
248
+ pump.ended = true
249
+ while (pump.waiters.length > 0) deliver(pump.error || null)
250
+ }
251
+ this.socket.on('data', (data) => deliver(data))
252
+ this.socket.on('end', () => finish(null))
253
+ this.socket.on('close', () => finish(null))
254
+ this.socket.on('error', (err) => finish(err))
255
+ this._readPump = pump
256
+ }
257
+ const pump = this._readPump
258
+ return new Promise((resolve, reject) => {
259
+ if (pump.buffered.length > 0) {
260
+ resolve(pump.buffered.shift())
261
+ } else if (pump.error) {
262
+ reject(pump.error)
263
+ } else if (pump.ended) {
264
+ resolve(null)
265
+ } else {
266
+ pump.waiters.push({ resolve, reject })
213
267
  }
214
-
215
- this.socket.once('data', onData)
216
- this.socket.once('end', onEnd)
217
- this.socket.once('error', onError)
218
268
  })
219
269
  }
220
270
  }
@@ -309,6 +359,174 @@ function _normalizeTcpAddress (address) {
309
359
  *
310
360
  * @extends AppMeshClient
311
361
  */
362
+ const EVENT_URI = '/appmesh/event'
363
+
364
+ /**
365
+ * Synthetic event_type pushed to every registered callback when the demuxer
366
+ * stops or the underlying transport disconnects. Lets long-running waits
367
+ * (e.g. waitForAsyncRun) unblock instead of hanging forever.
368
+ */
369
+ const EVENT_TYPE_DISCONNECTED = '__disconnected__'
370
+
371
+ /**
372
+ * Routes incoming messages to either pending request promises (by UUID) or
373
+ * event subscription callbacks. Replaces the broken dual-reader pattern.
374
+ * Events are dispatched to subscription callbacks synchronously from the single
375
+ * read loop, in arrival order (matches the other SDK demuxers).
376
+ */
377
+ class MessageDemuxer {
378
+ // Bound the pre-registration event buffer (atomic-subscribe race window) so a
379
+ // subscription whose callback never registers cannot grow memory without limit.
380
+ static _MAX_BUFFERED_SUBS = 64
381
+ static _MAX_BUFFERED_EVENTS_PER_SUB = 1000
382
+
383
+ constructor (transport) {
384
+ this._transport = transport
385
+ this._pending = new Map()
386
+ this._eventCallbacks = new Map()
387
+ // Events that arrive between server-side subscription and the client
388
+ // registering its callback (e.g. atomic addApp(subscribe_events) on a fast
389
+ // app, whose output is pushed before addApp returns). Held per subId and
390
+ // flushed on registerEventCallback so no events are lost.
391
+ this._eventBuffers = new Map()
392
+ this._running = false
393
+ }
394
+
395
+ start () {
396
+ if (this._running) return
397
+ this._running = true
398
+ this._readLoop()
399
+ }
400
+
401
+ stop () {
402
+ // Idempotent: _readLoop calls stop() again when it exits after an explicit
403
+ // stop — don't broadcast a second synthetic DISCONNECTED event.
404
+ if (!this._running) return
405
+ this._running = false
406
+ // Broadcast a synthetic disconnect event to all registered event callbacks
407
+ // so long-running waits can unblock cleanly.
408
+ this._broadcastDisconnect()
409
+ for (const [, entry] of this._pending) {
410
+ entry.reject(new TransportDisconnectedError('connection lost while waiting for response'))
411
+ }
412
+ this._pending.clear()
413
+ this._eventBuffers.clear() // drop events buffered for never-registered subs
414
+ }
415
+
416
+ registerRequest (uuid) {
417
+ return new Promise((resolve, reject) => {
418
+ this._pending.set(uuid, { resolve, reject })
419
+ })
420
+ }
421
+
422
+ unregisterRequest (uuid) {
423
+ this._pending.delete(uuid)
424
+ }
425
+
426
+ registerEventCallback (subId, callback) {
427
+ this._eventCallbacks.set(subId, callback)
428
+ // Flush events that arrived before this callback registered (atomic-subscribe
429
+ // race). Buffered events precede later live events: this runs synchronously at
430
+ // register time, before any subsequent live event for subId can be dispatched.
431
+ const buffered = this._eventBuffers.get(subId)
432
+ if (buffered) {
433
+ this._eventBuffers.delete(subId)
434
+ for (const event of buffered) {
435
+ try { callback(event) } catch (e) { console.error('Event callback error:', e) }
436
+ }
437
+ }
438
+ }
439
+
440
+ unregisterEventCallback (subId) {
441
+ this._eventCallbacks.delete(subId)
442
+ this._eventBuffers.delete(subId)
443
+ }
444
+
445
+ async _readLoop () {
446
+ while (this._running && this._transport && this._transport.connected()) {
447
+ try {
448
+ const data = await this._transport.receiveMessage()
449
+ if (!data) break // null: connection closed
450
+ if (data.length === 0) continue // zero-length frame: not a message, keep reading
451
+
452
+ const resp = new ResponseMessage().deserialize(data)
453
+
454
+ if (resp.requestUri === EVENT_URI) {
455
+ this._dispatchEvent(resp)
456
+ } else {
457
+ this._dispatchResponse(resp)
458
+ }
459
+ } catch (e) {
460
+ console.error('MessageDemuxer read error:', e)
461
+ break
462
+ }
463
+ }
464
+ // Connection lost — stop() handles broadcast + pending cleanup
465
+ this.stop()
466
+ }
467
+
468
+ _broadcastDisconnect () {
469
+ for (const [subId, cb] of this._eventCallbacks) {
470
+ try {
471
+ cb({
472
+ subscription_id: subId,
473
+ event_type: EVENT_TYPE_DISCONNECTED,
474
+ app_name: '',
475
+ timestamp: 0,
476
+ sequence: 0,
477
+ data: {}
478
+ })
479
+ } catch (e) {
480
+ console.error('Disconnect callback error:', e)
481
+ }
482
+ }
483
+ }
484
+
485
+ _dispatchEvent (resp) {
486
+ try {
487
+ const bodyStr = typeof resp.body === 'string' ? resp.body : resp.body.toString('utf-8')
488
+ const event = JSON.parse(bodyStr)
489
+ const subId = event.subscription_id || _headerValue(resp.headers, 'X-Subscription-Id')
490
+ const cb = this._eventCallbacks.get(subId)
491
+ if (cb) {
492
+ try { cb(event) } catch (e) { console.error('Event callback error:', e) }
493
+ } else if (subId) {
494
+ this._bufferEvent(subId, event)
495
+ }
496
+ } catch (e) {
497
+ console.error('Failed to dispatch event:', e)
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Hold an event whose callback has not registered yet, bounded to avoid
503
+ * unbounded growth when a callback never registers.
504
+ * @private
505
+ */
506
+ _bufferEvent (subId, event) {
507
+ let buf = this._eventBuffers.get(subId)
508
+ if (!buf) {
509
+ if (this._eventBuffers.size >= MessageDemuxer._MAX_BUFFERED_SUBS) {
510
+ return // cap distinct unregistered subs to bound memory
511
+ }
512
+ buf = []
513
+ this._eventBuffers.set(subId, buf)
514
+ }
515
+ if (buf.length >= MessageDemuxer._MAX_BUFFERED_EVENTS_PER_SUB) {
516
+ buf.shift() // drop-oldest
517
+ }
518
+ buf.push(event)
519
+ }
520
+
521
+ _dispatchResponse (resp) {
522
+ const entry = this._pending.get(resp.uuid)
523
+ if (entry) {
524
+ this._pending.delete(resp.uuid)
525
+ entry.resolve(resp)
526
+ }
527
+ }
528
+ }
529
+
312
530
  class AppMeshClientTCP extends AppMeshClient {
313
531
  /**
314
532
  * Create a TCP-based App Mesh client.
@@ -320,7 +538,8 @@ class AppMeshClientTCP extends AppMeshClient {
320
538
  * `new AppMeshClientTCP(sslConfig, {host, port})` — the address may be an explicit
321
539
  * `{host, port}` object in either position.
322
540
  *
323
- * @param {Object|false|null} [sslConfig=null] - TLS config; same tri-state as AppMeshClient (null=default CA, object=custom ca/cert/key, false=insecure).
541
+ * @param {Object|false|null} [sslConfig=null] - TLS config; same tri-state as AppMeshClient
542
+ * (null=default CA, object=custom ca/cert/key, false=insecure).
324
543
  * @param {Array<string, number>|{host: string, port: number}} [tcpAddress=['127.0.0.1', 6059]] - TCP server address
325
544
  */
326
545
  constructor (sslConfig = null, tcpAddress = ['127.0.0.1', 6059]) {
@@ -335,6 +554,8 @@ class AppMeshClientTCP extends AppMeshClient {
335
554
  // Pass dummy baseURL to parent - not used for TCP communication
336
555
  super('https://127.0.0.1:6060', sslConfig)
337
556
  this.tcpTransport = new TCPTransport(tcpAddress, sslConfig)
557
+ this._demuxer = null
558
+ this._demuxerEnablePromise = null
338
559
  }
339
560
 
340
561
  /**
@@ -447,7 +668,9 @@ class AppMeshClientTCP extends AppMeshClient {
447
668
  // Mirror the HTTP transport: one refresh-and-retry after a 401 when the
448
669
  // attached provider can refresh. TCP bodies are serialized Buffers built
449
670
  // from the caller's body, so every request is replayable here.
450
- if (result.status === 401 && !options.__appmeshRetried) {
671
+ const callerManagesAuth = !!(options.headers && Object.keys(options.headers).some(
672
+ (key) => key.toLowerCase() === 'authorization'))
673
+ if (result.status === 401 && !options.__appmeshRetried && !callerManagesAuth) {
451
674
  const provider = this._tokenProvider
452
675
  const canRefresh = provider &&
453
676
  (typeof provider.canRefresh === 'function' ? await provider.canRefresh() : !!provider.canRefresh)
@@ -538,7 +761,8 @@ class AppMeshClientTCP extends AppMeshClient {
538
761
  * on non-Windows Node.js hosts
539
762
  * @override
540
763
  */
541
- async download_file (filePath, localFile = null, applyAttrs = false) {
764
+ async downloadFile(filePath, localFile = null, applyAttrs = false) {
765
+ this._ensureNoActiveDemuxer()
542
766
  if (!localFile) localFile = filePath.split(/[\\/]/).pop()
543
767
  const headers = {
544
768
  [HTTP_HEADER_KEY_X_FILE_PATH]: encodeURIComponent(filePath),
@@ -615,7 +839,8 @@ class AppMeshClientTCP extends AppMeshClient {
615
839
  * metadata so the server can recreate permissions and ownership when supported
616
840
  * @override
617
841
  */
618
- async upload_file (localFile, filePath = null, applyAttrs = false) {
842
+ async uploadFile(localFile, filePath = null, applyAttrs = false) {
843
+ this._ensureNoActiveDemuxer()
619
844
  if (!filePath) filePath = localFile.split(/[\\/]/).pop()
620
845
  if (!fs.existsSync(localFile)) {
621
846
  throw new Error(`Local file not found: ${localFile}`)
@@ -719,7 +944,7 @@ class AppMeshClientTCP extends AppMeshClient {
719
944
  * @param {function} [callback] - Event callback (required when subscribeEvents is set).
720
945
  * @returns {Promise<Object>} Registered app (includes subscription_id when subscribed).
721
946
  */
722
- async add_app (name, appJson, subscribeEvents, callback) {
947
+ async addApp(name, appJson, subscribeEvents, callback) {
723
948
  const options = {}
724
949
  if (subscribeEvents && subscribeEvents.length > 0) {
725
950
  await this._enableDemuxer()
@@ -736,17 +961,46 @@ class AppMeshClientTCP extends AppMeshClient {
736
961
  return result
737
962
  }
738
963
 
964
+ /**
965
+ * Reject TCP file transfers while the event demuxer owns the read side: the
966
+ * demuxer read loop would steal the raw (non-msgpack) chunk frames and
967
+ * corrupt the transfer (mirrors the Python SDK).
968
+ * @private
969
+ */
970
+ _ensureNoActiveDemuxer () {
971
+ if (this._demuxer && this._demuxer._running) {
972
+ throw new Error('file transfer is not supported while an event subscription is active over the same TCP ' +
973
+ 'connection; use a separate client instance')
974
+ }
975
+ }
976
+
739
977
  /**
740
978
  * Enable demuxer for concurrent request-response and event routing.
979
+ * Serialized: two concurrent subscribe() calls on an unconnected transport
980
+ * would otherwise both await connect() and both create a demuxer — two read
981
+ * loops competing for the same socket.
741
982
  * @private
742
983
  */
743
984
  async _enableDemuxer () {
744
985
  if (this._demuxer && this._demuxer._running) return
745
- if (!this.tcpTransport.connected()) {
746
- await this.tcpTransport.connect()
986
+ if (this._demuxerEnablePromise) {
987
+ await this._demuxerEnablePromise
988
+ return
989
+ }
990
+ this._demuxerEnablePromise = (async () => {
991
+ if (!this.tcpTransport.connected()) {
992
+ await this.tcpTransport.connect()
993
+ }
994
+ if (!this._demuxer || !this._demuxer._running) {
995
+ this._demuxer = new MessageDemuxer(this.tcpTransport)
996
+ this._demuxer.start()
997
+ }
998
+ })()
999
+ try {
1000
+ await this._demuxerEnablePromise
1001
+ } finally {
1002
+ this._demuxerEnablePromise = null
747
1003
  }
748
- this._demuxer = new MessageDemuxer(this.tcpTransport)
749
- this._demuxer.start()
750
1004
  }
751
1005
 
752
1006
  /**
@@ -756,13 +1010,15 @@ class AppMeshClientTCP extends AppMeshClient {
756
1010
  * @param {number} [timeout=0] - Max wait time in seconds (0 = unlimited)
757
1011
  * @returns {Promise<number|null>} Exit code, or null on timeout
758
1012
  * @throws {AppRemovedError} If the app was removed before its exit was observed.
759
- * @throws {TransportDisconnectedError} If the transport disconnected while waiting, or the daemon delivered an unparseable exit code.
1013
+ * @throws {TransportDisconnectedError} If the transport disconnected while waiting, or the daemon delivered
1014
+ * an unparseable exit code.
760
1015
  */
761
- async wait_for_async_run (run, stdoutHandler, timeout = 0) {
1016
+ async waitForAsyncRun(run, stdoutHandler, timeout = 0) {
762
1017
  if (!run || !run.appName) return null
763
1018
 
764
1019
  let exitCode = null
765
1020
  let failure = null
1021
+ let disconnected = false // transport died — skip unsubscribe cleanup (SDKContract cleanup policy)
766
1022
  let deliveredUntil = 0 // next-byte offset already delivered
767
1023
  let done = false
768
1024
  let resolveWait
@@ -807,6 +1063,7 @@ class AppMeshClientTCP extends AppMeshClient {
807
1063
  done = true
808
1064
  resolveWait()
809
1065
  } else if (event.event_type === EVENT_TYPE_DISCONNECTED) {
1066
+ disconnected = true
810
1067
  if (exitCode === null && failure === null) {
811
1068
  failure = new TransportDisconnectedError(`transport disconnected while waiting for '${run.appName}' to exit`)
812
1069
  }
@@ -820,7 +1077,7 @@ class AppMeshClientTCP extends AppMeshClient {
820
1077
  try {
821
1078
  // Backfill output emitted before subscribe took effect
822
1079
  try {
823
- const backfill = await this.get_app_output(run.appName, 0, 0, 0, run.procUid, 0)
1080
+ const backfill = await this.getAppOutput(run.appName, 0, 0, 0, run.procUid, 0)
824
1081
  if (backfill.output) {
825
1082
  deliver(backfill.output, 0)
826
1083
  }
@@ -835,25 +1092,34 @@ class AppMeshClientTCP extends AppMeshClient {
835
1092
 
836
1093
  if (!done) {
837
1094
  if (timeout > 0) {
838
- await Promise.race([
839
- waitPromise,
840
- new Promise(resolve => setTimeout(resolve, timeout * 1000))
841
- ])
1095
+ // Clear the timer once the race settles so an early exit does not pin
1096
+ // the Node event loop for the remaining timeout window.
1097
+ let timer
1098
+ const timeoutPromise = new Promise(resolve => { timer = setTimeout(resolve, timeout * 1000) })
1099
+ try {
1100
+ await Promise.race([waitPromise, timeoutPromise])
1101
+ } finally {
1102
+ clearTimeout(timer)
1103
+ }
842
1104
  } else {
843
1105
  await waitPromise
844
1106
  }
845
1107
  }
846
1108
  } finally {
847
- try {
848
- if (sub && sub.subscription_id) {
849
- await this.unsubscribe(sub.subscription_id)
850
- }
851
- } catch (_) { /* ignore */ }
1109
+ // Cleanup policy: after a disconnect the transport is dead — an unsubscribe
1110
+ // would silently reconnect and register a never-answered waiter.
1111
+ if (!disconnected) {
1112
+ try {
1113
+ if (sub && sub.subscription_id) {
1114
+ await this.unsubscribe(sub.subscription_id)
1115
+ }
1116
+ } catch (_) { /* ignore */ }
1117
+ }
852
1118
 
853
1119
  // Best-effort delete on a real exit; on REMOVED/disconnect the app is already gone.
854
1120
  if (exitCode !== null && failure === null) {
855
1121
  try {
856
- await this.delete_app(run.appName)
1122
+ await this.deleteApp(run.appName)
857
1123
  } catch (_) { /* ignore */ }
858
1124
  }
859
1125
  }
@@ -863,171 +1129,7 @@ class AppMeshClientTCP extends AppMeshClient {
863
1129
  }
864
1130
  }
865
1131
 
866
- const EVENT_URI = '/appmesh/event'
867
-
868
- /**
869
- * Synthetic event_type pushed to every registered callback when the demuxer
870
- * stops or the underlying transport disconnects. Lets long-running waits
871
- * (e.g. wait_for_async_run) unblock instead of hanging forever.
872
- */
873
- const EVENT_TYPE_DISCONNECTED = '__disconnected__'
874
-
875
- /**
876
- * Routes incoming messages to either pending request promises (by UUID) or
877
- * event subscription callbacks. Replaces the broken dual-reader pattern.
878
- * Events are dispatched to subscription callbacks synchronously from the single
879
- * read loop, in arrival order (matches the other SDK demuxers).
880
- */
881
- class MessageDemuxer {
882
- // Bound the pre-registration event buffer (atomic-subscribe race window) so a
883
- // subscription whose callback never registers cannot grow memory without limit.
884
- static _MAX_BUFFERED_SUBS = 64
885
- static _MAX_BUFFERED_EVENTS_PER_SUB = 1000
886
-
887
- constructor (transport) {
888
- this._transport = transport
889
- this._pending = new Map()
890
- this._eventCallbacks = new Map()
891
- // Events that arrive between server-side subscription and the client
892
- // registering its callback (e.g. atomic add_app(subscribe_events) on a fast
893
- // app, whose output is pushed before add_app returns). Held per subId and
894
- // flushed on registerEventCallback so no events are lost.
895
- this._eventBuffers = new Map()
896
- this._running = false
897
- }
898
-
899
- start () {
900
- if (this._running) return
901
- this._running = true
902
- this._readLoop()
903
- }
904
-
905
- stop () {
906
- // Idempotent: _readLoop calls stop() again when it exits after an explicit
907
- // stop — don't broadcast a second synthetic DISCONNECTED event.
908
- if (!this._running) return
909
- this._running = false
910
- // Broadcast a synthetic disconnect event to all registered event callbacks
911
- // so long-running waits can unblock cleanly.
912
- this._broadcastDisconnect()
913
- for (const [, entry] of this._pending) {
914
- entry.reject(new TransportDisconnectedError('connection lost while waiting for response'))
915
- }
916
- this._pending.clear()
917
- this._eventBuffers.clear() // drop events buffered for never-registered subs
918
- }
919
-
920
- registerRequest (uuid) {
921
- return new Promise((resolve, reject) => {
922
- this._pending.set(uuid, { resolve, reject })
923
- })
924
- }
925
-
926
- unregisterRequest (uuid) {
927
- this._pending.delete(uuid)
928
- }
929
-
930
- registerEventCallback (subId, callback) {
931
- this._eventCallbacks.set(subId, callback)
932
- // Flush events that arrived before this callback registered (atomic-subscribe
933
- // race). Buffered events precede later live events: this runs synchronously at
934
- // register time, before any subsequent live event for subId can be dispatched.
935
- const buffered = this._eventBuffers.get(subId)
936
- if (buffered) {
937
- this._eventBuffers.delete(subId)
938
- for (const event of buffered) {
939
- try { callback(event) } catch (e) { console.error('Event callback error:', e) }
940
- }
941
- }
942
- }
943
-
944
- unregisterEventCallback (subId) {
945
- this._eventCallbacks.delete(subId)
946
- this._eventBuffers.delete(subId)
947
- }
948
-
949
- async _readLoop () {
950
- while (this._running && this._transport && this._transport.connected()) {
951
- try {
952
- const data = await this._transport.receiveMessage()
953
- if (!data || data.length === 0) break
954
-
955
- const resp = new ResponseMessage().deserialize(data)
956
-
957
- if (resp.requestUri === EVENT_URI) {
958
- this._dispatchEvent(resp)
959
- } else {
960
- this._dispatchResponse(resp)
961
- }
962
- } catch (e) {
963
- break
964
- }
965
- }
966
- // Connection lost — stop() handles broadcast + pending cleanup
967
- this.stop()
968
- }
969
-
970
- _broadcastDisconnect () {
971
- for (const [subId, cb] of this._eventCallbacks) {
972
- try {
973
- cb({
974
- subscription_id: subId,
975
- event_type: EVENT_TYPE_DISCONNECTED,
976
- app_name: '',
977
- timestamp: 0,
978
- sequence: 0,
979
- data: {}
980
- })
981
- } catch (e) {
982
- console.error('Disconnect callback error:', e)
983
- }
984
- }
985
- }
986
-
987
- _dispatchEvent (resp) {
988
- try {
989
- const bodyStr = typeof resp.body === 'string' ? resp.body : resp.body.toString('utf-8')
990
- const event = JSON.parse(bodyStr)
991
- const subId = event.subscription_id || (resp.headers && resp.headers['X-Subscription-Id'])
992
- const cb = this._eventCallbacks.get(subId)
993
- if (cb) {
994
- try { cb(event) } catch (e) { console.error('Event callback error:', e) }
995
- } else if (subId) {
996
- this._bufferEvent(subId, event)
997
- }
998
- } catch (e) {
999
- console.error('Failed to dispatch event:', e)
1000
- }
1001
- }
1002
-
1003
- /**
1004
- * Hold an event whose callback has not registered yet, bounded to avoid
1005
- * unbounded growth when a callback never registers.
1006
- * @private
1007
- */
1008
- _bufferEvent (subId, event) {
1009
- let buf = this._eventBuffers.get(subId)
1010
- if (!buf) {
1011
- if (this._eventBuffers.size >= MessageDemuxer._MAX_BUFFERED_SUBS) {
1012
- return // cap distinct unregistered subs to bound memory
1013
- }
1014
- buf = []
1015
- this._eventBuffers.set(subId, buf)
1016
- }
1017
- if (buf.length >= MessageDemuxer._MAX_BUFFERED_EVENTS_PER_SUB) {
1018
- buf.shift() // drop-oldest
1019
- }
1020
- buf.push(event)
1021
- }
1022
-
1023
- _dispatchResponse (resp) {
1024
- const entry = this._pending.get(resp.uuid)
1025
- if (entry) {
1026
- this._pending.delete(resp.uuid)
1027
- entry.resolve(resp)
1028
- }
1029
- }
1030
- }
1031
-
1032
1132
  export { AppMeshClientTCP, TCPTransport, RequestMessage, ResponseMessage, MessageDemuxer, EVENT_TYPE_DISCONNECTED }
1033
1133
  export default AppMeshClientTCP
1134
+
1135
+ // ---------------------------------------------------------------------------