Skip to content

spanner: Spanner.close() never resolves when called as a Promise #8106

@tykid16

Description

@tykid16

Environment details

  • OS: macOS (Darwin 25.3.0) / also reproducible on Linux (Cloud Run)
  • Node.js version: 24.14.1
  • @google-cloud/spanner version: 8.6.0
  • @grpc/grpc-js version: 1.14.3

Steps to reproduce

// repro.mjs — run with: node repro.mjs
import { Spanner } from "@google-cloud/spanner";
import { credentials } from "@grpc/grpc-js";

const spanner = new Spanner({
  projectId: "test-project",
  servicePath: "localhost",
  port: 15000,
  sslCreds: credentials.createInsecure(),
});

console.log("calling await spanner.close()...");
await spanner.close();
console.log("this line is never reached");
$ node repro.mjs
calling await spanner.close()...
Warning: Detected unsettled top-level await at file:///repro.mjs:12
await spanner.close();
^

$ echo $?
13

No Spanner instance is required — the bug is in the close() method itself.

Expected behavior

await spanner.close() should resolve once cleanup is complete.

Actual behavior

The Promise returned by spanner.close() never resolves. On Node 24, this causes the process to exit with code 13 ("unsettled top-level await"). On earlier Node versions, the process hangs until forcefully terminated.

Root cause

Spanner.close() is wrapped by promisifyAll() at the bottom of src/index.ts, and close is not in the exclude list:

https://github.com/googleapis/google-cloud-node/blob/main/packages/handwritten/src/spanner/src/index.ts#L2114-L2128

When called without a callback argument, the promisify wrapper creates a Promise and passes a synthetic callback to the original method, expecting it to be invoked. However, Spanner.close() ignores all arguments:

https://github.com/googleapis/google-cloud-node/blob/main/packages/handwritten/src/spanner/src/index.ts#L543-L554

close() {
  this.clients_.forEach(c => {
    const client = c as GapicClient;
    if (client.operationsClient && client.operationsClient.close) {
      client.operationsClient.close();
    }
    client.close();
  });
  cleanup().catch(err => {
    console.error('Error occured during cleanup: ', err);
  });
  // ← callback is never called
}

Since the callback is never invoked, the Promise stays pending forever.

The TypeScript declaration (close(): void) correctly reflects the implementation, but at runtime the promisify wrapper overrides the return type.

A similar issue was reported for Database.close() in googleapis/nodejs-spanner#1828.

Suggested fix

Either:

  1. Add 'close' to the promisifyAll exclude list (if close() is intended to remain fire-and-forget), or
  2. Accept and invoke the callback parameter:
close(callback?: (err: Error | null) => void) {
  this.clients_.forEach(c => {
    const client = c as GapicClient;
    if (client.operationsClient && client.operationsClient.close) {
      client.operationsClient.close();
    }
    client.close();
  });
  cleanup()
    .then(() => callback?.(null))
    .catch(err => callback?.(err));
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    api: spannerIssues related to the Spanner API.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions