Sfoglia il codice sorgente

将 Hermes 工具桥绑定超时延长至 60 秒

Samien 1 mese fa
parent
commit
172f12f
3 ha cambiato i file con 29 aggiunte e 18 eliminazioni
  1. 2 1
      src/server/runnerHub.ts
  2. 7 3
      src/server/upstream.ts
  3. 20 14
      tests/server/upstream.test.ts

+ 2 - 1
src/server/runnerHub.ts

@@ -103,7 +103,8 @@ export class RunnerHub {
     if(!this.online.has(id))return Promise.reject(new HttpError(503,'执行节点未连接','runner_offline'))
     const queue=this.pending.get(id)??new Map<string,Pending>();this.pending.set(id,queue)
     if(queue.size>=64)return Promise.reject(new HttpError(429,'执行节点请求过多','runner_busy'))
-    const timeout = kind === 'http' && payload.path === '/api/plugins/yaoyao-bot-bridge/memory-extract' ? 120000 : 30000
+    // Allow Hermes' 60-second binding budget plus transport overhead.
+    const timeout = kind === 'lease.bind' ? 70000 : kind === 'http' && payload.path === '/api/plugins/yaoyao-bot-bridge/memory-extract' ? 120000 : 30000
     const command:RunnerCommand={id:randomUUID(),kind,payload,expiresAt:Date.now()+timeout}
     const bytes=Buffer.byteLength(JSON.stringify(command))
     if(bytes>36*1024*1024)return Promise.reject(new HttpError(413,'执行命令超过传输限制','runner_payload_limit'))

+ 7 - 3
src/server/upstream.ts

@@ -258,7 +258,9 @@ export class UpstreamClient {
     let response: Response
     let timeout: ReturnType<typeof setTimeout> | undefined
     let timedOut = false
-    const requestTimeout = path === '/api/plugins/yaoyao-bot-bridge/memory-extract' && options.method === 'POST' ? 110_000 : 30_000
+    const isBridgeBind = path === '/api/plugins/yaoyao-bot-bridge/bind' && options.method === 'POST'
+    const requestTimeout = isBridgeBind ? 60_000 : path === '/api/plugins/yaoyao-bot-bridge/memory-extract' && options.method === 'POST' ? 110_000 : 30_000
+    const startedAt = Date.now()
     const controller = new AbortController()
     try {
       response = await Promise.race([
@@ -294,12 +296,14 @@ export class UpstreamClient {
     const reader = response.body?.getReader()
     if (reader) {
       let bodyTimeout: ReturnType<typeof setTimeout> | undefined
+      // Binding shares one 60-second budget across headers and body reads.
+      const bodyTimeoutMs = isBridgeBind ? Math.max(0, requestTimeout - (Date.now() - startedAt)) : 30_000
       try {
         const expired = new Promise<never>((_resolve, reject) => {
           bodyTimeout = setTimeout(() => {
-            reject(new HttpError(502, requestTimeoutMessage(path, options, 30) ?? 'Hermes response timed out', 'upstream_unavailable'))
+            reject(new HttpError(502, requestTimeoutMessage(path, options, isBridgeBind ? requestTimeout / 1000 : 30) ?? 'Hermes response timed out', 'upstream_unavailable'))
             void reader.cancel().catch(() => {})
-          }, 30_000)
+          }, bodyTimeoutMs)
           bodyTimeout.unref()
         })
         while (true) {

+ 20 - 14
tests/server/upstream.test.ts

@@ -30,11 +30,11 @@ describe('upstream request boundary', () => {
 })
 
 it.each([
-  {path:'/api/config', options:{search:new URLSearchParams({profile:'yaoer',token:'private-query'})}, stage:'Profile「yaoer」读取配置'},
-  {path:'/api/plugins/yaoyao-bot-bridge/capabilities', options:{search:new URLSearchParams({profile:'default'})}, stage:'Profile「default」检查工具桥'},
-  {path:'/api/plugins/yaoyao-bot-bridge/bind', options:{method:'POST',body:{profile:'yaoer',token:'private-bridge-token'}}, stage:'Profile「yaoer」绑定工具桥'},
-  {path:'/api/config', options:{search:new URLSearchParams({profile:'invalid\nprivate-value'})}, stage:'读取配置'},
-] satisfies Array<{path:string;options:UpstreamRequestOptions;stage:string}>)('identifies a timed-out $path without exposing request secrets',async({path,options,stage})=>{
+  {path:'/api/config', options:{search:new URLSearchParams({profile:'yaoer',token:'private-query'})}, stage:'Profile「yaoer」读取配置', seconds:30},
+  {path:'/api/plugins/yaoyao-bot-bridge/capabilities', options:{search:new URLSearchParams({profile:'default'})}, stage:'Profile「default」检查工具桥', seconds:30},
+  {path:'/api/plugins/yaoyao-bot-bridge/bind', options:{method:'POST',body:{profile:'yaoer',token:'private-bridge-token'}}, stage:'Profile「yaoer」绑定工具桥', seconds:60},
+  {path:'/api/config', options:{search:new URLSearchParams({profile:'invalid\nprivate-value'})}, stage:'读取配置', seconds:30},
+] satisfies Array<{path:string;options:UpstreamRequestOptions;stage:string;seconds:number}>)('identifies a timed-out $path without exposing request secrets',async({path,options,stage,seconds})=>{
   vi.useFakeTimers()
   const fetchImpl=vi.fn<typeof fetch>((_url,init)=>new Promise((_resolve,reject)=>{
     init?.signal?.addEventListener('abort',()=>reject(new Error('aborted')),{once:true})
@@ -42,34 +42,40 @@ it.each([
   const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),fetchImpl)
   try{
     const failed=expect(client.request(path,new CookieJar(),options)).rejects.toMatchObject({
-      code:'upstream_unavailable',message:`Hermes 请求超时(30 秒):${stage}。`,
+      code:'upstream_unavailable',message:`Hermes 请求超时(${seconds} 秒):${stage}。`,
     })
-    await vi.advanceTimersByTimeAsync(30001);await failed
+    await vi.advanceTimersByTimeAsync(seconds*1000+1);await failed
     expect(fetchImpl).toHaveBeenCalledTimes(1)
   }finally{client.close();vi.clearAllTimers();vi.useRealTimers()}
 })
 
-it('identifies the Profile when Hermes returns headers but the response body stalls',async()=>{
+it.each([
+  {path:'/api/config',options:{search:new URLSearchParams({profile:'yaoer'})},stage:'读取配置',seconds:30,headersDelay:0},
+  {path:'/api/plugins/yaoyao-bot-bridge/bind',options:{method:'POST',body:{profile:'yaoer'}},stage:'绑定工具桥',seconds:60,headersDelay:0},
+  {path:'/api/plugins/yaoyao-bot-bridge/bind',options:{method:'POST',body:{profile:'yaoer'}},stage:'绑定工具桥',seconds:60,headersDelay:40_000},
+] satisfies Array<{path:string;options:UpstreamRequestOptions;stage:string;seconds:number;headersDelay:number}>)('identifies a stalled $path body within its deadline (headers after $headersDelay ms)',async({path,options,stage,seconds,headersDelay})=>{
   vi.useFakeTimers()
   const cancel=vi.fn(),stream=new ReadableStream({cancel})
-  const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),vi.fn<typeof fetch>(async()=>new Response(stream)))
+  const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),vi.fn<typeof fetch>(()=>new Promise(resolve=>setTimeout(()=>resolve(new Response(stream)),headersDelay))))
   try{
-    const failed=expect(client.request('/api/config',new CookieJar(),{search:new URLSearchParams({profile:'yaoer'})})).rejects.toMatchObject({
-      code:'upstream_unavailable',message:'Hermes 请求超时(30 秒):Profile「yaoer」读取配置。',
+    const failed=expect(client.request(path,new CookieJar(),options)).rejects.toMatchObject({
+      code:'upstream_unavailable',message:`Hermes 请求超时(${seconds} 秒):Profile「yaoer」${stage}。`,
     })
-    await vi.advanceTimersByTimeAsync(30001);await failed
+    await vi.advanceTimersByTimeAsync(seconds*1000-1)
+    expect(cancel).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(2);await failed
     expect(cancel).toHaveBeenCalledOnce()
   }finally{client.close();vi.clearAllTimers();vi.useRealTimers()}
 })
 
-it('allows the Hermes memory helper to finish within its 90-second budget without changing normal request deadlines',async()=>{
+it.each(['bind','memory-extract'])('allows a slow Hermes %s request without changing normal request deadlines',async endpoint=>{
   vi.useFakeTimers()
   const fetchImpl=vi.fn<typeof fetch>(()=>new Promise(done=>setTimeout(()=>done(new Response('{"text":"memory"}')),40000)))
   const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),fetchImpl)
   try{
     const normal=client.request('/api/status',new CookieJar()),rejected=expect(normal).rejects.toThrow('request timed out')
     await vi.advanceTimersByTimeAsync(30001);await rejected
-    const memory=client.request('/api/plugins/yaoyao-bot-bridge/memory-extract',new CookieJar(),{method:'POST',body:{profile:'default',prompt:'facts'}})
+    const memory=client.request(`/api/plugins/yaoyao-bot-bridge/${endpoint}`,new CookieJar(),{method:'POST',body:{profile:'default',prompt:'facts'}})
     await vi.advanceTimersByTimeAsync(40001)
     expect(JSON.parse((await memory).body.toString())).toEqual({text:'memory'})
   }finally{client.close();vi.clearAllTimers();vi.useRealTimers()}