jspurefix 5.8.5 → 5.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.gitattributes +4 -0
  2. package/BACKPORT_PLAN.md +84 -0
  3. package/DEMO_PORT_PLAN.md +10 -0
  4. package/README.md +3 -0
  5. package/WORKLOG.md +119 -0
  6. package/dist/benchmark/bench-report.d.ts +5 -0
  7. package/dist/benchmark/bench-report.js +91 -0
  8. package/dist/benchmark/bench-report.js.map +1 -0
  9. package/dist/benchmark/bench-runner.d.ts +12 -0
  10. package/dist/benchmark/bench-runner.js +193 -0
  11. package/dist/benchmark/bench-runner.js.map +1 -0
  12. package/dist/benchmark/bench-stats.d.ts +10 -0
  13. package/dist/benchmark/bench-stats.js +56 -0
  14. package/dist/benchmark/bench-stats.js.map +1 -0
  15. package/dist/benchmark/bench-types.d.ts +74 -0
  16. package/dist/benchmark/bench-types.js +3 -0
  17. package/dist/benchmark/bench-types.js.map +1 -0
  18. package/dist/benchmark/gc-probe.d.ts +18 -0
  19. package/dist/benchmark/gc-probe.js +103 -0
  20. package/dist/benchmark/gc-probe.js.map +1 -0
  21. package/dist/benchmark/index.d.ts +6 -0
  22. package/dist/benchmark/index.js +23 -0
  23. package/dist/benchmark/index.js.map +1 -0
  24. package/dist/benchmark/parse-bench.d.ts +28 -0
  25. package/dist/benchmark/parse-bench.js +85 -0
  26. package/dist/benchmark/parse-bench.js.map +1 -0
  27. package/dist/buffer/ascii/ascii-parser.d.ts +1 -0
  28. package/dist/buffer/ascii/ascii-parser.js +4 -1
  29. package/dist/buffer/ascii/ascii-parser.js.map +1 -1
  30. package/dist/buffer/ascii/ascii-segment-parser.js +3 -2
  31. package/dist/buffer/ascii/ascii-segment-parser.js.map +1 -1
  32. package/dist/buffer/ascii/tag-index.js.map +1 -1
  33. package/dist/buffer/msg-view.d.ts +1 -0
  34. package/dist/buffer/msg-view.js +25 -19
  35. package/dist/buffer/msg-view.js.map +1 -1
  36. package/dist/config/js-fix-config.d.ts +4 -0
  37. package/dist/config/js-fix-config.js.map +1 -1
  38. package/dist/dictionary/parser/quickfix/dictionary-validator.d.ts +1 -1
  39. package/dist/dictionary/parser/quickfix/dictionary-validator.js.map +1 -1
  40. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.d.ts +1 -1
  41. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js +1 -2
  42. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js.map +1 -1
  43. package/dist/dictionary/parser/quickfix/validation-error.d.ts +2 -2
  44. package/dist/dictionary/parser/quickfix/validation-error.js.map +1 -1
  45. package/dist/jsfix-bench.d.ts +1 -0
  46. package/dist/jsfix-bench.js +213 -0
  47. package/dist/jsfix-bench.js.map +1 -0
  48. package/dist/runtime/session-launcher.js +1 -1
  49. package/dist/runtime/session-launcher.js.map +1 -1
  50. package/dist/runtime/session-scope.d.ts +3 -0
  51. package/dist/runtime/session-scope.js +47 -0
  52. package/dist/runtime/session-scope.js.map +1 -0
  53. package/dist/sample/http/oms/app.js +3 -3
  54. package/dist/sample/http/oms/app.js.map +1 -1
  55. package/dist/sample/tcp/qf-md/app.js +3 -3
  56. package/dist/sample/tcp/qf-md/app.js.map +1 -1
  57. package/dist/sample/tcp/skeleton/app.js +1 -1
  58. package/dist/sample/tcp/skeleton/app.js.map +1 -1
  59. package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
  60. package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
  61. package/dist/store/file-session-store.js +1 -1
  62. package/dist/store/file-session-store.js.map +1 -1
  63. package/dist/store/file-session-stream-provider.d.ts +1 -0
  64. package/dist/store/file-session-stream-provider.js +13 -13
  65. package/dist/store/file-session-stream-provider.js.map +1 -1
  66. package/dist/store/fix-session-store-factory.d.ts +1 -1
  67. package/dist/store/fix-session-store-factory.js.map +1 -1
  68. package/dist/store/fix-session-store.d.ts +11 -11
  69. package/dist/store/fix-session-store.js.map +1 -1
  70. package/dist/store/memory-session-store.js.map +1 -1
  71. package/dist/store/session-stream-provider.d.ts +13 -13
  72. package/dist/store/session-stream-provider.js.map +1 -1
  73. package/dist/transport/ascii/ascii-session.d.ts +17 -3
  74. package/dist/transport/ascii/ascii-session.js +121 -8
  75. package/dist/transport/ascii/ascii-session.js.map +1 -1
  76. package/dist/transport/duplex/fix-duplex.d.ts +1 -0
  77. package/dist/transport/duplex/fix-duplex.js +3 -0
  78. package/dist/transport/duplex/fix-duplex.js.map +1 -1
  79. package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
  80. package/dist/transport/duplex/tcp-duplex.js +5 -0
  81. package/dist/transport/duplex/tcp-duplex.js.map +1 -1
  82. package/dist/transport/factory/msg-transport.d.ts +2 -0
  83. package/dist/transport/factory/msg-transport.js +17 -2
  84. package/dist/transport/factory/msg-transport.js.map +1 -1
  85. package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
  86. package/dist/transport/session/a-session-msg-factory.js +4 -0
  87. package/dist/transport/session/a-session-msg-factory.js.map +1 -1
  88. package/dist/transport/session/clone-session-description.d.ts +6 -0
  89. package/dist/transport/session/clone-session-description.js +11 -0
  90. package/dist/transport/session/clone-session-description.js.map +1 -0
  91. package/dist/transport/session/fix-clock.d.ts +1 -1
  92. package/dist/transport/session/fix-clock.js.map +1 -1
  93. package/dist/transport/session/fix-session.d.ts +5 -1
  94. package/dist/transport/session/fix-session.js +93 -24
  95. package/dist/transport/session/fix-session.js.map +1 -1
  96. package/dist/transport/session/index.d.ts +2 -0
  97. package/dist/transport/session/index.js +2 -0
  98. package/dist/transport/session/index.js.map +1 -1
  99. package/dist/transport/session/resend-request-manager.d.ts +2 -2
  100. package/dist/transport/session/resend-request-manager.js.map +1 -1
  101. package/dist/transport/session/session-msg-factory.d.ts +1 -0
  102. package/dist/transport/session/session-msg-factory.js.map +1 -1
  103. package/dist/transport/session/session-registry.d.ts +23 -0
  104. package/dist/transport/session/session-registry.js +59 -0
  105. package/dist/transport/session/session-registry.js.map +1 -0
  106. package/dist/transport/session/session-sequence-coordinator.d.ts +1 -1
  107. package/dist/transport/session/session-sequence-coordinator.js.map +1 -1
  108. package/dist/transport/session/session-sequence-store.d.ts +3 -3
  109. package/dist/transport/session/session-sequence-store.js.map +1 -1
  110. package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
  111. package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
  112. package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
  113. package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
  114. package/dist/transport/tcp/tcp-acceptor.js +74 -20
  115. package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
  116. package/dist/transport/tcp/tcp-initiator.js +15 -5
  117. package/dist/transport/tcp/tcp-initiator.js.map +1 -1
  118. package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
  119. package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
  120. package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
  121. package/dist/transport/tcp/tls-diagnostics.js +64 -0
  122. package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
  123. package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
  124. package/dist/transport/tcp/tls-options-factory.js +24 -2
  125. package/dist/transport/tcp/tls-options-factory.js.map +1 -1
  126. package/docs/acceptor.md +207 -0
  127. package/docs/benchmarking.md +117 -0
  128. package/eslint.config.mjs +15 -1
  129. package/jsfix.test_client.txt +62 -75
  130. package/jsfix.test_server.txt +72 -72
  131. package/package.json +19 -10
  132. package/script/pack-local.js +68 -0
  133. package/src/benchmark/bench-report.ts +101 -0
  134. package/src/benchmark/bench-runner.ts +196 -0
  135. package/src/benchmark/bench-stats.ts +86 -0
  136. package/src/benchmark/bench-types.ts +110 -0
  137. package/src/benchmark/gc-probe.ts +145 -0
  138. package/src/benchmark/index.ts +6 -0
  139. package/src/benchmark/parse-bench.ts +144 -0
  140. package/src/buffer/ascii/ascii-parser.ts +16 -1
  141. package/src/buffer/ascii/ascii-segment-parser.ts +9 -6
  142. package/src/buffer/ascii/tag-index.ts +3 -3
  143. package/src/buffer/msg-view.ts +35 -18
  144. package/src/config/js-fix-config.ts +8 -0
  145. package/src/dictionary/parser/quickfix/dictionary-validator.ts +1 -1
  146. package/src/dictionary/parser/quickfix/quick-fix-graph-parser.ts +2 -3
  147. package/src/dictionary/parser/quickfix/validation-error.ts +1 -1
  148. package/src/jsfix-bench.ts +204 -0
  149. package/src/runtime/session-launcher.ts +5 -1
  150. package/src/runtime/session-scope.ts +84 -0
  151. package/src/sample/http/oms/app.ts +3 -3
  152. package/src/sample/tcp/qf-md/app.ts +3 -3
  153. package/src/sample/tcp/skeleton/app.ts +1 -1
  154. package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
  155. package/src/store/file-session-store.ts +2 -2
  156. package/src/store/file-session-stream-provider.ts +17 -11
  157. package/src/store/fix-session-store-factory.ts +1 -1
  158. package/src/store/fix-session-store.ts +11 -11
  159. package/src/store/memory-session-store.ts +1 -1
  160. package/src/store/session-stream-provider.ts +13 -13
  161. package/src/transport/ascii/ascii-session.ts +186 -11
  162. package/src/transport/duplex/fix-duplex.ts +9 -0
  163. package/src/transport/duplex/tcp-duplex.ts +10 -0
  164. package/src/transport/factory/msg-transport.ts +20 -0
  165. package/src/transport/session/a-session-msg-factory.ts +18 -0
  166. package/src/transport/session/clone-session-description.ts +41 -0
  167. package/src/transport/session/fix-clock.ts +1 -1
  168. package/src/transport/session/fix-session.ts +136 -25
  169. package/src/transport/session/index.ts +2 -0
  170. package/src/transport/session/resend-request-manager.ts +4 -4
  171. package/src/transport/session/session-msg-factory.ts +13 -0
  172. package/src/transport/session/session-registry.ts +112 -0
  173. package/src/transport/session/session-sequence-coordinator.ts +1 -1
  174. package/src/transport/session/session-sequence-store.ts +3 -3
  175. package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
  176. package/src/transport/tcp/tcp-acceptor.ts +92 -18
  177. package/src/transport/tcp/tcp-initiator.ts +26 -5
  178. package/src/transport/tcp/tcp-transport-description.ts +7 -0
  179. package/src/transport/tcp/tls-diagnostics.ts +76 -0
  180. package/src/transport/tcp/tls-options-factory.ts +34 -2
@@ -9,17 +9,44 @@ import * as events from 'events'
9
9
  import { SessionState } from './session-state'
10
10
  import { SegmentType } from '../../buffer/segment/segment-type'
11
11
 
12
+ /**
13
+ * The set of listeners this session attached to one specific transport.
14
+ *
15
+ * The handlers must be kept as stable references - `EventEmitter.removeListener`
16
+ * matches on identity, so subscribing with a fresh closure and unsubscribing with
17
+ * a method reference silently leaves the listener attached (issue #153). Holding
18
+ * the transport alongside the handlers also lets `unsubscribe` detach from the
19
+ * transport it actually subscribed to, even when `this.transport` has already
20
+ * moved on to a newer connection.
21
+ */
22
+ interface ITransportSubscription {
23
+ readonly transport: MsgTransport
24
+ readonly rxOnMsg: (msgType: string, view: MsgView) => void
25
+ readonly rxOnError: (e: Error) => void
26
+ readonly rxOnDone: () => void
27
+ readonly rxOnEnd: () => void
28
+ readonly rxOnDecoded: (msgType: string, data: ElasticBuffer, ptr: number) => void
29
+ readonly txOnError: (e: Error) => void
30
+ readonly txOnEncoded: (msgType: string, data: string, hdr: ILooseObject) => void
31
+ }
32
+
12
33
  export abstract class FixSession extends events.EventEmitter {
13
34
  public logReceivedMsgs: boolean = false
14
35
  protected timer: NodeJS.Timeout | null = null
15
36
  protected transport: MsgTransport | null = null
37
+ private subscription: ITransportSubscription | null = null
16
38
  public manageSession: boolean = true
17
39
  public checkMsgIntegrity: boolean = false
18
40
  protected readonly me: string
19
41
  protected readonly initiator: boolean
20
42
  protected readonly acceptor: boolean
21
43
  protected readonly sessionState: FixSessionState
22
- protected readonly sessionLogger: IJsFixLogger
44
+ /**
45
+ * Not readonly: a wildcard acceptor does not know which counterparty it is
46
+ * serving until the Logon arrives, and every session on that listener would
47
+ * otherwise log under the same name. Rebound once the identity is known.
48
+ */
49
+ protected sessionLogger: IJsFixLogger
23
50
  protected requestLogoutType: string
24
51
  protected respondLogoutType: string
25
52
  protected requestLogonType: string
@@ -120,7 +147,9 @@ export abstract class FixSession extends events.EventEmitter {
120
147
  }
121
148
  const onError = (e: Error): void => {
122
149
  cleanup()
123
- logger.error(e)
150
+ // read the logger now, not the one captured when the run started - a
151
+ // wildcard acceptor rebinds it once it knows which peer it is serving
152
+ this.sessionLogger.error(e)
124
153
  reject(e)
125
154
  }
126
155
  const onDone = (): void => {
@@ -217,35 +246,90 @@ export abstract class FixSession extends events.EventEmitter {
217
246
  this.onEncoded(msgType, data)
218
247
  }
219
248
 
249
+ /**
250
+ * true when the event which has just fired came from the transport this session
251
+ * is currently running. A half open socket can keep emitting long after the peer
252
+ * has reconnected on a fresh connection - those events must not be allowed to drive
253
+ * the live session (issue #153).
254
+ */
255
+ private isCurrentTransport (transport: MsgTransport, event: string): boolean {
256
+ if (this.subscription?.transport === transport && this.transport === transport) {
257
+ return true
258
+ }
259
+ const current = this.transport?.id ?? 'none'
260
+ this.sessionLogger.warning(`ignoring '${event}' from stale transport ${transport.id} - current transport is ${current}`)
261
+ return false
262
+ }
263
+
220
264
  protected unsubscribe (): void {
221
265
  const logger = this.sessionLogger
222
- logger.info(`unsubscribe sessionState = [${this.sessionState.toString()}]`)
223
- const transport = this.transport
224
- const rx = transport?.receiver
225
- const tx = transport?.transmitter
226
-
227
- rx?.removeListener('msg', this.rxOnMsg)
228
- rx?.removeListener('error', this.rxOnError)
229
- rx?.removeListener('done', this.rxOnDone)
230
- rx?.removeListener('end', this.rxOnEnd)
231
- rx?.removeListener('decoded', this.rxOnDecoded)
232
- tx?.removeListener('error', this.txOnError)
233
- tx?.removeListener('encoded', this.txOnEncoded)
266
+ const subscription = this.subscription
267
+ if (!subscription) {
268
+ logger.debug('unsubscribe called with no active subscription')
269
+ return
270
+ }
271
+ const transport = subscription.transport
272
+ logger.info(`unsubscribe transport ${transport.id} sessionState = [${this.sessionState.toString()}]`)
273
+ const rx = transport.receiver
274
+ const tx = transport.transmitter
275
+
276
+ rx?.removeListener('msg', subscription.rxOnMsg)
277
+ rx?.removeListener('error', subscription.rxOnError)
278
+ rx?.removeListener('done', subscription.rxOnDone)
279
+ rx?.removeListener('end', subscription.rxOnEnd)
280
+ rx?.removeListener('decoded', subscription.rxOnDecoded)
281
+ tx?.removeListener('error', subscription.txOnError)
282
+ tx?.removeListener('encoded', subscription.txOnEncoded)
283
+ this.subscription = null
284
+ logger.debug(`unsubscribe complete - rx listeners now ${rx?.listenerCount('msg') ?? 0} msg, ${rx?.listenerCount('end') ?? 0} end`)
234
285
  }
235
286
 
236
287
  protected subscribe (): void {
237
288
  const transport = this.transport
238
-
239
- const rx = transport?.receiver
240
- const tx = transport?.transmitter
289
+ if (!transport) {
290
+ this.sessionLogger.warning('subscribe called with no transport')
291
+ return
292
+ }
293
+ // detach from any previous transport before attaching to this one
294
+ if (this.subscription) {
295
+ this.unsubscribe()
296
+ }
241
297
  const inst = this
242
- rx?.on('msg', (msgType: string, view: MsgView) => { inst.rxOnMsg(msgType, view) })
243
- rx?.on('error', (e: Error) => { inst.rxOnError(e) })
244
- rx?.on('done', () => { inst.rxOnDone() })
245
- rx?.on('end', () => { inst.rxOnEnd() })
246
- rx?.on('decoded', (msgType: string, data: ElasticBuffer, ptr: number) => { inst.rxOnDecoded(msgType, data, ptr) })
247
- tx?.on('error', (e: Error) => { inst.txOnError(e) })
248
- tx?.on('encoded', (msgType: string, data: string, hdr: ILooseObject) => { inst.txOnEncoded(msgType, data, hdr) })
298
+ const subscription: ITransportSubscription = {
299
+ transport,
300
+ rxOnMsg: (msgType: string, view: MsgView) => {
301
+ if (inst.isCurrentTransport(transport, 'msg')) inst.rxOnMsg(msgType, view)
302
+ },
303
+ rxOnError: (e: Error) => {
304
+ if (inst.isCurrentTransport(transport, 'error')) inst.rxOnError(e)
305
+ },
306
+ rxOnDone: () => {
307
+ if (inst.isCurrentTransport(transport, 'done')) inst.rxOnDone()
308
+ },
309
+ rxOnEnd: () => {
310
+ if (inst.isCurrentTransport(transport, 'end')) inst.rxOnEnd()
311
+ },
312
+ rxOnDecoded: (msgType: string, data: ElasticBuffer, ptr: number) => {
313
+ if (inst.isCurrentTransport(transport, 'decoded')) inst.rxOnDecoded(msgType, data, ptr)
314
+ },
315
+ txOnError: (e: Error) => {
316
+ if (inst.isCurrentTransport(transport, 'tx error')) inst.txOnError(e)
317
+ },
318
+ txOnEncoded: (msgType: string, data: string, hdr: ILooseObject) => {
319
+ if (inst.isCurrentTransport(transport, 'encoded')) inst.txOnEncoded(msgType, data, hdr)
320
+ }
321
+ }
322
+ const rx = transport.receiver
323
+ const tx = transport.transmitter
324
+ rx?.on('msg', subscription.rxOnMsg)
325
+ rx?.on('error', subscription.rxOnError)
326
+ rx?.on('done', subscription.rxOnDone)
327
+ rx?.on('end', subscription.rxOnEnd)
328
+ rx?.on('decoded', subscription.rxOnDecoded)
329
+ tx?.on('error', subscription.txOnError)
330
+ tx?.on('encoded', subscription.txOnEncoded)
331
+ this.subscription = subscription
332
+ this.sessionLogger.info(`subscribed to transport ${transport.id}`)
249
333
  }
250
334
 
251
335
  protected validStateApplicationMsg (): boolean {
@@ -285,12 +369,16 @@ export abstract class FixSession extends events.EventEmitter {
285
369
  if (this.sessionState.state === SessionState.Stopped) return
286
370
  this.sessionLogger.error(error)
287
371
  this.stopTimer()
372
+ this.unsubscribe()
288
373
  if (this.transport) {
374
+ this.sessionLogger.info(`terminate: kill transport ${this.transport.id}`)
289
375
  this.transport.end()
290
376
  }
291
377
  this.transport = null
292
378
  this.setState(SessionState.Stopped)
379
+ this.onSessionStopping()
293
380
  this.emit('error', error)
381
+ this.onStopped(error)
294
382
  }
295
383
 
296
384
  protected peerLogout (view: MsgView): void {
@@ -415,6 +503,16 @@ export abstract class FixSession extends events.EventEmitter {
415
503
  // Override in subclass to reset coordinator/transient state
416
504
  }
417
505
 
506
+ /**
507
+ * Called once as the session moves to Stopped, before the application is informed
508
+ * via onStopped. Override to release session scoped resources - the ascii session
509
+ * uses this to remove itself from the session registry. Mirrors cspurefix
510
+ * FixSession.OnSessionStopping.
511
+ */
512
+ protected onSessionStopping (): void {
513
+ // Default no-op
514
+ }
515
+
418
516
  /**
419
517
  * Called for initiators after the store is loaded but before the Logon is sent.
420
518
  * Override to reset sequences/store when ResetSeqNumFlag=Y so the Logon goes out
@@ -424,13 +522,26 @@ export abstract class FixSession extends events.EventEmitter {
424
522
  // Default no-op
425
523
  }
426
524
 
525
+ /**
526
+ * Stop this session on behalf of an external caller - typically the session registry
527
+ * when a new connection for the same SessionId replaces this one, but equally an
528
+ * application which has decided the peer is gone. Mirrors cspurefix
529
+ * FixSession.RequestStop.
530
+ * @param reason recorded in the session log and carried on the emitted error
531
+ */
532
+ public requestStop (reason: string): void {
533
+ this.sessionLogger.info(`requestStop: ${reason}`)
534
+ this.stop(new Error(reason))
535
+ }
536
+
427
537
  protected stop (error: Error | null = null): void {
428
538
  if (this.sessionState.state === SessionState.Stopped) {
429
539
  return
430
540
  }
431
541
  this.stopTimer()
432
542
  this.unsubscribe()
433
- this.sessionLogger.info('stop: kill transport')
543
+ this.sessionLogger.info(`stop: kill transport ${this.transport?.id ?? 'none'}`)
544
+ this.onSessionStopping()
434
545
  this.transport?.end()
435
546
  if (error) {
436
547
  this.sessionLogger.info(`stop: emit error ${error.message}`)
@@ -10,3 +10,5 @@ export * from './session-sequence-coordinator'
10
10
  export * from './session-sequence-store'
11
11
  export * from './resend-request-manager'
12
12
  export * from './fix-clock'
13
+ export * from './session-registry'
14
+ export * from './clone-session-description'
@@ -46,7 +46,7 @@ export class ResendAction {
46
46
  }
47
47
 
48
48
  export class PendingResendRange {
49
- private readonly receivedSeqs: Set<number> = new Set()
49
+ private readonly receivedSeqs = new Set<number>()
50
50
 
51
51
  constructor (
52
52
  public readonly begin: number,
@@ -190,11 +190,11 @@ export class ResendRequestManager {
190
190
  // keep requestHistory for debugging
191
191
  }
192
192
 
193
- get pending (): ReadonlyArray<PendingResendRange> {
193
+ get pending (): readonly PendingResendRange[] {
194
194
  return this.pendingRequests.slice()
195
195
  }
196
196
 
197
- get history (): ReadonlyArray<ResendRequestRecord> {
197
+ get history (): readonly ResendRequestRecord[] {
198
198
  return this.requestHistory.slice()
199
199
  }
200
200
 
@@ -240,7 +240,7 @@ export class ResendRequestManager {
240
240
  return { begin: gapBegin, end: gapEnd }
241
241
  }
242
242
 
243
- const covered: Set<number> = new Set()
243
+ const covered = new Set<number>()
244
244
  for (const pending of this.pendingRequests) {
245
245
  for (let seq = pending.begin; seq <= pending.end; seq++) {
246
246
  covered.add(seq)
@@ -13,4 +13,17 @@ export interface ISessionMsgFactory {
13
13
  heartbeat: (testReqId: string) => ILooseObject
14
14
  header: (msgType: string, seqNum: number, time: Date, overrideData?: Partial<IStandardHeader>) => ILooseObject
15
15
  trailer: (checksum: number) => ILooseObject
16
+ /**
17
+ * Produce a factory of this same kind bound to another session description.
18
+ *
19
+ * An acceptor builds one of these per accepted connection, because the outbound
20
+ * header carries that session's comp ids - and under a wildcard TargetCompID the
21
+ * target is not known until the peer logs on. Implementing this is what lets an
22
+ * application's own factory survive into those per-session scopes; without it the
23
+ * acceptor can only fall back to the stock factory for the protocol.
24
+ *
25
+ * ASessionMsgFactory supplies a default that reconstructs via the concrete
26
+ * constructor, so a subclass taking (description, mutator) needs to do nothing.
27
+ */
28
+ cloneFor?: (description: ISessionDescription) => ISessionMsgFactory
16
29
  }
@@ -0,0 +1,112 @@
1
+ import { SessionId } from '../../store/session-id'
2
+ import { IJsFixLogger } from '../../config/js-fix-logger'
3
+ import { JsFixLoggerFactory } from '../../config/js-fix-logger-factory'
4
+
5
+ /**
6
+ * The registry only ever needs to ask a session to stop, so it is typed against
7
+ * that capability rather than against FixSession. IJsFixConfig carries a registry,
8
+ * and FixSession is constructed from IJsFixConfig - depending on the concrete class
9
+ * here would close an import cycle.
10
+ */
11
+ export interface IStoppableSession {
12
+ requestStop: (reason: string) => void
13
+ }
14
+
15
+ /**
16
+ * Tracks the live session for each SessionId served by an acceptor.
17
+ *
18
+ * FIX allows exactly one active session per (BeginString, SenderCompID,
19
+ * TargetCompID). A counterparty whose socket has gone half open - no FIN, no RST,
20
+ * so node never raises 'end' - will happily open a second connection and log on
21
+ * again. Without a registry the acceptor then runs two sessions for one CompID
22
+ * pair, both writing to the same message store and both stamping sequence numbers,
23
+ * which corrupts the store and produces sequence errors the peer cannot recover
24
+ * from. See https://github.com/TimelordUK/jspurefix/issues/153.
25
+ *
26
+ * Ported from cspurefix PureFix.Transport/Session/SessionRegistry.cs.
27
+ */
28
+ export interface ISessionRegistry {
29
+ /**
30
+ * Claim this SessionId for the given session. Any *different* session already
31
+ * holding it is stopped first, because its transport is by definition stale.
32
+ * @returns true when an existing session was stopped
33
+ */
34
+ register: (sessionId: SessionId, session: IStoppableSession) => boolean
35
+ /**
36
+ * Release this SessionId. A no-op unless the registered session is this exact
37
+ * instance - a session which has already been replaced must not evict its
38
+ * successor on the way out.
39
+ */
40
+ unregister: (sessionId: SessionId, session: IStoppableSession) => void
41
+ /** number of live sessions */
42
+ readonly count: number
43
+ /** the SessionId keys currently held, for logging and diagnostics */
44
+ keys: () => string[]
45
+ get: (sessionId: SessionId) => IStoppableSession | null
46
+ }
47
+
48
+ export const REPLACED_BY_NEW_CONNECTION =
49
+ 'replaced by new connection - stopping old session with stale transport'
50
+
51
+ export class SessionRegistry implements ISessionRegistry {
52
+ private readonly sessions = new Map<string, IStoppableSession>()
53
+ private readonly logger: IJsFixLogger | null
54
+
55
+ constructor (logFactory?: JsFixLoggerFactory) {
56
+ this.logger = logFactory?.logger('SessionRegistry') ?? null
57
+ }
58
+
59
+ public get count (): number {
60
+ return this.sessions.size
61
+ }
62
+
63
+ public keys (): string[] {
64
+ return Array.from(this.sessions.keys())
65
+ }
66
+
67
+ public get (sessionId: SessionId): IStoppableSession | null {
68
+ return this.sessions.get(sessionId.toString()) ?? null
69
+ }
70
+
71
+ public register (sessionId: SessionId, session: IStoppableSession): boolean {
72
+ const key = sessionId.toString()
73
+ const logger = this.logger
74
+ logger?.info(`register called for SessionId=${key}, activeSessions=${this.sessions.size}`)
75
+
76
+ const existing = this.sessions.get(key)
77
+ let stoppedOld = false
78
+
79
+ if (existing && existing !== session) {
80
+ logger?.info(`FOUND EXISTING SESSION for ${key} - this is a reconnection scenario`)
81
+ logger?.info('stopping old session to prevent stale transport writes')
82
+ this.sessions.set(key, session)
83
+ // stop after re-pointing the map so the old session's unregister (which runs
84
+ // synchronously inside requestStop) sees it is no longer the holder
85
+ existing.requestStop(REPLACED_BY_NEW_CONNECTION)
86
+ stoppedOld = true
87
+ logger?.info('old session stopped, new session registered')
88
+ } else if (existing === session) {
89
+ logger?.debug(`same session instance already registered for ${key}`)
90
+ } else {
91
+ logger?.info(`no existing session found - registering new session: ${key}`)
92
+ this.sessions.set(key, session)
93
+ }
94
+
95
+ logger?.info(`register complete for ${key}: stoppedOldSession=${stoppedOld}, totalActiveSessions=${this.sessions.size}`)
96
+ return stoppedOld
97
+ }
98
+
99
+ public unregister (sessionId: SessionId, session: IStoppableSession): void {
100
+ const key = sessionId.toString()
101
+ const logger = this.logger
102
+ logger?.info(`unregister called for SessionId=${key}`)
103
+
104
+ if (this.sessions.get(key) === session) {
105
+ this.sessions.delete(key)
106
+ logger?.info(`successfully unregistered session: ${key}, remainingActiveSessions=${this.sessions.size}`)
107
+ } else {
108
+ // expected when a new connection has already taken this SessionId over
109
+ logger?.info(`session NOT unregistered (already replaced by new connection or not found): ${key}`)
110
+ }
111
+ }
112
+ }
@@ -160,7 +160,7 @@ export class SessionSequenceCoordinator {
160
160
  this.resendManager.recordRequestSent(begin, end, now)
161
161
  }
162
162
 
163
- get pendingResendRequests (): ReadonlyArray<PendingResendRange> {
163
+ get pendingResendRequests (): readonly PendingResendRange[] {
164
164
  return this.resendManager.pending
165
165
  }
166
166
 
@@ -6,9 +6,9 @@
6
6
  export interface ISessionSequenceStore {
7
7
  senderSeqNum: number
8
8
  targetSeqNum: number
9
- setSenderSeqNum (value: number): Promise<void>
10
- setTargetSeqNum (value: number): Promise<void>
11
- reset (): Promise<void>
9
+ setSenderSeqNum: (value: number) => Promise<void>
10
+ setTargetSeqNum: (value: number) => Promise<void>
11
+ reset: () => Promise<void>
12
12
  }
13
13
 
14
14
  /**
@@ -1,4 +1,4 @@
1
- import { IJsFixConfig } from '../../config'
1
+ import { IJsFixConfig, IJsFixLogger } from '../../config'
2
2
  import { FixAcceptor } from '../fix-acceptor'
3
3
  import { TcpAcceptor } from './tcp-acceptor'
4
4
  import { MsgTransport } from '../factory'
@@ -6,14 +6,23 @@ import { inject, injectable } from 'tsyringe'
6
6
  import { FixSession } from '../session/fix-session'
7
7
  import { DITokens } from '../../runtime/di-tokens'
8
8
  import { FixEntity } from '../fix-entity'
9
+ import { ISessionRegistry, SessionRegistry } from '../session/session-registry'
9
10
 
10
11
  @injectable()
11
12
  export class TcpAcceptorListener extends FixEntity {
12
13
  private acceptor: FixAcceptor | null = null
13
14
  private resolveStart: ((value: any) => void) | null = null
15
+ /**
16
+ * One registry per listener, so a counterparty reconnecting onto a fresh socket
17
+ * replaces its previous session instead of running alongside it (issue #153).
18
+ * An application may supply its own via config.sessionRegistry.
19
+ */
20
+ public readonly sessionRegistry: ISessionRegistry
14
21
 
15
22
  constructor (@inject(DITokens.IJsFixConfig) public readonly config: IJsFixConfig) {
16
23
  super(config)
24
+ this.sessionRegistry = config.sessionRegistry ?? new SessionRegistry(config.logFactory)
25
+ config.sessionRegistry = this.sessionRegistry
17
26
  }
18
27
 
19
28
  async start (): Promise<any> {
@@ -29,24 +38,53 @@ export class TcpAcceptorListener extends FixEntity {
29
38
  this.acceptor = acceptor
30
39
 
31
40
  acceptor.on('transport', (t: MsgTransport) => {
32
- logger.info(`creates new transport using DI token ${DITokens.FixSession}.`)
33
- const acceptorSession = sessionContainer.resolve<FixSession>(DITokens.FixSession)
41
+ // resolve from the transport's own scope, not the listener's container, so
42
+ // this session gets the per-connection description, buffers and message
43
+ // factory created by TcpAcceptor. Falls back to the listener container for
44
+ // transports created outside the acceptor (tests, custom entities).
45
+ const scope = t.config?.sessionContainer ?? sessionContainer
46
+ const description = t.config?.description
47
+ logger.info(`transport ${t.id}: creating session via DI token ${DITokens.FixSession} ` +
48
+ `[${description?.SenderCompId ?? '?'} -> ${description?.TargetCompID ?? '?'}]`)
49
+ const acceptorSession = scope.resolve<FixSession>(DITokens.FixSession)
34
50
  this.emit('session', acceptorSession, t)
51
+ // a wildcard acceptor has not claimed its SessionId yet - report again once
52
+ // the session ends, by which time the registry line is meaningful
53
+ this.census(logger)
35
54
  // Run the session but do NOT close the listener when it ends.
36
55
  // The acceptor keeps listening for new connections (reconnects).
37
56
  // This matches the C# TcpAcceptorListener accept-loop pattern.
38
57
  acceptorSession.run(t).then(() => {
39
- logger.info('session ended normally, acceptor continues listening')
58
+ logger.info(`transport ${t.id}: session ended normally, acceptor continues listening`)
59
+ this.census(logger)
40
60
  }).catch((e: Error) => {
41
- logger.warning(`session ended with error: ${e.message}, acceptor continues listening`)
61
+ logger.warning(`transport ${t.id}: session ended with error: ${e.message}, acceptor continues listening`)
62
+ this.census(logger)
42
63
  })
43
64
  })
44
65
 
66
+ acceptor.on('harvested', (tid: number, reason: string) => {
67
+ logger.info(`transport ${tid} harvested (${reason})`)
68
+ this.census(logger)
69
+ })
70
+
45
71
  this.resolveStart = resolve
46
72
  acceptor.listen()
47
73
  })
48
74
  }
49
75
 
76
+ /**
77
+ * What is actually connected, and which SessionIds are claimed. Emitted whenever
78
+ * the population changes so "how many clients do I have, and who are they" is
79
+ * answerable from the server log alone.
80
+ */
81
+ private census (logger: IJsFixLogger): void {
82
+ const acceptor = this.acceptor as TcpAcceptor | null
83
+ const transports = acceptor?.liveTransports() ?? []
84
+ logger.info(`acceptor census: transports=${transports.length} [${transports.join(', ')}] ` +
85
+ `sessions=${this.sessionRegistry.count} [${this.sessionRegistry.keys().join(', ')}]`)
86
+ }
87
+
50
88
  /**
51
89
  * Explicitly stop the acceptor listener. Call this when the application
52
90
  * is shutting down (e.g., timeout, SIGINT). Without this call, the