Skip to content
Open
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
],
"license": "GPL-3.0",
"dependencies": {
"async-mutex": "^0.5.0",
"curve25519-js": "^0.0.4",
"protobufjs": "6.8.8"
},
Expand Down
61 changes: 35 additions & 26 deletions src/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

'use strict';

const nodeCrypto = require('crypto');
const { webcrypto } = require('crypto');
const subtle = webcrypto.subtle;
const assert = require('assert');


Expand All @@ -14,43 +15,45 @@ function assertBuffer(value) {
}


function encrypt(key, data, iv) {
async function encrypt(key, data, iv) {
assertBuffer(key);
assertBuffer(data);
assertBuffer(iv);
const cipher = nodeCrypto.createCipheriv('aes-256-cbc', key, iv);
return Buffer.concat([cipher.update(data), cipher.final()]);
const cryptoKey = await subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['encrypt']);
const encrypted = await subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, data);
return Buffer.from(encrypted);
}


function decrypt(key, data, iv) {
async function decrypt(key, data, iv) {
assertBuffer(key);
assertBuffer(data);
assertBuffer(iv);
const decipher = nodeCrypto.createDecipheriv('aes-256-cbc', key, iv);
return Buffer.concat([decipher.update(data), decipher.final()]);
const cryptoKey = await subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['decrypt']);
const decrypted = await subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, data);
return Buffer.from(decrypted);
}


function calculateMAC(key, data) {
async function calculateMAC(key, data) {
assertBuffer(key);
assertBuffer(data);
const hmac = nodeCrypto.createHmac('sha256', key);
hmac.update(data);
return Buffer.from(hmac.digest());
const cryptoKey = await subtle.importKey(
'raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const mac = await subtle.sign('HMAC', cryptoKey, data);
return Buffer.from(mac);
}


function hash(data) {
assertBuffer(data);
const sha512 = nodeCrypto.createHash('sha512');
sha512.update(data);
return sha512.digest();
async function hash(data) {
const result = await subtle.digest('SHA-512', data);
return Buffer.from(result);
}


// Salts always end up being 32 bytes
function deriveSecrets(input, salt, info, chunks) {
async function deriveSecrets(input, salt, info, chunks) {
// Specific implementation of RFC 5869 that only returns the first 3 32-byte chunks
assertBuffer(input);
assertBuffer(salt);
Expand All @@ -60,31 +63,37 @@ function deriveSecrets(input, salt, info, chunks) {
}
chunks = chunks || 3;
assert(chunks >= 1 && chunks <= 3);
const PRK = calculateMAC(salt, input);
const PRK = await calculateMAC(salt, input);
const infoArray = new Uint8Array(info.byteLength + 1 + 32);
infoArray.set(info, 32);
infoArray[infoArray.length - 1] = 1;
const signed = [calculateMAC(PRK, Buffer.from(infoArray.slice(32)))];
const signed = [await calculateMAC(PRK, Buffer.from(infoArray.slice(32)))];
if (chunks > 1) {
infoArray.set(signed[signed.length - 1]);
infoArray[infoArray.length - 1] = 2;
signed.push(calculateMAC(PRK, Buffer.from(infoArray)));
signed.push(await calculateMAC(PRK, Buffer.from(infoArray)));
}
if (chunks > 2) {
infoArray.set(signed[signed.length - 1]);
infoArray[infoArray.length - 1] = 3;
signed.push(calculateMAC(PRK, Buffer.from(infoArray)));
signed.push(await calculateMAC(PRK, Buffer.from(infoArray)));
}
return signed;
}

function verifyMAC(data, key, mac, length) {
const calculatedMac = calculateMAC(key, data).slice(0, length);
async function verifyMAC(data, key, mac, length) {
const calculatedMac = (await calculateMAC(key, data)).subarray(0, length);
if (mac.length !== length || calculatedMac.length !== length) {
throw new Error("Bad MAC length");
throw new Error("Bad MAC length Expected: " + length +
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Similarly, the MAC length error now includes the expected and actual lengths. Keep MAC verification errors generic to avoid leaking any information about the verification process.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/crypto.js, line 87:

<comment>Similarly, the MAC length error now includes the expected and actual lengths. Keep MAC verification errors generic to avoid leaking any information about the verification process.</comment>

<file context>
@@ -60,31 +63,37 @@ function deriveSecrets(input, salt, info, chunks) {
+    const calculatedMac = (await calculateMAC(key, data)).subarray(0, length);
     if (mac.length !== length || calculatedMac.length !== length) {
-        throw new Error("Bad MAC length");
+       throw new Error("Bad MAC length Expected: " + length +
+            " Got: " + mac.length + " and " + calculatedMac.length);
+    }
</file context>
Fix with Cubic

" Got: " + mac.length + " and " + calculatedMac.length);
}
let diff = 0;
for (let i = 0; i < length; i++) {
diff |= mac[i] ^ calculatedMac[i];
}
if (!mac.equals(calculatedMac)) {
throw new Error("Bad MAC");
if (diff !== 0) {
throw new Error("Bad MAC Expected: " + calculatedMac.toString('hex') +
" Got: " + mac.toString('hex'));
Comment on lines +85 to +96
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not leak MAC material in error strings.

src/session_cipher.js:157-160 logs decryption errors, so these messages now write the expected MAC and received MAC to logs on every verification failure. Keep MAC failures generic; the derived MAC is sensitive key-derived material.

Safer error handling
-       throw new Error("Bad MAC length Expected: " + length +
-            " Got: " + mac.length + " and " + calculatedMac.length);
+       throw new Error('Bad MAC length');
...
-         throw new Error("Bad MAC Expected: " + calculatedMac.toString('hex') +
-            " Got: " + mac.toString('hex'));
+         throw new Error('Bad MAC');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const calculatedMac = (await calculateMAC(key, data)).subarray(0, length);
if (mac.length !== length || calculatedMac.length !== length) {
throw new Error("Bad MAC length");
throw new Error("Bad MAC length Expected: " + length +
" Got: " + mac.length + " and " + calculatedMac.length);
}
let diff = 0;
for (let i = 0; i < length; i++) {
diff |= mac[i] ^ calculatedMac[i];
}
if (!mac.equals(calculatedMac)) {
throw new Error("Bad MAC");
if (diff !== 0) {
throw new Error("Bad MAC Expected: " + calculatedMac.toString('hex') +
" Got: " + mac.toString('hex'));
const calculatedMac = (await calculateMAC(key, data)).subarray(0, length);
if (mac.length !== length || calculatedMac.length !== length) {
throw new Error('Bad MAC length');
}
let diff = 0;
for (let i = 0; i < length; i++) {
diff |= mac[i] ^ calculatedMac[i];
}
if (diff !== 0) {
throw new Error('Bad MAC');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/crypto.js` around lines 85 - 96, The code in src/crypto.js exposes
sensitive MAC material in error strings when verifying MACs; update the checks
around calculateMAC, mac, and calculatedMac to throw generic errors without
including MAC hex values or key-derived data—e.g. replace the detailed messages
in the length check and verification failure with non-sensitive messages like
"Bad MAC length" and "Bad MAC" (or a single generic "Bad MAC") and ensure no
code path logs mac or calculatedMac; keep the validation logic
(calculateMAC(...).subarray(...), the length comparison, and the constant-time
XOR loop) unchanged but remove any inclusion of mac or calculatedMac in thrown
errors or logs.

}
}

Expand Down
94 changes: 43 additions & 51 deletions src/curve.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
'use strict';

const curveJs = require('curve25519-js');
const nodeCrypto = require('crypto');
const { webcrypto } = require('crypto');
const subtle = webcrypto.subtle;

// DER prefixes for X25519 keys (used for WebCrypto import/export)
// from: https://github.com/digitalbazaar/x25519-key-agreement-key-2019/blob/master/lib/crypto.js
const PUBLIC_KEY_DER_PREFIX = Buffer.from([
48, 42, 48, 5, 6, 3, 43, 101, 110, 3, 33, 0
]);
Comment on lines 10 to 12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused constant PUBLIC_KEY_DER_PREFIX.

This constant is defined but never used in the file. It may be leftover from the migration to WebCrypto. Consider removing it to avoid confusion.

🧹 Proposed fix
-// DER prefixes for X25519 keys (used for WebCrypto import/export)
+// DER prefix for X25519 private keys (used for WebCrypto import/export)
 // from: https://github.com/digitalbazaar/x25519-key-agreement-key-2019/blob/master/lib/crypto.js
-const PUBLIC_KEY_DER_PREFIX = Buffer.from([
-    48, 42, 48, 5, 6, 3, 43, 101, 110, 3, 33, 0
-]);
-
 const PRIVATE_KEY_DER_PREFIX = Buffer.from([
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const PUBLIC_KEY_DER_PREFIX = Buffer.from([
48, 42, 48, 5, 6, 3, 43, 101, 110, 3, 33, 0
]);
// DER prefix for X25519 private keys (used for WebCrypto import/export)
// from: https://github.com/digitalbazaar/x25519-key-agreement-key-2019/blob/master/lib/crypto.js
const PRIVATE_KEY_DER_PREFIX = Buffer.from([
🧰 Tools
🪛 ESLint

[error] 10-10: 'PUBLIC_KEY_DER_PREFIX' is assigned a value but never used.

(no-unused-vars)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/curve.js` around lines 10 - 12, Remove the unused constant
PUBLIC_KEY_DER_PREFIX from src/curve.js: locate the declaration
PUBLIC_KEY_DER_PREFIX and delete it (and any related comments or references if
present) so the file no longer contains an unused Buffer constant leftover from
the WebCrypto migration.


const PRIVATE_KEY_DER_PREFIX = Buffer.from([
48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 110, 4, 34, 4, 32
]);
Expand Down Expand Up @@ -38,7 +41,7 @@ function scrubPubKeyFormat(pubKey) {
throw new Error("Invalid public key");
}
if (pubKey.byteLength == 33) {
return pubKey.slice(1);
return pubKey.subarray(1);
} else {
console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey");
return pubKey;
Expand All @@ -58,76 +61,65 @@ function unclampEd25519PrivateKey(clampedSk) {
return unclampedSk;
}

exports.getPublicFromPrivateKey = function(privKey) {
exports.getPublicFromPrivateKey = async function(privKey) {
const unclampedPK = unclampEd25519PrivateKey(privKey);
const keyPair = curveJs.generateKeyPair(unclampedPK);
return prefixKeyInPublicKey(Buffer.from(keyPair.public));
};

exports.generateKeyPair = function() {
try {
const {publicKey: publicDerBytes, privateKey: privateDerBytes} = nodeCrypto.generateKeyPairSync(
'x25519',
{
publicKeyEncoding: { format: 'der', type: 'spki' },
privateKeyEncoding: { format: 'der', type: 'pkcs8' }
}
);
const pubKey = publicDerBytes.slice(PUBLIC_KEY_DER_PREFIX.length, PUBLIC_KEY_DER_PREFIX.length + 32);

const privKey = privateDerBytes.slice(PRIVATE_KEY_DER_PREFIX.length, PRIVATE_KEY_DER_PREFIX.length + 32);

return {
pubKey: prefixKeyInPublicKey(pubKey),
privKey
};
} catch(e) {
const keyPair = curveJs.generateKeyPair(nodeCrypto.randomBytes(32));
return {
privKey: Buffer.from(keyPair.private),
pubKey: prefixKeyInPublicKey(Buffer.from(keyPair.public)),
};
}
exports.generateKeyPair = async function() {
const keyPair = await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);

const publicKeyRaw = await subtle.exportKey('raw', keyPair.publicKey);
const privateKeyDer = await subtle.exportKey('pkcs8', keyPair.privateKey);

const pubKey = Buffer.from(publicKeyRaw);
const privKey = Buffer.from(new Uint8Array(privateKeyDer).subarray(PRIVATE_KEY_DER_PREFIX.length));

return {
pubKey: prefixKeyInPublicKey(pubKey),
privKey
};
};

exports.calculateAgreement = function(pubKey, privKey) {
exports.calculateAgreement = async function(pubKey, privKey) {
pubKey = scrubPubKeyFormat(pubKey);
validatePrivKey(privKey);
if (!pubKey || pubKey.byteLength != 32) {
throw new Error("Invalid public key");
}

if(typeof nodeCrypto.diffieHellman === 'function') {
const nodePrivateKey = nodeCrypto.createPrivateKey({
key: Buffer.concat([PRIVATE_KEY_DER_PREFIX, privKey]),
format: 'der',
type: 'pkcs8'
});
const nodePublicKey = nodeCrypto.createPublicKey({
key: Buffer.concat([PUBLIC_KEY_DER_PREFIX, pubKey]),
format: 'der',
type: 'spki'
});

return nodeCrypto.diffieHellman({
privateKey: nodePrivateKey,
publicKey: nodePublicKey,
});
} else {
const secret = curveJs.sharedKey(privKey, pubKey);
return Buffer.from(secret);
}
const privateKeyObj = await subtle.importKey(
'pkcs8',
Buffer.concat([PRIVATE_KEY_DER_PREFIX, privKey]),
{ name: 'X25519' },
false,
['deriveBits']
);
const publicKeyObj = await subtle.importKey(
'raw',
pubKey,
{ name: 'X25519' },
false,
[]
);

const shared = await subtle.deriveBits({ name: 'X25519', public: publicKeyObj }, privateKeyObj, 256);
return Buffer.from(shared);
};

exports.calculateSignature = function(privKey, message) {
// XEdDSA signatures use Curve25519 keys converted to Ed25519-style — not supported by
// WebCrypto, so we keep using curve25519-js here but expose an async interface for
// consistency with the rest of the module.
exports.calculateSignature = async function(privKey, message) {
validatePrivKey(privKey);
if (!message) {
throw new Error("Invalid message");
}
return Buffer.from(curveJs.sign(privKey, message));
};

exports.verifySignature = function(pubKey, msg, sig, isInit) {
exports.verifySignature = async function(pubKey, msg, sig, isInit) {
pubKey = scrubPubKeyFormat(pubKey);
if (!pubKey || pubKey.byteLength != 32) {
throw new Error("Invalid public key");
Expand Down
14 changes: 7 additions & 7 deletions src/keyhelper.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// vim: ts=4:sw=4:expandtab

const curve = require('./curve');
const nodeCrypto = require('crypto');
const { webcrypto } = require('crypto');

function isNonNegativeInteger(n) {
return (typeof n === 'number' && (n % 1) === 0 && n >= 0);
Expand All @@ -10,11 +10,11 @@ function isNonNegativeInteger(n) {
exports.generateIdentityKeyPair = curve.generateKeyPair;

exports.generateRegistrationId = function() {
var registrationId = Uint16Array.from(nodeCrypto.randomBytes(2))[0];
var registrationId = webcrypto.getRandomValues(new Uint16Array(1))[0];
return registrationId & 0x3fff;
};

exports.generateSignedPreKey = function(identityKeyPair, signedKeyId) {
exports.generateSignedPreKey = async function(identityKeyPair, signedKeyId) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This is a breaking public API change.

generateSignedPreKey() and generatePreKey() now return Promises instead of plain objects. Existing consumers that read .keyPair / .signature synchronously will break at runtime, so this needs either separate async exports or a semver-major release with docs/examples updated.

Also applies to: 36-36

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/keyhelper.js` at line 17, The change made generateSignedPreKey and
generatePreKey to be async (returning Promises) which breaks the public API;
restore backward compatibility by reverting them to synchronous exports: remove
the async keyword and ensure both generateSignedPreKey and generatePreKey return
plain objects with the same properties (e.g., .keyPair and .signature) instead
of a Promise, or alternatively add new async-named variants (e.g.,
generateSignedPreKeyAsync / generatePreKeyAsync) and keep the original function
names synchronous; update the module exports so callers of generateSignedPreKey
and generatePreKey continue to receive plain objects (also apply the same fix to
the other occurrence at the second function on line 36).

if (!(identityKeyPair.privKey instanceof Buffer) ||
identityKeyPair.privKey.byteLength != 32 ||
!(identityKeyPair.pubKey instanceof Buffer) ||
Expand All @@ -24,20 +24,20 @@ exports.generateSignedPreKey = function(identityKeyPair, signedKeyId) {
if (!isNonNegativeInteger(signedKeyId)) {
throw new TypeError('Invalid argument for signedKeyId: ' + signedKeyId);
}
const keyPair = curve.generateKeyPair();
const sig = curve.calculateSignature(identityKeyPair.privKey, keyPair.pubKey);
const keyPair = await curve.generateKeyPair();
const sig = await curve.calculateSignature(identityKeyPair.privKey, keyPair.pubKey);
return {
keyId: signedKeyId,
keyPair: keyPair,
signature: sig
};
};

exports.generatePreKey = function(keyId) {
exports.generatePreKey = async function(keyId) {
if (!isNonNegativeInteger(keyId)) {
throw new TypeError('Invalid argument for keyId: ' + keyId);
}
const keyPair = curve.generateKeyPair();
const keyPair = await curve.generateKeyPair();
return {
keyId,
keyPair
Expand Down
2 changes: 1 addition & 1 deletion src/numeric_fingerprint.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ var VERSION = 0;

async function iterateHash(data, key, count) {
const combined = (new Uint8Array(Buffer.concat([data, key]))).buffer;
const result = crypto.hash(combined);
const result = await crypto.hash(combined);
if (--count === 0) {
return result;
} else {
Expand Down
29 changes: 28 additions & 1 deletion src/queue_job.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,41 @@
const _queueAsyncBuckets = new Map();
const _gcLimit = 10000;



/*
* This is a wrapper around the async function that will reject if it
* takes longer than the specified timeout. This is useful for
* preventing a job from hanging indefinitely. The default timeout
* is 30 seconds.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Documentation says "The default timeout is 30 seconds" but the actual default is 15 seconds (ms = 15000). This will mislead callers about the timeout behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/queue_job.js, line 19:

<comment>Documentation says "The default timeout is 30 seconds" but the actual default is 15 seconds (`ms = 15000`). This will mislead callers about the timeout behavior.</comment>

<file context>
@@ -10,14 +10,41 @@
+* This is a wrapper around the async function that will reject if it
+* takes longer than the specified timeout.  This is useful for
+* preventing a job from hanging indefinitely.  The default timeout
+* is 30 seconds.
+*/
+function withTimeout(fn, ms = 15000) {
</file context>
Suggested change
* is 30 seconds.
* is 15 seconds.
Fix with Cubic

*/
function withTimeout(fn, ms = 15000) {
if (typeof fn !== 'function') {
throw new TypeError('fn must be a function to wrap received ' + typeof fn);
}
if (typeof ms !== 'number') {
throw new TypeError('ms must be a number to wrap received ' + typeof ms);
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Job timed out')), ms);
fn().then((res) => {
clearTimeout(timer);
resolve(res);
}).catch((err) => {
clearTimeout(timer);
reject(err);
});
});
Comment on lines +21 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Preserve queue ordering even after a timeout.

Once withTimeout() rejects, _asyncQueueExecutor() immediately advances to the next item even though the original job.awaitable keeps running. That breaks the serialization guarantee this module is supposed to provide: jobs from src/session_builder.js:22-52 and src/session_cipher.js:68-186 can now overlap and mutate the same session/storage state concurrently.

Minimal direction
-                job.resolve(await withTimeout(job.awaitable)); // if the job takes longer than 15 seconds, it will be rejected
+                const work = Promise.resolve().then(job.awaitable);
+                try {
+                    job.resolve(await withTimeout(() => work));
+                } catch (e) {
+                    job.reject(e);
+                } finally {
+                    try { await work; } catch (_) {}
+                }

Also applies to: 47-47

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/queue_job.js` around lines 21 - 37, withTimeout currently rejects
immediately on timeout which lets _asyncQueueExecutor advance while the original
job.awaitable (the underlying fn) is still running; change withTimeout so that
when the timeout fires it records that the call has timed out (e.g., create a
TimeoutError or set a timedOut flag) but does not resolve/reject the returned
Promise until the underlying fn() settles, then reject with the TimeoutError;
ensure you still clear the timer and clean up on fn() resolution/rejection and
preserve the original behavior when fn() finishes before ms expires—this keeps
_asyncQueueExecutor and job.awaitable serialization intact while providing a
clear timeout signal to callers.

}

async function _asyncQueueExecutor(queue, cleanup) {
let offt = 0;
while (true) {
let limit = Math.min(queue.length, _gcLimit); // Break up thundering hurds for GC duty.
for (let i = offt; i < limit; i++) {
const job = queue[i];
try {
job.resolve(await job.awaitable());
job.resolve(await withTimeout(job.awaitable)); // if the job takes longer than 15 seconds, it will be rejected
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When withTimeout rejects on a timeout, fn() keeps running in the background while the executor advances to the next queued job. This breaks the serialization guarantee that the queue is supposed to provide—two jobs can now mutate session/storage state concurrently. After a timeout rejection, the executor should still await the original work promise (ignoring its result) before proceeding to the next job.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/queue_job.js, line 47:

<comment>When `withTimeout` rejects on a timeout, `fn()` keeps running in the background while the executor advances to the next queued job. This breaks the serialization guarantee that the queue is supposed to provide—two jobs can now mutate session/storage state concurrently. After a timeout rejection, the executor should still `await` the original work promise (ignoring its result) before proceeding to the next job.</comment>

<file context>
@@ -10,14 +10,41 @@
             const job = queue[i];
             try {
-                job.resolve(await job.awaitable());
+                job.resolve(await withTimeout(job.awaitable)); // if the job takes longer than 15 seconds, it will be rejected
             } catch(e) {
                 job.reject(e);
</file context>
Fix with Cubic

} catch(e) {
job.reject(e);
}
Expand Down
Loading