fix(fig): harden document worker sessions (#594)
* fix(app): cancel large FIG opens before tab cleanup - Abort tab-local preparation before awaiting recovery persistence so closing a decoding tab terminates its FIG worker immediately - Add explicit graph-event unsubscription and lazy FIG resource release for disposed editor stores - Keep staging-editor disposal separate from live graph resource ownership - Let same-origin document fetches bypass all cross-origin font response limits * refactor(fig): isolate worker sessions per document - Give each lazy FIG document a dedicated MessageChannel and retained worker session - Route manifest and page population traffic through the session port instead of global worker handlers - Close the port and worker explicitly on cancellation or tab disposal - Preserve current lazy source transfer and export fidelity until raw NodeChange patch export is implemented * perf(fig): preserve unchanged archives in worker sessions - Keep a second transferable copy of the original FIG archive inside the document worker session instead of main-thread memory - Track whether user graph mutations invalidate exact archive reuse - Return unchanged documents byte-for-byte through the session without full-page materialization or recompression - Release archive request ownership with the page population worker and editor graph resources * fix(fig): choose the first visible imported page - Ignore internal-only canvases when selecting the initial active page\n- Keep internal component pages available for lazy instance population * fix(fig): retain original archives across fallbacks - Keep session-owned workers alive when population is oversized\n- Register the input archive when worker graph transfer falls back to the main thread\n- Preserve byte-identical unchanged saves without restoring lossy materialization * fix(fig): harden worker session ownership - Route concurrent archive replies through persistent request resolvers and reject stale in-flight archives after edits\n- Retain disposal-only handles for oversized sessions and guard Worker construction\n- Reuse the shared FIG import options contract and cover session disposal and invalidation * test(fig): isolate Worker availability mutation
This commit is contained in:
parent
0f64cecc49
commit
a2d0d6552d
|
|
@ -8,6 +8,9 @@ import { UndoManager } from '@open-pencil/scene-graph/undo'
|
|||
import type { SkiaRenderer } from '#core/canvas/renderer'
|
||||
import { prefetchFigmaSchema } from '#core/clipboard'
|
||||
import { IS_BROWSER } from '#core/constants'
|
||||
import { clearLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
|
||||
import { releaseFigPopulationWorker } from '#core/kiwi/fig/population/client'
|
||||
import { releaseOriginalFigArchive } from '#core/kiwi/fig/session/original-archive'
|
||||
import { setTextMeasurer } from '#core/layout'
|
||||
import { TextEditor } from '#core/text/editor'
|
||||
import { fontManager } from '#core/text/fonts'
|
||||
|
|
@ -123,7 +126,7 @@ export function createEditor(options?: EditorOptions) {
|
|||
const { runLayoutForNode } = createLayoutRunner(() => _graph)
|
||||
const { scheduleComponentSync } = createComponentSyncScheduler(() => _graph, requestRender)
|
||||
|
||||
const { subscribeToGraph } = createGraphEventSubscription({
|
||||
const { subscribeToGraph, unsubscribeFromGraph } = createGraphEventSubscription({
|
||||
getGraph: () => _graph,
|
||||
getRenderers: () => _renderers,
|
||||
scheduleComponentSync,
|
||||
|
|
@ -220,6 +223,17 @@ export function createEditor(options?: EditorOptions) {
|
|||
requestRender()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stopFontResolutionEvents()
|
||||
unsubscribeFromGraph()
|
||||
}
|
||||
|
||||
function releaseGraphResources() {
|
||||
releaseFigPopulationWorker(_graph)
|
||||
releaseOriginalFigArchive(_graph)
|
||||
clearLazyFigImportContext(_graph)
|
||||
}
|
||||
|
||||
return {
|
||||
get graph() {
|
||||
return _graph
|
||||
|
|
@ -247,7 +261,8 @@ export function createEditor(options?: EditorOptions) {
|
|||
removeCanvasRenderer,
|
||||
replaceGraph,
|
||||
subscribeToGraph,
|
||||
dispose: stopFontResolutionEvents,
|
||||
dispose,
|
||||
releaseGraphResources,
|
||||
|
||||
// Selection
|
||||
...selection,
|
||||
|
|
|
|||
|
|
@ -104,5 +104,10 @@ export function createGraphEventSubscription(options: GraphEventOptions) {
|
|||
})
|
||||
}
|
||||
|
||||
return { subscribeToGraph }
|
||||
function unsubscribeFromGraph() {
|
||||
unbindGraphEvents?.()
|
||||
unbindGraphEvents = null
|
||||
}
|
||||
|
||||
return { subscribeToGraph, unsubscribeFromGraph }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
makeCanvasNodeChange
|
||||
} from '#core/kiwi/fig/node-change/serialize'
|
||||
import { cloneSceneGraphForFigExport } from '#core/kiwi/fig/parse/transfer'
|
||||
import { originalFigArchive } from '#core/kiwi/fig/session/original-archive'
|
||||
|
||||
const THUMBNAIL_1X1 = decodeBase64(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
|
||||
|
|
@ -445,8 +446,8 @@ export async function exportFigFile(
|
|||
pageId?: string,
|
||||
renderHeadlessThumbnail = false
|
||||
): Promise<Uint8Array> {
|
||||
// Lazy population synchronizes component trees and therefore mutates its graph. Saving must not
|
||||
// rewrite the live editor document or restore component values over edits made by the user.
|
||||
const originalArchive = await originalFigArchive(sourceGraph)
|
||||
if (originalArchive) return originalArchive.slice()
|
||||
const graph = cloneSceneGraphForFigExport(sourceGraph)
|
||||
populateAllLazyFigImportRoots(graph)
|
||||
await initCodec()
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
|
|||
|
||||
import { IS_BROWSER } from '#core/constants'
|
||||
import { importNodeChanges } from '#core/kiwi/fig/import'
|
||||
import { createFigParseWorker } from '#core/kiwi/fig/parse/client'
|
||||
import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||
import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||
import { registerFigPopulationWorker } from '#core/kiwi/fig/population/client'
|
||||
import {
|
||||
registerFigPopulationWorker,
|
||||
registerOriginalArchiveRequest
|
||||
} from '#core/kiwi/fig/population/client'
|
||||
import { createFigSessionWorker } from '#core/kiwi/fig/session/client'
|
||||
import type { FigSessionOpenRequest, FigSessionResponse } from '#core/kiwi/fig/session/protocol'
|
||||
import { randomHex } from '#core/random'
|
||||
|
||||
export interface ParseFigFileOptions {
|
||||
populate?: 'all' | 'first-page' | 'none'
|
||||
|
|
@ -29,37 +33,37 @@ function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}
|
|||
return graph
|
||||
}
|
||||
|
||||
interface WorkerGraphResult {
|
||||
type: 'graph'
|
||||
graph?: SerializedSceneGraph
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface WorkerPageManifestResult {
|
||||
type: 'page-manifest'
|
||||
pages: FigPageManifestEntry[]
|
||||
}
|
||||
|
||||
type WorkerParseResult = WorkerGraphResult | WorkerPageManifestResult
|
||||
|
||||
function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Promise<SceneGraph> {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal?.throwIfAborted()
|
||||
const worker = createFigParseWorker()
|
||||
const worker = createFigSessionWorker()
|
||||
const channel = new MessageChannel()
|
||||
const pendingArchives = new Map<string, (bytes: Uint8Array) => void>()
|
||||
const abort = () => {
|
||||
channel.port1.postMessage({ type: 'dispose' })
|
||||
channel.port1.close()
|
||||
worker.terminate()
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
const cleanupAbort = () => options.signal?.removeEventListener('abort', abort)
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerParseResult>) => {
|
||||
channel.port1.onmessage = (e: MessageEvent<FigSessionResponse>) => {
|
||||
if (e.data.type === 'original-archive-result') {
|
||||
const resolveArchive = pendingArchives.get(e.data.requestId)
|
||||
if (!resolveArchive) return
|
||||
pendingArchives.delete(e.data.requestId)
|
||||
resolveArchive(e.data.bytes)
|
||||
return
|
||||
}
|
||||
if (e.data.type === 'page-manifest') {
|
||||
options.onPages?.(e.data.pages)
|
||||
return
|
||||
}
|
||||
if (e.data.type !== 'graph') return
|
||||
if (e.data.error || !e.data.graph) {
|
||||
cleanupAbort()
|
||||
channel.port1.close()
|
||||
worker.terminate()
|
||||
reject(new Error(e.data.error ?? 'Worker failed to parse .fig file'))
|
||||
return
|
||||
|
|
@ -68,28 +72,46 @@ function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Prom
|
|||
const graph = deserializeSceneGraph(e.data.graph)
|
||||
if (options.populate === 'first-page') {
|
||||
cleanupAbort()
|
||||
worker.onmessage = null
|
||||
worker.onerror = null
|
||||
registerFigPopulationWorker(graph, worker)
|
||||
registerFigPopulationWorker(graph, worker, channel.port1)
|
||||
registerOriginalArchiveRequest(
|
||||
graph,
|
||||
() =>
|
||||
new Promise<Uint8Array>((resolveArchive) => {
|
||||
const requestId = randomHex()
|
||||
pendingArchives.set(requestId, resolveArchive)
|
||||
channel.port1.postMessage({ type: 'original-archive', requestId })
|
||||
})
|
||||
)
|
||||
} else {
|
||||
cleanupAbort()
|
||||
channel.port1.close()
|
||||
worker.terminate()
|
||||
}
|
||||
resolve(graph)
|
||||
} catch (error) {
|
||||
cleanupAbort()
|
||||
channel.port1.close()
|
||||
worker.terminate()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
channel.port1.start()
|
||||
worker.onerror = (err) => {
|
||||
cleanupAbort()
|
||||
channel.port1.close()
|
||||
worker.terminate()
|
||||
reject(new Error(err.message || 'Worker failed to parse .fig file'))
|
||||
}
|
||||
|
||||
worker.postMessage({ buffer, options: { populate: options.populate } }, [buffer])
|
||||
const workerBuffer = buffer.slice(0)
|
||||
const archiveBuffer = buffer.slice(0)
|
||||
const request: FigSessionOpenRequest = {
|
||||
type: 'open',
|
||||
originalBuffer: workerBuffer,
|
||||
archiveBuffer,
|
||||
options: { populate: options.populate },
|
||||
port: channel.port2
|
||||
}
|
||||
worker.postMessage(request, [workerBuffer, archiveBuffer, channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +127,9 @@ export async function parseFigFile(
|
|||
} catch (error) {
|
||||
if (options.signal?.aborted) throw error
|
||||
console.warn('Worker parsing failed, falling back to main thread:', error)
|
||||
return parseFigFileSync(copy, options)
|
||||
const graph = parseFigFileSync(copy, options)
|
||||
registerOriginalArchiveRequest(graph, async () => new Uint8Array(copy.slice(0)))
|
||||
return graph
|
||||
}
|
||||
}
|
||||
options.signal?.throwIfAborted()
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ export function importNodeChanges(
|
|||
remapInstanceSwapPropertyValues(graph, guidToNodeId)
|
||||
applyVariantPropSpecs(graph)
|
||||
|
||||
const firstPageId = graph.getPages()[0]?.id
|
||||
const firstPageId = graph.getPages().find((page) => !page.internalOnly)?.id
|
||||
const componentPageIds =
|
||||
options.populate === 'first-page' ? componentPageIdsForLazyPopulation(graph) : new Set<string>()
|
||||
const activeRootIds =
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ export function getLazyFigImportContext(graph: SceneGraph): LazyFigImportContext
|
|||
return lazyFigImportContexts.get(graph)
|
||||
}
|
||||
|
||||
export function clearLazyFigImportContext(graph: SceneGraph): void {
|
||||
lazyFigImportContexts.delete(graph)
|
||||
}
|
||||
|
||||
function applyPopulation(
|
||||
graph: SceneGraph,
|
||||
context: LazyFigImportContext,
|
||||
|
|
|
|||
|
|
@ -8,17 +8,13 @@ import {
|
|||
serializedSceneGraphTransferList
|
||||
} from '#core/kiwi/fig/parse/transfer'
|
||||
import { buildFigPopulationDelta, installFigMutationJournal } from '#core/kiwi/fig/population/delta'
|
||||
import type { FigSessionPopulateRequest } from '#core/kiwi/fig/session/protocol'
|
||||
|
||||
interface WorkerParseRequest {
|
||||
buffer: ArrayBuffer
|
||||
options?: { populate?: 'all' | 'first-page' }
|
||||
}
|
||||
interface PopulateRequest {
|
||||
type: 'populate'
|
||||
requestId: string
|
||||
baseRevision: number
|
||||
pageId: string
|
||||
}
|
||||
type PopulateRequest = FigSessionPopulateRequest
|
||||
type WorkerRequest = ArrayBuffer | WorkerParseRequest | PopulateRequest
|
||||
type WorkerPostMessage = (message: unknown, transfer: Transferable[]) => void
|
||||
const postWorkerMessage: WorkerPostMessage = (message, transfer) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { getLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
|
||||
import type { FigSessionResponse } from '#core/kiwi/fig/session/protocol'
|
||||
import { randomHex } from '#core/random'
|
||||
|
||||
import { applyFigPopulationDelta, type FigPopulationDelta } from './delta'
|
||||
|
|
@ -17,6 +18,12 @@ type WorkerResult = PopulationResult | { type: 'population-error'; error: string
|
|||
const MAX_FIG_POPULATION_WORKER_NODES = 200_000
|
||||
const FIG_POPULATION_WORKER_TIMEOUT_MS = 30_000
|
||||
const populationWorkers = new WeakMap<SceneGraph, FigPopulationWorker>()
|
||||
interface OriginalArchiveRequest {
|
||||
request: () => Promise<Uint8Array>
|
||||
valid: boolean
|
||||
unbind: () => void
|
||||
}
|
||||
const originalArchiveRequests = new WeakMap<SceneGraph, OriginalArchiveRequest>()
|
||||
|
||||
export interface FigPopulationWorkerTelemetry {
|
||||
event: 'registered' | 'populate' | 'fallback' | 'stale' | 'terminated'
|
||||
|
|
@ -33,13 +40,21 @@ function emitTelemetry(detail: FigPopulationWorkerTelemetry): void {
|
|||
globalThis.dispatchEvent(new CustomEvent('openpencil:fig-population-worker', { detail }))
|
||||
}
|
||||
|
||||
export function registerFigPopulationWorker(graph: SceneGraph, worker: Worker): void {
|
||||
export function registerFigPopulationWorker(
|
||||
graph: SceneGraph,
|
||||
worker: Worker,
|
||||
port?: MessagePort
|
||||
): void {
|
||||
if (graph.nodes.size > MAX_FIG_POPULATION_WORKER_NODES) {
|
||||
emitTelemetry({ event: 'fallback', reason: 'oversized' })
|
||||
worker.terminate()
|
||||
if (!port) {
|
||||
worker.terminate()
|
||||
return
|
||||
}
|
||||
populationWorkers.set(graph, createDisposalOnlyWorker(worker, port))
|
||||
return
|
||||
}
|
||||
const client = createPopulationWorkerClient(graph, worker)
|
||||
const client = createPopulationWorkerClient(graph, worker, port)
|
||||
populationWorkers.set(graph, client)
|
||||
emitTelemetry({ event: 'registered' })
|
||||
}
|
||||
|
|
@ -56,17 +71,71 @@ export function canUseFigPopulationWorker(graph: SceneGraph): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
export function registerOriginalArchiveRequest(
|
||||
graph: SceneGraph,
|
||||
request: () => Promise<Uint8Array>
|
||||
): void {
|
||||
const entry: OriginalArchiveRequest = { request, valid: true, unbind: () => undefined }
|
||||
const invalidate = () => {
|
||||
if (!graph.isApplyingLayout) entry.valid = false
|
||||
}
|
||||
entry.unbind = graph.onNodeEvents({
|
||||
created: invalidate,
|
||||
updated: invalidate,
|
||||
deleted: invalidate,
|
||||
reparented: invalidate,
|
||||
reordered: invalidate
|
||||
})
|
||||
originalArchiveRequests.set(graph, entry)
|
||||
}
|
||||
|
||||
export async function requestOriginalArchive(graph: SceneGraph): Promise<Uint8Array | null> {
|
||||
const entry = originalArchiveRequests.get(graph)
|
||||
if (!entry?.valid) return null
|
||||
const archive = await entry.request()
|
||||
return originalArchiveRequests.get(graph)?.valid === true &&
|
||||
originalArchiveRequests.get(graph) === entry
|
||||
? archive
|
||||
: null
|
||||
}
|
||||
|
||||
export function releaseFigPopulationWorker(graph: SceneGraph): void {
|
||||
populationWorkers.get(graph)?.terminate()
|
||||
populationWorkers.delete(graph)
|
||||
originalArchiveRequests.get(graph)?.unbind()
|
||||
originalArchiveRequests.delete(graph)
|
||||
}
|
||||
|
||||
export interface FigPopulationWorker {
|
||||
populate: (pageId: string, signal?: AbortSignal) => Promise<boolean | null>
|
||||
terminate: () => void
|
||||
}
|
||||
|
||||
function createDisposalOnlyWorker(worker: Worker, port: MessagePort): FigPopulationWorker {
|
||||
let disposed = false
|
||||
return {
|
||||
populate: () => Promise.resolve(null),
|
||||
terminate() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
emitTelemetry({ event: 'terminated' })
|
||||
port.postMessage({ type: 'dispose' })
|
||||
port.close()
|
||||
worker.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFigPopulationWorker(graph: SceneGraph): FigPopulationWorker | null {
|
||||
if (!canUseFigPopulationWorker(graph)) return null
|
||||
return populationWorkers.get(graph) ?? null
|
||||
}
|
||||
|
||||
function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPopulationWorker {
|
||||
function createPopulationWorkerClient(
|
||||
graph: SceneGraph,
|
||||
worker: Worker,
|
||||
port?: MessagePort
|
||||
): FigPopulationWorker {
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -115,8 +184,7 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop
|
|||
reparented: invalidate,
|
||||
reordered: invalidate
|
||||
})
|
||||
worker.onmessage = (event: MessageEvent<WorkerResult>) => {
|
||||
const result = event.data
|
||||
const receive = (result: WorkerResult) => {
|
||||
if (result.type === 'population-error') return fail()
|
||||
const request = pending.get(result.requestId)
|
||||
if (!request) return
|
||||
|
|
@ -150,6 +218,13 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop
|
|||
deleted: result.delta.deleted.length
|
||||
})
|
||||
}
|
||||
if (port) {
|
||||
port.onmessage = (event: MessageEvent<FigSessionResponse>) =>
|
||||
receive(event.data as WorkerResult)
|
||||
port.start()
|
||||
} else {
|
||||
worker.onmessage = (event: MessageEvent<WorkerResult>) => receive(event.data)
|
||||
}
|
||||
worker.onerror = () => fail()
|
||||
return {
|
||||
populate(pageId, signal) {
|
||||
|
|
@ -175,13 +250,16 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop
|
|||
startedAt: performance.now(),
|
||||
timeout
|
||||
})
|
||||
worker.postMessage({ type: 'populate', requestId, baseRevision, pageId }, [])
|
||||
if (port) port.postMessage({ type: 'populate', requestId, baseRevision, pageId })
|
||||
else worker.postMessage({ type: 'populate', requestId, baseRevision, pageId }, [])
|
||||
})
|
||||
},
|
||||
terminate() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
emitTelemetry({ event: 'terminated' })
|
||||
port?.postMessage({ type: 'dispose' })
|
||||
port?.close()
|
||||
fail(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
packages/core/src/kiwi/fig/session/client.ts
Normal file
4
packages/core/src/kiwi/fig/session/client.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function createFigSessionWorker(): Worker {
|
||||
if (typeof Worker === 'undefined') throw new Error('FIG session workers are unavailable')
|
||||
return new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
||||
}
|
||||
20
packages/core/src/kiwi/fig/session/original-archive.ts
Normal file
20
packages/core/src/kiwi/fig/session/original-archive.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { requestOriginalArchive } from '#core/kiwi/fig/population/client'
|
||||
|
||||
const originalFigArchives = new WeakMap<SceneGraph, () => Promise<Uint8Array>>()
|
||||
|
||||
export function registerOriginalFigArchive(
|
||||
graph: SceneGraph,
|
||||
requestArchive: () => Promise<Uint8Array>
|
||||
): void {
|
||||
originalFigArchives.set(graph, requestArchive)
|
||||
}
|
||||
|
||||
export async function originalFigArchive(graph: SceneGraph): Promise<Uint8Array | null> {
|
||||
return (await originalFigArchives.get(graph)?.()) ?? (await requestOriginalArchive(graph))
|
||||
}
|
||||
|
||||
export function releaseOriginalFigArchive(graph: SceneGraph): void {
|
||||
originalFigArchives.delete(graph)
|
||||
}
|
||||
54
packages/core/src/kiwi/fig/session/protocol.ts
Normal file
54
packages/core/src/kiwi/fig/session/protocol.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
|
||||
|
||||
import type { FigImportOptions } from '#core/kiwi/fig/import'
|
||||
import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||
import type { FigPopulationDelta } from '#core/kiwi/fig/population/delta'
|
||||
|
||||
export interface FigSessionOpenRequest {
|
||||
type: 'open'
|
||||
originalBuffer: ArrayBuffer
|
||||
archiveBuffer: ArrayBuffer
|
||||
options?: FigImportOptions
|
||||
port: MessagePort
|
||||
}
|
||||
|
||||
export interface FigSessionPopulateRequest {
|
||||
type: 'populate'
|
||||
requestId: string
|
||||
baseRevision: number
|
||||
pageId: string
|
||||
}
|
||||
|
||||
export interface FigSessionOriginalArchiveRequest {
|
||||
type: 'original-archive'
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface FigSessionCancelRequest {
|
||||
type: 'cancel'
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface FigSessionDisposeRequest {
|
||||
type: 'dispose'
|
||||
}
|
||||
|
||||
export type FigSessionRequest =
|
||||
| FigSessionPopulateRequest
|
||||
| FigSessionOriginalArchiveRequest
|
||||
| FigSessionCancelRequest
|
||||
| FigSessionDisposeRequest
|
||||
|
||||
export type FigSessionResponse =
|
||||
| { type: 'page-manifest'; pages: FigPageManifestEntry[] }
|
||||
| { type: 'graph'; graph?: SerializedSceneGraph; error?: string }
|
||||
| {
|
||||
type: 'population-result'
|
||||
requestId: string
|
||||
baseRevision: number
|
||||
populated: boolean
|
||||
delta: FigPopulationDelta
|
||||
}
|
||||
| { type: 'population-error'; requestId?: string; error: string }
|
||||
| { type: 'original-archive-result'; requestId: string; bytes: Uint8Array }
|
||||
| { type: 'disposed' }
|
||||
90
packages/core/src/kiwi/fig/session/worker.ts
Normal file
90
packages/core/src/kiwi/fig/session/worker.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { parseFigBuffer } from '@open-pencil/fig'
|
||||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { importNodeChanges } from '#core/kiwi/fig/import'
|
||||
import { getLazyFigImportContext, populateLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
|
||||
import { serializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||
import { buildFigPopulationDelta, installFigMutationJournal } from '#core/kiwi/fig/population/delta'
|
||||
import type {
|
||||
FigSessionOpenRequest,
|
||||
FigSessionRequest,
|
||||
FigSessionResponse
|
||||
} from '#core/kiwi/fig/session/protocol'
|
||||
|
||||
let graph: SceneGraph | undefined
|
||||
let originalArchive: Uint8Array | undefined
|
||||
let port: MessagePort | undefined
|
||||
|
||||
function respond(message: FigSessionResponse): void {
|
||||
port?.postMessage(message)
|
||||
}
|
||||
|
||||
function populate(request: Extract<FigSessionRequest, { type: 'populate' }>): void {
|
||||
if (!graph) throw new Error('FIG session has no retained graph')
|
||||
const journal = installFigMutationJournal(graph)
|
||||
try {
|
||||
const populated = populateLazyFigImportRoots(graph, [request.pageId])
|
||||
const context = getLazyFigImportContext(graph)
|
||||
if (!context) throw new Error('FIG session has no lazy import context')
|
||||
respond({
|
||||
type: 'population-result',
|
||||
requestId: request.requestId,
|
||||
baseRevision: request.baseRevision,
|
||||
populated,
|
||||
delta: buildFigPopulationDelta(graph, journal, context.populatedRootIds)
|
||||
})
|
||||
} finally {
|
||||
journal.stop()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequest(request: FigSessionRequest): void {
|
||||
try {
|
||||
if (request.type === 'original-archive') {
|
||||
if (!originalArchive) throw new Error('FIG session has no original archive')
|
||||
const bytes = originalArchive.slice()
|
||||
port?.postMessage({ type: 'original-archive-result', requestId: request.requestId, bytes }, [
|
||||
bytes.buffer
|
||||
])
|
||||
return
|
||||
}
|
||||
if (request.type === 'dispose') {
|
||||
graph = undefined
|
||||
originalArchive = undefined
|
||||
respond({ type: 'disposed' })
|
||||
port?.close()
|
||||
port = undefined
|
||||
self.close()
|
||||
return
|
||||
}
|
||||
if (request.type === 'cancel') return
|
||||
populate(request)
|
||||
} catch (error) {
|
||||
respond({
|
||||
type: 'population-error',
|
||||
requestId: request.type === 'populate' ? request.requestId : undefined,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<FigSessionOpenRequest>) => {
|
||||
const request = event.data
|
||||
port = request.port
|
||||
port.onmessage = (message: MessageEvent<FigSessionRequest>) => handleRequest(message.data)
|
||||
port.start()
|
||||
originalArchive = new Uint8Array(request.archiveBuffer)
|
||||
try {
|
||||
const { nodeChanges, blobs, images, figKiwiVersion, figSchemaDeflated } = parseFigBuffer(
|
||||
request.originalBuffer,
|
||||
(pages) => respond({ type: 'page-manifest', pages })
|
||||
)
|
||||
const parsedGraph = importNodeChanges(nodeChanges, blobs, new Map(images), request.options)
|
||||
parsedGraph.figKiwiVersion = figKiwiVersion
|
||||
parsedGraph.figSchemaDeflated = figSchemaDeflated
|
||||
graph = request.options?.populate === 'first-page' ? parsedGraph : undefined
|
||||
respond({ type: 'graph', graph: serializeSceneGraph(parsedGraph) })
|
||||
} catch (error) {
|
||||
respond({ type: 'graph', error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ export function createEditorStoreModules(
|
|||
setPlannedFilePath: documentIO.setPlannedFilePath,
|
||||
startWatchingCurrentFile: documentIO.startWatchingCurrentFile,
|
||||
dispose: () => {
|
||||
editor.releaseGraphResources()
|
||||
editor.dispose()
|
||||
editor.clearPageViewports()
|
||||
documentIO.disposeDocumentIO()
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export async function closeTab(tabId: string): Promise<void> {
|
|||
const wasActive = activeTabId.value === tabId
|
||||
coverThumbnailListeners.get(closingTab.store)?.()
|
||||
coverThumbnailListeners.delete(closingTab.store)
|
||||
closingTab.store.preparationController.dispose()
|
||||
await closingTab.store.persistRecoveryNow()
|
||||
closingTab.store.dispose()
|
||||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
||||
|
|
|
|||
67
tests/engine/io/fig/import/session-ownership.test.ts
Normal file
67
tests/engine/io/fig/import/session-ownership.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import {
|
||||
registerFigPopulationWorker,
|
||||
registerOriginalArchiveRequest,
|
||||
releaseFigPopulationWorker,
|
||||
requestOriginalArchive
|
||||
} from '#core/kiwi/fig/population/client'
|
||||
|
||||
describe('FIG session ownership', () => {
|
||||
test('discards an archive response when the graph changes while it is pending', async () => {
|
||||
const graph = new SceneGraph()
|
||||
let resolveArchive: ((bytes: Uint8Array) => void) | null = null
|
||||
registerOriginalArchiveRequest(
|
||||
graph,
|
||||
() =>
|
||||
new Promise<Uint8Array>((resolve) => {
|
||||
resolveArchive = resolve
|
||||
})
|
||||
)
|
||||
const request = requestOriginalArchive(graph)
|
||||
|
||||
graph.updateNode(graph.rootId, { name: 'Edited' })
|
||||
resolveArchive?.(new Uint8Array([1, 2, 3]))
|
||||
|
||||
await expect(request).resolves.toBeNull()
|
||||
releaseFigPopulationWorker(graph)
|
||||
})
|
||||
|
||||
test('retains and releases oversized session workers', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
for (let index = 0; index < 200_001; index++) {
|
||||
graph.nodes.set(`oversized-${index}`, {
|
||||
...page,
|
||||
id: `oversized-${index}`,
|
||||
childIds: []
|
||||
})
|
||||
}
|
||||
let workerTerminated = false
|
||||
let portClosed = false
|
||||
const worker = { terminate: () => (workerTerminated = true) } as Worker
|
||||
const port = {
|
||||
postMessage: () => undefined,
|
||||
close: () => (portClosed = true)
|
||||
} as MessagePort
|
||||
|
||||
registerFigPopulationWorker(graph, worker, port)
|
||||
releaseFigPopulationWorker(graph)
|
||||
|
||||
expect(workerTerminated).toBe(true)
|
||||
expect(portClosed).toBe(true)
|
||||
})
|
||||
|
||||
test.serial('rejects session worker construction outside Worker runtimes', async () => {
|
||||
const originalWorker = globalThis.Worker
|
||||
Reflect.deleteProperty(globalThis, 'Worker')
|
||||
try {
|
||||
const { createFigSessionWorker } = await import('#core/kiwi/fig/session/client')
|
||||
expect(() => createFigSessionWorker()).toThrow('unavailable')
|
||||
} finally {
|
||||
globalThis.Worker = originalWorker
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue