Skip to content

[BUG]: sf package uninstall hides the real uninstall error behind MALFORMED_QUERY #3661

Description

@jamesquinn103

TL;DR: the SOQL in getUninstallErrors (src/package/packageUninstall.ts L30) is wrapped in
literal ". The patch below removes them and adds a test for each caller. I'd like to open it as
a PR on forcedotcom/packaging. If that's OK, assign this issue to me and I'll open it.

Summary

I'm a Salesforce employee (product management for ISV extensibility). I found this while testing
managed-package installs and uninstalls the way our ISV partners run them.

When an uninstall fails after the org accepts the request, sf package uninstall --wait and
sf package uninstall report hide the failure: no "uninstall failed", no request id, no reasons
(see Actual). I've hit it four times in three months in my own ISV test pipelines, once mistaking
it for a platform error and once losing a real failure behind a passing retry.

Cause

src/package/packageUninstall.ts L28-33:

const errorQueryResult = await conn.tooling.query<{ Message: string }>(
  `"SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message"`
);

The query throws before UNINSTALL_ERROR is built. Its only two callers:

No test caught it: the pollUninstall error test
(uninstallPackage.test.ts L85)
stubs conn.tooling.query without checking its argument, and uninstallStatus's Error branch
has no test in
subscriberPackageVersion.test.ts.

Steps to reproduce

  1. Unit tests, under Node 22. Save the patch below as tool01.patch in an empty directory, then
    from that directory:
    git clone https://github.com/forcedotcom/packaging && cd packaging
    git checkout 51fc61b                                # the commit the patch is cut against
    yarn install
    git apply ../tool01.patch                           # fix + tests
    git checkout -- src/package/packageUninstall.ts     # undo only the fix
    npx mocha test/package/uninstallPackage.test.ts test/package/subscriberPackageVersion.test.ts
    Result: 13 passing, 2 failing ("should send the uninstall request, and handle errors
    appropriately" and "uninstallStatus reports a failed request with its errors, using valid
    SOQL"), each with
    AssertionError: expected '"SELECT Message FROM PackageVersionUn…' to equal 'SELECT Message FROM PackageVersionUni…'
    
  2. Against a scratch org, the query string from L30:
    sf data query --use-tooling-api -o <org> -q "\"SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '06y000000000000AAA' ORDER BY Message\""
    
    Result: MALFORMED_QUERY: unexpected token: '"'. Without the outer quotes it runs
    (totalSize: 0).
End to end, with a real uninstall that fails late (optional; needs a Dev Hub with a namespace)

Only an uninstall the org accepts and that fails later reaches this code. One the org refuses up
front (for example, a subscriber class references the package) is reported correctly. So this
recipe adds the blocking reference while a slow uninstall script holds the job open.

  1. Build a managed beta with one custom object (UninstProbe__c) and an uninstall script that
    waits 40 seconds:
    // sfdx-project.json, package directory
    {
      "path": "force-app",
      "package": "UninstLateFailProbe",
      "versionNumber": "0.1.0.NEXT",
      "uninstallScript": "UninstProbeHandler",
      "default": true
    }
    global without sharing class UninstProbeHandler implements UninstallHandler {
      global void onUninstall(UninstallContext ctx) {
        Long start = System.currentTimeMillis();
        while (System.currentTimeMillis() - start < 40000) {
        }
      }
    }
    sf package version create -p UninstLateFailProbe -x --skip-validation -w 30
  2. Install it in a scratch org and start sf package uninstall -p <04t> -o <org> -w 20.
  3. Once the uninstall prints Status = InProgress, deploy this class from a second terminal, finishing
    within the script's 40 seconds (<ns> is the package's namespace):
    public with sharing class SubRefProbe {
        public static Integer countRows() { return [SELECT COUNT() FROM <ns>__UninstProbe__c]; }
    }
    Result: the uninstall command ends with the Actual output; the request's Status is Error and the package
    is still installed. sf package uninstall report -i <06y> prints the same.
  4. Control: delete SubRefProbe and uninstall again (the package is still installed): Successfully uninstalled package.

Measured 2026-10-04, CLI 2.152.14, Winter '27 scratch org.

Expected

UNINSTALL_ERROR: Can't uninstall the package <06y> during uninstall request <06y>., plus the
=== Errors list when PackageVersionUninstallRequestError has rows. (Both ids are the request id
today, at packageUninstall.ts L58 and subscriberPackageVersion.ts L219; a separate, minor issue.)

Actual

The CLI prints Error (1): unexpected token: '"' (exit 1). With --json, the error code is
MALFORMED_QUERY. This is the uninstall command's output, from the end-to-end steps; the quick
repro (step 2) shows the underlying query error.

Fix and tests

Checked on main at 51fc61b (5.0.14): with the patch, the two files pass 15 tests.

  • Fix: drop the outer " at L30.
  • uninstallPackage.test.ts, "should send the uninstall request, and handle errors
    appropriately": now also checks the SOQL pollUninstall sends. The id it expects,
    04t4p000002BaHYAA0, is the request id this file's create() stub returns, not the package id.
  • subscriberPackageVersion.test.ts, new "uninstallStatus reports a failed request with its
    errors, using valid SOQL": reuses the queryStub from beforeEach, and checks the error after
    the try, so a call that doesn't throw fails.
diff --git a/src/package/packageUninstall.ts b/src/package/packageUninstall.ts
index 927a1c4..7680743 100644
--- a/src/package/packageUninstall.ts
+++ b/src/package/packageUninstall.ts
@@ -27,7 +27,7 @@ type UninstallResult = PackagingSObjects.SubscriberPackageVersionUninstallReques
 
 export async function getUninstallErrors(conn: Connection, id: string): Promise<Array<{ Message: string }>> {
   const errorQueryResult = await conn.tooling.query<{ Message: string }>(
-    `"SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message"`
+    `SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message`
   );
   return errorQueryResult?.records ?? [];
 }
diff --git a/test/package/subscriberPackageVersion.test.ts b/test/package/subscriberPackageVersion.test.ts
index 0f191f4..44e5ccf 100644
--- a/test/package/subscriberPackageVersion.test.ts
+++ b/test/package/subscriberPackageVersion.test.ts
@@ -170,6 +170,24 @@ describe('subscriberPackageVersion', () => {
       expect(queryStub.called).to.be.true;
     }
   });
+  it('uninstallStatus reports a failed request with its errors, using valid SOQL', async () => {
+    const id = '06y000000000001AAA';
+    // @ts-ignore
+    $$.SANDBOX.stub(connection.tooling, 'retrieve').resolves({ Id: id, Status: 'Error' });
+    queryStub.resolves({ records: [{ Message: 'this is a server-side error message' }], done: true, totalSize: 1 });
+
+    let error: Error | undefined;
+    try {
+      await SubscriberPackageVersion.uninstallStatus(id, connection);
+    } catch (e) {
+      error = e as Error;
+    }
+    expect(error?.name).to.equal('UNINSTALL_ERROR');
+    expect(error?.message).to.include('(1) this is a server-side error message');
+    expect(queryStub.firstCall.args[0]).to.equal(
+      `SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message`
+    );
+  });
   it('should propagate the same error from the SPV query', async () => {
     connection = await testOrg.getConnection();
 
diff --git a/test/package/uninstallPackage.test.ts b/test/package/uninstallPackage.test.ts
index 3ebf4cc..89c5ca0 100644
--- a/test/package/uninstallPackage.test.ts
+++ b/test/package/uninstallPackage.test.ts
@@ -91,7 +91,7 @@ describe('Package Uninstall', () => {
       }),
     });
     // @ts-ignore
-    $$.SANDBOX.stub(conn.tooling, 'query').resolves({
+    const queryStub = $$.SANDBOX.stub(conn.tooling, 'query').resolves({
       records: [{ Message: 'this is a server-side error message' }, { Message: 'this is a second error message' }],
     });
 
@@ -108,6 +108,9 @@ describe('Package Uninstall', () => {
       expect(error.message).to.include('(2) this is a second error message');
       expect(error.actions).to.deep.equal(['Verify installed package ID and resolve errors, then try again.']);
     }
+    expect(queryStub.firstCall.args[0]).to.equal(
+      "SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '04t4p000002BaHYAA0' ORDER BY Message"
+    );
   });
 
   it('should send the uninstall request, and handle errors appropriately (0 error messages)', async () => {

Environment

  • Affected: @salesforce/packaging 5.0.12 (L30 at its commit 7ec944d) through 5.0.14 (main, 51fc61b).
  • CLI runs (step 2 and the end-to-end recipe): @salesforce/cli/2.152.14 darwin-arm64 node-v24.20.0, which bundles the @salesforce/packaging library 5.0.12 (via plugin-packaging 3.0.7).
  • Unit tests (step 1): Node 22, on 51fc61b. The repo's mocha doesn't start on Node 26.
sf doctor (version detail, trimmed)
cliVersion: @salesforce/cli/2.152.14
architecture: darwin-arm64 (Darwin 25.6.0), shell zsh
nodeVersion: node-v24.20.0
plugin-packaging: 3.0.7 (core); it loads the @salesforce/packaging library 5.0.12, where this bug is
other plugins: deploy-retrieve 4.2.2, auth 5.0.7, org 6.0.13, data 5.1.8 (core); code-analyzer 5.16.0 (user)
diagnostics: all pass except "sourceApiVersion matches apiVersion" (warn: run outside a project)

Activity

  1. changed the title [-]sf package uninstall hides the real uninstall error behind MALFORMED_QUERY[/-] [+][BUG]: sf package uninstall hides the real uninstall error behind MALFORMED_QUERY[/+] on Oct 7, 2026
  2. jamesquinn103 commented on Oct 7, 2026

    @jamesquinn103
    Author

    Filling in the bug form's fields here, since I opened this from the command line and skipped the form:

    • Command: sf package uninstall --wait <minutes> and sf package uninstall report -i <06y request id>
    • Org Type: scratch org (the subscriber org the package was installed into)
    • Did this work before? No, as far as I can tell. The quoted query has been in src/package/packageUninstall.ts since the file was added in 243f4e8 (August 2022), and every later version of the file has it.
    • Before you submit: I'm on the latest CLI (2.152.14; sf doctor reports it as current), I searched the existing issues, and the trimmed sf doctor output is in the issue body.
  3. jamesquinn103 commented on Oct 8, 2026

    @jamesquinn103
    Author

    Closing in favor of #3664, which I filed through the bug form. I also got one fact wrong here: this is a regression. sfdx force:package:uninstall reported this failure correctly through sfdx-cli 7.182.1 and broke in 7.183.0, when the command moved to plugin-packaging. Measurements are in #3664.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions