fix(FN-1285): harden async listener error guards
- Wrap TaskExecutor task:updated async listener logic in a top-level guard and log uncaught listener failures - Add explicit catch handling for ProjectManager activity logging and dashboard stuck-detector settings-triggered checkNow calls - Document async EventEmitter guard conventions in scheduler and hybrid executor listener wiring - Add regression tests for executor and dashboard listener guards and include a patch changeset for @gsxdsm/fusion
This commit is contained in:
@@ -14,6 +14,7 @@ const {
|
||||
mockSelfHealingStart,
|
||||
mockSelfHealingStop,
|
||||
mockCheckStuckBudget,
|
||||
mockStuckCheckNow,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthStorage: { getAuth: vi.fn(), setAuth: vi.fn() },
|
||||
mockModelRegistry: {
|
||||
@@ -28,6 +29,7 @@ const {
|
||||
mockSelfHealingStart: vi.fn(),
|
||||
mockSelfHealingStop: vi.fn(),
|
||||
mockCheckStuckBudget: vi.fn().mockResolvedValue(true),
|
||||
mockStuckCheckNow: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||
@@ -197,6 +199,14 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
resumeOrphaned: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}),
|
||||
StuckTaskDetector: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
checkNow: mockStuckCheckNow,
|
||||
trackTask: vi.fn(),
|
||||
untrackTask: vi.fn(),
|
||||
markTaskProgress: vi.fn(),
|
||||
})),
|
||||
Scheduler: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
@@ -311,6 +321,8 @@ beforeEach(() => {
|
||||
mockExecSync.mockReset();
|
||||
mockExecSync.mockReturnValue("");
|
||||
mockExec.mockClear();
|
||||
mockStuckCheckNow.mockReset();
|
||||
mockStuckCheckNow.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("PR merge helpers", () => {
|
||||
@@ -921,6 +933,54 @@ describe("runDashboard — engine pause/unpause cycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — stuck task timeout listener guards", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
const engine = await import("@fusion/engine");
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("catches and logs checkNow errors when taskStuckTimeoutMs changes", async () => {
|
||||
const detectorError = new Error("detector exploded");
|
||||
mockStuckCheckNow.mockRejectedValueOnce(detectorError);
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const unhandledRejectionSpy = vi.fn();
|
||||
process.on("unhandledRejection", unhandledRejectionSpy);
|
||||
|
||||
try {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: { taskStuckTimeoutMs: 600_000 },
|
||||
previous: { taskStuckTimeoutMs: 1_200_000 },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[stuck-detector] Error during immediate stuck-task check:",
|
||||
detectorError,
|
||||
);
|
||||
expect(unhandledRejectionSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.off("unhandledRejection", unhandledRejectionSpy);
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
|
||||
@@ -794,8 +794,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// immediately check for stuck tasks under the new timer value.
|
||||
registerHandler(store, "settings:updated", async ({ settings: s, previous: prev }) => {
|
||||
if (s.taskStuckTimeoutMs !== prev.taskStuckTimeoutMs) {
|
||||
console.log(`[stuck-detector] Timeout changed to ${s.taskStuckTimeoutMs}ms — running immediate check`);
|
||||
await stuckTaskDetector.checkNow();
|
||||
try {
|
||||
console.log(`[stuck-detector] Timeout changed to ${s.taskStuckTimeoutMs}ms — running immediate check`);
|
||||
await stuckTaskDetector.checkNow();
|
||||
} catch (err) {
|
||||
console.error("[stuck-detector] Error during immediate stuck-task check:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user