Repository navigation
libuv assertion on Windows with Node.js 23.x #56645
Description
Version
23.x+
Platform
Windows
Subsystem
No response
What steps will reproduce the bug?
Write the following file as registryServer.mjs:
registryServer.mjs
The original file is https://github.com/nodejs/corepack/blob/main/tests/_registryServer.mjs, I tried to trim the unrelated stuff but it's still a large file:
import { createHash, createSign, generateKeyPairSync } from "node:crypto";
import { once } from "node:events";
import { createServer } from "node:http";
import { gzipSync } from "node:zlib";
let privateKey, keyid;
({ privateKey } = generateKeyPairSync(`ec`, {
namedCurve: `sect239k1`,
}));
const { privateKey: p, publicKey } = generateKeyPairSync(`ec`, {
namedCurve: `sect239k1`,
publicKeyEncoding: {
type: `spki`,
format: `pem`,
},
});
privateKey ??= p;
keyid = `SHA256:${createHash(`SHA256`).end(publicKey).digest(`base64`)}`;
process.env.COREPACK_INTEGRITY_KEYS = JSON.stringify({
npm: [
{
expires: null,
keyid,
keytype: `ecdsa-sha2-sect239k1`,
scheme: `ecdsa-sha2-sect239k1`,
key: publicKey.split(`\n`).slice(1, -2).join(``),
},
],
});
function createSimpleTarArchive(fileName, fileContent, mode = 0o644) {
const contentBuffer = Buffer.from(fileContent);
const header = Buffer.alloc(512); // TAR headers are 512 bytes
header.write(fileName);
header.write(`100${mode.toString(8)} `, 100, 7, `utf-8`); // File mode (octal) followed by a space
header.write(`0001750 `, 108, 8, `utf-8`); // Owner's numeric user ID (octal) followed by a space
header.write(`0001750 `, 116, 8, `utf-8`); // Group's numeric user ID (octal) followed by a space
header.write(`${contentBuffer.length.toString(8)} `, 124, 12, `utf-8`); // File size in bytes (octal) followed by a space
header.write(
`${Math.floor(new Date(2000, 1, 1) / 1000).toString(8)} `,
136,
12,
`utf-8`
); // Last modification time in numeric Unix time format (octal) followed by a space
header.fill(` `, 148, 156); // Fill checksum area with spaces for calculation
header.write(`ustar `, 257, 8, `utf-8`); // UStar indicator
// Calculate and write the checksum. Note: This is a simplified calculation not recommended for production
const checksum = header.reduce((sum, value) => sum + value, 0);
header.write(`${checksum.toString(8)}\0 `, 148, 8, `utf-8`); // Write checksum in octal followed by null and space
return Buffer.concat([
header,
contentBuffer,
Buffer.alloc(512 - (contentBuffer.length % 512)),
]);
}
const mockPackageTarGz = gzipSync(
Buffer.concat([
createSimpleTarArchive(
`package/bin/pnpm.js`,
`#!/usr/bin/env node\nconsole.log("pnpm: Hello from custom registry");\n`,
0o755
),
createSimpleTarArchive(
`package/package.json`,
JSON.stringify({
bin: {
pnpm: `bin/pnpm.js`,
},
})
),
Buffer.alloc(1024),
])
);
const shasum = createHash(`sha1`).update(mockPackageTarGz).digest(`hex`);
const integrity = `sha512-${createHash(`sha512`)
.update(mockPackageTarGz)
.digest(`base64`)}`;
const registry = {
__proto__: null,
pnpm: [`42.9998.9999`],
};
function generateSignature(packageName, version) {
if (privateKey == null) return undefined;
const sign = createSign(`SHA256`).end(
`${packageName}@${version}:${integrity}`
);
return {
integrity,
signatures: [
{
keyid,
sig: sign.sign(privateKey, `base64`),
},
],
};
}
function generateVersionMetadata(packageName, version) {
return {
name: packageName,
version,
bin: {
[packageName]: `./bin/${packageName}.js`,
},
dist: {
shasum,
size: mockPackageTarGz.length,
tarball: `https://registry.npmjs.org/${packageName}/-/${packageName}-${version}.tgz`,
...generateSignature(packageName, version),
},
};
}
const server = createServer((req, res) => {
let slashPosition = req.url.indexOf(`/`, 1);
if (req.url.charAt(1) === `@`)
slashPosition = req.url.indexOf(`/`, slashPosition + 1);
const packageName = req.url.slice(
1,
slashPosition === -1 ? undefined : slashPosition
);
if (packageName in registry) {
if (req.url === `/${packageName}`) {
// eslint-disable-next-line @typescript-eslint/naming-convention
res.end(
JSON.stringify({
"dist-tags": {
latest: registry[packageName].at(-1),
},
versions: Object.fromEntries(
registry[packageName].map((version) => [
version,
generateVersionMetadata(packageName, version),
])
),
})
);
return;
}
const isDownloadingRequest =
req.url.slice(packageName.length + 1, packageName.length + 4) === `/-/`;
let version;
if (isDownloadingRequest) {
const match = /^(.+)-(.+)\.tgz$/.exec(
req.url.slice(packageName.length + 4)
);
if (match?.[1] === packageName) {
version = match[2];
}
} else {
version = req.url.slice(packageName.length + 2);
}
if (version === `latest`) version = registry[packageName].at(-1);
if (registry[packageName].includes(version)) {
res.end(
isDownloadingRequest
? mockPackageTarGz
: JSON.stringify(generateVersionMetadata(packageName, version))
);
} else {
res.writeHead(404).end(`Not Found`);
throw new Error(`unsupported request`, {
cause: { url: req.url, packageName, version, isDownloadingRequest },
});
}
} else {
res.writeHead(500).end(`Internal Error`);
throw new Error(`unsupported request`, {
cause: { url: req.url, packageName },
});
}
});
server.listen(0, `localhost`);
await once(server, `listening`);
const { address, port } = server.address();
process.env.COREPACK_NPM_REGISTRY = `http://user:pass@${
address.includes(`:`) ? `[${address}]` : address
}:${port}`;
server.unref();Then run the following commands:
$env:COREPACK_ENABLE_PROJECT_SPEC=0
$env:NODE_OPTIONS="--import ./registryServer.mjs"
corepack pnpm@42.x --versionor, a simpler repro taken from #58091:
"use strict";
(async function(){
var url = 'https://google.com/';
var code = await fetch(url).then(function(r){
return r.status;
});
console.log(code);
process.exit();
})();How often does it reproduce? Is there a required condition?
Always on Windows with Node.js 23.x, no required condition, tested with 23.0.0 (libuv 1.48.0), 23.4.0 (libuv 1.49.1), and 23.6.0 (libuv 1.49.2).
It does not reproduce on Linux nor macOS.
It does not reproduce on 22.13.2 (libuv 1.49.2), which makes me think it's not a libuv bug, but a Node.js one.
What is the expected behavior? Why is that the expected behavior?
No assertions, the exit code should be 1
What do you see instead?
Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file c:\ws\deps\uv\src\win\async.c, line 76
The exit code is 3221226505.
Additional information
My initial thought was that it might be related to having an exception thrown while handling an HTTP request, but I wasn't able to reproduce with just that.
Activity
aduh95 commentedon Jan 17, 2025
/cc @nodejs/platform-windows @nodejs/libuv
bnoordhuis commentedon Jan 17, 2025
FWIW, I also agree it's not a libuv bug; it's libuv saying node is doing something wrong. Specifically, node calls uv_async_send() after uv_close().
aduh95 commentedon Jan 22, 2025
git bisectpoints to #54077MikeMcC399 commentedon Jan 30, 2025
This PR shows that it changes 3143 files, so that does not immediately point to a source of the issue.
Is anybody going to be able to take a closer look at the issue?
targos commentedon Feb 5, 2025
@aduh95 did you take into account in your bisect that intermediary commits in V8 updates may not build or may have other issues?
aduh95 commentedon Feb 5, 2025
@targos yes, 7fab6e8 is "good" (i.e. it builds and the repro exits without any assert), 64e8646 is "bad" (i.e. it builds and it exits with the assert), it doesn't build in between
bnoordhuis commentedon May 1, 2025
This may have been fixed by #57910.
BananaAcid commentedon May 5, 2025
Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file c:\ws\deps\uv\src\win\async.c, line 76=> $LastExitCode == 0here - I can't seem to pinpoint where this gets triggered / conditions by the JS script. But it seems to work, if you stub any fileWrite and fileRead function. Never gets triggered when using --inspect
node v23.11.0
edit:
Triggered the error:
if (parsedArgs['config']) { process.exit(0); }fixed the error:
if (parsedArgs['config']) { await new Promise(resolve => setTimeout(resolve, 100)); process.exit(0); }silverwind commentedon Jul 16, 2025
Still seems to be an issue on Node 24.4.0, Windows, example failure, stderr is the same as OP. Node 20 and 22 are good.
Adding a 50ms async delay before
process.exitseems to work as a workaround.MikeMcC399 commentedon Jul 16, 2025
24.5.024.10.0and25.0.0Exclude until issue resolved nodejs/node#56645
53 remaining items
ci: move Windows off 24.15.0, which carries the crash we were chasing
Gate batch: postmortem track, libuv exitCode workaround, smoke profil…
Let operium CLI drain native handles on Windows exit
Fix: symbolSearch usa node:https en vez de fetch() — fetch() disparab…
test: settle before process.exit — Node 24 on Windows asserts in libu…
Add Node 24 to the gates matrix, Windows leg allowed to fail
fix(cli): avoid the Windows abort when exiting after an HTTPS fetch
Tyler-V commentedon Sep 16, 2026
Hitting the same assertion from a different call site, worth noting since this issue closed on staleness while still active: our crash is not from
fetch/undici socket cleanup, it is V8's own memory pool background task.Environment: Electron 41.1.1 (bundles Node 24.14.0), Windows 10/11, native crash reporting via a Crashpad minidump. Crashing thread's stack, innermost first:
NodePlatform::PostDelayedTaskOnWorkerThreadImplcallsuv_async_sendon a handle that shutdown has already started closing, same assertion class as the OP, just triggered by V8's own delayed-task scheduling rather than an app-levelfetch/process.exit()sequence. That argues against the theory floated above that this is specifically an undici socket-cleanup gap: here nothing in application code touched the network, the process was simply exiting while a GC-driven memory pool release was still scheduled on a worker thread.One difference from the OP: our crash reporter (Sentry via Crashpad) captured this as
EXCEPTION_BREAKPOINTviawil::details::DebugBreakrather than the rawabort()/exit code 3221226505 other reporters saw. That is likely just how Crashpad's exception handler intercepts the fail-fast path on this build, not a different root cause; the assertion site and call shape are identical.Only one occurrence so far in our telemetry, so this is a data point rather than a reliable local repro, but wanted to add it since #61999 is still open and every independent trigger narrows down what needs covering.
Load queue-loop's TypeScript modules on use, not at import
ci: test on Node.js 24.20 rather than 24.11
feat!: replace lint-roller-markdown-standard with oxlint and oxfmt co…
fix(toolbox,pipeline): fix double-tab dashboard launcher, stale Super…
CHANTIERS: Bell a1-bis checkpoint-2 (C-V-1..6), C-11 probe ruling, D4…
Bell a1-bis fold (checkpoint-2 C-V-1..6 + G2 C-G2b-1..6): persist per…
Bell a1-bis docs due at G7 (validator C-R-3): dated amendments of the…