Propagate synchronization and plugin failures correctly

This commit is contained in:
eezd committed 2026-10-20 12:58:41 +08:00
1 parent 1e33f50c5e
commit ac81544e03
12 files changed
+235 -56

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { api } from './api'
import { api, ApiError } from './api'
import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue'
@@ -79,7 +79,7 @@ function onTouchMove(e: TouchEvent) {
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
onMounted(() => void startWorkspace())
onMounted(() => void startWorkspace().catch((err) => toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误'))))
onBeforeUnmount(() => {
unregisterLogout()
stopWorkspace()
+52 -1
View File
@@ -4,16 +4,67 @@ import { EditorView } from '@codemirror/view'
import { insertImageFiles } from '../editor/paste'
import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, ws } from './workspace'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
let view: EditorView | undefined
afterEach(() => {
stopWorkspace()
attachView(null)
view?.destroy()
view = undefined
vi.restoreAllMocks()
})
describe('事件同步', () => {
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries)
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const read = vi.spyOn(api, 'readFile').mockResolvedValue({ content: '旧正文', rev: 'r1' })
await startWorkspace()
await openNote('sync.md')
const handlers = vi.mocked(connectEvents).mock.lastCall![0]
const observed: string[] = []
let resyncs = 0
const changed = () => observed.push(editorStateOf('sync.md')!.doc.toString())
const resync = () => { resyncs++ }
pluginEvents.addEventListener('changed', changed)
pluginEvents.addEventListener('resync', resync)
try {
let finish!: (value: { content: string; rev: string }) => void
read.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r2' }])
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(observed).toEqual([])
finish({ content: '新正文', rev: 'r2' })
await vi.waitFor(() => expect(observed).toEqual(['新正文']))
read.mockRejectedValueOnce(new ApiError(500, '正文读取失败'))
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r3' }])
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('正文读取失败'))).toBe(true))
expect(observed).toEqual(['新正文'])
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
handlers.onResync()
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('目录读取失败'))).toBe(true))
expect(resyncs).toBe(0)
read.mockResolvedValue({ content: '恢复后的正文', rev: 'r3' })
handlers.onResync()
await vi.waitFor(() => expect(resyncs).toBe(1))
expect(editorStateOf('sync.md')!.doc.toString()).toBe('恢复后的正文')
} finally {
pluginEvents.removeEventListener('changed', changed)
pluginEvents.removeEventListener('resync', resync)
await closeTab('sync.md')
}
})
})
async function edit(path: string) {
vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
+24 -18
View File
@@ -382,7 +382,7 @@ export async function keepLocalVersion(path: string) {
s.savedSeq = seq
Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined })
if (s.seq === seq) await drafts.delete(path)
if (!ws.entries.some((e) => e.path === path)) void refreshTree()
if (!ws.entries.some((e) => e.path === path)) void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
} catch (err) {
if (err instanceof ApiError && err.status === 412) {
tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : ''
@@ -544,11 +544,11 @@ let treeTimer: ReturnType<typeof setTimeout> | undefined
let treeFetching = false
let treeAgain = false
let localTreeEdits = 0
const treeWaiters: (() => void)[] = []
const treeWaiters: { resolve: () => void; reject: (reason: unknown) => void }[] = []
export function refreshTree(): Promise<void> {
return new Promise((resolve) => {
treeWaiters.push(resolve)
return new Promise((resolve, reject) => {
treeWaiters.push({ resolve, reject })
scheduleTreeRefresh()
})
}
@@ -566,23 +566,23 @@ async function fetchTree() {
treeFetching = true
treeAgain = false
const edits = localTreeEdits
let entries: Entry[] | null = null
let entries: Entry[]
try {
entries = await api.tree()
} catch (err) {
toast('刷新目录失败:' + errorText(err))
treeFetching = false
for (const waiter of treeWaiters.splice(0)) waiter.reject(err)
return
}
treeFetching = false
if (entries && (treeAgain || edits !== localTreeEdits || opsInFlight > 0)) {
if (treeAgain || edits !== localTreeEdits || opsInFlight > 0) {
// 有操作进行中时由 withOp 在全部结束后重新调度
if (opsInFlight === 0) scheduleTreeRefresh()
return
}
if (entries) {
ws.entries = entries
markMissingTabsDeleted()
}
for (const resolve of treeWaiters.splice(0)) resolve()
for (const waiter of treeWaiters.splice(0)) waiter.resolve()
}
function markMissingTabsDeleted() {
@@ -716,7 +716,7 @@ export async function move(from: string, to: string) {
} else await syncOpenTab(tab)
}))
})
void refreshTree()
void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
}
export async function remove(path: string) {
@@ -767,14 +767,13 @@ async function syncOpenTab(tab: Tab) {
if (err instanceof ApiError && err.status === 404) {
tab.status = 'deleted'
tab.conflictRev = ''
}
} else throw err
}
}
function handleEvents(evs: VaultEvent[]) {
async function handleEvents(evs: VaultEvent[]) {
for (const ev of evs) {
if (ev.type === 'tree') void refreshTree()
else if (ev.type === 'settings') {
if (ev.type === 'settings') {
void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
void refreshPlugins()
}
@@ -782,7 +781,7 @@ function handleEvents(evs: VaultEvent[]) {
else if (ev.type === 'changed') {
const tab = tabOf(ev.path!)
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) void syncOpenTab(tab)
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!)
@@ -795,7 +794,7 @@ function handleEvents(evs: VaultEvent[]) {
}
if (ev.type === 'changed' || ev.type === 'moved' || ev.type === 'deleted') pluginEvents.dispatchEvent(new CustomEvent(ev.type, { detail: ev.type === 'moved' ? { from: ev.from!, path: ev.path! } : ev.type === 'changed' ? { path: ev.path!, rev: ev.rev! } : { path: ev.path! } }))
}
if (evs.some((event) => event.type === 'tree')) void resync().catch((err) => toast('插件同步失败:' + errorText(err)))
if (evs.some((event) => event.type === 'tree')) await resync()
}
async function resync() {
@@ -851,7 +850,14 @@ export async function startWorkspace() {
toast('恢复本地草稿失败:' + errorText(err))
}
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() })
let eventQueue = Promise.resolve()
let stopped = false
const enqueue = (work: () => Promise<void>) => {
// 同步读取是异步的,后续批次不能越过前一批次的移动和正文更新。
eventQueue = eventQueue.then(() => { if (!stopped) return work() }).catch((err) => toast('同步失败:' + errorText(err)))
}
const stopEvents = connectEvents({ onEvents: (events) => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
disconnect = () => { stopped = true; stopEvents() }
setPluginEditorHandlers(reconfigurePluginStates, pluginEditorFailure)
startPlugins({
notice: toast,
+5 -1
View File
@@ -154,7 +154,11 @@ const codeHighlighter = ViewPlugin.fromClass(
effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
} else {
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算
requestTokens(block.lang, block.code).then(() => this.schedule())
requestTokens(block.lang, block.code).then(() => this.schedule()).catch((error) => {
// 本次内容失败后不随每次光标移动重试,修改代码或语言后重新请求。
if (!this.destroyed) this.applied.set(block.from, key)
console.error('代码高亮失败', error)
})
}
}
if (effects.length) this.view.dispatch({ effects })
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import type { HighlightRequest } from './protocol'
import { requestTokens } from './client'
const control = vi.hoisted(() => ({ startupError: undefined as Error | undefined, workers: [] as { dispatchEvent(event: Event): boolean; request?: HighlightRequest }[] }))
vi.mock('./worker?worker', () => ({
default: class extends EventTarget {
request?: HighlightRequest
constructor() {
super()
if (control.startupError) throw control.startupError
control.workers.push(this)
}
postMessage(request: HighlightRequest) { this.request = request }
terminate() {}
},
}))
it('启动和运行失败均结束请求,失败不会伪装成缓存结果且后续请求可恢复', async () => {
const startupError = new Error('无法创建 Worker')
control.startupError = startupError
await expect(requestTokens('python', 'print(1)')).rejects.toBe(startupError)
control.startupError = undefined
const failed = requestTokens('python', 'print(1)')
const error = new Error('语言加载失败')
const worker = control.workers[0]!
const rejected = expect(failed).rejects.toBe(error)
worker.dispatchEvent(new MessageEvent('message', { data: { id: worker.request!.id, error } }))
await rejected
const interrupted = requestTokens('python', 'print(1)')
const crashed = expect(interrupted).rejects.toMatchObject({ cause: error })
worker.dispatchEvent(new ErrorEvent('error', { error }))
await crashed
const recovered = requestTokens('python', 'print(1)')
const replacement = control.workers[1]!
replacement.dispatchEvent(new MessageEvent('message', { data: { id: replacement.request!.id, tokens: [0, 5, '#fff', 0] } }))
await expect(recovered).resolves.toEqual([0, 5, '#fff', 0])
})
+19 -7
View File
@@ -7,7 +7,7 @@ const CACHE_LIMIT = 500
let worker: Worker | undefined
let nextId = 1
const pending = new Map<number, (tokens: TokenRun | null) => void>()
const pending = new Map<number, { resolve: (tokens: TokenRun | null) => void; reject: (reason: unknown) => void }>()
// 以"语言 + 代码"为键缓存结果;Map 保持插入顺序,超限时淘汰最早的条目
const cache = new Map<string, TokenRun | null>()
@@ -15,9 +15,17 @@ function getWorker(): Worker {
if (!worker) {
worker = new HighlightWorker()
worker.addEventListener('message', (e: MessageEvent<HighlightResponse>) => {
const resolve = pending.get(e.data.id)
const request = pending.get(e.data.id)
pending.delete(e.data.id)
resolve?.(e.data.tokens)
if ('error' in e.data) request?.reject(e.data.error)
else request?.resolve(e.data.tokens)
})
worker.addEventListener('error', (event) => {
const error = new Error('高亮 Worker 运行失败', { cause: event.error ?? event.message })
for (const request of pending.values()) request.reject(error)
pending.clear()
worker?.terminate()
worker = undefined
})
}
return worker
@@ -27,17 +35,21 @@ export function cachedTokens(lang: string, code: string): TokenRun | null | unde
return cache.get(lang + '\0' + code)
}
export function requestTokens(lang: string, code: string): Promise<TokenRun | null> {
export async function requestTokens(lang: string, code: string): Promise<TokenRun | null> {
const key = lang + '\0' + code
if (cache.has(key)) return Promise.resolve(cache.get(key)!)
const id = nextId++
const req: HighlightRequest = { id, lang, code }
return new Promise((resolve) => {
pending.set(id, (tokens) => {
const target = getWorker()
return new Promise((resolve, reject) => {
pending.set(id, {
resolve: (tokens) => {
cache.set(key, tokens)
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!)
resolve(tokens)
},
reject,
})
getWorker().postMessage(req)
try { target.postMessage(req) } catch (error) { pending.delete(id); reject(error) }
})
}
+2 -5
View File
@@ -9,8 +9,5 @@ export interface HighlightRequest {
// 扁平数组:[offset, length, color, fontStyle, offset, length, ...],避免大量小对象跨线程拷贝
export type TokenRun = (number | string)[]
export interface HighlightResponse {
id: number
// 语言不受支持时为 null,调用方按纯文本显示
tokens: TokenRun | null
}
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
export type HighlightResponse = { id: number; tokens: TokenRun | null } | { id: number; error: Error }
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, expect, it, vi } from 'vitest'
import { bundledLanguages } from 'shiki/langs'
import type { HighlightResponse } from './protocol'
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('未知语言正常完成,语言加载失败返回错误并允许下一次明确请求恢复', async () => {
const replies = new Map<number, HighlightResponse>()
const worker = new class extends EventTarget {
postMessage(response: HighlightResponse) { replies.set(response.id, response) }
}()
vi.stubGlobal('self', worker)
await import('./worker')
for (const [id, lang] of ['unknown-language', 'constructor', '__proto__'].entries()) {
worker.dispatchEvent(new MessageEvent('message', { data: { id, lang, code: 'text' } }))
await vi.waitFor(() => expect(replies.get(id)).toEqual({ id, tokens: null }))
}
const cause = new Error('语言资源加载失败')
const load = vi.spyOn(bundledLanguages, 'python').mockRejectedValueOnce(cause)
worker.dispatchEvent(new MessageEvent('message', { data: { id: 10, lang: 'python', code: 'print(1)' } }))
await vi.waitFor(() => expect(replies.has(10)).toBe(true))
const failure = replies.get(10)!
expect('error' in failure && failure.error.cause).toBe(cause)
load.mockRestore()
worker.dispatchEvent(new MessageEvent('message', { data: { id: 11, lang: 'python', code: 'print(1)' } }))
await vi.waitFor(() => expect(replies.has(11)).toBe(true))
const success = replies.get(11)!
expect('tokens' in success && success.tokens?.length).toBeGreaterThan(0)
})
+9 -4
View File
@@ -18,9 +18,9 @@ const langLoads = new Map<string, Promise<boolean>>()
function ensureLanguage(hl: HighlighterCore, lang: string): Promise<boolean> {
let load = langLoads.get(lang)
if (!load) {
const importer = bundledLanguages[lang as keyof typeof bundledLanguages]
const importer = Object.hasOwn(bundledLanguages, lang) ? bundledLanguages[lang as keyof typeof bundledLanguages] : undefined
load = importer
? importer().then((mod) => hl.loadLanguage(mod.default)).then(() => true)
? importer().then((mod) => hl.loadLanguage(mod.default)).then(() => true).catch((error) => { langLoads.delete(lang); throw error })
: Promise.resolve(false)
langLoads.set(lang, load)
}
@@ -41,6 +41,11 @@ async function highlight(req: HighlightRequest): Promise<TokenRun | null> {
}
self.addEventListener('message', async (e: MessageEvent<HighlightRequest>) => {
const res: HighlightResponse = { id: e.data.id, tokens: await highlight(e.data) }
;(self as unknown as Worker).postMessage(res)
let res: HighlightResponse
try {
res = { id: e.data.id, tokens: await highlight(e.data) }
} catch (cause) {
res = { id: e.data.id, error: new Error('代码高亮失败', { cause }) }
}
self.postMessage(res)
})
+24
View File
@@ -0,0 +1,24 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '../app/api'
import { allCommands } from '../app/commands'
import type { PluginAPI } from '../plugin-api'
import { pluginState, startPlugins, stopPlugins } from './host'
const { cause } = vi.hoisted(() => ({ cause: new Error('插件命令的原始原因') }))
vi.mock('/api/v1/plugins/fault/assets/rev/main.js', () => ({
default: (sdk: PluginAPI) => sdk.addCommand({ id: 'fail', title: '失败命令', run() { throw cause } }),
}))
afterEach(() => { stopPlugins(); vi.restoreAllMocks() })
it('命令失败保留原始异常并清理注册,正常插件列表读取不受影响', async () => {
vi.spyOn(api, 'plugins').mockResolvedValue([{ id: 'fault', name: '故障插件', version: '1', apiVersion: 1, revision: 'rev', enabled: true }])
const notices: string[] = []
startPlugins({ notice: (message) => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
allCommands().find((command) => command.id === 'plugin:fault:fail')!.run()
await vi.waitFor(() => expect(pluginState.status.fault?.cause).toBe(cause))
expect(pluginState.status.fault?.phase).toBe('error')
expect(allCommands().some((command) => command.id === 'plugin:fault:fail')).toBe(false)
expect(notices.some((message) => message.includes('命令失败'))).toBe(true)
})
+26 -17
View File
@@ -4,7 +4,7 @@ import { allCommands, registerCommand } from '../app/commands'
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '../plugin-api'
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
export const pluginState = reactive({ installed: [] as InstalledPlugin[], status: {} as Record<string, { phase: string; error: string }>, error: '' })
export const pluginState = reactive({ installed: [] as InstalledPlugin[], status: {} as Record<string, { phase: string; error: string; cause?: unknown; cleanupErrors?: unknown[] }>, error: '', errorCause: undefined as unknown })
interface Adapter extends PluginWorkspace {
notice(message: string): void
writeNote: PluginAPI['vault']['write']
@@ -15,6 +15,7 @@ interface Instance {
stopped: boolean
cleanups: Set<() => void>
pending: Promise<unknown>[]
cleanupErrors: unknown[]
}
const instances = new Map<string, Instance>()
const faults = new Map<string, string>()
@@ -24,11 +25,16 @@ let session = 0
let request = 0
let loading = false
function status(id: string, phase: string, error = '') { pluginState.status[id] = { phase, error } }
function status(id: string, phase: string, error = '', cause?: unknown) { pluginState.status[id] = { phase, error, cause, cleanupErrors: pluginState.status[id]?.cleanupErrors } }
function assertActive(instance: Instance) {
if (instance.stopped || !running) throw new Error('插件已停止,不能继续调用 SDK')
}
function cleanupFailure(instance: Instance, cause: unknown) {
instance.cleanupErrors.push(cause)
adapter.notice(`插件“${instance.info.name}”清理失败`)
}
function track(instance: Instance, cleanup: AsyncCleanup): () => void {
assertActive(instance)
let done = false
@@ -37,9 +43,9 @@ function track(instance: Instance, cleanup: AsyncCleanup): () => void {
done = true
instance.cleanups.delete(remove)
try {
const pending = Promise.resolve(cleanup()).catch(() => adapter.notice(`插件“${instance.info.name}”清理失败`))
const pending = Promise.resolve(cleanup()).catch((cause) => cleanupFailure(instance, cause))
instance.pending.push(pending)
} catch { adapter.notice(`插件“${instance.info.name}”清理失败`) }
} catch (cause) { cleanupFailure(instance, cause) }
}
instance.cleanups.add(remove)
return remove
@@ -60,11 +66,11 @@ async function stop(instance: Instance) {
if (!finished) adapter.notice(`插件“${instance.info.name}”清理超过 5 秒`)
}
function fail(instance: Instance, phase: string) {
function fail(instance: Instance, phase: string, cause: unknown) {
if (instance.stopped) return
const message = `${phase}失败,已在当前页面停用;可在插件设置中重试`
faults.set(instance.info.id, instance.info.revision)
status(instance.info.id, 'error', message)
status(instance.info.id, 'error', message, cause)
adapter.notice(`插件“${instance.info.name}”:${message}`)
void stop(instance)
}
@@ -77,7 +83,7 @@ function localID(instance: Instance, id: string) {
function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) {
assertActive(instance)
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error('插件视图必须提供标题与挂载函数')
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: () => fail(instance, '视图') }))
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: (cause) => fail(instance, '视图', cause) }))
}
async function createAPI(instance: Instance): Promise<PluginAPI> {
@@ -96,11 +102,11 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
return track(instance, registerCommand({ ...command, id: commandID,
when: () => {
if (instance.stopped) return false
try { return command.when?.() ?? true } catch { fail(instance, '命令条件'); return false }
try { return command.when?.() ?? true } catch (cause) { fail(instance, '命令条件', cause); return false }
},
run: () => {
if (instance.stopped) return
try { void Promise.resolve(command.run()).catch(() => fail(instance, '命令')) } catch { fail(instance, '命令') }
try { void Promise.resolve(command.run()).catch((cause) => fail(instance, '命令', cause)) } catch (cause) { fail(instance, '命令', cause) }
},
}))
},
@@ -115,7 +121,7 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
registerCodeBlockRenderer(language, render) {
check()
if (typeof render !== 'function') throw new Error('代码块渲染器必须为函数')
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: () => fail(instance, '代码块渲染') }))
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: (cause) => fail(instance, '代码块渲染', cause) }))
},
addSidebarView: (view) => addView(instance, sidebarViews, view),
addSettingTab: (view) => addView(instance, settingTabs, view),
@@ -132,7 +138,7 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error('仓库事件类型无效')
const listener = (e: Event) => {
if (instance.stopped) return
try { void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(() => fail(instance, '仓库事件')) } catch { fail(instance, '仓库事件') }
try { void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch((cause) => fail(instance, '仓库事件', cause)) } catch (cause) { fail(instance, '仓库事件', cause) }
}
pluginEvents.addEventListener(event, listener)
return track(instance, () => pluginEvents.removeEventListener(event, listener))
@@ -171,15 +177,15 @@ async function activate(instance: Instance) {
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error('激活返回值必须为清理函数')
if (cleanup) {
if (instance.stopped) {
try { await cleanup() } catch { adapter.notice(`插件“${instance.info.name}”迟到的清理失败`) }
try { await cleanup() } catch (cause) { cleanupFailure(instance, cause) }
} else track(instance, cleanup as AsyncCleanup)
}
})()
try {
await Promise.race([pending, interrupted])
if (!instance.stopped) status(instance.info.id, 'active')
} catch {
fail(instance, '激活')
} catch (cause) {
fail(instance, '激活', cause)
} finally {
clearTimeout(timer!)
signal.removeEventListener('abort', onAbort)
@@ -194,9 +200,10 @@ async function pump() {
while (running && currentSession === session && !pluginState.error) {
const next = pluginState.installed.find((p) => p.enabled && !instances.has(p.id) && faults.get(p.id) !== p.revision)
if (!next) break
const instance: Instance = { info: { ...next }, controller: new AbortController(), stopped: false, cleanups: new Set(), pending: [] }
const instance: Instance = { info: { ...next }, controller: new AbortController(), stopped: false, cleanups: new Set(), pending: [], cleanupErrors: [] }
instances.set(next.id, instance)
status(next.id, 'loading')
pluginState.status[next.id]!.cleanupErrors = instance.cleanupErrors
await activate(instance)
}
} finally { loading = false; if (running && currentSession !== session) void pump() }
@@ -211,6 +218,7 @@ export async function refreshPlugins() {
if (!running || currentSession !== session || sequence !== request) return
pluginState.installed = installed
pluginState.error = ''
pluginState.errorCause = undefined
for (const instance of instances.values()) {
if (!installed.some((p) => p.id === instance.info.id && p.revision === instance.info.revision && p.enabled)) void stop(instance)
}
@@ -219,9 +227,10 @@ export async function refreshPlugins() {
else if (pluginsPaused) status(p.id, 'paused')
}
void pump()
} catch {
} catch (cause) {
if (!running || currentSession !== session || sequence !== request) return
pluginState.error = '读取插件列表失败,请检查连接后重试'
pluginState.errorCause = cause
adapter.notice(pluginState.error)
for (const instance of instances.values()) {
status(instance.info.id, 'paused')
@@ -258,7 +267,7 @@ export function pluginEditorFailure(error: unknown) {
queueMicrotask(() => {
for (const owner of owners) {
const instance = instances.get(owner)
if (instance) fail(instance, '编辑器扩展')
if (instance) fail(instance, '编辑器扩展', error)
}
})
}
+4 -1
View File
@@ -100,7 +100,10 @@ function render(note: SharedNote) {
resolveImages(article)
buildToc(article)
hydrate(article)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch((error) => {
code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error)
})
}
// 相对地址的图片(笔记的 .assets)经分享接口读取;服务器只允许读取本篇的图片目录