From 8c9aceec163bcadf328eed26796a8a96532fbfe3 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Wed, 14 Oct 2026 14:39:58 +0000 Subject: [PATCH 1/2] zccache: Add version 1.13.21 --- .github/workflows/build-zccache.yml | 250 ++++++++++++++++++ docs/packages/zccache.yaml | 6 + ...ck-to-prost-build-s-own-protoc-looku.patch | 43 +++ ...h-handler-to-0.8-for-riscv64-support.patch | 137 ++++++++++ ...-ship-LICENSE-in-every-wheel-s-dist-.patch | 39 +++ 5 files changed, 475 insertions(+) create mode 100644 .github/workflows/build-zccache.yml create mode 100644 docs/packages/zccache.yaml create mode 100644 patches/zccache/1.13.21/0001-protocol-fall-back-to-prost-build-s-own-protoc-looku.patch create mode 100644 patches/zccache/1.13.21/0002-deps-bump-crash-handler-to-0.8-for-riscv64-support.patch create mode 100644 patches/zccache/1.13.21/0003-release_workflow-ship-LICENSE-in-every-wheel-s-dist-.patch diff --git a/.github/workflows/build-zccache.yml b/.github/workflows/build-zccache.yml new file mode 100644 index 000000000000..68af43510eee --- /dev/null +++ b/.github/workflows/build-zccache.yml @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# This workflow is based on the `build`, `build-wheels` and `test-wheels` jobs of +# https://github.com/zackees/zccache/blob/1.13.21/.github/workflows/release-auto.yml +# (with its .github/actions/build-target/action.yml) and the `native-pytest` job of +# https://github.com/zackees/zccache/blob/1.13.21/.github/workflows/python-tests.yml +name: Build zccache wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/zccache.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-zccache.yml' + - 'docs/packages/zccache.yaml' + - 'patches/zccache/**' + push: + branches: [main] + paths: + - '.github/workflows/build-zccache.yml' + - 'docs/packages/zccache.yaml' + - 'patches/zccache/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + with: + package: zccache + version: ${{ inputs.version }} + + build_wheel: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + name: Build zccache ${{ matrix.version }} py3-none-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 1440 + + env: + ZCCACHE_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout zccache ${{ env.ZCCACHE_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zackees/zccache + ref: ${{ env.ZCCACHE_VERSION }} + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Apply patches + run: git apply --verbose python-wheels/patches/zccache/${{ env.ZCCACHE_VERSION }}/*.patch + + - name: Write container script + run: | + cat > riscv64-build.sh <<'EOF' + #!/bin/bash + set -e -x + + export PATH="/opt/python/cp312-cp312/bin:${PATH}" + TARGET=riscv64gc-unknown-linux-gnu + + # Upstream cross-compiles every target from x86_64 through soldr/zig; this builds natively instead. + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none + . "${HOME}/.cargo/env" + rustup toolchain install + + dnf install -y protobuf-compiler + + export PYO3_PYTHON=python + cargo build --release --target "${TARGET}" -p zccache \ + --features zccache-bin,fingerprint-bin \ + --bin zccache --bin zccache-fp + cargo build --release --target "${TARGET}" -p zccache-cli --features python --lib + cargo build --release --target "${TARGET}" -p zccache-watcher-py --lib + cargo build --release --target "${TARGET}" -p zccache-fingerprint-py --lib + + release="target/${TARGET}/release" + staging=dist/linux-riscv64-gnu + mkdir -p "${staging}/python/zccache/fingerprint" "${staging}/python/zccache/watcher" + cp "${release}/zccache" "${release}/zccache-fp" "${staging}/" + cp "${release}/libzccache_cli.so" "${staging}/python/zccache/_native.so" + cp "${release}/libzccache_fingerprint.so" "${staging}/python/zccache/fingerprint/_native.so" + cp "${release}/libzccache_watcher.so" "${staging}/python/zccache/watcher/_native.so" + for f in "${staging}/zccache" "${staging}/zccache-fp" "${staging}"/python/zccache/{,fingerprint/,watcher/}_native.so; do + strip --strip-debug "${f}" + done + + now="$(date -u +%s)" + for bin in zccache zccache-fp; do + python ci/stamp_release.py \ + --binary "${staging}/${bin}" \ + --sha "${ZCCACHE_SHA}" \ + --version "${ZCCACHE_VERSION}" \ + --triple "${TARGET}" \ + --timestamp "${now}" + done + + for bin in zccache zccache-fp; do + "${staging}/${bin}" --version | grep -Eq "^${bin} [0-9]" + done + export ZCCACHE_CACHE_DIR="$(mktemp -d)/cache" + "${staging}/zccache" "$(rustup which rustc)" -vV | grep -q '^host: ' + "${staging}/zccache" start + "${staging}/zccache" status + "${staging}/zccache" stop + + python - <<'PY' + from ci.release_workflow import build_wheel, read_project_meta + + build_wheel(*read_project_meta(), "linux-riscv64-gnu", ["manylinux_2_39_riscv64"]) + PY + EOF + + - name: Build wheel + run: | + podman run -t \ + --log-driver=none \ + --network=host \ + -v "${PWD}":/zccache_src \ + --workdir /zccache_src \ + -e ZCCACHE_VERSION="${ZCCACHE_VERSION}" \ + -e ZCCACHE_SHA="$(git rev-parse HEAD)" \ + --pull=newer \ + "${MANYLINUX_RISCV64_IMAGE}" \ + bash /zccache_src/riscv64-build.sh + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zccache-${{ env.ZCCACHE_VERSION }}-py3-none-manylinux_riscv64 + path: dist/wheels/*.whl + if-no-files-found: error + + test_wheel: + name: Test zccache ${{ matrix.version }} ${{ matrix.python }} + needs: [setup, build_wheel] + if: needs.setup.outputs.versions != '[]' + runs-on: ubuntu-24.04-riscv + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + # The pyo3 extensions are abi3, which free-threaded interpreters cannot load. + python: ["cp312", "cp313", "cp314"] + + env: + ZCCACHE_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout zccache ${{ env.ZCCACHE_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zackees/zccache + ref: ${{ env.ZCCACHE_VERSION }} + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zccache-${{ env.ZCCACHE_VERSION }}-py3-none-manylinux_riscv64 + path: dist/wheels + + - name: Write container script + run: | + cat > riscv64-test.sh <<'EOF' + #!/bin/bash + set -e -x + + export PATH="/opt/python/${PYTHON_TAG}-${PYTHON_TAG}/bin:${PATH}" + + python -m pip install --only-binary=:all: /zccache_src/dist/wheels/*.whl pytest + + zccache --version + zccache-fp --version + python -c 'import zccache._native, zccache.fingerprint._native, zccache.watcher._native' + python - <<'PY' + import importlib.metadata as md + + shipped = {p.name for p in md.files("zccache") if ".dist-info/" in str(p)} + assert "LICENSE" in shipped, sorted(shipped) + PY + + python ci/test_exec_cached_wheel.py + + export ZCCACHE_CACHE_DIR="$(mktemp -d)/cache" + export ZCCACHE_DAEMON_NAMESPACE=native-python-tests + zccache start + for attempt in $(seq 1 30); do + zccache status && break + sleep 1 + done + zccache status + python -m pytest python/tests -q -rs --durations=10 + python -m pytest crates/zccache-watcher/python/tests -q -rs --durations=10 + python -m pytest crates/zccache-fingerprint/python/tests -q -rs --durations=10 + zccache stop + EOF + + - name: Test wheel + run: | + podman run -t \ + --log-driver=none \ + --network=host \ + -v "${PWD}":/zccache_src \ + --workdir /zccache_src \ + -e PYTHON_TAG="${{ matrix.python }}" \ + -e PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ \ + --pull=newer \ + "${MANYLINUX_RISCV64_IMAGE}" \ + bash /zccache_src/riscv64-test.sh + + publish: + name: Publish zccache ${{ matrix.version }} + needs: [setup, build_wheel, test_wheel] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + secrets: + app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }} + with: + artifact-pattern: zccache-${{ matrix.version }}-*-manylinux_riscv64 diff --git a/docs/packages/zccache.yaml b/docs/packages/zccache.yaml new file mode 100644 index 000000000000..7f21499a9fbf --- /dev/null +++ b/docs/packages/zccache.yaml @@ -0,0 +1,6 @@ +package-name: zccache +source-code: https://github.com/zackees/zccache +license: AGPL-3.0-only +versions: +- version: 1.13.21 + patched: true diff --git a/patches/zccache/1.13.21/0001-protocol-fall-back-to-prost-build-s-own-protoc-looku.patch b/patches/zccache/1.13.21/0001-protocol-fall-back-to-prost-build-s-own-protoc-looku.patch new file mode 100644 index 000000000000..257f3da3dddb --- /dev/null +++ b/patches/zccache/1.13.21/0001-protocol-fall-back-to-prost-build-s-own-protoc-looku.patch @@ -0,0 +1,43 @@ +From 813a874854534eb6bbb9be60184312215ccf66bd Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Wed, 14 Oct 2026 14:27:20 +0000 +Subject: [PATCH] protocol: fall back to prost-build's own protoc lookup on + riscv64 + +Upstream-Status: Inappropriate [protoc-bin-vendored ships no riscv64 binary; prost_build already defaults to its own PROTOC-env/PATH lookup, so not overriding it on unsupported platforms is the whole fix] + +protoc_bin_vendored::protoc_bin_path() returns Err on any host outside +its small set of prebuilt targets (x86, x86_64, aarch64, ppc64le, s390x, +macOS, Windows), and build.rs turned that into a panic before +prost_build ever got a chance to look for a protoc on PATH or via +$PROTOC. Only exporting PROTOC when the vendored lookup succeeds +restores that fallback, which prost-build documents in its "Sourcing +protoc" section. + +Signed-off-by: Ludovic Henry +--- + crates/zccache-protocol/build.rs | 8 +++++--- + 1 file changed, 5 insertions(+), 3 deletions(-) + +diff --git a/crates/zccache-protocol/build.rs b/crates/zccache-protocol/build.rs +index 7179f40..aba99f4 100644 +--- a/crates/zccache-protocol/build.rs ++++ b/crates/zccache-protocol/build.rs +@@ -7,9 +7,11 @@ fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=proto/zccache_v1.proto"); + +- let protoc = protoc_bin_vendored::protoc_bin_path() +- .expect("vendored protoc is available for zccache wire protobufs"); +- std::env::set_var("PROTOC", protoc); ++ // protoc-bin-vendored ships no riscv64 binary; falling through leaves ++ // prost_build's own PROTOC-env/PATH lookup in charge there. ++ if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() { ++ std::env::set_var("PROTOC", protoc); ++ } + + prost_build::Config::new() + .compile_protos(&["proto/zccache_v1.proto"], &["proto"]) +-- +2.43.0 + diff --git a/patches/zccache/1.13.21/0002-deps-bump-crash-handler-to-0.8-for-riscv64-support.patch b/patches/zccache/1.13.21/0002-deps-bump-crash-handler-to-0.8-for-riscv64-support.patch new file mode 100644 index 000000000000..cc94dd6b3978 --- /dev/null +++ b/patches/zccache/1.13.21/0002-deps-bump-crash-handler-to-0.8-for-riscv64-support.patch @@ -0,0 +1,137 @@ +From 574416402af8b0467efe616739889f6045fc8c8d Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Wed, 14 Oct 2026 14:30:47 +0000 +Subject: [PATCH] deps: bump crash-handler to 0.8 for riscv64 support + +Upstream-Status: To upstream [not yet submitted; python-wheels does not open issues/PRs on third-party repos] + +crash-handler 0.6 pins crash-context 0.6, whose Linux CrashContext only +defines ucontext_t/fpregset_t for x86, x86_64, arm and aarch64, so every +crate depending on it fails to compile for riscv64gc-unknown-linux-gnu +("cannot find type `ucontext_t`"). crash-context 0.7.0 added a riscv64 +getcontext, and crash-handler 0.8 is the first release built on it. The +CrashHandler::attach / make_crash_event / CrashContext API zccache uses +is unchanged; the lockfile update is limited to crash-handler, +crash-context and mach2. + +Signed-off-by: Ludovic Henry +--- + Cargo.lock | 15 ++++++--------- + crates/zccache-cli-core/Cargo.toml | 2 +- + crates/zccache-core/Cargo.toml | 2 +- + crates/zccache-daemon-core/Cargo.toml | 2 +- + crates/zccache-platform/Cargo.toml | 2 +- + crates/zccache/Cargo.toml | 2 +- + 6 files changed, 11 insertions(+), 14 deletions(-) + +diff --git a/Cargo.lock b/Cargo.lock +index 45b1e18..2d4ea44 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -563,9 +563,9 @@ dependencies = [ + + [[package]] + name = "crash-context" +-version = "0.6.3" ++version = "0.8.0" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" ++checksum = "3748e166981fa3ba9dc54ff950ad0ed5ca4aab4b73eae21d9f539659fe806e28" + dependencies = [ + "cfg-if", + "libc", +@@ -574,9 +574,9 @@ dependencies = [ + + [[package]] + name = "crash-handler" +-version = "0.6.3" ++version = "0.8.1" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3" ++checksum = "b9897480a6a7009a9ca940c1813daf2755d8a3e6857e5ac4fd0ae526ff5682d1" + dependencies = [ + "cfg-if", + "crash-context", +@@ -1707,12 +1707,9 @@ checksum = "e6effa5c575f68109664a511f378c0e170888e33a426694ee0910a673762bd47" + + [[package]] + name = "mach2" +-version = "0.4.3" ++version = "0.6.0" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +-dependencies = [ +- "libc", +-] ++checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + + [[package]] + name = "matchers" +diff --git a/crates/zccache-cli-core/Cargo.toml b/crates/zccache-cli-core/Cargo.toml +index d898fff..ae836d3 100644 +--- a/crates/zccache-cli-core/Cargo.toml ++++ b/crates/zccache-cli-core/Cargo.toml +@@ -68,7 +68,7 @@ thiserror = { workspace = true } + tracing = { workspace = true } + serde = { workspace = true } + serde_json = { workspace = true } +-crash-handler = "0.6" ++crash-handler = "0.8" + blake3 = { workspace = true } + memmap2 = { workspace = true } + bincode = { workspace = true } +diff --git a/crates/zccache-core/Cargo.toml b/crates/zccache-core/Cargo.toml +index ddcde23..37d1c39 100644 +--- a/crates/zccache-core/Cargo.toml ++++ b/crates/zccache-core/Cargo.toml +@@ -11,7 +11,7 @@ description = "Core shared types and utilities for zccache" + publish = false + + [dependencies] +-crash-handler = "0.6" ++crash-handler = "0.8" + serde = { workspace = true } + serde_json = { workspace = true } + thiserror = { workspace = true } +diff --git a/crates/zccache-daemon-core/Cargo.toml b/crates/zccache-daemon-core/Cargo.toml +index f4123b5..9732c52 100644 +--- a/crates/zccache-daemon-core/Cargo.toml ++++ b/crates/zccache-daemon-core/Cargo.toml +@@ -41,7 +41,7 @@ thiserror = { workspace = true } + tracing = { workspace = true } + serde = { workspace = true } + serde_json = { workspace = true } +-crash-handler = "0.6" ++crash-handler = "0.8" + blake3 = { workspace = true } + memmap2 = { workspace = true } + bincode = { workspace = true } +diff --git a/crates/zccache-platform/Cargo.toml b/crates/zccache-platform/Cargo.toml +index 2ebc62a..a370080 100644 +--- a/crates/zccache-platform/Cargo.toml ++++ b/crates/zccache-platform/Cargo.toml +@@ -12,7 +12,7 @@ publish = false + [dependencies] + # Native mechanics arrive per capability phase (fs, ipc, process, executable, + # host); the leaf must never depend on any zccache-* crate. +-crash-handler = "0.6" ++crash-handler = "0.8" + interprocess = "2.4.2" + tokio = { workspace = true } + +diff --git a/crates/zccache/Cargo.toml b/crates/zccache/Cargo.toml +index f6ffdd1..d41e553 100644 +--- a/crates/zccache/Cargo.toml ++++ b/crates/zccache/Cargo.toml +@@ -98,7 +98,7 @@ thiserror = { workspace = true } + tracing = { workspace = true } + serde = { workspace = true } + serde_json = { workspace = true } +-crash-handler = "0.6" ++crash-handler = "0.8" + + # hash + blake3 = { workspace = true } +-- +2.43.0 + diff --git a/patches/zccache/1.13.21/0003-release_workflow-ship-LICENSE-in-every-wheel-s-dist-.patch b/patches/zccache/1.13.21/0003-release_workflow-ship-LICENSE-in-every-wheel-s-dist-.patch new file mode 100644 index 000000000000..d2d3ca51b8af --- /dev/null +++ b/patches/zccache/1.13.21/0003-release_workflow-ship-LICENSE-in-every-wheel-s-dist-.patch @@ -0,0 +1,39 @@ +From 82939f0ed04aa9aa77ff386203439f9127d83d13 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Wed, 14 Oct 2026 14:27:48 +0000 +Subject: [PATCH] release_workflow: ship LICENSE in every wheel's dist-info + +Upstream-Status: To upstream [not yet submitted; python-wheels does not open issues/PRs on third-party repos] + +build_wheel() hand-assembles each wheel from staged binaries and writes +only METADATA, WHEEL and RECORD into dist-info, so the published wheels +carry no copy of the AGPL-3.0 licence text even though they redistribute +compiled binaries. Copy the repository LICENSE to +dist-info/licenses/LICENSE (the PEP 639 location) and record it in +RECORD. + +Signed-off-by: Ludovic Henry +--- + ci/release_workflow.py | 6 ++++++ + 1 file changed, 6 insertions(+) + +diff --git a/ci/release_workflow.py b/ci/release_workflow.py +index 6a23ce8..104756a 100644 +--- a/ci/release_workflow.py ++++ b/ci/release_workflow.py +@@ -368,6 +368,12 @@ def build_wheel( + whl.writestr(rel, data) + record_rows.append((rel, record_hash(data), len(data))) + ++ license_bytes = (ROOT / "LICENSE").read_bytes() ++ whl.writestr(f"{dist_info}/licenses/LICENSE", license_bytes) ++ record_rows.append( ++ (f"{dist_info}/licenses/LICENSE", record_hash(license_bytes), len(license_bytes)) ++ ) ++ + metadata_bytes = metadata.encode() + whl.writestr(f"{dist_info}/METADATA", metadata_bytes) + record_rows.append( +-- +2.43.0 + From fe036c4db2ab7324fa3cff2e4151e07fd90c8327 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Thu, 15 Oct 2026 22:48:24 +0000 Subject: [PATCH 2/2] zccache: backport upstream fix for the watcher KeyboardInterrupt flake The watcher test suite aborted with exit code 2 and "!!! KeyboardInterrupt !!!" after every reported test passed (cp313 and cp314 legs of run 38582612676). A notification_predicate that raises KeyboardInterrupt made FileWatcher call the real _thread.interrupt_main() twice, and the tests let it reach the main thread while it was still inside file_path.write_text(), outside their assertRaises block. Reproduces on x86_64 in 23 of 50 runs; upstream fixed it in 64b7e7c (released in 1.14.5). Backport that commit, and apply patches in the test job too, which checks the upstream tree out again (gotcha 501). --- .github/workflows/build-zccache.yml | 8 + ...otify-main-thread-once-per-interrupt.patch | 210 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 patches/zccache/1.13.21/0004-watcher-notify-main-thread-once-per-interrupt.patch diff --git a/.github/workflows/build-zccache.yml b/.github/workflows/build-zccache.yml index 68af43510eee..5dc80586ad59 100644 --- a/.github/workflows/build-zccache.yml +++ b/.github/workflows/build-zccache.yml @@ -178,6 +178,14 @@ jobs: ref: ${{ env.ZCCACHE_VERSION }} persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Apply patches + run: git apply --verbose python-wheels/patches/zccache/${{ env.ZCCACHE_VERSION }}/*.patch + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zccache-${{ env.ZCCACHE_VERSION }}-py3-none-manylinux_riscv64 diff --git a/patches/zccache/1.13.21/0004-watcher-notify-main-thread-once-per-interrupt.patch b/patches/zccache/1.13.21/0004-watcher-notify-main-thread-once-per-interrupt.patch new file mode 100644 index 000000000000..7208e8b0d887 --- /dev/null +++ b/patches/zccache/1.13.21/0004-watcher-notify-main-thread-once-per-interrupt.patch @@ -0,0 +1,210 @@ +From 64b7e7c88eb52d09abbea410071a8a8fd2f0acf9 Mon Sep 17 00:00:00 2001 +From: Zachary Vorhies +Date: Mon, 21 Sep 2026 18:46:08 -0700 +Subject: [PATCH] fix(watcher): notify main thread once per interrupt (#1616) + +Upstream-Status: Backport [https://github.com/zackees/zccache/commit/64b7e7c88eb52d09abbea410071a8a8fd2f0acf9] + +Let the dispatch loop own worker KeyboardInterrupt notification so +predicate failures cannot leak a second interrupt into pytest. + +FileWatcher._predicate_allows() called handle_keyboard_interrupt() (a +real _thread.interrupt_main()) and then re-raised into _dispatch_loop(), +whose own except clause called it again, so one predicate interrupt +could reach the main thread twice. The tests also let the real +interrupt_main() fire and expected to catch it in an assertRaises block +entered only after file_path.write_text("b\n"); NativeWatcher.poll_batch +blocks while holding the GIL, so the main thread could still be inside +write_text when the interrupt arrived, and the KeyboardInterrupt aborted +the whole pytest session (exit code 2, "!!! KeyboardInterrupt !!!" after +every reported test passed). The tests now keep interrupt_main mocked +until the watcher has stopped and assert it was called exactly once. + +Fixes https://github.com/zackees/zccache/issues/1615 +--- + .../tests/test_keyboard_interrupt_behavior.py | 105 ++++++++++++------ + .../python/zccache/watcher/__init__.py | 5 +- + python/zccache/watcher/__init__.py | 5 +- + 3 files changed, 80 insertions(+), 35 deletions(-) + +diff --git a/crates/zccache-watcher/python/tests/test_keyboard_interrupt_behavior.py b/crates/zccache-watcher/python/tests/test_keyboard_interrupt_behavior.py +index 014b1f6..b5b7e9b 100644 +--- a/crates/zccache-watcher/python/tests/test_keyboard_interrupt_behavior.py ++++ b/crates/zccache-watcher/python/tests/test_keyboard_interrupt_behavior.py +@@ -32,12 +32,12 @@ class KeyboardInterruptBehaviorTests(unittest.TestCase): + ): + thread = threading.Thread(target=worker, name="worker-test") + thread.start() +- thread.join(timeout=2.0) ++ thread.join() + + self.assertFalse(worker_error) + self.assertTrue(called) + +- def test_poll_predicate_keyboard_interrupt_propagates(self) -> None: ++ def test_poll_predicate_keyboard_interrupt_stops_dispatch(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + file_path = root / "watch.cpp" +@@ -52,15 +52,52 @@ class KeyboardInterruptBehaviorTests(unittest.TestCase): + KeyboardInterrupt() + ), + ) +- try: +- time.sleep(0.2) +- file_path.write_text("b\n") +- with self.assertRaises(KeyboardInterrupt): ++ called = [] ++ with mock.patch.object( ++ _thread, "interrupt_main", side_effect=lambda: called.append(True) ++ ): ++ try: ++ time.sleep(0.2) ++ file_path.write_text("b\n") ++ deadline = time.time() + 2.0 ++ while time.time() < deadline and not called: ++ time.sleep(0.05) ++ finally: ++ watcher.stop() ++ self.assertEqual(called, [True]) ++ ++ def test_poll_predicate_keyboard_interrupt_notifies_main_once(self) -> None: ++ with tempfile.TemporaryDirectory() as temp_dir: ++ root = Path(temp_dir) ++ file_path = root / "watch.cpp" ++ file_path.write_text("a\n") ++ called = [] ++ watcher = FileWatcher( ++ root, ++ include_globs=["**/*.cpp"], ++ debounce_seconds=0.05, ++ poll_interval=0.05, ++ notification_predicate=lambda *args, **kwargs: (_ for _ in ()).throw( ++ KeyboardInterrupt() ++ ), ++ ) ++ with mock.patch.object( ++ _thread, "interrupt_main", side_effect=lambda: called.append(True) ++ ): ++ try: ++ time.sleep(0.2) ++ file_path.write_text("b\n") + deadline = time.time() + 2.0 +- while time.time() < deadline: +- watcher.poll(0.1) +- finally: +- watcher.stop() ++ while time.time() < deadline and not called: ++ time.sleep(0.05) ++ # Give the dispatch exception handler time to reveal a ++ # duplicate notification before lifting the mock. ++ time.sleep(0.1) ++ finally: ++ # Keep the process-wide interrupt mocked until the ++ # dispatch thread has joined, even on a slow CI host. ++ watcher.stop() ++ self.assertEqual(called, [True]) + + def test_keyboard_interrupt_stop_logs_once_per_watcher(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: +@@ -77,22 +114,26 @@ class KeyboardInterruptBehaviorTests(unittest.TestCase): + KeyboardInterrupt() + ), + ) +- try: +- time.sleep(0.2) +- with mock.patch("builtins.print") as print_mock: +- file_path.write_text("b\n") +- with self.assertRaises(KeyboardInterrupt): ++ called = [] ++ with mock.patch.object( ++ _thread, "interrupt_main", side_effect=lambda: called.append(True) ++ ): ++ try: ++ time.sleep(0.2) ++ with mock.patch("builtins.print") as print_mock: ++ file_path.write_text("b\n") + deadline = time.time() + 2.0 +- while time.time() < deadline: +- watcher.poll(0.1) +- stop_logs = [ +- call +- for call in print_mock.call_args_list +- if call.args == ("KeyboardInterrupt: watcher stopped",) +- ] +- self.assertEqual(len(stop_logs), 1) +- finally: +- watcher.stop() ++ while time.time() < deadline and not called: ++ time.sleep(0.05) ++ stop_logs = [ ++ call ++ for call in print_mock.call_args_list ++ if call.args == ("KeyboardInterrupt: watcher stopped",) ++ ] ++ self.assertEqual(len(stop_logs), 1) ++ finally: ++ watcher.stop() ++ self.assertEqual(called, [True]) + + def test_callback_keyboard_interrupt_notifies_main_thread(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: +@@ -112,13 +153,15 @@ class KeyboardInterruptBehaviorTests(unittest.TestCase): + poll_interval=0.05, + callback=callback, + ) +- try: +- time.sleep(0.2) +- with mock.patch.object(_thread, "interrupt_main", side_effect=lambda: called.append(True)): ++ with mock.patch.object( ++ _thread, "interrupt_main", side_effect=lambda: called.append(True) ++ ): ++ try: ++ time.sleep(0.2) + file_path.write_text("b\n") + deadline = time.time() + 2.0 + while time.time() < deadline and not called: + time.sleep(0.05) +- self.assertTrue(called) +- finally: +- watcher.stop() ++ finally: ++ watcher.stop() ++ self.assertEqual(called, [True]) +diff --git a/crates/zccache-watcher/python/zccache/watcher/__init__.py b/crates/zccache-watcher/python/zccache/watcher/__init__.py +index fbf1d02..0127ac6 100644 +--- a/crates/zccache-watcher/python/zccache/watcher/__init__.py ++++ b/crates/zccache-watcher/python/zccache/watcher/__init__.py +@@ -197,9 +197,10 @@ class FileWatcher: + root=self.root, + ) + ) +- except KeyboardInterrupt as ke: ++ except KeyboardInterrupt: ++ # The dispatch loop owns main-thread notification. Re-raise so it ++ # can request exactly one interruption for this watcher. + self._log_keyboard_interrupt_stop_once() +- handle_keyboard_interrupt(ke) + raise + except Exception: + return True +diff --git a/python/zccache/watcher/__init__.py b/python/zccache/watcher/__init__.py +index 820fb1e..7e69da8 100644 +--- a/python/zccache/watcher/__init__.py ++++ b/python/zccache/watcher/__init__.py +@@ -197,9 +197,10 @@ class FileWatcher: + root=self.root, + ) + ) +- except KeyboardInterrupt as ke: ++ except KeyboardInterrupt: ++ # The dispatch loop owns main-thread notification. Re-raise so it ++ # can request exactly one interruption for this watcher. + self._log_keyboard_interrupt_stop_once() +- handle_keyboard_interrupt(ke) + raise + except Exception: + return True +-- +2.43.0 +