Skip to content

Watcher done callback may be invoked twice on unexpected connection loss #2387

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
bverhoeven opened this issue Apr 23, 2025 · 1 comment
Closed

Comments

@bverhoeven
Copy link
Contributor

Describe the bug

When a watch connection closes abruptly, the done callback may be invoked multiple times because of reentrancy in the doneCallOnce() function.

Client Version
1.1.2

Server Version
v1.32.2-gke.1182003

To Reproduce

  1. Use Watch to start a stream (e.g. watch pods in a namespace).
  2. Simulate a premature close on the connection (e.g. by destroying the socket on the server side).
  3. The done callback is called with "AbortError: The user aborted a request"
  4. The done callback is called again, but now with "Error: Premature close"

Expected behavior

The done callback should only be called once per watch termination.

Example Code

The code below can be used to replicate the issue on a local machine.

import http from 'http'
import { KubeConfig, Watch } from '@kubernetes/client-node'

const MOCK_PORT = 8333
const MOCK_URL = `http://localhost:${MOCK_PORT}`

const minimalServer = http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'application/json',
        'Transfer-Encoding': 'chunked',
    })

    res.flushHeaders()
    res.destroy() // Prematurely close the connection
})

minimalServer.listen(MOCK_PORT, () => {
    const kubeConfig = new KubeConfig()

    kubeConfig.loadFromClusterAndUser(
        {
            name: 'mock-cluster',
            server: MOCK_URL,
            skipTLSVerify: true,
        },
        { name: 'mock-user' }
    )

    const watch = new Watch(kubeConfig)

    watch.watch(
        `/api/v1/namespaces/default/pods`,
        {},
        () => { },
        (err) => console.log('done()', err)
    )
})

Output:

done() AbortError: The user aborted a request.
    at abort (/home/bas/projects/watch-repro/node_modules/node-fetch/lib/index.js:1458:16)
    at AbortSignal.abortAndFinalize (/home/bas/projects/watch-repro/node_modules/node-fetch/lib/index.js:1473:4)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:827:20)
    at AbortSignal.dispatchEvent (node:internal/event_target:762:26)
    at runAbort (node:internal/abort_controller:449:10)
    at abortSignal (node:internal/abort_controller:435:3)
    at AbortController.abort (node:internal/abort_controller:468:5)
    at PassThrough.doneCallOnce (file:///home/bas/projects/watch-repro/node_modules/@kubernetes/client-node/dist/watch.js:33:28)
    at PassThrough.emit (node:events:519:35)
    at emitErrorNT (node:internal/streams/destroy:170:8) {
  type: 'aborted'
}
done() Error: Premature close
    at IncomingMessage.<anonymous> (/home/bas/projects/watch-repro/node_modules/node-fetch/lib/index.js:1748:18)
    at Object.onceWrapper (node:events:621:28)
    at IncomingMessage.emit (node:events:507:28)
    at emitCloseNT (node:internal/streams/destroy:148:10)
    at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {
  code: 'ERR_STREAM_PREMATURE_CLOSE'
}

Environment (please complete the following information):

  • OS: Linux
  • Node.js version 23.11.0
  • Cloud runtime: GCP

Additional context

This was discovered while debugging a broader issue with ListWatch failing to reconnect on non-410 errors (#2385). In those cases, doneCallOnce emits two errors:

  • AbortError: The user aborted a request.
  • Error: Premature close

Although doneCallOnce is intended to emit an error only once, it currently calls controller.abort() before setting doneCalled = true. This seems to trigger an AbortError in certain circumstances, which leads to a second invocation of doneCallOnce, since the doneCalled flag hasn’t been set yet.

As a result, both the original error and the AbortError are reported.

javascript/src/watch.ts

Lines 46 to 53 in 88184dc

let doneCalled: boolean = false;
const doneCallOnce = (err: any) => {
if (!doneCalled) {
controller.abort();
doneCalled = true;
done(err);
}
};

I was asked to create a separate issue and am working on a PR to add tests and fix the issue.

bverhoeven added a commit to bverhoeven/k8s-client-node that referenced this issue Apr 24, 2025
Avoids multiple invocations of the done callback when a watch connection
is closed unexpectedly (e.g., a premature socket close). The fix sets
doneCalled = true before calling controller.abort(), to ensure that any
AbortError triggered by aborting does not result in another call to the
done callback.

A regression test is included to simulate an abrupt connection termination.
It uses a real HTTP server that flushes headers and then destroys the
connection immediately. This reliably triggers the combination of AbortError
and Premature close needed to reproduce the issue.

Refs: kubernetes-client#2387
@bverhoeven
Copy link
Contributor Author

Fixed in #2389

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant