jspurefix 5.8.5 → 5.9.1

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 (92) hide show
  1. package/.gitattributes +4 -0
  2. package/BACKPORT_PLAN.md +71 -0
  3. package/DEMO_PORT_PLAN.md +10 -0
  4. package/README.md +3 -0
  5. package/dist/config/js-fix-config.d.ts +4 -0
  6. package/dist/config/js-fix-config.js.map +1 -1
  7. package/dist/runtime/session-launcher.js +1 -1
  8. package/dist/runtime/session-launcher.js.map +1 -1
  9. package/dist/runtime/session-scope.d.ts +3 -0
  10. package/dist/runtime/session-scope.js +47 -0
  11. package/dist/runtime/session-scope.js.map +1 -0
  12. package/dist/sample/http/oms/app.js +3 -3
  13. package/dist/sample/http/oms/app.js.map +1 -1
  14. package/dist/sample/tcp/qf-md/app.js +3 -3
  15. package/dist/sample/tcp/qf-md/app.js.map +1 -1
  16. package/dist/sample/tcp/skeleton/app.js +1 -1
  17. package/dist/sample/tcp/skeleton/app.js.map +1 -1
  18. package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
  19. package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
  20. package/dist/transport/ascii/ascii-session.d.ts +17 -3
  21. package/dist/transport/ascii/ascii-session.js +121 -8
  22. package/dist/transport/ascii/ascii-session.js.map +1 -1
  23. package/dist/transport/duplex/fix-duplex.d.ts +1 -0
  24. package/dist/transport/duplex/fix-duplex.js +3 -0
  25. package/dist/transport/duplex/fix-duplex.js.map +1 -1
  26. package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
  27. package/dist/transport/duplex/tcp-duplex.js +5 -0
  28. package/dist/transport/duplex/tcp-duplex.js.map +1 -1
  29. package/dist/transport/factory/msg-transport.d.ts +2 -0
  30. package/dist/transport/factory/msg-transport.js +17 -2
  31. package/dist/transport/factory/msg-transport.js.map +1 -1
  32. package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
  33. package/dist/transport/session/a-session-msg-factory.js +4 -0
  34. package/dist/transport/session/a-session-msg-factory.js.map +1 -1
  35. package/dist/transport/session/clone-session-description.d.ts +6 -0
  36. package/dist/transport/session/clone-session-description.js +11 -0
  37. package/dist/transport/session/clone-session-description.js.map +1 -0
  38. package/dist/transport/session/fix-session.d.ts +5 -1
  39. package/dist/transport/session/fix-session.js +93 -24
  40. package/dist/transport/session/fix-session.js.map +1 -1
  41. package/dist/transport/session/index.d.ts +2 -0
  42. package/dist/transport/session/index.js +2 -0
  43. package/dist/transport/session/index.js.map +1 -1
  44. package/dist/transport/session/session-msg-factory.d.ts +1 -0
  45. package/dist/transport/session/session-msg-factory.js.map +1 -1
  46. package/dist/transport/session/session-registry.d.ts +23 -0
  47. package/dist/transport/session/session-registry.js +59 -0
  48. package/dist/transport/session/session-registry.js.map +1 -0
  49. package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
  50. package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
  51. package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
  52. package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
  53. package/dist/transport/tcp/tcp-acceptor.js +74 -20
  54. package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
  55. package/dist/transport/tcp/tcp-initiator.js +15 -5
  56. package/dist/transport/tcp/tcp-initiator.js.map +1 -1
  57. package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
  58. package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
  59. package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
  60. package/dist/transport/tcp/tls-diagnostics.js +64 -0
  61. package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
  62. package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
  63. package/dist/transport/tcp/tls-options-factory.js +24 -2
  64. package/dist/transport/tcp/tls-options-factory.js.map +1 -1
  65. package/docs/acceptor.md +207 -0
  66. package/jsfix.test_client.txt +62 -75
  67. package/jsfix.test_server.txt +72 -72
  68. package/package.json +11 -10
  69. package/script/pack-local.js +68 -0
  70. package/src/config/js-fix-config.ts +8 -0
  71. package/src/runtime/session-launcher.ts +5 -1
  72. package/src/runtime/session-scope.ts +84 -0
  73. package/src/sample/http/oms/app.ts +3 -3
  74. package/src/sample/tcp/qf-md/app.ts +3 -3
  75. package/src/sample/tcp/skeleton/app.ts +1 -1
  76. package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
  77. package/src/transport/ascii/ascii-session.ts +186 -11
  78. package/src/transport/duplex/fix-duplex.ts +9 -0
  79. package/src/transport/duplex/tcp-duplex.ts +10 -0
  80. package/src/transport/factory/msg-transport.ts +20 -0
  81. package/src/transport/session/a-session-msg-factory.ts +18 -0
  82. package/src/transport/session/clone-session-description.ts +41 -0
  83. package/src/transport/session/fix-session.ts +136 -25
  84. package/src/transport/session/index.ts +2 -0
  85. package/src/transport/session/session-msg-factory.ts +13 -0
  86. package/src/transport/session/session-registry.ts +112 -0
  87. package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
  88. package/src/transport/tcp/tcp-acceptor.ts +92 -18
  89. package/src/transport/tcp/tcp-initiator.ts +26 -5
  90. package/src/transport/tcp/tcp-transport-description.ts +7 -0
  91. package/src/transport/tcp/tls-diagnostics.ts +76 -0
  92. package/src/transport/tcp/tls-options-factory.ts +34 -2
@@ -6,8 +6,9 @@ import {
6
6
  FixMsgAsciiStoreResend, FixMsgMemoryStore, FixMsgStoreRecord,
7
7
  IFixMsgStore, IFixMsgStoreRecord,
8
8
  IFixSessionStore, IFixSessionStoreFactory,
9
- MemorySessionStoreFactory, FileSessionStoreFactory, SessionId
9
+ MemorySessionStore, MemorySessionStoreFactory, FileSessionStoreFactory, SessionId
10
10
  } from '../../store'
11
+ import { asMutable } from '../session/clone-session-description'
11
12
  import { SessionState } from '../tcp'
12
13
  import { TickAction } from '../tick-action'
13
14
  import { IMsgApplication } from '../msg-application'
@@ -20,12 +21,24 @@ import { MsgTransport } from '../factory'
20
21
  import { ILooseObject } from '../../collections/collection'
21
22
 
22
23
  export abstract class AsciiSession extends FixSession {
24
+ /**
25
+ * TargetCompID value which tells an acceptor to take its target from whichever
26
+ * counterparty logs on, rather than from static configuration.
27
+ */
28
+ public static readonly WildcardCompId = '*'
23
29
  public heartbeat: boolean = true
24
30
  protected store: IFixMsgStore | null = null
25
31
  protected resender: FixMsgAsciiStoreResend
26
- protected readonly coordinator: SessionSequenceCoordinator
27
- protected readonly sessionStore: IFixSessionStore
28
- protected readonly sessionId: SessionId
32
+ protected coordinator: SessionSequenceCoordinator
33
+ protected sessionStore: IFixSessionStore
34
+ protected sessionId: SessionId
35
+ private registered: boolean = false
36
+ /** acceptor configured with TargetCompID '*' - identity comes from the peer Logon */
37
+ protected readonly wildcardTarget: boolean
38
+ private identityBound: boolean
39
+ private bindInProgress: boolean = false
40
+ private readonly pendingRx: Array<{ msgType: string, view: MsgView }> = []
41
+ private readonly clock: DefaultFixClock = new DefaultFixClock()
29
42
 
30
43
  protected constructor (public readonly config: IJsFixConfig) {
31
44
  super(config)
@@ -34,22 +47,38 @@ export abstract class AsciiSession extends FixSession {
34
47
  this.store = new FixMsgMemoryStore(this.config.description.SenderCompId, this.config)
35
48
  this.resender = new FixMsgAsciiStoreResend(this.store, this.config)
36
49
 
37
- // Create session store from factory.
38
- // Priority: programmatic config > JSON store config > default in-memory
39
- const storeFactory = config.sessionStoreFactory ?? AsciiSession.createStoreFactory(config.description.store)
50
+ // A wildcard TargetCompID lets one acceptor serve counterparties it was not
51
+ // statically configured for - the peer's SenderCompID becomes our target, and
52
+ // therefore our SessionId and store, once the Logon arrives. An initiator has
53
+ // to know who it is calling, so '*' is meaningless there.
54
+ this.wildcardTarget = config.description.TargetCompID === AsciiSession.WildcardCompId
55
+ if (this.wildcardTarget && this.initiator) {
56
+ throw new Error(`TargetCompID '${AsciiSession.WildcardCompId}' is only valid for acceptors, not initiators`)
57
+ }
58
+ this.identityBound = !this.wildcardTarget
59
+
40
60
  this.sessionId = new SessionId(
41
61
  config.description.BeginString,
42
62
  config.description.SenderCompId,
43
63
  config.description.TargetCompID
44
64
  )
45
- this.sessionStore = storeFactory.create(this.sessionId)
46
-
47
- const clock = new DefaultFixClock()
48
- this.coordinator = new SessionSequenceCoordinator(this.sessionStore, clock)
65
+ // Until the peer identifies itself there is nothing worth persisting, so a
66
+ // wildcard session starts on a throwaway in-memory store. The real store -
67
+ // keyed on the peer's comp id - is created by bindIdentity.
68
+ this.sessionStore = this.wildcardTarget
69
+ ? new MemorySessionStore(this.sessionId)
70
+ : this.storeFactory().create(this.sessionId)
71
+
72
+ this.coordinator = new SessionSequenceCoordinator(this.sessionStore, this.clock)
49
73
  const lastReceivedSeqNum = config.description.LastReceivedSeqNum ?? 0
50
74
  this.coordinator.initializeFromConfig(undefined, lastReceivedSeqNum + 1)
51
75
  }
52
76
 
77
+ private storeFactory (): IFixSessionStoreFactory {
78
+ // Priority: programmatic config > JSON store config > default in-memory
79
+ return this.config.sessionStoreFactory ?? AsciiSession.createStoreFactory(this.config.description.store)
80
+ }
81
+
53
82
  /**
54
83
  * Initialize the session store (reads persisted sequences from file if using FileSessionStore),
55
84
  * sync the coordinator and transmitter from the store, then start the session.
@@ -65,6 +94,15 @@ export abstract class AsciiSession extends FixSession {
65
94
  // the client's Logon can arrive and be parsed into the void.
66
95
  transport.duplex.readable?.pause()
67
96
 
97
+ if (this.wildcardTarget && !this.identityBound) {
98
+ // Nothing to recover yet - the SessionId is not known until the peer logs on,
99
+ // so store initialisation, sequence sync and registry claim all happen in
100
+ // bindIdentity. Resume and wait for the Logon.
101
+ this.sessionLogger.info('wildcard acceptor: awaiting peer Logon to establish session identity')
102
+ transport.duplex.readable?.resume()
103
+ return await super.run(transport)
104
+ }
105
+
68
106
  // Initialize store — reads persisted .seqnums file if using FileSessionStore.
69
107
  // For MemorySessionStore this is a no-op.
70
108
  await this.sessionStore.initialize()
@@ -81,6 +119,11 @@ export abstract class AsciiSession extends FixSession {
81
119
 
82
120
  this.sessionLogger.info(`store initialized: nextSender=${this.coordinator.nextSenderSeqNum}, expectedTarget=${this.coordinator.expectedTargetSeqNum}`)
83
121
 
122
+ // Claim this SessionId. A counterparty which reconnected before its previous
123
+ // socket was detected as dead would otherwise leave two live sessions writing
124
+ // to one store - see issue #153.
125
+ this.claimSessionId()
126
+
84
127
  // Resume the stream — super.run() will call subscribe() to hook up the message handler,
85
128
  // then the buffered data (including the client's Logon) will be delivered.
86
129
  transport.duplex.readable?.resume()
@@ -330,6 +373,30 @@ export abstract class AsciiSession extends FixSession {
330
373
  this.sessionLogger.info('coordinator reset transient state for reconnect')
331
374
  }
332
375
 
376
+ /**
377
+ * Take ownership of this SessionId in the registry, stopping whatever session
378
+ * held it before. Idempotent - a session reused across reconnects (the
379
+ * RecoveringTcpInitiator pattern) simply re-registers itself.
380
+ */
381
+ private claimSessionId (): void {
382
+ const registry = this.config.sessionRegistry
383
+ if (!registry) return
384
+ const stoppedOld = registry.register(this.sessionId, this)
385
+ if (stoppedOld) {
386
+ this.sessionLogger.info(`session registry stopped previous session for ${this.sessionId.toString()}`)
387
+ }
388
+ this.registered = true
389
+ }
390
+
391
+ protected override onSessionStopping (): void {
392
+ super.onSessionStopping()
393
+ const registry = this.config.sessionRegistry
394
+ if (!registry || !this.registered) return
395
+ this.registered = false
396
+ this.sessionLogger.info(`session stopping - unregistering from registry: ${this.sessionId.toString()}`)
397
+ registry.unregister(this.sessionId, this)
398
+ }
399
+
333
400
  protected override async onPreLogon (): Promise<void> {
334
401
  if (!this.config.description.ResetSeqNumFlag) return
335
402
 
@@ -477,6 +544,114 @@ export abstract class AsciiSession extends FixSession {
477
544
  }
478
545
 
479
546
  protected onMsg (msgType: string, view: MsgView): void {
547
+ if (this.wildcardTarget && !this.identityBound) {
548
+ this.onMsgBeforeIdentity(msgType, view)
549
+ return
550
+ }
551
+ this.dispatchMsg(msgType, view)
552
+ }
553
+
554
+ /**
555
+ * Message handling for a wildcard acceptor which has not yet learned who it is
556
+ * talking to. The first message must be a Logon; it carries the peer's
557
+ * SenderCompID, which becomes our TargetCompID and hence our SessionId and store.
558
+ *
559
+ * Binding is asynchronous (the store may have to be read from disk) while message
560
+ * delivery is not: the parser emits every message it finds in a TCP segment in one
561
+ * synchronous burst, and a view is only valid for the duration of its own event -
562
+ * AsciiParser recycles the tag index and buffer immediately afterwards. So each
563
+ * message that arrives mid-bind is cloned and queued, then replayed in order.
564
+ */
565
+ private onMsgBeforeIdentity (msgType: string, view: MsgView): void {
566
+ if (this.bindInProgress) {
567
+ this.sessionLogger.info(`queueing '${msgType}' received while binding session identity`)
568
+ this.pendingRx.push({ msgType, view: view.clone() })
569
+ return
570
+ }
571
+
572
+ if (msgType !== MsgType.Logon) {
573
+ this.terminate(new Error(`wildcard acceptor expects Logon as the first message, received '${msgType}'`))
574
+ return
575
+ }
576
+
577
+ const peerCompId = view.getString(MsgTag.SenderCompID)
578
+ if (!peerCompId) {
579
+ this.terminate(new Error('Logon has no SenderCompID - cannot establish session identity'))
580
+ return
581
+ }
582
+
583
+ this.bindInProgress = true
584
+ const logon = view.clone()
585
+ this.transport?.duplex.readable?.pause()
586
+
587
+ this.bindIdentity(peerCompId).then(() => {
588
+ this.bindInProgress = false
589
+ this.identityBound = true
590
+ this.dispatchMsg(MsgType.Logon, logon)
591
+ this.drainPendingRx()
592
+ this.transport?.duplex.readable?.resume()
593
+ }).catch((e: Error) => {
594
+ this.bindInProgress = false
595
+ this.pendingRx.length = 0
596
+ this.terminate(new Error(`failed to bind session identity for peer '${peerCompId}': ${e.message}`))
597
+ })
598
+ }
599
+
600
+ private drainPendingRx (): void {
601
+ while (this.pendingRx.length > 0) {
602
+ const queued = this.pendingRx.shift()
603
+ if (!queued) break
604
+ if (this.sessionState.state === SessionState.Stopped) {
605
+ this.sessionLogger.info(`dropping queued '${queued.msgType}' - session stopped during bind`)
606
+ continue
607
+ }
608
+ this.dispatchMsg(queued.msgType, queued.view)
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Adopt the peer's comp id as our TargetCompID, then build the identity derived
614
+ * state: SessionId, message store, sequence coordinator, registry claim. This is
615
+ * the wildcard equivalent of the work run() does up front for a statically
616
+ * configured session.
617
+ */
618
+ private async bindIdentity (peerCompId: string): Promise<void> {
619
+ const logger = this.sessionLogger
620
+ logger.info(`wildcard acceptor: binding session identity to peer SenderCompID '${peerCompId}'`)
621
+
622
+ // the description is this session's own clone (see makeSessionScope), and the
623
+ // session message factory holds the same object, so outbound headers pick the
624
+ // new target up immediately
625
+ asMutable(this.config.description).TargetCompID = peerCompId
626
+
627
+ // every session on a wildcard listener shared one logger name until now, which
628
+ // makes a multi-client server log impossible to follow
629
+ this.sessionLogger = this.config.logFactory.logger(`${this.me}:${peerCompId}:FixSession`)
630
+
631
+ this.sessionId = new SessionId(
632
+ this.config.description.BeginString,
633
+ this.config.description.SenderCompId,
634
+ peerCompId
635
+ )
636
+ this.sessionStore = this.storeFactory().create(this.sessionId)
637
+ await this.sessionStore.initialize()
638
+
639
+ this.coordinator = new SessionSequenceCoordinator(this.sessionStore, this.clock)
640
+ this.coordinator.initializeFromStore()
641
+
642
+ const transmitter = this.transport?.transmitter as AsciiMsgTransmitter | undefined
643
+ if (transmitter) {
644
+ transmitter.msgSeqNum = this.coordinator.nextSenderSeqNum
645
+ }
646
+ this.sessionState.lastPeerMsgSeqNum = this.coordinator.lastProcessedPeerSeqNum
647
+
648
+ logger.info(`session identity bound: ${this.sessionId.toString()} ` +
649
+ `nextSender=${this.coordinator.nextSenderSeqNum}, expectedTarget=${this.coordinator.expectedTargetSeqNum}`)
650
+
651
+ this.claimSessionId()
652
+ }
653
+
654
+ private dispatchMsg (msgType: string, view: MsgView): void {
480
655
  if (!this.checkSeqNo(msgType, view)) {
481
656
  this.sessionLogger.info(`message '${msgType}' failed checkSeqNo.`)
482
657
  return
@@ -5,5 +5,14 @@ export type MakeDuplex = () => FixDuplex
5
5
  export abstract class FixDuplex {
6
6
  public readable: Readable
7
7
  public writable: Writable
8
+ /** graceful shutdown - for a socket this is a FIN, which the peer may never answer */
8
9
  public abstract end (): void
10
+ /**
11
+ * Release the underlying resource unconditionally. A half open socket - one whose
12
+ * peer has vanished without FIN or RST - will not complete a graceful end, so the
13
+ * acceptor needs a way to stop holding it (issue #153). Defaults to end().
14
+ */
15
+ public destroy (): void {
16
+ this.end()
17
+ }
9
18
  }
@@ -14,4 +14,14 @@ export class TcpDuplex extends FixDuplex {
14
14
  end (): void {
15
15
  this.socket.end()
16
16
  }
17
+
18
+ /**
19
+ * A socket whose peer has gone without FIN or RST will never complete the
20
+ * graceful end() above - the FIN is sent and no answer ever comes, so the handle
21
+ * stays open indefinitely. destroy() drops it regardless.
22
+ */
23
+ destroy (): void {
24
+ if (this.socket.destroyed) return
25
+ this.socket.destroy()
26
+ }
17
27
  }
@@ -34,10 +34,30 @@ export class MsgTransport {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Milliseconds to wait after a graceful end before dropping the connection. A
39
+ * half open socket never answers the FIN, so without this the handle - and the
40
+ * memory behind its session - is held for the life of the process (issue #153).
41
+ */
42
+ public static lingerMs: number = 5000
43
+
37
44
  public end (): void {
45
+ if (this.ended) return
46
+ this.ended = true
38
47
  this.duplex.end()
48
+ if (MsgTransport.lingerMs <= 0) {
49
+ this.duplex.destroy()
50
+ return
51
+ }
52
+ const linger = setTimeout(() => {
53
+ this.duplex.destroy()
54
+ }, MsgTransport.lingerMs)
55
+ // never keep the process alive purely to time out a dead socket
56
+ linger.unref?.()
39
57
  }
40
58
 
59
+ private ended: boolean = false
60
+
41
61
  public async wait (): Promise<any> {
42
62
  return await new Promise<any>((resolve, reject) => {
43
63
  this.receiver.on('end', () => {
@@ -20,6 +20,24 @@ export abstract class ASessionMsgFactory implements ISessionMsgFactory {
20
20
  protected constructor (public readonly description: ISessionDescription, public mutator: ObjectMutator | null = null) {
21
21
  }
22
22
 
23
+ /**
24
+ * Rebuild this factory against another session description, preserving the
25
+ * concrete type. An acceptor needs one factory per accepted connection so that
26
+ * outbound headers carry that session's comp ids; without preserving the type, an
27
+ * application's own factory would be silently replaced by the stock one.
28
+ *
29
+ * Reconstructing through `this.constructor` covers any subclass whose constructor
30
+ * takes (description, mutator) - the shape every factory in this library uses.
31
+ * Override this if yours takes anything else.
32
+ */
33
+ public cloneFor (description: ISessionDescription): ISessionMsgFactory {
34
+ const Ctor = this.constructor as new (
35
+ description: ISessionDescription,
36
+ mutator: ObjectMutator | null
37
+ ) => ISessionMsgFactory
38
+ return new Ctor(description, this.mutator)
39
+ }
40
+
23
41
  public reject (msgType: string, seqNo: number, msg: string, reason: number): ILooseObject {
24
42
  const o: IReject = {
25
43
  RefMsgType: msgType,
@@ -0,0 +1,41 @@
1
+ import { IDynamicSessionParams, ISessionDescription } from './session-description'
2
+
3
+ /**
4
+ * A session description with the readonly modifiers stripped. The description is
5
+ * immutable by design - it is loaded once from JSON and shared by everything in the
6
+ * session - but an acceptor running in wildcard mode has to write the peer's
7
+ * SenderCompID into its own copy once the Logon arrives. Cast through this type so
8
+ * the (deliberately rare) mutation is explicit at every call site.
9
+ */
10
+ export type MutableSessionDescription = {
11
+ -readonly [K in keyof ISessionDescription]: ISessionDescription[K]
12
+ }
13
+
14
+ /**
15
+ * Shallow clone a session description, optionally overriding identity fields.
16
+ *
17
+ * Each connection accepted by a FIX acceptor needs its own description: the
18
+ * SessionId (and therefore the message store), the outbound header comp ids and the
19
+ * session registry key are all derived from it. Sharing one description across
20
+ * concurrent sessions makes every client collide on the same store.
21
+ *
22
+ * `application` and `store` are shared by reference - they are read only
23
+ * configuration (host, port, dictionary, store directory) common to every session
24
+ * on the listener.
25
+ */
26
+ export function cloneSessionDescription (
27
+ description: ISessionDescription,
28
+ overrides?: Partial<IDynamicSessionParams>): ISessionDescription {
29
+ return {
30
+ ...description,
31
+ ...(overrides ?? {})
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Make a description writable. Use only where the FIX protocol itself forces a
37
+ * late binding - i.e. wildcard TargetCompID resolved from the peer's Logon.
38
+ */
39
+ export function asMutable (description: ISessionDescription): MutableSessionDescription {
40
+ return description as MutableSessionDescription
41
+ }
@@ -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'
@@ -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
  }