jspurefix 5.10.6 → 5.11.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.
@@ -57,6 +57,7 @@ export abstract class SessionLauncher {
57
57
  }
58
58
 
59
59
  private acceptorEntity: FixEntity | null = null
60
+ private initiatorEntity: FixEntity | null = null
60
61
  private stopRequested: boolean = false
61
62
 
62
63
  protected async getAcceptor (sessionContainer: DependencyContainer): Promise<any> {
@@ -78,6 +79,11 @@ export abstract class SessionLauncher {
78
79
  protected async getInitiator (sessionContainer: DependencyContainer): Promise<any> {
79
80
  if (sessionContainer.isRegistered<FixEntity>(DITokens.FixEntity)) {
80
81
  const entity = sessionContainer.resolve<FixEntity>(DITokens.FixEntity)
82
+ this.initiatorEntity = entity
83
+ if (this.stopRequested) {
84
+ this.logger.info('stop requested before the initiator started - not connecting')
85
+ return this.empty()
86
+ }
81
87
  return entity.start()
82
88
  } else {
83
89
  return this.empty()
@@ -95,7 +101,8 @@ export abstract class SessionLauncher {
95
101
  }
96
102
 
97
103
  /**
98
- * Stop what this launcher started - in practice, close the acceptor's listener.
104
+ * Stop what this launcher started - close the acceptor's listener, and give up a
105
+ * resilient initiator's attempts to get its transport back.
99
106
  *
100
107
  * An acceptor is meant to outlive any one counterparty, so nothing closes its
101
108
  * listener on its own. That is right for a venue and wrong for an application
@@ -107,12 +114,18 @@ export abstract class SessionLauncher {
107
114
  * usual arrangement as soon as more than one client is involved. There was then
108
115
  * no way to shut a listener down short of killing the process.
109
116
  *
110
- * Safe before start (the request is remembered and the listener never opens), and
111
- * safe to call twice. run() resolves once the listener has closed.
117
+ * A resilient initiator had the same problem from the other side: losing the
118
+ * transport schedules another attempt, so it too kept the process alive with no way
119
+ * to call it off. See issue #72.
120
+ *
121
+ * Safe before start (the request is remembered and neither is opened), and safe to
122
+ * call twice. run() resolves once the listener has closed and the initiator has
123
+ * given up.
112
124
  */
113
125
  public stop (): void {
114
126
  this.stopRequested = true
115
127
  this.stopAcceptor()
128
+ this.stopInitiator()
116
129
  }
117
130
 
118
131
  public async run (): Promise<boolean> {
@@ -229,6 +242,15 @@ export abstract class SessionLauncher {
229
242
  }
230
243
  }
231
244
 
245
+ // only a resilient initiator has anything to call off - a plain one ends with its
246
+ // session, so it has no stop to find here
247
+ private stopInitiator (): void {
248
+ const entity = this.initiatorEntity as any
249
+ if (entity && typeof entity.stop === 'function') {
250
+ entity.stop()
251
+ }
252
+ }
253
+
232
254
  private loadConfig (config: string | ISessionDescription): ISessionDescription {
233
255
  if (typeof config === 'string') {
234
256
  return require(path.join(this.root, config))
@@ -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
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A readable reason for a failed connection.
3
+ *
4
+ * Node raises an AggregateError when the host resolved to more than one address and
5
+ * every attempt failed. That is not an edge case: 'localhost' is both ::1 and
6
+ * 127.0.0.1 on any modern machine, so it is what an ordinary refused connection looks
7
+ * like. Its own message is empty and the detail sits in errors[], so reporting
8
+ * e.message alone said nothing at all about the commonest failure there is - an
9
+ * application catching a rejected connect saw an Error with no message.
10
+ *
11
+ * Falls back to the code (ECONNREFUSED, ENOTFOUND, ETIMEDOUT) when there is no
12
+ * message anywhere, which is still enough to act on.
13
+ */
14
+ export function describeConnectError (e: unknown): string {
15
+ if (e == null) return 'unknown error'
16
+ const described = detail(e)
17
+ return described.length > 0 ? described : String(e)
18
+ }
19
+
20
+ /**
21
+ * The reason, or '' when this error carries none.
22
+ *
23
+ * Kept separate from the public function so a nested error with nothing to say
24
+ * contributes nothing, rather than a stringified 'Error' that would then look like a
25
+ * reason and stop the aggregate falling back to its own code.
26
+ */
27
+ function detail (e: unknown): string {
28
+ if (e == null) return ''
29
+
30
+ const err = e as { message?: string, code?: string, errors?: unknown[] }
31
+
32
+ if (Array.isArray(err.errors) && err.errors.length > 0) {
33
+ // every address usually fails the same way - say it once rather than repeating
34
+ // 'connect ECONNREFUSED' for each family
35
+ const unique = Array.from(new Set(err.errors.map(detail).filter(m => m.length > 0)))
36
+ if (unique.length > 0) return unique.join('; ')
37
+ }
38
+
39
+ if (err.message) return err.message
40
+ if (err.code) return err.code
41
+ return ''
42
+ }
43
+
44
+ /**
45
+ * Give an error a message when it has none, keeping the object itself.
46
+ *
47
+ * Deliberately not a wrapper: `code` and `errors` are what a caller switches on, and
48
+ * building a fresh Error to hold the text would throw both away. Returns the same
49
+ * error so it can be used inline.
50
+ */
51
+ export function withConnectReason (e: Error): Error {
52
+ if (!e.message) {
53
+ e.message = describeConnectError(e)
54
+ }
55
+ return e
56
+ }
@@ -9,6 +9,7 @@ import { DITokens } from '../../runtime/di-tokens'
9
9
  import { ITcpTransportDescription } from './tcp-transport-description'
10
10
  import { IMsgApplication } from '../msg-application'
11
11
  import { FixEntity } from '../fix-entity'
12
+ import { describeConnectError } from './connect-error'
12
13
 
13
14
  /*
14
15
  create one application session instance - and recover a lost transport. Hence, the application
@@ -25,8 +26,14 @@ export class RecoveringTcpInitiator extends FixEntity {
25
26
  private initiator: TcpInitiator
26
27
  private transport: MsgTransport
27
28
  private th: Timeout | null = null
28
- public recoveryAttemptSecs: number = 5
29
- public backoffFailConnectSecs: number = 30
29
+ private stopRequested: boolean = false
30
+ public static readonly DefaultRecoveryAttemptSecs = 5
31
+ public static readonly DefaultBackoffFailConnectSecs = 30
32
+ public static readonly DefaultConnectTimeoutSecs = 60
33
+
34
+ public recoveryAttemptSecs: number = RecoveringTcpInitiator.DefaultRecoveryAttemptSecs
35
+ public backoffFailConnectSecs: number = RecoveringTcpInitiator.DefaultBackoffFailConnectSecs
36
+ public connectTimeoutSecs: number = RecoveringTcpInitiator.DefaultConnectTimeoutSecs
30
37
 
31
38
  constructor (@inject(DITokens.IJsFixConfig) public readonly jsFixConfig: IJsFixConfig) {
32
39
  super(jsFixConfig)
@@ -40,6 +47,14 @@ export class RecoveringTcpInitiator extends FixEntity {
40
47
  if (!this.tcp) {
41
48
  throw new Error('no tcp in session description need tcp { host: hostname, port: port }')
42
49
  }
50
+ // the retry policy used to live only in these fields, which nothing set - an
51
+ // application could not change how hard or how long the engine tried to get its
52
+ // transport back without subclassing the launcher. See issue #72.
53
+ this.recoveryAttemptSecs = this.application.recoveryAttemptSeconds ?? this.recoveryAttemptSecs
54
+ this.backoffFailConnectSecs = this.application.backoffFailConnectSeconds ?? this.backoffFailConnectSecs
55
+ this.connectTimeoutSecs = this.application.connectTimeoutSeconds ?? this.connectTimeoutSecs
56
+ this.logger.info(`recovery policy: connectTimeout=${this.connectTimeoutSecs}s, ` +
57
+ `recoveryAttempt=${this.recoveryAttemptSecs}s, backoffFailConnect=${this.backoffFailConnectSecs}s`)
43
58
  this.createSession(jsFixConfig)
44
59
  }
45
60
 
@@ -54,6 +69,13 @@ export class RecoveringTcpInitiator extends FixEntity {
54
69
  this.logger.info('session has permanently ended')
55
70
  this.emit('end', this)
56
71
  })
72
+ // A lost transport is this initiator's normal course of events, and recovery is
73
+ // driven by session.run() rejecting rather than by this event. But whilst waiting
74
+ // between attempts there is no run in flight to carry a listener, so a stop landing
75
+ // then would emit 'error' to nobody - which node treats as fatal.
76
+ this.session.on('error', (e: Error) => {
77
+ this.logger.info(`session error: ${e.message}`)
78
+ })
57
79
  this.session.setState(SessionState.DisconnectedNoConnectionToday)
58
80
  }
59
81
 
@@ -104,7 +126,31 @@ export class RecoveringTcpInitiator extends FixEntity {
104
126
  // succeed in which case can restart session or fails in which case wait and
105
127
  // restart an attempt to connect
106
128
 
129
+ /**
130
+ * Give up recovering and end the session for good.
131
+ *
132
+ * Without this a resilient initiator could not be shut down: losing the transport
133
+ * schedules another attempt, and nothing cancelled that timer, so the process stayed
134
+ * alive retrying a counterparty the application had finished with. Safe to call
135
+ * twice, and safe whilst waiting between attempts with no transport at all.
136
+ */
137
+ public stop (): void {
138
+ if (this.stopRequested) return
139
+ this.stopRequested = true
140
+ this.logger.info('stop requested - cancelling recovery')
141
+ this.clearTimer()
142
+ if (this.session.getState() !== SessionState.Stopped) {
143
+ this.session.requestStop('initiator stopped')
144
+ }
145
+ this.emit('end', this)
146
+ }
147
+
107
148
  private recover (): void {
149
+ if (this.stopRequested) {
150
+ this.logger.info('transport lost after a stop was requested - not recovering')
151
+ this.emit('end', this)
152
+ return
153
+ }
108
154
  this.session.setState(SessionState.DetectBrokenNetworkConnection)
109
155
  this.logger.info(`recover session transport - attempt in ${this.recoveryAttemptSecs} secs`)
110
156
  if (!this.resetSeq()) {
@@ -115,10 +161,10 @@ export class RecoveringTcpInitiator extends FixEntity {
115
161
  }
116
162
  }
117
163
  this.th = setTimeout(() => {
118
- this.connect(60).then(t => {
164
+ this.connect(this.connectTimeoutSecs).then(t => {
119
165
  this.logger.info(`new transport ${t.id}`)
120
166
  }).catch((e) => {
121
- this.logger.info(`failed to re-connect ${e.message} - backoff for ${this.backoffFailConnectSecs}`)
167
+ this.logger.info(`failed to re-connect: ${describeConnectError(e)} - backoff for ${this.backoffFailConnectSecs}`)
122
168
  this.th = setTimeout(() => {
123
169
  this.logger.info('returning to recover()')
124
170
  setImmediate(() => {
@@ -135,18 +181,34 @@ export class RecoveringTcpInitiator extends FixEntity {
135
181
 
136
182
  // for first connection - reject if no initial connection established within timeout
137
183
  // once connection established, will not resolve until session is ended - i.e. lost
138
- // connections are re-established using the same session instance.
184
+ // connections are re-established using the same session instance. So this promise
185
+ // is the lifetime of the application, not a signal that the session is up: for that,
186
+ // use onReady on the session. See issue #72.
139
187
 
140
- public async run (initialTimeout: number = 60): Promise<any> {
188
+ public async run (initialTimeout: number = this.connectTimeoutSecs): Promise<any> {
141
189
  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 => {
190
+ // 'end' can arrive more than once - a stop both ends the session and calls off
191
+ // recovery, and each path announces itself - so settle only on the first. The
192
+ // listener goes on before connecting, not after: a stop landing whilst the first
193
+ // connection is still being attempted has to be able to end this too, and until
194
+ // now there was nothing listening that early to hear it.
195
+ let ended = false
196
+ const finish = (): void => {
197
+ if (ended) return
198
+ ended = true
199
+ this.clearTimer()
200
+ this.initiator?.end()
201
+ this.logger.info(`run: transport ${this.transport?.id ?? 'none'} ends - resolving`)
202
+ resolve(null)
203
+ }
204
+ this.on('end', finish)
205
+
206
+ this.connect(initialTimeout).catch(e => {
207
+ if (this.stopRequested) {
208
+ this.logger.info('run: first connection abandoned on stop - resolving')
209
+ finish()
210
+ return
211
+ }
150
212
  this.logger.info(`run: failed to connect to first transport ${initialTimeout} - rejecting`)
151
213
  reject(e)
152
214
  })
@@ -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')
@@ -4,12 +4,12 @@ import { MsgTransport } from '../factory'
4
4
  import { IJsFixConfig, IJsFixLogger } from '../../config'
5
5
  import { TcpDuplex, FixDuplex } from '../duplex'
6
6
 
7
- import * as util from 'util'
8
7
  import { connect as tlsConnect, ConnectionOptions, TLSSocket } from 'tls'
9
8
  import { createConnection } from 'net'
10
9
  import Timeout = NodeJS.Timeout
11
10
  import { TlsOptionsFactory } from './tls-options-factory'
12
11
  import { describeConnectionOptions, describeNegotiated, describePeerCertificate } from './tls-diagnostics'
12
+ import { describeConnectError, withConnectReason } from './connect-error'
13
13
  import { inject, injectable } from 'tsyringe'
14
14
  import { DITokens } from '../../runtime/di-tokens'
15
15
  import { ITcpTransportDescription } from './tcp-transport-description'
@@ -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)
@@ -73,7 +74,7 @@ export class TcpInitiator extends FixInitiator {
73
74
  // connect() whose timeout expires before the first retry interval fires - the
74
75
  // default case, timeout and reconnectSeconds are both 5 - reported a generic
75
76
  // "timeout whilst connecting" and threw away the reason. see #94.
76
- this.logger.info(`first connect attempt failed: ${first.message}`)
77
+ this.logger.info(`first connect attempt failed: ${describeConnectError(first)}`)
77
78
  this.repeatConnect(timeoutSeconds, first)
78
79
  .then((t: MsgTransport) => { resolve(t) })
79
80
  .catch((e: Error) => { reject(e) })
@@ -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
@@ -209,18 +212,24 @@ export class TcpInitiator extends FixInitiator {
209
212
  resolve(t)
210
213
  }).catch((e: Error) => {
211
214
  lastError = e
212
- this.logger.info(`${name}: retries ${retries} ${e.message}`)
215
+ this.logger.info(`${name}: retries ${retries} ${describeConnectError(e)}`)
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
- const e = lastError ?? new Error(`${name}: timeout of ${timeoutSeconds} whilst connecting`)
226
+ // this error is what the application catches from run() - so give it a message
227
+ // it can print. An AggregateError arrives with none at all, which meant a
228
+ // caller handling a failed connect had nothing to report but an empty string.
229
+ const e = withConnectReason(lastError ?? new Error(`${name}: timeout of ${timeoutSeconds} whilst connecting`))
219
230
  this.logger.warning(`${name}: giving up after ${timeoutSeconds}s and ${retries} retries, last error: ${e.message}`)
220
231
  reject(e)
221
- }).catch(e => {
222
- reject(e)
223
- })
232
+ }, timeoutSeconds * 1000)
224
233
  })
225
234
  }
226
235
  }