Propagate synchronization and plugin failures correctly
This commit is contained in:
1 parent
1e33f50c5e
commit
ac81544e03
12 files changed
+235
-56
No files matched your search
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+24
-18
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
@@ -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) => {
|
||||||
|
pending.set(id, {
|
||||||
|
resolve: (tokens) => {
|
||||||
cache.set(key, tokens)
|
cache.set(key, tokens)
|
||||||
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!)
|
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!)
|
||||||
resolve(tokens)
|
resolve(tokens)
|
||||||
|
},
|
||||||
|
reject,
|
||||||
})
|
})
|
||||||
getWorker().postMessage(req)
|
try { target.postMessage(req) } catch (error) { pending.delete(id); reject(error) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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)经分享接口读取;服务器只允许读取本篇的图片目录
|
||||||
|
|||||||
Reference in new issue
Block a user