diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 21718d2..a4b366a 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -535,3 +535,13 @@ Sinyal soru/denetim/taslak çağrılarından AI sağlayıcı fetch'lerine taşı Testler: gerçek yerel HTTP isteğinde gövde tamamlandıktan sonra bağlantıyı keserek iptal; sağlayıcı fetch'ini iptal edip tek çağrı ve sıfır yedek deneme; sonraki denetim gruplarının durması; görünüm iptalinde istemci fetch'inin sonlanması. Sağlayıcı yanıtları ağ dışına çıkmayan test taklididir. Tüm 118 test ve üretim derlemesi geçti. Sınırlar: İlan/doküman kaynak ediniminin kendi alt istekleri ve OCR bu iptal zincirinin tamamına henüz bağlı değil. Kalıcı iş kuyruğu, yeniden başlatmada sürdürme ve tarayıcıdan uzun başarılı denetim kabulü açık. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı. + +### D06 — PDF / OCR iptal desteği + +Belge okuyucuya isteğe bağlı AbortSignal eklendi. İptal bekleyen dosya metni, PDF açılışı, sayfa edinimi/metni ve OCR sonucunu beklemeyi keser; sonraki sayfalara geçilmez. PDF yükleme görevi, etkin çizim ve OCR çalışanı iptal/sonlanma yolunda temizlenir. Çalışan oluşturulması veya sayfa edinimi iptalden sonra sonuçlanırsa geç gelen kaynak kapatılır. İptal hatası normal bozuk dosya/OCR hatasına çevrilmez. + +Teklif Asistanı dosya okuması görünümün yaşam süresi sinyalini kullanır. Böylece modal/sekme bileşeni kapanınca yalnız sonuç atılmakla kalmaz, okuyucu da iptal edilir. Diğer belge okuma girişlerinin tamamı henüz bu sinyali iletmiyor; uygulama genelinde OCR iptali tamamlandı sayılmaz. + +Testler: bekleyen OCR sırasında iptalde tek sayfa ve temizlik; geciken sayfa ediniminde temizlik; geç sonuçta iptal callback'inin ikinci kez çağrılmaması; normal tamamlanma sonrasında dinleyici temizliği. Mevcut gerçek 45 sayfalık PDF okuma testi de geçti. Gerçek tarayıcıda Tesseract çalışanı sırasında iptal kabulü bu turda yapılmadı. + +Sonuç: Tüm 122 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın kalan kapsamı açık; canlı yayın yapılmadı. diff --git a/src/components/TeklifAsistani.jsx b/src/components/TeklifAsistani.jsx index 2f28afe..1c4b8b7 100644 --- a/src/components/TeklifAsistani.jsx +++ b/src/components/TeklifAsistani.jsx @@ -70,7 +70,7 @@ const OfferAssistantSession = ({ tender }) => { return calistir(async () => { const { extractText } = await import('../services/pdfText'); if (!current()) return; - const r = await extractText(f); + const r = await extractText(f,{signal:lifetime.current.signal}); if (typeof r.text !== 'string' || !r.text.trim()) throw new Error('Dosyada okunabilir metin bulunamadı. Önceki kaynak değiştirilmedi. Başka bir dosya seçin.'); if (current()) { setMetin(r.text); setSourceName(f.name); setCevap(null); setDenetim(null); } }); diff --git a/src/services/abortable.js b/src/services/abortable.js new file mode 100644 index 0000000..d053025 --- /dev/null +++ b/src/services/abortable.js @@ -0,0 +1,23 @@ +/** Stop awaiting work on cancellation; retain handlers for late settlement. */ +export function awaitWithAbort(work, signal, cancel) { + if (!signal) return Promise.resolve(work); + return new Promise((resolve,reject)=>{ + let settled=false; + const cleanup=()=>signal.removeEventListener('abort',abort); + const abort=()=>{ + if(settled)return; + settled=true;cleanup(); + try {Promise.resolve(cancel?.()).catch(()=>{});} catch { /* cancellation is best effort */ } + reject(signal.reason || new DOMException('İşlem iptal edildi.','AbortError')); + }; + Promise.resolve(work).then(value=>{ + if(settled)return; + if(signal.aborted){abort();return;} + settled=true;cleanup();resolve(value); + },error=>{ + if(settled)return; + settled=true;cleanup();reject(signal.aborted ? signal.reason : error); + }); + if(signal.aborted)abort();else signal.addEventListener('abort',abort,{once:true}); + }); +} diff --git a/src/services/pdfDocumentReader.js b/src/services/pdfDocumentReader.js index 2a22b0b..51d8351 100644 --- a/src/services/pdfDocumentReader.js +++ b/src/services/pdfDocumentReader.js @@ -1,19 +1,23 @@ +import {awaitWithAbort} from './abortable.js'; /** Read every requested page in order, retaining text/OCR provenance per page. */ -export async function readPdfPages(doc, {maxPages=Infinity, ocrPage, forceOcr=false, onProgress}={}) { +export async function readPdfPages(doc, {maxPages=Infinity, ocrPage, forceOcr=false, onProgress, signal}={}) { if (!(maxPages === Infinity || (Number.isInteger(maxPages) && maxPages > 0))) throw new Error('Geçersiz sayfa sınırı.'); + signal?.throwIfAborted(); const pages = Math.min(doc.numPages,maxPages); const results=[]; for(let number=1;number<=pages;number++) { + signal?.throwIfAborted(); onProgress?.({sayfa:number,toplam:pages}); - const page=await doc.getPage(number); + const page=await awaitWithAbort(doc.getPage(number).then(page=>{if(signal?.aborted){page.cleanup?.();signal.throwIfAborted();}return page;}),signal); try { - const content=await page.getTextContent(); + const content=await awaitWithAbort(page.getTextContent(),signal); let text=content.items.map(item=>item.str + (item.hasEOL ? '\n' : ' ')).join('').trim(); let method='text'; if(ocrPage && (forceOcr || text.replace(/\s/g,'').length < 100)) { - try { text=await ocrPage(page); method='ocr'; } - catch { throw new Error(`${number}. sayfada OCR tamamlanamadı. Okunabilir belgeyle yeniden deneyin.`); } + try { text=await awaitWithAbort(ocrPage(page),signal); method='ocr'; } + catch { signal?.throwIfAborted(); throw new Error(`${number}. sayfada OCR tamamlanamadı. Okunabilir belgeyle yeniden deneyin.`); } } + signal?.throwIfAborted(); results.push({page:number,text,method}); } finally { page.cleanup?.(); } } diff --git a/src/services/pdfText.js b/src/services/pdfText.js index 019e6a5..163fe83 100644 --- a/src/services/pdfText.js +++ b/src/services/pdfText.js @@ -1,52 +1,71 @@ +import {awaitWithAbort} from './abortable.js'; import { readPdfPages } from './pdfDocumentReader.js'; -async function openPdf(file) { +async function openPdf(file,signal) { + signal?.throwIfAborted(); const pdfjs = await import('pdfjs-dist'); pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; - let task; + let task,destruction; + const destroy=()=>destruction ||= task?.destroy(); try { - task = pdfjs.getDocument({data:await file.arrayBuffer()}); - return {doc:await task.promise,task}; + const data=await awaitWithAbort(file.arrayBuffer(),signal); + signal?.throwIfAborted(); + task = pdfjs.getDocument({data}); + return {doc:await awaitWithAbort(task.promise,signal,destroy),task:{destroy}}; } catch(error) { - await task?.destroy(); + await destroy(); + signal?.throwIfAborted(); if(error.name === 'InvalidPDFException') throw new Error('PDF dosyası bozuk veya geçerli PDF biçiminde değil.'); if(error.name === 'PasswordException') throw new Error('PDF parola korumalı; okunabilir bir kopyasını yükleyin.'); throw new Error('PDF okunamadı. Dosyayı kontrol edip yeniden deneyin.'); } } -async function readPdf(file,{ocr=false,forceOcr=false,maxPages=Infinity,onProgress}={}) { - const {doc,task}=await openPdf(file); - let worker; +async function readPdf(file,{ocr=false,forceOcr=false,maxPages=Infinity,onProgress,signal}={}) { + const {doc,task}=await openPdf(file,signal); + let worker,termination,render; + const stopWorker=()=>termination ||= worker?.terminate(); + const cancel=()=>{render?.cancel();void Promise.resolve(stopWorker()).catch(()=>{});void Promise.resolve(task.destroy()).catch(()=>{});}; + signal?.addEventListener('abort',cancel,{once:true}); try { - const result=await readPdfPages(doc,{maxPages,forceOcr,onProgress,ocrPage:ocr ? async(page)=>{ + signal?.throwIfAborted(); + const result=await readPdfPages(doc,{maxPages,forceOcr,onProgress,signal,ocrPage:ocr ? async(page)=>{ if(!worker) { const {createWorker}=await import('tesseract.js'); - worker=await createWorker('tur'); + signal?.throwIfAborted(); + worker=await awaitWithAbort(createWorker('tur').then(created=>{ + if(signal?.aborted){void created.terminate().catch(()=>{});signal.throwIfAborted();} + return created; + }),signal); } const viewport=page.getViewport({scale:2}); const canvas=document.createElement('canvas'); canvas.width=viewport.width;canvas.height=viewport.height; try { - await page.render({canvasContext:canvas.getContext('2d'),viewport}).promise; - return (await worker.recognize(canvas)).data.text; - } finally { canvas.width=0;canvas.height=0; } + signal?.throwIfAborted(); + render=page.render({canvasContext:canvas.getContext('2d'),viewport}); + await awaitWithAbort(render.promise,signal,()=>render.cancel()); + signal?.throwIfAborted(); + return (await awaitWithAbort(worker.recognize(canvas),signal,stopWorker)).data.text; + } finally { render=null;canvas.width=0;canvas.height=0; } } : undefined}); return result; } finally { - try { if(worker) await worker.terminate(); } + signal?.removeEventListener('abort',cancel); + try { await stopWorker(); } finally { await task.destroy(); } } } export const extractPdfText=(file,options={})=>readPdf(file,{...options,ocr:false}); export const ocrPdfText=(file,options={})=>readPdf(file,{...options,ocr:true,forceOcr:true}); -export async function extractPlainText(file) { - return {text:(await file.text()).trim(),pageCount:1,usedPages:1,complete:true}; +export async function extractPlainText(file,{signal}={}) { + signal?.throwIfAborted(); + return {text:(await awaitWithAbort(file.text(),signal)).trim(),pageCount:1,usedPages:1,complete:true}; } -export async function extractText(file,{ocr=true,onProgress}={}) { +export async function extractText(file,{ocr=true,onProgress,signal}={}) { if((file.name || '').toLowerCase().endsWith('.pdf') || file.type==='application/pdf') - return readPdf(file,{ocr,onProgress}); - return extractPlainText(file); + return readPdf(file,{ocr,onProgress,signal}); + return extractPlainText(file,{signal}); } diff --git a/tests/pdf-cancellation.test.mjs b/tests/pdf-cancellation.test.mjs new file mode 100644 index 0000000..2ef67bb --- /dev/null +++ b/tests/pdf-cancellation.test.mjs @@ -0,0 +1,28 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {readPdfPages} from '../src/services/pdfDocumentReader.js'; +import {awaitWithAbort} from '../src/services/abortable.js'; +test('cancelling pending OCR cleans the page and prevents reading subsequent pages',async()=>{ + const c=new AbortController();let reads=0,cleanups=0; + const doc={numPages:3,getPage:async()=>{reads++;return {getTextContent:async()=>({items:[]}),cleanup:()=>cleanups++};}}; + await assert.rejects(readPdfPages(doc,{signal:c.signal,ocrPage:async()=>{c.abort();return new Promise(()=>{});}}),{name:'AbortError'}); + assert.equal(reads,1);assert.equal(cleanups,1); +}); +test('late page acquisition after cancellation cleans the acquired page',async()=>{ + const c=new AbortController();let release,cleanups=0; + const pending=readPdfPages({numPages:1,getPage:()=>new Promise(resolve=>release=resolve)},{signal:c.signal}); + c.abort();await assert.rejects(pending,{name:'AbortError'}); + release({cleanup:()=>cleanups++});await Promise.resolve();await Promise.resolve(); + assert.equal(cleanups,1); +}); +test('abort invokes resource cancellation once even if work settles later',async()=>{ + const c=new AbortController();let release,cancelled=0; + const pending=awaitWithAbort(new Promise(resolve=>release=resolve),c.signal,()=>cancelled++); + c.abort();await assert.rejects(pending,{name:'AbortError'}); + release('late');await Promise.resolve();assert.equal(cancelled,1); +}); +test('normal completion removes cancellation callbacks',async()=>{ + const c=new AbortController();let cancelled=0; + assert.equal(await awaitWithAbort(Promise.resolve('source'),c.signal,()=>cancelled++),'source'); + c.abort();assert.equal(cancelled,0); +});