@rdfc/js-runner 3.0.2 → 3.1.0-alpha.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/src/writer.ts CHANGED
@@ -4,8 +4,14 @@ import { Logger } from 'winston'
4
4
  import { Any } from './reader'
5
5
 
6
6
  type Writable = (msg: FromRunner) => Promise<unknown>
7
+ export type Handler<T = void> = [T] extends [void]
8
+ ? () => void | Promise<void>
9
+ : (value: T) => void | Promise<void>
10
+
7
11
  export interface Writer {
8
12
  readonly uri: string
13
+ readonly canceled: boolean
14
+ on(event: 'cancel', listener: Handler): this
9
15
  buffer(buffer: Uint8Array): Promise<void>
10
16
 
11
17
  stream(buffer: AsyncIterable<Uint8Array>): Promise<void>
@@ -26,10 +32,22 @@ export class WriterInstance implements Writer {
26
32
  private readonly notifyOrchestrator: Writable
27
33
  private readonly logger: Logger
28
34
 
29
- private awaitingProcessed: Array<() => void> = []
35
+ // FIFO of message-level acknowledgements coming back from the orchestrator.
36
+ private awaitingProcessed: Array<{
37
+ resolve: () => void
38
+ reject: (reason: Error) => void
39
+ }> = []
30
40
 
31
41
  private openStreams: number = 0
42
+ // Close callers wait here while active streams are still flushing.
32
43
  private shouldClose: Array<() => void> = []
44
+ private closed = false
45
+ private _canceled = false
46
+
47
+ // Shared cancellation signal to abort in-flight waits when the remote closes.
48
+ private readonly cancelSignal = new AbortController()
49
+ // Processors can subscribe here to stop upstream work when downstream cancels.
50
+ private readonly cancelHandlers = new Set<Handler>()
33
51
 
34
52
  private readonly runnerId: string
35
53
 
@@ -47,8 +65,85 @@ export class WriterInstance implements Writer {
47
65
  this.runnerId = runnerId
48
66
  }
49
67
 
68
+ get canceled(): boolean {
69
+ return this._canceled
70
+ }
71
+
72
+ on(event: 'cancel', listener: Handler): this {
73
+ if (event === 'cancel') {
74
+ this.cancelHandlers.add(listener)
75
+ }
76
+
77
+ return this
78
+ }
79
+
80
+ private cancellationError(): Error {
81
+ return new Error(
82
+ `Writer for channel ${this.uri} was canceled by the connected reader`,
83
+ )
84
+ }
85
+
86
+ private emitCancel() {
87
+ for (const handler of this.cancelHandlers) {
88
+ try {
89
+ Promise.resolve(handler()).catch((error: unknown) => {
90
+ this.logger.error(
91
+ `Cancel listener for channel ${this.uri} failed: ${String(error)}`,
92
+ )
93
+ })
94
+ } catch (error: unknown) {
95
+ this.logger.error(
96
+ `Cancel listener for channel ${this.uri} failed: ${String(error)}`,
97
+ )
98
+ }
99
+ }
100
+ }
101
+
102
+ private assertCanWrite() {
103
+ if (this._canceled) {
104
+ throw this.cancellationError()
105
+ }
106
+
107
+ if (this.closed) {
108
+ throw new Error(`Writer for channel ${this.uri} is closed`)
109
+ }
110
+ }
111
+
112
+ private async raceWithCancellation<T>(promise: Promise<T>): Promise<T> {
113
+ this.assertCanWrite()
114
+
115
+ return await new Promise<T>((resolve, reject) => {
116
+ const onAbort = () => reject(this.cancellationError())
117
+ this.cancelSignal.signal.addEventListener('abort', onAbort, {
118
+ once: true,
119
+ })
120
+
121
+ promise.then(
122
+ (value) => {
123
+ // Clean up the abort listener if the original operation finished first.
124
+ this.cancelSignal.signal.removeEventListener('abort', onAbort)
125
+ resolve(value)
126
+ },
127
+ (error) => {
128
+ this.cancelSignal.signal.removeEventListener('abort', onAbort)
129
+ reject(error)
130
+ },
131
+ )
132
+ })
133
+ }
134
+
135
+ private rejectPendingProcessed(error: Error) {
136
+ // Reject all queued message waits so callers do not hang during cancellation.
137
+ while (this.awaitingProcessed.length > 0) {
138
+ this.awaitingProcessed.shift()!.reject(error)
139
+ }
140
+ }
141
+
50
142
  private awaitProcessed(): Promise<void> {
51
- return new Promise((res) => this.awaitingProcessed.push(res))
143
+ this.assertCanWrite()
144
+ return new Promise((resolve, reject) =>
145
+ this.awaitingProcessed.push({ resolve, reject }),
146
+ )
52
147
  }
53
148
 
54
149
  async any(any: Any): Promise<void> {
@@ -64,6 +159,7 @@ export class WriterInstance implements Writer {
64
159
  }
65
160
 
66
161
  async buffer(buffer: Uint8Array): Promise<void> {
162
+ this.assertCanWrite()
67
163
  this.logger.debug(`${this.uri} sends buffer ${buffer.length} bytes`)
68
164
  const localSequenceNumber = this.localSequenceNumber++
69
165
  const handledPromise = this.awaitProcessed()
@@ -78,44 +174,59 @@ export class WriterInstance implements Writer {
78
174
  buffer: AsyncIterable<T>,
79
175
  transform?: (x: T) => Uint8Array,
80
176
  ) {
177
+ this.assertCanWrite()
81
178
  this.openStreams += 1
82
179
  const t = transform || ((x: unknown) => <Uint8Array>x)
83
180
  const stream = this.client.sendStreamMessage()
84
181
 
85
- const handledPromise = this.awaitProcessed()
86
- const writeStreamMessageChunk = promisify(stream.write.bind(stream))
87
- const localSequenceNumber = this.localSequenceNumber++
88
- await writeStreamMessageChunk({
89
- id: {
90
- channel: this.uri,
91
- localSequenceNumber,
92
- runner: this.runnerId,
93
- },
94
- })
182
+ try {
183
+ // Message-level ack that signals the whole stream message is fully handled.
184
+ const handledPromise = this.awaitProcessed()
185
+ const writeStreamMessageChunk = promisify(stream.write.bind(stream))
186
+ const localSequenceNumber = this.localSequenceNumber++
187
+ await writeStreamMessageChunk({
188
+ id: {
189
+ channel: this.uri,
190
+ localSequenceNumber,
191
+ runner: this.runnerId,
192
+ },
193
+ })
95
194
 
96
- const id = await new Promise((res) => stream.once('data', res))
195
+ // First response confirms stream id registration on the remote side.
196
+ const id = await this.raceWithCancellation(
197
+ new Promise((res) => stream.once('data', res)),
198
+ )
97
199
 
98
- this.logger.debug(
99
- `${this.uri} streams message with id ${JSON.stringify(id)}`,
100
- )
200
+ this.logger.debug(
201
+ `${this.uri} streams message with id ${JSON.stringify(id)}`,
202
+ )
101
203
 
102
- for await (const msg of buffer) {
103
- const processedPromise = new Promise((res) => stream.once('data', res))
104
- await writeStreamMessageChunk({ data: { data: t(msg) } })
105
- // Await a message on the stream, indicating that the chunk has been processed
106
- await processedPromise
107
- }
204
+ for await (const msg of buffer) {
205
+ const processedPromise = new Promise((res) => stream.once('data', res))
206
+ await writeStreamMessageChunk({ data: { data: t(msg) } })
207
+ // Await a message on the stream, indicating that the chunk has been processed
208
+ await processedPromise
209
+ }
108
210
 
109
- stream.end()
211
+ stream.end()
110
212
 
111
- await handledPromise
213
+ await handledPromise
214
+ } finally {
215
+ this.openStreams -= 1
112
216
 
113
- this.openStreams -= 1
217
+ if (!stream.writableEnded) {
218
+ stream.end()
219
+ }
114
220
 
115
- if (this.shouldClose.length > 0) await this.close()
221
+ // If a close call was deferred while streaming, complete it now.
222
+ if (this.shouldClose.length > 0) {
223
+ await this.close()
224
+ }
225
+ }
116
226
  }
117
227
 
118
228
  async string(msg: string): Promise<void> {
229
+ this.assertCanWrite()
119
230
  this.logger.debug(`${this.uri} sends string ${msg.length} characters`)
120
231
  const localSequenceNumber = this.localSequenceNumber++
121
232
  const handledPromise = this.awaitProcessed()
@@ -143,6 +254,33 @@ export class WriterInstance implements Writer {
143
254
  * @param issued - If true, indicates the close request originated remotely
144
255
  */
145
256
  async close(issued = false): Promise<void> {
257
+ if (issued) {
258
+ if (!this.closed) {
259
+ // Remote initiated close: mark writer canceled to fail future writes.
260
+ this._canceled = true
261
+
262
+ // Notify processors so they can stop producing upstream work as well.
263
+ this.emitCancel()
264
+ }
265
+ this.closed = true
266
+
267
+ const cancelError = this.cancellationError()
268
+ this.rejectPendingProcessed(cancelError)
269
+ this.cancelSignal.abort(cancelError)
270
+
271
+ // Unblock any local close() callers waiting for streams to settle.
272
+ let waiting = this.shouldClose.pop()
273
+ while (waiting) {
274
+ waiting()
275
+ waiting = this.shouldClose.pop()
276
+ }
277
+ return
278
+ }
279
+
280
+ if (this.closed) {
281
+ return
282
+ }
283
+
146
284
  // Case 1: Active streams still running → wait until they finish
147
285
  if (this.openStreams !== 0) {
148
286
  await new Promise<void>((resolve) => this.shouldClose.push(resolve))
@@ -151,11 +289,10 @@ export class WriterInstance implements Writer {
151
289
 
152
290
  // Case 2: No active streams → perform actual close
153
291
  this.logger.debug(`${this.uri} closes stream`)
154
- if (!issued) {
155
- await this.notifyOrchestrator({
156
- close: { channel: this.uri },
157
- })
158
- }
292
+ this.closed = true
293
+ await this.notifyOrchestrator({
294
+ close: { channel: this.uri },
295
+ })
159
296
 
160
297
  let resolve = this.shouldClose.pop()
161
298
  while (resolve) {
@@ -169,7 +306,10 @@ export class WriterInstance implements Writer {
169
306
  */
170
307
  handled(): void {
171
308
  if (this.awaitingProcessed.length > 0) {
172
- this.awaitingProcessed.shift()!()
309
+ this.awaitingProcessed.shift()!.resolve()
310
+ } else if (this.closed || this._canceled) {
311
+ // A late ack can arrive after a close/cancel race; nothing to resolve anymore.
312
+ return
173
313
  } else {
174
314
  this.logger.error(
175
315
  'Expected to be waiting for a message to be processed, but this is not the case ' +
package/jest.config.js DELETED
@@ -1,2 +0,0 @@
1
- export const preset = 'ts-jest'
2
- export const testEnvironment = 'node'