Skip to content

fix: prevent unhandledRejection in batchAppend - #505

Merged
w1am merged 3 commits into
kurrent-io:masterfrom
avens19:master
Aug 24, 2026
Merged

fix: prevent unhandledRejection in batchAppend#505
w1am merged 3 commits into
kurrent-io:masterfrom
avens19:master

Conversation

@avens19

@avens19 avens19 commented May 8, 2026

Copy link
Copy Markdown
Contributor

Rebased onto master and repointed at the part of this that is still broken.

What changed since this PR was opened

The original report below was a crash in the "data" handler, from a response arriving for a correlation id that was no longer in the promise bank. #523 has since fixed that on master — each stream now owns its own bank and unknown correlation ids are dropped — so the original one-line change here is obsolete and has been dropped.

What is still broken

The unhandledRejection in the title is still reachable by a different route.

batchAppend builds and writes its batches inside new Promise(async (...batchPromise) => { … }). Anything that throws in there throws while the stream is perfectly healthy, so the "error" handler never runs and never settles that entry. The Promise constructor also discards an async executor's rejection. So the append is orphaned twice over:

  • the returned promise never settles, and the caller awaits forever; and
  • the failure escapes as an unhandledRejection, which ends the process under Node's default --unhandled-rejections=throw.

The easiest way in is a non-serializable event payload reaching JSON.stringify in eventBatcher. A bigint does it, which is an easy mistake to make against this client specifically, since it hands revisions and positions back as bigints:

await client.appendToStream("some-stream", [
  jsonEvent({ type: "example", data: { revision: 0n } }),
]);
// never settles; "Do not know how to serialize a BigInt" escapes as an unhandledRejection

options.setStreamPosition(streamState.toString(10)) and message.serializeBinary() are reachable the same way.

The fix

Wrap that body in a try/catch which deletes the orphaned promise-bank entry and rejects. It rejects via convertToCommandError to match the "error" handler — that is a passthrough for anything that isn't a gRPC service error, so a TypeError from JSON.stringify still surfaces as itself, while a genuine transport error raised by backpressuredWrite is normalized the same way it would be elsewhere.

Test

Added to appendToStream-batch-append.test.ts: appends an event carrying a bigint and asserts the append rejects, and that nothing escapes as an unhandledRejection.

Verified against kurrentdb 26.1.2 that it fails on unpatched master — the append never settles and the test dies on the 60s jest timeout — and passes with the fix. Full file: 5 passed, 1 skipped.

Original report (fixed separately by #523)

We saw some unhandledRejections during client reconnect coming from this code. Stack below:

TypeError: undefined is not iterable (cannot read property
Symbol(Symbol.iterator))
at ClientDuplexStreamImpl.<anonymous>
(/opt/app/node_modules/.pnpm/@kurrent+kurrentdb-client@1.1.2/node_modules/@kurrent/kurrentdb-client/dist/streams/appendToStream/batchAppend.js:21:35)
at ClientDuplexStreamImpl.emit (node:events:508:28)
at addChunk (node:internal/streams/readable:563:12)
at readableAddChunkPushObjectMode (node:internal/streams/readable:540:3)
at Readable.push (node:internal/streams/readable:395:5)
at Object.onReceiveMessage
(/opt/app/node_modules/.pnpm/@grpc+grpc-js@1.14.3/node_modules/@grpc/grpc-js/build/src/client.js:411:24)
at Object.onReceiveMessage
(/opt/app/node_modules/.pnpm/@grpc+grpc-js@1.14.3/node_modules/@grpc/grpc-js/build/src/client-interceptors.js:319:178)
at
/opt/app/node_modules/.pnpm/@grpc+grpc-js@1.14.3/node_modules/@grpc/grpc-js/build/src/resolving-call.js:213:39
at process.processTicksAndRejections
(node:internal/process/task_queues:104:5)

batchAppend builds and writes its batches inside `new Promise(async (...) =>
{ ... })`. Anything that throws in there — most easily a non-serializable
event payload reaching JSON.stringify in eventBatcher — throws while the
stream is healthy, so the "error" handler never runs and so never settles
the entry. The Promise constructor also discards an async executor's
rejection, which leaves the append orphaned: the caller awaits forever and
the failure surfaces only as an unhandledRejection, ending the process
under Node's default --unhandled-rejections=throw.

Wrap the body in a try/catch that drops the promise-bank entry and rejects
via convertToCommandError, matching the "error" handler.

A bigint payload is an easy way to hit this by accident, since the client
hands revisions and positions back as bigints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@w1am

w1am commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Aug 13, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Unsafe error casting ✓ Resolved 🐞 Bug ☼ Reliability
Description
In batchAppend's new catch block, error is cast to Error and passed to convertToCommandError;
if a non-Error value is thrown, convertToCommandError can throw (it uses the in operator),
preventing reject() from running and leaving the append promise pending after the promiseBank
entry is deleted. This can reintroduce an unhandledRejection despite the intended fix.
Code

packages/db-client/src/streams/appendToStream/batchAppend.ts[R203-205]

+    } catch (error) {
+      promiseBank.delete(correlationId);
+      reject(convertToCommandError(error as Error));
Evidence
The catch block passes a type-asserted value into convertToCommandError. convertToCommandError
uses isServiceError, which applies the in operator on its argument, which throws if the caught
value is not an object. If that happens, reject() is never called and the promiseBank entry was
already deleted, so the append promise can hang again and the async executor rejection can become
unhandled.

packages/db-client/src/streams/appendToStream/batchAppend.ts[155-206]
packages/db-client/src/utils/CommandError.ts[580-582]
packages/db-client/src/utils/CommandError.ts[733-737]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`batchAppend` now catches failures during request construction/writing, but it assumes the caught value is an `Error` (`error as Error`). If a non-Error is thrown (allowed in JS), `convertToCommandError` can throw before `reject()` is invoked, leaving the returned promise pending (and potentially surfacing as an `unhandledRejection` due to the async Promise executor).

### Issue Context
`convertToCommandError` calls `isServiceError`, which uses the `in` operator on its input; this throws when the input is not an object. The new catch block deletes the promiseBank entry before calling `convertToCommandError`, so if conversion throws, the promise is orphaned again.

### Fix
In the catch block, normalize `error` to an `Error` instance before calling `convertToCommandError` (e.g., `error instanceof Error ? error : new Error(String(error))`).

### Fix Focus Areas
- packages/db-client/src/streams/appendToStream/batchAppend.ts[203-206]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread packages/db-client/src/streams/appendToStream/batchAppend.ts Outdated
@kurrent-io kurrent-io deleted a comment from qodo-code-review Bot Aug 13, 2026
@kurrent-io kurrent-io deleted a comment from qodo-code-review Bot Aug 13, 2026
@w1am
w1am merged commit 68ad669 into kurrent-io:master Aug 24, 2026
14 checks passed

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 @w1am Failed to create cherry Pick PR due to error:

Error: Commit feba98217b3d205e690be24c85cc2494e4b1886e has 2 parents. github-cherry-pick is designed for the rebase workflow and doesn't support merge commits.
   at Object.<anonymous> (/home/runner/work/_actions/kurrent-io/Automations/master/lib/cherry-pick/index.js:94:13)
   at Generator.next (<anonymous>)
   at fulfilled (/home/runner/work/_actions/kurrent-io/Automations/master/lib/cherry-pick/index.js:9:26)
   at process.processTicksAndRejections (node:internal/process/task_queues:104:5)

🚨👉 Check https://github.com/kurrent-io/KurrentDB-Client-NodeJS/actions/runs/32696051058

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants