Skip to content

fix: avoid unhandled rejection from orphan .finally() in gallery extension download dedup (fixes #323530) - #323537

Open
vs-code-engineering[bot] wants to merge 1 commit into
mainfrom
fix-extension-download-orphan-finally-323530-473da94610340703
Open

fix: avoid unhandled rejection from orphan .finally() in gallery extension download dedup (fixes #323530)#323537
vs-code-engineering[bot] wants to merge 1 commit into
mainfrom
fix-extension-download-orphan-finally-323530-473da94610340703

Conversation

@vs-code-engineering

Copy link
Copy Markdown
Contributor

Automated error-fix analysis for an error-telemetry issue. Fix at the producer of the unhandled rejection — the download-dedup memoization — not at the signature-verification crash site.

Summary

The error bucket SignatureVerificationInternal: 署名の検証が 'UnknownError' エラーで失敗しました。 ("Signature verification failed with 'UnknownError' error") is reported as an unhandled promise rejection in the shared process, with a 2-frame stack:

  • ExtensionManagementService.downloadExtension (extensionManagementService.ts:381)
  • async ExtensionManagementService.downloadAndExtractGalleryExtension (extensionManagementService.ts:300)

The throw itself is legitimate: when the @vscode/vsce-sign module throws during verification, ExtensionSignatureVerificationService.verify() returns verificationStatus = UnknownError; downloadExtension's switch has no UnknownError case, so it correctly fails closed by throwing ExtensionManagementError(..., SignatureVerificationInternal). The UI path already handles this error gracefully (dialog with Learn More / Report Issue / Install Anyway).

The actual defect is in createInstallExtensionTask, which memoizes in-flight gallery downloads in the extractingGalleryExtensions map. It stored/returned the raw download promise and then called promise.finally(cleanup) as a standalone, discarded statement. promise.finally() returns a new promise; when the download rejects, the returned promise is handled by the caller, but the discarded .finally() promise rejects with no handler → unhandled rejection → error telemetry. This leaks every download/extract rejection, with the signature UnknownError case being the most frequent.

Fixes #323530
Recommended reviewer: @sandy081

Culprit Commit

The unhandled-rejection defect is a latent bug in the extractingGalleryExtensions dedup block of createInstallExtensionTask. The orphan promise.finally(...) statement is present unchanged at commit 7f503b81 (2026-04-09) with identical line numbers, and the most recent fix-window edit to this file — 88e21a04 ("handle edge cases while updating built in extensions", @sandy081, 2026-04-10) — added unrelated lines and did not touch the dedup block. The exact commit that first introduced the orphan .finally() was not pinned within the time budget.

The recent-regression / stable-anomaly signal correlates with the auto-update for built-in extensions feature series by @sandy081 (e.g. 143b631d "Enable auto-update for built-in extensions", and follow-ups aa0db967, 7f503b81, 88e21a04, late March–April 2026), which added a background gallery download/verify code path. This greatly increased background download/verification frequency, amplifying the pre-existing unhandled-rejection leak into a visible telemetry spike for version 1.126.0 (shipped commit 7e7950df89d055b5a378379db9ee14290772148a).

Code Flow

flowchart TD
    A[Install / background auto-update of gallery extension] --> B[createInstallExtensionTask]
    B --> C{extractingGalleryExtensions<br/>has in-flight promise for key?}
    C -- yes --> R[return cached promise]
    C -- no --> D[downloadAndExtractGalleryExtension]
    D --> E[downloadExtension → verify signature]
    E --> F[vsce-sign module.verify throws]
    F --> G[verificationStatus = UnknownError]
    G --> H["downloadExtension switch has no UnknownError case →<br/>throw ExtensionManagementError(SignatureVerificationInternal)"]
    H --> I[returned/stored promise rejects → awaited by task → HANDLED]
    H --> J["orphan promise.finally(...) promise rejects → NO handler"]
    J --> K[Unhandled rejection in shared process → error telemetry bucket]
Loading

Affected Files

  • src/vs/platform/extensionManagement/node/extensionManagementService.ts — in createInstallExtensionTask, fold the .finally() cleanup into the promise that is stored in extractingGalleryExtensions and returned, eliminating the discarded orphan promise branch.

Repro Steps

  1. Trigger a gallery extension download whose signature verification fails internally — the @vscode/vsce-sign module throws, so ExtensionSignatureVerificationService.verify() yields verificationStatus = UnknownError. In practice this is reached frequently by the background auto-update path for built-in extensions, which performs many background downloads.
  2. downloadExtension has no UnknownError case in its verification switch, so it throws ExtensionManagementError(..., SignatureVerificationInternal) (correct fail-closed behavior).
  3. In createInstallExtensionTask, the returned/stored dedup promise rejects and is handled by the awaiting task, but the discarded promise.finally(...) branch rejects with no handler.
  4. Observe an unhandled promise rejection reported to error telemetry as SignatureVerificationInternal: 署名の検証が 'UnknownError' エラーで失敗しました。.

How the Fix Works

Chosen approachsrc/vs/platform/extensionManagement/node/extensionManagementService.ts, createInstallExtensionTask:

Before:

this.extractingGalleryExtensions.set(extensionKey.toString(), promise = this.downloadAndExtractGalleryExtension(extensionKey, extension, operation, options, token));
promise.finally(() => this.extractingGalleryExtensions.delete(extensionKey.toString()));

After:

promise = this.downloadAndExtractGalleryExtension(extensionKey, extension, operation, options, token)
    .finally(() => this.extractingGalleryExtensions.delete(extensionKey.toString()));
this.extractingGalleryExtensions.set(extensionKey.toString(), promise);

.finally() is now chained into the single promise that is both stored in the map and returned to the caller, so that one handled promise carries the cleanup. The discarded second promise — the only branch without a rejection handler — no longer exists, so a rejecting download can no longer produce an unhandled rejection. The fix is placed at the producer of the unhandled rejection (the dedup memoization), not at the crash site (the signature-verification throw) — following the principle of fixing where the unhandled value is produced rather than where it surfaces. No try/catch is added and no logService.error/throw is removed: the awaiting caller still receives and handles the rejection exactly as before; only the duplicate, unobserved promise branch is removed. The change mirrors the already-correct idiom used by scanExtensions in the same file (its scan dedup chains .finally() into the stored/awaited promise), so it restores consistency rather than inventing a new pattern.

After this change, the createInstallExtensionTask dedup block cannot produce an unhandled rejection on a failed download, because the only promise derived from the download (the stored/returned one) now includes the .finally() cleanup and is awaited by the caller — there is no longer a separate, unobserved .finally() promise to reject.

Alternatives considered:

  • Add an UnknownError case at the downloadExtension switch so it stops throwing SignatureVerificationInternal — rejected: failing closed on an unverifiable signature is correct security behavior, and the UI already handles this error gracefully; the throw is not the bug, the orphan rejection is.
  • Attach a .catch() to the orphan .finally() promise — rejected: this swallows/duplicates handling and is strictly worse than removing the orphan branch and reusing the existing, verified idiom.
  • Wrap the download/verify in try/catch — rejected: that hides the error from the telemetry pipeline instead of fixing the unhandled-rejection producer.

Recommended Owner

@sandy081 (Sandeep Somavarapu). Rationale: declared GDPR owner of the signature-verification telemetry event in extensionSignatureVerificationService.ts; author of the extension-management auto-update series that introduced/amplified the affected background download path; long-standing owner of the extension-management area. Active in the repository within the last days (e.g. 88e21a04, 2026-04-10).

Generated by errors-fix · 1.5K AIC · ⌖ 116.6 AIC · ⊞ 69.4K ·

…nsion download dedup (fixes #323530)

createInstallExtensionTask memoizes in-flight gallery downloads in
extractingGalleryExtensions, but called promise.finally(cleanup) as a
standalone statement and returned the raw promise. When the download/
verify rejected (e.g. signature verification UnknownError), the returned
promise was handled by the caller while the discarded .finally() promise
rejected with no handler, producing an unhandled rejection in the shared
process that surfaced in error telemetry.

Fold .finally() into the stored/returned promise so the single handled
promise carries the cleanup, matching the existing idiom used by
scanExtensions in the same file. No error is suppressed; the real caller
still receives and handles the rejection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 29, 2026 16:57

Copilot AI 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.

Copilot can't review bot-authored pull requests automatically. A user with Copilot access can request a review manually.

Copilot AI 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.

Copilot can't review bot-authored pull requests automatically. A user with Copilot access can request a review manually.

@vs-code-engineering
vs-code-engineering Bot marked this pull request as ready for review June 29, 2026 17:00
@vs-code-engineering
vs-code-engineering Bot enabled auto-merge (squash) June 29, 2026 17:00
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.

[Error] unhandlederror-署名の検証が 'UnknownError' エラーで失敗しました。

2 participants