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.
- package/README.md +27 -23
- package/dist/appmesh.esm.js +1 -1
- package/package.json +2 -2
- package/src/appmesh.js +77 -71
- package/src/appmesh_tcp.js +317 -215
- package/src/worker.js +34 -44
package/src/appmesh_tcp.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
230
|
+
if (!this._readPump) {
|
|
231
|
+
const pump = {
|
|
232
|
+
waiters: [],
|
|
233
|
+
buffered: [],
|
|
234
|
+
ended: false,
|
|
235
|
+
error: null
|
|
199
236
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
746
|
-
await this.
|
|
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
|
|
1013
|
+
* @throws {TransportDisconnectedError} If the transport disconnected while waiting, or the daemon delivered
|
|
1014
|
+
* an unparseable exit code.
|
|
760
1015
|
*/
|
|
761
|
-
async
|
|
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.
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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.
|
|
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
|
+
// ---------------------------------------------------------------------------
|