Skip to content

Release the parallelism slot when a dispatched message rejects - #1857

Open
Ngo Quoc Viet (NgoQuocViet2001) wants to merge 1 commit into
microsoft:mainfrom
NgoQuocViet2001:fix-parallelism-rejected-dispatch
Open

Ngo Quoc Viet (NgoQuocViet2001) wants to merge 1 commit into
microsoft:mainfrom
NgoQuocViet2001:fix-parallelism-rejected-dispatch

Conversation

@NgoQuocViet2001

Copy link
Copy Markdown

Problem

With maxParallelism configured, the message queue's slot accounting lives only in the fulfilment callback:

// jsonrpc/src/common/connection.ts
inFlight++;
...
if (result instanceof Promise) {
    result.then(() => {
        inFlight--;
        triggerMessageQueue();
    }).catch((error) => {
        logger.error(`Processing message queue failed: ${error.toString()}`);
    });
}

Written as .then(onFulfilled).catch(onRejected), a rejection skips onFulfilled entirely. The .catch logs, but never runs inFlight-- and never re-pumps. inFlight is the gate triggerMessageQueue checks:

if (maxParallelism !== -1 && inFlight >= maxParallelism) {
    return;
}

so one rejected dispatch leaves the counter permanently at the limit and the queue stops draining. The connection stays open and Listening; there is no error event, no close, nothing but a single log line, while every later request and notification sits in messageQueue forever.

Trigger

The promise the queue awaits is handleRequest's, which ends in messageWriter.write(...). A response that cannot be serialized rejects it. A handler throwing a ResponseError whose data is not JSON-serializable is enough — toJson() copies data onto the wire message:

server.onRequest(first, () => {
    const circular = {}; circular.self = circular;
    throw new ResponseError(ErrorCodes.InternalError, 'boom', circular);
});
server.onRequest(second, () => 'handled');

With { maxParallelism: 1 }, the request after it is never answered.

A handler that merely throws is not affected — that is caught inside handleRequest and turned into a well-formed replyError. It is the failure to write the response that escapes.

Fix

Use the two-argument form of then, so both settlements do the same bookkeeping. The log line is unchanged.

result.then(() => {
    inFlight--;
    triggerMessageQueue();
}, (error) => {
    inFlight--;
    logger.error(`Processing message queue failed: ${error.toString()}`);
    triggerMessageQueue();
});

The maxParallelism === -1 default never reaches the gate, so unlimited-parallelism behaviour is untouched.

Test plan

  • Added Parallelism - a rejected dispatch releases its slot to jsonrpc/src/node/test/connection.test.ts, beside the existing Parallelism - limited case and reusing its TestDuplex pair.
  • Ran: npm run compile:jsonrpc then the node suite from jsonrpc/68 passing.
  • Checked: reverting only connection.ts fails the new test with 'timed out' — the second request is never answered.
  • Ran: npx eslint src in jsonrpc/ → clean.

With maxParallelism set, the message queue's bookkeeping lived only in the
fulfilment callback of .then(...).catch(...). A rejected dispatch skipped
inFlight-- and the re-pump, so one failure left inFlight permanently at the
limit and triggerMessageQueue() returned early from then on: the connection
stayed open and Listening while every later request and notification sat in
the queue undelivered.

Use the two-argument form of then so both settlements decrement and re-pump.
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant