From 56ffdb693d7def5c820aae472f8bb2e7697cf1bf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:45:31 +0000 Subject: [PATCH] Refactor decryptLegacyDm to use ordered parallel race for decryptors. Replaces Promise.any with raceOrdered utility to respect decryptor preference order while maintaining parallel execution. Adds regression test for order preference. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/dmDecryptor.js | 52 +++++++++++++++++++++++++++- tests/dm-decryptor-order.test.mjs | 57 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/dm-decryptor-order.test.mjs diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index 3001a3bb..d6300a37 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -467,6 +467,56 @@ async function decryptGiftWrap(event, decryptors, actorPubkey) { }); } +function raceOrdered(promises) { + if (!promises || promises.length === 0) { + return Promise.reject(new AggregateError([], "No promises to race")); + } + + return new Promise((resolve, reject) => { + let pendingCount = promises.length; + const errors = new Array(promises.length); + const results = new Array(promises.length); + const states = new Array(promises.length).fill("pending"); + let scanIndex = 0; + + promises.forEach((p, i) => { + Promise.resolve(p).then( + (val) => { + states[i] = "fulfilled"; + results[i] = val; + + if (i === scanIndex) { + resolve(val); + } + }, + (err) => { + states[i] = "rejected"; + errors[i] = err; + pendingCount--; + + if (i === scanIndex) { + while (scanIndex < promises.length) { + if (states[scanIndex] === "fulfilled") { + resolve(results[scanIndex]); + return; + } + if (states[scanIndex] === "rejected") { + scanIndex++; + } else { + return; + } + } + } + + if (pendingCount === 0 && scanIndex >= promises.length) { + reject(new AggregateError(errors, "All promises rejected")); + } + }, + ); + }); + }); +} + async function decryptLegacyDm(event, decryptors, actorPubkey) { const ciphertext = typeof event?.content === "string" ? event.content : ""; const senderPubkey = normalizeHex(event?.pubkey); @@ -564,7 +614,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { // Race all decryption attempts to return the first successful result // This significantly reduces latency compared to sequential attempts try { - return await Promise.any(attempts); + return await raceOrdered(attempts); } catch (aggregateError) { const aggregatedErrors = aggregateError.errors || []; errors.push(...aggregatedErrors); diff --git a/tests/dm-decryptor-order.test.mjs b/tests/dm-decryptor-order.test.mjs new file mode 100644 index 00000000..86c5eafe --- /dev/null +++ b/tests/dm-decryptor-order.test.mjs @@ -0,0 +1,57 @@ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { decryptDM } from "../js/dmDecryptor.js"; + +const SENDER_HEX = "a".repeat(64); +const RECIPIENT_HEX = "b".repeat(64); + +test("decryptDM prefers higher priority decryptor even if slower", async () => { + // Mock event + const event = { + kind: 4, + content: "ciphertext", + pubkey: SENDER_HEX, + tags: [["p", RECIPIENT_HEX]] + }; + + const context = { + actorPubkey: RECIPIENT_HEX, + decryptors: [ + { + scheme: "preferred", + priority: 0, + source: "slow-preferred", + decrypt: async (remote, cipher) => { + // Normalize remote key might be done inside decryptDM + if (remote === SENDER_HEX) { + await new Promise(r => setTimeout(r, 100)); + return "preferred-plaintext"; + } + throw new Error("wrong remote: " + remote); + } + }, + { + scheme: "fallback", + priority: 10, + source: "fast-fallback", + decrypt: async (remote, cipher) => { + if (remote === SENDER_HEX) { + // Fast! + return "fallback-plaintext"; + } + throw new Error("wrong remote: " + remote); + } + } + ] + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "Result should be ok. Errors: " + JSON.stringify(result.errors)); + + // If Promise.any is used, it will likely pick "fallback-plaintext" (fast). + // We WANT "preferred-plaintext". + assert.equal(result.plaintext, "preferred-plaintext", + `Expected preferred-plaintext but got ${result.plaintext}. Current implementation likely uses Promise.any which is sensitive to race conditions.`); +});