@contember/engine-actions 2.2.0-alpha.4 → 2.2.0-alpha.6
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/dist/development/index.cjs +0 -1
- package/dist/development/index.cjs.map +1 -1
- package/dist/development/index.js +0 -1
- package/dist/development/index.js.map +1 -1
- package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.cjs +68 -28
- package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.cjs.map +1 -1
- package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.js +68 -28
- package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.js.map +1 -1
- package/dist/production/index.cjs +0 -1
- package/dist/production/index.cjs.map +1 -1
- package/dist/production/index.js +0 -1
- package/dist/production/index.js.map +1 -1
- package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.cjs +68 -28
- package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.cjs.map +1 -1
- package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.js +68 -28
- package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.js.map +1 -1
- package/dist/tests/cases/unit/actionsWebsocketController.test.d.ts +2 -0
- package/dist/tests/cases/unit/actionsWebsocketController.test.d.ts.map +1 -0
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/graphql/http/ActionsWebsocketControllerFactory.d.ts +7 -5
- package/dist/types/graphql/http/ActionsWebsocketControllerFactory.d.ts.map +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -16
- package/src/graphql/http/ActionsWebsocketControllerFactory.ts +73 -30
- package/src/index.ts +0 -1
- package/tests/cases/unit/actionsWebsocketController.test.ts +270 -0
package/package.json
CHANGED
|
@@ -22,26 +22,26 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@contember/authorization": "2.2.0-alpha.
|
|
26
|
-
"@contember/database": "2.2.0-alpha.
|
|
27
|
-
"@contember/database-migrations": "2.2.0-alpha.
|
|
28
|
-
"@contember/engine-common": "2.2.0-alpha.
|
|
29
|
-
"@contember/engine-content-api": "2.2.0-alpha.
|
|
30
|
-
"@contember/engine-http": "2.2.0-alpha.
|
|
31
|
-
"@contember/engine-plugins": "2.2.0-alpha.
|
|
32
|
-
"@contember/engine-system-api": "2.2.0-alpha.
|
|
33
|
-
"@contember/engine-tenant-api": "2.2.0-alpha.
|
|
34
|
-
"@contember/graphql-utils": "2.2.0-alpha.
|
|
35
|
-
"@contember/logger": "2.2.0-alpha.
|
|
36
|
-
"@contember/schema": "2.2.0-alpha.
|
|
37
|
-
"@contember/schema-utils": "2.2.0-alpha.
|
|
38
|
-
"@contember/typesafe": "2.2.0-alpha.
|
|
25
|
+
"@contember/authorization": "2.2.0-alpha.6",
|
|
26
|
+
"@contember/database": "2.2.0-alpha.6",
|
|
27
|
+
"@contember/database-migrations": "2.2.0-alpha.6",
|
|
28
|
+
"@contember/engine-common": "2.2.0-alpha.6",
|
|
29
|
+
"@contember/engine-content-api": "2.2.0-alpha.6",
|
|
30
|
+
"@contember/engine-http": "2.2.0-alpha.6",
|
|
31
|
+
"@contember/engine-plugins": "2.2.0-alpha.6",
|
|
32
|
+
"@contember/engine-system-api": "2.2.0-alpha.6",
|
|
33
|
+
"@contember/engine-tenant-api": "2.2.0-alpha.6",
|
|
34
|
+
"@contember/graphql-utils": "2.2.0-alpha.6",
|
|
35
|
+
"@contember/logger": "2.2.0-alpha.6",
|
|
36
|
+
"@contember/schema": "2.2.0-alpha.6",
|
|
37
|
+
"@contember/schema-utils": "2.2.0-alpha.6",
|
|
38
|
+
"@contember/typesafe": "2.2.0-alpha.6",
|
|
39
39
|
"@graphql-tools/schema": "^10.0.31",
|
|
40
40
|
"graphql-tag": "^2.12.6",
|
|
41
41
|
"prom-client": "^12.0.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@contember/database-tester": "2.2.0-alpha.
|
|
44
|
+
"@contember/database-tester": "2.2.0-alpha.6",
|
|
45
45
|
"@types/bun": "^1.3.10",
|
|
46
46
|
"@types/ws": "^8.18.1",
|
|
47
47
|
"graphql": "^16.13.1"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"pg": "^8.20.0"
|
|
52
52
|
},
|
|
53
53
|
"type": "module",
|
|
54
|
-
"version": "2.2.0-alpha.
|
|
54
|
+
"version": "2.2.0-alpha.6",
|
|
55
55
|
"repository": {
|
|
56
56
|
"type": "git",
|
|
57
57
|
"url": "git+https://github.com/contember/contember.git",
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { HttpErrorResponse, WebSocketController } from '@contember/engine-http'
|
|
2
|
-
import { ActionsContextResolver } from './ActionsContextResolver.js'
|
|
3
|
-
import { DispatchWorkerSupervisorFactory } from '../../dispatch/DispatchWorkerSupervisor.js'
|
|
1
|
+
import { HttpErrorResponse, ProjectGroupContainer, WebSocketController } from '@contember/engine-http'
|
|
4
2
|
import * as Typesafe from '@contember/typesafe'
|
|
5
3
|
import { TenantRole } from '@contember/engine-tenant-api'
|
|
6
|
-
import { Running } from '@contember/engine-common'
|
|
4
|
+
import { Runnable, Running } from '@contember/engine-common'
|
|
7
5
|
|
|
8
6
|
const IncomingMessage = Typesafe.discriminatedUnion(
|
|
9
7
|
'type',
|
|
@@ -41,11 +39,14 @@ const OutgoingMessage = Typesafe.discriminatedUnion(
|
|
|
41
39
|
|
|
42
40
|
type OutgoingMessage = ReturnType<typeof OutgoingMessage>
|
|
43
41
|
|
|
42
|
+
type DispatchWorkerFactory = {
|
|
43
|
+
create(projectGroup: ProjectGroupContainer): Runnable
|
|
44
|
+
}
|
|
45
|
+
|
|
44
46
|
export class ActionsWebsocketControllerFactory {
|
|
45
47
|
constructor(
|
|
46
48
|
private readonly debug: boolean,
|
|
47
|
-
private readonly
|
|
48
|
-
private readonly dispatchWorkerSupervisorFactory: DispatchWorkerSupervisorFactory,
|
|
49
|
+
private readonly dispatchWorkerSupervisorFactory: DispatchWorkerFactory,
|
|
49
50
|
) {
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -67,24 +68,44 @@ export class ActionsWebsocketControllerFactory {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
let workers: { id: string; running: Promise<Running> }[] = []
|
|
71
|
+
let stopAllPromise: Promise<void> | undefined
|
|
72
|
+
let connectionClosing = false
|
|
70
73
|
const abortListener = async () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
connectionClosing = true
|
|
75
|
+
try {
|
|
76
|
+
send({ type: 'message', message: 'shutting down' })
|
|
77
|
+
await stopAll()
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger.error(error, { message: 'Websocket worker cleanup failed' })
|
|
80
|
+
} finally {
|
|
81
|
+
try {
|
|
82
|
+
send({ type: 'message', message: 'closing connection' })
|
|
83
|
+
} finally {
|
|
84
|
+
ws.close(1012) // Service Restart
|
|
85
|
+
}
|
|
86
|
+
}
|
|
75
87
|
}
|
|
76
88
|
ctx.abortSignal.addEventListener('abort', abortListener)
|
|
77
89
|
|
|
78
|
-
const stopAll =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
const stopAll = (): Promise<void> => {
|
|
91
|
+
stopAllPromise ??= (async () => {
|
|
92
|
+
// a worker stays tracked until its own end() resolves, so a failed cleanup is not forgotten
|
|
93
|
+
const results = await Promise.allSettled(workers.map(async it => {
|
|
94
|
+
await (await it.running).end()
|
|
95
|
+
workers = workers.filter(worker => worker !== it)
|
|
96
|
+
send({
|
|
97
|
+
type: 'workedStopped',
|
|
98
|
+
workerId: it.id,
|
|
99
|
+
})
|
|
100
|
+
}))
|
|
101
|
+
const errors = results.flatMap(it => it.status === 'rejected' ? [it.reason] : [])
|
|
102
|
+
if (errors.length > 0) {
|
|
103
|
+
throw new AggregateError(errors, 'Worker cleanup failed')
|
|
104
|
+
}
|
|
105
|
+
})().finally(() => {
|
|
106
|
+
stopAllPromise = undefined
|
|
85
107
|
})
|
|
86
|
-
|
|
87
|
-
await Promise.all(currentWorkers)
|
|
108
|
+
return stopAllPromise
|
|
88
109
|
}
|
|
89
110
|
send({ type: 'ready' })
|
|
90
111
|
|
|
@@ -110,27 +131,41 @@ export class ActionsWebsocketControllerFactory {
|
|
|
110
131
|
|
|
111
132
|
switch (data.type) {
|
|
112
133
|
case 'startWorker':
|
|
134
|
+
if (connectionClosing || stopAllPromise !== undefined) {
|
|
135
|
+
send({ type: 'error', message: 'Workers are stopping' })
|
|
136
|
+
break
|
|
137
|
+
}
|
|
113
138
|
const workerId = Math.random().toString().substring(2)
|
|
114
139
|
const dispatchSupervisor = this.dispatchWorkerSupervisorFactory.create(projectGroup)
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
const running = Promise.resolve().then(() =>
|
|
141
|
+
dispatchSupervisor.run({
|
|
117
142
|
logger,
|
|
118
143
|
onError: () => {
|
|
119
144
|
send({ type: 'workerCrashed', workerId })
|
|
120
145
|
},
|
|
121
146
|
})
|
|
122
|
-
|
|
147
|
+
)
|
|
148
|
+
const worker = { id: workerId, running }
|
|
149
|
+
workers.push(worker)
|
|
150
|
+
try {
|
|
123
151
|
await running
|
|
124
|
-
send({ type: 'workerStarted', workerId })
|
|
125
152
|
} catch (e) {
|
|
153
|
+
workers = workers.filter(it => it !== worker)
|
|
126
154
|
logger.error(e, { message: 'Worker failed to start' })
|
|
127
155
|
send({ type: 'workerFailedToStart', workerId })
|
|
156
|
+
break
|
|
128
157
|
}
|
|
158
|
+
send({ type: 'workerStarted', workerId })
|
|
129
159
|
break
|
|
130
160
|
case 'stopAllWorkers':
|
|
131
161
|
send({ type: 'message', message: 'stopping' })
|
|
132
|
-
|
|
133
|
-
|
|
162
|
+
try {
|
|
163
|
+
await stopAll()
|
|
164
|
+
send({ type: 'message', message: 'all stopped' })
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.error(error, { message: 'Websocket worker cleanup failed' })
|
|
167
|
+
send({ type: 'error', message: 'Worker cleanup failed' })
|
|
168
|
+
}
|
|
134
169
|
break
|
|
135
170
|
}
|
|
136
171
|
})
|
|
@@ -139,11 +174,19 @@ export class ActionsWebsocketControllerFactory {
|
|
|
139
174
|
ws.ping()
|
|
140
175
|
}, 5000)
|
|
141
176
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
177
|
+
ctx.waitUntil(
|
|
178
|
+
new Promise<void>(resolve => {
|
|
179
|
+
ws.addEventListener('close', () => {
|
|
180
|
+
connectionClosing = true
|
|
181
|
+
ctx.abortSignal.removeEventListener('abort', abortListener)
|
|
182
|
+
clearInterval(pingHandle)
|
|
183
|
+
void stopAll().then(resolve, error => {
|
|
184
|
+
logger.error(error, { message: 'Websocket worker cleanup failed' })
|
|
185
|
+
resolve()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
147
190
|
}
|
|
148
191
|
}
|
|
149
192
|
}
|
package/src/index.ts
CHANGED
|
@@ -123,7 +123,6 @@ export default class ActionsPlugin implements Plugin {
|
|
|
123
123
|
)
|
|
124
124
|
const actionsWebsocketMiddlewareFactory = new ActionsWebsocketControllerFactory(
|
|
125
125
|
debugMode,
|
|
126
|
-
actionsContextResolver,
|
|
127
126
|
actions_dispatchWorkerSupervisorFactory,
|
|
128
127
|
)
|
|
129
128
|
it.addRoute('actions', '/actions/:projectSlug', actionsMiddlewareFactory.create())
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { Runnable, Running } from '@contember/engine-common'
|
|
3
|
+
import { Application, Authenticator, ProjectGroupContainer, ProjectGroupResolver, serverConfigSchema } from '@contember/engine-http'
|
|
4
|
+
import { createLogger, TestLoggerHandler } from '@contember/logger'
|
|
5
|
+
import { TenantRole, VerifyResult } from '@contember/engine-tenant-api'
|
|
6
|
+
import { WebSocket } from 'ws'
|
|
7
|
+
import { ActionsWebsocketControllerFactory } from '../../../src/graphql/http/ActionsWebsocketControllerFactory.js'
|
|
8
|
+
|
|
9
|
+
type Interface<T> = { [P in keyof T]: T[P] }
|
|
10
|
+
|
|
11
|
+
const createMock = <T>(members: Interface<T>): T => members
|
|
12
|
+
|
|
13
|
+
const unavailable = (): never => {
|
|
14
|
+
throw new Error('This service is not available in the actions WebSocket controller test')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const waitFor = async (condition: () => boolean): Promise<void> => {
|
|
18
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
19
|
+
if (condition()) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
await new Promise<void>(resolve => setTimeout(resolve, 1))
|
|
23
|
+
}
|
|
24
|
+
throw new Error('Condition was not met')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const listen = async (createWorker: () => Runnable) => {
|
|
28
|
+
const loggerHandler = new TestLoggerHandler()
|
|
29
|
+
const logger = createLogger(loggerHandler)
|
|
30
|
+
const authenticator = createMock<Authenticator>({
|
|
31
|
+
authenticate: async () => new VerifyResult('identity', 'api-key', [TenantRole.SUPER_ADMIN], null),
|
|
32
|
+
})
|
|
33
|
+
const projectGroup: ProjectGroupContainer = {
|
|
34
|
+
slug: undefined,
|
|
35
|
+
logger,
|
|
36
|
+
authenticator,
|
|
37
|
+
get projectMembershipResolver() {
|
|
38
|
+
return unavailable()
|
|
39
|
+
},
|
|
40
|
+
get projectContainerResolver() {
|
|
41
|
+
return unavailable()
|
|
42
|
+
},
|
|
43
|
+
get projectSchemaResolver() {
|
|
44
|
+
return unavailable()
|
|
45
|
+
},
|
|
46
|
+
get projectInitializer() {
|
|
47
|
+
return unavailable()
|
|
48
|
+
},
|
|
49
|
+
get tenantContainer() {
|
|
50
|
+
return unavailable()
|
|
51
|
+
},
|
|
52
|
+
get tenantGraphQLHandler() {
|
|
53
|
+
return unavailable()
|
|
54
|
+
},
|
|
55
|
+
get systemContainer() {
|
|
56
|
+
return unavailable()
|
|
57
|
+
},
|
|
58
|
+
get systemGraphQLHandler() {
|
|
59
|
+
return unavailable()
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
const projectGroupResolver = createMock<ProjectGroupResolver>({
|
|
63
|
+
resolveContainer: async () => projectGroup,
|
|
64
|
+
})
|
|
65
|
+
const application = new Application(projectGroupResolver, serverConfigSchema({ port: 0 }), false, undefined, logger)
|
|
66
|
+
application.addWebsocketRoute(
|
|
67
|
+
'actions',
|
|
68
|
+
'/actions/_worker',
|
|
69
|
+
new ActionsWebsocketControllerFactory(false, { create: createWorker }).create(),
|
|
70
|
+
)
|
|
71
|
+
const running = await application.listen()
|
|
72
|
+
const address = running.server.address()
|
|
73
|
+
if (address === null || typeof address === 'string') {
|
|
74
|
+
await running.close()
|
|
75
|
+
throw new Error('The test server did not bind to a TCP port')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const messages: string[] = []
|
|
79
|
+
const client = new WebSocket(`ws://127.0.0.1:${address.port}/actions/_worker`)
|
|
80
|
+
client.on('message', data => {
|
|
81
|
+
const message: unknown = JSON.parse(data.toString())
|
|
82
|
+
if (typeof message === 'object' && message !== null && 'type' in message && typeof message.type === 'string') {
|
|
83
|
+
const detail = 'message' in message && typeof message.message === 'string' ? `:${message.message}` : ''
|
|
84
|
+
messages.push(`${message.type}${detail}`)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
const opened = new Promise<void>((resolve, reject) => {
|
|
88
|
+
client.once('open', () => resolve())
|
|
89
|
+
client.once('error', reject)
|
|
90
|
+
})
|
|
91
|
+
const closed = new Promise<number>(resolve => client.once('close', code => resolve(code)))
|
|
92
|
+
return { client, closed, loggerHandler, messages, opened, running }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('a worker startup failure does not block WebSocket shutdown', async () => {
|
|
96
|
+
const worker: Runnable = {
|
|
97
|
+
run: (): Promise<Running> => Promise.reject(new Error('Worker startup failed')),
|
|
98
|
+
}
|
|
99
|
+
const { client, closed, loggerHandler, messages, opened, running } = await listen(() => worker)
|
|
100
|
+
let applicationClosed = false
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await opened
|
|
104
|
+
await waitFor(() => messages.includes('ready'))
|
|
105
|
+
client.send(JSON.stringify({ type: 'startWorker' }))
|
|
106
|
+
await waitFor(() => messages.includes('workerFailedToStart'))
|
|
107
|
+
|
|
108
|
+
await running.close()
|
|
109
|
+
applicationClosed = true
|
|
110
|
+
expect(await closed).toBe(1012)
|
|
111
|
+
expect(loggerHandler.messages.some(it => it.message.startsWith('Websocket worker cleanup failed'))).toBe(false)
|
|
112
|
+
} finally {
|
|
113
|
+
client.terminate()
|
|
114
|
+
if (!applicationClosed) {
|
|
115
|
+
await running.close()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('shutdown waits for every worker cleanup after one cleanup fails', async () => {
|
|
121
|
+
let releaseCleanup = () => {}
|
|
122
|
+
const cleanupReleased = new Promise<void>(resolve => {
|
|
123
|
+
releaseCleanup = resolve
|
|
124
|
+
})
|
|
125
|
+
let cleanupStarted = false
|
|
126
|
+
const workers: Runnable[] = [
|
|
127
|
+
{
|
|
128
|
+
run: async () => ({
|
|
129
|
+
end: (): Promise<void> => Promise.reject(new Error('Worker cleanup failed')),
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
run: async () => ({
|
|
134
|
+
end: async () => {
|
|
135
|
+
cleanupStarted = true
|
|
136
|
+
await cleanupReleased
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
const { client, closed, loggerHandler, messages, opened, running } = await listen(() => workers.shift() ?? unavailable())
|
|
142
|
+
let shutdown: Promise<void> | undefined
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await opened
|
|
146
|
+
await waitFor(() => messages.includes('ready'))
|
|
147
|
+
client.send(JSON.stringify({ type: 'startWorker' }))
|
|
148
|
+
await waitFor(() => messages.filter(it => it === 'workerStarted').length === 1)
|
|
149
|
+
client.send(JSON.stringify({ type: 'startWorker' }))
|
|
150
|
+
await waitFor(() => messages.filter(it => it === 'workerStarted').length === 2)
|
|
151
|
+
|
|
152
|
+
let shutdownFinished = false
|
|
153
|
+
shutdown = running.close().then(() => {
|
|
154
|
+
shutdownFinished = true
|
|
155
|
+
})
|
|
156
|
+
await waitFor(() => cleanupStarted)
|
|
157
|
+
await new Promise<void>(resolve => setImmediate(resolve))
|
|
158
|
+
expect(shutdownFinished).toBe(false)
|
|
159
|
+
expect(messages).not.toContain('message:closing connection')
|
|
160
|
+
releaseCleanup()
|
|
161
|
+
await shutdown
|
|
162
|
+
expect(await closed).toBe(1012)
|
|
163
|
+
expect(loggerHandler.messages.some(it => it.message.startsWith('Websocket worker cleanup failed'))).toBe(true)
|
|
164
|
+
} finally {
|
|
165
|
+
releaseCleanup()
|
|
166
|
+
shutdown ??= running.close()
|
|
167
|
+
await shutdown
|
|
168
|
+
client.terminate()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('shutdown waits for worker cleanup started by a peer disconnect', async () => {
|
|
173
|
+
let releaseCleanup = () => {}
|
|
174
|
+
const cleanupReleased = new Promise<void>(resolve => {
|
|
175
|
+
releaseCleanup = resolve
|
|
176
|
+
})
|
|
177
|
+
let cleanupStarted = false
|
|
178
|
+
const worker: Runnable = {
|
|
179
|
+
run: async () => ({
|
|
180
|
+
end: async () => {
|
|
181
|
+
cleanupStarted = true
|
|
182
|
+
await cleanupReleased
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
}
|
|
186
|
+
const { client, messages, opened, running } = await listen(() => worker)
|
|
187
|
+
let shutdown: Promise<void> | undefined
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await opened
|
|
191
|
+
await waitFor(() => messages.includes('ready'))
|
|
192
|
+
client.send(JSON.stringify({ type: 'startWorker' }))
|
|
193
|
+
await waitFor(() => messages.includes('workerStarted'))
|
|
194
|
+
client.close()
|
|
195
|
+
await waitFor(() => cleanupStarted)
|
|
196
|
+
|
|
197
|
+
let shutdownFinished = false
|
|
198
|
+
shutdown = running.close().then(() => {
|
|
199
|
+
shutdownFinished = true
|
|
200
|
+
})
|
|
201
|
+
await new Promise<void>(resolve => setImmediate(resolve))
|
|
202
|
+
expect(shutdownFinished).toBe(false)
|
|
203
|
+
releaseCleanup()
|
|
204
|
+
await shutdown
|
|
205
|
+
} finally {
|
|
206
|
+
releaseCleanup()
|
|
207
|
+
shutdown ??= running.close()
|
|
208
|
+
await shutdown
|
|
209
|
+
client.terminate()
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a worker whose cleanup fails stays tracked for the next stop', async () => {
|
|
214
|
+
const worker: Runnable = {
|
|
215
|
+
run: async () => ({
|
|
216
|
+
end: (): Promise<void> => Promise.reject(new Error('Worker cleanup failed')),
|
|
217
|
+
}),
|
|
218
|
+
}
|
|
219
|
+
const { client, closed, messages, opened, running } = await listen(() => worker)
|
|
220
|
+
let applicationClosed = false
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await opened
|
|
224
|
+
await waitFor(() => messages.includes('ready'))
|
|
225
|
+
client.send(JSON.stringify({ type: 'startWorker' }))
|
|
226
|
+
await waitFor(() => messages.includes('workerStarted'))
|
|
227
|
+
client.send(JSON.stringify({ type: 'stopAllWorkers' }))
|
|
228
|
+
await waitFor(() => messages.filter(it => it === 'error:Worker cleanup failed').length === 1)
|
|
229
|
+
client.send(JSON.stringify({ type: 'stopAllWorkers' }))
|
|
230
|
+
await waitFor(() => messages.filter(it => it === 'error:Worker cleanup failed').length === 2)
|
|
231
|
+
expect(messages).not.toContain('message:all stopped')
|
|
232
|
+
|
|
233
|
+
await running.close()
|
|
234
|
+
applicationClosed = true
|
|
235
|
+
expect(await closed).toBe(1012)
|
|
236
|
+
} finally {
|
|
237
|
+
client.terminate()
|
|
238
|
+
if (!applicationClosed) {
|
|
239
|
+
await running.close()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('reports a manual worker cleanup failure without blocking shutdown', async () => {
|
|
245
|
+
const worker: Runnable = {
|
|
246
|
+
run: async () => ({
|
|
247
|
+
end: (): Promise<void> => Promise.reject(new Error('Worker cleanup failed')),
|
|
248
|
+
}),
|
|
249
|
+
}
|
|
250
|
+
const { client, closed, messages, opened, running } = await listen(() => worker)
|
|
251
|
+
let applicationClosed = false
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await opened
|
|
255
|
+
await waitFor(() => messages.includes('ready'))
|
|
256
|
+
client.send(JSON.stringify({ type: 'startWorker' }))
|
|
257
|
+
await waitFor(() => messages.includes('workerStarted'))
|
|
258
|
+
client.send(JSON.stringify({ type: 'stopAllWorkers' }))
|
|
259
|
+
await waitFor(() => messages.includes('error:Worker cleanup failed'))
|
|
260
|
+
|
|
261
|
+
await running.close()
|
|
262
|
+
applicationClosed = true
|
|
263
|
+
expect(await closed).toBe(1012)
|
|
264
|
+
} finally {
|
|
265
|
+
client.terminate()
|
|
266
|
+
if (!applicationClosed) {
|
|
267
|
+
await running.close()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
})
|