fix(export): cancel PDF work when requesting document retires (#34907)

* fix(export): cancel PDF work when requesting document retires

* test(export): assert success on non-retired repeated export cycles

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 02:56:26 -07:00
committed by GitHub
co-authored by OrcaWin
parent b050ac28f9
commit 84059825e3
4 changed files with 469 additions and 9 deletions
+362
View File
@@ -0,0 +1,362 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExportHtmlToPdfArgs, ExportHtmlToPdfResult } from './export'
type ExportHandler = (
event: { sender: EventEmitter },
args: ExportHtmlToPdfArgs
) => Promise<ExportHtmlToPdfResult>
const fake = vi.hoisted(() => ({
handlers: new Map<string, ExportHandler>(),
tempFiles: new Set<string>(),
savedFiles: new Map<string, Uint8Array>(),
windows: new Array<{ events: EventEmitter; destroyed: boolean }>(),
uuid: 0,
write:
vi.fn<
(path: string, data: string | Uint8Array, options?: { signal?: AbortSignal }) => Promise<void>
>(),
load: vi.fn<(events: EventEmitter, path: string) => Promise<void>>(),
images: vi.fn<() => Promise<void>>(),
print: vi.fn<() => Promise<Buffer>>(),
dialog: vi.fn<() => Promise<{ canceled: boolean; filePath?: string }>>()
}))
vi.mock('electron', () => ({
app: { getPath: () => '/mock-export-temp' },
ipcMain: {
removeHandler: () => {
fake.handlers.delete('export:html-to-pdf')
},
handle: (_channel: string, handler: ExportHandler) => {
fake.handlers.set('export:html-to-pdf', handler)
}
},
dialog: { showSaveDialog: fake.dialog },
BrowserWindow: class {
static fromWebContents() {
return { id: 1 }
}
events = new EventEmitter()
destroyed = false
webContents = Object.assign(this.events, {
executeJavaScript: fake.images,
printToPDF: fake.print
})
constructor() {
fake.windows.push(this)
}
loadFile(path: string) {
return fake.load(this.events, path)
}
isDestroyed() {
return this.destroyed
}
destroy() {
this.destroyed = true
}
}
}))
vi.mock('node:fs/promises', () => ({
writeFile: fake.write,
unlink: async (path: string) => {
fake.tempFiles.delete(path)
}
}))
vi.mock('node:crypto', () => ({ randomUUID: () => `export-${++fake.uuid}` }))
import { registerExportHandlers } from './export'
const PDF = Buffer.from('exported PDF snapshot')
const ARGS = { html: '<p>captured markdown</p>', title: 'draft' }
const GONE_EVENTS = ['render-process-gone', 'did-navigate', 'destroyed'] as const
function exportFrom(sender: EventEmitter): Promise<ExportHtmlToPdfResult> {
const handler = fake.handlers.get('export:html-to-pdf')
if (!handler) {
throw new Error('Export handler was not registered')
}
return handler({ sender }, ARGS)
}
async function flush(): Promise<void> {
await vi.advanceTimersByTimeAsync(0)
}
function expectReleased(sender: EventEmitter): void {
expect(fake.tempFiles.size).toBe(0)
expect(fake.windows.every((window) => window.destroyed)).toBe(true)
expect(vi.getTimerCount()).toBe(0)
for (const event of GONE_EVENTS) {
expect(sender.listenerCount(event)).toBe(0)
}
for (const window of fake.windows) {
expect(window.events.listenerCount('did-finish-load')).toBe(0)
expect(window.events.listenerCount('did-fail-load')).toBe(0)
}
}
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
fake.tempFiles.clear()
fake.savedFiles.clear()
fake.windows.length = 0
fake.write.mockImplementation(async (path, data) => {
if (typeof data === 'string') {
fake.tempFiles.add(path)
} else {
fake.savedFiles.set(path, data)
}
})
fake.load.mockImplementation(async (events) => {
events.emit('did-finish-load')
})
fake.images.mockResolvedValue()
fake.print.mockResolvedValue(PDF)
fake.dialog.mockResolvedValue({ canceled: true })
registerExportHandlers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('PDF export requesting document lifetime', () => {
it.each(GONE_EVENTS)('releases a hidden export on %s before images settle', async (event) => {
const images = Promise.withResolvers<void>()
fake.images.mockReturnValueOnce(images.promise)
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
expect(fake.tempFiles.size).toBe(1)
expect(fake.windows).toHaveLength(1)
sender.emit(event)
await flush()
expectReleased(sender)
await expect(result).resolves.toEqual({ success: false, cancelled: true })
expectReleased(sender)
images.resolve()
await flush()
expect(fake.print).not.toHaveBeenCalled()
expect(fake.dialog).not.toHaveBeenCalled()
})
it.each(['load', 'load-event', 'images', 'print'] as const)(
'cleans up pending %s and observes its late rejection',
async (stage) => {
const pending = Promise.withResolvers<void>()
const printing = Promise.withResolvers<Buffer>()
if (stage === 'load') {
fake.load.mockReturnValueOnce(pending.promise)
}
if (stage === 'load-event') {
fake.load.mockResolvedValueOnce()
}
if (stage === 'images') {
fake.images.mockReturnValueOnce(pending.promise)
}
if (stage === 'print') {
fake.print.mockReturnValueOnce(printing.promise)
}
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
sender.emit('render-process-gone')
await flush()
expectReleased(sender)
await expect(result).resolves.toEqual({ success: false, cancelled: true })
expectReleased(sender)
if (stage === 'print') {
printing.reject(new Error('late print failure'))
} else if (stage === 'load-event') {
fake.windows[0]?.events.emit('did-fail-load', {}, -3, 'late failure')
} else {
pending.reject(new Error('late renderer failure'))
}
await flush()
expect(fake.dialog).not.toHaveBeenCalled()
expect(fake.print).toHaveBeenCalledTimes(stage === 'print' ? 1 : 0)
}
)
it.each(['resolve', 'reject'] as const)(
'cancels initial document writing and cleans up after late %s',
async (settlement) => {
const writing = Promise.withResolvers<void>()
fake.write.mockImplementationOnce(async (path) => {
fake.tempFiles.add(path)
await writing.promise
})
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
const signal = fake.write.mock.calls[0]?.[2]?.signal
expect(signal?.aborted).toBe(false)
sender.emit('did-navigate')
expect(signal?.aborted).toBe(true)
expect(fake.windows).toHaveLength(0)
expect(fake.tempFiles.size).toBe(1)
if (settlement === 'resolve') {
writing.resolve()
} else {
writing.reject(new Error('write aborted'))
}
await expect(result).resolves.toEqual({ success: false, cancelled: true })
expectReleased(sender)
expect(fake.windows).toHaveLength(0)
expect(fake.dialog).not.toHaveBeenCalled()
}
)
it('prevents a late load resolution from admitting image or print work', async () => {
const load = Promise.withResolvers<void>()
fake.load.mockReturnValueOnce(load.promise)
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
sender.emit('destroyed')
await flush()
expectReleased(sender)
await expect(result).resolves.toEqual({ success: false, cancelled: true })
load.resolve()
fake.windows[0]?.events.emit('did-finish-load')
await flush()
expectReleased(sender)
expect(fake.images).not.toHaveBeenCalled()
expect(fake.print).not.toHaveBeenCalled()
})
it('keeps a replacement document and another requester independent of late old work', async () => {
const images = Promise.withResolvers<void>()
const otherImages = Promise.withResolvers<void>()
fake.images.mockReturnValueOnce(images.promise).mockReturnValueOnce(otherImages.promise)
const sender = new EventEmitter()
const oldResult = exportFrom(sender)
await flush()
const other = new EventEmitter()
const otherResult = exportFrom(other)
await flush()
sender.emit('did-navigate')
await flush()
expect(fake.windows.map((window) => window.destroyed)).toEqual([true, false])
expect(fake.tempFiles.size).toBe(1)
expect(other.listenerCount('render-process-gone')).toBe(1)
await expect(oldResult).resolves.toEqual({ success: false, cancelled: true })
fake.dialog.mockResolvedValueOnce({ canceled: false, filePath: '/chosen/new.pdf' })
fake.dialog.mockResolvedValueOnce({ canceled: false, filePath: '/chosen/other.pdf' })
await expect(exportFrom(sender)).resolves.toEqual({
success: true,
filePath: '/chosen/new.pdf'
})
otherImages.resolve()
await expect(otherResult).resolves.toEqual({
success: true,
filePath: '/chosen/other.pdf'
})
images.resolve()
await flush()
expect(fake.print).toHaveBeenCalledTimes(2)
expect(fake.dialog).toHaveBeenCalledTimes(2)
expect(fake.savedFiles).toEqual(
new Map([
['/chosen/new.pdf', PDF],
['/chosen/other.pdf', PDF]
])
)
expectReleased(sender)
expectReleased(other)
})
it('preserves captured content through tab/file switches and same-document navigation', async () => {
const images = Promise.withResolvers<void>()
fake.images.mockReturnValueOnce(images.promise)
fake.dialog.mockResolvedValueOnce({ canceled: false, filePath: '/chosen/snapshot.pdf' })
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
sender.emit('did-navigate-in-page')
// Tab/file selection is renderer-local; the captured HTML request remains owned by this document.
images.resolve()
await expect(result).resolves.toEqual({ success: true, filePath: '/chosen/snapshot.pdf' })
expect(fake.savedFiles.get('/chosen/snapshot.pdf')).toBe(PDF)
expectReleased(sender)
})
it('does not write a save choice returned after the requesting document retired', async () => {
const choice = Promise.withResolvers<{ canceled: boolean; filePath: string }>()
fake.dialog.mockReturnValueOnce(choice.promise)
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
expect(fake.dialog).toHaveBeenCalledOnce()
sender.emit('render-process-gone')
choice.resolve({ canceled: false, filePath: '/chosen/late.pdf' })
await expect(result).resolves.toEqual({ success: false, cancelled: true })
expect(fake.savedFiles.size).toBe(0)
expectReleased(sender)
})
it('finishes a user-accepted write that started before retirement', async () => {
const writing = Promise.withResolvers<void>()
fake.dialog.mockResolvedValueOnce({ canceled: false, filePath: '/chosen/accepted.pdf' })
fake.write.mockImplementation(async (path, data) => {
if (typeof data === 'string') {
fake.tempFiles.add(path)
return
}
await writing.promise
fake.savedFiles.set(path, data)
})
const sender = new EventEmitter()
const result = exportFrom(sender)
await flush()
expect(fake.write).toHaveBeenCalledWith('/chosen/accepted.pdf', PDF)
sender.emit('destroyed')
writing.resolve()
await expect(result).resolves.toEqual({ success: true, filePath: '/chosen/accepted.pdf' })
expect(fake.savedFiles.get('/chosen/accepted.pdf')).toBe(PDF)
expectReleased(sender)
})
it('releases listeners and resources across repeated successful and retired exports', async () => {
const sender = new EventEmitter()
for (let index = 0; index < 10; index++) {
const retired = index % 2 === 0
const filePath = `/chosen/${index}.pdf`
const images = Promise.withResolvers<void>()
fake.images.mockReturnValueOnce(images.promise)
if (!retired) {
fake.dialog.mockResolvedValueOnce({ canceled: false, filePath })
}
const prints = fake.print.mock.calls.length
const dialogs = fake.dialog.mock.calls.length
const result = exportFrom(sender)
await flush()
if (retired) {
sender.emit('render-process-gone')
} else {
images.resolve()
}
await flush()
expectReleased(sender)
if (retired) {
await expect(result).resolves.toEqual({ success: false, cancelled: true })
expect(fake.print.mock.calls.length).toBe(prints)
expect(fake.dialog.mock.calls.length).toBe(dialogs)
expect(fake.savedFiles.has(filePath)).toBe(false)
} else {
await expect(result).resolves.toEqual({ success: true, filePath })
expect(fake.print.mock.calls.length).toBe(prints + 1)
expect(fake.savedFiles.get(filePath)).toEqual(PDF)
}
images.reject(new Error('late image failure'))
await flush()
expectReleased(sender)
}
})
})
+11 -1
View File
@@ -1,6 +1,7 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { writeFile } from 'node:fs/promises'
import { ExportTimeoutError, htmlToPdf } from '../lib/html-to-pdf'
import { abortWhenRendererGone } from './renderer-lifetime-abort'
export type ExportHtmlToPdfArgs = {
html: string
@@ -21,8 +22,10 @@ export function registerExportHandlers(): void {
return { success: false, error: 'No content to export' }
}
const lifetime = abortWhenRendererGone(event.sender)
try {
const pdfBuffer = await htmlToPdf(html)
const pdfBuffer = await htmlToPdf(html, lifetime.signal)
lifetime.signal.throwIfAborted()
// Keep long titles and Windows-forbidden characters out of suggested save filenames.
const sanitizedTitle = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100) || 'export'
@@ -37,13 +40,18 @@ export function registerExportHandlers(): void {
? await dialog.showSaveDialog(parent, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
lifetime.signal.throwIfAborted()
if (canceled || !filePath) {
return { success: false, cancelled: true }
}
lifetime.dispose()
await writeFile(filePath, pdfBuffer)
return { success: true, filePath }
} catch (error) {
if (lifetime.signal.aborted) {
return { success: false, cancelled: true }
}
if (error instanceof ExportTimeoutError) {
return { success: false, error: 'Export timed out' }
}
@@ -51,6 +59,8 @@ export function registerExportHandlers(): void {
success: false,
error: error instanceof Error ? error.message : 'Failed to export PDF'
}
} finally {
lifetime.dispose()
}
}
)
+55
View File
@@ -25,6 +25,11 @@ vi.mock('electron', () => ({
once: (event: string, listener: (...args: unknown[]) => void) => {
fake.handlers.set(event, listener)
},
removeListener: (event: string, listener: (...args: unknown[]) => void) => {
if (fake.handlers.get(event) === listener) {
fake.handlers.delete(event)
}
},
executeJavaScript: fake.images,
printToPDF: fake.print
}
@@ -67,6 +72,56 @@ afterEach(() => {
})
describe('htmlToPdf resource ownership', () => {
it('does not create a document for an already-aborted request', async () => {
const controller = new AbortController()
const error = new Error('request retired')
controller.abort(error)
await expect(htmlToPdf(HTML, controller.signal)).rejects.toBe(error)
expect(fake.write).not.toHaveBeenCalled()
expect(fake.construct).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it.each(['success', 'failure', 'abort'] as const)(
'detaches conversion listeners after %s',
async (outcome) => {
const controller = new AbortController()
const added = vi.spyOn(controller.signal, 'addEventListener')
const removed = vi.spyOn(controller.signal, 'removeEventListener')
const images = Promise.withResolvers<void>()
fake.images.mockReturnValueOnce(images.promise)
const error = new Error('request retired or renderer failed')
const result = htmlToPdf(HTML, controller.signal)
const assertion =
outcome === 'success'
? expect(result).resolves.toBe(PDF)
: expect(result).rejects.toBe(error)
await vi.advanceTimersByTimeAsync(0)
expect(added).toHaveBeenCalledWith('abort', expect.any(Function), { once: true })
if (outcome === 'abort') {
controller.abort(error)
} else if (outcome === 'failure') {
images.reject(error)
} else {
images.resolve()
}
await assertion
const listener = added.mock.calls.find(([event]) => event === 'abort')?.[1]
expect(listener).toBeTypeOf('function')
expect(removed).toHaveBeenCalledWith('abort', listener)
expect(fake.handlers.size).toBe(0)
expect(fake.files.size).toBe(0)
expect(fake.destroy).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
images.reject(new Error('late image failure'))
await Promise.resolve()
}
)
it('removes the temporary document when window construction throws', async () => {
const error = new Error('window construction failed')
fake.construct.mockImplementation(() => {
+41 -8
View File
@@ -27,13 +27,18 @@ new Promise((resolve) => {
})
`
export async function htmlToPdf(html: string): Promise<Buffer> {
export async function htmlToPdf(html: string, signal?: AbortSignal): Promise<Buffer> {
signal?.throwIfAborted()
const tempDir = app.getPath('temp')
const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`)
// Exclusive creation prevents writing through pre-seated symlinks; EEXIST paths belong to someone else.
try {
await writeFile(tempPath, html, { encoding: 'utf-8', flag: 'wx' })
await writeFile(tempPath, html, {
encoding: 'utf-8',
flag: 'wx',
...(signal ? { signal } : {})
})
} catch (error) {
if (isAlreadyExists(error)) {
throw error
@@ -43,6 +48,7 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
}
try {
signal?.throwIfAborted()
const win = new BrowserWindow({
show: false,
webPreferences: {
@@ -55,24 +61,49 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
})
let timer: NodeJS.Timeout | undefined
let removeAbortListener: (() => void) | undefined
let removeLoadListeners: (() => void) | undefined
try {
const abortPromise = new Promise<never>((_resolve, reject) => {
if (!signal) {
return
}
const abort = (): void => reject(signal.reason)
signal.addEventListener('abort', abort, { once: true })
removeAbortListener = () => signal.removeEventListener('abort', abort)
if (signal.aborted) {
abort()
}
})
// Bound loading too: a non-yielding document script never fires load completion or failure.
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS)
})
const loadAndPrint = (async (): Promise<Buffer> => {
signal?.throwIfAborted()
const loadPromise = new Promise<void>((resolve, reject) => {
win.webContents.once('did-finish-load', () => resolve())
win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => {
const finish = (): void => resolve()
const fail = (
_event: Electron.Event,
errorCode: number,
errorDescription: string
): void => {
reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`))
})
}
win.webContents.once('did-finish-load', finish)
win.webContents.once('did-fail-load', fail)
removeLoadListeners = () => {
win.webContents.removeListener('did-finish-load', finish)
win.webContents.removeListener('did-fail-load', fail)
}
})
await win.loadFile(tempPath)
await loadPromise
await Promise.all([win.loadFile(tempPath), loadPromise])
signal?.throwIfAborted()
await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true)
signal?.throwIfAborted()
return win.webContents.printToPDF({
printBackground: true,
pageSize: 'A4',
@@ -85,8 +116,10 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
})
})()
return await Promise.race([loadAndPrint, timeoutPromise])
return await Promise.race([loadAndPrint, timeoutPromise, abortPromise])
} finally {
removeAbortListener?.()
removeLoadListeners?.()
if (timer) {
clearTimeout(timer)
}