Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/hold-my-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1899,20 +1899,37 @@ export class HoldMyTask extends EventEmitter {
error.taskId = item.id;

if (item.callback) {
// Callback API - emit error event
// Callback API - deliver the failure to the callback
try {
item.callback(error, null);
} catch (err) {
this.emit("error", { error: err, task: item });
}
// Emit error event for callback API
this.emit("error", { error, task: item });
// Also report it as a queue "error" event, but only to listeners: the callback already
// has the failure, so an unhandled-"error" throw would only crash the caller's process.
this._emitTaskError({ error, task: item });
} else if (item.reject) {
// Promise API - just reject, don't emit error event (handled by promise)
item.reject(error);
}
}

/**
* Emits a task failure as an `error` event when, and only when, a listener is attached.
* Used for callback-API task failures, which are always delivered to the task's callback
* as well; with no listener, EventEmitter would otherwise throw the event and crash the
* process before (or after) the callback could handle it.
* @param {Object} payload - Event payload
* @returns {void}
* @private
* @internal
*/
_emitTaskError(payload) {
if (this.listenerCount("error") > 0) {
this.emit("error", payload);
}
}

/**
* Starts execution of a ready task.
* @param {Object} item - The task item to execute
Expand Down Expand Up @@ -1998,9 +2015,10 @@ export class HoldMyTask extends EventEmitter {
: "error";
item.finishedAt = this.now();

// Emit error event only for callback API (promise API conveys error via rejection)
// Emit error event only for callback API (promise API conveys error via rejection),
// and only to listeners - the callback below always receives the failure.
if (item.callback) {
this.emit("error", item);
this._emitTaskError(item);
}

// Store final values on promise handle before rejecting (for promise API)
Expand Down
103 changes: 103 additions & 0 deletions tests/CallbackErrorWithoutListener.test.vitest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
*
* @Project: @cldmv/holdmytask
* @Filename: /tests/CallbackErrorWithoutListener.test.vitest.mjs
* @Date: 2026-10-03T17:20:10-07:00 (1791073210)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-10-03T17:23:55-07:00 (1791073435)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*
*/

import { test, expect, describe } from "vitest";
import { HoldMyTask } from "../src/hold-my-task.mjs";

// #53: a callback-style task failure must reach the callback even when nothing listens
// for the queue's `error` event; the event is only emitted when a listener exists.
const runCallbackTask = (q, task, options = {}) =>
new Promise((resolve) => {
q.enqueue(task, (err, result) => resolve({ err, result }), options);
});

describe.each([
{ smartScheduling: true, mode: "Smart Scheduling" },
{ smartScheduling: false, mode: "Traditional Polling" }
])("callback task failures without an error listener ($mode)", ({ smartScheduling }) => {
test("a failing task delivers { type: 'error', error } to the callback without throwing", async () => {
const q = new HoldMyTask({ smartScheduling });
const boom = new Error("boom");
expect(q.listenerCount("error")).toBe(0);
const { err, result } = await runCallbackTask(q, () => {
throw boom;
});
expect(err).toEqual({ type: "error", error: boom });
expect(result).toBeNull();
q.destroy();
});

test("a timed-out task delivers { type: 'timeout', message } to the callback without throwing", async () => {
const q = new HoldMyTask({ smartScheduling });
const { err, result } = await runCallbackTask(q, () => new Promise((resolve) => setTimeout(resolve, 500)), { timeout: 20 });
expect(err).toEqual({ type: "timeout", message: "Task timed out after 20ms" });
expect(result).toBeNull();
q.destroy();
});

test("an aborted task delivers { type: 'canceled', message: 'Task was aborted' } to the callback without throwing", async () => {
const q = new HoldMyTask({ smartScheduling });
const { err, result } = await runCallbackTask(q, () => {
const abort = new Error("The operation was aborted");
abort.name = "AbortError";
throw abort;
});
expect(err).toEqual({ type: "canceled", message: "Task was aborted" });
expect(result).toBeNull();
q.destroy();
});

test("an expired task delivers its expire error to the callback without throwing", async () => {
const q = new HoldMyTask({ smartScheduling, concurrency: 1 });
q.enqueue(
() => new Promise((resolve) => setTimeout(resolve, 60)),
() => {}
);
const { err, result } = await runCallbackTask(q, () => "never", { expire: 10 });
expect(err).toBeInstanceOf(Error);
expect(err.type).toBe("expire");
expect(result).toBeNull();
q.destroy();
});

test("with an error listener attached, the event is still emitted and the callback still runs", async () => {
const q = new HoldMyTask({ smartScheduling });
const events = [];
q.on("error", (payload) => events.push(payload));
const boom = new Error("boom");
const { err } = await runCallbackTask(q, () => {
throw boom;
});
expect(err).toEqual({ type: "error", error: boom });
expect(events).toHaveLength(1);
expect(events[0].error).toBe(boom);
expect(events[0].status).toBe("error");
q.destroy();
});

test("with an error listener attached, an expired callback task is still reported", async () => {
const q = new HoldMyTask({ smartScheduling, concurrency: 1 });
const events = [];
q.on("error", (payload) => events.push(payload));
q.enqueue(
() => new Promise((resolve) => setTimeout(resolve, 60)),
() => {}
);
const { err } = await runCallbackTask(q, () => "never", { expire: 10 });
expect(err.type).toBe("expire");
expect(events.some((e) => e.error === err)).toBe(true);
q.destroy();
});
});
2 changes: 1 addition & 1 deletion types/src/hold-my-task.d.mts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading