jspurefix 5.10.5 → 5.11.0

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 (34) hide show
  1. package/README.md +70 -0
  2. package/dist/runtime/session-launcher.d.ts +2 -0
  3. package/dist/runtime/session-launcher.js +13 -0
  4. package/dist/runtime/session-launcher.js.map +1 -1
  5. package/dist/transport/ascii/ascii-session-msg-factory.d.ts +1 -1
  6. package/dist/transport/ascii/ascii-session-msg-factory.js +2 -2
  7. package/dist/transport/ascii/ascii-session-msg-factory.js.map +1 -1
  8. package/dist/transport/ascii/ascii-session.d.ts +6 -0
  9. package/dist/transport/ascii/ascii-session.js +72 -8
  10. package/dist/transport/ascii/ascii-session.js.map +1 -1
  11. package/dist/transport/msg-application.d.ts +3 -0
  12. package/dist/transport/msg-application.js.map +1 -1
  13. package/dist/transport/session/fix-session.d.ts +1 -1
  14. package/dist/transport/session/fix-session.js.map +1 -1
  15. package/dist/transport/tcp/recovering-tcp-initiator.d.ts +6 -0
  16. package/dist/transport/tcp/recovering-tcp-initiator.js +55 -15
  17. package/dist/transport/tcp/recovering-tcp-initiator.js.map +1 -1
  18. package/dist/transport/tcp/tcp-initiator-connector.d.ts +2 -0
  19. package/dist/transport/tcp/tcp-initiator-connector.js +8 -3
  20. package/dist/transport/tcp/tcp-initiator-connector.js.map +1 -1
  21. package/dist/transport/tcp/tcp-initiator.d.ts +1 -0
  22. package/dist/transport/tcp/tcp-initiator.js +7 -40
  23. package/dist/transport/tcp/tcp-initiator.js.map +1 -1
  24. package/jsfix.test_client.txt +62 -62
  25. package/jsfix.test_server.txt +72 -72
  26. package/package.json +1 -1
  27. package/src/runtime/session-launcher.ts +25 -3
  28. package/src/transport/ascii/ascii-session-msg-factory.ts +8 -2
  29. package/src/transport/ascii/ascii-session.ts +115 -7
  30. package/src/transport/msg-application.ts +16 -0
  31. package/src/transport/session/fix-session.ts +12 -2
  32. package/src/transport/tcp/recovering-tcp-initiator.ts +74 -13
  33. package/src/transport/tcp/tcp-initiator-connector.ts +6 -1
  34. package/src/transport/tcp/tcp-initiator.ts +12 -6
@@ -4,8 +4,24 @@ import { IHttpTransportDescription } from './http/http-transport-description'
4
4
  export interface IMsgApplication {
5
5
  readonly name: string
6
6
  readonly type: string
7
+ /**
8
+ * An initiator which re-establishes its transport after the connection drops and
9
+ * resumes the same session, rather than ending when the socket does. Defaults to
10
+ * false, which gives a single connection. See "Reconnecting initiators" in the
11
+ * README for the retry policy below.
12
+ */
7
13
  readonly resilient: boolean
14
+ /** how long to wait between attempts whilst trying to establish a connection. Defaults to 5. */
8
15
  readonly reconnectSeconds: number
16
+ /**
17
+ * how long to keep attempting a connection before giving up. Defaults to 60 for a
18
+ * resilient initiator and 22 for a plain one.
19
+ */
20
+ readonly connectTimeoutSeconds?: number
21
+ /** resilient only: how long after losing the transport before trying to get it back. Defaults to 5. */
22
+ readonly recoveryAttemptSeconds?: number
23
+ /** resilient only: how long to wait after a failed recovery before trying again. Defaults to 30. */
24
+ readonly backoffFailConnectSeconds?: number
9
25
  readonly tcp?: ITcpTransportDescription
10
26
  readonly http?: IHttpTransportDescription
11
27
  readonly protocol: string
@@ -631,11 +631,21 @@ export abstract class FixSession extends events.EventEmitter {
631
631
  */
632
632
  protected abstract onStopped (error?: Error): void
633
633
  /**
634
- * Placeholder infomring the application of a peer login attempt.
634
+ * Inform the application of a peer login attempt and ask it to accept or reject.
635
+ *
636
+ * Return true to let the session continue. Return false to refuse the peer: a
637
+ * Logout carrying a Text reason is sent and the transport is dropped, so onReady
638
+ * is never reached and no heartbeat is started.
639
+ *
640
+ * The verdict may be returned as a promise, for applications that must check the
641
+ * credentials against an async source such as an http api or a database. While
642
+ * that promise is pending the transport is paused and any message the peer sends
643
+ * is queued, then replayed in order once the verdict arrives.
644
+ *
635
645
  * @param view the login message
636
646
  * @param user extracted user from message
637
647
  * @param password extracted password from the message.
638
648
  * @protected
639
649
  */
640
- protected abstract onLogon (view: MsgView, user: string, password: string): boolean
650
+ protected abstract onLogon (view: MsgView, user: string, password: string): boolean | Promise<boolean>
641
651
  }
@@ -25,8 +25,14 @@ export class RecoveringTcpInitiator extends FixEntity {
25
25
  private initiator: TcpInitiator
26
26
  private transport: MsgTransport
27
27
  private th: Timeout | null = null
28
- public recoveryAttemptSecs: number = 5
29
- public backoffFailConnectSecs: number = 30
28
+ private stopRequested: boolean = false
29
+ public static readonly DefaultRecoveryAttemptSecs = 5
30
+ public static readonly DefaultBackoffFailConnectSecs = 30
31
+ public static readonly DefaultConnectTimeoutSecs = 60
32
+
33
+ public recoveryAttemptSecs: number = RecoveringTcpInitiator.DefaultRecoveryAttemptSecs
34
+ public backoffFailConnectSecs: number = RecoveringTcpInitiator.DefaultBackoffFailConnectSecs
35
+ public connectTimeoutSecs: number = RecoveringTcpInitiator.DefaultConnectTimeoutSecs
30
36
 
31
37
  constructor (@inject(DITokens.IJsFixConfig) public readonly jsFixConfig: IJsFixConfig) {
32
38
  super(jsFixConfig)
@@ -40,6 +46,14 @@ export class RecoveringTcpInitiator extends FixEntity {
40
46
  if (!this.tcp) {
41
47
  throw new Error('no tcp in session description need tcp { host: hostname, port: port }')
42
48
  }
49
+ // the retry policy used to live only in these fields, which nothing set - an
50
+ // application could not change how hard or how long the engine tried to get its
51
+ // transport back without subclassing the launcher. See issue #72.
52
+ this.recoveryAttemptSecs = this.application.recoveryAttemptSeconds ?? this.recoveryAttemptSecs
53
+ this.backoffFailConnectSecs = this.application.backoffFailConnectSeconds ?? this.backoffFailConnectSecs
54
+ this.connectTimeoutSecs = this.application.connectTimeoutSeconds ?? this.connectTimeoutSecs
55
+ this.logger.info(`recovery policy: connectTimeout=${this.connectTimeoutSecs}s, ` +
56
+ `recoveryAttempt=${this.recoveryAttemptSecs}s, backoffFailConnect=${this.backoffFailConnectSecs}s`)
43
57
  this.createSession(jsFixConfig)
44
58
  }
45
59
 
@@ -54,6 +68,13 @@ export class RecoveringTcpInitiator extends FixEntity {
54
68
  this.logger.info('session has permanently ended')
55
69
  this.emit('end', this)
56
70
  })
71
+ // A lost transport is this initiator's normal course of events, and recovery is
72
+ // driven by session.run() rejecting rather than by this event. But whilst waiting
73
+ // between attempts there is no run in flight to carry a listener, so a stop landing
74
+ // then would emit 'error' to nobody - which node treats as fatal.
75
+ this.session.on('error', (e: Error) => {
76
+ this.logger.info(`session error: ${e.message}`)
77
+ })
57
78
  this.session.setState(SessionState.DisconnectedNoConnectionToday)
58
79
  }
59
80
 
@@ -104,7 +125,31 @@ export class RecoveringTcpInitiator extends FixEntity {
104
125
  // succeed in which case can restart session or fails in which case wait and
105
126
  // restart an attempt to connect
106
127
 
128
+ /**
129
+ * Give up recovering and end the session for good.
130
+ *
131
+ * Without this a resilient initiator could not be shut down: losing the transport
132
+ * schedules another attempt, and nothing cancelled that timer, so the process stayed
133
+ * alive retrying a counterparty the application had finished with. Safe to call
134
+ * twice, and safe whilst waiting between attempts with no transport at all.
135
+ */
136
+ public stop (): void {
137
+ if (this.stopRequested) return
138
+ this.stopRequested = true
139
+ this.logger.info('stop requested - cancelling recovery')
140
+ this.clearTimer()
141
+ if (this.session.getState() !== SessionState.Stopped) {
142
+ this.session.requestStop('initiator stopped')
143
+ }
144
+ this.emit('end', this)
145
+ }
146
+
107
147
  private recover (): void {
148
+ if (this.stopRequested) {
149
+ this.logger.info('transport lost after a stop was requested - not recovering')
150
+ this.emit('end', this)
151
+ return
152
+ }
108
153
  this.session.setState(SessionState.DetectBrokenNetworkConnection)
109
154
  this.logger.info(`recover session transport - attempt in ${this.recoveryAttemptSecs} secs`)
110
155
  if (!this.resetSeq()) {
@@ -115,7 +160,7 @@ export class RecoveringTcpInitiator extends FixEntity {
115
160
  }
116
161
  }
117
162
  this.th = setTimeout(() => {
118
- this.connect(60).then(t => {
163
+ this.connect(this.connectTimeoutSecs).then(t => {
119
164
  this.logger.info(`new transport ${t.id}`)
120
165
  }).catch((e) => {
121
166
  this.logger.info(`failed to re-connect ${e.message} - backoff for ${this.backoffFailConnectSecs}`)
@@ -135,18 +180,34 @@ export class RecoveringTcpInitiator extends FixEntity {
135
180
 
136
181
  // for first connection - reject if no initial connection established within timeout
137
182
  // once connection established, will not resolve until session is ended - i.e. lost
138
- // connections are re-established using the same session instance.
183
+ // connections are re-established using the same session instance. So this promise
184
+ // is the lifetime of the application, not a signal that the session is up: for that,
185
+ // use onReady on the session. See issue #72.
139
186
 
140
- public async run (initialTimeout: number = 60): Promise<any> {
187
+ public async run (initialTimeout: number = this.connectTimeoutSecs): Promise<any> {
141
188
  return await new Promise<any>((resolve, reject) => {
142
- this.connect(initialTimeout).then(() => {
143
- this.on('end', () => {
144
- this.clearTimer()
145
- this.initiator.end()
146
- this.logger.info(`run: transport ${this.transport.id} gracefully ends ${initialTimeout} - resolving`)
147
- resolve(null)
148
- })
149
- }).catch(e => {
189
+ // 'end' can arrive more than once - a stop both ends the session and calls off
190
+ // recovery, and each path announces itself - so settle only on the first. The
191
+ // listener goes on before connecting, not after: a stop landing whilst the first
192
+ // connection is still being attempted has to be able to end this too, and until
193
+ // now there was nothing listening that early to hear it.
194
+ let ended = false
195
+ const finish = (): void => {
196
+ if (ended) return
197
+ ended = true
198
+ this.clearTimer()
199
+ this.initiator?.end()
200
+ this.logger.info(`run: transport ${this.transport?.id ?? 'none'} ends - resolving`)
201
+ resolve(null)
202
+ }
203
+ this.on('end', finish)
204
+
205
+ this.connect(initialTimeout).catch(e => {
206
+ if (this.stopRequested) {
207
+ this.logger.info('run: first connection abandoned on stop - resolving')
208
+ finish()
209
+ return
210
+ }
150
211
  this.logger.info(`run: failed to connect to first transport ${initialTimeout} - rejecting`)
151
212
  reject(e)
152
213
  })
@@ -9,8 +9,13 @@ import { FixEntity } from '../fix-entity'
9
9
 
10
10
  @injectable()
11
11
  export class TcpInitiatorConnector extends FixEntity {
12
+ public static readonly DefaultConnectTimeoutSecs = 22
13
+
14
+ public connectTimeoutSecs: number = TcpInitiatorConnector.DefaultConnectTimeoutSecs
15
+
12
16
  constructor (@inject(DITokens.IJsFixConfig) public readonly config: IJsFixConfig) {
13
17
  super(config)
18
+ this.connectTimeoutSecs = config.description.application?.connectTimeoutSeconds ?? this.connectTimeoutSecs
14
19
  }
15
20
 
16
21
  async start (reconnectTimeout: number = 0): Promise<any> {
@@ -62,7 +67,7 @@ export class TcpInitiatorConnector extends FixEntity {
62
67
  const logger = this.config.logFactory.logger('initiator')
63
68
  const initiator: FixInitiator = this.config.sessionContainer.resolve<FixInitiator>(TcpInitiator)
64
69
  logger.info('connecting ...')
65
- const initiatorTransport: MsgTransport = await initiator.connect(22)
70
+ const initiatorTransport: MsgTransport = await initiator.connect(this.connectTimeoutSecs)
66
71
  logger.info('... connected, run session')
67
72
  await initiatorSession.run(initiatorTransport)
68
73
  logger.info('ends')
@@ -28,6 +28,7 @@ export class TcpInitiator extends FixInitiator {
28
28
  private readonly logger: IJsFixLogger
29
29
  private duplex: FixDuplex
30
30
  private th: Timeout | null = null
31
+ private timeoutTh: Timeout | null = null
31
32
 
32
33
  constructor (@inject(DITokens.IJsFixConfig) public readonly jsFixConfig: IJsFixConfig) {
33
34
  super(jsFixConfig.description.application ?? null)
@@ -189,13 +190,15 @@ export class TcpInitiator extends FixInitiator {
189
190
  clearInterval(this.th)
190
191
  this.th = null
191
192
  }
193
+ if (this.timeoutTh) {
194
+ clearTimeout(this.timeoutTh)
195
+ this.timeoutTh = null
196
+ }
192
197
  }
193
198
 
194
199
  private async repeatConnect (timeoutSeconds: number, initialError?: Error): Promise < MsgTransport > {
195
200
  return await new Promise<MsgTransport>(async (resolve, reject) => {
196
201
  const application = this.application
197
- const promisify = util.promisify
198
- const timeoutPromise = promisify(setTimeout)
199
202
  const reconnectSeconds = application?.reconnectSeconds ?? 5
200
203
  let retries = 0
201
204
  let lastError: Error | undefined = initialError
@@ -212,15 +215,18 @@ export class TcpInitiator extends FixInitiator {
212
215
  this.logger.info(`${name}: retries ${retries} ${e.message}`)
213
216
  })
214
217
  }, reconnectSeconds * 1000)
215
- timeoutPromise(timeoutSeconds * 1000).then(() => {
218
+ // a plain timer, not a promisified one: promisify(setTimeout) hands back no
219
+ // handle, so this deadline could never be cancelled. Connecting successfully
220
+ // cleared the retry interval and left this one running to its full term - up to
221
+ // the 60s an initiator allows - holding the event loop open long after the
222
+ // session was up, and stopping a process (or a test runner) from exiting.
223
+ this.timeoutTh = setTimeout(() => {
216
224
  this.clearTimer()
217
225
  this.state = InitiatorState.Stopped
218
226
  const e = lastError ?? new Error(`${name}: timeout of ${timeoutSeconds} whilst connecting`)
219
227
  this.logger.warning(`${name}: giving up after ${timeoutSeconds}s and ${retries} retries, last error: ${e.message}`)
220
228
  reject(e)
221
- }).catch(e => {
222
- reject(e)
223
- })
229
+ }, timeoutSeconds * 1000)
224
230
  })
225
231
  }
226
232
  }