From 557908ca44af08b813370638dffae0281f61e86d Mon Sep 17 00:00:00 2001 From: wujunchen Date: Sun, 26 Apr 2026 18:29:48 +0800 Subject: [PATCH] fix: clean up streaming abort listener Change-Id: I9fa8ace7bc0fa723466ef851f7ec2960c6638eba --- main.js | 2 +- src/streaming.ts | 5 ++- tests/direct-modules.test.js | 87 ++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index bcebdbf..e8367b5 100644 --- a/main.js +++ b/main.js @@ -97,7 +97,7 @@ ${o}`;return{system:g,user:b}}function Xe(r){if(typeof r=="string")return r;if(A `)?i.slice(0,-1):i;for(let o of s){let c=[];for(let l of o.split(` `)){if(!l.startsWith("data:"))continue;let p=l.slice(l.startsWith("data: ")?6:5);c.push(p)}if(c.length===0)continue;let u=c.join(` -`);if(u.trim()!=="[DONE]")try{let l=JSON.parse(u),p=t(l);p&&e.push(p)}catch{}}return{deltas:e,rest:a}}async function er(r,t,e,n,i,a,s){let o=await globalThis.fetch(r,{method:"POST",headers:t,body:JSON.stringify(e),signal:a});if(!o.ok){let f=await o.text();throw new Error(w(s||null,"errorProviderApiStatus",{label:"Streaming",status:o.status,excerpt:f.slice(0,500)}))}if(!o.body)throw new Error("Response has no body for streaming");let c=o.body.getReader(),u=new TextDecoder,l="",p="";try{for(;;){let{done:f,value:h}=await c.read();if(f)break;p+=u.decode(h,{stream:!0});let g=Ze(p,n);p=g.rest;for(let b of g.deltas)l+=b;g.deltas.length>0&&i?.({accumulated:l,done:!1})}}finally{c.releaseLock()}return i?.({accumulated:l,done:!0}),l}async function Qe(r,t,e,n,i,a,s){if(typeof globalThis.fetch!="function")throw new Error("Streaming requires fetch API");let o=s?.streamingTimeoutMs??12e4,c=new AbortController;a&&(a.aborted?c.abort():a.addEventListener("abort",()=>c.abort(),{once:!0}));let u=null,l=new Promise((p,f)=>{u=setTimeout(()=>{c.abort(),f(new Error(`Streaming timed out after ${o}ms`))},o)});try{return await Promise.race([er(r,t,e,n,i,c.signal,s),l])}finally{u!==null&&clearTimeout(u)}}function Et(r){let t=Q(r);if(!t)throw new Error(`Streaming not supported for format: ${r}`);return t}function re(r,t){let e=r.replace(/\/+$/,"");for(let n of t)if(e.endsWith(n))return e;return e+t[0]}function tr(r,t){let e=(r||"").trim();if(!e)return{};if(e.startsWith("{")){let i;try{i=JSON.parse(e)}catch(s){throw new Error(w(t||null,"errorCustomHeadersJsonParse",{error:s.message}))}if(!i||typeof i!="object"||Array.isArray(i))throw new Error(w(t||null,"errorCustomHeadersJsonObject"));let a={};for(let[s,o]of Object.entries(i))typeof o=="string"&&s.trim()&&(a[s.trim()]=o);return a}let n={};for(let i of e.split(/\r?\n/)){let a=i.trim();if(!a||a.startsWith("#"))continue;let s=a.indexOf(":");if(s<=0)throw new Error(w(t||null,"errorCustomHeadersLineFormat"));let o=a.slice(0,s).trim(),c=a.slice(s+1).trim();o&&(n[o]=c)}return n}function rr(r){let t=Le(r);if(t==="none")return{};let e=ht(r);if(!e){let n=(r.apiKeyEnvVar||x(r).envVar||"").trim(),i=n?w(r,"errorApiKeyEnvHint",{envVar:n}):"";throw new Error(w(r,"errorApiKeyMissing",{hint:i}))}return t==="bearer"?{authorization:`Bearer ${e}`}:t==="x-api-key"?{"x-api-key":e}:t==="x-goog-api-key"?{"x-goog-api-key":e}:t==="api-key"?{"api-key":e}:{authorization:`Bearer ${e}`}}function H(r,t){return{"content-type":"application/json",...rr(r),...t||{},...tr(r.apiHeaders,r)}}function nr(r,t,e){if(r.json&&typeof r.json=="object")return r.json;try{return JSON.parse(r.text||"{}")}catch{throw new Error(w(e||null,"errorProviderNonJson",{label:t,excerpt:(r.text||"").slice(0,500)}))}}async function kt(r,t,e,n,i,a){let s;try{s=await r({url:e,method:"POST",headers:n,body:JSON.stringify(i),throw:!1})}catch(o){throw new Error(w(a||null,"errorProviderRequestFailed",{label:t,error:o.message||String(o)}))}if(s.status>=400)throw new Error(w(a||null,"errorProviderApiStatus",{label:t,status:s.status,excerpt:(s.text||"").slice(0,500)}));return nr(s,t,a)}function ir(r){let t=String(r?.message??r);return/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(t)?/response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(t):!1}async function xe(r,t,e,n,i,a,s){try{return await kt(r,t,e,n,i,s)}catch(o){if(!a||!ir(o))throw o;return console.warn(`[parallel-reader] ${t} structured output rejected; retrying without structured output`,o),kt(r,t+" fallback",e,n,a,s)}}function et(r){let t=x(r),e=T[P(r)];return t.tokenLimitField||e?.tokenLimitField||"max_tokens"}function ee(r,t,e,n){let i=!n||n.structured!==!1,a={model:I(e),max_tokens:Number(e.apiMaxTokens)||4096,system:r,messages:[{role:"user",content:t}]};return i&&(a.tools=[yt()],a.tool_choice={type:"tool",name:Y}),a}function te(r,t,e,n){let i=!n||n.structured!==!1,a={model:I(e),messages:[{role:"system",content:r},{role:"user",content:t}]};return a[et(e)]=Number(e.apiMaxTokens)||4096,i&&(a.response_format=gt()),a}function Ce(r,t,e,n){let i=!n||n.structured!==!1,a={model:I(e),instructions:r,input:t,max_output_tokens:Number(e.apiMaxTokens)||4096};return i&&(a.text=ft()),a}function ve(r,t,e,n){let i=!n||n.structured!==!1,a={temperature:0,maxOutputTokens:Number(e.apiMaxTokens)||4096};return i&&(a.responseMimeType="application/json",a.responseJsonSchema=Z(!1)),{systemInstruction:{parts:[{text:r}]},contents:[{role:"user",parts:[{text:t}]}],generationConfig:a}}async function ar(r,t,e,n){let i=re(F(n),["/messages"]),a=await xe(r,"Anthropic-compatible",i,H(n,{"anthropic-version":"2023-06-01"}),ee(t,e,n),ee(t,e,n,{structured:!1}),n),s=At(a,n);return s||S(Pt(a),n)}async function sr(r,t,e,n){let i=re(F(n),["/chat/completions"]),a=await xe(r,"OpenAI-compatible Chat",i,H(n),te(t,e,n),te(t,e,n,{structured:!1}),n);return S(St(a),n)}async function or(r,t,e,n){let i=re(F(n),["/responses"]),a=await xe(r,"OpenAI Responses",i,H(n),Ce(t,e,n),Ce(t,e,n,{structured:!1}),n);return S(xt(a).trim(),n)}async function cr(r,t,e,n){let i=encodeURIComponent(I(n)),a=F(n);/:generateContent(?:\?|$)/.test(a)||(a=`${a.replace(/\/+$/,"")}/models/${i}:generateContent`);let s=H(n),o=await xe(r,"Google Gemini",a,s,ve(t,e,n),ve(t,e,n,{structured:!1}),n);return S(Tt(o),n)}async function ne(r,t,e,n){switch(P(n)){case"openai-chat":return sr(r,t,e,n);case"openai-responses":return or(r,t,e,n);case"google-generative-ai":return cr(r,t,e,n);default:return ar(r,t,e,n)}}function Ae(r){if(!r.streaming)return!1;let t=P(r);return!!Q(t)}async function lr(r,t,e,n,i){let a=re(F(e),["/chat/completions"]),s=H(e),o=te(r,t,e,{structured:!1});o.stream=!0;let c=Et("openai-chat"),u=await Qe(a,s,o,c,n,i,e);return S(u.trim(),e)}async function ur(r,t,e,n,i){let a=re(F(e),["/messages"]),s=H(e,{"anthropic-version":"2023-06-01"}),o=ee(r,t,e,{structured:!1});o.stream=!0;let c=Et("anthropic-messages"),u=await Qe(a,s,o,c,n,i,e);return S(u.trim(),e)}async function Ft(r,t,e,n,i){let a=P(e);switch(a){case"openai-chat":return lr(r,t,e,n,i);case"anthropic-messages":return ur(r,t,e,n,i);default:throw new Error(`Streaming not supported for format: ${a}`)}}async function Rt(r,t){await ne(r,'\u53EA\u8F93\u51FA JSON\uFF1A{"cards":[]}','\u8FDE\u901A\u6027\u6D4B\u8BD5\uFF1A\u8BF7\u539F\u6837\u8F93\u51FA {"cards":[]}',t);let e=P(t);return`${x(t).label} / ${T[e].label}`}async function tt(r,t,e,n){let{system:i,user:a}=we(r,t),s;switch(t.backend){case"claude-code":s=await Ct(i,a,t,e);break;case"codex":s=await vt(i,a,t,e);break;default:{if(Ae(t)){let u=new AbortController;e.onCancel(()=>u.abort()),s=await Ft(i,a,t,n,u.signal)}else s=await ne(Mt.requestUrl,i,a,t);break}}let o=s.map(c=>({title:c.title,level:2,anchor:c.anchor,gist:c.gist,startLine:J(r,c.anchor),bullets:c.bullets}));return o.sort((c,u)=>c.startLine<0&&u.startLine<0?0:c.startLine<0?1:u.startLine<0?-1:c.startLine-u.startLine),o}function rt(r,t){return t?.phase==="generating"&&r&&De(r.backend)?"cancelRequestedApiInFlight":"cancelRequested"}function nt(r){let t=[`## ${r.title}`];if(r.anchor){let e=r.anchor.replace(/\s+/g," ").trim();t.push(`> ${e}`)}return r.gist&&t.push(r.gist),r.bullets&&r.bullets.length>0&&t.push(r.bullets.map(e=>`- ${e}`).join(` +`);if(u.trim()!=="[DONE]")try{let l=JSON.parse(u),p=t(l);p&&e.push(p)}catch{}}return{deltas:e,rest:a}}async function er(r,t,e,n,i,a,s){let o=await globalThis.fetch(r,{method:"POST",headers:t,body:JSON.stringify(e),signal:a});if(!o.ok){let f=await o.text();throw new Error(w(s||null,"errorProviderApiStatus",{label:"Streaming",status:o.status,excerpt:f.slice(0,500)}))}if(!o.body)throw new Error("Response has no body for streaming");let c=o.body.getReader(),u=new TextDecoder,l="",p="";try{for(;;){let{done:f,value:h}=await c.read();if(f)break;p+=u.decode(h,{stream:!0});let g=Ze(p,n);p=g.rest;for(let b of g.deltas)l+=b;g.deltas.length>0&&i?.({accumulated:l,done:!1})}}finally{c.releaseLock()}return i?.({accumulated:l,done:!0}),l}async function Qe(r,t,e,n,i,a,s){if(typeof globalThis.fetch!="function")throw new Error("Streaming requires fetch API");let o=s?.streamingTimeoutMs??12e4,c=new AbortController,u=null;a&&(a.aborted?c.abort():(u=()=>c.abort(),a.addEventListener("abort",u,{once:!0})));let l=null,p=new Promise((f,h)=>{l=setTimeout(()=>{c.abort(),h(new Error(`Streaming timed out after ${o}ms`))},o)});try{return await Promise.race([er(r,t,e,n,i,c.signal,s),p])}finally{l!==null&&clearTimeout(l),a&&u&&a.removeEventListener("abort",u)}}function Et(r){let t=Q(r);if(!t)throw new Error(`Streaming not supported for format: ${r}`);return t}function re(r,t){let e=r.replace(/\/+$/,"");for(let n of t)if(e.endsWith(n))return e;return e+t[0]}function tr(r,t){let e=(r||"").trim();if(!e)return{};if(e.startsWith("{")){let i;try{i=JSON.parse(e)}catch(s){throw new Error(w(t||null,"errorCustomHeadersJsonParse",{error:s.message}))}if(!i||typeof i!="object"||Array.isArray(i))throw new Error(w(t||null,"errorCustomHeadersJsonObject"));let a={};for(let[s,o]of Object.entries(i))typeof o=="string"&&s.trim()&&(a[s.trim()]=o);return a}let n={};for(let i of e.split(/\r?\n/)){let a=i.trim();if(!a||a.startsWith("#"))continue;let s=a.indexOf(":");if(s<=0)throw new Error(w(t||null,"errorCustomHeadersLineFormat"));let o=a.slice(0,s).trim(),c=a.slice(s+1).trim();o&&(n[o]=c)}return n}function rr(r){let t=Le(r);if(t==="none")return{};let e=ht(r);if(!e){let n=(r.apiKeyEnvVar||x(r).envVar||"").trim(),i=n?w(r,"errorApiKeyEnvHint",{envVar:n}):"";throw new Error(w(r,"errorApiKeyMissing",{hint:i}))}return t==="bearer"?{authorization:`Bearer ${e}`}:t==="x-api-key"?{"x-api-key":e}:t==="x-goog-api-key"?{"x-goog-api-key":e}:t==="api-key"?{"api-key":e}:{authorization:`Bearer ${e}`}}function H(r,t){return{"content-type":"application/json",...rr(r),...t||{},...tr(r.apiHeaders,r)}}function nr(r,t,e){if(r.json&&typeof r.json=="object")return r.json;try{return JSON.parse(r.text||"{}")}catch{throw new Error(w(e||null,"errorProviderNonJson",{label:t,excerpt:(r.text||"").slice(0,500)}))}}async function kt(r,t,e,n,i,a){let s;try{s=await r({url:e,method:"POST",headers:n,body:JSON.stringify(i),throw:!1})}catch(o){throw new Error(w(a||null,"errorProviderRequestFailed",{label:t,error:o.message||String(o)}))}if(s.status>=400)throw new Error(w(a||null,"errorProviderApiStatus",{label:t,status:s.status,excerpt:(s.text||"").slice(0,500)}));return nr(s,t,a)}function ir(r){let t=String(r?.message??r);return/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(t)?/response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(t):!1}async function xe(r,t,e,n,i,a,s){try{return await kt(r,t,e,n,i,s)}catch(o){if(!a||!ir(o))throw o;return console.warn(`[parallel-reader] ${t} structured output rejected; retrying without structured output`,o),kt(r,t+" fallback",e,n,a,s)}}function et(r){let t=x(r),e=T[P(r)];return t.tokenLimitField||e?.tokenLimitField||"max_tokens"}function ee(r,t,e,n){let i=!n||n.structured!==!1,a={model:I(e),max_tokens:Number(e.apiMaxTokens)||4096,system:r,messages:[{role:"user",content:t}]};return i&&(a.tools=[yt()],a.tool_choice={type:"tool",name:Y}),a}function te(r,t,e,n){let i=!n||n.structured!==!1,a={model:I(e),messages:[{role:"system",content:r},{role:"user",content:t}]};return a[et(e)]=Number(e.apiMaxTokens)||4096,i&&(a.response_format=gt()),a}function Ce(r,t,e,n){let i=!n||n.structured!==!1,a={model:I(e),instructions:r,input:t,max_output_tokens:Number(e.apiMaxTokens)||4096};return i&&(a.text=ft()),a}function ve(r,t,e,n){let i=!n||n.structured!==!1,a={temperature:0,maxOutputTokens:Number(e.apiMaxTokens)||4096};return i&&(a.responseMimeType="application/json",a.responseJsonSchema=Z(!1)),{systemInstruction:{parts:[{text:r}]},contents:[{role:"user",parts:[{text:t}]}],generationConfig:a}}async function ar(r,t,e,n){let i=re(F(n),["/messages"]),a=await xe(r,"Anthropic-compatible",i,H(n,{"anthropic-version":"2023-06-01"}),ee(t,e,n),ee(t,e,n,{structured:!1}),n),s=At(a,n);return s||S(Pt(a),n)}async function sr(r,t,e,n){let i=re(F(n),["/chat/completions"]),a=await xe(r,"OpenAI-compatible Chat",i,H(n),te(t,e,n),te(t,e,n,{structured:!1}),n);return S(St(a),n)}async function or(r,t,e,n){let i=re(F(n),["/responses"]),a=await xe(r,"OpenAI Responses",i,H(n),Ce(t,e,n),Ce(t,e,n,{structured:!1}),n);return S(xt(a).trim(),n)}async function cr(r,t,e,n){let i=encodeURIComponent(I(n)),a=F(n);/:generateContent(?:\?|$)/.test(a)||(a=`${a.replace(/\/+$/,"")}/models/${i}:generateContent`);let s=H(n),o=await xe(r,"Google Gemini",a,s,ve(t,e,n),ve(t,e,n,{structured:!1}),n);return S(Tt(o),n)}async function ne(r,t,e,n){switch(P(n)){case"openai-chat":return sr(r,t,e,n);case"openai-responses":return or(r,t,e,n);case"google-generative-ai":return cr(r,t,e,n);default:return ar(r,t,e,n)}}function Ae(r){if(!r.streaming)return!1;let t=P(r);return!!Q(t)}async function lr(r,t,e,n,i){let a=re(F(e),["/chat/completions"]),s=H(e),o=te(r,t,e,{structured:!1});o.stream=!0;let c=Et("openai-chat"),u=await Qe(a,s,o,c,n,i,e);return S(u.trim(),e)}async function ur(r,t,e,n,i){let a=re(F(e),["/messages"]),s=H(e,{"anthropic-version":"2023-06-01"}),o=ee(r,t,e,{structured:!1});o.stream=!0;let c=Et("anthropic-messages"),u=await Qe(a,s,o,c,n,i,e);return S(u.trim(),e)}async function Ft(r,t,e,n,i){let a=P(e);switch(a){case"openai-chat":return lr(r,t,e,n,i);case"anthropic-messages":return ur(r,t,e,n,i);default:throw new Error(`Streaming not supported for format: ${a}`)}}async function Rt(r,t){await ne(r,'\u53EA\u8F93\u51FA JSON\uFF1A{"cards":[]}','\u8FDE\u901A\u6027\u6D4B\u8BD5\uFF1A\u8BF7\u539F\u6837\u8F93\u51FA {"cards":[]}',t);let e=P(t);return`${x(t).label} / ${T[e].label}`}async function tt(r,t,e,n){let{system:i,user:a}=we(r,t),s;switch(t.backend){case"claude-code":s=await Ct(i,a,t,e);break;case"codex":s=await vt(i,a,t,e);break;default:{if(Ae(t)){let u=new AbortController;e.onCancel(()=>u.abort()),s=await Ft(i,a,t,n,u.signal)}else s=await ne(Mt.requestUrl,i,a,t);break}}let o=s.map(c=>({title:c.title,level:2,anchor:c.anchor,gist:c.gist,startLine:J(r,c.anchor),bullets:c.bullets}));return o.sort((c,u)=>c.startLine<0&&u.startLine<0?0:c.startLine<0?1:u.startLine<0?-1:c.startLine-u.startLine),o}function rt(r,t){return t?.phase==="generating"&&r&&De(r.backend)?"cancelRequestedApiInFlight":"cancelRequested"}function nt(r){let t=[`## ${r.title}`];if(r.anchor){let e=r.anchor.replace(/\s+/g," ").trim();t.push(`> ${e}`)}return r.gist&&t.push(r.gist),r.bullets&&r.bullets.length>0&&t.push(r.bullets.map(e=>`- ${e}`).join(` `)),t.join(` `)}function Nt(r){return[r.title,r.gist||"",...(r.bullets||[]).map(t=>"\u2022 "+t)].filter(Boolean).join(` diff --git a/src/streaming.ts b/src/streaming.ts index 34e2331..6c9a9da 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -156,12 +156,14 @@ export async function streamingFetch( const timeoutMs = settings?.streamingTimeoutMs ?? 120000; const timeoutController = new AbortController(); + let abortListener: (() => void) | null = null; if (signal) { if (signal.aborted) { timeoutController.abort(); } else { - signal.addEventListener('abort', () => timeoutController.abort(), { once: true }); + abortListener = () => timeoutController.abort(); + signal.addEventListener('abort', abortListener, { once: true }); } } @@ -180,5 +182,6 @@ export async function streamingFetch( ]); } finally { if (timeoutId !== null) clearTimeout(timeoutId); + if (signal && abortListener) signal.removeEventListener('abort', abortListener); } } diff --git a/tests/direct-modules.test.js b/tests/direct-modules.test.js index 9df3a78..8d4d9c1 100644 --- a/tests/direct-modules.test.js +++ b/tests/direct-modules.test.js @@ -38,6 +38,7 @@ async function requireBundledModule(relativePath) { const generation = await requireBundledModule('src/generation.ts'); const providerParsers = await requireBundledModule('src/provider-parsers.ts'); const settings = await requireBundledModule('src/settings.ts'); + const streaming = await requireBundledModule('src/streaming.ts'); const cacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' }; const touched = cache.touchCacheEntry(cacheEntry, '2024-06-01T00:00:00.000Z'); @@ -95,6 +96,92 @@ async function requireBundledModule(relativePath) { 'direct settings import exposes generation fingerprinting', ); + function trackedSignal() { + const controller = new AbortController(); + const signal = controller.signal; + let activeListeners = 0; + const addEventListener = signal.addEventListener.bind(signal); + const removeEventListener = signal.removeEventListener.bind(signal); + signal.addEventListener = (type, listener, options) => { + if (type === 'abort') activeListeners++; + return addEventListener(type, listener, options); + }; + signal.removeEventListener = (type, listener, options) => { + if (type === 'abort') activeListeners--; + return removeEventListener(type, listener, options); + }; + return { controller, signal, activeListeners: () => activeListeners }; + } + + function streamingBody(text) { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); + } + + const originalFetch = globalThis.fetch; + try { + const success = trackedSignal(); + globalThis.fetch = async () => ({ + ok: true, + status: 200, + body: streamingBody('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n'), + text: async () => '', + }); + await streaming.streamingFetch( + 'https://example.test', + {}, + {}, + streaming.deltaExtractorForFormat('openai-chat'), + undefined, + success.signal, + { streamingTimeoutMs: 1000 }, + ); + assert.strictEqual(success.activeListeners(), 0, 'streamingFetch removes abort listener after success'); + + const httpError = trackedSignal(); + globalThis.fetch = async () => ({ ok: false, status: 500, body: null, text: async () => 'bad' }); + await assert.rejects( + () => + streaming.streamingFetch( + 'https://example.test', + {}, + {}, + streaming.deltaExtractorForFormat('openai-chat'), + undefined, + httpError.signal, + { streamingTimeoutMs: 1000 }, + ), + /HTTP 500|API returned HTTP 500/, + 'streamingFetch rejects HTTP errors', + ); + assert.strictEqual(httpError.activeListeners(), 0, 'streamingFetch removes abort listener after HTTP error'); + + const timeout = trackedSignal(); + globalThis.fetch = async () => new Promise(() => {}); + await assert.rejects( + () => + streaming.streamingFetch( + 'https://example.test', + {}, + {}, + streaming.deltaExtractorForFormat('openai-chat'), + undefined, + timeout.signal, + { streamingTimeoutMs: 1 }, + ), + /Streaming timed out after 1ms/, + 'streamingFetch rejects on timeout', + ); + assert.strictEqual(timeout.activeListeners(), 0, 'streamingFetch removes abort listener after timeout'); + } finally { + globalThis.fetch = originalFetch; + } + console.log('direct module tests passed'); } finally { fs.rmSync(tempDir, { recursive: true, force: true });