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:
Danila Poyarkov 2026-08-30 21:40:13 +03:00 committed by GitHub
parent 0f64cecc49
commit a2d0d6552d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 404 additions and 44 deletions

View file

@ -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,

View file

@ -104,5 +104,10 @@ export function createGraphEventSubscription(options: GraphEventOptions) {
})
}
return { subscribeToGraph }
function unsubscribeFromGraph() {
unbindGraphEvents?.()
unbindGraphEvents = null
}
return { subscribeToGraph, unsubscribeFromGraph }
}

View file

@ -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()

View file

@ -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()

View file

@ -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 =

View file

@ -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,

View file

@ -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) => {

View file

@ -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)
}
}

View 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' })
}

View 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)
}

View 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' }

View 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) })
}
}

View file

@ -87,6 +87,7 @@ export function createEditorStoreModules(
setPlannedFilePath: documentIO.setPlannedFilePath,
startWatchingCurrentFile: documentIO.startWatchingCurrentFile,
dispose: () => {
editor.releaseGraphResources()
editor.dispose()
editor.clearPageViewports()
documentIO.disposeDocumentIO()

View file

@ -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)

View 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
}
})
})