Skip to content

@redis/client Isolated blocking command with AbortSignal bug (yallist) #2498

@mishase

Description

@mishase

Description

Bug

I'm getting an error (line numbers may be affected because of me modifying the package while trying to investigate the source of an error)

TypeError: Cannot read properties of undefined (reading 'reject')
    at Function._RedisCommandsQueue_flushQueue (/Users/mishase/Projects/wp/node_modules/@redis/client/dist/lib/client/commands-queue.js:204:11)
    at RedisCommandsQueue.flushAll (/Users/mishase/Projects/wp/node_modules/@redis/client/dist/lib/client/commands-queue.js:195:93)
    at RedisClient.disconnect (/Users/mishase/Projects/wp/node_modules/@redis/client/dist/lib/client/index.js:336:63)
    at <anonymous> (/Users/mishase/Projects/wp/packages/redis/dist/lib/events/EventsClient.js:86:24)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at EventsClient.blockingReadMany (/Users/mishase/Projects/wp/packages/redis/dist/lib/events/EventsClient.js:55:22)
    at Object.handler (/Users/mishase/Projects/wp/apps/screen-api/src/routes/screenshot/status.ts:23:15)

Error happens in flushQueue function

// RedisCommandsQueue.#flushQueue function
while (queue.length) {
    queue.shift()!.reject(err); // This line
}

Because of queue.length equals -1. It goes bellow zero due to bug in yallist package, developers of which seems to be dead in mid-2019, so submitting issue there will never be resolved

Detailed description of yallist bug

Let's look at the shift() function

Yallist.prototype.shift = function () {
    if (!this.head) {
        return undefined;
    }

    var res = this.head.value;
    this.head = this.head.next;
    if (this.head) {
        this.head.prev = null;
    } else {
        this.tail = null;
    }
    this.length--;
    return res;
};

It removes item from the list and returns Node of it

Now let's examine the removeNode(node) function

Yallist.prototype.removeNode = function (node) {
    if (node.list !== this) {
        throw new Error("removing node which does not belong to this list");
    }

    var next = node.next;
    var prev = node.prev;

    if (next) {
        next.prev = prev;
    }

    if (prev) {
        prev.next = next;
    }

    if (node === this.head) {
        this.head = next;
    }
    if (node === this.tail) {
        this.tail = prev;
    }

    node.list.length--;
    node.next = null;
    node.prev = null;
    node.list = null;

    return next;
};

As you can see, there is a node.list !== this check in the beginning of the function and node.list = null in the end of this function which prevents it from being called twice, but mentioned earlier function shift() does not have list = null line. So we can call shift and then removeNode which will break node.list.length value. In our case, we're calling shift() while there is one item and then removeNode call results list.length to be -1

Code to reproduce this bug

import LinkedList from "yallist";

const ll = new LinkedList();

ll.push({ name: "a" });

const node = ll.head;

ll.shift();
console.log(ll.length); // 0

ll.removeNode(node);
console.log(ll.length); // -1

What happens at the event of AbortSignal triggered in redis command when passing it to the command options

In mentioned earlier flushQueue function it calls queue.shift function which removes node from the waitingToBeSent list

In addCommand function there is a call this.#waitingToBeSent.removeNode to removeNode function which I described earlier. This causes waitingToBeSent.length to be -1

return new Promise((resolve, reject) => {
    const node = new LinkedList.Node<CommandWaitingToBeSent>({
        args,
        chainId: options?.chainId,
        returnBuffers: options?.returnBuffers,
        resolve,
        reject,
    });

    if (options?.signal) {
        const listener = () => {
            // Remove already removed from list node and cause length decrement
            this.#waitingToBeSent.removeNode(node);
            node.value.reject(new AbortError());
        };
        node.value.abort = {
            signal: options.signal,
            listener,
        };
        // AbortSignal type is incorrent
        (options.signal as any).addEventListener("abort", listener, {
            once: true,
        });
    }

    if (options?.asap) {
        this.#waitingToBeSent.unshiftNode(node);
    } else {
        this.#waitingToBeSent.pushNode(node);
    }
});

Possible fixes

Check if node exists before calling removeNode function in Redis library

Fork yallist and add node.list = null in all necessary functions or implement different presence checks in the removeNode function

Node.js Version

v18.14.0

Redis Server Version

all versions affected

Node Redis Version

4.6.6

Platform

all platforms affected

Logs

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions