Add no-silent-catch lint rule, fix silent failures

- New oxlint rule: open-pencil/no-silent-catch — errors on empty catch blocks
- Replace all 8 empty catch blocks with console.warn() logging
- Add worker timeout (30s) and main-thread fallback for .fig parsing
- Fix null crash in renderer filter/picture cache cleanup
- Buffer copy before worker transfer for safe fallback
This commit is contained in:
Danila Poyarkov 2026-03-13 10:03:12 +03:00
parent e8f8a0b8e2
commit 353c867d03
10 changed files with 72 additions and 27 deletions

View file

@ -187,6 +187,33 @@ const noRawConsoleFormat = {
},
}
const noSilentCatch = {
meta: {
docs: {
description:
'Disallow empty catch blocks — log a warning or re-throw instead of silently swallowing errors',
},
},
create(context) {
return {
CatchClause(node) {
const body = node.body
if (!body || !body.body) return
const stmts = body.body.filter(
(s) => s.type !== 'EmptyStatement',
)
if (stmts.length === 0) {
context.report({
node,
message:
'Empty catch block silently swallows errors. Add console.warn(), re-throw, or an explicit // oxlint-ignore-next-line comment.',
})
}
},
}
},
}
const plugin = {
meta: { name: 'open-pencil' },
rules: {
@ -195,6 +222,7 @@ const plugin = {
'no-math-random': noMathRandom,
'no-hand-rolled-color': noHandRolledColor,
'no-raw-console-format': noRawConsoleFormat,
'no-silent-catch': noSilentCatch,
},
}

View file

@ -78,14 +78,16 @@
"open-pencil/no-structuredclone-scene-arrays": "error",
"open-pencil/no-math-random": "error",
"open-pencil/no-hand-rolled-color": "error",
"open-pencil/no-raw-console-format": "off"
"open-pencil/no-raw-console-format": "off",
"open-pencil/no-silent-catch": "error"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-non-null-assertion": "off"
"typescript/no-non-null-assertion": "off",
"open-pencil/no-silent-catch": "off"
}
},

View file

@ -374,8 +374,8 @@ export function parseOpenPencilClipboard(
}
return { nodes: decoded.nodes, images }
}
} catch {
// Not our format
} catch (e) {
console.warn('Failed to parse OpenPencil clipboard data:', e)
}
return null
}

View file

@ -162,8 +162,8 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
return buffer
}
}
} catch {
/* fall through to Google Fonts */
} catch (e) {
console.warn(`Local font access failed for "${family}" ${style}:`, e)
}
}
@ -176,8 +176,8 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
registerFontInBrowser(family, style, buffer)
return buffer
}
} catch {
/* fall through to bundled */
} catch (e) {
console.warn(`Google Fonts fetch failed for "${family}" ${style}:`, e)
}
}
@ -193,8 +193,8 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
registerFontInBrowser(family, style, buffer)
return buffer
}
} catch {
/* no bundled font available */
} catch (e) {
console.warn(`Bundled font fetch failed for "${family}" ${style}:`, e)
}
}

View file

@ -104,11 +104,19 @@ function parseFigFileSync(buffer: ArrayBuffer): SceneGraph {
return importNodeChanges(nodeChanges, blobs, images)
}
const WORKER_TIMEOUT_MS = 30_000
function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./fig-parse-worker.ts', import.meta.url), { type: 'module' })
const timeout = setTimeout(() => {
worker.terminate()
reject(new Error('Worker timed out parsing .fig file'))
}, WORKER_TIMEOUT_MS)
worker.onmessage = (e: MessageEvent<FigParseResult & { error?: string }>) => {
clearTimeout(timeout)
worker.terminate()
if (e.data.error) {
reject(new Error(e.data.error))
@ -120,8 +128,9 @@ function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
}
worker.onerror = (err) => {
clearTimeout(timeout)
worker.terminate()
reject(new Error(err.message))
reject(new Error(err.message || 'Worker failed to parse .fig file'))
}
worker.postMessage(buffer, [buffer])
@ -130,7 +139,13 @@ function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
if (typeof Worker !== 'undefined' && typeof window !== 'undefined') {
return parseViaWorker(buffer)
const copy = buffer.slice(0)
try {
return await parseViaWorker(buffer)
} catch (e) {
console.warn('Worker parsing failed, falling back to main thread:', e)
return parseFigFileSync(copy)
}
}
return parseFigFileSync(buffer)
}

View file

@ -172,8 +172,8 @@ export class SkiaRenderer {
auxStroke: Paint
opacityPaint: Paint
effectLayerPaint: Paint
imageFilterCache = new Map<string, ImageFilter>()
maskFilterCache = new Map<number, MaskFilter>()
imageFilterCache = new Map<string, ImageFilter | null>()
maskFilterCache = new Map<number, MaskFilter | null>()
_tmpColor = new Float32Array(4)
_tmpRect = new Float32Array(4)
textFont: Font | null = null
@ -191,7 +191,7 @@ export class SkiaRenderer {
scenePicture: SkPicture | null = null
scenePictureVersion = -1
scenePicturePageId: string | null = null
nodePictureCache = new Map<string, SkPicture>()
nodePictureCache = new Map<string, SkPicture | null>()
readonly labelCache = new LabelCache()
readonly profiler: RenderProfiler
@ -430,7 +430,7 @@ export class SkiaRenderer {
invalidateAllPictures(): void {
this.invalidateScenePicture()
for (const pic of this.nodePictureCache.values()) pic.delete()
for (const pic of this.nodePictureCache.values()) pic?.delete()
this.nodePictureCache.clear()
}
@ -848,11 +848,11 @@ export class SkiaRenderer {
this.penVertexFill.delete()
this.penVertexStroke.delete()
this.effectLayerPaint.delete()
for (const filter of this.imageFilterCache.values()) filter.delete()
for (const filter of this.imageFilterCache.values()) filter?.delete()
this.imageFilterCache.clear()
for (const filter of this.maskFilterCache.values()) filter.delete()
for (const filter of this.maskFilterCache.values()) filter?.delete()
this.maskFilterCache.clear()
for (const pic of this.nodePictureCache.values()) pic.delete()
for (const pic of this.nodePictureCache.values()) pic?.delete()
this.nodePictureCache.clear()
this.scenePicture?.delete()
this._flashPaint?.delete()

View file

@ -77,8 +77,8 @@ export function startAutomationBridge(server: ViteServer) {
req.resolve(payload)
}
}
} catch {
// ignore malformed messages
} catch (e) {
console.warn('Malformed automation message:', e)
}
}

View file

@ -173,8 +173,8 @@ export function connectAutomation(getStore: () => EditorStore) {
})
)
}
} catch {
// ignore parse errors
} catch (e) {
console.warn('Failed to parse WebSocket message:', e)
}
}

View file

@ -294,8 +294,8 @@ export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Edi
insertText(text, node)
resetBlink()
}
} catch {
// Clipboard access denied
} catch (e) {
console.warn('Clipboard access denied:', e)
}
}

View file

@ -222,8 +222,8 @@ export function createEditorStore() {
if (!state.autosaveEnabled) return
try {
await writeFile(await buildFigFile())
} catch {
// silently fail — user can still save manually
} catch (e) {
console.warn('Autosave failed:', e)
}
}, AUTOSAVE_DELAY)
}