Skip to content

PromiseCache leaks unhandledRejection from cache-cleanup branch in getOrCreate #5367

Description

@julianmesa-gitkraken

Summary

PromiseCache in @gitkraken/core-gitlens leaks an unhandledRejection from its cache-cleanup branch. When a cached factory promise rejects, the void promise.finally(...) cleanup chain in getOrCreate has no rejection handler, so the rejection surfaces as a process-level unhandledRejection. The rejecting promise is cache-internal and unreachable from the caller, so neither the caller's try/catch nor a .catch on the value returned by getOrCreate can suppress it (verified empirically).

This was found while debugging an unhandledRejection storm in Kepler (105 in one prod log). The root cause is in core-gitlens, not in Kepler.

Similar to a previously reported issue in the same area.

Where

dist/utils/promiseCache.js (source: packages/core/src/utils/promiseCache.ts), in getOrCreate.

There are two void promise.finally(...) cleanup chains that are not followed by a .catch:

  • PromiseCache ~line 105 / 152
  • PromiseMap ~line 301 / 352
void promise.finally(() => { /* cleanup: dispose aggregate, delete key */ });

When the cached factory promise rejects (e.g. a git merge-base [--fork-point] that exits 1 with empty stderr — a legitimate "no fork-point / no common ancestor" result that defaultExceptionHandler re-throws), this finally-derived promise has no rejection handler, so it surfaces as a process-level unhandledRejection.

Telling detail

The sibling caller branch attachCallerLifecycle already guards itself:

void returned.finally(cleanup).catch(() => {});

with a comment literally saying "Attach .catch to swallow any rejection in the cleanup chain so it doesn't surface as an unhandled rejection."

The cleanup branch in getOrCreate is missing that same .catch, which looks like an oversight rather than intent. Present in current main.

Repro (against 0.3.0)

Call a cached, rejecting op (e.g. refs.getMergeBase between two commits with no common ancestor) without consuming its promise, or with an AbortSignal that fires mid-flight. A 5x burst produces 10 unhandled rejections; the native stack is just Git.runCore → the cache branch in git-cli/exec/git.js.

Suggested fix

Append .catch(() => {}) to both void promise.finally(...) cleanup chains, mirroring attachCallerLifecycle.

Notes

We've shipped a temporary pnpm patch on our side (Kepler PR gitkraken/kepler#1154) pinned to 0.3.0 to stop the bleeding, but it'd be great to fix it upstream so we can drop the patch.

Metadata

Metadata

Assignees

Labels

needs-verificationRequest for verificationpending-releaseResolved but not yet released to the stable edition

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions