Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions docs/fig-extract-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 실패에 직접 기여한다.

## 주의사항

Expand All @@ -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)`로만 받는다
Expand Down
37 changes: 32 additions & 5 deletions src/viewer/panel/tab-figures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = [];
Expand All @@ -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<void> {
const doc = this.#doc;
if (!doc) return;
this.#setStatus('figure 스캔 중…');
async #scan(scanGeneration: number, abort: AbortController): Promise<void> {
/* 취소·완료 어느 쪽으로 끝나도 #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;
}
}

Expand Down
69 changes: 69 additions & 0 deletions test/tab-figures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EngineResult>((_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]); // 스캔마다 자기 컨트롤러를 갖는다
});
});