diff --git a/docs/fig-extract-integration.md b/docs/fig-extract-integration.md index 3fcd8a3..325868b 100644 --- a/docs/fig-extract-integration.md +++ b/docs/fig-extract-integration.md @@ -104,13 +104,20 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); 5. `tab-figures.ts`에서 `error.name === 'FigRenderError'`를 분기해 "메모리가 부족했을 수 있어요 — 다시 시도해 주세요" 문구를 노출 (현재는 일반 실패 문구 + 재시도 버튼). -### 벤더링과 무관한 선재 결함 (지금도 유효) - -- **`tab-figures.ts`에 `AbortController` 배선이 없다.** §취소가 "호스트는 문서 교체 시 반드시 signal을 - abort해야 한다"고 요구하는데 `#scan()`은 `signal`을 넘기지 않는다. `setDocument`는 `#scanGeneration`을 - 올려 **결과만 버리고 작업은 안 멈춘다**. 문서를 빠르게 갈아타면 스캔 두 개가 동시에 돌아 크롭 세트가 - 두 벌 상주한다 — 백로그 B7이 기술한 메모리 압력을 호스트가 스스로 만들고 있다. - **엔진 버전과 무관하게 지금 v2.14.0에서도 유효한 결함**이라 벤더링을 기다릴 이유가 없다. +### 벤더링과 무관한 선재 결함 + +- ~~`tab-figures.ts`에 `AbortController` 배선이 없다~~ → **해소 (#34)**. `setDocument`가 진행 중인 + 스캔을 실제로 abort하고, `#scan`이 엔진에 `signal`을 넘긴다. 취소로 인한 거절은 정상 흐름이라 + 에러 UI를 띄우지 않는다(엔진이 던지는 이름에 기대지 않고 `signal.aborted`만 본다). + abort가 `setDocument`에만 있는 이유는 **문서 교체만이 진행 중인 스캔을 무효화하는 사건**이기 + 때문이다 — 재시도는 종료 상태 `'error'`에서만 진입하므로 그때 취소할 스캔이 없다. + - **취소는 협조적이므로 중첩이 0이 되는 것은 아니다**: 엔진은 페이지 경계의 `checkAborted()`와 + 진행 중 렌더의 `RenderTask.cancel()`에서만 멈춘다. 문서를 바꾼 뒤에도 나가는 스캔이 **약 1페이지 + 분량**(페이지 캔버스 1장 + 그 페이지까지의 크롭)을 더 들고 있을 수 있다. "스캔 두 개가 끝까지" + 대비 이득이 목적이고, 0이 목표가 아니다. +- **이전 `PDFDocumentProxy`를 `destroy()`하지 않는다** (#35). `PdfHost.#setDocument`가 `#doc`을 + 덮어쓸 뿐이라 한 세션에서 문서를 N번 열면 N개가 상주한다. **#34보다 큰 압력원**이다(문서를 열수록 + 단조 증가). v2.14.0에서는 크롭이 살아 있는 캔버스로 유지되므로 위 B7 실패에 직접 기여한다. ## 주의사항 @@ -128,6 +135,9 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); 문서 교체 시 이전 스캔 중단에 사용 (#12). v2.5.1+: abort 시 진행 중 페이지 렌더도 `RenderTask.cancel()`로 즉시 중단 — 페이지 경계까지 기다리지 않는다. **호스트는 문서 교체 시 반드시 signal을 abort해야 한다** (엔진은 메커니즘만 제공 — signal 미전달 시 스캔이 끝까지 진행됨). + → Margin 측 배선 완료(#34): `FiguresTab.setDocument()`가 이전 스캔을 abort하고 `#scan`이 `signal`을 + 전달한다. **취소는 정상 흐름이므로 소비자는 `AbortError`를 에러 UI로 취급하지 말 것** — 문서를 + 바꿀 때마다 실패 메시지가 번쩍인다. - **크롭 이미지 수명/메모리** (#12 → 엔진 백로그 B7, **v2.19.1에서 재설계 · `[BREAKING]`**): 크롭은 이제 스캔 중 **PNG로 즉시 직렬화**되고 캔버스는 그 자리에서 반환된다. `figure.cropCanvas` 필드와 `cropCanvas()` 접근자는 **제거**됐고, 이미지는 `cropDataURL(fig)` / `cropBlob(fig)`로만 받는다 diff --git a/src/viewer/panel/tab-figures.ts b/src/viewer/panel/tab-figures.ts index 884f431..5bb4deb 100644 --- a/src/viewer/panel/tab-figures.ts +++ b/src/viewer/panel/tab-figures.ts @@ -19,6 +19,8 @@ export class FiguresTab { #state: 'idle' | 'scanning' | 'done' | 'error' = 'idle'; #figures: EngineFigure[] = []; #scanGeneration = 0; + /** 진행 중인 스캔의 취소 핸들 — 문서가 바뀔 때만 abort한다 (#34) */ + #scanAbort: AbortController | null = null; constructor( list: HTMLElement, @@ -43,6 +45,14 @@ export class FiguresTab { setDocument(doc: PDFDocumentProxy | null): void { this.#scanGeneration += 1; + /* generation만 올리면 이전 스캔의 **결과만** 버려지고 작업은 끝까지 돈다 — 문서를 빠르게 + * 갈아타면 스캔이 중첩돼 크롭 세트가 두 벌 상주한다. 통합 규약 §취소가 요구하는 대로 + * 실제로 중단시킨다 (#34). + * abort가 여기에만 있는 이유: **문서 교체만이 진행 중인 스캔을 무효화하는 사건**이다. + * 재시도(ensureScanned)는 종료 상태인 'error'에서만 진입 가능하므로 그때 in-flight 스캔은 + * 없다 — 취소할 대상 자체가 없다. */ + this.#scanAbort?.abort(); + this.#scanAbort = null; this.#doc = doc; this.#state = 'idle'; this.#figures = []; @@ -54,29 +64,46 @@ export class FiguresTab { ensureScanned(): void { if ((this.#state !== 'idle' && this.#state !== 'error') || !this.#doc) return; this.#state = 'scanning'; - void this.#scan(this.#scanGeneration); + const abort = new AbortController(); + this.#scanAbort = abort; + void this.#scan(this.#scanGeneration, abort); } - async #scan(scanGeneration: number): Promise { - const doc = this.#doc; - if (!doc) return; - this.#setStatus('figure 스캔 중…'); + async #scan(scanGeneration: number, abort: AbortController): Promise { + /* 취소·완료 어느 쪽으로 끝나도 #scanAbort를 정리해야 하므로 조기 반환도 try 안에 둔다. + * 밖에 두면 여기서 반환할 때 #scanAbort가 끝나지 않는 스캔을 계속 가리키고 #state가 + * 'scanning'에 갇혀 재시도도 취소도 불가능해진다 (현재는 도달 불가하지만, 이 불변식은 + * ensureScanned의 !this.#doc 가드에만 의존하게 두지 않는다). */ try { + const doc = this.#doc; + if (!doc) return; + this.#setStatus('figure 스캔 중…'); const result = await this.#engine.extract(null, { pdfDocument: doc, + signal: abort.signal, onProgress: (msg) => { if (this.#scanGeneration === scanGeneration) this.#setStatus(msg); } }); + /* 취소된 스캔의 결과는 후임 문서의 탭에 그려져선 안 된다. 지금은 abort가 항상 generation + * 증가와 짝이라 아래 두 검사가 같은 집합을 막지만, generation을 올리지 않는 abort 지점 + * (dispose·패널 닫기 등)이 나중에 생기면 성공 경로에만 구멍이 남는다 — 두 경로를 대칭으로 + * 둔다 (#34). */ + if (abort.signal.aborted) return; if (this.#scanGeneration !== scanGeneration) return; this.#figures = result.figures; this.#state = 'done'; this.#render(); } catch (error) { + /* 취소는 정상 흐름이다 — 에러 UI를 띄우면 문서를 바꿀 때마다 "실패했어요"가 번쩍인다. + * 엔진이 던지는 이름(AbortError·RenderingCancelledException…)에 기대지 않고 signal만 본다. */ + if (abort.signal.aborted) return; if (this.#scanGeneration !== scanGeneration) return; console.error('figure 스캔 실패', error); this.#state = 'error'; this.#setStatus('figure 스캔에 실패했어요.', true); + } finally { + if (this.#scanAbort === abort) this.#scanAbort = null; } } diff --git a/test/tab-figures.test.ts b/test/tab-figures.test.ts index 497ad6a..eb19803 100644 --- a/test/tab-figures.test.ts +++ b/test/tab-figures.test.ts @@ -160,4 +160,73 @@ describe('FiguresTab', () => { await Promise.resolve(); expect(list.children[0]?.dataset.page).toBe('2'); }); + + it('aborts the in-flight scan when the document changes (#34)', async () => { + const list = new FakeElement(); + const signals: (AbortSignal | undefined)[] = []; + /* 첫 스캔은 abort될 때까지 매달아 둔다 — 엔진이 signal을 받아 AbortError로 reject하는 + * 동작을 모사한다 (실제 엔진은 페이지 경계에서 체크한다). */ + const extract = vi.fn(async (_data, opts) => { + signals.push(opts?.signal); + if (signals.length === 1) { + return await new Promise((_resolve, reject) => { + /* 엔진이 실제로 던지는 것과 같은 형태로 거절한다 (fig-extract.js의 + * `new DOMException("figure 추출이 취소됨", "AbortError")`). */ + opts?.signal?.addEventListener('abort', () => { + reject(new DOMException('figure 추출이 취소됨', 'AbortError')); + }); + }); + } + return result([figure(2)]); + }) as unknown as FigExtractApi['extract']; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const tab = new FiguresTab( + list as unknown as HTMLElement, + { onJumpToPage: vi.fn() }, + makeEngine(extract) + ); + + tab.setDocument(doc); + await vi.waitFor(() => expect(signals.length).toBe(1)); + expect(signals[0]?.aborted).toBe(false); + + tab.setDocument({} as PDFDocumentProxy); + /* 수용 기준 ①: 이전 스캔이 실제로 중단된다 (결과만 버리는 게 아니다) */ + expect(signals[0]?.aborted).toBe(true); + + /* 수용 기준 ③: 새 문서 스캔은 정상 렌더된다 */ + await vi.waitFor(() => expect(list.children[0]?.dataset.page).toBe('2')); + /* 수용 기준 ②: 취소는 정상 흐름 — 에러 UI도 콘솔 오류도 남기지 않는다 */ + expect(list.children[0]?.className).toBe('fig-card'); + expect(consoleError).not.toHaveBeenCalled(); + }); + + /* 재시도가 자기 스캔을 죽이지 않는다는 것은 **구조적으로** 보장된다 — 재시도는 종료 상태 + * 'error'에서만 진입하고 그때 #scanAbort는 이미 정리돼 있다. 즉 abort 호출을 ensureScanned로 + * 옮기는 변형은 도달 가능한 경로에서 현재 코드와 동작이 같아 테스트로 구별할 수 없다. + * 그래서 이 테스트가 실제로 고정하는 것은 "재시도 스캔도 signal을 받고 그 signal이 살아 있다"다. */ + it('gives the retry scan a live signal of its own (#34)', async () => { + const list = new FakeElement(); + const signals: (AbortSignal | undefined)[] = []; + const extract = vi.fn(async (_data, opts) => { + signals.push(opts?.signal); + if (signals.length === 1) throw new Error('temporary failure'); + return result([figure(3)]); + }) as unknown as FigExtractApi['extract']; + vi.spyOn(console, 'error').mockImplementation(() => {}); + const tab = new FiguresTab( + list as unknown as HTMLElement, + { onJumpToPage: vi.fn() }, + makeEngine(extract) + ); + + tab.setDocument(doc); + await vi.waitFor(() => expect(list.children[0]?.children[0]?.className).toContain('fig-retry')); + + list.emit('click', list.children[0].children[0]); + await vi.waitFor(() => expect(list.children[0]?.dataset.page).toBe('3')); + expect(signals[1]).toBeInstanceOf(AbortSignal); + expect(signals[1]?.aborted).toBe(false); + expect(signals[1]).not.toBe(signals[0]); // 스캔마다 자기 컨트롤러를 갖는다 + }); });