diff --git a/.claude/commands/prep-release.md b/.claude/commands/prep-release.md deleted file mode 100644 index f2436c527..000000000 --- a/.claude/commands/prep-release.md +++ /dev/null @@ -1,19 +0,0 @@ -# Prep Release - -Create a new project-level skill based on the provided intent. - -## User Input - -$ARGUMENTS - -## Guideliones - -1. Make sure you are on develop. -2. Make sure it is up-to-date. -3. Checkout the master branch -4. Merge develop into master -5. Open CHANGELOG.md and update the unreleased section with the new version (if not specified, use your tool to ask the user for the version). -6. Create a new Unreleased section with all sub-sections for a changelog entry: Breaking Change, Fixed, Added, Changed. -7. Make sure you update the version link all the way at the bottom of the CHANGELOG.md file. -8. Commit the changes with the message "chore: prepare release ". -9. Push the changes to the remote repository. diff --git a/.claude/skills/prep-release/SKILL.md b/.claude/skills/prep-release/SKILL.md new file mode 100644 index 000000000..303a0db6d --- /dev/null +++ b/.claude/skills/prep-release/SKILL.md @@ -0,0 +1,162 @@ +--- +name: prep-release +description: Prepare node-libcurl for a release — merge develop into master, decide the version bump, and convert the [Unreleased] CHANGELOG section into a dated version section. Use whenever Jonathan asks to "prep a release", "prepare a release", "do release prep", "merge develop into master", "cut a release", "ship a new version", or anything similar in this project. Also use when he says "we're shipping vX.Y.Z" or asks for help deciding what version number to use for the next release. Do NOT bump the package.json version and do NOT create tags — the release workflow handles those. +--- + +# Prep Release (node-libcurl) + +This project uses a master/develop branching model. Active work lands on `develop`. To release, develop is merged into `master`, and a CI workflow (`build-and-release.yaml`) builds prebuilt binaries and publishes them when a tag is pushed. + +**Your job here is the human-readable part of that flow**: getting master pointed at the right commit and converting the accumulated `[Unreleased]` changelog notes into a dated version section. The release workflow handles `package.json` version bumps and the git tag — don't touch those. + +## The workflow + +### 1. Pre-flight + +Confirm intent and check the working tree: + +```bash +git status +git fetch origin +git log --oneline origin/develop ^origin/master +``` + +That last command lists every commit on develop that hasn't reached master yet — i.e., everything that's about to ship. Read through it. You'll need this both for the version-bump call and to make sure the CHANGELOG actually mentions everything important. + +### 2. Reconcile master with the remote + +Local master is often stale on Jonathan's machine because the release workflow updates it on the remote. Don't merge develop into a stale local master and push that mess — first sync: + +```bash +git checkout master +git log master --not origin/master --oneline # any local-only commits? +``` + +If local master has commits not on origin/master, **look at them before touching anything**: +- `chore: prepare release X.Y.Z` or `fix: version fix` style commits with `[skip ci]` are leftover prep-release attempts — safe to `git reset --hard origin/master` +- Anything else: stop and ask Jonathan what they are + +If everything is fine and you just need to fast-forward: +```bash +git reset --hard origin/master +``` + +### 3. Decide the version bump + +Look at the commits from step 1 and at the current `[Unreleased]` block in `CHANGELOG.md`. Use semver: + +- **Patch (X.Y.Z+1)** — only when every change is a bug fix that doesn't alter the public API or any observable behavior contract for existing code. CI/build infrastructure fixes alone are patch-worthy. +- **Minor (X.Y+1.0)** — any of these, even if everything else is just a bug fix: + - **New runtime/platform support shipped as prebuilt binary.** Adding a Node.js major (e.g., Node 26) is the textbook minor for native addons: users on the new runtime couldn't use the old version at all, and now they can. This is the most common reason this project goes minor instead of patch. + - New public API surface (exported function, type, constant, method). + - Observable behavior change in existing API — including a "bug fix" that changes the value a getter returns or the timing of a callback. Code written against the broken behavior will see a different result. + - Documented contract change (e.g., examples in TSDoc now show a different API call that consumers may have copy-pasted). +- **Major** — breaking changes (removal/rename of public API, incompatible behavior changes that aren't bug fixes, etc.). + +When the call is borderline, **lean minor**. Patch in this ecosystem signals "rebuilt with no consumer-visible changes" — anything beyond that deserves the bump. + +Tell Jonathan which version you're proposing and why, and let them confirm before continuing. The version they pick drives the rest of the workflow. + +### 4. Merge develop into master + +```bash +git merge origin/develop --no-ff -m "Merge branch 'develop' into master for vX.Y.Z" +``` + +`--no-ff` keeps a merge commit so the release boundary is visible in the history. The release workflow expects this shape. + +### 5. Update CHANGELOG.md + +Two edits in the same commit: + +**A. Convert the `[Unreleased]` section into a dated version.** Find this block at the top: +```markdown +## [Unreleased] + +### Breaking Change + +### Fixed +- ...accumulated bullets... + +### Added +- ... + +### Changed +- ... +``` + +Split it: insert a fresh empty `[Unreleased]` block above (with all four subsections, even if empty — keeps the diff for the next release minimal), and rename the original to the dated version header: + +```markdown +## [Unreleased] + +### Breaking Change + +### Fixed + +### Added + +### Changed + +## [X.Y.Z] - YYYY-MM-DD + +### Fixed +- ...accumulated bullets... + +### Added +- ... + +### Changed +- ... +``` + +Use today's actual date (check `date +%Y-%m-%d` if unsure). + +**B. Before committing, look for changes that landed since the last release but never got a changelog line.** Run `git log v..HEAD --oneline` and scan for things like CI fixes, dependency bumps, or build-script changes that the author skipped because they thought of them as "not user-facing." Some of them *are* user-facing in practice — e.g.: +- A CI fix that unblocks a platform that was failing to publish (users on that platform feel it). +- A node-gyp / node-pre-gyp bump that changes what compilers are detected when users build from source. +- A container image bump in the build matrix. + +If you find any, add them to the appropriate section of the new `[X.Y.Z]` block. Don't pad — just capture what a user might care about. + +**C. Update the compare links at the bottom of the file.** Find: +```markdown +[Unreleased]: https://github.com/JCMais/node-libcurl/compare/v...HEAD +[]: ... +``` + +Bump it to: +```markdown +[Unreleased]: https://github.com/JCMais/node-libcurl/compare/vX.Y.Z...HEAD +[X.Y.Z]: https://github.com/JCMais/node-libcurl/compare/v...vX.Y.Z +[]: ... +``` + +It's easy to forget the second edit and the bottom link will silently rot — always do both. + +### 6. Commit (do not push, do not tag) + +```bash +git add CHANGELOG.md +git commit -m "chore: prepare CHANGELOG for vX.Y.Z release" +``` + +Then **stop and tell Jonathan what's staged**. Show them the log of what's about to be pushed (the merge commit + the changelog commit). Do not push without explicit go-ahead — they sometimes want a final look first. + +When they say push: `git push origin master`. + +### 7. Don't do these things + +- **Don't bump `package.json`'s `version` field.** The release workflow (`build-and-release.yaml`) handles that. +- **Don't create or push a tag.** Same — the workflow handles it when master moves. +- **Don't push develop.** Only master gets pushed in this flow. +- **Don't merge with `--ff-only` or squash.** The release boundary needs to be a real merge commit. + +## Why the workflow has this exact shape + +A few things in here look fussy until you've seen them break: + +- **`git fetch` before checking master state** — a stale local view causes you to overwrite remote progress. +- **Insisting on an empty `[Unreleased]` block** — keeps the diff for the next release small; the next prep-release just fills it in. Without this, the next person has to remember to recreate the four subsections. +- **Reviewing commits before writing the version section** — the `[Unreleased]` block is whatever the contributors remembered to write. Things that landed in CI-fix or dep-bump commits often *aren't* in there but matter for users. +- **Patch vs minor on platform support** — Node.js majors in particular: a user on Node 26 trying to install v5.0.2 gets nothing (no ABI 145 binary). When that user shows up post-release saying "does this even support Node 26?", you want the answer to be "yes, since v5.1.0," not "yes since v5.0.3, technically." The version number is part of the user-facing answer. diff --git a/.codex b/.codex new file mode 100644 index 000000000..e69de29bb diff --git a/.github/actions/setup-vcpkg-windows/action.yaml b/.github/actions/setup-vcpkg-windows/action.yaml index 4122a7f97..f2032c68e 100644 --- a/.github/actions/setup-vcpkg-windows/action.yaml +++ b/.github/actions/setup-vcpkg-windows/action.yaml @@ -34,7 +34,7 @@ runs: - name: Setup vcpkg shell: pwsh env: - VCPKG_DISABLE_METRICS: false + VCPKG_DISABLE_METRICS: '1' run: | cd vcpkg .\bootstrap-vcpkg.bat diff --git a/.github/workflows/build-and-release.yaml b/.github/workflows/build-and-release.yaml index d954cdedd..e0bb8e870 100644 --- a/.github/workflows/build-and-release.yaml +++ b/.github/workflows/build-and-release.yaml @@ -50,7 +50,7 @@ jobs: || matrix.os == 'alpine-arm64' && 'ubuntu-24.04-arm' || matrix.os }} container: >- - ${{ matrix.os == 'alpine' && format('node:{0}-alpine3.21', matrix.node) + ${{ matrix.os == 'alpine' && format('node:{0}-alpine3.22', matrix.node) || matrix.os == 'rocky-linux-8' && format('rockylinux:8') || '' }} needs: @@ -72,6 +72,7 @@ jobs: libcurl-release: - ${{ needs.config.outputs.latest-libcurl-release }} node: + - 26 - 25 - 24 - 22 @@ -195,7 +196,7 @@ jobs: -v "${HOME}/deps:/root/deps" \ -v "${HOME}/.node-gyp:/root/.node-gyp" \ -w "${GITHUB_WORKSPACE}" \ - "node:${{ matrix.node }}-alpine3.21" \ + "node:${{ matrix.node }}-alpine3.22" \ sh -c ' apk add --no-cache bash python3 make g++ cmake perl linux-headers \ autoconf automake libtool ca-certificates coreutils pkgconfig py3-pip \ @@ -283,7 +284,7 @@ jobs: -e PNPM_VERSION="${PNPM_VERSION}" \ -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -w "${GITHUB_WORKSPACE}" \ - "node:${{ matrix.node }}-alpine3.21" \ + "node:${{ matrix.node }}-alpine3.22" \ sh -c ' apk add --no-cache bash ca-certificates && npm install -g pnpm@${PNPM_VERSION} && diff --git a/.github/workflows/build-lint-test.yaml b/.github/workflows/build-lint-test.yaml index bb15ddadb..f1d2b3607 100644 --- a/.github/workflows/build-lint-test.yaml +++ b/.github/workflows/build-lint-test.yaml @@ -32,7 +32,7 @@ jobs: || matrix.os == 'alpine-arm64' && 'ubuntu-24.04-arm' || matrix.os }} container: >- - ${{ matrix.os == 'alpine' && format('node:{0}-alpine3.21', matrix.node) + ${{ matrix.os == 'alpine' && format('node:{0}-alpine3.22', matrix.node) || matrix.os == 'rocky-linux-8' && format('rockylinux:8') || '' }} needs: config @@ -51,6 +51,7 @@ jobs: libcurl-release: - ${{ needs.config.outputs.latest-libcurl-release }} node: + - 26 - 25 - 24 - 22 @@ -153,7 +154,7 @@ jobs: -v "${HOME}/deps:/root/deps" \ -v "${HOME}/.node-gyp:/root/.node-gyp" \ -w "${GITHUB_WORKSPACE}" \ - "node:${{ matrix.node }}-alpine3.21" \ + "node:${{ matrix.node }}-alpine3.22" \ sh -c ' apk add --no-cache bash python3 make g++ cmake perl linux-headers \ autoconf automake libtool ca-certificates coreutils pkgconfig py3-pip \ @@ -209,7 +210,7 @@ jobs: -e PNPM_VERSION="${PNPM_VERSION}" \ -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -w "${GITHUB_WORKSPACE}" \ - "node:${{ matrix.node }}-alpine3.21" \ + "node:${{ matrix.node }}-alpine3.22" \ sh -c 'npm install -g pnpm@${PNPM_VERSION} && pnpm test:coverage' - name: Upload coverage to Codecov diff --git a/.github/workflows/stress-test.yaml b/.github/workflows/stress-test.yaml new file mode 100644 index 000000000..0d7c5ed80 --- /dev/null +++ b/.github/workflows/stress-test.yaml @@ -0,0 +1,116 @@ +# Canary stress test for issues that only show up under concurrent load. +# Currently exercises the deferred multi.removeHandle fix for issue #439 +# (CURLM_RECURSIVE_API_CALL), which reproduces most reliably on Alpine +# with libcurl 8.17+ but is a real race on any platform. +# +# This workflow doesn't publish or deploy anything — it just builds the +# addon and runs `pnpm test:stress`, which lives outside the default +# vitest run because it's expensive and timing-sensitive. + +name: stress-test + +defaults: + run: + shell: bash + +on: + push: + branches: + - master + - develop + pull_request: + +concurrency: + group: stress-test-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + config: + uses: ./.github/workflows/reusable-config.yaml + + stress: + needs: config + runs-on: ${{ matrix.os == 'alpine' && 'ubuntu-22.04' || matrix.os }} + container: ${{ matrix.os == 'alpine' && format('node:{0}-alpine3.22', matrix.node) || '' }} + strategy: + # Each cell is informative on its own — we want to see which platform + # tripped, not give up on the rest. + fail-fast: false + matrix: + # Keep this matrix narrow on purpose. Alpine is where the recursive + # API call manifests most aggressively (~73% failure rate without + # the fix), so it's the highest-signal canary. Ubuntu is included + # to catch glibc-side timing surprises. + os: + - alpine + - ubuntu-22.04 + node: + - 24 + env: + LIBCURL_RELEASE: ${{ needs.config.outputs.latest-libcurl-release }} + LATEST_LIBCURL_RELEASE: ${{ needs.config.outputs.latest-libcurl-release }} + PUBLISH_BINARY: false + RUN_TESTS: false + RUN_PREGYP_CLEAN: false + GIT_COMMIT: ${{ github.sha }} + GIT_REF_NAME: ${{ github.ref_name }} + steps: + - name: Install updated git (Alpine) + if: matrix.os == 'alpine' + shell: sh + run: | + apk add --no-cache git bash + + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: true + + - name: Install System Packages + uses: ./.github/actions/install-system-packages + + - name: Setup Node and PNPM + uses: ./.github/actions/setup-node-pnpm + with: + node-version: '${{ matrix.node }}' + skip-node-setup: ${{ matrix.os == 'alpine' && 'true' || 'false' }} + + - name: Setup Libcurl Cache (Restore) + id: libcurl-deps-cache + uses: ./.github/actions/setup-libcurl-cache + with: + libcurl-release: ${{ needs.config.outputs.latest-libcurl-release }} + node-version: ${{ matrix.node }} + electron-config-cache: ${{ needs.config.outputs.electron-config-cache }} + mode: 'restore-only' + + - name: Build addon + run: ./scripts/ci/build.sh + + - name: Check if fully installed and built + id: built-and-installed + run: | + if [[ -f built-and-installed.hidden.txt ]]; then + echo "status=true" >> $GITHUB_OUTPUT + else + echo "status=false" >> $GITHUB_OUTPUT + fi + + - name: Setup Libcurl Cache (Save) + if: steps.libcurl-deps-cache.outputs.cache-hit != 'true' && steps.built-and-installed.outputs.status == 'true' + uses: ./.github/actions/setup-libcurl-cache + with: + libcurl-release: ${{ needs.config.outputs.latest-libcurl-release }} + node-version: ${{ matrix.node }} + electron-config-cache: ${{ needs.config.outputs.electron-config-cache }} + mode: 'save-only' + + - name: Run stress tests + run: pnpm test:stress + + - name: Upload Build Logs + if: always() + uses: ./.github/actions/upload-build-logs + with: + artifact-name: stress-test-logs-${{ matrix.os }}-node-${{ matrix.node }} + retention-days: '3' diff --git a/.github/workflows/windows-consumer-install.yaml b/.github/workflows/windows-consumer-install.yaml new file mode 100644 index 000000000..b842f9bb9 --- /dev/null +++ b/.github/workflows/windows-consumer-install.yaml @@ -0,0 +1,175 @@ +name: windows-consumer-install + +defaults: + run: + shell: pwsh + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/windows-consumer-install.yaml' + - 'binding.gyp' + - 'package.json' + - 'pnpm-lock.yaml' + - 'scripts/**' + - 'src/**' + - 'vcpkg.template.json' + - 'vcpkg-configuration.json' + - 'overlays/**' + +concurrency: + group: windows-consumer-install-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + electron-source-install: + runs-on: windows-latest + timeout-minutes: 120 + + steps: + - name: Enable Windows long paths (registry + git) + run: | + # The whole point of this job is exercising a deeply-nested + # consumer path. Windows' default MAX_PATH=260 ceiling makes + # vcpkg blow up the moment it tries to CreateProcessW its + # downloaded pwsh.exe (~270+ char path). Flip the registry + # opt-in and the git default to long-path mode — both are + # standard for Windows dev/CI environments dealing with + # deep node_modules. + New-ItemProperty ` + -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' ` + -Name 'LongPathsEnabled' ` + -Value 1 ` + -PropertyType DWORD ` + -Force | Out-Null + git config --global core.longpaths true + + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: true + + - name: Setup Node and PNPM + uses: ./.github/actions/setup-node-pnpm + with: + node-version: '24' + + - name: Build package tarball + run: | + pnpm install --frozen-lockfile --ignore-scripts + pnpm build:dist + pnpm pack --pack-destination $env:RUNNER_TEMP + + - name: Install as pnpm consumer from source + env: + npm_config_build_from_source: 'true' + npm_config_runtime: electron + npm_config_target: '39.1.1' + npm_config_disturl: https://electronjs.org/headers + run: | + # PowerShell does NOT fail the step on a non-zero exit from a + # native command unless we tell it to. Without this, a failed + # `pnpm add` (gyp couldn't find VS, native build threw, etc.) + # would slide right past and the curl.exe check below would + # happily find the vcpkg-built curl from preinstall and report + # success — exactly what fooled us on the last run. + $ErrorActionPreference = 'Stop' + $PSNativeCommandErrorActionPreference = $true + + $package = Get-ChildItem $env:RUNNER_TEMP -Filter 'node-libcurl-*.tgz' | + Select-Object -First 1 + + if (-not $package) { + throw 'Packed node-libcurl tarball was not found' + } + + # 'nested-' * 4 puts the consumer at ~110 chars before node_modules, + # which mirrors what real-world pnpm monorepos hit (workspace + + # a couple of nested package depths). The previous 'nested-' * 12 + # forced moduleRoot to ~256 chars, leaving no headroom under + # MAX_PATH for MSVC's c1xx.exe — that compiler front-end doesn't + # carry the long-path app manifest, so the registry opt-in does + # nothing for it. With *4 we have ~135 chars of slack inside the + # 260-char ceiling for MSBuild's intermediate/log files. + $consumerRoot = Join-Path $env:RUNNER_TEMP ( + 'node-libcurl-consumer-' + ('nested-' * 4) + ) + + New-Item -ItemType Directory -Force -Path $consumerRoot + Set-Location $consumerRoot + # pnpm v10 blocks dependency install scripts by default. The consumer + # has to opt in for any package that needs its preinstall/install/ + # postinstall to run — and node-libcurl needs all three to run the + # vcpkg setup and build the native addon. Without this approval the + # install completes "successfully" but leaves the package non- + # functional (no vcpkg-installed curl.exe, no native binding). + # This is the exact failure mode real pnpm-v10 consumers hit, so we + # encode the workaround here rather than masking it. + Set-Content -Path package.json -Value (@' + { + "private": true, + "type": "commonjs", + "pnpm": { + "onlyBuiltDependencies": ["node-libcurl"] + } + } + '@) + + # pnpm v10 uses its own bundled node-gyp (v11.x as of pnpm 10.16), + # which doesn't know about Visual Studio 2026 (v145 toolset). The + # windows-latest runner is being migrated to VS 2026 ahead of the + # 2026-06-15 cutover, so pnpm's bundled gyp fails the build with + # `unknown version "undefined" found at C:\Program Files\Microsoft + # Visual Studio\18\Enterprise`. Override with a fresh node-gyp 12.x, + # which added VS 2026 detection in v12.1.0 — same workaround + # documented in scripts/ci/windows/build.ps1. + npm install --global node-gyp@latest + $globalNodeGypPath = Join-Path (npm prefix -g) 'node_modules\node-gyp\bin\node-gyp.js' + $env:npm_config_node_gyp = $globalNodeGypPath + Write-Host "Pointing pnpm at node-gyp: $globalNodeGypPath" + + pnpm add $package.FullName --allow-build=node-libcurl --fetch-timeout 300000 + if ($LASTEXITCODE -ne 0) { + throw "pnpm add exited with code $LASTEXITCODE" + } + + # The actual addon — this is what consumers `require()` at runtime, + # so it's the most important thing to verify exists. Without the + # node-gyp override above this never got written, and the previous + # "passing" run was just finding the vcpkg byproduct. + $consumerPkgRoot = Join-Path $consumerRoot 'node_modules\node-libcurl' + $addon = Get-ChildItem -Path $consumerPkgRoot -Recurse -Filter node_libcurl.node | + Select-Object -First 1 + + if (-not $addon) { + throw 'node_libcurl.node was not built; the consumer install would not be loadable at runtime' + } + Write-Host "Built addon at: $($addon.FullName) ($($addon.Length) bytes)" + + # vcpkg_installed lives outside the consumer's node_modules on + # Windows (see scripts/vcpkg-common.js for why). The path is + # %LOCALAPPDATA%\node-libcurl-vcpkg\-installed — + # `node scripts/vcpkg-get-info.js --include-dir` would print the + # final include path, but the simplest check is to scan the + # whole cache for any curl.exe in this run. + $vcpkgCacheRoot = Join-Path $env:LOCALAPPDATA 'node-libcurl-vcpkg' + $curlExe = Get-ChildItem -Path $vcpkgCacheRoot -Recurse -Filter curl.exe | + Where-Object { $_.FullName -like '*-installed\*\tools\curl\curl.exe' } | + Select-Object -First 1 + + if (-not $curlExe) { + throw 'vcpkg curl.exe was not found after install' + } + + $curlVersion = & $curlExe.FullName --version + $curlVersion + $curlVersionText = $curlVersion -join "`n" + + if ($curlVersionText -notmatch 'Features:.*\bIDN\b') { + throw 'Windows curl build did not retain IDN support' + } + + if ($curlVersionText -match 'libidn2') { + throw 'Windows curl build still links libidn2' + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b06c0b4f..edcf65ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,41 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed +## [5.1.2] - 2026-06-08 + +### Fixed +- Fixed Windows source builds against Node.js 26 failing with `LINK : fatal error LNK1117: syntax error in option 'opt:lldltojobs=2'`. Node 26 was built with clang-cl + lld + ThinLTO (PR [nodejs/node#63114](https://github.com/nodejs/node/pull/63114), released in v26.3.0), and node-gyp's `create-config-gypi.js` seeds the addon's `config.gypi` from `process.config` of the running Node binary — picking up `enable_thin_lto: true` and `lto_jobs: ` from how Node itself was built. Node's installed `common.gypi` then unconditionally appends `-flto=thin` to MSVC `cl.exe`'s `AdditionalOptions` and `/opt:lldltojobs=` to `link.exe`'s. MSVC ignores `-flto=thin` (warning) but rejects `/opt:lldltojobs=` because `/OPT:` only accepts `REF/ICF/NOREF/NOICF/LBR/NOLBR`. Force them off via `npm_config_enable_thin_lto=false` + `npm_config_enable_lto=false` in the Windows build script, which node-gyp forwards as `-Denable_thin_lto=false` gyp defines (top precedence — `binding.gyp` `variables` don't override `config.gypi`'s, they're a separate gyp scope). + +## [5.1.1] - 2026-06-05 + +### Fixed +- Fixed Windows source builds linking against `libidn2` (and its `libunistring` transitive dep) instead of using the Windows-native WinIDN backed by `Normaliz.lib`. The vcpkg manifest was requesting both the `idn` and `idn2` curl features, which forced vcpkg's curl port to pull libidn2 in; only `idn` is needed and lets the port pick WinIDN on Windows. Smaller binary, fewer transitive deps. +- Fixed Windows source builds failing in deeply-nested consumer paths (typical of pnpm v10 monorepos) with `CreateProcessW failed with 206 (The filename or extension is too long)`. vcpkg's bundled `pwsh.exe` sits at `vcpkg/downloads/tools/powershell-core--windows/pwsh.exe` — easy to push past Windows' MAX_PATH from inside a deep `node_modules/.pnpm/` tree. vcpkg's clone now goes to `%LOCALAPPDATA%\node-libcurl-vcpkg\\` regardless of where the package lives, so the toolchain's internal paths stay short. +- Fixed Windows source builds failing with `pkg-config: 'libcrypto'/'zlib' not found` while building libssh2 in nested consumer paths. `vcpkg_installed` was also being written inside the deep module root, and msys2 pkg-config's `PKG_CONFIG_PATH` parsing tripped over both the path length and the drive-letter colon. `vcpkg_installed` now goes to `%LOCALAPPDATA%\node-libcurl-vcpkg\-installed` via `--x-install-root`, and `vcpkg-get-info.js` resolves against that path so `binding.gyp` still finds the libs. +- Fixed `vcpkg.exe`'s git clone failing with `Filename too long` while writing pack `.keep` files on deep paths. The clone now passes `git -c core.longpaths=true`. +- Propagate `VCPKG_DISABLE_METRICS=1` to all vcpkg subprocess invocations from `scripts/vcpkg-setup.js` (it was being set in the env action but lost when the install script spawned `vcpkg.exe`). + +### Changed +- A consumer install of node-libcurl under pnpm v10 now requires opting node-libcurl's lifecycle scripts in via `pnpm.onlyBuiltDependencies: ["node-libcurl"]` in the consumer's `package.json` (or `pnpm add --allow-build=node-libcurl`). pnpm v10's default scripts-off policy otherwise leaves the package installed but non-functional — no vcpkg setup, no native addon build. This isn't a node-libcurl change per se, but is now exercised by the new `windows-consumer-install` CI workflow so the regression-protected path is documented. + +## [5.1.0] - 2026-05-31 + +### Fixed +- Fixed `Curl.perform()` intermittently throwing `CurlMultiError: Could not remove easy handle from multi handle.: API function called from within callback` (CURLM_RECURSIVE_API_CALL) under concurrent load, particularly visible on Alpine. The regression came from v5.0.0 enabling libcurl 8.17's new `CURLMOPT_NOTIFYFUNCTION` API, which fires from inside `curl_multi_socket_action`. The notification resolved the perform-promise synchronously, and the resulting `.then()` microtask called `curl_multi_remove_handle` while libcurl was still on its own call stack. The removal is now deferred via `setImmediate` so libcurl can unwind first. (fixes [#439](https://github.com/JCMais/node-libcurl/issues/439)) +- Fixed macOS x64 prebuilt binary tarballs containing an arm64 binary instead of x86_64. The universal build packaging in `scripts/ci/build.sh` was extracting both architectures to the same output file before either was packaged, so the second `lipo` extraction overwrote the first. This affected all macOS releases since v5.0.0. ([#446](https://github.com/JCMais/node-libcurl/pull/446), fixes [#445](https://github.com/JCMais/node-libcurl/issues/445)) +- Fixed `CurlMimePart#setDataStream` hanging on Linux when libcurl needed a second read callback after the stream emitted its initial data. The mime read callback wasn't tracking `CURLPAUSE_SEND` state after returning `CURL_READFUNC_PAUSE`, so `isPausedSend` stayed `false` and the test/example unpause callbacks were silent no-ops. The unpause is now also deferred via `setImmediate` to avoid re-entering libcurl while it's still processing the pause. ([#448](https://github.com/JCMais/node-libcurl/pull/448)) +- Fixed vcpkg build failures when the exact OpenSSL version bundled with Node.js isn't present in the vcpkg registry. The build now resolves to the closest compatible version (preferring a newer patch on the same minor line, falling back to the closest lower patch, then the next minor) with a clear warning about the substitution. ([#447](https://github.com/JCMais/node-libcurl/pull/447)) +- Fixed Alpine CI builds failing with `fatal error: ngtcp2/ngtcp2_crypto_quictls.h: No such file or directory` after the ngtcp2 1.17.0 + OpenSSL 3.5+ combination switched to the new `libngtcp2_crypto_ossl` backend. Added the statically-built OpenSSL's pkgconfig dir to `PKG_CONFIG_PATH` so libcurl's probe for `libngtcp2_crypto_ossl` can resolve its `Requires: libcrypto` on systems without system OpenSSL. +- Fixed Windows CI builds failing with `Could not find any Visual Studio installation to use` after GitHub started serving Visual Studio 2026 (v145) on the `windows-2025` runner ahead of the official 2026-06-15 migration. The build script no longer pins `msvs_version=2022`, letting node-gyp 12.1.0+ auto-detect whichever supported MSVC toolset is installed. + +### Added +- Node.js 26 to the CI matrix. Prebuilt binaries are now published for Node 26 alongside the existing 22, 24, and 25 versions. + +### Changed +- Bumped node-gyp from 11.4.2 to 12.3.0 and `@mapbox/node-pre-gyp` from 2.0.0 to 2.0.3. node-gyp v12.1.0+ adds Visual Studio 2026 detection support. The only node-gyp v12 breaking change (engine range bumped to `^20.17.0 || >=22.9.0`) does not affect this project, which already requires Node.js ≥ 22.20.0. +- Bumped Alpine container image from `alpine3.21` to `alpine3.22` so the Node.js 26 image variant is available (`node:26-alpine3.21` is not published). Musl is `1.2.5` across Alpine 3.20–3.23, so the prebuilt binary remains runtime-compatible on Alpine 3.21. +- The `unpause` callback documentation and examples for `CurlMimePart#setDataStream` and `Easy#setMimePost` now correctly reference `CurlPause.Send` instead of `CurlPause.Recv`. Mime upload data is supplied via the read callback, so pausing affects `CURLPAUSE_SEND`. ([#448](https://github.com/JCMais/node-libcurl/pull/448)) + ## [5.0.2] - 2026-01-15 ### Fixed @@ -512,7 +547,10 @@ Special Thanks to [@koskokos2](https://github.com/koskokos2) for their contribut - Improved code style, started using prettier ## [1.2.0] - 2017-08-28 -[Unreleased]: https://github.com/JCMais/node-libcurl/compare/v5.0.2...HEAD +[Unreleased]: https://github.com/JCMais/node-libcurl/compare/v5.1.2...HEAD +[5.1.2]: https://github.com/JCMais/node-libcurl/compare/v5.1.1...v5.1.2 +[5.1.1]: https://github.com/JCMais/node-libcurl/compare/v5.1.0...v5.1.1 +[5.1.0]: https://github.com/JCMais/node-libcurl/compare/v5.0.2...v5.1.0 [5.0.2]: https://github.com/JCMais/node-libcurl/compare/v5.0.1...v5.0.2 [5.0.1]: https://github.com/JCMais/node-libcurl/compare/v5.0.0...v5.0.1 [5.0.0]: https://github.com/JCMais/node-libcurl/compare/v4.1.0...v5.0.0 diff --git a/lib/Curl.ts b/lib/Curl.ts index def619ba6..2ab1a97ad 100644 --- a/lib/Curl.ts +++ b/lib/Curl.ts @@ -749,15 +749,37 @@ class Curl extends EventEmitter { // Use custom Multi instance if set, otherwise use the default global one const multi = this.multiInstance || multiHandle + // On libcurl 8.17+ the multi handle uses the new CURLMOPT_NOTIFYFUNCTION API, + // and our NotifyCallback resolves the perform() promise while still inside + // curl_multi_socket_action. Microtask draining can then run the .then() + // synchronously and try to call curl_multi_remove_handle from there, which + // libcurl rejects with CURLM_RECURSIVE_API_CALL ("API function called from + // within callback"). Defer removeHandle to the next tick so libcurl has + // unwound its own call stack first. + // + // See: https://github.com/JCMais/node-libcurl/issues/439 + const finalize = (cb: () => void) => { + setImmediate(() => { + try { + if (this.handle.isOpen && this.handle.isInsideMultiHandle) { + multi.removeHandle(this.handle) + } + } catch { + // If the user closed the handle inside their callback (the curly + // pattern does this), or the handle was already removed for any + // other reason, swallow — we still want to settle the request. + } + cb() + }) + } + multi .perform(this.handle) .then(() => { - multi.removeHandle(this.handle) - this.onEnd() + finalize(() => this.onEnd()) }) .catch((error) => { - multi.removeHandle(this.handle) - this.onError(error, error.code) + finalize(() => this.onError(error, error.code)) }) return this diff --git a/lib/CurlMimePart.ts b/lib/CurlMimePart.ts index 1750c2b3d..cc3ff3e5f 100644 --- a/lib/CurlMimePart.ts +++ b/lib/CurlMimePart.ts @@ -24,7 +24,7 @@ export interface MimeDataCallbacks { * * @remarks * When `CurlReadFunc.Pause` is returned, the transfer will be paused until it is - * explicitly resumed by calling `handle.pause(handle.pauseFlags & ~CurlPause.Recv)`. + * explicitly resumed by calling `handle.pause(handle.pauseFlags & ~CurlPause.Send)`. * When `CurlReadFunc.Abort` is returned, the transfer will be aborted. * * @example @@ -360,8 +360,9 @@ declare class CurlMimePart { * `CurlReadFunc.Pause`, and the `unpause` callback is invoked when data becomes * available to resume the transfer. * - * The `unpause` function should unpause the curl handle's receive operation, typically - * by calling `handle.pause(handle.pauseFlags & ~CurlPause.Recv)`. + * The `unpause` function should unpause the curl handle's send operation (mime upload + * data is sent via the read callback), typically by calling + * `handle.pause(handle.pauseFlags & ~CurlPause.Send)`. * * For very large files, consider using {@link setFileData} instead, as it streams * directly from disk without going through Node.js streams. @@ -380,7 +381,7 @@ declare class CurlMimePart { * .addPart() * .setName('document') * .setDataStream(stream, () => { - * curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + * curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) * }) * .setType('text/plain') * ``` @@ -402,7 +403,7 @@ declare class CurlMimePart { * .setName('document') * .setDataStream( * stream, - * () => curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv), + * () => curl.pause(curl.handle.pauseFlags & ~CurlPause.Send), * size * ) * ``` @@ -424,76 +425,45 @@ CurlMimePart.prototype.setDataStream = function ( ): typeof CurlMimePart.prototype { let streamEnded = false let streamError: Error | null = null - // Set to true when the read callback returns Pause; cleared only when - // read() successfully returns data (confirming libcurl resumed the handle). - // Prevents calling unpause() on a handle that isn't paused. let paused = false - let pendingUnpause: ReturnType | null = null - const tryUnpause = () => { - pendingUnpause = null + // Defer unpause to the next event loop iteration to avoid calling + // curl_easy_pause() while libcurl is still processing the READFUNC_PAUSE + // return value from the read callback. Without this, the synchronous + // unpause can re-enter libcurl and cause a hang (observed on Linux). + // This matches the pattern used by setUploadStream in Curl.ts. + const deferredUnpause = () => { if (paused) { - // Call the user-provided unpause callback. If isPausedRecv is still - // false (libcurl hasn't finished processing the Pause return value yet), - // the callback will be a no-op and we must retry. We do NOT clear - // `paused` here — it is cleared only when read() successfully returns - // data, which confirms libcurl has actually resumed. Re-schedule so - // we keep trying until libcurl resumes the handle. - unpause() - scheduleUnpause() - } - } - - const scheduleUnpause = () => { - if (!pendingUnpause) { - pendingUnpause = setImmediate(tryUnpause) + paused = false + setImmediate(() => { + unpause() + }) } } const onReadable = () => { - // Defer unpause so libcurl has finished processing the read callback - // result and marked the handle as paused before we resume it. - scheduleUnpause() + deferredUnpause() } const onEnd = () => { streamEnded = true - // Remove listeners first so we don't re-enter these handlers. - removeListeners() - // Schedule a deferred unpause so libcurl has time to set isPausedRecv - // after processing the read callback's Pause return value before we - // try to resume it. Using scheduleUnpause (not tryUnpause) preserves - // the setImmediate deferral that is essential for correct ordering. - scheduleUnpause() + deferredUnpause() + cleanup() } const onError = (err: Error) => { streamError = err streamEnded = true - removeListeners() - // Same deferral rationale as onEnd. - scheduleUnpause() + deferredUnpause() + cleanup() } - // Removes stream event listeners only — does NOT cancel pending unpause - // so that an in-flight scheduleUnpause() from read() or onEnd/onError - // can still fire after the stream is done. - const removeListeners = () => { + const cleanup = () => { stream.off('readable', onReadable) stream.off('end', onEnd) stream.off('error', onError) } - // Full cleanup: remove listeners AND cancel any pending unpause. - // Called only from free() when the curl handle is being torn down. - const cleanup = () => { - removeListeners() - if (pendingUnpause) { - clearImmediate(pendingUnpause) - pendingUnpause = null - } - } - stream.pause() stream.on('readable', onReadable) @@ -510,7 +480,6 @@ CurlMimePart.prototype.setDataStream = function ( } if (streamEnded) { - paused = false return null } @@ -518,23 +487,12 @@ CurlMimePart.prototype.setDataStream = function ( if (data === null) { if (streamEnded) { - paused = false return null } paused = true - // Safety net: if the stream already emitted 'readable' before - // paused=true was set, that event's scheduleUnpause() was a no-op. - // Schedule one deferred retry to cover that race. The deduplication - // guard (pendingUnpause) ensures at most one pending retry at a time, - // so this does not create a tight busy-loop — the retry is a single - // deferred tick, not a continuous spin. - scheduleUnpause() return CurlReadFunc.Pause } - // Data is available: libcurl has resumed the handle, so clear paused - // to stop the retry loop in tryUnpause. - paused = false return data instanceof Buffer ? data : Buffer.from(data) }, free: () => { diff --git a/lib/Easy.ts b/lib/Easy.ts index d8dcac39b..9dbabd30c 100644 --- a/lib/Easy.ts +++ b/lib/Easy.ts @@ -745,7 +745,8 @@ const Easy = bindings.Easy as Easy * @remarks * For stream-based parts, you must provide the unpause callback that will be * called when more data is available. The callback should unpause the transfer - * using `handle.pause(handle.pauseFlags & ~CurlPause.Recv)`. + * using `handle.pause(handle.pauseFlags & ~CurlPause.Send)` (mime upload data + * is sent via the read callback, so it pauses SEND, not RECV). * * Available since libcurl 7.56.0. * @@ -774,7 +775,7 @@ const Easy = bindings.Easy as Easy * name: 'logfile', * stream: createReadStream('/path/to/log.txt'), * unpause: () => { - * easy.pause(easy.pauseFlags & ~CurlPause.Recv) + * easy.pause(easy.pauseFlags & ~CurlPause.Send) * }, * size: 12345 * }, @@ -832,7 +833,7 @@ Easy.prototype.setMimePost = function ( part.setDataStream( partSpec.stream, () => { - this.pause(this.pauseFlags & ~CurlPause.Recv) + this.pause(this.pauseFlags & ~CurlPause.Send) }, partSpec.size, ) diff --git a/package.json b/package.json index db4614aec..e70495c67 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "prettier:all": "pnpm prettier lib/**/*.ts tools/**/*.js scripts/**/*.js test/**/*.ts examples/**/*.js", "test": "vitest run", "test:coverage": "pnpm test -- --coverage.enabled=true", - "test:watch": "vitest", + "test:stress": "vitest run --config vitest.stress.config.ts", + "test:watch": "vitest --testTimeout=60000", "preversion": "pnpm lint && pnpm clean:dist && pnpm build:dist" }, "lint-staged": { @@ -78,7 +79,7 @@ ] }, "dependencies": { - "@mapbox/node-pre-gyp": "2.0.0", + "@mapbox/node-pre-gyp": "2.0.3", "env-paths": "2.2.0", "node-addon-api": "8.5.0", "node-gyp": "13.0.0", @@ -134,7 +135,6 @@ }, "publishConfig": { "access": "public", - "registry": "https://registry.npmjs.org", "executableFiles": [ "scripts/arm/publish-binary.sh", "scripts/ci/build-brotli.sh", @@ -159,7 +159,8 @@ "scripts/ci/get-latest-libcurl-version.sh", "scripts/ci/utils/gsort.sh", "scripts/gyp-macos-postbuild.sh" - ] + ], + "registry": "https://registry.npmjs.org" }, "np": { "cleanup": false diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad1c03a59..b4bd3be61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@mapbox/node-pre-gyp': - specifier: 2.0.0 - version: 2.0.0(encoding@0.1.13) + specifier: 2.0.3 + version: 2.0.3(encoding@0.1.13) env-paths: specifier: 2.2.0 version: 2.2.0 @@ -797,8 +797,8 @@ packages: resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} - '@mapbox/node-pre-gyp@2.0.0': - resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} hasBin: true @@ -945,56 +945,67 @@ packages: resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.49.0': resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.49.0': resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.49.0': resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.49.0': resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.49.0': resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.49.0': resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.49.0': resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.49.0': resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.49.0': resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.49.0': resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.49.0': resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==} @@ -3283,6 +3294,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -5095,7 +5111,7 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@mapbox/node-pre-gyp@2.0.0(encoding@0.1.13)': + '@mapbox/node-pre-gyp@2.0.3(encoding@0.1.13)': dependencies: consola: 3.4.2 detect-libc: 2.0.4 @@ -7811,6 +7827,10 @@ snapshots: dependencies: abbrev: 3.0.1 + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 diff --git a/scripts/ci/build-libcurl.sh b/scripts/ci/build-libcurl.sh index 7d6b06aad..ec92a37c3 100755 --- a/scripts/ci/build-libcurl.sh +++ b/scripts/ci/build-libcurl.sh @@ -257,6 +257,16 @@ if [ "$is_less_than_8_0_0" == "0" ] && [ ! -z "$NGHTTP3_BUILD_FOLDER" ] && [ ! - CPPFLAGS="$CPPFLAGS -I$NGTCP2_BUILD_FOLDER/include" LDFLAGS="$LDFLAGS -L$NGTCP2_BUILD_FOLDER/lib -Wl,-rpath,$NGTCP2_BUILD_FOLDER/lib" PKG_CONFIG_PATH="$NGTCP2_BUILD_FOLDER/lib/pkgconfig:$PKG_CONFIG_PATH" + # ngtcp2 1.17.0+ with OpenSSL 3.5+ uses the libngtcp2_crypto_ossl backend + # (the legacy quictls backend is gone). Its .pc file Requires libcrypto, + # so pkg-config needs to be able to find our statically-built OpenSSL. + # On Ubuntu/macOS the system OpenSSL .pc is in pkg-config's default path + # so this happens implicitly, but on Alpine containers there's no system + # OpenSSL and the resolve fails — libcurl then falls back to assuming + # the quictls backend and fails to compile (#includes ngtcp2_crypto_quictls.h). + if [ ! -z "$OPENSSL_BUILD_FOLDER" ]; then + PKG_CONFIG_PATH="$OPENSSL_BUILD_FOLDER/lib/pkgconfig:$PKG_CONFIG_PATH" + fi # no path, we set pkg config path instead # see https://github.com/curl/curl/issues/18188 libcurl_args+=("--with-ngtcp2") diff --git a/scripts/ci/download-and-unpack.sh b/scripts/ci/download-and-unpack.sh index e8f4663e3..6eb5a98b5 100755 --- a/scripts/ci/download-and-unpack.sh +++ b/scripts/ci/download-and-unpack.sh @@ -5,10 +5,44 @@ set -euo pipefail # download tar gz file from source_url and unpack it to destination # download_and_upack download_and_unpack() { - mkdir -p $2 - # User agent for Edge on macOS - wget -U "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36 Edg/94.0.992.38" \ - -qO- $1 | tar xzf - -C $2 + mkdir -p "$2" + # Download to a temp file first instead of streaming straight into tar. + # The previous `wget -qO- | tar xzf -` pipe couldn't recover from a + # truncated response (e.g. curl.se / GitHub-release occasional hiccups) — + # tar consumes the partial bytes immediately and fails with + # "gzip: stdin: unexpected end of file" before any retry can fire. + # With a temp file, we can retry until the file is whole, and we only + # touch tar once. + # + # The retry loop is done in bash rather than via wget's own retry flags + # because Alpine ships BusyBox wget, which only supports -c/-q/-O/-U/-T — + # no --tries, --waitretry, --retry-connrefused. Hand-rolling the loop + # keeps us portable across GNU wget (Ubuntu/macOS) and BusyBox wget + # (Alpine container) without conditional code. + local tmpfile + tmpfile=$(mktemp) + trap "rm -f \"$tmpfile\"" RETURN + + local attempts=5 + local i=1 + local sleep_sec=3 + # User agent for Edge on macOS + local user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36 Edg/94.0.992.38" + + while [ "$i" -le "$attempts" ]; do + # -T sets the read timeout in seconds and is available in both GNU + # and BusyBox wget. + if wget -q -T 60 -U "$user_agent" -O "$tmpfile" "$1"; then + tar xzf "$tmpfile" -C "$2" + return 0 + fi + echo "download attempt $i/$attempts for $1 failed; retrying in ${sleep_sec}s..." >&2 + i=$((i + 1)) + sleep "$sleep_sec" + done + + echo "download failed after $attempts attempts: $1" >&2 + return 1 } if [ "${1}" != "--source-only" ]; then diff --git a/scripts/ci/windows/build.ps1 b/scripts/ci/windows/build.ps1 index 708dbca73..3eb214aeb 100644 --- a/scripts/ci/windows/build.ps1 +++ b/scripts/ci/windows/build.ps1 @@ -152,21 +152,40 @@ Write-Host "Set node-gyp path to ${globalNodeGypPath}" $target = $target -replace '^v', '' # Set npm config variables -# NOTE: This script requires a windows-2025+ runner (ships with VS 2026 / msvc v18). -# The CI matrix uses only windows-2025; no windows-2022 runners are present. -# Do not pin msvs_version: let node-gyp auto-detect the installed Visual Studio. -# Pinning to "2022" would cause a version mismatch on VS 2026 with node-gyp >= 13. +# msvs_version is intentionally not pinned. The windows-2025 runner is +# transitioning from VS 2022 to VS 2026 (full rollout by 2026-06-15) and +# the installed Visual Studio version varies across image revisions. Let +# node-gyp auto-detect whichever supported MSVC toolset is present. $env:npm_config_build_from_source = "true" $env:npm_config_runtime = $runtime $env:npm_config_dist_url = $dist_url $env:npm_config_target = $target $env:npm_config_node_gyp = $globalNodeGypPath +# Disable LTO/ThinLTO for the addon build. Node 26 ships with +# enable_thin_lto=true baked into process.config because the Node binary +# itself was built with clang-cl + lld + ThinLTO (PR nodejs/node#63114, +# released in v26.3.0). node-gyp's create-config-gypi.js seeds config.gypi +# from process.config when neither --nodedir nor --dist-url is set, so +# common.gypi's `enable_thin_lto=="true"` conditions fire and emit +# `-flto=thin` to cl.exe and `/opt:lldltojobs=` to link.exe. Those are +# clang-cl/lld-link flags; MSVC's cl.exe warns and ignores `-flto=thin`, +# but link.exe rejects `/opt:lldltojobs=` with `LNK1117` because +# /OPT: only accepts REF/ICF/NOREF/NOICF/LBR/NOLBR. +# +# Setting npm_config_enable_thin_lto/enable_lto here makes node-gyp +# forward them as `-Denable_thin_lto=false -Denable_lto=false` gyp +# defines, which take precedence over config.gypi (binding.gyp variables +# don't — they're a separate scope and lose to config.gypi). +$env:npm_config_enable_thin_lto = "false" +$env:npm_config_enable_lto = "false" + Write-Host "Build configuration:" -ForegroundColor Green Write-Host " npm_config_build_from_source: $env:npm_config_build_from_source" -ForegroundColor Cyan Write-Host " npm_config_runtime: $env:npm_config_runtime" -ForegroundColor Cyan Write-Host " npm_config_dist_url: $env:npm_config_dist_url" -ForegroundColor Cyan Write-Host " npm_config_target: $env:npm_config_target" -ForegroundColor Cyan +Write-Host " npm_config_enable_thin_lto: $env:npm_config_enable_thin_lto" -ForegroundColor Cyan # Install dependencies and build Write-Host "Installing dependencies..." -ForegroundColor Blue diff --git a/scripts/vcpkg-common.js b/scripts/vcpkg-common.js index eabdd8e29..fe96e843a 100644 --- a/scripts/vcpkg-common.js +++ b/scripts/vcpkg-common.js @@ -1,4 +1,6 @@ const path = require('path') +const os = require('os') +const crypto = require('crypto') // Exit if not Windows if (process.platform !== 'win32') { @@ -6,7 +8,42 @@ if (process.platform !== 'win32') { } const moduleRoot = path.resolve(__dirname, '..') -const vcpkgRoot = process.env.VCPKG_ROOT || path.join(moduleRoot, 'vcpkg') + +// Choose a vcpkg clone location that stays well under Windows MAX_PATH. +// vcpkg.exe itself doesn't carry the long-path app manifest, so its +// CreateProcessW calls (e.g. for the downloaded pwsh.exe at +// vcpkg/downloads/tools/powershell-core--windows/pwsh.exe) silently +// fail with error 206 once the absolute path passes 260 chars. When this +// package is installed as a dependency in a deep pnpm path it's trivial +// to start well past that just from node_modules/.pnpm/... — so we keep +// vcpkg outside the module root. +// +// Honour an explicit VCPKG_ROOT first (Windows CI sets one), fall back to +// a stable short path under the user cache that's keyed by the module +// root so multiple installs don't clobber each other. +const moduleRootHash = crypto + .createHash('sha1') + .update(moduleRoot) + .digest('hex') + .slice(0, 8) +const defaultVcpkgRoot = path.join( + process.env.LOCALAPPDATA || os.tmpdir(), + 'node-libcurl-vcpkg', + moduleRootHash, +) +const vcpkgRoot = process.env.VCPKG_ROOT || defaultVcpkgRoot + +// vcpkg_installed has the same MAX_PATH problem as the clone — pkg-config +// from msys2 (used during dependency builds like libssh2 -> zlib/libcrypto) +// silently fails to find .pc files once the absolute path passes ~260 chars. +// Keep it out of the module root for the same reasons. +const defaultVcpkgInstalledRoot = path.join( + process.env.LOCALAPPDATA || os.tmpdir(), + 'node-libcurl-vcpkg', + `${moduleRootHash}-installed`, +) +const vcpkgInstalledRoot = + process.env.NODE_LIBCURL_VCPKG_INSTALLED_ROOT || defaultVcpkgInstalledRoot // Triplet mapping const arch = process.arch @@ -26,6 +63,7 @@ if (!triplet) { module.exports = { triplet, vcpkgRoot, + vcpkgInstalledRoot, moduleRoot, arch, } diff --git a/scripts/vcpkg-get-info.js b/scripts/vcpkg-get-info.js index 3abf675f9..dbc6df4f7 100644 --- a/scripts/vcpkg-get-info.js +++ b/scripts/vcpkg-get-info.js @@ -1,7 +1,7 @@ const fs = require('fs') const path = require('path') -const { triplet, moduleRoot } = require('./vcpkg-common') +const { triplet, vcpkgInstalledRoot } = require('./vcpkg-common') // Exit if not Windows if (process.platform !== 'win32') { @@ -10,7 +10,7 @@ if (process.platform !== 'win32') { const args = process.argv.slice(2) -const installedRoot = path.join(moduleRoot, 'vcpkg_installed', triplet) +const installedRoot = path.join(vcpkgInstalledRoot, triplet) // Collect all .lib files const libDir = path.join(installedRoot, 'lib') diff --git a/scripts/vcpkg-setup.js b/scripts/vcpkg-setup.js index 453c8565c..fc0cca98d 100644 --- a/scripts/vcpkg-setup.js +++ b/scripts/vcpkg-setup.js @@ -3,7 +3,12 @@ const { execSync: exec } = require('child_process') const fs = require('fs') const path = require('path') -const { triplet, moduleRoot, vcpkgRoot } = require('./vcpkg-common') +const { + triplet, + moduleRoot, + vcpkgRoot, + vcpkgInstalledRoot, +} = require('./vcpkg-common') const { getAvailableVersions, findBestVersion, @@ -11,6 +16,11 @@ const { const modulePackageJson = require('../package.json') +const commonEnv = { + ...process.env, + VCPKG_DISABLE_METRICS: '1', +} + async function setupVcpkg() { try { let vcpkgExe @@ -26,9 +36,16 @@ async function setupVcpkg() { } else { // Bootstrap local vcpkg if (!fs.existsSync(vcpkgRoot)) { - console.log('Cloning vcpkg locally...') + console.log(`Cloning vcpkg into ${vcpkgRoot}...`) + // `-c core.longpaths=true` lets git write files past Windows' + // 260-char MAX_PATH limit. vcpkg's pack/keep filenames already + // sit close to that limit on their own, and consumers installing + // node-libcurl via pnpm pile a deep `node_modules/.pnpm//...` + // prefix on top — easy to overflow without this flag. On + // Linux/macOS the flag is a harmless no-op. + fs.mkdirSync(path.dirname(vcpkgRoot), { recursive: true }) exec( - `git clone https://github.com/microsoft/vcpkg.git "${vcpkgRoot}"`, + `git -c core.longpaths=true clone https://github.com/microsoft/vcpkg.git "${vcpkgRoot}"`, { cwd: path.dirname(vcpkgRoot), maxBuffer: 10 * 1024 * 1024, @@ -46,22 +63,29 @@ async function setupVcpkg() { cwd: vcpkgRoot, maxBuffer: 10 * 1024 * 1024, stdio: 'inherit', + env: commonEnv, }) } } await createVcpkgJson() - // Install dependencies + // Install dependencies. --x-install-root sends `vcpkg_installed` to a + // path outside the module root so the per-port cmake builds (and the + // bundled msys2 pkg-config they call) don't trip over MAX_PATH when + // node-libcurl is being installed via a deep pnpm consumer path. + fs.mkdirSync(vcpkgInstalledRoot, { recursive: true }) console.log(`Installing curl with ${triplet}...`) - const installCmd = `"${vcpkgExe}" install --triplet ${triplet}` + console.log(` vcpkg_installed: ${vcpkgInstalledRoot}`) + const installCmd = `"${vcpkgExe}" install --triplet ${triplet} --x-install-root="${vcpkgInstalledRoot}"` exec(installCmd, { cwd: moduleRoot, maxBuffer: 20 * 1024 * 1024, stdio: 'inherit', + env: commonEnv, }) - const installedRoot = path.join(moduleRoot, 'vcpkg_installed', triplet) + const installedRoot = path.join(vcpkgInstalledRoot, triplet) console.log(`✓ vcpkg setup complete`) console.log(` Installed to: ${installedRoot}`) diff --git a/src/CurlMime.cc b/src/CurlMime.cc index 7ed408f05..be2b4e12c 100644 --- a/src/CurlMime.cc +++ b/src/CurlMime.cc @@ -596,9 +596,10 @@ size_t CurlMimePart::StaticReadCallback(char* buffer, size_t size, size_t nitems if (result.IsNumber()) { int32_t returnValue = result.As().Int32Value(); + // Track pause state so isPausedSend reflects reality. + // The mime data callback pauses SEND (it supplies upload data), + // matching the behavior of Easy::ReadFunction. if (returnValue == CURL_READFUNC_PAUSE) { - // Track the paused-send state so isPausedSend reflects reality, - // mirroring what Easy::ReadFunction does for CURLOPT_READFUNCTION. part->easy->pauseState |= CURLPAUSE_SEND; } return static_cast(returnValue); diff --git a/test/stress/issue-439-recursive-api-call.spec.ts b/test/stress/issue-439-recursive-api-call.spec.ts new file mode 100644 index 000000000..07aca5448 --- /dev/null +++ b/test/stress/issue-439-recursive-api-call.spec.ts @@ -0,0 +1,131 @@ +/** + * Stress test for https://github.com/JCMais/node-libcurl/issues/439 + * + * v5.0.0 enabled libcurl 8.17's CURLMOPT_NOTIFYFUNCTION by default. The + * notify callback fires from inside curl_multi_socket_action. Our handler + * synchronously resolves the perform() promise from there, and microtask + * draining can then run the `.then()` handler — including the + * curl_multi_remove_handle call — while libcurl is still on its own call + * stack. libcurl rejects that with CURLM_RECURSIVE_API_CALL. + * + * The fix wraps removeHandle in setImmediate so libcurl unwinds first. + * + * This is a stress test (not part of the normal suite) because it has to + * push enough concurrent work through the multi handle to make the + * timing window observable. On Alpine + libcurl 8.17, the bug + * reproduces in ~73% of requests under load; on glibc-based systems it + * shows up more rarely but is still real. Either way, a clean run here + * means the deferral is working. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import http from 'http' +import type { AddressInfo } from 'net' + +import { Curl, CurlEasyError } from '../../lib' + +const TARGET_DURATION_MS = Number(process.env.STRESS_DURATION_MS ?? 10_000) +const REQUESTS_PER_TICK = Number(process.env.STRESS_BURST ?? 40) +const TICK_INTERVAL_MS = Number(process.env.STRESS_INTERVAL_MS ?? 20) + +describe('stress: recursive-api-call regression (#439)', () => { + let server: http.Server + let url: string + + beforeAll(async () => { + server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('ok') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const addr = server.address() as AddressInfo + url = `http://127.0.0.1:${addr.port}/` + }) + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())) + }) + + it( + `runs ${REQUESTS_PER_TICK} concurrent requests every ${TICK_INTERVAL_MS}ms for ${TARGET_DURATION_MS}ms without CURLM_RECURSIVE_API_CALL`, + async () => { + let completed = 0 + let recursive = 0 + const otherErrors: Error[] = [] + + function fire() { + const curl = new Curl() + curl.setOpt('URL', url) + curl.setOpt('TIMEOUT', 5) + + curl.on('end', () => { + completed++ + curl.close() + }) + curl.on('error', (err: Error) => { + if ( + err instanceof CurlEasyError && + // CURLM_RECURSIVE_API_CALL = 8 + (err as CurlEasyError & { code?: number }).code === 8 + ) { + recursive++ + } else if (err.message?.includes('within callback')) { + // Belt and braces — if the error type ever changes, still catch + // the underlying libcurl string. + recursive++ + } else { + otherErrors.push(err) + } + curl.close() + }) + + try { + curl.perform() + } catch (e) { + otherErrors.push(e as Error) + } + } + + const recursiveFromRejection: string[] = [] + const onRejection = (reason: unknown) => { + const message = + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : '' + if (message.includes('within callback')) { + recursive++ + recursiveFromRejection.push(message) + } + } + process.on('unhandledRejection', onRejection) + + try { + const interval = setInterval(() => { + for (let i = 0; i < REQUESTS_PER_TICK; i++) fire() + }, TICK_INTERVAL_MS) + + await new Promise((resolve) => + setTimeout(resolve, TARGET_DURATION_MS), + ) + clearInterval(interval) + + // Drain the in-flight requests + await new Promise((resolve) => setTimeout(resolve, 2_000)) + } finally { + process.off('unhandledRejection', onRejection) + } + + // Sanity: we actually pushed real load through the multi handle + expect(completed).toBeGreaterThan(0) + + expect( + recursive, + `Got ${recursive} CURLM_RECURSIVE_API_CALL errors out of ${ + completed + recursive + } finished requests. Sample rejection messages: ${recursiveFromRejection.slice(0, 3).join(' | ')}`, + ).toBe(0) + }, + TARGET_DURATION_MS + 30_000, + ) +}) diff --git a/vcpkg.template.json b/vcpkg.template.json index 7bdf18732..bd4d9fb97 100644 --- a/vcpkg.template.json +++ b/vcpkg.template.json @@ -13,7 +13,6 @@ "ldap", "gsasl", "idn", - "idn2", "openssl", "ssh", "sspi", diff --git a/vitest.stress.config.ts b/vitest.stress.config.ts new file mode 100644 index 000000000..253edf292 --- /dev/null +++ b/vitest.stress.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/stress/**/*.spec.ts'], + // No globalSetup — the stress test brings its own minimal HTTP server. + testTimeout: 120_000, + pool: 'forks', + poolOptions: { + forks: { + execArgv: ['--expose-gc'], + }, + }, + }, +})