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
+240 -61

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted } from 'vue'
import { api } from './api' import { api, ApiError } from './api'
import { registerCommand } from './commands' import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue' import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
@@ -79,7 +79,7 @@ function onTouchMove(e: TouchEvent) {
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() }) 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(() => { onBeforeUnmount(() => {
unregisterLogout() unregisterLogout()
stopWorkspace() stopWorkspace()
+52 -1
View File
@@ -4,16 +4,67 @@ import { EditorView } from '@codemirror/view'
import { insertImageFiles } from '../editor/paste' import { insertImageFiles } from '../editor/paste'
import { api, ApiError } from './api' import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts' 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 let view: EditorView | undefined
afterEach(() => { afterEach(() => {
stopWorkspace()
attachView(null) attachView(null)
view?.destroy() view?.destroy()
view = undefined view = undefined
vi.restoreAllMocks() 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) { async function edit(path: string) {
vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' }) vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined) vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
+26 -20
View File
@@ -382,7 +382,7 @@ export async function keepLocalVersion(path: string) {
s.savedSeq = seq s.savedSeq = seq
Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined }) Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined })
if (s.seq === seq) await drafts.delete(path) 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) { } catch (err) {
if (err instanceof ApiError && err.status === 412) { if (err instanceof ApiError && err.status === 412) {
tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : '' tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : ''
@@ -544,11 +544,11 @@ let treeTimer: ReturnType<typeof setTimeout> | undefined
let treeFetching = false let treeFetching = false
let treeAgain = false let treeAgain = false
let localTreeEdits = 0 let localTreeEdits = 0
const treeWaiters: (() => void)[] = [] const treeWaiters: { resolve: () => void; reject: (reason: unknown) => void }[] = []
export function refreshTree(): Promise<void> { export function refreshTree(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
treeWaiters.push(resolve) treeWaiters.push({ resolve, reject })
scheduleTreeRefresh() scheduleTreeRefresh()
}) })
} }
@@ -566,23 +566,23 @@ async function fetchTree() {
treeFetching = true treeFetching = true
treeAgain = false treeAgain = false
const edits = localTreeEdits const edits = localTreeEdits
let entries: Entry[] | null = null let entries: Entry[]
try { try {
entries = await api.tree() entries = await api.tree()
} catch (err) { } catch (err) {
toast('刷新目录失败:' + errorText(err)) treeFetching = false
for (const waiter of treeWaiters.splice(0)) waiter.reject(err)
return
} }
treeFetching = false treeFetching = false
if (entries && (treeAgain || edits !== localTreeEdits || opsInFlight > 0)) { if (treeAgain || edits !== localTreeEdits || opsInFlight > 0) {
// 有操作进行中时由 withOp 在全部结束后重新调度 // 有操作进行中时由 withOp 在全部结束后重新调度
if (opsInFlight === 0) scheduleTreeRefresh() if (opsInFlight === 0) scheduleTreeRefresh()
return return
} }
if (entries) { ws.entries = entries
ws.entries = entries markMissingTabsDeleted()
markMissingTabsDeleted() for (const waiter of treeWaiters.splice(0)) waiter.resolve()
}
for (const resolve of treeWaiters.splice(0)) resolve()
} }
function markMissingTabsDeleted() { function markMissingTabsDeleted() {
@@ -716,7 +716,7 @@ export async function move(from: string, to: string) {
} else await syncOpenTab(tab) } else await syncOpenTab(tab)
})) }))
}) })
void refreshTree() void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
} }
export async function remove(path: string) { export async function remove(path: string) {
@@ -767,14 +767,13 @@ async function syncOpenTab(tab: Tab) {
if (err instanceof ApiError && err.status === 404) { if (err instanceof ApiError && err.status === 404) {
tab.status = 'deleted' tab.status = 'deleted'
tab.conflictRev = '' tab.conflictRev = ''
} } else throw err
} }
} }
function handleEvents(evs: VaultEvent[]) { async function handleEvents(evs: VaultEvent[]) {
for (const ev of evs) { for (const ev of evs) {
if (ev.type === 'tree') void refreshTree() if (ev.type === 'settings') {
else if (ev.type === 'settings') {
void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err))) void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
void refreshPlugins() void refreshPlugins()
} }
@@ -782,7 +781,7 @@ function handleEvents(evs: VaultEvent[]) {
else if (ev.type === 'changed') { else if (ev.type === 'changed') {
const tab = tabOf(ev.path!) const tab = tabOf(ev.path!)
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理 // 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) void syncOpenTab(tab) if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') { } else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!) if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
remapRecent(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 (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() { async function resync() {
@@ -851,7 +850,14 @@ export async function startWorkspace() {
toast('恢复本地草稿失败:' + errorText(err)) toast('恢复本地草稿失败:' + errorText(err))
} }
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null) 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) setPluginEditorHandlers(reconfigurePluginStates, pluginEditorFailure)
startPlugins({ startPlugins({
notice: toast, 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) })) effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
} else { } else {
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算 // 结果到达后再走一次 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 }) 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])
})
+22 -10
View File
@@ -7,7 +7,7 @@ const CACHE_LIMIT = 500
let worker: Worker | undefined let worker: Worker | undefined
let nextId = 1 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 保持插入顺序,超限时淘汰最早的条目 // 以"语言 + 代码"为键缓存结果;Map 保持插入顺序,超限时淘汰最早的条目
const cache = new Map<string, TokenRun | null>() const cache = new Map<string, TokenRun | null>()
@@ -15,9 +15,17 @@ function getWorker(): Worker {
if (!worker) { if (!worker) {
worker = new HighlightWorker() worker = new HighlightWorker()
worker.addEventListener('message', (e: MessageEvent<HighlightResponse>) => { 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) 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 return worker
@@ -27,17 +35,21 @@ export function cachedTokens(lang: string, code: string): TokenRun | null | unde
return cache.get(lang + '\0' + code) 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 const key = lang + '\0' + code
if (cache.has(key)) return Promise.resolve(cache.get(key)!) if (cache.has(key)) return Promise.resolve(cache.get(key)!)
const id = nextId++ const id = nextId++
const req: HighlightRequest = { id, lang, code } const req: HighlightRequest = { id, lang, code }
return new Promise((resolve) => { const target = getWorker()
pending.set(id, (tokens) => { return new Promise((resolve, reject) => {
cache.set(key, tokens) pending.set(id, {
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!) resolve: (tokens) => {
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, ...],避免大量小对象跨线程拷贝 // 扁平数组:[offset, length, color, fontStyle, offset, length, ...],避免大量小对象跨线程拷贝
export type TokenRun = (number | string)[] export type TokenRun = (number | string)[]
export interface HighlightResponse { // 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
id: number export type HighlightResponse = { id: number; tokens: TokenRun | null } | { id: number; error: Error }
// 语言不受支持时为 null,调用方按纯文本显示
tokens: TokenRun | null
}
+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> { function ensureLanguage(hl: HighlighterCore, lang: string): Promise<boolean> {
let load = langLoads.get(lang) let load = langLoads.get(lang)
if (!load) { 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 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) : Promise.resolve(false)
langLoads.set(lang, load) langLoads.set(lang, load)
} }
@@ -41,6 +41,11 @@ async function highlight(req: HighlightRequest): Promise<TokenRun | null> {
} }
self.addEventListener('message', async (e: MessageEvent<HighlightRequest>) => { self.addEventListener('message', async (e: MessageEvent<HighlightRequest>) => {
const res: HighlightResponse = { id: e.data.id, tokens: await highlight(e.data) } let res: HighlightResponse
;(self as unknown as Worker).postMessage(res) 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 type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '../plugin-api'
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry' 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 { interface Adapter extends PluginWorkspace {
notice(message: string): void notice(message: string): void
writeNote: PluginAPI['vault']['write'] writeNote: PluginAPI['vault']['write']
@@ -15,6 +15,7 @@ interface Instance {
stopped: boolean stopped: boolean
cleanups: Set<() => void> cleanups: Set<() => void>
pending: Promise<unknown>[] pending: Promise<unknown>[]
cleanupErrors: unknown[]
} }
const instances = new Map<string, Instance>() const instances = new Map<string, Instance>()
const faults = new Map<string, string>() const faults = new Map<string, string>()
@@ -24,11 +25,16 @@ let session = 0
let request = 0 let request = 0
let loading = false 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) { function assertActive(instance: Instance) {
if (instance.stopped || !running) throw new Error('插件已停止,不能继续调用 SDK') 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 { function track(instance: Instance, cleanup: AsyncCleanup): () => void {
assertActive(instance) assertActive(instance)
let done = false let done = false
@@ -37,9 +43,9 @@ function track(instance: Instance, cleanup: AsyncCleanup): () => void {
done = true done = true
instance.cleanups.delete(remove) instance.cleanups.delete(remove)
try { 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) instance.pending.push(pending)
} catch { adapter.notice(`插件“${instance.info.name}”清理失败`) } } catch (cause) { cleanupFailure(instance, cause) }
} }
instance.cleanups.add(remove) instance.cleanups.add(remove)
return remove return remove
@@ -60,11 +66,11 @@ async function stop(instance: Instance) {
if (!finished) adapter.notice(`插件“${instance.info.name}”清理超过 5 秒`) 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 if (instance.stopped) return
const message = `${phase}失败,已在当前页面停用;可在插件设置中重试` const message = `${phase}失败,已在当前页面停用;可在插件设置中重试`
faults.set(instance.info.id, instance.info.revision) 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}`) adapter.notice(`插件“${instance.info.name}”:${message}`)
void stop(instance) void stop(instance)
} }
@@ -77,7 +83,7 @@ function localID(instance: Instance, id: string) {
function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) { function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) {
assertActive(instance) assertActive(instance)
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error('插件视图必须提供标题与挂载函数') 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> { 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, return track(instance, registerCommand({ ...command, id: commandID,
when: () => { when: () => {
if (instance.stopped) return false 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: () => { run: () => {
if (instance.stopped) return 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) { registerCodeBlockRenderer(language, render) {
check() check()
if (typeof render !== 'function') throw new Error('代码块渲染器必须为函数') 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), addSidebarView: (view) => addView(instance, sidebarViews, view),
addSettingTab: (view) => addView(instance, settingTabs, 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('仓库事件类型无效') if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error('仓库事件类型无效')
const listener = (e: Event) => { const listener = (e: Event) => {
if (instance.stopped) return 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) pluginEvents.addEventListener(event, listener)
return track(instance, () => pluginEvents.removeEventListener(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 !== undefined && typeof cleanup !== 'function') throw new Error('激活返回值必须为清理函数')
if (cleanup) { if (cleanup) {
if (instance.stopped) { 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) } else track(instance, cleanup as AsyncCleanup)
} }
})() })()
try { try {
await Promise.race([pending, interrupted]) await Promise.race([pending, interrupted])
if (!instance.stopped) status(instance.info.id, 'active') if (!instance.stopped) status(instance.info.id, 'active')
} catch { } catch (cause) {
fail(instance, '激活') fail(instance, '激活', cause)
} finally { } finally {
clearTimeout(timer!) clearTimeout(timer!)
signal.removeEventListener('abort', onAbort) signal.removeEventListener('abort', onAbort)
@@ -194,9 +200,10 @@ async function pump() {
while (running && currentSession === session && !pluginState.error) { while (running && currentSession === session && !pluginState.error) {
const next = pluginState.installed.find((p) => p.enabled && !instances.has(p.id) && faults.get(p.id) !== p.revision) const next = pluginState.installed.find((p) => p.enabled && !instances.has(p.id) && faults.get(p.id) !== p.revision)
if (!next) break 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) instances.set(next.id, instance)
status(next.id, 'loading') status(next.id, 'loading')
pluginState.status[next.id]!.cleanupErrors = instance.cleanupErrors
await activate(instance) await activate(instance)
} }
} finally { loading = false; if (running && currentSession !== session) void pump() } } finally { loading = false; if (running && currentSession !== session) void pump() }
@@ -211,6 +218,7 @@ export async function refreshPlugins() {
if (!running || currentSession !== session || sequence !== request) return if (!running || currentSession !== session || sequence !== request) return
pluginState.installed = installed pluginState.installed = installed
pluginState.error = '' pluginState.error = ''
pluginState.errorCause = undefined
for (const instance of instances.values()) { 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) 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') else if (pluginsPaused) status(p.id, 'paused')
} }
void pump() void pump()
} catch { } catch (cause) {
if (!running || currentSession !== session || sequence !== request) return if (!running || currentSession !== session || sequence !== request) return
pluginState.error = '读取插件列表失败,请检查连接后重试' pluginState.error = '读取插件列表失败,请检查连接后重试'
pluginState.errorCause = cause
adapter.notice(pluginState.error) adapter.notice(pluginState.error)
for (const instance of instances.values()) { for (const instance of instances.values()) {
status(instance.info.id, 'paused') status(instance.info.id, 'paused')
@@ -258,7 +267,7 @@ export function pluginEditorFailure(error: unknown) {
queueMicrotask(() => { queueMicrotask(() => {
for (const owner of owners) { for (const owner of owners) {
const instance = instances.get(owner) 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) resolveImages(article)
buildToc(article) buildToc(article)
hydrate(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)经分享接口读取;服务器只允许读取本篇的图片目录 // 相对地址的图片(笔记的 .assets)经分享接口读取;服务器只允许读取本篇的图片目录