jspurefix 5.8.4 → 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.
- package/.gitattributes +4 -0
- package/BACKPORT_PLAN.md +71 -0
- package/DEMO_PORT_PLAN.md +10 -0
- package/README.md +3 -0
- package/dist/config/js-fix-config.d.ts +4 -0
- package/dist/config/js-fix-config.js.map +1 -1
- package/dist/runtime/session-launcher.js +1 -1
- package/dist/runtime/session-launcher.js.map +1 -1
- package/dist/runtime/session-scope.d.ts +3 -0
- package/dist/runtime/session-scope.js +47 -0
- package/dist/runtime/session-scope.js.map +1 -0
- package/dist/sample/http/oms/app.js +3 -3
- package/dist/sample/http/oms/app.js.map +1 -1
- package/dist/sample/tcp/qf-md/app.js +3 -3
- package/dist/sample/tcp/qf-md/app.js.map +1 -1
- package/dist/sample/tcp/skeleton/app.js +1 -1
- package/dist/sample/tcp/skeleton/app.js.map +1 -1
- package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
- package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
- package/dist/transport/ascii/ascii-session.d.ts +17 -3
- package/dist/transport/ascii/ascii-session.js +121 -8
- package/dist/transport/ascii/ascii-session.js.map +1 -1
- package/dist/transport/duplex/fix-duplex.d.ts +1 -0
- package/dist/transport/duplex/fix-duplex.js +3 -0
- package/dist/transport/duplex/fix-duplex.js.map +1 -1
- package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
- package/dist/transport/duplex/tcp-duplex.js +5 -0
- package/dist/transport/duplex/tcp-duplex.js.map +1 -1
- package/dist/transport/factory/msg-transport.d.ts +2 -0
- package/dist/transport/factory/msg-transport.js +17 -2
- package/dist/transport/factory/msg-transport.js.map +1 -1
- package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
- package/dist/transport/session/a-session-msg-factory.js +4 -0
- package/dist/transport/session/a-session-msg-factory.js.map +1 -1
- package/dist/transport/session/clone-session-description.d.ts +6 -0
- package/dist/transport/session/clone-session-description.js +11 -0
- package/dist/transport/session/clone-session-description.js.map +1 -0
- package/dist/transport/session/fix-session.d.ts +5 -1
- package/dist/transport/session/fix-session.js +105 -28
- package/dist/transport/session/fix-session.js.map +1 -1
- package/dist/transport/session/index.d.ts +2 -0
- package/dist/transport/session/index.js +2 -0
- package/dist/transport/session/index.js.map +1 -1
- package/dist/transport/session/session-msg-factory.d.ts +1 -0
- package/dist/transport/session/session-msg-factory.js.map +1 -1
- package/dist/transport/session/session-registry.d.ts +23 -0
- package/dist/transport/session/session-registry.js +59 -0
- package/dist/transport/session/session-registry.js.map +1 -0
- package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
- package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
- package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
- package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
- package/dist/transport/tcp/tcp-acceptor.js +74 -20
- package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
- package/dist/transport/tcp/tcp-initiator.js +15 -5
- package/dist/transport/tcp/tcp-initiator.js.map +1 -1
- package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
- package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
- package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
- package/dist/transport/tcp/tls-diagnostics.js +64 -0
- package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
- package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
- package/dist/transport/tcp/tls-options-factory.js +24 -2
- package/dist/transport/tcp/tls-options-factory.js.map +1 -1
- package/docs/acceptor.md +207 -0
- package/jsfix.test_client.txt +62 -75
- package/jsfix.test_server.txt +72 -72
- package/package.json +11 -10
- package/script/pack-local.js +68 -0
- package/src/config/js-fix-config.ts +8 -0
- package/src/runtime/session-launcher.ts +5 -1
- package/src/runtime/session-scope.ts +84 -0
- package/src/sample/http/oms/app.ts +3 -3
- package/src/sample/tcp/qf-md/app.ts +3 -3
- package/src/sample/tcp/skeleton/app.ts +1 -1
- package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
- package/src/transport/ascii/ascii-session.ts +186 -11
- package/src/transport/duplex/fix-duplex.ts +9 -0
- package/src/transport/duplex/tcp-duplex.ts +10 -0
- package/src/transport/factory/msg-transport.ts +20 -0
- package/src/transport/session/a-session-msg-factory.ts +18 -0
- package/src/transport/session/clone-session-description.ts +41 -0
- package/src/transport/session/fix-session.ts +153 -30
- package/src/transport/session/index.ts +2 -0
- package/src/transport/session/session-msg-factory.ts +13 -0
- package/src/transport/session/session-registry.ts +112 -0
- package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
- package/src/transport/tcp/tcp-acceptor.ts +92 -18
- package/src/transport/tcp/tcp-initiator.ts +26 -5
- package/src/transport/tcp/tcp-transport-description.ts +7 -0
- package/src/transport/tcp/tls-diagnostics.ts +76 -0
- 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
|
|
27
|
-
protected
|
|
28
|
-
protected
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
this.
|
|
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
|
-
|
|
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
|
|
@@ -110,14 +137,28 @@ export abstract class FixSession extends events.EventEmitter {
|
|
|
110
137
|
this.setState(SessionState.WaitingForALogon)
|
|
111
138
|
}
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
// Use named handlers and detach BOTH when the promise settles. Only one of
|
|
141
|
+
// 'error'/'done' fires per run; without removing the sibling the unfired
|
|
142
|
+
// listener would leak on every reconnect (issue #146). `once` alone is not
|
|
143
|
+
// enough for the same reason — the sibling never fires, so never self-detaches.
|
|
144
|
+
const cleanup = (): void => {
|
|
145
|
+
this.removeListener('error', onError)
|
|
146
|
+
this.removeListener('done', onDone)
|
|
147
|
+
}
|
|
148
|
+
const onError = (e: Error): void => {
|
|
149
|
+
cleanup()
|
|
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)
|
|
115
153
|
reject(e)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
}
|
|
155
|
+
const onDone = (): void => {
|
|
156
|
+
cleanup()
|
|
119
157
|
resolve(this.transport?.id)
|
|
120
|
-
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.on('error', onError)
|
|
161
|
+
this.on('done', onDone)
|
|
121
162
|
})
|
|
122
163
|
}
|
|
123
164
|
|
|
@@ -205,35 +246,90 @@ export abstract class FixSession extends events.EventEmitter {
|
|
|
205
246
|
this.onEncoded(msgType, data)
|
|
206
247
|
}
|
|
207
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
|
+
|
|
208
264
|
protected unsubscribe (): void {
|
|
209
265
|
const logger = this.sessionLogger
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
rx
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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`)
|
|
222
285
|
}
|
|
223
286
|
|
|
224
287
|
protected subscribe (): void {
|
|
225
288
|
const transport = this.transport
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
297
|
const inst = this
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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}`)
|
|
237
333
|
}
|
|
238
334
|
|
|
239
335
|
protected validStateApplicationMsg (): boolean {
|
|
@@ -273,12 +369,16 @@ export abstract class FixSession extends events.EventEmitter {
|
|
|
273
369
|
if (this.sessionState.state === SessionState.Stopped) return
|
|
274
370
|
this.sessionLogger.error(error)
|
|
275
371
|
this.stopTimer()
|
|
372
|
+
this.unsubscribe()
|
|
276
373
|
if (this.transport) {
|
|
374
|
+
this.sessionLogger.info(`terminate: kill transport ${this.transport.id}`)
|
|
277
375
|
this.transport.end()
|
|
278
376
|
}
|
|
279
377
|
this.transport = null
|
|
280
378
|
this.setState(SessionState.Stopped)
|
|
379
|
+
this.onSessionStopping()
|
|
281
380
|
this.emit('error', error)
|
|
381
|
+
this.onStopped(error)
|
|
282
382
|
}
|
|
283
383
|
|
|
284
384
|
protected peerLogout (view: MsgView): void {
|
|
@@ -403,6 +503,16 @@ export abstract class FixSession extends events.EventEmitter {
|
|
|
403
503
|
// Override in subclass to reset coordinator/transient state
|
|
404
504
|
}
|
|
405
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
|
+
|
|
406
516
|
/**
|
|
407
517
|
* Called for initiators after the store is loaded but before the Logon is sent.
|
|
408
518
|
* Override to reset sequences/store when ResetSeqNumFlag=Y so the Logon goes out
|
|
@@ -412,13 +522,26 @@ export abstract class FixSession extends events.EventEmitter {
|
|
|
412
522
|
// Default no-op
|
|
413
523
|
}
|
|
414
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
|
+
|
|
415
537
|
protected stop (error: Error | null = null): void {
|
|
416
538
|
if (this.sessionState.state === SessionState.Stopped) {
|
|
417
539
|
return
|
|
418
540
|
}
|
|
419
541
|
this.stopTimer()
|
|
420
542
|
this.unsubscribe()
|
|
421
|
-
this.sessionLogger.info(
|
|
543
|
+
this.sessionLogger.info(`stop: kill transport ${this.transport?.id ?? 'none'}`)
|
|
544
|
+
this.onSessionStopping()
|
|
422
545
|
this.transport?.end()
|
|
423
546
|
if (error) {
|
|
424
547
|
this.sessionLogger.info(`stop: emit error ${error.message}`)
|
|
@@ -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
|
}
|