fix(editor): 修复 IdleTask 任务异常或 clearTasks 时队列卡住的问题

单个任务失败不再中断整批依赖收集,并在 runTaskQueue 中正确重置 taskHandle、始终通过实例队列消费任务。
This commit is contained in:
roymondchen 2026-07-28 15:32:58 +08:00
parent a123747e9d
commit 3fe9cf9b66
3 changed files with 167 additions and 41 deletions

View File

@ -1,5 +1,7 @@
import { EventEmitter } from 'events';
import { error } from '../logger';
export interface IdleTaskEvents {
finish: [];
'hight-level-finish': [];
@ -64,7 +66,7 @@ export class IdleTask<T = any> extends EventEmitter {
this.taskHandle = null;
this.emit('update-task-length', {
length: this.taskList.length + this.hightLevelTaskList.length,
length: this.getTaskLength(),
hightLevelLength: this.hightLevelTaskList.length,
});
}
@ -88,51 +90,80 @@ export class IdleTask<T = any> extends EventEmitter {
}
private runTaskQueue(deadline: IdleDeadline) {
const { hightLevelTaskList, taskList } = this;
// 本次回调已触发,句柄随之失效;置空后 clearTasks / enqueueTask 才能正确判断是否需要重新调度
this.taskHandle = null;
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
// 执行不完不会影响配置,但是会影响画布渲染
while (deadline.timeRemaining() > 0 && (taskList.length || hightLevelTaskList.length)) {
const timeRemaining = deadline.timeRemaining();
let times = 0;
if (timeRemaining <= 5) {
times = 10;
} else if (timeRemaining <= 10) {
times = 100;
} else if (timeRemaining <= 15) {
times = 300;
try {
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
// 执行不完不会影响配置,但是会影响画布渲染
while (deadline.timeRemaining() > 0 && this.getTaskLength()) {
const timeRemaining = deadline.timeRemaining();
let times = 0;
if (timeRemaining <= 5) {
times = 10;
} else if (timeRemaining <= 10) {
times = 100;
} else if (timeRemaining <= 15) {
times = 300;
} else {
times = 600;
}
for (let i = 0; i < times; i++) {
// 每次都从实例上取队列,任务执行过程中调用 clearTasks 能立即生效,不会继续消费已被清空的旧队列
const task = this.hightLevelTaskList.length > 0 ? this.hightLevelTaskList.shift() : this.taskList.shift();
if (task) {
this.runTask(task);
}
if (!this.getTaskLength()) {
break;
}
}
}
} finally {
// 必须放在 finally 中一旦这里被跳过taskHandle 会一直是真值,
// enqueueTask 也就不会再重新调度,剩余任务与任务数将永久停在当前状态
this.finishRun();
}
}
/**
*
*
*/
private runTask(task: TaskList<T>[number]) {
try {
task.handler(task.data);
} catch (e) {
error('magic editor: 空闲任务执行失败', e);
}
}
private finishRun() {
try {
if (!this.hightLevelTaskList.length) {
this.emit('hight-level-finish');
}
} finally {
if (this.getTaskLength()) {
// 任务执行或事件监听中可能已经重新调度过
if (!this.taskHandle) {
this.taskHandle = globalThis.requestIdleCallback(this.runTaskQueue.bind(this), { timeout: 300 });
}
} else {
times = 600;
this.emit('finish');
}
for (let i = 0; i < times; i++) {
const task = hightLevelTaskList.length > 0 ? hightLevelTaskList.shift() : taskList.shift();
if (task) {
task.handler(task.data);
}
if (hightLevelTaskList.length === 0 && taskList.length === 0) {
break;
}
}
this.emit('update-task-length', {
length: this.getTaskLength(),
hightLevelLength: this.hightLevelTaskList.length,
});
}
}
if (!hightLevelTaskList.length) {
this.emit('hight-level-finish');
}
if (hightLevelTaskList.length || taskList.length) {
this.taskHandle = globalThis.requestIdleCallback(this.runTaskQueue.bind(this), { timeout: 300 });
} else {
this.taskHandle = 0;
this.emit('finish');
}
this.emit('update-task-length', {
length: taskList.length + hightLevelTaskList.length,
hightLevelLength: hightLevelTaskList.length,
});
private getTaskLength() {
return this.taskList.length + this.hightLevelTaskList.length;
}
}

View File

@ -297,6 +297,29 @@ describe('Dep service', () => {
expect(depService.get('collecting')).toBe(false);
});
test('target 收集抛错时批次仍会结算collecting 与 taskLength 复位', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
depService.addTarget(
new Target({
id: 'throw-target',
type: DepTargetType.DATA_SOURCE,
isTarget: () => {
throw new Error('boom');
},
}),
);
const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, type: 'text', text: 'x' })) as any;
await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(depService.get('collecting')).toBe(false);
// taskLength 的更新做了 1s 节流,等节流窗口结束后才会同步到 0
await new Promise((resolve) => setTimeout(resolve, 1100));
expect(depService.get('taskLength')).toBe(0);
errorSpy.mockRestore();
});
test('destroy 会 reset 并移除监听', () => {
depService.addTarget(makeTarget('destroy-me'));
expect(() => depService.destroy()).not.toThrow();

View File

@ -6,6 +6,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { IdleTask } from '@editor/utils/dep/idle-task';
import * as logger from '@editor/utils/logger';
const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadline => {
let remainingCalls = callsBeforeZero;
@ -127,6 +128,77 @@ describe('IdleTask', () => {
expect(scheduled.length).toBeGreaterThan(1);
});
test('单个任务抛错不会中断整个队列', () => {
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const task = new IdleTask<number>();
const done: number[] = [];
const finishHandler = vi.fn();
task.on('finish', finishHandler);
for (let i = 0; i < 10; i++) {
task.enqueueTask((n) => {
if (n === 3) throw new Error('boom');
done.push(n);
}, i);
}
expect(() => scheduled[0].cb(fakeIdleDeadline(50))).not.toThrow();
expect(done).toEqual([0, 1, 2, 4, 5, 6, 7, 8, 9]);
expect(finishHandler).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
test('任务抛错且仍有剩余任务时继续调度下一轮并同步任务数', () => {
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const task = new IdleTask<number>();
const updateHandler = vi.fn();
task.on('update-task-length', updateHandler);
for (let i = 0; i < 200; i++) {
task.enqueueTask((n) => {
if (n === 0) throw new Error('boom');
}, i);
}
// 单批最多执行 10 个,执行完仍有剩余任务
scheduled[0].cb(fakeIdleDeadline(3, 1));
expect(scheduled.length).toBeGreaterThan(1);
expect(updateHandler).toHaveBeenCalledWith({ length: 190, hightLevelLength: 0 });
errorSpy.mockRestore();
});
test('任务执行过程中 clearTasks 会立即停止消费已清空的队列', () => {
const task = new IdleTask<number>();
const handler = vi.fn((n: number) => {
if (n === 0) {
task.clearTasks();
}
});
for (let i = 0; i < 200; i++) task.enqueueTask(handler, i);
scheduled[0].cb(fakeIdleDeadline(50));
expect(handler).toHaveBeenCalledTimes(1);
});
test('任务执行过程中清空并重新入队,新任务仍会被执行', () => {
const task = new IdleTask<number>();
const afterHandler = vi.fn();
task.enqueueTask((n) => {
if (n === 0) {
task.clearTasks();
task.enqueueTask(afterHandler, 999);
}
}, 0);
for (let i = 1; i < 200; i++) task.enqueueTask(() => undefined, i);
scheduled[0].cb(fakeIdleDeadline(50));
expect(afterHandler).toHaveBeenCalled();
});
test('clearTasks - 取消挂起任务并重置队列', () => {
const task = new IdleTask<number>();
task.enqueueTask(() => undefined, 1);