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:
gsxdsm
2026-04-08 11:49:33 -07:00
parent d3569361d9
commit 9ac5b19646
8 changed files with 227 additions and 100 deletions

View File

@@ -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>;

View File

@@ -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);
}
}
});