diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts index cf740af..c380e23 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts @@ -236,21 +236,19 @@ export class QqbotPluginWorkerRuntime { error instanceof QqbotPluginWorkerExpiredRequestError || isNamedError(error, 'QqbotPluginWorkerExpiredRequestError') ) { + const safeSummary = { + correlationId: message.correlationId, + operationId: message.operationId, + timeoutMs, + type, + }; const runtimeError = new QqbotPluginRuntimeError( 'PLUGIN_WORKER_TIMEOUT', this.options.pluginKey, 'QQBot plugin worker queue request expired.', - { - correlationId: message.correlationId, - operationId: message.operationId, - timeoutMs, - type, - }, - ); - this.recordRuntimeEvent( - 'worker-request-expired', - runtimeError.safeSummary, + safeSummary, ); + await this.markWorkerFailed('worker-request-expired', safeSummary); throw runtimeError; } if (error instanceof QqbotPluginWorkerResponseError) { diff --git a/src/modules/qqbot/plugins/bangdream/plugin.json b/src/modules/qqbot/plugins/bangdream/plugin.json index 1754ffd..ef964f5 100644 --- a/src/modules/qqbot/plugins/bangdream/plugin.json +++ b/src/modules/qqbot/plugins/bangdream/plugin.json @@ -90,7 +90,7 @@ "plugin.config.read", "plugin.storage.read" ], - "timeoutMs": 30000 + "timeoutMs": 120000 }, { "key": "bangdream.card.search", diff --git a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts index 6e8a74f..e77c7c6 100644 --- a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts @@ -2,6 +2,7 @@ import type { ConfigService } from '@nestjs/config'; import { createQqbotBullmqWorkerQueueOptions, QqbotPluginRuntimeError, + QqbotPluginWorkerExpiredRequestError, QqbotPluginWorkerResponseError, QqbotPluginWorkerRuntime, resolveQqbotPluginQueueConnection, @@ -111,6 +112,28 @@ class TimeoutAwareRecordingRequestQueue extends RecordingRequestQueue { readonly queueWaitTimeoutMs = 50; } +class ExpiringOnceRequestQueue implements QqbotPluginWorkerRequestQueue { + constructor(private readonly driver: QqbotPluginWorkerDriver) {} + + async close() { + await this.driver.dispose(); + } + + async request(message: QqbotPluginWorkerRequest): Promise { + if (message.operationId === 'op-expire') { + await this.reset(); + throw new QqbotPluginWorkerExpiredRequestError( + 'worker-request-execution-timeout', + ); + } + return this.driver.request(message); + } + + async reset() { + await this.driver.dispose(); + } +} + const createRuntime = (driver = new RecordingDriver()) => { const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), { defaultTimeoutMs: 50, @@ -349,6 +372,66 @@ describe('QQBot plugin worker runtime', () => { expect(runtime.status).toBe('active'); }); + it('recovers after the queue expires and disposes a slow worker request', async () => { + const requestTypes: string[] = []; + const driver: QqbotPluginWorkerDriver = { + dispose: jest.fn(async () => undefined), + request: jest.fn(async (message) => { + requestTypes.push(message.operationId || message.type); + return { + ok: true, + operationId: message.operationId, + type: message.type, + }; + }), + }; + const runtime = new QqbotPluginWorkerRuntime( + new ExpiringOnceRequestQueue(driver), + { + defaultTimeoutMs: 50, + installationId: 'install-expired-recover', + pluginKey: 'demo-plugin', + }, + ); + + await runtime.load({ + entry: 'src/index.ts', + pluginKey: 'demo-plugin', + version: '0.1.0', + }); + await runtime.activate(); + + await expect( + runtime.executeOperation({ + input: { text: 'slow' }, + operationId: 'op-expire', + operationKey: 'demo-plugin.echo', + }), + ).rejects.toMatchObject({ + code: 'PLUGIN_WORKER_TIMEOUT', + }); + expect(runtime.status).toBe('failed'); + + await expect( + runtime.executeOperation({ + input: { text: 'after expired' }, + operationId: 'op-after-expire', + operationKey: 'demo-plugin.echo', + }), + ).resolves.toMatchObject({ + operationId: 'op-after-expire', + }); + + expect(requestTypes).toEqual([ + 'load', + 'activate', + 'load', + 'activate', + 'op-after-expire', + ]); + expect(runtime.status).toBe('active'); + }); + it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => { const { driver, runtime } = createRuntime(); driver.responses.set('executeOperation', { replyText: 'ok' }); diff --git a/test/qqbot/plugins/bangdream/manifest/operation-manifest.spec.ts b/test/qqbot/plugins/bangdream/manifest/operation-manifest.spec.ts index f4d5c4c..d930762 100644 --- a/test/qqbot/plugins/bangdream/manifest/operation-manifest.spec.ts +++ b/test/qqbot/plugins/bangdream/manifest/operation-manifest.spec.ts @@ -52,4 +52,15 @@ describe('BangDream operation manifest', () => { }); expect(byKey.get('bangdream.unknown')).toBeUndefined(); }); + + it('keeps heavyweight list renderers on an extended timeout budget', () => { + const manifest = readManifest(); + const byKey = new Map( + manifest.operations.map((operation) => [operation.key, operation]), + ); + + expect(byKey.get('bangdream.song.meta')?.timeoutMs).toBeGreaterThanOrEqual( + 120000, + ); + }); });