diff --git a/.cirrus.yml b/.cirrus.yml deleted file mode 100644 index 633d08be..00000000 --- a/.cirrus.yml +++ /dev/null @@ -1,43 +0,0 @@ -freebsd_instance: - image_family: freebsd-14-3 - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -task: - name: FreeBSD - matrix: - - name: FreeBSD 14.3 - Rust stable - env: - RUST_VERSION: stable - - name: FreeBSD 14.3 - Rust nightly - env: - RUST_VERSION: nightly - - name: FreeBSD 14.3 - Rust 1.85 (MSRV) - env: - RUST_VERSION: 1.85 - - setup_script: - - pkg install -y ca_root_nss - - rm -f rust-toolchain.toml - - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile=minimal --default-toolchain ${RUST_VERSION} - - . $HOME/.cargo/env - - cargo --version - - rustc --version - - cargo_cache: - folder: $HOME/.cargo/registry - fingerprint_script: cat Cargo.lock || echo "No Cargo.lock" - - build_script: - - . $HOME/.cargo/env - - cargo build --verbose - - build_examples_script: - - . $HOME/.cargo/env - - cargo build --examples --verbose - - test_script: - - . $HOME/.cargo/env - - cargo test --verbose diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..3035a50e --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,50 @@ +# Security Policy + +This document describes how to report security vulnerabilities in the crates maintained in this +repository. + +## Supported Versions + +Security fixes are provided for: + +- The `main` branch. +- The latest released versions of crates published from this repository. + +Older versions may not receive security backports. If you are using an older release, please plan to +upgrade to a supported version to receive fixes. + +## Reporting a Vulnerability + +Please **do not** report security issues via public GitHub issues, pull requests, Discord, or other +public channels. + +Instead, use GitHub's private vulnerability reporting: + +1. Go to this repository's **Security** tab. +2. Click **Report a vulnerability** or create a **New draft security advisory**. + +Include as much of the following as you can: + +- Affected crate(s) and version(s), and whether you are using crates.io releases or git revisions. +- Impact and severity assessment. +- Reproduction steps and a minimal proof of concept. +- Any relevant configuration details. + +If you're not sure whether something is a security issue, report it anyway and mark it as uncertain. + +### Notes about AI/LLM + +Fully AI-generated, low-quality, or spammy reports are not accepted. Reports must reflect a +real investigation, include repository-specific details, and provide a credible reproduction or +impact assessment. Sending AI slop or repeatedly submitting low-quality reports may result in a +ban from the organization. + +## Disclosure Process + +After receiving a report, maintainers will make a best effort to: + +- Triage and assess impact, then work on a fix. +- Coordinate a release and publish an advisory when a fix is available. + +Please keep vulnerability details confidential until an advisory is published, or maintainers confirm +it is safe to disclose. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6de9d3f..e2bfddcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,16 +11,38 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 +permissions: + contents: read + jobs: + zizmor: + name: zizmor + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + with: + advanced-security: false + annotations: true + version: v1.24.1 + # Code quality checks quality: name: Quality Checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Cache dependencies - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Check formatting run: cargo fmt --all -- --check @@ -29,7 +51,7 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: Install typos-cli - uses: taiki-e/install-action@288875dd3d64326724fa6d9593062d9f8ba0b131 # v2.67.30 + uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2.75.28 with: tool: typos-cli @@ -37,7 +59,7 @@ jobs: run: typos - name: Install cargo-deny - uses: taiki-e/install-action@288875dd3d64326724fa6d9593062d9f8ba0b131 # v2.67.30 + uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2.75.28 with: tool: cargo-deny @@ -45,7 +67,7 @@ jobs: run: cargo deny check - name: Install cargo-machete - uses: taiki-e/install-action@288875dd3d64326724fa6d9593062d9f8ba0b131 # v2.67.30 + uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2.75.28 with: tool: cargo-machete @@ -61,7 +83,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - rust: [stable, nightly, 1.85] + rust: [stable, nightly, 1.88] include: # MacOS with fsevent - os: macos-latest @@ -71,7 +93,7 @@ jobs: rust: nightly features: "--no-default-features --features macos_fsevent" - os: macos-latest - rust: 1.85 + rust: 1.88 features: "--no-default-features --features macos_fsevent" # MacOS with kqueue - os: macos-latest @@ -81,11 +103,13 @@ jobs: rust: nightly features: "--no-default-features --features macos_kqueue" - os: macos-latest - rust: 1.85 + rust: 1.88 features: "--no-default-features --features macos_kqueue" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Remove rust-toolchain.toml (Unix) if: runner.os != 'Windows' @@ -99,29 +123,61 @@ jobs: } - name: Install Rust ${{ matrix.rust }} - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} + run: | + rustup override set ${{ matrix.rust }} + rustup update ${{ matrix.rust }} - name: Cache dependencies - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Build - run: cargo build --verbose + run: cargo build --verbose ${{ matrix.features }} - name: Build examples - run: cargo build --examples --verbose + run: cargo build --examples --verbose ${{ matrix.features }} - name: Run tests - run: cargo test --verbose + run: cargo test --workspace --exclude examples --verbose ${{ matrix.features }} - name: Run tests with futures if: matrix.rust == 'stable' - run: cargo test --verbose --features futures + run: cargo test --workspace --exclude examples --verbose ${{ matrix.features }} --features futures - name: Run tests with tokio if: matrix.rust == 'stable' - run: cargo test --verbose --features tokio + run: cargo test --workspace --exclude examples --verbose ${{ matrix.features }} --features tokio + + # FreeBSD + freebsd: + name: FreeBSD - ${{ matrix.rust }} + runs-on: ubuntu-latest + needs: quality + strategy: + fail-fast: false + matrix: + rust: [stable, nightly, 1.88] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Test in FreeBSD VM + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 + with: + release: "14.3" + envs: "CARGO_TERM_COLOR RUST_BACKTRACE" + usesh: true + prepare: | + pkg install -y curl + run: | + rm -f rust-toolchain.toml + curl https://sh.rustup.rs -sSf | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.rust }} + . $HOME/.cargo/env + cargo --version + rustc --version + cargo build --verbose + cargo build --examples --verbose + cargo test --verbose # Android cross-compilation android: @@ -129,21 +185,24 @@ jobs: runs-on: ubuntu-latest needs: quality steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Remove rust-toolchain.toml run: rm -f rust-toolchain.toml - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: armv7-linux-androideabi, aarch64-linux-android + run: | + rustup override set stable + rustup update stable + rustup target add armv7-linux-androideabi aarch64-linux-android - name: Cache dependencies - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Install cargo-ndk - uses: taiki-e/install-action@288875dd3d64326724fa6d9593062d9f8ba0b131 # v2.67.30 + uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2.75.28 with: tool: cargo-ndk @@ -165,9 +224,10 @@ jobs: # run: rm -f rust-toolchain.toml # - name: Install Rust - # uses: dtolnay/rust-toolchain@nightly - # with: - # targets: wasm32-wasip2 + # run: | + # rustup override set stable + # rustup update stable + # rustup target wasm32-wasip2 # - name: Cache dependencies # uses: Swatinem/rust-cache@v2 diff --git a/Cargo.lock b/Cargo.lock index ed3fc537..c156a215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -37,15 +37,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -67,47 +67,41 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bitflags" -version = "1.3.2" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cc" -version = "1.2.54" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -138,9 +132,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -149,20 +143,19 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -197,9 +190,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "deser-hjson" -version = "2.2.5" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbc1498156ddf9adc2eb251c1697b3a0dea599c94fb8cf1313f986adcc70f6e" +checksum = "a0d49a923edd92dc1ca5819822f540ea83f094ab5ac6940e4044e03c1d8e6576" dependencies = [ "serde", ] @@ -218,9 +211,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -228,9 +221,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -252,7 +245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -270,11 +263,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] @@ -283,14 +276,14 @@ version = "0.2.3" dependencies = [ "serde", "tempfile", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" @@ -312,9 +305,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -327,9 +320,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -337,15 +330,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -354,15 +347,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -371,27 +364,27 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -401,32 +394,32 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] name = "getrandom" -version = "0.2.17" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core", "wasip2", "wasip3", @@ -449,9 +442,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -461,9 +454,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -491,23 +484,23 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.11.0", + "bitflags", "inotify-sys", "libc", ] @@ -523,9 +516,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.3" +version = "1.47.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "console", "once_cell", @@ -541,15 +534,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -560,9 +553,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -571,19 +564,21 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -591,11 +586,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags", "libc", ] @@ -607,15 +602,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "lock_api" @@ -628,35 +623,35 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "nix" -version = "0.31.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -664,9 +659,9 @@ dependencies = [ [[package]] name = "notify" -version = "9.0.0-rc.2" +version = "9.0.0-rc.4" dependencies = [ - "bitflags 2.11.0", + "bitflags", "crossbeam-channel", "flume", "futures", @@ -686,13 +681,13 @@ dependencies = [ "tokio", "trash", "walkdir", - "windows-sys 0.61.2", + "windows-sys", "xxhash-rust", ] [[package]] name = "notify-debouncer-full" -version = "0.7.0" +version = "0.8.0-rc.2" dependencies = [ "crossbeam-channel", "deser-hjson", @@ -705,6 +700,7 @@ dependencies = [ "pretty_assertions", "rand", "rstest", + "rustc-hash", "serde", "tempfile", "tokio", @@ -729,7 +725,7 @@ dependencies = [ name = "notify-types" version = "2.1.0" dependencies = [ - "bitflags 2.11.0", + "bitflags", "insta", "rstest", "serde", @@ -748,9 +744,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -761,7 +757,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -786,15 +782,15 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags", "objc2", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -810,27 +806,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -857,9 +847,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -875,9 +865,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -888,28 +878,34 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.1", + "getrandom 0.4.2", "rand_core", ] [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -919,9 +915,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -930,9 +926,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relative-path" @@ -969,6 +965,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -980,15 +982,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1014,9 +1016,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1050,9 +1052,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1063,9 +1065,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "similar" @@ -1075,9 +1077,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "spin" @@ -1090,9 +1092,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1101,22 +1103,22 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "pin-project-lite", "tokio-macros", @@ -1124,9 +1126,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1135,18 +1137,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -1156,18 +1158,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "trash" -version = "5.2.5" +version = "5.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" dependencies = [ "chrono", "libc", @@ -1183,9 +1185,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-xid" @@ -1223,11 +1225,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -1236,14 +1238,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -1254,9 +1256,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1264,9 +1266,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -1277,9 +1279,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -1312,7 +1314,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -1334,7 +1336,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1449,15 +1451,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1533,9 +1526,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -1549,6 +1542,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -1598,7 +1597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap", "log", "serde", @@ -1642,6 +1641,6 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 9e79373b..61bbabaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ exclude = [ ] [workspace.package] -rust-version = "1.85" +rust-version = "1.88" homepage = "https://github.com/notify-rs/notify" repository = "https://github.com/notify-rs/notify.git" edition = "2021" @@ -31,14 +31,14 @@ futures = "0.3.30" tokio = { version = "1.49.0", default-features = false, features = ["sync"] } inotify = { version = "0.11.0", default-features = false } insta = "1.34.0" -kqueue = "1.1.1" +kqueue = "1.2.0" libc = "0.2.4" log = "0.4.17" mio = { version = "1.0", features = ["os-ext"] } web-time = "1.1.0" nix = "0.31.0" -notify = { version = "9.0.0-rc.2", path = "notify" } -notify-debouncer-full = { version = "0.7.0", path = "notify-debouncer-full" } +notify = { version = "9.0.0-rc.4", path = "notify" } +notify-debouncer-full = { version = "0.8.0-rc.2", path = "notify-debouncer-full" } notify-debouncer-mini = { version = "0.7.0", path = "notify-debouncer-mini" } notify-types = { version = "2.1.0", path = "notify-types" } pretty_assertions = "1.3.0" diff --git a/README.md b/README.md index 88ad90c2..b4737b79 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ _Cross-platform filesystem notification library for Rust._ - [File ID][file-id-docs] - [Examples][examples] - [Changelog][changelog] -- [Upgrading notify from v4](UPGRADING_V4_TO_V5.md) -- Minimum supported Rust version: **1.85** +- [Upgrading notify from v8](./docs/UPGRADING_V8_TO_V9.md) +- [Upgrading notify from v4](./docs/UPGRADING_V4_TO_V5.md) +- Minimum supported Rust version: **1.88** As used by: [alacritty], [cargo watch], [cobalt], [deno], [docket], [mdBook], [rust-analyzer], [watchexec], [watchfiles], [xi-editor], [zed] @@ -31,7 +32,7 @@ and others. We follow these MSRV rules: -- The current MSRV is **1.85**. +- The current MSRV is **1.88**. - MSRV bumps do NOT require a major release and may happen in minor releases. - The MSRV may be updated when needed, but support for the current stable Rust release and the previous two stable releases (N, N-1, N-2) is always guaranteed. - For example, if the current stable version is 1.85, we guarantee support for 1.85, 1.84, and 1.83, so the minimum supported Rust version will be **at most** 1.83. diff --git a/UPGRADING_V4_TO_V5.md b/docs/UPGRADING_V4_TO_V5.md similarity index 99% rename from UPGRADING_V4_TO_V5.md rename to docs/UPGRADING_V4_TO_V5.md index 85e0f5bc..74012fc2 100644 --- a/UPGRADING_V4_TO_V5.md +++ b/docs/UPGRADING_V4_TO_V5.md @@ -28,4 +28,4 @@ For macOS the kqueue backend can now be used alternatively by using the `macos_k Platform support in v5 now includes BSD and kqueue on macos in addition to fsevent. -[notify-debouncer-mini]: https://crates.io/crates/notify-debouncer-mini \ No newline at end of file +[notify-debouncer-mini]: https://crates.io/crates/notify-debouncer-mini diff --git a/docs/UPGRADING_V8_TO_V9.md b/docs/UPGRADING_V8_TO_V9.md new file mode 100644 index 00000000..6dcceba0 --- /dev/null +++ b/docs/UPGRADING_V8_TO_V9.md @@ -0,0 +1,124 @@ +# Upgrading from notify v8 to v9 + +This guide documents changes between v8 and v9 for upgrading existing code. + +## Breaking changes + +### 1) MSRV is now Rust 1.88 + +`notify` v9 requires Rust 1.88 or newer. + +We also declared a new MSRV policy. For details, please read README. + +### 2) `Watcher::paths_mut` was removed + +`PathsMut` and `Watcher::paths_mut()` were removed in v9 and replaced by: + +- `Watcher::update_paths(Vec)` +- `PathOp` and `WatchPathConfig` +- `UpdatePathsError` for partial-failure reporting + +Before (v8): + +```rust +use notify::{RecursiveMode, Result, Watcher}; + +fn add_many_paths(watcher: &mut W, paths: &[std::path::PathBuf]) -> Result<()> { + let mut batch = watcher.paths_mut(); + for path in paths { + batch.add(path, RecursiveMode::Recursive)?; + } + batch.commit() +} +``` + +After (v9): + +```rust +use notify::{PathOp, Result, Watcher}; + +fn add_many_paths(watcher: &mut W, paths: &[std::path::PathBuf]) -> Result<()> { + let ops = paths + .iter() + .cloned() + .map(PathOp::watch_recursive) + .collect::>(); + + watcher.update_paths(ops).map_err(notify::Error::from)?; + Ok(()) +} +``` + +`update_paths` applies operations in order and stops on the first error. +When it fails, `UpdatePathsError` includes: + +- `source`: underlying `notify::Error` +- `origin`: failing operation (if known) +- `remaining`: operations that were not attempted + +This lets you retry only unfinished operations if needed. + +If you implemented a custom watcher and overrode `paths_mut`, migrate that logic to `update_paths`. + +### 3) Event paths preserve the watched path representation + +`Event.paths` and `Watcher::watched_paths()` now use the same root representation that was passed +to `Watcher::watch` or `Watcher::update_paths`. + +For example, watching `src` now reports `src/lib.rs`. Watching `/repo/src` reports +`/repo/src/lib.rs`. + +If your code relied on Linux or Windows backends converting relative watch paths to absolute event +paths, convert the path before calling `watch`: + +```rust +let path = std::env::current_dir()?.join("src"); +watcher.watch(&path, notify::RecursiveMode::Recursive)?; +``` + +### 4) Rewatching the same path replaces the existing watch + +Calling `Watcher::watch` again for the same backend-resolved path now replaces the existing watch +on success. The recursive mode and reported path are updated to the new request, a second +independent watch is not added, and one `Watcher::unwatch` call removes the path. + +In v8 this behavior varied by backend. Some backends effectively merged repeated watches, while +others could keep duplicate backend entries or resources. If your code depended on repeated +`watch` calls acting like independent watches for the same path, use separate watcher instances or +manage parent/child watches explicitly. + +Before (v8 behavior varied by backend): + +```rust +watcher.watch(path, notify::RecursiveMode::Recursive)?; +watcher.watch(path, notify::RecursiveMode::NonRecursive)?; +``` + +After (v9): + +```rust +watcher.watch(path, notify::RecursiveMode::Recursive)?; +watcher.watch(path, notify::RecursiveMode::NonRecursive)?; + +// `path` is now watched non-recursively. +watcher.unwatch(path)?; +``` + +## Non-breaking changes but worth mentioning + +### Event-kind filtering was added + +`Config::with_event_kinds` and `EventKindMask` allow filtering delivered events: + +```rust +use notify::{Config, EventKindMask}; + +let config = Config::default().with_event_kinds(EventKindMask::CORE); +``` + +No migration is required unless you want filtering. + +### macOS FSEvents backend internals changed + +The `macos_fsevent` feature now uses `objc2-core-foundation` and `objc2-core-services` instead of `fsevent-sys`. +Public `notify` API stays the same, but macOS behavior should be revalidated in integration tests. diff --git a/examples/async_monitor.rs b/examples/async_monitor.rs index dfa0fca5..6d0ae5e6 100644 --- a/examples/async_monitor.rs +++ b/examples/async_monitor.rs @@ -10,11 +10,11 @@ fn main() { let path = std::env::args() .nth(1) .expect("Argument 1 needs to be a path"); - println!("watching {}", path); + println!("watching {path}"); futures::executor::block_on(async { if let Err(e) = async_watch(path).await { - println!("error: {:?}", e) + println!("error: {e:?}") } }); } @@ -45,8 +45,8 @@ async fn async_watch>(path: P) -> notify::Result<()> { while let Some(res) = rx.next().await { match res { - Ok(event) => println!("changed: {:?}", event), - Err(e) => println!("watch error: {:?}", e), + Ok(event) => println!("changed: {event:?}"), + Err(e) => println!("watch error: {e:?}"), } } diff --git a/examples/poll_sysfs.rs b/examples/poll_sysfs.rs index e04d9f81..5c106550 100644 --- a/examples/poll_sysfs.rs +++ b/examples/poll_sysfs.rs @@ -17,14 +17,11 @@ fn not_windows_main() -> notify::Result<()> { eprintln!("Must provide path to watch, default system path was not found (probably you're not running on Linux?)"); std::process::exit(1); } - println!( - "Trying {:?}, use `ping localhost` to see changes!", - lo_stats - ); + println!("Trying {lo_stats:?}, use `ping localhost` to see changes!"); paths.push(lo_stats); } - println!("watching {:?}...", paths); + println!("watching {paths:?}..."); // configure pollwatcher backend let config = Config::default() .with_compare_contents(true) // crucial part for pseudo filesystems @@ -39,8 +36,8 @@ fn not_windows_main() -> notify::Result<()> { // print all events, never returns for res in rx { match res { - Ok(event) => println!("changed: {:?}", event), - Err(e) => println!("watch error: {:?}", e), + Ok(event) => println!("changed: {event:?}"), + Err(e) => println!("watch error: {e:?}"), } } diff --git a/examples/pollwatcher_manual.rs b/examples/pollwatcher_manual.rs index bb440fdd..134df14d 100644 --- a/examples/pollwatcher_manual.rs +++ b/examples/pollwatcher_manual.rs @@ -30,8 +30,8 @@ fn watch>(path: P) -> notify::Result<()> { std::thread::spawn(move || { for res in rx { match res { - Ok(event) => println!("changed: {:?}", event), - Err(e) => println!("watch error: {:?}", e), + Ok(event) => println!("changed: {event:?}"), + Err(e) => println!("watch error: {e:?}"), } } }); diff --git a/examples/watcher_kind.rs b/examples/watcher_kind.rs index 72b59301..d8ef5d5a 100644 --- a/examples/watcher_kind.rs +++ b/examples/watcher_kind.rs @@ -23,6 +23,6 @@ fn main() { // just print all events, this blocks forever for e in rx { - println!("{:?}", e); + println!("{e:?}"); } } diff --git a/file-id/CHANGELOG.md b/file-id/CHANGELOG.md index b99bbbaa..a0654485 100644 --- a/file-id/CHANGELOG.md +++ b/file-id/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## file-id 0.2.4 (unreleased) + +- CHANGE: raise MSRV to 1.88 +- CHANGE: add `#[must_use]` annotations to `FileId` constructors + ## file-id 0.2.3 (2025-08-03) - CHANGE: implement `AsRef` for `FileId` [#664] diff --git a/file-id/README.md b/file-id/README.md index 2f80f243..ec9267ba 100644 --- a/file-id/README.md +++ b/file-id/README.md @@ -25,7 +25,7 @@ println!("{file_id:?}"); We follow these MSRV rules: -- The current MSRV is **1.85**. +- The current MSRV is **1.88**. - MSRV bumps do NOT require a major release and may happen in minor releases. - The MSRV may be updated when needed, but support for the current stable Rust release and the previous two stable releases (N, N-1, N-2) is always guaranteed. - For example, if the current stable version is 1.85, we guarantee support for 1.85, 1.84, and 1.83, so the minimum supported Rust version will be **at most** 1.83. diff --git a/file-id/src/lib.rs b/file-id/src/lib.rs index d1cc121e..156e584d 100644 --- a/file-id/src/lib.rs +++ b/file-id/src/lib.rs @@ -94,6 +94,7 @@ pub enum FileId { } impl FileId { + #[must_use] pub fn new_inode(device_id: u64, inode_number: u64) -> Self { FileId::Inode { device_id, @@ -101,6 +102,7 @@ impl FileId { } } + #[must_use] pub fn new_low_res(volume_serial_number: u32, file_index: u64) -> Self { FileId::LowRes { volume_serial_number, @@ -108,6 +110,7 @@ impl FileId { } } + #[must_use] pub fn new_high_res(volume_serial_number: u64, file_id: u128) -> Self { FileId::HighRes { volume_serial_number, diff --git a/notify-debouncer-full/CHANGELOG.md b/notify-debouncer-full/CHANGELOG.md index 904ccda0..ce44b279 100644 --- a/notify-debouncer-full/CHANGELOG.md +++ b/notify-debouncer-full/CHANGELOG.md @@ -1,11 +1,34 @@ # Changelog -## debouncer-full 0.7.1 (unreleased) +## unreleased + +- PERF: park the debouncer thread to avoid idle polling [#933] + +[#933]: https://github.com/notify-rs/notify/pull/933 + +## debouncer-full 0.8.0-rc.2 (2026-05-02) + +- CHANGE: upgrade `notify` to 9.0.0-rc.4 +- CHANGE: emit `remove` events even if a file was created and then removed (because macOS repeats the "create" event) [#900] **breaking** +- CHANGE: speed up debouncer root tracking for large numbers of watched paths while preserving recursive matching for overlapping roots [#913] + +[#900]: https://github.com/notify-rs/notify/issues/900 +[#913]: https://github.com/notify-rs/notify/pull/913 + +## debouncer-full 0.8.0-rc.1 (2026-04-16) + +- CHANGE: raise MSRV to 1.88 +- CHANGE: upgrade `notify` to 9.0.0-rc.3 +- CHANGE: add `#[must_use]` annotations to cache constructors and watcher kind accessors +- CHANGE: use `HashMap::extract_if` to reduce debouncer flush overhead +- CHANGE: speed up debouncer event flushing and file ID cache lookups while preserving stable path ordering for equal-timestamp events - FEATURE: impl `EventHandler` for `futures::channel::mpsc::UnboundedSender` and `tokio::sync::mpsc::UnboundedSender` behind the `futures` and `tokio` feature flags [#767] - FEATURE: add support of a watcher's method `update_paths` [#705] +- FEATURE: add `Debouncer::watched_paths` for `Debouncer` [#710] [#767]: https://github.com/notify-rs/notify/pull/767 [#705]: https://github.com/notify-rs/notify/pull/705 +[#710]: https://github.com/notify-rs/notify/issues/710 ## debouncer-full 0.7.0 (2026-01-23) diff --git a/notify-debouncer-full/Cargo.toml b/notify-debouncer-full/Cargo.toml index 834a4135..7312e2af 100644 --- a/notify-debouncer-full/Cargo.toml +++ b/notify-debouncer-full/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notify-debouncer-full" -version = "0.7.0" +version = "0.8.0-rc.2" description = "notify event debouncer optimized for ease of use" documentation = "https://docs.rs/notify-debouncer-full" authors = ["Daniel Faust "] @@ -34,6 +34,7 @@ tokio = { workspace = true, optional = true } file-id.workspace = true walkdir.workspace = true log.workspace = true +rustc-hash = "2.1.2" [dev-dependencies] pretty_assertions.workspace = true diff --git a/notify-debouncer-full/README.md b/notify-debouncer-full/README.md index 9357ba9e..779f12b8 100644 --- a/notify-debouncer-full/README.md +++ b/notify-debouncer-full/README.md @@ -24,7 +24,7 @@ A debouncer for [notify] that is optimized for ease of use. We follow these MSRV rules: -- The current MSRV is **1.85**. +- The current MSRV is **1.88**. - MSRV bumps do NOT require a major release and may happen in minor releases. - The MSRV may be updated when needed, but support for the current stable Rust release and the previous two stable releases (N, N-1, N-2) is always guaranteed. - For example, if the current stable version is 1.85, we guarantee support for 1.85, 1.84, and 1.83, so the minimum supported Rust version will be **at most** 1.83. diff --git a/notify-debouncer-full/src/cache.rs b/notify-debouncer-full/src/cache.rs index b894b74d..b644c443 100644 --- a/notify-debouncer-full/src/cache.rs +++ b/notify-debouncer-full/src/cache.rs @@ -42,6 +42,7 @@ pub struct NoCache; impl NoCache { /// Construct an empty cache. + #[must_use] pub fn new() -> Self { Default::default() } diff --git a/notify-debouncer-full/src/file_id_map.rs b/notify-debouncer-full/src/file_id_map.rs index 064bd78a..5626521b 100644 --- a/notify-debouncer-full/src/file_id_map.rs +++ b/notify-debouncer-full/src/file_id_map.rs @@ -1,10 +1,8 @@ use crate::FileIdCache; use file_id::{get_file_id, FileId}; use notify::RecursiveMode; -use std::{ - collections::HashMap, - path::{Path, PathBuf}, -}; +use rustc_hash::FxHashMap as HashMap; +use std::path::{Path, PathBuf}; use walkdir::WalkDir; /// A cache to hold the file system IDs of all watched files. @@ -18,6 +16,7 @@ pub struct FileIdMap { impl FileIdMap { /// Construct an empty cache. + #[must_use] pub fn new() -> Self { Default::default() } diff --git a/notify-debouncer-full/src/lib.rs b/notify-debouncer-full/src/lib.rs index 47a561f5..d5ec1ff2 100644 --- a/notify-debouncer-full/src/lib.rs +++ b/notify-debouncer-full/src/lib.rs @@ -12,7 +12,7 @@ //! //! ```toml //! [dependencies] -//! notify-debouncer-full = "0.7.0" +//! notify-debouncer-full = "0.8.0-rc.2" //! ``` //! //! In case you want to select specific features of notify, @@ -20,7 +20,7 @@ //! Otherwise you can just use the re-export of notify from debouncer-full. //! //! ```toml -//! notify-debouncer-full = "0.7.0" +//! notify-debouncer-full = "0.8.0-rc.2" //! notify = { version = "..", features = [".."] } //! ``` //! @@ -72,15 +72,16 @@ mod file_id_map; use std::{ cmp::Reverse, - collections::{BinaryHeap, HashMap, VecDeque}, + collections::{BinaryHeap, VecDeque}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Mutex, + Arc, Condvar, Mutex, }, time::{Duration, Instant}, }; +use rustc_hash::FxHashMap as HashMap; use time::now; pub use cache::{FileIdCache, NoCache, RecommendedCache}; @@ -171,7 +172,13 @@ impl DebounceEventHandler for std::sync::mpsc::Sender { /// Comes with either a vec of events or vec of errors. pub type DebounceEventResult = Result, Vec>; -type DebounceData = Arc>>; +type DebounceData = Arc>; + +#[derive(Debug)] +struct SharedDebounceData { + inner: Mutex>, + changed: Condvar, +} #[derive(Debug, Clone, Default, PartialEq, Eq)] struct Queue { @@ -205,7 +212,9 @@ impl Queue { #[derive(Debug)] pub(crate) struct DebounceDataInner { queues: HashMap, - roots: Vec<(PathBuf, RecursiveMode)>, + /// Registered watch roots, kept **sorted by path** so that `add_root` + /// can dedupe via binary search in O(log N) and doesn't suffer from injection + roots: VecDeque<(PathBuf, RecursiveMode)>, cache: T, rename_event: Option<(DebouncedEvent, Option)>, rescan_event: Option, @@ -216,8 +225,8 @@ pub(crate) struct DebounceDataInner { impl DebounceDataInner { pub(crate) fn new(cache: T, timeout: Duration) -> Self { Self { - queues: HashMap::new(), - roots: Vec::new(), + queues: HashMap::default(), + roots: VecDeque::new(), cache, rename_event: None, rescan_event: None, @@ -230,7 +239,6 @@ impl DebounceDataInner { pub fn debounced_events(&mut self) -> Vec { let now = now(); let mut events_expired = Vec::with_capacity(self.queues.len()); - let mut queues_remaining = HashMap::with_capacity(self.queues.len()); if let Some(event) = self.rescan_event.take() { if now.saturating_duration_since(event.time) >= self.timeout { @@ -241,39 +249,34 @@ impl DebounceDataInner { } } - // drain the entire queue, then process the expired events and re-add the rest - // TODO: perfect fit for drain_filter https://github.com/rust-lang/rust/issues/59618 - for (path, mut queue) in self.queues.drain() { - let mut kind_index = HashMap::new(); - - while let Some(event) = queue.events.pop_front() { - // remove previous event of the same kind - if let Some(idx) = kind_index.get(&event.kind).copied() { - events_expired.remove(idx); + // Visit each queue in place and remove only the ones that become empty. + self.queues + .extract_if(|_, queue| { + let mut kind_index: HashMap = HashMap::default(); + let mut queue_expired = Vec::new(); + + while let Some(event) = queue.events.pop_front() { + // remove previous event of the same kind + if now.saturating_duration_since(event.time) >= self.timeout { + if let Some(idx) = kind_index.insert(event.kind, queue_expired.len()) { + queue_expired[idx] = None; + } - kind_index.values_mut().for_each(|i| { - if *i > idx { - *i -= 1 + queue_expired.push(Some(event)); + } else { + if let Some(&idx) = kind_index.get(&event.kind) { + queue_expired[idx] = None; } - }) + queue.events.push_front(event); + break; + } } - if now.saturating_duration_since(event.time) >= self.timeout { - kind_index.insert(event.kind, events_expired.len()); - - events_expired.push(event); - } else { - queue.events.push_front(event); - break; - } - } + events_expired.extend(queue_expired.into_iter().flatten()); - if !queue.events.is_empty() { - queues_remaining.insert(path, queue); - } - } - - self.queues = queues_remaining; + queue.events.is_empty() + }) + .for_each(drop); sort_events(events_expired) } @@ -286,7 +289,6 @@ impl DebounceDataInner { /// Add an error entry to re-send later on pub fn add_error(&mut self, error: Error) { log::trace!("raw error: {error:?}"); - self.errors.push(error); } @@ -295,7 +297,8 @@ impl DebounceDataInner { log::trace!("raw event: {event:?}"); if event.need_rescan() { - self.cache.rescan(&self.roots); + let roots = self.roots.make_contiguous(); + self.cache.rescan(roots); self.rescan_event = Some(DebouncedEvent { event, time: now() }); return; } @@ -357,17 +360,19 @@ impl DebounceDataInner { } } - fn recursive_mode(&mut self, path: &Path) -> RecursiveMode { - self.roots - .iter() - .find_map(|(root, recursive_mode)| { - if path.starts_with(root) { - Some(*recursive_mode) - } else { - None + fn recursive_mode(&self, path: &Path) -> RecursiveMode { + for ancestor in path.ancestors() { + if let Ok(index) = self + .roots + .binary_search_by(|(root, _)| root.as_path().cmp(ancestor)) + { + if self.roots[index].1 == RecursiveMode::Recursive { + return RecursiveMode::Recursive; } - }) - .unwrap_or(RecursiveMode::NonRecursive) + } + } + + RecursiveMode::NonRecursive } fn handle_rename_from(&mut self, event: Event) { @@ -514,9 +519,6 @@ impl DebounceDataInner { self.cache.remove_path(path); match self.queues.get_mut(path) { - Some(queue) if queue.was_created() => { - self.queues.remove(path); - } Some(queue) => { queue.events = [DebouncedEvent::new(event, time)].into(); } @@ -580,7 +582,9 @@ impl Debouncer { } fn set_stop(&self) { + let _lock = self.data.inner.lock().unwrap(); self.stop.store(true, Ordering::Relaxed); + self.data.changed.notify_all(); } #[deprecated = "`Debouncer` provides all methods from `Watcher` itself now. Remove `.watcher()` and use those methods directly."] @@ -592,20 +596,24 @@ impl Debouncer { fn add_root(&mut self, path: impl Into, recursive_mode: RecursiveMode) { let path = path.into(); - let mut data = self.data.lock().unwrap(); + let mut data = self.data.inner.lock().unwrap(); - // skip, if the root has already been added - if data.roots.iter().any(|(p, _)| p == &path) { - return; + match data + .roots + .binary_search_by(|(p, _)| p.as_path().cmp(path.as_path())) + { + Ok(_) => return, // already registered + Err(pos) => { + // `VecDeque::insert` is O(min(pos, len - pos)) + data.roots.insert(pos, (path.clone(), recursive_mode)); + } } - data.roots.push((path.clone(), recursive_mode)); - data.cache.add_path(&path, recursive_mode); } fn remove_root(&mut self, path: impl AsRef) { - let mut data = self.data.lock().unwrap(); + let mut data = self.data.inner.lock().unwrap(); data.roots.retain(|(root, _)| !root.starts_with(&path)); @@ -628,6 +636,10 @@ impl Debouncer { Ok(()) } + pub fn watched_paths(&self) -> notify::Result> { + self.watcher.watched_paths() + } + /// Add/remove paths to watch in batch. /// /// For some [`Watcher`] implementations this method provides better performance than multiple @@ -702,6 +714,7 @@ impl Debouncer { self.watcher.configure(option) } + #[must_use] pub fn kind() -> WatcherKind where Self: Sized, @@ -728,7 +741,10 @@ pub fn new_debouncer_opt Result, Error> { - let data = Arc::new(Mutex::new(DebounceDataInner::new(file_id_cache, timeout))); + let data = Arc::new(SharedDebounceData { + inner: Mutex::new(DebounceDataInner::new(file_id_cache, timeout)), + changed: Condvar::new(), + }); let stop = Arc::new(AtomicBool::new(false)); let tick_div = 4; @@ -753,17 +769,26 @@ pub fn new_debouncer_opt| { - let mut lock = data_c.lock().unwrap(); - + let mut lock = data_c.inner.lock().unwrap(); match e { Ok(e) => lock.add_event(e), // can't have multiple TX, so we need to pipe that through our debouncer Err(e) => lock.add_error(e), } + data_c.changed.notify_all(); }, config, )?; @@ -819,25 +844,35 @@ fn sort_events(events: Vec) -> Vec { let mut sorted = Vec::with_capacity(events.len()); // group events by path - let mut events_by_path: HashMap<_, VecDeque<_>> = - events.into_iter().fold(HashMap::new(), |mut acc, event| { - acc.entry(event.paths.last().cloned().unwrap_or_default()) - .or_default() - .push_back(event); - acc - }); + let mut groups = Vec::<(PathBuf, VecDeque)>::new(); + let mut group_indexes: HashMap = HashMap::default(); + group_indexes.reserve(events.len()); + groups.reserve(events.len()); + + for event in events { + let path = event.paths.last().cloned().unwrap_or_default(); + + if let Some(&index) = group_indexes.get(&path) { + groups[index].1.push_back(event); + } else { + group_indexes.insert(path.clone(), groups.len()); + groups.push((path, [event].into())); + } + } + + // Keep path order as the tie-breaker for identical timestamps. + groups.sort_unstable_by(|(left_path, _), (right_path, _)| left_path.cmp(right_path)); // push events for different paths in chronological order and keep the order of events with the same path - let mut min_time_heap = events_by_path + let mut min_time_heap = groups .iter() - .map(|(path, events)| Reverse((events[0].time, path.clone()))) + .enumerate() + .map(|(index, (_, events))| Reverse((events[0].time, index))) .collect::>(); - while let Some(Reverse((min_time, path))) = min_time_heap.pop() { - // unwrap is safe because only paths from `events_by_path` are added to `min_time_heap` - // and they are never removed from `events_by_path`. - let events = events_by_path.get_mut(&path).unwrap(); + while let Some(Reverse((min_time, index))) = min_time_heap.pop() { + let events = &mut groups[index].1; let mut push_next = false; @@ -850,7 +885,7 @@ fn sort_events(events: Vec) -> Vec { if push_next { if let Some(event) = events.front() { - min_time_heap.push(Reverse((event.time, path))); + min_time_heap.push(Reverse((event.time, index))); } } } @@ -863,6 +898,7 @@ mod tests { use std::{ fs, path::{Path, PathBuf}, + time::Duration, }; use super::*; @@ -909,9 +945,49 @@ mod tests { } } + #[derive(Debug, Default)] + struct TrackingWatcher { + watched: Vec<(PathBuf, RecursiveMode)>, + } + + impl Watcher for TrackingWatcher { + fn new( + _event_handler: F, + _config: notify::Config, + ) -> notify::Result { + Ok(Self::default()) + } + + fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> { + self.watched.push((path.to_path_buf(), recursive_mode)); + Ok(()) + } + + fn unwatch(&mut self, path: &Path) -> notify::Result<()> { + let original_len = self.watched.len(); + self.watched + .retain(|(watched_path, _)| watched_path != path); + + if self.watched.len() == original_len { + Err(Error::watch_not_found()) + } else { + Ok(()) + } + } + + fn kind() -> WatcherKind { + WatcherKind::NullWatcher + } + + fn watched_paths(&self) -> notify::Result> { + Ok(self.watched.clone()) + } + } + #[rstest] fn state( #[values( + "add_create_and_remove_event", "add_create_event", "add_create_event_after_remove_event", "add_create_dir_event_twice", @@ -963,7 +1039,7 @@ mod tests { MockTime::set_time(time); let mut state = test_case.state.into_debounce_data_inner(time); - state.roots = vec![(PathBuf::from("/"), RecursiveMode::Recursive)]; + state.roots = VecDeque::from([(PathBuf::from("/"), RecursiveMode::Recursive)]); let mut prev_event_time = Duration::default(); @@ -1044,6 +1120,57 @@ mod tests { } } + #[test] + fn recursive_mode_uses_recursive_root_for_overlapping_watches() { + let state = DebounceDataInner { + queues: HashMap::default(), + roots: VecDeque::from([ + (PathBuf::from("root"), RecursiveMode::NonRecursive), + (PathBuf::from("root/nested"), RecursiveMode::Recursive), + ]), + cache: NoCache, + rename_event: None, + rescan_event: None, + errors: Vec::new(), + timeout: Duration::from_millis(50), + }; + + assert_eq!( + state.recursive_mode(Path::new("root/nested/child")), + RecursiveMode::Recursive + ); + assert_eq!( + state.recursive_mode(Path::new("root/other")), + RecursiveMode::NonRecursive + ); + } + + #[test] + fn sort_events_ties_by_path() { + let time = now(); + let events = vec![ + DebouncedEvent::new( + Event::new(EventKind::Any).add_path(PathBuf::from("/watch/b")), + time, + ), + DebouncedEvent::new( + Event::new(EventKind::Any).add_path(PathBuf::from("/watch/a")), + time, + ), + ]; + + let sorted = sort_events(events); + let paths = sorted + .into_iter() + .map(|event| event.paths[0].clone()) + .collect::>(); + + assert_eq!( + paths, + vec![PathBuf::from("/watch/a"), PathBuf::from("/watch/b")] + ); + } + #[test] fn integration() -> Result<(), Box> { let dir = tempdir()?; @@ -1081,6 +1208,134 @@ mod tests { panic!("did not receive expected event"); } + /// Regression test: on macOS, FSEvents reports file deletions as a burst + /// of `Create(File)` + `Modify(Data)` + `Remove(File)`. The debouncer + /// must not suppress the `Remove` event, even though a prior `Create` + /// for the same path exists in the queue. + /// + /// Without the fix, `push_remove_event` would see `was_created() == true` + /// and cancel the entire queue, swallowing the removal. + #[test] + #[cfg(all(target_os = "macos", feature = "macos_fsevent"))] + fn remove_event_not_swallowed_after_create() -> Result<(), Box> { + let dir = tempdir()?; + let dir_path = dir.path().canonicalize()?; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut debouncer = new_debouncer(Duration::from_millis(10), None, tx)?; + debouncer.watch(&dir_path, RecursiveMode::NonRecursive)?; + + // Create a file and wait for the debouncer to deliver the Create event. + let file_path = dir_path.join("ephemeral.txt"); + fs::write(&file_path, b"will be deleted")?; + + let deadline = Instant::now() + Duration::from_secs(5); + let mut got_create = false; + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(Ok(events)) => { + if events.iter().any(|e| { + matches!(e.event.kind, EventKind::Create(_)) + && e.event.paths.contains(&file_path) + }) { + got_create = true; + break; + } + } + Ok(Err(_)) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(_) => break, + } + } + assert!(got_create, "expected Create event for ephemeral.txt"); + + // Drain any remaining events from the creation. + std::thread::sleep(Duration::from_millis(200)); + while rx.try_recv().is_ok() {} + + // Delete the file. + fs::remove_file(&file_path)?; + + // The debouncer MUST deliver an event for this path (Remove, or at + // minimum any event whose path matches so the consumer can stat it). + let deadline = Instant::now() + Duration::from_secs(5); + let mut got_removal = false; + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(Ok(events)) => { + if events.iter().any(|e| e.event.paths.contains(&file_path)) { + got_removal = true; + break; + } + } + Ok(Err(_)) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(_) => break, + } + } + assert!( + got_removal, + "expected Remove (or any) event for deleted ephemeral.txt, got none within 5s" + ); + + Ok(()) + } + + /// Unit-level reproducer for the same bug: feed a Create + Remove + /// sequence into `DebounceDataInner` directly, with the queue already + /// flushed between them (simulating the debounce tick). The Remove + /// must not be swallowed. + #[test] + #[cfg(target_os = "macos")] + fn push_remove_after_flushed_create() { + use crate::NoCache; + use notify::event::{CreateKind, RemoveKind}; + use std::path::PathBuf; + + let mut state = DebounceDataInner::new(NoCache, Duration::from_millis(50)); + + let time = std::time::Instant::now(); + MockTime::set_time(time); + + let path = PathBuf::from("/tmp/test_file.txt"); + + // Simulate: file created → Create event added + state.add_event(Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![path.clone()], + ..Default::default() + }); + + // Simulate: debounce tick flushes the Create + MockTime::advance(Duration::from_millis(100)); + let flushed = state.debounced_events(); + assert_eq!(flushed.len(), 1); + assert!(matches!(flushed[0].event.kind, EventKind::Create(_))); + + // Simulate: FSEvents sends Create + Remove for the deletion + // (this is what macOS does) + state.add_event(Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![path.clone()], + ..Default::default() + }); + state.add_event(Event { + kind: EventKind::Remove(RemoveKind::File), + paths: vec![path.clone()], + ..Default::default() + }); + + // Flush again — we MUST get the Remove event + MockTime::advance(Duration::from_millis(100)); + let flushed = state.debounced_events(); + assert!( + flushed + .iter() + .any(|e| matches!(e.event.kind, EventKind::Remove(_))), + "expected Remove event after flushed Create, got: {flushed:?}" + ); + } + #[cfg(feature = "futures")] #[tokio::test] async fn futures_unbounded_sender_as_handler() { @@ -1144,10 +1399,7 @@ mod tests { fs::write(&file_path1, b"Lorem ipsum1")?; fs::write(&file_path2, b"Lorem ipsum1")?; - println!( - "waiting for events at {:?} and {:?}", - file_path1, file_path2 - ); + println!("waiting for events at {file_path1:?} and {file_path2:?}"); // wait for up to 10 seconds for the create event, ignore all other events let deadline = Instant::now() + Duration::from_secs(10); @@ -1201,10 +1453,55 @@ mod tests { assert!(err.origin.is_some()); assert_eq!(err.remaining.len(), 1); - let roots = debouncer.data.lock().unwrap().roots.clone(); + let roots = debouncer.data.inner.lock().unwrap().roots.clone(); assert_eq!( roots, - vec![(PathBuf::from("ok1"), RecursiveMode::Recursive)] + VecDeque::from([(PathBuf::from("ok1"), RecursiveMode::Recursive)]) + ); + + Ok(()) + } + + #[test] + fn watched_paths_with_watch_update_paths_and_unwatch() -> Result<(), Box> + { + let mut debouncer = new_debouncer_opt::<_, TrackingWatcher, NoCache>( + Duration::from_millis(20), + Some(Duration::from_millis(5)), + |_| {}, + NoCache::new(), + notify::Config::default(), + )?; + + let path1 = PathBuf::from("one"); + let path2 = PathBuf::from("two"); + let path3 = PathBuf::from("three"); + + assert!(debouncer.watched_paths()?.is_empty()); + + debouncer.watch(&path1, RecursiveMode::Recursive)?; + assert_eq!( + debouncer.watched_paths()?, + vec![(path1.clone(), RecursiveMode::Recursive)] + ); + + debouncer.update_paths([ + PathOp::unwatch(&path1), + PathOp::watch_non_recursive(path2.clone()), + PathOp::watch_recursive(path3.clone()), + ])?; + assert_eq!( + debouncer.watched_paths()?, + vec![ + (path2.clone(), RecursiveMode::NonRecursive), + (path3.clone(), RecursiveMode::Recursive), + ] + ); + + debouncer.unwatch(&path2)?; + assert_eq!( + debouncer.watched_paths()?, + vec![(path3, RecursiveMode::Recursive)] ); Ok(()) diff --git a/notify-debouncer-full/src/testing.rs b/notify-debouncer-full/src/testing.rs index 3315fe3b..760c6a7e 100644 --- a/notify-debouncer-full/src/testing.rs +++ b/notify-debouncer-full/src/testing.rs @@ -267,7 +267,7 @@ impl schema::State { DebounceDataInner { queues, - roots: Vec::new(), + roots: VecDeque::new(), cache, rename_event, rescan_event, diff --git a/notify-debouncer-full/test_cases/add_create_and_remove_event.hjson b/notify-debouncer-full/test_cases/add_create_and_remove_event.hjson new file mode 100644 index 00000000..61907935 --- /dev/null +++ b/notify-debouncer-full/test_cases/add_create_and_remove_event.hjson @@ -0,0 +1,28 @@ +// https://github.com/notify-rs/notify/issues/900 +// +// macOS repeats the "create" event when a file is created and then removed. +// The "remove" event must not be swallowed. +{ + state: { + queues: { + /watch/file: { + events: [ + { kind: "create-any", paths: ["*"] } + ] + } + } + } + events: [ + { kind: "create-any", paths: ["/watch/file"] } + { kind: "remove-any", paths: ["/watch/file"] } + ] + expected: { + queues: { + /watch/file: { + events: [ + { kind: "remove-any", paths: ["*"] } + ] + } + } + } +} diff --git a/notify-debouncer-full/test_cases/add_remove_event_after_create_and_modify_event.hjson b/notify-debouncer-full/test_cases/add_remove_event_after_create_and_modify_event.hjson index b511c2dd..1075c42f 100644 --- a/notify-debouncer-full/test_cases/add_remove_event_after_create_and_modify_event.hjson +++ b/notify-debouncer-full/test_cases/add_remove_event_after_create_and_modify_event.hjson @@ -1,3 +1,7 @@ +// https://github.com/notify-rs/notify/issues/900 +// +// macOS repeats the "create" event when a file is created and then removed. +// The "remove" event must not be swallowed. { state: { queues: { @@ -12,5 +16,13 @@ events: [ { kind: "remove-any", paths: ["/watch/file"] } ] - expected: {} + expected: { + queues: { + /watch/file: { + events: [ + { kind: "remove-any", paths: ["*"] } + ] + } + } + } } diff --git a/notify-debouncer-full/test_cases/add_remove_event_after_create_event.hjson b/notify-debouncer-full/test_cases/add_remove_event_after_create_event.hjson index beac5c79..b486befc 100644 --- a/notify-debouncer-full/test_cases/add_remove_event_after_create_event.hjson +++ b/notify-debouncer-full/test_cases/add_remove_event_after_create_event.hjson @@ -1,3 +1,7 @@ +// https://github.com/notify-rs/notify/issues/900 +// +// macOS repeats the "create" event when a file is created and then removed. +// The "remove" event must not be swallowed. { state: { queues: { @@ -11,5 +15,13 @@ events: [ { kind: "remove-any", paths: ["/watch/file"] } ] - expected: {} + expected: { + queues: { + /watch/file: { + events: [ + { kind: "remove-any", paths: ["*"] } + ] + } + } + } } diff --git a/notify-debouncer-mini/CHANGELOG.md b/notify-debouncer-mini/CHANGELOG.md index 6cced7db..08f96f1b 100644 --- a/notify-debouncer-mini/CHANGELOG.md +++ b/notify-debouncer-mini/CHANGELOG.md @@ -2,6 +2,8 @@ ## debouncer-mini 0.7.1 (unreleased) +- CHANGE: raise MSRV to 1.88 +- CHANGE: add `#[must_use]` annotations to debouncer config builder APIs - FEATURE: impl `EventHandler` for `futures::channel::mpsc::UnboundedSender` and `tokio::sync::mpsc::UnboundedSender` behind the `futures` and `tokio` feature flags [#767] [#767]: https://github.com/notify-rs/notify/pull/767 diff --git a/notify-debouncer-mini/README.md b/notify-debouncer-mini/README.md index 9c2319da..3fae1ca5 100644 --- a/notify-debouncer-mini/README.md +++ b/notify-debouncer-mini/README.md @@ -18,7 +18,7 @@ Tiny debouncer for [notify]. Filters incoming events and emits only one event pe We follow these MSRV rules: -- The current MSRV is **1.85**. +- The current MSRV is **1.88**. - MSRV bumps do NOT require a major release and may happen in minor releases. - The MSRV may be updated when needed, but support for the current stable Rust release and the previous two stable releases (N, N-1, N-2) is always guaranteed. - For example, if the current stable version is 1.85, we guarantee support for 1.85, 1.84, and 1.83, so the minimum supported Rust version will be **at most** 1.83. diff --git a/notify-debouncer-mini/src/lib.rs b/notify-debouncer-mini/src/lib.rs index 45398f23..a5bd8835 100644 --- a/notify-debouncer-mini/src/lib.rs +++ b/notify-debouncer-mini/src/lib.rs @@ -127,6 +127,7 @@ impl Config { /// Set timeout /// /// Timeout is the amount of time after which a debounced event is emitted or a continuous event is send, if there still are events incoming for the specific path. + #[must_use] pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self @@ -135,11 +136,13 @@ impl Config { /// /// When `batch_mode` is enabled, events may be delayed (at most 2x the specified timeout) and delivered with others. /// If disabled, all events are delivered immediately when their debounce timeout is reached. + #[must_use] pub fn with_batch_mode(mut self, batch_mode: bool) -> Self { self.batch_mode = batch_mode; self } /// Set [`notify::Config`] for the backend + #[must_use] pub fn with_notify_config(mut self, notify_config: notify::Config) -> Self { self.notify_config = notify_config; self diff --git a/notify-types/CHANGELOG.md b/notify-types/CHANGELOG.md index cef64ba7..7d3ae24f 100644 --- a/notify-types/CHANGELOG.md +++ b/notify-types/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## notify-types 2.1.1 (unreleased) + +- CHANGE: raise MSRV to 1.88 +- CHANGE: add `#[must_use]` annotations to `EventKind` predicates plus `Event` and `EventAttributes` getter/builder APIs + ## notify-types 2.1.0 (2026-01-25) - FEATURE: add `EventKindMask` for filtering filesystem events [#736] diff --git a/notify-types/src/debouncer_full.rs b/notify-types/src/debouncer_full.rs index dd29ccb0..2b59eb35 100644 --- a/notify-types/src/debouncer_full.rs +++ b/notify-types/src/debouncer_full.rs @@ -19,6 +19,7 @@ pub struct DebouncedEvent { } impl DebouncedEvent { + #[must_use] pub fn new(event: Event, time: Instant) -> Self { Self { event, time } } diff --git a/notify-types/src/debouncer_mini.rs b/notify-types/src/debouncer_mini.rs index 99397f51..47b16855 100644 --- a/notify-types/src/debouncer_mini.rs +++ b/notify-types/src/debouncer_mini.rs @@ -28,6 +28,7 @@ pub struct DebouncedEvent { impl DebouncedEvent { #[inline(always)] + #[must_use] pub fn new(path: PathBuf, kind: DebouncedEventKind) -> Self { Self { path, kind } } diff --git a/notify-types/src/event.rs b/notify-types/src/event.rs index 50ba3099..a7acad4f 100644 --- a/notify-types/src/event.rs +++ b/notify-types/src/event.rs @@ -249,26 +249,31 @@ pub enum EventKind { impl EventKind { /// Indicates whether an event is an Access variant. + #[must_use] pub fn is_access(&self) -> bool { matches!(self, EventKind::Access(_)) } /// Indicates whether an event is a Create variant. + #[must_use] pub fn is_create(&self) -> bool { matches!(self, EventKind::Create(_)) } /// Indicates whether an event is a Modify variant. + #[must_use] pub fn is_modify(&self) -> bool { matches!(self, EventKind::Modify(_)) } /// Indicates whether an event is a Remove variant. + #[must_use] pub fn is_remove(&self) -> bool { matches!(self, EventKind::Remove(_)) } /// Indicates whether an event is an Other variant. + #[must_use] pub fn is_other(&self) -> bool { matches!(self, EventKind::Other) } @@ -334,10 +339,10 @@ bitflags! { const ALL_ACCESS = Self::ACCESS_OPEN.bits() | Self::ACCESS_CLOSE.bits() | Self::ACCESS_CLOSE_NOWRITE.bits(); /// Core events: create, remove, and all modify events. - /// This is the default and matches the current notify behavior (no access events). const CORE = Self::CREATE.bits() | Self::REMOVE.bits() | Self::ALL_MODIFY.bits(); /// All events including access events. + /// This is the default. const ALL = Self::CORE.bits() | Self::ALL_ACCESS.bits(); } } @@ -368,6 +373,7 @@ impl EventKindMask { /// assert!(empty.matches(&EventKind::Any)); /// assert!(empty.matches(&EventKind::Other)); /// ``` + #[must_use] pub fn matches(&self, kind: &EventKind) -> bool { match kind { // Meta-events always pass @@ -452,6 +458,10 @@ pub struct Event { /// Paths the event is about, if known. /// + /// Notify backends report paths using the same root representation that was passed to + /// `Watcher::watch`. For example, watching `src` reports paths like `src/lib.rs`, while + /// watching an absolute path reports absolute event paths. + /// /// If an event concerns two or more paths, and the paths are known at the time of event /// creation, they should all go in this `Vec`. Otherwise, using the `Tracker` attr may be more /// appropriate. @@ -560,16 +570,19 @@ struct EventAttributesInner { impl EventAttributes { /// Creates a new `EventAttributes`. + #[must_use] pub fn new() -> Self { Self { inner: None } } /// Retrieves the tracker ID for an event directly, if present. + #[must_use] pub fn tracker(&self) -> Option { self.inner.as_ref().and_then(|inner| inner.tracker) } /// Retrieves the Notify flag for an event directly, if present. + #[must_use] pub fn flag(&self) -> Option { self.inner.as_ref().and_then(|inner| inner.flag) } @@ -603,11 +616,13 @@ impl EventAttributes { /// - `is: symlink`, `is: hardlink`, `is: clone` /// - `meta: finder info` /// - `override` (debouncer-generated synthetic events) + #[must_use] pub fn info(&self) -> Option<&str> { self.inner.as_ref().and_then(|inner| inner.info.as_deref()) } /// Retrieves the source for an event directly, if present. + #[must_use] pub fn source(&self) -> Option<&str> { self.inner .as_ref() @@ -618,6 +633,7 @@ impl EventAttributes { /// /// This attribute is experimental and, while included in Notify itself, is not considered /// stable or standard enough to be part of the serde, eq, hash, and debug representations. + #[must_use] pub fn process_id(&self) -> Option { self.inner.as_ref().and_then(|inner| inner.process_id) } @@ -675,30 +691,36 @@ impl Event { /// folder might have been modified. /// /// See [`Flag::Rescan`] for more information. + #[must_use] pub fn need_rescan(&self) -> bool { matches!(self.flag(), Some(Flag::Rescan)) } /// Retrieves the tracker ID for an event directly, if present. + #[must_use] pub fn tracker(&self) -> Option { self.attrs.tracker() } /// Retrieves the Notify flag for an event directly, if present. + #[must_use] pub fn flag(&self) -> Option { self.attrs.flag() } /// Returns [`EventAttributes::info`]. + #[must_use] pub fn info(&self) -> Option<&str> { self.attrs.info() } /// Retrieves the source for an event directly, if present. + #[must_use] pub fn source(&self) -> Option<&str> { self.attrs.source() } /// Creates a new `Event` given a kind. + #[must_use] pub fn new(kind: EventKind) -> Self { Self { kind, @@ -708,18 +730,21 @@ impl Event { } /// Sets the kind. + #[must_use] pub fn set_kind(mut self, kind: EventKind) -> Self { self.kind = kind; self } /// Adds a path to the event. + #[must_use] pub fn add_path(mut self, path: PathBuf) -> Self { self.paths.push(path); self } /// Adds a path to the event if the argument is Some. + #[must_use] pub fn add_some_path(self, path: Option) -> Self { if let Some(path) = path { self.add_path(path) @@ -729,24 +754,28 @@ impl Event { } /// Sets the tracker. + #[must_use] pub fn set_tracker(mut self, tracker: usize) -> Self { self.attrs.set_tracker(tracker); self } /// Sets [`EventAttributes::info`]. + #[must_use] pub fn set_info(mut self, info: &str) -> Self { self.attrs.set_info(info); self } /// Sets the Notify flag onto the event. + #[must_use] pub fn set_flag(mut self, flag: Flag) -> Self { self.attrs.set_flag(flag); self } /// Sets the process id onto the event. + #[must_use] pub fn set_process_id(mut self, process_id: u32) -> Self { self.attrs.set_process_id(process_id); self diff --git a/notify/CHANGELOG.md b/notify/CHANGELOG.md index 169416db..af4ad062 100644 --- a/notify/CHANGELOG.md +++ b/notify/CHANGELOG.md @@ -1,6 +1,42 @@ # Changelog -## notify 9.0.0 (unreleased) +## unreleased + +- FEATURE: [macOS] add `Config::with_fsevent_latency` to configure FSEvents stream latency [#930] +- FIX: [windows] emit a Remove event when a watched directory is deleted, matching inotify and FSEvents +- FIX: [windows] surface `ReadDirectoryChangesW` read-start failures [#935] +- FEATURE: [windows] report created file/folder kinds when they can be determined [#935] +- CHANGE: [macOS] improve FSEvents callback performance by avoiding unnecessary allocations and repeated handler locking +- PERF: [kqueue] avoid filesystem walks for recursive kqueue unwatch + +[#930]: https://github.com/notify-rs/notify/pull/930 +[#935]: https://github.com/notify-rs/notify/issues/935 + +## notify 9.0.0-rc.4 (2026-05-02) + +- CHANGE: preserve watched path representation in `Event.paths` and `Watcher::watched_paths`; relative watch paths now produce relative event paths consistently across backends [#453] [#740] +- FIX: [kqueue] stop reporting arbitrary existing child paths for non-recursive directory write events [#644] +- FIX: replace an existing watch when `watch` is called again for the same path, avoiding duplicate FSEvent paths and leaked Windows watch handles [#708] +- DOCS: define `Watcher::watch` replacement behavior for an existing backend-resolved path [#708] + +## notify 9.0.0-rc.3 (2026-04-16) + +- CHANGE: raise MSRV to 1.88 +- FEATURE: add `Watcher::watched_paths` to list active watches as `(PathBuf, RecursiveMode)` pairs across supported backends +- FIX: [windows] normalize emitted event paths to follow the watched path separator style and trim leading separators; add `Config::with_windows_path_separator_style` for explicit control [#375] +- FIX: [windows] make `unwatch()` wait until the watch is fully removed so later filesystem changes do not leak events [#730] +- FIX: [macOS] annotate FSEvents clone-related events with `info = "is: clone"` [#465] +- FIX: avoid panicking in `unwatch` when internal mutexes are poisoned +- CHANGE: add `#[must_use]` annotations to builder, constructor, and getter-style APIs such as `Config`, `PathOp`, and `Error` + +[#375]: https://github.com/notify-rs/notify/issues/375 +[#465]: https://github.com/notify-rs/notify/issues/465 +[#730]: https://github.com/notify-rs/notify/issues/730 +[#739]: https://github.com/notify-rs/notify/issues/739 +[#453]: https://github.com/notify-rs/notify/issues/453 +[#740]: https://github.com/notify-rs/notify/issues/740 +[#644]: https://github.com/notify-rs/notify/issues/644 +[#708]: https://github.com/notify-rs/notify/issues/708 ## notify 9.0.0-rc.2 (2026-02-14) @@ -165,7 +201,7 @@ ## notify 5.0.0 (2022-08-28) -For a list of changes when upgrading from v4 see [UPGRADING_V4_TO_V5.md](../UPGRADING_V4_TO_V5.md). +For a list of changes when upgrading from v4 see [UPGRADING_V4_TO_V5.md](../docs/UPGRADING_V4_TO_V5.md). Differences to 5.0.0-pre.16: diff --git a/notify/Cargo.toml b/notify/Cargo.toml index c756b7c1..5648f9e1 100644 --- a/notify/Cargo.toml +++ b/notify/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notify" -version = "9.0.0-rc.2" +version = "9.0.0-rc.4" description = "Cross-platform filesystem notification library" documentation = "https://docs.rs/notify" readme = "../README.md" diff --git a/notify/src/config.rs b/notify/src/config.rs index 7bffab53..20b99071 100644 --- a/notify/src/config.rs +++ b/notify/src/config.rs @@ -25,6 +25,25 @@ impl RecursiveMode { } } +/// Controls how path separators are represented in emitted event paths on Windows. +/// +/// This applies to [`ReadDirectoryChangesWatcher`](crate::ReadDirectoryChangesWatcher). +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default)] +pub enum WindowsPathSeparatorStyle { + /// Infer separator style from each watched input path. + /// + /// For example, watching `C:/tmp` yields paths with `/`, while watching `C:\tmp` + /// yields paths with `\`. + #[default] + Auto, + + /// Always use `/` in emitted event paths. + Slash, + + /// Always use `\` in emitted event paths. + Backslash, +} + /// Watcher Backend configuration /// /// This contains multiple settings that may relate to only one specific backend, @@ -51,6 +70,12 @@ pub struct Config { /// See [Config::with_event_kinds] event_kinds: EventKindMask, + + /// See [Config::with_windows_path_separator_style] + windows_path_separator_style: WindowsPathSeparatorStyle, + + /// See [Config::with_fsevent_latency] + fsevent_latency: Duration, } impl Config { @@ -62,6 +87,7 @@ impl Config { /// The default poll frequency is 30 seconds. /// /// This will enable automatic polling, overwriting [`with_manual_polling()`](Config::with_manual_polling). + #[must_use] pub fn with_poll_interval(mut self, dur: Duration) -> Self { // TODO: v7.0 break signature to option self.poll_interval = Some(dur); @@ -69,6 +95,7 @@ impl Config { } /// Returns current setting + #[must_use] pub fn poll_interval(&self) -> Option { // Changed Signature to Option self.poll_interval @@ -79,6 +106,7 @@ impl Config { /// Disable automatic polling. Requires calling [`crate::PollWatcher::poll()`] manually. /// /// This will disable automatic polling, overwriting [`with_poll_interval()`](Config::with_poll_interval). + #[must_use] pub fn with_manual_polling(mut self) -> Self { self.poll_interval = None; self @@ -94,12 +122,14 @@ impl Config { /// need to be read and hashed at each `poll_interval`. /// /// This can't be changed during runtime. Off by default. + #[must_use] pub fn with_compare_contents(mut self, compare_contents: bool) -> Self { self.compare_contents = compare_contents; self } /// Returns current setting + #[must_use] pub fn compare_contents(&self) -> bool { self.compare_contents } @@ -110,12 +140,14 @@ impl Config { /// Determine if symbolic links should be followed when recursively watching a directory. /// /// This can't be changed during runtime. On by default. + #[must_use] pub fn with_follow_symlinks(mut self, follow_symlinks: bool) -> Self { self.follow_symlinks = follow_symlinks; self } /// Returns current setting + #[must_use] pub fn follow_symlinks(&self) -> bool { self.follow_symlinks } @@ -146,15 +178,63 @@ impl Config { /// let config_all = Config::default() /// .with_event_kinds(EventKindMask::ALL); /// ``` + #[must_use] pub fn with_event_kinds(mut self, event_kinds: EventKindMask) -> Self { self.event_kinds = event_kinds; self } /// Returns current setting + #[must_use] pub fn event_kinds(&self) -> EventKindMask { self.event_kinds } + + /// For the [`ReadDirectoryChangesWatcher`](crate::ReadDirectoryChangesWatcher) backend. + /// + /// Controls path separator normalization for emitted event paths on Windows. + /// + /// The default is [`WindowsPathSeparatorStyle::Auto`], which preserves the + /// separator style implied by each watched input path. + /// + /// This can't be changed during runtime. + #[must_use] + pub fn with_windows_path_separator_style(mut self, style: WindowsPathSeparatorStyle) -> Self { + self.windows_path_separator_style = style; + self + } + + /// Returns current setting. + #[must_use] + pub fn windows_path_separator_style(&self) -> WindowsPathSeparatorStyle { + self.windows_path_separator_style + } + + /// For the [`FsEventWatcher`](crate::FsEventWatcher) backend. + /// + /// The latency passed to `FSEventStreamCreate`: how long the FSEvents service waits + /// after hearing about an event from the kernel before invoking the callback. A larger + /// value lets the system coalesce bursts of changes into fewer callbacks at the cost of + /// higher delivery latency. + /// + /// This watcher is created with `kFSEventStreamCreateFlagNoDefer`, so the first event in + /// an idle period is delivered immediately; only subsequent events get coalesced within + /// the latency window. + /// + /// The default is [`Duration::ZERO`]. + /// + /// This can't be changed during runtime. + #[must_use] + pub fn with_fsevent_latency(mut self, latency: Duration) -> Self { + self.fsevent_latency = latency; + self + } + + /// Returns current setting + #[must_use] + pub fn fsevent_latency(&self) -> Duration { + self.fsevent_latency + } } impl Default for Config { @@ -164,6 +244,8 @@ impl Default for Config { compare_contents: false, follow_symlinks: true, event_kinds: EventKindMask::ALL, + windows_path_separator_style: WindowsPathSeparatorStyle::Auto, + fsevent_latency: Duration::ZERO, } } } @@ -179,17 +261,20 @@ pub struct WatchPathConfig { impl WatchPathConfig { /// Creates new instance with provided [`RecursiveMode`] + #[must_use] pub fn new(recursive_mode: RecursiveMode) -> Self { Self { recursive_mode } } /// Set [`RecursiveMode`] for the watch + #[must_use] pub fn with_recursive_mode(mut self, recursive_mode: RecursiveMode) -> Self { self.recursive_mode = recursive_mode; self } /// Returns current setting + #[must_use] pub fn recursive_mode(&self) -> RecursiveMode { self.recursive_mode } @@ -209,11 +294,13 @@ pub enum PathOp { impl PathOp { /// Watch the path with [`RecursiveMode::Recursive`] + #[must_use] pub fn watch_recursive>(path: P) -> Self { Self::Watch(path.into(), WatchPathConfig::new(RecursiveMode::Recursive)) } /// Watch the path with [`RecursiveMode::NonRecursive`] + #[must_use] pub fn watch_non_recursive>(path: P) -> Self { Self::Watch( path.into(), @@ -222,11 +309,13 @@ impl PathOp { } /// Unwatch the path + #[must_use] pub fn unwatch>(path: P) -> Self { Self::Unwatch(path.into()) } /// Returns the path associated with this operation. + #[must_use] pub fn as_path(&self) -> &Path { match self { PathOp::Watch(p, _) => p, @@ -235,6 +324,7 @@ impl PathOp { } /// Returns the path associated with this operation. + #[must_use] pub fn into_path(self) -> PathBuf { match self { PathOp::Watch(p, _) => p, @@ -279,4 +369,35 @@ mod tests { assert_eq!(EventKindMask::default(), Config::default().event_kinds()); assert_eq!(EventKindMask::default(), EventKindMask::ALL); } + + #[test] + fn config_default_windows_separator_style_is_auto() { + let config = Config::default(); + assert_eq!( + config.windows_path_separator_style(), + WindowsPathSeparatorStyle::Auto + ); + } + + #[test] + fn config_with_windows_separator_style() { + let config = + Config::default().with_windows_path_separator_style(WindowsPathSeparatorStyle::Slash); + assert_eq!( + config.windows_path_separator_style(), + WindowsPathSeparatorStyle::Slash + ); + } + + #[test] + fn config_default_fsevent_latency_is_zero() { + assert_eq!(Config::default().fsevent_latency(), Duration::ZERO); + } + + #[test] + fn config_with_fsevent_latency() { + let latency = Duration::from_millis(250); + let config = Config::default().with_fsevent_latency(latency); + assert_eq!(config.fsevent_latency(), latency); + } } diff --git a/notify/src/error.rs b/notify/src/error.rs index 3d9f60ad..01f524b9 100644 --- a/notify/src/error.rs +++ b/notify/src/error.rs @@ -53,18 +53,21 @@ pub struct Error { impl Error { /// Adds a path to the error. + #[must_use] pub fn add_path(mut self, path: PathBuf) -> Self { self.paths.push(path); self } /// Replaces the paths for the error. + #[must_use] pub fn set_paths(mut self, paths: Vec) -> Self { self.paths = paths; self } /// Creates a new Error with empty paths given its kind. + #[must_use] pub fn new(kind: ErrorKind) -> Self { Self { kind, @@ -73,16 +76,19 @@ impl Error { } /// Creates a new generic Error from a message. + #[must_use] pub fn generic(msg: &str) -> Self { Self::new(ErrorKind::Generic(msg.into())) } /// Creates a new i/o Error from a stdlib `io::Error`. + #[must_use] pub fn io(err: io::Error) -> Self { Self::new(ErrorKind::Io(err)) } /// Similar to [`Error::io`], but specifically handles [`io::ErrorKind::NotFound`]. + #[must_use] pub fn io_watch(err: io::Error) -> Self { if err.kind() == io::ErrorKind::NotFound { Self::path_not_found() @@ -92,16 +98,19 @@ impl Error { } /// Creates a new "path not found" error. + #[must_use] pub fn path_not_found() -> Self { Self::new(ErrorKind::PathNotFound) } /// Creates a new "watch not found" error. + #[must_use] pub fn watch_not_found() -> Self { Self::new(ErrorKind::WatchNotFound) } /// Creates a new "invalid config" error from the given `Config`. + #[must_use] pub fn invalid_config(config: &Config) -> Self { Self::new(ErrorKind::InvalidConfig(*config)) } @@ -112,14 +121,14 @@ impl fmt::Display for Error { let error = match self.kind { ErrorKind::PathNotFound => "No path was found.".into(), ErrorKind::WatchNotFound => "No watch was found.".into(), - ErrorKind::InvalidConfig(ref config) => format!("Invalid configuration: {:?}", config), + ErrorKind::InvalidConfig(ref config) => format!("Invalid configuration: {config:?}"), ErrorKind::Generic(ref err) => err.clone(), ErrorKind::Io(ref err) => err.to_string(), ErrorKind::MaxFilesWatch => "OS file watch limit reached.".into(), }; if self.paths.is_empty() { - write!(f, "{}", error) + write!(f, "{error}") } else { write!(f, "{} about {:?}", error, self.paths) } @@ -143,19 +152,19 @@ impl From for Error { impl From> for Error { fn from(err: std::sync::mpsc::SendError) -> Self { - Error::generic(&format!("internal channel disconnect: {:?}", err)) + Error::generic(&format!("internal channel disconnect: {err:?}")) } } impl From for Error { fn from(err: std::sync::mpsc::RecvError) -> Self { - Error::generic(&format!("internal channel disconnect: {:?}", err)) + Error::generic(&format!("internal channel disconnect: {err:?}")) } } impl From> for Error { fn from(err: std::sync::PoisonError) -> Self { - Error::generic(&format!("internal mutex poisoned: {:?}", err)) + Error::generic(&format!("internal mutex poisoned: {err:?}")) } } diff --git a/notify/src/fsevent.rs b/notify/src/fsevent.rs index 36bf22e9..788884cf 100644 --- a/notify/src/fsevent.rs +++ b/notify/src/fsevent.rs @@ -14,6 +14,7 @@ #![allow(non_upper_case_globals, dead_code)] +use crate::paths::{absolute_path, reported_path}; use crate::{event::*, PathOp}; use crate::{ unbounded, Config, Error, EventHandler, EventKindMask, RecursiveMode, Result, Sender, Watcher, @@ -26,7 +27,6 @@ use std::fmt; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::ptr::{self, NonNull}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; @@ -69,14 +69,20 @@ pub struct FsEventWatcher { flags: fs::FSEventStreamCreateFlags, event_handler: Arc>, runloop: Option, - recursive_info: HashMap, + recursive_info: HashMap, event_kinds: EventKindMask, } +#[derive(Clone, Debug)] +struct WatchInfo { + is_recursive: bool, + reported_path: PathBuf, +} + #[derive(Debug)] struct RunLoopHandle { runloop: cf::CFRetained, - stop_flag: Arc, + stop_source: cf::CFRetained, thread_handle: thread::JoinHandle<()>, } @@ -103,7 +109,37 @@ unsafe impl Sync for FsEventWatcher {} fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { let mut evs = Vec::new(); + translate_flags_with(flags, precise, |ev| evs.push(ev)); + evs +} +// Keep this in sync with `translate_flags_with`; the callback uses it to avoid path clones. +fn translated_event_count(flags: &StreamFlags, precise: bool) -> usize { + if flags.contains(StreamFlags::HISTORY_DONE) { + return 0; + } + + let mut count = usize::from(flags.contains(StreamFlags::MUST_SCAN_SUBDIRS)); + if !precise { + return count + 1; + } + + let root_changed = flags.contains(StreamFlags::ROOT_CHANGED); + count += usize::from(root_changed); + count += usize::from(flags.contains(StreamFlags::MOUNT)); + count += usize::from(flags.contains(StreamFlags::UNMOUNT)); + count += usize::from(flags.contains(StreamFlags::ITEM_CREATED)); + count += usize::from(flags.contains(StreamFlags::ITEM_RENAMED) && !root_changed); + count += usize::from(flags.contains(StreamFlags::INODE_META_MOD)); + count += usize::from(flags.contains(StreamFlags::FINDER_INFO_MOD)); + count += usize::from(flags.contains(StreamFlags::ITEM_CHANGE_OWNER)); + count += usize::from(flags.contains(StreamFlags::ITEM_XATTR_MOD)); + count += usize::from(flags.contains(StreamFlags::ITEM_MODIFIED)); + count += usize::from(flags.contains(StreamFlags::ITEM_REMOVED) && !root_changed); + count +} + +fn translate_flags_with(flags: StreamFlags, precise: bool, mut emit: impl FnMut(Event)) { // «Denotes a sentinel event sent to mark the end of the "historical" events // sent as a result of specifying a `sinceWhen` value in the FSEvents.Create // call that created this event stream. After invoking the client's callback @@ -115,8 +151,28 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { // As a result, we just stop processing here and return an empty vec, which // will ignore this completely and not emit any Events whatsoever. if flags.contains(StreamFlags::HISTORY_DONE) { - return evs; - } + return; + } + + // `ITEM_CLONED` can be present alongside other flags (including create/modify/remove). + // Preserve any existing `info` (like "root changed"), but annotate otherwise so downstream + // can detect and filter clone-related events. See https://github.com/notify-rs/notify/issues/465. + let clone_related = precise && flags.contains(StreamFlags::ITEM_CLONED); + let own_process_id = if precise && flags.contains(StreamFlags::OWN_EVENT) { + Some(std::process::id()) + } else { + None + }; + + let mut emit_event = |mut ev: Event| { + if clone_related && ev.info().is_none() { + ev.attrs.set_info("is: clone"); + } + if let Some(process_id) = own_process_id { + ev.attrs.set_process_id(process_id); + } + emit(ev); + }; // FSEvents provides two possible hints as to why events were dropped, // however documentation on what those mean is scant, so we just pass them @@ -124,7 +180,7 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { // additional information is provided if the user wants it. if flags.contains(StreamFlags::MUST_SCAN_SUBDIRS) { let e = Event::new(EventKind::Other).set_flag(Flag::Rescan); - evs.push(if flags.contains(StreamFlags::USER_DROPPED) { + emit_event(if flags.contains(StreamFlags::USER_DROPPED) { e.set_info("rescan: user dropped") } else if flags.contains(StreamFlags::KERNEL_DROPPED) { e.set_info("rescan: kernel dropped") @@ -136,8 +192,8 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { // In imprecise mode, let's not even bother parsing the kind of the event // except for the above very special events. if !precise { - evs.push(Event::new(EventKind::Any)); - return evs; + emit(Event::new(EventKind::Any)); + return; } // A watched root changed (renamed or removed). If the flags provide a hint, @@ -159,21 +215,21 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { EventKind::Remove(RemoveKind::Any) }; - evs.push(Event::new(kind).set_info("root changed")); + emit_event(Event::new(kind).set_info("root changed")); } // A path was mounted at the event path; we treat that as a create. if flags.contains(StreamFlags::MOUNT) { - evs.push(Event::new(EventKind::Create(CreateKind::Other)).set_info("mount")); + emit_event(Event::new(EventKind::Create(CreateKind::Other)).set_info("mount")); } // A path was unmounted at the event path; we treat that as a remove. if flags.contains(StreamFlags::UNMOUNT) { - evs.push(Event::new(EventKind::Remove(RemoveKind::Other)).set_info("mount")); + emit_event(Event::new(EventKind::Remove(RemoveKind::Other)).set_info("mount")); } if flags.contains(StreamFlags::ITEM_CREATED) { - evs.push(if flags.contains(StreamFlags::IS_DIR) { + emit_event(if flags.contains(StreamFlags::IS_DIR) { Event::new(EventKind::Create(CreateKind::Folder)) } else if flags.contains(StreamFlags::IS_FILE) { Event::new(EventKind::Create(CreateKind::File)) @@ -195,7 +251,7 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { // rename event. // Avoid emitting duplicate events around a root change by checking `root_changed`. if flags.contains(StreamFlags::ITEM_RENAMED) && !root_changed { - evs.push(Event::new(EventKind::Modify(ModifyKind::Name( + emit_event(Event::new(EventKind::Modify(ModifyKind::Name( RenameMode::Any, )))); } @@ -204,26 +260,26 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { // only emitted for some more precise subset of events... if so, will need // amending, but for now we have an Any-shaped bucket to put it in. if flags.contains(StreamFlags::INODE_META_MOD) { - evs.push(Event::new(EventKind::Modify(ModifyKind::Metadata( + emit_event(Event::new(EventKind::Modify(ModifyKind::Metadata( MetadataKind::Any, )))); } if flags.contains(StreamFlags::FINDER_INFO_MOD) { - evs.push( + emit_event( Event::new(EventKind::Modify(ModifyKind::Metadata(MetadataKind::Other))) .set_info("meta: finder info"), ); } if flags.contains(StreamFlags::ITEM_CHANGE_OWNER) { - evs.push(Event::new(EventKind::Modify(ModifyKind::Metadata( + emit_event(Event::new(EventKind::Modify(ModifyKind::Metadata( MetadataKind::Ownership, )))); } if flags.contains(StreamFlags::ITEM_XATTR_MOD) { - evs.push(Event::new(EventKind::Modify(ModifyKind::Metadata( + emit_event(Event::new(EventKind::Modify(ModifyKind::Metadata( MetadataKind::Extended, )))); } @@ -231,14 +287,14 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { // This is specifically described as a data change, which we take to mean // is a content change. if flags.contains(StreamFlags::ITEM_MODIFIED) { - evs.push(Event::new(EventKind::Modify(ModifyKind::Data( + emit_event(Event::new(EventKind::Modify(ModifyKind::Data( DataChange::Content, )))); } // Avoid emitting duplicate events around a root change by checking `root_changed`. if flags.contains(StreamFlags::ITEM_REMOVED) && !root_changed { - evs.push(if flags.contains(StreamFlags::IS_DIR) { + emit_event(if flags.contains(StreamFlags::IS_DIR) { Event::new(EventKind::Remove(RemoveKind::Folder)) } else if flags.contains(StreamFlags::IS_FILE) { Event::new(EventKind::Remove(RemoveKind::File)) @@ -255,19 +311,11 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { } }); } - - if flags.contains(StreamFlags::OWN_EVENT) { - for ev in &mut evs { - *ev = std::mem::take(ev).set_process_id(std::process::id()); - } - } - - evs } struct StreamContextInfo { event_handler: Arc>, - recursive_info: HashMap, + recursive_info: HashMap, event_kinds: EventKindMask, } @@ -286,15 +334,24 @@ unsafe extern "C-unwind" fn release_context(info: *const libc::c_void) { } } +// Runs on the watcher thread, inside the running runloop, where +// `CFRunLoopStop` is guaranteed to take effect. +unsafe extern "C-unwind" fn stop_runloop_perform(_info: *mut std::ffi::c_void) { + if let Some(runloop) = cf::CFRunLoop::current() { + runloop.stop(); + } +} + impl FsEventWatcher { fn from_event_handler( event_handler: Arc>, event_kinds: EventKindMask, + latency: cf::CFTimeInterval, ) -> Result { Ok(FsEventWatcher { paths: cf::CFMutableArray::empty(), since_when: fs::kFSEventStreamEventIdSinceNow, - latency: 0.0, + latency, flags: fs::kFSEventStreamCreateFlagFileEvents | fs::kFSEventStreamCreateFlagNoDefer | fs::kFSEventStreamCreateFlagWatchRoot, @@ -364,14 +421,19 @@ impl FsEventWatcher { if let Some(RunLoopHandle { runloop, - stop_flag, + stop_source, thread_handle, }) = self.runloop.take() { - // Don't wait for the runloop to become "waiting" before stopping; if the - // stream is under heavy load that can delay shutdown indefinitely. - stop_flag.store(true, Ordering::Release); - runloop.stop(); + // Calling `CFRunLoopStop` directly here would race: it only takes effect + // while the runloop is actually running, so a stop landing in the window + // before the watcher thread enters `CFRunLoopRun` would be lost and the + // `join` below would deadlock. Signaling a runloop source instead is + // sticky: the signal stays pending until the loop runs, and the source's + // `perform` callback then stops the loop from the inside, where the stop + // cannot be lost. The wake-up covers the case where the loop is already + // asleep. + stop_source.signal(); runloop.wake_up(); // Wait for the thread to shut down. thread_handle.join().expect("thread to shut down"); @@ -379,6 +441,26 @@ impl FsEventWatcher { } fn remove_path(&mut self, path: &Path) -> Result<()> { + let p = path + .canonicalize() + .ok() + .or_else(|| { + self.recursive_info + .iter() + .find(|(_, info)| info.reported_path == path) + .map(|(path, _)| path.clone()) + }) + .or_else(|| absolute_path(path).ok()) + .unwrap_or_else(|| path.to_owned()); + self.remove_cf_path(&p)?; + + match self.recursive_info.remove(&p) { + Some(_) => Ok(()), + None => Err(Error::watch_not_found()), + } + } + + fn remove_cf_path(&mut self, path: &Path) -> Result<()> { let mut err: *mut cf::CFError = ptr::null_mut(); let Some(cf_path) = (unsafe { path_to_cfstring_ref(path, &mut err) }) else { if let Some(err) = NonNull::new(err) { @@ -403,16 +485,7 @@ impl FsEventWatcher { cf::CFMutableArray::remove_value_at_index(Some(self.paths.as_opaque()), *idx) }; } - - let p = if let Ok(canonicalized_path) = path.canonicalize() { - canonicalized_path - } else { - path.to_owned() - }; - match self.recursive_info.remove(&p) { - Some(_) => Ok(()), - None => Err(Error::watch_not_found()), - } + Ok(()) } // https://github.com/thibaudgg/rb-fsevent/blob/master/ext/fsevent_watch/main.c @@ -422,7 +495,7 @@ impl FsEventWatcher { } let canonical_path = path.to_path_buf().canonicalize()?; let mut err: *mut cf::CFError = ptr::null_mut(); - let Some(cf_path) = (unsafe { path_to_cfstring_ref(path, &mut err) }) else { + let Some(cf_path) = (unsafe { path_to_cfstring_ref(&canonical_path, &mut err) }) else { if let Some(err) = NonNull::new(err) { let _ = unsafe { cf::CFRetained::from_raw(err) }; } @@ -430,10 +503,18 @@ impl FsEventWatcher { // while the above code was running. return Err(Error::path_not_found().add_path(path.into())); }; + if self.recursive_info.contains_key(&canonical_path) { + self.remove_cf_path(&canonical_path)?; + } self.paths.append(&cf_path); - self.recursive_info - .insert(canonical_path, recursive_mode.is_recursive()); + self.recursive_info.insert( + canonical_path, + WatchInfo { + is_recursive: recursive_mode.is_recursive(), + reported_path: path.to_path_buf(), + }, + ); Ok(()) } @@ -473,10 +554,14 @@ impl FsEventWatcher { }; // Wrapper to help send CFRunLoop types across threads. - struct CFRunLoopSendWrapper(cf::CFRetained); + struct CFRunLoopSendWrapper( + cf::CFRetained, + cf::CFRetained, + ); // Safety: - // - According to the Apple documentation, it's safe to move `CFRunLoop`s across threads. + // - According to the Apple documentation, it's safe to move `CFRunLoop`s and + // `CFRunLoopSource`s across threads. // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafetySummary/ThreadSafetySummary.html unsafe impl Send for CFRunLoopSendWrapper {} @@ -493,11 +578,6 @@ impl FsEventWatcher { // channel to pass runloop around let (rl_tx, rl_rx) = unbounded(); - // Used to stop the runloop thread without relying on privileged APIs or - // on `CFRunLoopIsWaiting()` becoming true under heavy event load. - let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_flag_thread = Arc::clone(&stop_flag); - let thread_handle = thread::Builder::new() .name("notify-rs fsevents loop".to_string()) .spawn(move || { @@ -526,27 +606,67 @@ impl FsEventWatcher { return; } - // `stop()` will call `CFRunLoopStop` + `CFRunLoopWakeUp` and then join this - // thread. + // The source through which stop() asks this thread to shut down. It + // must be created and added to the runloop before the handles are + // published below, so the caller can never signal a source that is + // not registered yet. + let mut stop_source_context = cf::CFRunLoopSourceContext { + version: 0, + info: ptr::null_mut(), + retain: None, + release: None, + copyDescription: None, + equal: None, + hash: None, + schedule: None, + cancel: None, + perform: Some(stop_runloop_perform), + }; + let stop_source = cf::CFRunLoopSource::new( + cf::kCFAllocatorDefault, + 0, + &mut stop_source_context, + ) + .expect("Failed to create stop source"); + cur_runloop.add_source(Some(&stop_source), cf::kCFRunLoopDefaultMode); + + // `stop()` will signal `stop_source`, wake the runloop, and then join + // this thread. rl_tx - .send(Ok(CFRunLoopSendWrapper(cur_runloop))) + .send(Ok(CFRunLoopSendWrapper(cur_runloop, stop_source.clone()))) .expect("Unable to send runloop to watcher"); - // Avoid polling the runloop: block indefinitely until `CFRunLoopStop` is - // called (or until the runloop is otherwise finished). - if !stop_flag_thread.load(Ordering::Acquire) { - cf::CFRunLoop::run(); - } + // Block until the pending signal on `stop_source` (if any) or a later + // one stops the loop from the inside; see `stop()`. + cf::CFRunLoop::run(); + + stop_source.invalidate(); fs::FSEventStreamStop(stream); fs::FSEventStreamInvalidate(stream); fs::FSEventStreamRelease(stream); } })?; // block until runloop has been sent - let runloop_wrapper = rl_rx.recv().unwrap()?; + let runloop_wrapper = match rl_rx.recv() { + Ok(Ok(runloop_wrapper)) => runloop_wrapper, + Ok(Err(err)) => { + thread_handle + .join() + .expect("thread to shut down after FSEvent stream startup failure"); + return Err(err); + } + Err(_) => { + thread_handle + .join() + .expect("thread to shut down after FSEvent stream startup channel close"); + return Err(Error::generic( + "unable to receive FSEvent stream startup result", + )); + } + }; self.runloop = Some(RunLoopHandle { runloop: runloop_wrapper.0, - stop_flag, + stop_source: runloop_wrapper.1, thread_handle, }); @@ -594,12 +714,14 @@ unsafe fn callback_impl( ) { let event_paths = event_paths.as_ptr() as *const *const libc::c_char; let info = info as *const StreamContextInfo; - let event_handler = &(*info).event_handler; + let event_handler_mutex = &(*info).event_handler; + let event_kinds = (*info).event_kinds; + let mut event_handler_guard = None; for p in 0..num_events { // Paths are not guaranteed to be valid UTF-8 (e.g. NFS); keep them as raw bytes. let path = CStr::from_ptr(*event_paths.add(p)); - let path = PathBuf::from(OsStr::from_bytes(path.to_bytes())); + let path = Path::new(OsStr::from_bytes(path.to_bytes())); let raw_flag = *event_flags.as_ptr().add(p) as u32; let flag = StreamFlags::from_bits_truncate(raw_flag); @@ -609,38 +731,64 @@ unsafe fn callback_impl( log::trace!("unknown FSEventStreamEventFlags bits: 0x{unknown_bits:08x}"); } - let mut handle_event = false; - for (p, r) in &(*info).recursive_info { - if path.starts_with(p) { - if *r || &path == p { - handle_event = true; - break; + let mut watch_match = None; + for (watch_path, watch_info) in &(*info).recursive_info { + if path.starts_with(watch_path) { + let matches_watch = if watch_info.is_recursive || path == watch_path { + true } else if let Some(parent_path) = path.parent() { - if parent_path == p { - handle_event = true; - break; - } + parent_path == watch_path + } else { + false + }; + + if matches_watch + && watch_match.as_ref().is_none_or( + |(matched_path, _): &(&PathBuf, &WatchInfo)| { + watch_path.as_os_str().as_bytes().len() + > matched_path.as_os_str().as_bytes().len() + }, + ) + { + watch_match = Some((watch_path, watch_info)); } } } - if !handle_event { + let Some((watch_path, watch_info)) = watch_match else { + continue; + }; + let translated_count = translated_event_count(&flag, true); + if translated_count == 0 { continue; } + // Most FSEvents flags produce one Event; move the reported path in that case. + let mut event_path = Some(reported_path(watch_path, &watch_info.reported_path, path)); + let single_translated_event = translated_count == 1; log::trace!("FSEvent: path = `{}`, flag = {:?}", path.display(), flag); - for ev in translate_flags(flag, true).into_iter() { - // TODO: precise - let ev = ev.add_path(path.clone()); - // Filter events based on EventKindMask - if !(*info).event_kinds.matches(&ev.kind) { - continue; // Skip events that don't match the mask + translate_flags_with(flag, true, |mut ev| { + // Filter events based on EventKindMask before adding the path. + if !event_kinds.matches(&ev.kind) { + return; } - let mut event_handler = match event_handler.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + if single_translated_event { + ev.paths.push( + event_path.take().unwrap_or_else(|| { + reported_path(watch_path, &watch_info.reported_path, path) + }), + ); + } else { + ev.paths + .push(event_path.as_ref().expect("translated event path").clone()); + } + + let event_handler = + event_handler_guard.get_or_insert_with(|| match event_handler_mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }); // Protect against panicking event handlers, which would otherwise unwind into // the CoreServices callback. let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -649,14 +797,18 @@ unsafe fn callback_impl( .map_err(|_| { log::error!("panic in FSEvents event handler; dropping event"); }); - } + }); } } impl Watcher for FsEventWatcher { /// Create a new watcher. fn new(event_handler: F, config: Config) -> Result { - Self::from_event_handler(Arc::new(Mutex::new(event_handler)), config.event_kinds()) + Self::from_event_handler( + Arc::new(Mutex::new(event_handler)), + config.event_kinds(), + config.fsevent_latency().as_secs_f64(), + ) } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { @@ -677,6 +829,26 @@ impl Watcher for FsEventWatcher { rx.recv()? } + fn watched_paths(&self) -> Result> { + // Unlike the channel-based backends, FSEvents keeps watch state on the watcher itself. + // The runloop callback gets a cloned snapshot in `StreamContextInfo`, so it does not + // mutate or read this map concurrently. + Ok(self + .recursive_info + .iter() + .map(|(_path, info)| { + ( + info.reported_path.clone(), + if info.is_recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }, + ) + }) + .collect()) + } + fn kind() -> crate::WatcherKind { crate::WatcherKind::Fsevent } @@ -738,6 +910,26 @@ mod tests { channel() } + #[test] + fn rewatching_same_path_replaces_recursive_info() { + let dir = tempfile::tempdir().unwrap(); + let mut watcher = FsEventWatcher::new(|_| {}, Config::default()).unwrap(); + + watcher + .append_path(dir.path(), RecursiveMode::Recursive) + .expect("watch recursively"); + watcher + .append_path(dir.path(), RecursiveMode::NonRecursive) + .expect("rewatch non-recursively"); + + let watched = watcher.watched_paths().expect("watched paths"); + assert_eq!( + watched, + vec![(dir.path().to_path_buf(), RecursiveMode::NonRecursive)] + ); + assert_eq!(watcher.paths.iter().count(), 1); + } + #[test] fn stop_does_not_wait_for_runloop_to_be_waiting() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -837,6 +1029,108 @@ mod tests { // sensitive on some systems. Dropping the last reference to the runloop will release it. } + // Regression test for a lost `CFRunLoopStop`: stopping is a no-op while the + // runloop thread is between publishing its handles and actually entering + // `CFRunLoopRun`, so a single stop could leave the thread parked forever and + // deadlock the join in `stop()`. Rapid watch/unwatch cycles maximize pressure + // on that window. + #[test] + fn rapid_watch_unwatch_does_not_hang() { + use std::sync::mpsc; + + let tmpdir = tempfile::tempdir().unwrap(); + let dir_a = tmpdir.path().join("a"); + let dir_b = tmpdir.path().join("b"); + std::fs::create_dir(&dir_a).expect("create_dir a"); + std::fs::create_dir(&dir_b).expect("create_dir b"); + + let (done_tx, done_rx) = mpsc::channel::<()>(); + let stress_thread = thread::spawn(move || { + let (tx, _rx) = mpsc::channel::>(); + let mut watcher = FsEventWatcher::new(tx, Default::default()).unwrap(); + for _ in 0..500 { + // Errors are tolerated: under load (e.g. the 4096-path test running + // concurrently) fseventsd transiently refuses stream starts even for + // tiny path sets. The property under test is purely that none of + // these operations hangs. + let _ = watcher.watch(&dir_a, RecursiveMode::NonRecursive); + let _ = watcher.watch(&dir_b, RecursiveMode::NonRecursive); + let _ = watcher.unwatch(&dir_a); + let _ = watcher.unwatch(&dir_b); + } + let _ = done_tx.send(()); + }); + + done_rx + .recv_timeout(Duration::from_secs(120)) + .expect("rapid watch/unwatch timed out (lost CFRunLoopStop?)"); + stress_thread.join().expect("stress thread to shut down"); + } + + // Deterministic test of the race window above: a stop source signaled + // before `CFRunLoopRun` is entered must still stop the loop, because + // source signals stay pending until the loop processes them. + #[test] + fn stop_source_signaled_before_runloop_run_still_stops_loop() { + use std::sync::mpsc; + + struct CFSendWrapper(cf::CFRetained); + unsafe impl Send for CFSendWrapper {} + + let (handles_tx, handles_rx) = mpsc::channel(); + let (signaled_tx, signaled_rx) = mpsc::channel::<()>(); + let (done_tx, done_rx) = mpsc::channel::<()>(); + + let loop_thread = thread::spawn(move || { + let cur_runloop = cf::CFRunLoop::current().expect("current runloop"); + + let mut stop_source_context = cf::CFRunLoopSourceContext { + version: 0, + info: ptr::null_mut(), + retain: None, + release: None, + copyDescription: None, + equal: None, + hash: None, + schedule: None, + cancel: None, + perform: Some(stop_runloop_perform), + }; + let stop_source = unsafe { + cf::CFRunLoopSource::new(cf::kCFAllocatorDefault, 0, &mut stop_source_context) + .expect("stop source to be created") + }; + let mode = unsafe { cf::kCFRunLoopDefaultMode.expect("default runloop mode") }; + cur_runloop.add_source(Some(&stop_source), Some(mode)); + + handles_tx + .send(( + CFSendWrapper(cur_runloop), + CFSendWrapper(stop_source.clone()), + )) + .expect("send runloop handles"); + + signaled_rx + .recv() + .expect("wait for the stop source to be signaled"); + + cf::CFRunLoop::run(); + + stop_source.invalidate(); + let _ = done_tx.send(()); + }); + + let (runloop, stop_source) = handles_rx.recv().expect("receive runloop handles"); + stop_source.0.signal(); + runloop.0.wake_up(); + signaled_tx.send(()).expect("release the loop thread"); + + done_rx + .recv_timeout(Duration::from_secs(10)) + .expect("CFRunLoopRun did not exit; pre-run stop source signal was lost"); + loop_thread.join().expect("loop thread to shut down"); + } + #[test] fn test_fsevent_watcher_drop() { use super::*; @@ -883,7 +1177,13 @@ mod tests { let event_handler: Arc> = Arc::new(Mutex::new(tx)); let mut recursive_info = HashMap::new(); - recursive_info.insert(PathBuf::from("/tmp"), true); + recursive_info.insert( + PathBuf::from("/tmp"), + WatchInfo { + is_recursive: true, + reported_path: PathBuf::from("/tmp"), + }, + ); let context = Box::new(StreamContextInfo { event_handler, @@ -941,7 +1241,13 @@ mod tests { let event_handler: Arc> = Arc::new(Mutex::new(tx)); let mut recursive_info = HashMap::new(); - recursive_info.insert(PathBuf::from("/tmp"), true); + recursive_info.insert( + PathBuf::from("/tmp"), + WatchInfo { + is_recursive: true, + reported_path: PathBuf::from("/tmp"), + }, + ); let context = Box::new(StreamContextInfo { event_handler, @@ -998,6 +1304,58 @@ mod tests { ); } + #[test] + fn translate_flags_ignores_is_file_only_events() { + assert!(translate_flags(StreamFlags::IS_FILE, true).is_empty()); + assert!( + translate_flags(StreamFlags::IS_FILE | StreamFlags::ITEM_CLONED, true).is_empty(), + "type-only clone flags should not produce events" + ); + } + + #[test] + fn translate_flags_sets_clone_info_for_file_events() { + let create = translate_flags( + StreamFlags::ITEM_CREATED | StreamFlags::IS_FILE | StreamFlags::ITEM_CLONED, + true, + ); + assert_eq!(create.len(), 1); + assert_eq!(create[0].kind, EventKind::Create(CreateKind::File)); + assert_eq!(create[0].info(), Some("is: clone")); + + let modify = translate_flags( + StreamFlags::INODE_META_MOD + | StreamFlags::ITEM_MODIFIED + | StreamFlags::IS_FILE + | StreamFlags::ITEM_CLONED, + true, + ); + assert_eq!(modify.len(), 2); + assert!(modify + .iter() + .any(|e| matches!(e.kind, EventKind::Modify(ModifyKind::Metadata(_))))); + assert!(modify + .iter() + .any(|e| matches!(e.kind, EventKind::Modify(ModifyKind::Data(_))))); + assert!( + modify.iter().all(|e| e.info() == Some("is: clone")), + "all events should be annotated as clone-related: {modify:?}" + ); + } + + #[test] + fn translate_flags_does_not_override_existing_info() { + let evs = translate_flags( + StreamFlags::ROOT_CHANGED + | StreamFlags::ITEM_REMOVED + | StreamFlags::IS_FILE + | StreamFlags::ITEM_CLONED, + true, + ); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].info(), Some("root changed")); + } + #[test] fn does_not_crash_with_empty_path() { let mut watcher = FsEventWatcher::new(|_| {}, Default::default()).unwrap(); @@ -1115,6 +1473,20 @@ mod tests { rx.wait_unordered([expected(file).remove_file()]); } + #[test] + fn delete_self_dir() { + let tmpdir = testdir(); + let dir = tmpdir.path().join("dir"); + std::fs::create_dir(&dir).expect("create"); + + let (mut watcher, mut rx) = watcher(); + watcher.watch_nonrecursively(&dir); + + std::fs::remove_dir(&dir).expect("remove"); + + rx.wait_unordered([expected(&dir).remove_folder()]); + } + #[test] fn create_write_overwrite() { let tmpdir = testdir(); @@ -1441,6 +1813,17 @@ mod tests { } assert!(watcher.watcher.update_paths(paths).is_err()); + + // Best-effort cleanup: on macOS + recent rustc, `remove_dir_all` can + // panic with `closedir: Bad file descriptor` while tearing down the + // 4097 directories created above (likely an interaction with fsevents + // having held FDs on those paths). Bypass `TempDir`'s Drop and swallow + // the potential panic so the test does not flake. + let path = tmpdir.path().to_path_buf(); + std::mem::forget(tmpdir); + let _ = std::panic::catch_unwind(|| { + let _ = std::fs::remove_dir_all(&path); + }); } #[test] diff --git a/notify/src/inotify.rs b/notify/src/inotify.rs index c28e9318..82692543 100644 --- a/notify/src/inotify.rs +++ b/notify/src/inotify.rs @@ -6,12 +6,15 @@ use super::event::*; use super::{Config, Error, ErrorKind, EventHandler, RecursiveMode, Result, Watcher}; +use crate::paths::{ + absolute_path, is_preserved_watch_root, preserved_watch_mode, preserved_watch_roots, + recursive_user_watch_ancestor, reported_path, WatchMetadata, WatchPath, +}; use crate::{bounded, unbounded, BoundSender, Receiver, Sender}; use inotify as inotify_sys; use inotify_sys::{EventMask, Inotify, WatchDescriptor, WatchMask}; use notify_types::event::EventKindMask; use std::collections::HashMap; -use std::env; use std::ffi::OsStr; use std::fs::metadata; use std::os::unix::io::AsRawFd; @@ -86,7 +89,7 @@ struct EventLoop { event_loop_rx: Receiver, inotify: Option, event_handler: Box, - /// PathBuf -> (WatchDescriptor, WatchMask, is_recursive, is_dir) + /// Absolute path -> inotify descriptor and watch metadata. watches: HashMap, paths: HashMap, rename_event: Option, @@ -97,8 +100,8 @@ struct EventLoop { struct Watch { watch_descriptor: WatchDescriptor, watch_mask: WatchMask, - is_recursive: bool, is_dir: bool, + metadata: WatchMetadata, } /// Watcher implementation based on inotify @@ -109,8 +112,9 @@ pub struct INotifyWatcher { } enum EventLoopMsg { - AddWatch(PathBuf, RecursiveMode, Sender>), + AddWatch(WatchPath, RecursiveMode, Sender>), RemoveWatch(PathBuf, Sender>), + GetWatchedPaths(Sender>), Shutdown, Configure(Config, BoundSender>), } @@ -120,13 +124,16 @@ fn add_watch_by_event( path: &PathBuf, event: &inotify_sys::Event<&OsStr>, watches: &HashMap, - add_watches: &mut Vec, + add_watches: &mut Vec, ) { if event.mask.contains(EventMask::ISDIR) { if let Some(parent_path) = path.parent() { if let Some(watch) = watches.get(parent_path) { - if watch.is_recursive { - add_watches.push(path.to_owned()); + if watch.metadata.is_recursive { + add_watches.push(WatchPath::from_parts( + path.to_owned(), + reported_path(parent_path, &watch.metadata.reported_path, path), + )); } } } @@ -200,7 +207,7 @@ impl EventLoop { // System call was interrupted, we will retry // TODO: Not covered by tests (to reproduce likely need to setup signal handlers) } - Err(e) => panic!("poll failed: {}", e), + Err(e) => panic!("poll failed: {e}"), Ok(()) => {} } @@ -240,6 +247,24 @@ impl EventLoop { EventLoopMsg::RemoveWatch(path, tx) => { let _ = tx.send(self.remove_watch(path, false)); } + EventLoopMsg::GetWatchedPaths(tx) => { + let _ = tx.send( + self.watches + .iter() + .filter(|(_path, watch)| watch.metadata.is_user_watch) + .map(|(_path, watch)| { + ( + watch.metadata.reported_path.clone(), + if watch.metadata.user_is_recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }, + ) + }) + .collect(), + ); + } EventLoopMsg::Shutdown => { let _ = self.remove_all_watches(); if let Some(inotify) = self.inotify.take() { @@ -281,13 +306,23 @@ impl EventLoop { self.event_handler.handle_event(ev); } - let path = match event.name { - Some(name) => self.paths.get(&event.wd).map(|root| root.join(name)), - None => self.paths.get(&event.wd).cloned(), - }; - - let path = match path { - Some(path) => path, + let paths = self.paths.get(&event.wd).and_then(|root| { + self.watches.get(root).map(|watch| match event.name { + Some(name) => { + let path = root.join(name); + let reported_path = reported_path( + root, + &watch.metadata.reported_path, + &path, + ); + (path, reported_path) + } + None => (root.clone(), watch.metadata.reported_path.clone()), + }) + }); + + let (path, event_path) = match paths { + Some(paths) => paths, None => { log::debug!("inotify event with unknown descriptor: {event:?}"); continue; @@ -302,7 +337,7 @@ impl EventLoop { let event = Event::new(EventKind::Modify(ModifyKind::Name( RenameMode::From, ))) - .add_path(path.clone()) + .add_path(event_path.clone()) .set_tracker(event.cookie as usize); self.rename_event = Some(event.clone()); @@ -312,7 +347,7 @@ impl EventLoop { evs.push( Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))) .set_tracker(event.cookie as usize) - .add_path(path.clone()), + .add_path(event_path.clone()), ); let trackers_match = @@ -327,7 +362,7 @@ impl EventLoop { ))) .set_tracker(event.cookie as usize) .add_some_path(rename_event.paths.first().cloned()) - .add_path(path.clone()), + .add_path(event_path.clone()), ); } add_watch_by_event(&path, &event, &self.watches, &mut add_watches); @@ -337,7 +372,7 @@ impl EventLoop { Event::new(EventKind::Modify(ModifyKind::Name( RenameMode::From, ))) - .add_path(path.clone()), + .add_path(event_path.clone()), ); // TODO stat the path and get to new path // - emit To and Both events @@ -352,7 +387,7 @@ impl EventLoop { CreateKind::File }, )) - .add_path(path.clone()), + .add_path(event_path.clone()), ); add_watch_by_event(&path, &event, &self.watches, &mut add_watches); } @@ -365,7 +400,7 @@ impl EventLoop { RemoveKind::File }, )) - .add_path(path.clone()), + .add_path(event_path.clone()), ); remove_watch_by_event(&path, &self.watches, &mut remove_watches); } @@ -377,12 +412,12 @@ impl EventLoop { }; evs.push( Event::new(EventKind::Remove(remove_kind)) - .add_path(path.clone()), + .add_path(event_path.clone()), ); remove_watch_by_event(&path, &self.watches, &mut remove_watches); } if event.mask.contains(EventMask::UNMOUNT) { - evs.push(unmount_event(path.clone())); + evs.push(unmount_event(event_path.clone())); // The kernel has already removed this watch descriptor and will // emit IGNORED; clean up internal state without inotify_rm_watch. // ref. https://www.man7.org/linux/man-pages/man7/inotify.7.html @@ -397,7 +432,7 @@ impl EventLoop { Event::new(EventKind::Modify(ModifyKind::Data( DataChange::Any, ))) - .add_path(path.clone()), + .add_path(event_path.clone()), ); } if event.mask.contains(EventMask::CLOSE_WRITE) { @@ -405,7 +440,7 @@ impl EventLoop { Event::new(EventKind::Access(AccessKind::Close( AccessMode::Write, ))) - .add_path(path.clone()), + .add_path(event_path.clone()), ); } if event.mask.contains(EventMask::CLOSE_NOWRITE) { @@ -413,7 +448,7 @@ impl EventLoop { Event::new(EventKind::Access(AccessKind::Close( AccessMode::Read, ))) - .add_path(path.clone()), + .add_path(event_path.clone()), ); } if event.mask.contains(EventMask::ATTRIB) { @@ -421,7 +456,7 @@ impl EventLoop { Event::new(EventKind::Modify(ModifyKind::Metadata( MetadataKind::Any, ))) - .add_path(path.clone()), + .add_path(event_path.clone()), ); } if event.mask.contains(EventMask::OPEN) { @@ -429,7 +464,7 @@ impl EventLoop { Event::new(EventKind::Access(AccessKind::Open( AccessMode::Any, ))) - .add_path(path.clone()), + .add_path(event_path.clone()), ); } @@ -486,18 +521,70 @@ impl EventLoop { } } - fn add_watch(&mut self, path: PathBuf, is_recursive: bool, watch_self: bool) -> Result<()> { + fn add_watch(&mut self, path: WatchPath, is_recursive: bool, watch_self: bool) -> Result<()> { + let path_is_dir = metadata(&path.absolute).map_err(Error::io_watch)?.is_dir(); + let requested_is_recursive = is_recursive && path_is_dir; + if watch_self { + if let Some(watch) = self + .watches + .get(&path.absolute) + .filter(|watch| watch.metadata.is_user_watch) + { + if watch.metadata.user_is_recursive == requested_is_recursive + && watch.metadata.reported_path == path.requested + { + return Ok(()); + } + + // Rewatching an explicit user watch replaces its requested mode and reported path + // instead of merging with the previous metadata. If the current entry also carries + // recursive coverage from an ancestor, remember that ancestor before removal so we + // can rebuild that inherited coverage below. + let inherited_recursive_root = + if !requested_is_recursive && path_is_dir && watch.metadata.is_recursive { + recursive_user_watch_ancestor( + &path.absolute, + self.watches + .iter() + .map(|(path, watch)| (path, &watch.metadata)), + ) + } else { + None + }; + let replaced_path = path.absolute.clone(); + self.remove_watch(replaced_path.clone(), false)?; + + if let Some((ancestor_path, ancestor_reported_path)) = inherited_recursive_root { + // Removing a directory watch removes its recursively inherited children too. + // Re-add them as non-user watches so the ancestor recursive watch still covers + // this subtree after the user watch is replaced. + let entries = WalkDir::new(&replaced_path) + .follow_links(self.follow_links) + .into_iter() + .filter_map(filter_dir) + .map(|entry| { + let absolute = entry.into_path(); + let requested = + reported_path(&ancestor_path, &ancestor_reported_path, &absolute); + WatchPath::from_parts(absolute, requested) + }); + self.add_watches_for_paths(entries, true, false)?; + } + } + } + // If the watch is not recursive, or if we determine (by stat'ing the path to get its // metadata) that the watched path is not a directory, add a single path watch. - if !is_recursive || !metadata(&path).map_err(Error::io_watch)?.is_dir() { + if !requested_is_recursive { return self.add_single_watch(path, false, true); } - let entries = WalkDir::new(path) + let root = path.clone(); + let entries = WalkDir::new(&root.absolute) .follow_links(self.follow_links) .into_iter() .filter_map(filter_dir) - .map(|entry| entry.into_path()); + .map(move |entry| root.child(entry.into_path())); self.add_watches_for_paths(entries, is_recursive, watch_self) } @@ -509,7 +596,7 @@ impl EventLoop { mut watch_self: bool, ) -> Result<()> where - I: IntoIterator, + I: IntoIterator, { for path in paths { match self.add_single_watch(path, is_recursive, watch_self) { @@ -527,7 +614,7 @@ impl EventLoop { fn add_single_watch( &mut self, - path: PathBuf, + path: WatchPath, is_recursive: bool, watch_self: bool, ) -> Result<()> { @@ -539,15 +626,16 @@ impl EventLoop { watchmask.insert(WatchMask::MOVE_SELF); } - if let Some(watch) = self.watches.get(&path) { + let existing_watch = self.watches.get(&path.absolute); + if let Some(watch) = existing_watch { watchmask.insert(watch.watch_mask); watchmask.insert(WatchMask::MASK_ADD); } if let Some(ref mut inotify) = self.inotify { - log::trace!("adding inotify watch: {}", path.display()); + log::trace!("adding inotify watch: {}", path.absolute.display()); - match inotify.watches().add(&path, watchmask) { + match inotify.watches().add(&path.absolute, watchmask) { Err(e) => { Err(if e.raw_os_error() == Some(libc::ENOSPC) { // do not report inotify limits as "no more space" on linux #266 @@ -557,29 +645,48 @@ impl EventLoop { } else { Error::io(e) } - .add_path(path)) + .add_path(path.requested)) } Ok(w) => { watchmask.remove(WatchMask::MASK_ADD); - let is_dir = match metadata(&path) { + let is_dir = match metadata(&path.absolute) { Ok(metadata) => metadata.is_dir(), Err(e) => { // Avoid leaking an inotify watch if we can't stat after adding it. // This can happen due to racy deletions. let _ = inotify.watches().remove(w.clone()); - return Err(Error::io_watch(e).add_path(path)); + return Err(Error::io_watch(e).add_path(path.requested)); + } + }; + let metadata = if let Some(existing_watch) = existing_watch { + WatchMetadata::new( + &path, + is_recursive, + watch_self, + Some(&existing_watch.metadata), + self.watches + .iter() + .map(|(path, watch)| (path, &watch.metadata)), + ) + } else { + WatchMetadata { + is_recursive, + reported_path: path.requested.clone(), + is_user_watch: watch_self, + user_is_recursive: watch_self && is_recursive, } }; + self.watches.insert( - path.clone(), + path.absolute.clone(), Watch { watch_descriptor: w.clone(), watch_mask: watchmask, - is_recursive, is_dir, + metadata, }, ); - self.paths.insert(w, path); + self.paths.insert(w, path.absolute); Ok(()) } } @@ -589,6 +696,14 @@ impl EventLoop { } fn remove_watch(&mut self, path: PathBuf, remove_recursive: bool) -> Result<()> { + let preserved_roots = preserved_watch_roots( + &path, + remove_recursive, + self.watches + .iter() + .map(|(path, watch)| (path, &watch.metadata)), + ); + match self.watches.remove(&path) { None => return Err(Error::watch_not_found().add_path(path)), Some(watch) => { @@ -602,10 +717,22 @@ impl EventLoop { ); self.paths.remove(&watch.watch_descriptor); - if watch.is_recursive || remove_recursive { + if watch.metadata.is_recursive || remove_recursive { let mut remove_list = Vec::new(); + let mut reset_list = Vec::new(); for (w, p) in &self.paths { if p.starts_with(&path) { + if let Some(user_is_recursive) = + preserved_watch_mode(p, &preserved_roots) + { + if !user_is_recursive + || is_preserved_watch_root(p, &preserved_roots) + { + reset_list.push(p.clone()); + } + continue; + } + Self::remove_single_descriptor(&mut inotify_watches, w.clone()); self.watches.remove(p); remove_list.push(w.clone()); @@ -614,6 +741,11 @@ impl EventLoop { for w in remove_list { self.paths.remove(&w); } + for p in reset_list { + if let Some(watch) = self.watches.get_mut(&p) { + watch.metadata.is_recursive = watch.metadata.user_is_recursive; + } + } } } } @@ -626,15 +758,35 @@ impl EventLoop { path: PathBuf, remove_recursive: bool, ) -> Result<()> { + let preserved_roots = preserved_watch_roots( + &path, + remove_recursive, + self.watches + .iter() + .map(|(path, watch)| (path, &watch.metadata)), + ); + match self.watches.remove(&path) { None => return Err(Error::watch_not_found().add_path(path)), Some(watch) => { self.paths.remove(&watch.watch_descriptor); - if watch.is_recursive || remove_recursive { + if watch.metadata.is_recursive || remove_recursive { let mut remove_list = Vec::new(); + let mut reset_list = Vec::new(); for (w, p) in &self.paths { if p.starts_with(&path) { + if let Some(user_is_recursive) = + preserved_watch_mode(p, &preserved_roots) + { + if !user_is_recursive + || is_preserved_watch_root(p, &preserved_roots) + { + reset_list.push(p.clone()); + } + continue; + } + self.watches.remove(p); remove_list.push(w.clone()); } @@ -642,6 +794,11 @@ impl EventLoop { for w in remove_list { self.paths.remove(&w); } + for p in reset_list { + if let Some(watch) = self.watches.get_mut(&p) { + watch.metadata.is_recursive = watch.metadata.user_is_recursive; + } + } } } } @@ -694,10 +851,8 @@ impl EventLoop { /// return `DirEntry` when it is a directory fn filter_dir(e: walkdir::Result) -> Option { if let Ok(e) = e { - if let Ok(metadata) = e.metadata() { - if metadata.is_dir() { - return Some(e); - } + if e.file_type().is_dir() { + return Some(e); } } None @@ -714,35 +869,30 @@ impl INotifyWatcher { } fn watch_inner(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { - let pb = if path.is_absolute() { - path.to_owned() - } else { - let p = env::current_dir().map_err(Error::io)?; - p.join(path) - }; + let pb = WatchPath::new(path)?; let (tx, rx) = unbounded(); let msg = EventLoopMsg::AddWatch(pb, recursive_mode, tx); - // we expect the event loop to live and reply => unwraps must not panic - self.channel.send(msg).unwrap(); - self.waker.wake().unwrap(); - rx.recv().unwrap() + self.channel.send(msg)?; + self.waker.wake()?; + rx.recv().map_err(Error::from)? } fn unwatch_inner(&mut self, path: &Path) -> Result<()> { - let pb = if path.is_absolute() { - path.to_owned() - } else { - let p = env::current_dir().map_err(Error::io)?; - p.join(path) - }; + let pb = absolute_path(path)?; let (tx, rx) = unbounded(); let msg = EventLoopMsg::RemoveWatch(pb, tx); - // we expect the event loop to live and reply => unwraps must not panic - self.channel.send(msg).unwrap(); - self.waker.wake().unwrap(); - rx.recv().unwrap() + self.channel.send(msg)?; + self.waker.wake()?; + rx.recv().map_err(Error::from)? + } + + fn watched_paths_inner(&self) -> Result> { + let (tx, rx) = unbounded(); + self.channel.send(EventLoopMsg::GetWatchedPaths(tx))?; + self.waker.wake()?; + rx.recv().map_err(Error::from) } } @@ -767,6 +917,10 @@ impl Watcher for INotifyWatcher { rx.recv()? } + fn watched_paths(&self) -> Result> { + self.watched_paths_inner() + } + fn kind() -> crate::WatcherKind { crate::WatcherKind::Inotify } @@ -792,7 +946,7 @@ mod tests { use super::inotify_sys::WatchMask; use super::{ Config, Error, ErrorKind, Event, EventKind, EventLoop, INotifyWatcher, RecursiveMode, - Result, Watcher, + Result, WatchPath, Watcher, }; use notify_types::event::{EventKindMask, RemoveKind}; @@ -852,13 +1006,126 @@ mod tests { // Simulate the TOCTOU: we *intend* to watch a subdirectory discovered during initial scan, // but it's already gone by the time we call `inotify_add_watch`. - let result = event_loop.add_watches_for_paths(vec![root, disappearing], true, true); + let result = event_loop.add_watches_for_paths( + [root, disappearing] + .into_iter() + .map(|path| WatchPath::new(&path).unwrap()), + true, + true, + ); assert!( result.is_ok(), "expected recursive watch to succeed, got: {result:?}" ); } + #[test] + fn rewatching_same_path_replaces_recursive_state() { + let tmpdir = tempfile::tempdir().unwrap(); + let root = tmpdir.path().to_path_buf(); + let child = root.join("child"); + std::fs::create_dir(&child).unwrap(); + + let inotify = super::inotify_sys::Inotify::init().unwrap(); + let mut event_loop = EventLoop::new(inotify, Box::new(|_| {}), &Config::default()).unwrap(); + + event_loop + .add_watch(WatchPath::new(&root).unwrap(), true, true) + .expect("watch recursively"); + assert!(event_loop.watches.contains_key(&child)); + + event_loop + .add_watch(WatchPath::new(&root).unwrap(), false, true) + .expect("rewatch non-recursively"); + + let watch = event_loop.watches.get(&root).expect("root watch"); + assert!(watch.metadata.is_user_watch); + assert!(!watch.metadata.user_is_recursive); + assert!(!watch.metadata.is_recursive); + assert!(!event_loop.watches.contains_key(&child)); + } + + #[test] + fn rewatching_child_preserves_recursive_parent_state() { + let tmpdir = tempfile::tempdir().unwrap(); + let root = tmpdir.path().to_path_buf(); + let child = root.join("child"); + let grandchild = child.join("grandchild"); + std::fs::create_dir_all(&grandchild).unwrap(); + + let inotify = super::inotify_sys::Inotify::init().unwrap(); + let mut event_loop = EventLoop::new(inotify, Box::new(|_| {}), &Config::default()).unwrap(); + + event_loop + .add_watch(WatchPath::new(&root).unwrap(), true, true) + .expect("watch root recursively"); + event_loop + .add_watch(WatchPath::new(&child).unwrap(), false, true) + .expect("watch child non-recursively"); + event_loop + .add_watch( + WatchPath::from_parts(child.clone(), PathBuf::from("reported-child")), + false, + true, + ) + .expect("rewatch child non-recursively"); + + let child_watch = event_loop.watches.get(&child).expect("child watch"); + assert!(child_watch.metadata.is_user_watch); + assert!(!child_watch.metadata.user_is_recursive); + assert!(child_watch.metadata.is_recursive); + assert_eq!( + child_watch.metadata.reported_path, + PathBuf::from("reported-child") + ); + + let grandchild_watch = event_loop + .watches + .get(&grandchild) + .expect("grandchild still covered by recursive parent"); + assert!(!grandchild_watch.metadata.is_user_watch); + assert!(grandchild_watch.metadata.is_recursive); + } + + #[test] + fn rewatching_carved_out_child_does_not_restore_parent_recursive_state() { + let tmpdir = tempfile::tempdir().unwrap(); + let root = tmpdir.path().to_path_buf(); + let child = root.join("child"); + let grandchild = child.join("grandchild"); + std::fs::create_dir_all(&grandchild).unwrap(); + + let inotify = super::inotify_sys::Inotify::init().unwrap(); + let mut event_loop = EventLoop::new(inotify, Box::new(|_| {}), &Config::default()).unwrap(); + + event_loop + .add_watch(WatchPath::new(&root).unwrap(), true, true) + .expect("watch root recursively"); + event_loop + .remove_watch(child.clone(), false) + .expect("carve out child"); + event_loop + .add_watch(WatchPath::new(&child).unwrap(), false, true) + .expect("watch child non-recursively"); + event_loop + .add_watch( + WatchPath::from_parts(child.clone(), PathBuf::from("reported-child")), + false, + true, + ) + .expect("rewatch child non-recursively"); + + let child_watch = event_loop.watches.get(&child).expect("child watch"); + assert!(child_watch.metadata.is_user_watch); + assert!(!child_watch.metadata.user_is_recursive); + assert!(!child_watch.metadata.is_recursive); + assert_eq!( + child_watch.metadata.reported_path, + PathBuf::from("reported-child") + ); + assert!(!event_loop.watches.contains_key(&grandchild)); + } + /// Runs manually. /// /// * Save actual value of the limit: `MAX_USER_WATCHES=$(sysctl -n fs.inotify.max_user_watches)` @@ -1023,7 +1290,7 @@ mod tests { let mut event_loop = EventLoop::new(inotify, Box::new(|_| {}), &Config::default()).unwrap(); event_loop - .add_watch(watched.clone(), false, true) + .add_watch(WatchPath::new(&watched).unwrap(), false, true) .expect("add_watch"); event_loop @@ -1153,6 +1420,20 @@ mod tests { ]); } + #[test] + fn delete_self_dir() { + let tmpdir = testdir(); + let dir = tmpdir.path().join("dir"); + std::fs::create_dir(&dir).expect("create"); + + let (mut watcher, mut rx) = watcher(); + watcher.watch_nonrecursively(&dir); + + std::fs::remove_dir(&dir).expect("remove"); + + rx.wait_unordered([expected(&dir).remove_folder()]); + } + #[test] fn create_write_overwrite() { let tmpdir = testdir(); diff --git a/notify/src/kqueue.rs b/notify/src/kqueue.rs index 5e5dc6e5..b1f98f54 100644 --- a/notify/src/kqueue.rs +++ b/notify/src/kqueue.rs @@ -5,11 +5,16 @@ //! pieces of kernel code termed filters. use super::event::*; -use super::{Config, Error, EventHandler, EventKindMask, RecursiveMode, Result, Watcher}; +use super::{ + Config, Error, ErrorKind, EventHandler, EventKindMask, RecursiveMode, Result, Watcher, +}; +use crate::paths::{ + absolute_path, is_preserved_watch_root, preserved_watch_mode, preserved_watch_roots, + recursive_user_watch_ancestor, reported_path, WatchMetadata as Watch, WatchPath, +}; use crate::{unbounded, Receiver, Sender}; use kqueue::{EventData, EventFilter, FilterFlag, Ident}; use std::collections::HashMap; -use std::env; use std::fs::metadata; use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; @@ -33,7 +38,7 @@ struct EventLoop { event_loop_rx: Receiver, kqueue: kqueue::Watcher, event_handler: Box, - watches: HashMap, + watches: HashMap, follow_symlinks: bool, event_kinds: EventKindMask, } @@ -46,8 +51,9 @@ pub struct KqueueWatcher { } enum EventLoopMsg { - AddWatch(PathBuf, RecursiveMode, Sender>), + AddWatch(WatchPath, RecursiveMode, Sender>), RemoveWatch(PathBuf, Sender>), + GetWatchedPaths(Sender>), Shutdown, } @@ -99,7 +105,7 @@ impl EventLoop { // System call was interrupted, we will retry // TODO: Not covered by tests (to reproduce likely need to setup signal handlers) } - Err(e) => panic!("poll failed: {}", e), + Err(e) => panic!("poll failed: {e}"), Ok(()) => {} } @@ -134,11 +140,29 @@ impl EventLoop { while let Ok(msg) = self.event_loop_rx.try_recv() { match msg { EventLoopMsg::AddWatch(path, recursive_mode, tx) => { - let _ = tx.send(self.add_watch(path, recursive_mode.is_recursive())); + let _ = tx.send(self.add_watch(path, recursive_mode.is_recursive(), true)); } EventLoopMsg::RemoveWatch(path, tx) => { let _ = tx.send(self.remove_watch(path, false)); } + EventLoopMsg::GetWatchedPaths(tx) => { + let _ = tx.send( + self.watches + .iter() + .filter(|(_path, watch)| watch.is_user_watch) + .map(|(_path, watch)| { + ( + watch.reported_path.clone(), + if watch.user_is_recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }, + ) + }) + .collect(), + ); + } EventLoopMsg::Shutdown => { self.running = false; break; @@ -160,6 +184,11 @@ impl EventLoop { ident: Ident::Filename(_, path), } => { let path = PathBuf::from(path); + let watch = self.watches.get(&path); + let event_path = watch + .map(|watch| watch.reported_path.clone()) + .unwrap_or_else(|| path.clone()); + let is_user_watch = watch.is_some_and(|watch| watch.is_user_watch); let event = match data { /* TODO: Differentiate folders and files @@ -169,13 +198,24 @@ impl EventLoop { lookup. */ kqueue::Vnode::Delete => { - remove_watches.push(path.clone()); - Ok(Event::new(EventKind::Remove(RemoveKind::Any)).add_path(path)) + remove_watches.push((path.clone(), true)); + Ok(Event::new(EventKind::Remove(RemoveKind::Any)).add_path(event_path)) } - // a write to a directory means that a new file was created in it, let's - // figure out which file this was - kqueue::Vnode::Write if path.is_dir() => { + // A write to a recursively watched directory may mean that a new file + // was created in it. Non-recursive directory watches do not track + // children, so guessing from read_dir would be unreliable. + // FIXME: harden guessing for non-recursive watches. + // Context: https://github.com/notify-rs/notify/issues/644 + kqueue::Vnode::Write + if watch.is_some_and(|watch| watch.is_recursive) + && if self.follow_symlinks { + path.is_dir() + } else { + std::fs::symlink_metadata(&path) + .is_ok_and(|metadata| metadata.is_dir()) + } => + { // find which file is new in the directory by comparing it with our // list of known watches std::fs::read_dir(&path) @@ -187,7 +227,15 @@ impl EventLoop { .map(|file| { if let Some(file) = file { // watch this new file - add_watches.push(file.clone()); + let reported_file = + reported_path(&path, &event_path, &file); + add_watches.push(( + WatchPath::from_parts( + file.clone(), + reported_file.clone(), + ), + false, + )); Event::new(EventKind::Create(if file.is_dir() { CreateKind::Folder @@ -196,12 +244,12 @@ impl EventLoop { } else { CreateKind::Other })) - .add_path(file) + .add_path(reported_file) } else { Event::new(EventKind::Modify(ModifyKind::Data( DataChange::Any, ))) - .add_path(path) + .add_path(event_path) } }) .map_err(Into::into) @@ -211,7 +259,7 @@ impl EventLoop { kqueue::Vnode::Write => Ok(Event::new(EventKind::Modify( ModifyKind::Data(DataChange::Any), )) - .add_path(path)), + .add_path(event_path)), /* Extend and Truncate are just different names for the same @@ -221,7 +269,7 @@ impl EventLoop { kqueue::Vnode::Extend | kqueue::Vnode::Truncate => Ok(Event::new( EventKind::Modify(ModifyKind::Data(DataChange::Size)), ) - .add_path(path)), + .add_path(event_path)), /* this kevent has the same problem as the delete kevent. The @@ -233,7 +281,7 @@ impl EventLoop { kqueue::Vnode::Attrib => Ok(Event::new(EventKind::Modify( ModifyKind::Metadata(MetadataKind::Any), )) - .add_path(path)), + .add_path(event_path)), /* The link count on a file changed => subdirectory created or @@ -254,25 +302,28 @@ impl EventLoop { // readd the whole directory. // This is a expensive operation, as we recursive through all // subdirectories. - remove_watches.push(path.clone()); - add_watches.push(path.clone()); - Ok(Event::new(EventKind::Modify(ModifyKind::Any)).add_path(path)) + remove_watches.push((path.clone(), false)); + add_watches.push(( + WatchPath::from_parts(path.clone(), event_path.clone()), + is_user_watch, + )); + Ok(Event::new(EventKind::Modify(ModifyKind::Any)).add_path(event_path)) } // Kqueue not provide us with the information necessary to provide // the new file name to the event. kqueue::Vnode::Rename => { - remove_watches.push(path.clone()); + remove_watches.push((path.clone(), true)); Ok( Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Any))) - .add_path(path), + .add_path(event_path), ) } // Access to the file was revoked via revoke(2) or the underlying file system was unmounted. kqueue::Vnode::Revoke => { - remove_watches.push(path.clone()); - Ok(Event::new(EventKind::Remove(RemoveKind::Any)).add_path(path)) + remove_watches.push((path.clone(), true)); + Ok(Event::new(EventKind::Remove(RemoveKind::Any)).add_path(event_path)) } // On different BSD variants, different extra events may be present @@ -293,27 +344,97 @@ impl EventLoop { } } - for path in remove_watches { - self.remove_watch(path, true).ok(); + for (path, remove_recursive) in remove_watches { + self.remove_watch(path, remove_recursive).ok(); } - for path in add_watches { - self.add_watch(path, true).ok(); + for (path, is_user_watch) in add_watches { + self.add_watch(path, true, is_user_watch).ok(); } } - fn add_watch(&mut self, path: PathBuf, is_recursive: bool) -> Result<()> { + fn add_watch( + &mut self, + path: WatchPath, + is_recursive: bool, + is_user_watch: bool, + ) -> Result<()> { + let path_is_dir = metadata(&path.absolute).map_err(Error::io)?.is_dir(); + let requested_is_recursive = is_recursive && path_is_dir; + if is_user_watch { + if let Some(watch) = self + .watches + .get(&path.absolute) + .filter(|watch| watch.is_user_watch) + { + if watch.user_is_recursive == requested_is_recursive + && watch.reported_path == path.requested + { + return Ok(()); + } + + // Rewatching an explicit user watch replaces its requested mode and reported path + // instead of merging with the previous metadata. If the current entry also carries + // recursive coverage from an ancestor, remember that ancestor before removal so we + // can rebuild that inherited coverage below. + let inherited_recursive_root = + if !requested_is_recursive && path_is_dir && watch.is_recursive { + recursive_user_watch_ancestor(&path.absolute, self.watches.iter()) + } else { + None + }; + let replaced_path = path.absolute.clone(); + self.remove_watch(replaced_path.clone(), false)?; + + if let Some((ancestor_path, ancestor_reported_path)) = inherited_recursive_root { + // Removing a directory watch removes its recursively inherited children too. + // Re-add them as non-user watches so the ancestor recursive watch still covers + // this subtree after the user watch is replaced. + for entry in WalkDir::new(&replaced_path) + .follow_links(self.follow_symlinks) + .into_iter() + { + let absolute = match entry { + Ok(entry) => entry.into_path(), + Err(err) if walkdir_error_is_not_found(&err) => continue, + Err(err) => return Err(map_walkdir_error(err)), + }; + let requested = + reported_path(&ancestor_path, &ancestor_reported_path, &absolute); + let result = self.add_single_watch( + WatchPath::from_parts(absolute, requested), + true, + false, + ); + if let Err(err) = result { + if !error_is_not_found(&err) { + return Err(err); + } + } + } + } + } + } + // If the watch is not recursive, or if we determine (by stat'ing the path to get its // metadata) that the watched path is not a directory, add a single path watch. - if !is_recursive || !metadata(&path).map_err(Error::io)?.is_dir() { - self.add_single_watch(path, false)?; + if !requested_is_recursive { + self.add_single_watch(path, false, is_user_watch)?; } else { - for entry in WalkDir::new(path) + let root = path; + let mut first = true; + for entry in WalkDir::new(&root.absolute) .follow_links(self.follow_symlinks) .into_iter() { let entry = entry.map_err(map_walkdir_error)?; - self.add_single_watch(entry.into_path(), is_recursive)?; + // WalkDir yields the root first; only it is the user-requested watch. + self.add_single_watch( + root.child(entry.into_path()), + is_recursive, + is_user_watch && first, + )?; + first = false; } } @@ -326,7 +447,12 @@ impl EventLoop { /// Adds a single watch to the kqueue. /// /// The caller of this function must call `self.kqueue.watch()` afterwards to register the new watch. - fn add_single_watch(&mut self, path: PathBuf, is_recursive: bool) -> Result<()> { + fn add_single_watch( + &mut self, + path: WatchPath, + is_recursive: bool, + is_user_watch: bool, + ) -> Result<()> { let event_filter = EventFilter::EVFILT_VNODE; let filter_flags = FilterFlag::NOTE_DELETE | FilterFlag::NOTE_WRITE @@ -336,12 +462,20 @@ impl EventLoop { | FilterFlag::NOTE_RENAME | FilterFlag::NOTE_REVOKE; - log::trace!("adding kqueue watch: {}", path.display()); + log::trace!("adding kqueue watch: {}", path.absolute.display()); self.kqueue - .add_filename(&path, event_filter, filter_flags) - .map_err(|e| Error::io(e).add_path(path.clone()))?; - self.watches.insert(path, is_recursive); + .add_filename(&path.absolute, event_filter, filter_flags) + .map_err(|e| Error::io(e).add_path(path.requested.clone()))?; + let existing_watch = self.watches.get(&path.absolute); + let watch = Watch::new( + &path, + is_recursive, + is_user_watch, + existing_watch, + self.watches.iter(), + ); + self.watches.insert(path.absolute, watch); Ok(()) } @@ -349,18 +483,41 @@ impl EventLoop { fn remove_watch(&mut self, path: PathBuf, remove_recursive: bool) -> Result<()> { log::trace!("removing kqueue watch: {}", path.display()); + let preserved_roots = preserved_watch_roots(&path, remove_recursive, self.watches.iter()); + match self.watches.remove(&path) { None => return Err(Error::watch_not_found()), - Some(is_recursive) => { - if is_recursive || remove_recursive { - for entry in WalkDir::new(path) - .follow_links(self.follow_symlinks) - .into_iter() - { - let p = entry.map_err(map_walkdir_error)?.into_path(); + Some(watch) => { + if watch.is_recursive || remove_recursive { + self.kqueue + .remove_filename(&path, EventFilter::EVFILT_VNODE) + .map_err(|e| Error::io(e).add_path(path.clone()))?; + + let mut remove_list = Vec::new(); + let mut reset_list = Vec::new(); + for p in self.watches.keys().filter(|p| p.starts_with(&path)) { + if let Some(user_is_recursive) = preserved_watch_mode(p, &preserved_roots) { + if !user_is_recursive || is_preserved_watch_root(p, &preserved_roots) { + reset_list.push(p.clone()); + } + continue; + } + + remove_list.push(p.clone()); + } + + for p in &remove_list { self.kqueue - .remove_filename(&p, EventFilter::EVFILT_VNODE) - .map_err(|e| Error::io(e).add_path(p))?; + .remove_filename(p, EventFilter::EVFILT_VNODE) + .map_err(|e| Error::io(e).add_path(p.clone()))?; + } + for p in remove_list { + self.watches.remove(&p); + } + for p in reset_list { + if let Some(watch) = self.watches.get_mut(&p) { + watch.is_recursive = watch.user_is_recursive; + } } } else { self.kqueue @@ -384,6 +541,16 @@ fn map_walkdir_error(e: walkdir::Error) -> Error { } } +fn walkdir_error_is_not_found(e: &walkdir::Error) -> bool { + e.io_error() + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) +} + +fn error_is_not_found(e: &Error) -> bool { + matches!(&e.kind, ErrorKind::PathNotFound) + || matches!(&e.kind, ErrorKind::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound) +} + impl KqueueWatcher { fn from_event_handler( event_handler: Box, @@ -399,12 +566,7 @@ impl KqueueWatcher { } fn watch_inner(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { - let pb = if path.is_absolute() { - path.to_owned() - } else { - let p = env::current_dir().map_err(Error::io)?; - p.join(path) - }; + let pb = WatchPath::new(path)?; let (tx, rx) = unbounded(); let msg = EventLoopMsg::AddWatch(pb, recursive_mode, tx); @@ -420,12 +582,7 @@ impl KqueueWatcher { } fn unwatch_inner(&mut self, path: &Path) -> Result<()> { - let pb = if path.is_absolute() { - path.to_owned() - } else { - let p = env::current_dir().map_err(Error::io)?; - p.join(path) - }; + let pb = absolute_path(path)?; let (tx, rx) = unbounded(); let msg = EventLoopMsg::RemoveWatch(pb, tx); @@ -439,6 +596,13 @@ impl KqueueWatcher { .unwrap() .map_err(|e| Error::generic(&e.to_string())) } + + fn watched_paths_inner(&self) -> Result> { + let (tx, rx) = unbounded(); + self.channel.send(EventLoopMsg::GetWatchedPaths(tx))?; + self.waker.wake()?; + rx.recv().map_err(Error::from) + } } impl Watcher for KqueueWatcher { @@ -459,6 +623,10 @@ impl Watcher for KqueueWatcher { self.unwatch_inner(path) } + fn watched_paths(&self) -> Result> { + self.watched_paths_inner() + } + fn kind() -> crate::WatcherKind { crate::WatcherKind::Kqueue } @@ -487,7 +655,7 @@ mod tests { fn test_remove_recursive() -> std::result::Result<(), Box> { let path = PathBuf::from("src"); - let mut watcher = KqueueWatcher::new(|event| println!("{:?}", event), Config::default())?; + let mut watcher = KqueueWatcher::new(|event| println!("{event:?}"), Config::default())?; watcher.watch(&path, RecursiveMode::Recursive)?; let result = watcher.unwatch(&path); assert!( @@ -498,6 +666,152 @@ mod tests { Ok(()) } + #[test] + fn internal_recursive_refresh_preserves_explicit_child( + ) -> std::result::Result<(), Box> { + let dir = tempfile::tempdir()?; + let child = dir.path().join("child"); + std::fs::create_dir(&child)?; + + let kqueue = kqueue::Watcher::new()?; + let mut event_loop = EventLoop::new(kqueue, Box::new(|_| {}), false, EventKindMask::ALL)?; + + event_loop.add_watch(WatchPath::new(dir.path())?, true, true)?; + event_loop.add_watch(WatchPath::new(&child)?, false, true)?; + + event_loop.remove_watch(dir.path().to_path_buf(), false)?; + assert!( + event_loop + .watches + .get(&child) + .is_some_and(|watch| watch.is_user_watch && !watch.user_is_recursive), + "internal refresh removed explicit child watch" + ); + + event_loop.add_watch(WatchPath::new(dir.path())?, true, true)?; + + let watched: HashMap<_, _> = event_loop + .watches + .iter() + .filter(|(_path, watch)| watch.is_user_watch) + .map(|(path, watch)| (path.clone(), watch.user_is_recursive)) + .collect(); + assert_eq!(watched.get(dir.path()), Some(&true)); + assert_eq!(watched.get(&child), Some(&false)); + + Ok(()) + } + + #[test] + fn recursive_remove_uses_tracked_watches() -> std::result::Result<(), Box> + { + let dir = tempfile::tempdir()?; + let child = dir.path().join("child"); + std::fs::write(&child, "")?; + + let kqueue = kqueue::Watcher::new()?; + let mut event_loop = EventLoop::new(kqueue, Box::new(|_| {}), false, EventKindMask::ALL)?; + + event_loop.add_watch(WatchPath::new(dir.path())?, true, true)?; + assert!(event_loop.watches.contains_key(&child)); + + std::fs::remove_file(&child)?; + event_loop.remove_watch(dir.path().to_path_buf(), false)?; + + assert!(!event_loop.watches.contains_key(&child)); + + Ok(()) + } + + #[test] + fn rewatching_same_path_replaces_recursive_state( + ) -> std::result::Result<(), Box> { + let dir = tempfile::tempdir()?; + let child = dir.path().join("child"); + std::fs::create_dir(&child)?; + + let kqueue = kqueue::Watcher::new()?; + let mut event_loop = EventLoop::new(kqueue, Box::new(|_| {}), false, EventKindMask::ALL)?; + + event_loop.add_watch(WatchPath::new(dir.path())?, true, true)?; + assert!(event_loop.watches.contains_key(&child)); + + event_loop.add_watch(WatchPath::new(dir.path())?, false, true)?; + + let watch = event_loop.watches.get(dir.path()).expect("root watch"); + assert!(watch.is_user_watch); + assert!(!watch.user_is_recursive); + assert!(!watch.is_recursive); + assert!(!event_loop.watches.contains_key(&child)); + + Ok(()) + } + + #[test] + fn rewatching_child_preserves_recursive_parent_state( + ) -> std::result::Result<(), Box> { + let dir = tempfile::tempdir()?; + let child = dir.path().join("child"); + let grandchild = child.join("grandchild"); + std::fs::create_dir_all(&grandchild)?; + + let kqueue = kqueue::Watcher::new()?; + let mut event_loop = EventLoop::new(kqueue, Box::new(|_| {}), false, EventKindMask::ALL)?; + + event_loop.add_watch(WatchPath::new(dir.path())?, true, true)?; + event_loop.add_watch(WatchPath::new(&child)?, false, true)?; + event_loop.add_watch( + WatchPath::from_parts(child.clone(), PathBuf::from("reported-child")), + false, + true, + )?; + + let child_watch = event_loop.watches.get(&child).expect("child watch"); + assert!(child_watch.is_user_watch); + assert!(!child_watch.user_is_recursive); + assert!(child_watch.is_recursive); + assert_eq!(child_watch.reported_path, PathBuf::from("reported-child")); + + let grandchild_watch = event_loop + .watches + .get(&grandchild) + .expect("grandchild still covered by recursive parent"); + assert!(!grandchild_watch.is_user_watch); + assert!(grandchild_watch.is_recursive); + + Ok(()) + } + + #[test] + fn rewatching_carved_out_child_does_not_restore_parent_recursive_state( + ) -> std::result::Result<(), Box> { + let dir = tempfile::tempdir()?; + let child = dir.path().join("child"); + let grandchild = child.join("grandchild"); + std::fs::create_dir_all(&grandchild)?; + + let kqueue = kqueue::Watcher::new()?; + let mut event_loop = EventLoop::new(kqueue, Box::new(|_| {}), false, EventKindMask::ALL)?; + + event_loop.add_watch(WatchPath::new(dir.path())?, true, true)?; + event_loop.remove_watch(child.clone(), false)?; + event_loop.add_watch(WatchPath::new(&child)?, false, true)?; + event_loop.add_watch( + WatchPath::from_parts(child.clone(), PathBuf::from("reported-child")), + false, + true, + )?; + + let child_watch = event_loop.watches.get(&child).expect("child watch"); + assert!(child_watch.is_user_watch); + assert!(!child_watch.user_is_recursive); + assert!(!child_watch.is_recursive); + assert_eq!(child_watch.reported_path, PathBuf::from("reported-child")); + assert!(!event_loop.watches.contains_key(&grandchild)); + + Ok(()) + } + #[test] fn create_file() { let tmpdir = testdir(); @@ -573,6 +887,23 @@ mod tests { rx.wait_unordered([expected(tmpdir.path()).modify_data_any()]); } + #[test] + fn create_file_in_non_recursive_directory_with_existing_child() { + let tmpdir = testdir(); + let (mut watcher, mut rx) = watcher(); + let existing = tmpdir.path().join("existing"); + let created = tmpdir.path().join("created"); + std::fs::write(&existing, "").expect("write"); + + watcher.watch_nonrecursively(&tmpdir); + + std::fs::write(&created, "").expect("write"); + + // kqueue does not report which directory entry changed, so the backend + // must not guess an arbitrary pre-existing child as the created path. + rx.wait_unordered([expected(tmpdir.path()).modify_data_any()]); + } + #[test] fn delete_self_file() { let tmpdir = testdir(); @@ -587,6 +918,20 @@ mod tests { rx.wait_unordered([expected(file).remove_any()]); } + #[test] + fn delete_self_dir() { + let tmpdir = testdir(); + let dir = tmpdir.path().join("dir"); + std::fs::create_dir(&dir).expect("create"); + + let (mut watcher, mut rx) = watcher(); + watcher.watch_nonrecursively(&dir); + + std::fs::remove_dir(&dir).expect("remove"); + + rx.wait_unordered([expected(&dir).remove_any()]); + } + #[test] #[ignore = "FIXME"] fn create_write_overwrite() { @@ -887,15 +1232,13 @@ mod tests { // Should have CREATE event assert!( events.iter().any(|e| e.kind.is_create()), - "Expected CREATE event, got: {:?}", - events + "Expected CREATE event, got: {events:?}" ); // Should NOT have MODIFY event (filtered out) assert!( !events.iter().any(|e| e.kind.is_modify()), - "Should not receive MODIFY events with CREATE-only mask, got: {:?}", - events + "Should not receive MODIFY events with CREATE-only mask, got: {events:?}" ); } } diff --git a/notify/src/lib.rs b/notify/src/lib.rs index c5b2fe8e..5367125a 100644 --- a/notify/src/lib.rs +++ b/notify/src/lib.rs @@ -4,7 +4,7 @@ //! //! ```toml //! [dependencies] -//! notify = "8.1.0" +//! notify = "9.0.0-rc.4" //! ``` //! //! If you want debounced events (or don't need them in-order), see [notify-debouncer-mini](https://docs.rs/notify-debouncer-mini/latest/notify_debouncer_mini/) @@ -24,7 +24,7 @@ //! Events are serializable via [serde](https://serde.rs) if the `serde` feature is enabled: //! //! ```toml -//! notify = { version = "8.1.0", features = ["serde"] } +//! notify = { version = "9.0.0-rc.4", features = ["serde"] } //! ``` //! //! # Known Problems @@ -52,7 +52,8 @@ //! //! On APFS, `std::fs::copy` may use copy-on-write cloning (`fclonefileat`/`clonefile`). //! This can update inode metadata on the source file, and FSEvents may report a metadata change -//! for the source path (see [issue #259](https://github.com/notify-rs/notify/issues/259)). +//! for the source path (see [issue #259](https://github.com/notify-rs/notify/issues/259) and +//! [issue #465](https://github.com/notify-rs/notify/issues/465)). //! //! Workarounds are to avoid `std::fs::copy` (use `std::io::copy` or `read`/`write` instead), or //! filter out metadata-only events if they're not relevant (e.g. don't include @@ -172,10 +173,10 @@ #![deny(missing_docs)] -pub use config::{Config, PathOp, RecursiveMode, WatchPathConfig}; +pub use config::{Config, PathOp, RecursiveMode, WatchPathConfig, WindowsPathSeparatorStyle}; pub use error::{Error, ErrorKind, Result, UpdatePathsError}; pub use notify_types::event::{self, Event, EventKind, EventKindMask}; -use std::path::Path; +use std::path::{Path, PathBuf}; pub(crate) type StdResult = std::result::Result; pub(crate) type Receiver = std::sync::mpsc::Receiver; @@ -233,6 +234,7 @@ pub mod poll; mod config; mod error; +mod paths; #[cfg(test)] pub(crate) mod test; @@ -358,6 +360,16 @@ pub trait Watcher { /// If the `path` is a file, `recursive_mode` will be ignored and events will be delivered only /// for the file. /// + /// Event paths are reported using the same root representation as `path`. If `path` is + /// relative, emitted event paths are relative to the process current directory at the time this + /// method is called. If `path` is absolute, emitted event paths are absolute. Convert `path` + /// before calling this method if your application needs a specific representation. + /// + /// On success, calling this method again for the same backend-resolved path replaces the + /// existing watch for that path. The recursive mode is updated to the new value, a second + /// independent watch is not added, and a single call to [`Watcher::unwatch`] removes the + /// watched path. + /// /// On some platforms, if the `path` is renamed or removed while being watched, behaviour may /// be unexpected. See discussions in [#165] and [#166]. If less surprising behaviour is wanted /// one may non-recursively watch the _parent_ directory as well and manage related events. @@ -432,6 +444,20 @@ pub trait Watcher { Ok(false) } + /// Returns the currently watched paths and their recursive modes. + /// + /// Returned paths use the same representation that was passed to [`Watcher::watch`] or + /// [`Watcher::update_paths`]. + /// + /// # Errors + /// + /// Returns an error if the watcher implementation cannot provide this information. + fn watched_paths(&self) -> Result> { + Err(Error::generic( + "listing watched paths is not supported by this watcher", + )) + } + /// Returns the watcher kind, allowing to perform backend-specific tasks fn kind() -> WatcherKind where @@ -514,6 +540,7 @@ where #[cfg(test)] mod tests { use std::{ + collections::HashSet, fs, iter, path::{Path, PathBuf}, sync::mpsc, @@ -568,6 +595,84 @@ mod tests { }) } + fn canonical_or_path(path: &Path) -> PathBuf { + path.canonicalize() + .expect("test paths should always be canonicalizable") + } + + fn canonical_watch_set( + paths: Vec<(PathBuf, RecursiveMode)>, + ) -> HashSet<(PathBuf, RecursiveMode)> { + paths + .into_iter() + .map(|(path, recursive_mode)| (canonical_or_path(&path), recursive_mode)) + .collect() + } + + fn matches_path(path: &Path, expected: &Path, canonical_expected: Option<&PathBuf>) -> bool { + path == expected || canonical_expected.is_some_and(|canonical| path == canonical) + } + + fn watch_with_retry( + watcher: &mut RecommendedWatcher, + path: impl AsRef, + recursive_mode: RecursiveMode, + ) -> Result<()> { + const FSEVENT_WATCH_RETRIES: usize = 5; + const FSEVENT_WATCH_RETRY_BASE_DELAY: Duration = Duration::from_millis(50); + + let path = path.as_ref(); + for attempt in 0..=FSEVENT_WATCH_RETRIES { + match watcher.watch(path, recursive_mode) { + Ok(()) => return Ok(()), + Err(err) + if RecommendedWatcher::kind() == WatcherKind::Fsevent + && matches!( + &err.kind, + ErrorKind::Generic(message) + if message == "unable to start FSEvent stream" + ) + && attempt < FSEVENT_WATCH_RETRIES => + { + let _ = watcher.unwatch(path); + let delay_factor = 1u32 << attempt; + std::thread::sleep(FSEVENT_WATCH_RETRY_BASE_DELAY * delay_factor); + } + Err(err) => return Err(err), + } + } + + unreachable!("watch() retries must return or error") + } + + fn update_paths_unwatch_with_retry( + watcher: &mut RecommendedWatcher, + path: &Path, + ) -> Result<()> { + const FSEVENT_UNWATCH_RETRIES: usize = 5; + const FSEVENT_UNWATCH_RETRY_BASE_DELAY: Duration = Duration::from_millis(50); + + for attempt in 0..=FSEVENT_UNWATCH_RETRIES { + match watcher.update_paths(vec![PathOp::unwatch(path)]) { + Ok(()) => return Ok(()), + Err(err) + if RecommendedWatcher::kind() == WatcherKind::Fsevent + && matches!( + &err.source.kind, + ErrorKind::Io(io_err) if io_err.raw_os_error() == Some(9) + ) + && attempt < FSEVENT_UNWATCH_RETRIES => + { + let delay_factor = 1u32 << attempt; + std::thread::sleep(FSEVENT_UNWATCH_RETRY_BASE_DELAY * delay_factor); + } + Err(err) => return Err(err.into()), + } + } + + unreachable!("fsevent unwatch retries must return or error") + } + #[test] fn integration() -> std::result::Result<(), Box> { let dir = tempdir()?; @@ -575,7 +680,7 @@ mod tests { // set up the watcher let (tx, rx) = std::sync::mpsc::channel(); let mut watcher = RecommendedWatcher::new(tx, Config::default())?; - watcher.watch(dir.path(), RecursiveMode::Recursive)?; + watch_with_retry(&mut watcher, dir.path(), RecursiveMode::Recursive)?; // create a new file let file_path = dir.path().join("file.txt"); @@ -597,6 +702,46 @@ mod tests { panic!("did not receive expected event"); } + #[test] + fn event_paths_preserve_relative_watch_root( + ) -> std::result::Result<(), Box> { + let cwd = std::env::current_dir()?; + let dir = tempfile::Builder::new() + .prefix("notify-relative-") + .tempdir_in(&cwd)?; + let relative_dir = dir.path().strip_prefix(&cwd)?.to_path_buf(); + let relative_file = relative_dir.join("file.txt"); + let absolute_file = dir.path().join("file.txt"); + + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + watch_with_retry(&mut watcher, &relative_dir, RecursiveMode::Recursive)?; + + assert!( + watcher + .watched_paths()? + .iter() + .any(|(path, mode)| path == &relative_dir && *mode == RecursiveMode::Recursive), + "watched_paths() did not preserve relative watch path" + ); + + fs::write(&absolute_file, b"Lorem ipsum")?; + + for event in iter_with_timeout(&rx) { + if event.paths.iter().any(|path| path == &relative_file) { + assert!( + event.paths.iter().all(|path| path.is_relative()), + "relative watch emitted absolute path: {event:?}" + ); + return Ok(()); + } + + println!("unexpected event: {event:?}"); + } + + panic!("did not receive expected relative event path"); + } + #[test] #[cfg(target_os = "windows")] fn test_windows_trash_dir() -> std::result::Result<(), Box> { @@ -632,32 +777,53 @@ mod tests { let mut watcher = RecommendedWatcher::new(tx, Config::default())?; // start watching a and b - watcher.update_paths(vec![ - PathOp::Watch( - dir_a.clone(), - WatchPathConfig::new(RecursiveMode::Recursive), - ), - PathOp::Watch( - dir_b.clone(), - WatchPathConfig::new(RecursiveMode::Recursive), - ), - ])?; + const FSEVENT_UPDATE_PATHS_RETRIES: usize = 5; + const FSEVENT_UPDATE_PATHS_RETRY_BASE_DELAY: Duration = Duration::from_millis(50); + for attempt in 0..=FSEVENT_UPDATE_PATHS_RETRIES { + match watcher.update_paths(vec![ + PathOp::Watch( + dir_a.clone(), + WatchPathConfig::new(RecursiveMode::Recursive), + ), + PathOp::Watch( + dir_b.clone(), + WatchPathConfig::new(RecursiveMode::Recursive), + ), + ]) { + Ok(()) => break, + Err(err) + if RecommendedWatcher::kind() == WatcherKind::Fsevent + && matches!( + &err.source.kind, + ErrorKind::Generic(message) + if message == "unable to start FSEvent stream" + ) + && attempt < FSEVENT_UPDATE_PATHS_RETRIES => + { + let delay_factor = 1u32 << attempt; + std::thread::sleep(FSEVENT_UPDATE_PATHS_RETRY_BASE_DELAY * delay_factor); + } + Err(err) => return Err(err.into()), + } + } // create file1 in both a and b let a_file1 = dir_a.join("file1"); let b_file1 = dir_b.join("file1"); fs::write(&a_file1, b"Lorem ipsum")?; fs::write(&b_file1, b"Lorem ipsum")?; + let a_file1_canonical = a_file1.canonicalize().ok(); + let b_file1_canonical = b_file1.canonicalize().ok(); // wait for create events of a/file1 and b/file1 let mut a_file1_encountered: bool = false; let mut b_file1_encountered: bool = false; for event in iter_with_timeout(&rx) { for path in event.paths { - a_file1_encountered = - a_file1_encountered || (path == a_file1 || path == a_file1.canonicalize()?); - b_file1_encountered = - b_file1_encountered || (path == b_file1 || path == b_file1.canonicalize()?); + a_file1_encountered = a_file1_encountered + || matches_path(&path, &a_file1, a_file1_canonical.as_ref()); + b_file1_encountered = b_file1_encountered + || matches_path(&path, &b_file1, b_file1_canonical.as_ref()); } if a_file1_encountered && b_file1_encountered { break; @@ -667,22 +833,24 @@ mod tests { assert!(b_file1_encountered, "Did not receive event of {b_file1:?}"); // stop watching a - watcher.update_paths(vec![PathOp::unwatch(&dir_a)])?; + update_paths_unwatch_with_retry(&mut watcher, &dir_a)?; // create file2 in both a and b let a_file2 = dir_a.join("file2"); let b_file2 = dir_b.join("file2"); fs::write(&a_file2, b"Lorem ipsum")?; fs::write(&b_file2, b"Lorem ipsum")?; + let a_file2_canonical = a_file2.canonicalize().ok(); + let b_file2_canonical = b_file2.canonicalize().ok(); // wait for the create event of b/file2 only for event in iter_with_timeout(&rx) { for path in event.paths { assert!( - path != a_file2 || path != a_file2.canonicalize()?, + !matches_path(&path, &a_file2, a_file2_canonical.as_ref()), "Event of {a_file2:?} should not be received" ); - if path == b_file2 || path == b_file2.canonicalize()? { + if matches_path(&path, &b_file2, b_file2_canonical.as_ref()) { return Ok(()); } } @@ -690,6 +858,140 @@ mod tests { panic!("Did not receive the event of {b_file2:?}"); } + #[test] + fn watched_paths_reflect_watch_and_unwatch( + ) -> std::result::Result<(), Box> { + let dir = tempdir()?; + let dir_a = dir.path().join("a"); + let dir_b = dir.path().join("b"); + fs::create_dir(&dir_a)?; + fs::create_dir(&dir_b)?; + + let (tx, _rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + + watch_with_retry(&mut watcher, &dir_a, RecursiveMode::Recursive)?; + watch_with_retry(&mut watcher, &dir_b, RecursiveMode::NonRecursive)?; + + let watched = canonical_watch_set(watcher.watched_paths()?); + assert!(watched.contains(&(canonical_or_path(&dir_a), RecursiveMode::Recursive))); + assert!(watched.contains(&(canonical_or_path(&dir_b), RecursiveMode::NonRecursive))); + + watcher.unwatch(&dir_a)?; + + let watched = canonical_watch_set(watcher.watched_paths()?); + assert!(!watched.contains(&(canonical_or_path(&dir_a), RecursiveMode::Recursive))); + assert!(watched.contains(&(canonical_or_path(&dir_b), RecursiveMode::NonRecursive))); + + Ok(()) + } + + #[test] + fn rewatching_same_path_replaces_recursive_mode( + ) -> std::result::Result<(), Box> { + let dir = tempdir()?; + let root = canonical_or_path(dir.path()); + + let (tx, _rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + + watch_with_retry(&mut watcher, dir.path(), RecursiveMode::Recursive)?; + watch_with_retry(&mut watcher, dir.path(), RecursiveMode::NonRecursive)?; + + let watched = canonical_watch_set(watcher.watched_paths()?); + assert!(watched.contains(&(root.clone(), RecursiveMode::NonRecursive))); + assert!(!watched.contains(&(root.clone(), RecursiveMode::Recursive))); + assert_eq!( + watched.iter().filter(|(path, _mode)| path == &root).count(), + 1 + ); + + watcher.unwatch(dir.path())?; + + let watched = canonical_watch_set(watcher.watched_paths()?); + assert!(!watched.iter().any(|(path, _mode)| path == &root)); + + Ok(()) + } + + #[test] + fn overlapping_recursive_watch_preserves_explicit_child( + ) -> std::result::Result<(), Box> { + let dir = tempdir()?; + let child = dir.path().join("child"); + fs::create_dir(&child)?; + + let (tx, _rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + + watch_with_retry(&mut watcher, &child, RecursiveMode::NonRecursive)?; + watch_with_retry(&mut watcher, dir.path(), RecursiveMode::Recursive)?; + + let watched = canonical_watch_set(watcher.watched_paths()?); + assert!(watched.contains(&(canonical_or_path(dir.path()), RecursiveMode::Recursive))); + assert!(watched.contains(&(canonical_or_path(&child), RecursiveMode::NonRecursive))); + + watcher.unwatch(dir.path())?; + + let watched = canonical_watch_set(watcher.watched_paths()?); + assert!(!watched.contains(&(canonical_or_path(dir.path()), RecursiveMode::Recursive))); + assert!(watched.contains(&(canonical_or_path(&child), RecursiveMode::NonRecursive))); + + Ok(()) + } + + #[test] + fn overlapping_recursive_child_rewrites_descendant_event_paths( + ) -> std::result::Result<(), Box> { + let cwd = std::env::current_dir()?; + let dir = tempfile::Builder::new() + .prefix("notify-overlap-") + .tempdir_in(&cwd)?; + let relative_dir = dir.path().strip_prefix(&cwd)?.to_path_buf(); + let child = dir.path().join("child"); + let grandchild = child.join("grandchild"); + fs::create_dir_all(&grandchild)?; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + + watch_with_retry(&mut watcher, &relative_dir, RecursiveMode::Recursive)?; + watch_with_retry(&mut watcher, &child, RecursiveMode::Recursive)?; + watcher.unwatch(&relative_dir)?; + + let watched = watcher.watched_paths()?; + assert!( + watched + .iter() + .any(|(path, mode)| path == &child && *mode == RecursiveMode::Recursive), + "watched_paths() did not preserve explicit child path: {watched:?}" + ); + + let file = grandchild.join("file.txt"); + let stale_file = relative_dir + .join("child") + .join("grandchild") + .join("file.txt"); + fs::write(&file, b"Lorem ipsum")?; + + for event in iter_with_timeout(&rx) { + if event.paths.iter().any(|path| path == &file) { + assert!( + event.paths.iter().all(|path| path.is_absolute()), + "absolute child watch emitted non-absolute path: {event:?}" + ); + return Ok(()); + } + + assert!( + !event.paths.iter().any(|path| path == &stale_file), + "stale parent-relative file path after parent unwatch: {event:?}" + ); + } + + panic!("did not receive expected child event path"); + } + #[test] fn update_paths_in_a_loop_with_errors() -> StdResult<(), Box> { let dir = tempdir()?; @@ -719,6 +1021,7 @@ mod tests { fs::write(¬_existent_file, "")?; let waiting_path = existing_dir_2.join("1"); fs::write(&waiting_path, "")?; + let waiting_path_canonical = waiting_path.canonicalize().ok(); for event in iter_with_timeout(&rx) { let path = event @@ -727,10 +1030,9 @@ mod tests { .unwrap_or_else(|| panic!("event must have a path: {event:?}")); assert!( path != ¬_existent_file, - "unexpected {:?} event", - not_existent_file + "unexpected {not_existent_file:?} event" ); - if path == &waiting_path || path == &waiting_path.canonicalize()? { + if matches_path(path, &waiting_path, waiting_path_canonical.as_ref()) { return Ok(()); } } diff --git a/notify/src/null.rs b/notify/src/null.rs index bbcd80d9..36d442ad 100644 --- a/notify/src/null.rs +++ b/notify/src/null.rs @@ -5,7 +5,7 @@ use crate::Config; use super::{RecursiveMode, Result, Watcher}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Stub `Watcher` implementation /// @@ -33,6 +33,10 @@ impl Watcher for NullWatcher { Ok(false) } + fn watched_paths(&self) -> Result> { + Ok(Vec::new()) + } + fn kind() -> crate::WatcherKind { crate::WatcherKind::NullWatcher } diff --git a/notify/src/paths.rs b/notify/src/paths.rs new file mode 100644 index 00000000..6841709e --- /dev/null +++ b/notify/src/paths.rs @@ -0,0 +1,172 @@ +use crate::{Error, Result}; +use std::{ + env, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Debug)] +pub(crate) struct WatchPath { + pub(crate) absolute: PathBuf, + pub(crate) requested: PathBuf, +} + +#[derive(Clone, Debug)] +pub(crate) struct WatchMetadata { + pub(crate) is_recursive: bool, + pub(crate) reported_path: PathBuf, + pub(crate) is_user_watch: bool, + pub(crate) user_is_recursive: bool, +} + +impl WatchPath { + pub(crate) fn new(path: &Path) -> Result { + Ok(Self { + absolute: absolute_path(path)?, + requested: path.to_path_buf(), + }) + } + + pub(crate) fn from_parts(absolute: PathBuf, requested: PathBuf) -> Self { + Self { + absolute, + requested, + } + } + + pub(crate) fn child(&self, path: PathBuf) -> Self { + let requested = reported_path(&self.absolute, &self.requested, &path); + Self::from_parts(path, requested) + } +} + +impl WatchMetadata { + pub(crate) fn new<'a, I>( + path: &WatchPath, + is_recursive: bool, + is_user_watch: bool, + existing_watch: Option<&Self>, + user_roots: I, + ) -> Self + where + I: IntoIterator, + { + let existing_reported_path = existing_watch.map(|watch| watch.reported_path.clone()); + let existing_is_user_watch = existing_watch.is_some_and(|watch| watch.is_user_watch); + let existing_user_is_recursive = + existing_watch.is_some_and(|watch| watch.user_is_recursive); + let existing_is_recursive = existing_watch.is_some_and(|watch| watch.is_recursive); + + let reported_path = if is_user_watch { + path.requested.clone() + } else if existing_is_user_watch { + existing_reported_path.unwrap_or_else(|| path.requested.clone()) + } else { + user_roots + .into_iter() + .filter(|(candidate, watch)| { + watch.is_user_watch + && watch.user_is_recursive + && path.absolute.starts_with(candidate) + }) + .max_by_key(|(candidate, _)| candidate.as_os_str().len()) + .map_or_else( + || path.requested.clone(), + |(root, watch)| reported_path(root, &watch.reported_path, &path.absolute), + ) + }; + + Self { + is_recursive: is_recursive || existing_is_recursive, + reported_path, + is_user_watch: is_user_watch || existing_is_user_watch, + user_is_recursive: if is_user_watch { + is_recursive + } else { + existing_user_is_recursive + }, + } + } +} + +pub(crate) fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(env::current_dir().map_err(Error::io)?.join(path)) + } +} + +pub(crate) fn reported_path(root_absolute: &Path, root_requested: &Path, path: &Path) -> PathBuf { + debug_assert!( + path.starts_with(root_absolute), + "reported_path called with path outside root: root={}, path={}", + root_absolute.display(), + path.display() + ); + + match path.strip_prefix(root_absolute) { + Ok(relative) if !relative.as_os_str().is_empty() => root_requested.join(relative), + _ => root_requested.to_path_buf(), + } +} + +pub(crate) fn preserved_watch_mode( + path: &Path, + preserved_roots: &[(PathBuf, bool)], +) -> Option { + preserved_roots + .iter() + .find(|(root, user_is_recursive)| { + path == root || (*user_is_recursive && path.starts_with(root)) + }) + .map(|(_, user_is_recursive)| *user_is_recursive) +} + +pub(crate) fn preserved_watch_roots<'a, I>( + path: &Path, + remove_recursive: bool, + watches: I, +) -> Vec<(PathBuf, bool)> +where + I: IntoIterator, +{ + if remove_recursive { + Vec::new() + } else { + watches + .into_iter() + .filter(|(candidate, watch)| { + *candidate != path && candidate.starts_with(path) && watch.is_user_watch + }) + .map(|(path, watch)| (path.clone(), watch.user_is_recursive)) + .collect() + } +} + +pub(crate) fn is_preserved_watch_root(path: &Path, preserved_roots: &[(PathBuf, bool)]) -> bool { + preserved_roots.iter().any(|(root, _)| path == root) +} + +/// Finds the nearest recursive user watch that covers `path`. +/// +/// Backends use this when replacing an explicit watch that also inherits recursive coverage from an +/// ancestor. Returning the ancestor's reported path lets them rebuild the inherited subtree with the +/// same path representation users expect from that ancestor watch. +pub(crate) fn recursive_user_watch_ancestor<'a, I>( + path: &Path, + watches: I, +) -> Option<(PathBuf, PathBuf)> +where + I: IntoIterator, +{ + watches + .into_iter() + .filter(|(candidate, watch)| { + *candidate != path + && path.starts_with(candidate) + && watch.is_user_watch + && watch.user_is_recursive + }) + .max_by_key(|(candidate, _)| candidate.as_os_str().len()) + .map(|(path, watch)| (path.clone(), watch.reported_path.clone())) +} diff --git a/notify/src/poll.rs b/notify/src/poll.rs index bd2f95f3..80701e0d 100644 --- a/notify/src/poll.rs +++ b/notify/src/poll.rs @@ -3,7 +3,10 @@ //! Checks the `watch`ed paths periodically to detect changes. This implementation only uses //! Rust stdlib APIs and should work on all of the platforms it supports. -use crate::{unbounded, Config, Error, EventHandler, Receiver, RecursiveMode, Sender, Watcher}; +use crate::{ + paths::{absolute_path, WatchPath}, + unbounded, Config, Error, EventHandler, Receiver, RecursiveMode, Sender, Watcher, +}; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -69,7 +72,8 @@ use data::{DataBuilder, WatchData}; mod data { use crate::{ event::{CreateKind, DataChange, Event, EventKind, MetadataKind, ModifyKind, RemoveKind}, - EventHandler, + paths::{reported_path, WatchPath}, + EventHandler, RecursiveMode, }; use notify_types::event::EventKindMask; use std::{ @@ -142,7 +146,7 @@ mod data { /// the path location. (e.g., not found). pub(super) fn build_watch_data( &self, - root: PathBuf, + root: WatchPath, is_recursive: bool, follow_symlinks: bool, ) -> Option { @@ -168,6 +172,7 @@ mod data { pub(super) struct WatchData { // config part, won't change. root: PathBuf, + requested_root: PathBuf, is_recursive: bool, follow_symlinks: bool, @@ -183,7 +188,7 @@ mod data { /// This function may send event by `data_builder.emitter`. fn new( data_builder: &DataBuilder, - root: PathBuf, + root: WatchPath, is_recursive: bool, follow_symlinks: bool, ) -> Option { @@ -205,14 +210,15 @@ mod data { // // FIXME: Can we always allow to watch a path, even file not // found at this path? - if let Err(e) = fs::metadata(&root) { - data_builder.emitter.emit_io_err(e, Some(&root)); + if let Err(e) = fs::metadata(&root.absolute) { + data_builder.emitter.emit_io_err(e, Some(&root.requested)); return None; } let all_path_data = Self::scan_all_path_data( data_builder, - root.clone(), + root.absolute.clone(), + root.requested.clone(), is_recursive, follow_symlinks, true, @@ -220,7 +226,8 @@ mod data { .collect(); Some(Self { - root, + root: root.absolute, + requested_root: root.requested, is_recursive, follow_symlinks, all_path_data, @@ -237,18 +244,28 @@ mod data { for (path, new_path_data) in Self::scan_all_path_data( data_builder, self.root.clone(), + self.requested_root.clone(), self.is_recursive, self.follow_symlinks, false, ) { - let old_path_data = self - .all_path_data - .insert(path.clone(), new_path_data.clone()); - - // emit event - let event = - PathData::compare_to_event(path, old_path_data.as_ref(), Some(&new_path_data)); - if let Some(event) = event { + let event_kind = if let Some(old_path_data) = self.all_path_data.get_mut(&path) { + let event_kind = + PathData::compare_to_kind(Some(&*old_path_data), Some(&new_path_data)); + *old_path_data = new_path_data; + event_kind + } else { + let event_kind = PathData::compare_to_kind(None, Some(&new_path_data)); + self.all_path_data.insert(path.clone(), new_path_data); + event_kind + }; + + if let Some(event_kind) = event_kind { + let event = Event::new(event_kind).add_path(reported_path( + &self.root, + &self.requested_root, + &path, + )); data_builder.emitter.emit_ok(event); } } @@ -265,9 +282,12 @@ mod data { for path in disappeared_paths { let old_path_data = self.all_path_data.remove(&path); - // emit event - let event = PathData::compare_to_event(path, old_path_data.as_ref(), None); - if let Some(event) = event { + if let Some(event_kind) = PathData::compare_to_kind(old_path_data.as_ref(), None) { + let event = Event::new(event_kind).add_path(reported_path( + &self.root, + &self.requested_root, + &path, + )); data_builder.emitter.emit_ok(event); } } @@ -281,6 +301,7 @@ mod data { fn scan_all_path_data( data_builder: &'_ DataBuilder, root: PathBuf, + requested_root: PathBuf, is_recursive: bool, follow_symlinks: bool, // whether this is an initial scan, used only for events @@ -291,7 +312,7 @@ mod data { // so we can use single logic to do the both file & dir's jobs. // // See: https://docs.rs/walkdir/2.0.1/walkdir/struct.WalkDir.html#method.new - WalkDir::new(root) + WalkDir::new(root.clone()) .follow_links(follow_symlinks) .max_depth(Self::dir_scan_depth(is_recursive)) .into_iter() @@ -318,7 +339,11 @@ mod data { if is_initial { // emit initial scans if let Some(ref emitter) = data_builder.scan_emitter { - emitter.borrow_mut().handle_event(Ok(path.clone())); + emitter.borrow_mut().handle_event(Ok(reported_path( + &root, + &requested_root, + &path, + ))); } } let meta_path = MetaPath::from_parts_unchecked(path, metadata); @@ -343,6 +368,18 @@ mod data { 1 } } + + pub(super) fn recursive_mode(&self) -> RecursiveMode { + if self.is_recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + } + } + + pub(super) fn requested_root(&self) -> &Path { + &self.requested_root + } } /// Stored data for a one path locations. @@ -378,19 +415,6 @@ mod data { } } - /// Get [`Event`] by compare two optional [`PathData`]. - fn compare_to_event

( - path: P, - old: Option<&PathData>, - new: Option<&PathData>, - ) -> Option - where - P: Into, - { - Self::compare_to_kind(old, new) - .map(|event_kind| Event::new(event_kind).add_path(path.into())) - } - fn compare_to_kind(old: Option<&PathData>, new: Option<&PathData>) -> Option { match (old, new) { (Some(old), Some(new)) => { @@ -666,37 +690,42 @@ impl PollWatcher { /// /// QUESTION: this function never return an Error, is it as intend? /// Please also consider the IO Error event problem. - fn watch_inner(&mut self, path: &Path, recursive_mode: RecursiveMode) { + fn watch_inner(&mut self, path: &Path, recursive_mode: RecursiveMode) -> crate::Result<()> { + let watch_path = WatchPath::new(path)?; + // HINT: Make sure always lock in the same order to avoid deadlock. // // FIXME: inconsistent: some place mutex poison cause panic, some place just ignore. - if let (Ok(mut watches), Ok(mut data_builder)) = - (self.watches.lock(), self.data_builder.lock()) - { - data_builder.update_timestamp(); + let mut watches = self.watches.lock().unwrap_or_else(|e| e.into_inner()); + let mut data_builder = self.data_builder.lock().unwrap_or_else(|e| e.into_inner()); - let watch_data = data_builder.build_watch_data( - path.to_path_buf(), - recursive_mode.is_recursive(), - self.follow_sylinks, - ); + data_builder.update_timestamp(); - // if create watch_data successful, add it to watching list. - if let Some(watch_data) = watch_data { - watches.insert(path.to_path_buf(), watch_data); - } + let watch_data = data_builder.build_watch_data( + watch_path.clone(), + recursive_mode.is_recursive(), + self.follow_sylinks, + ); + + // if create watch_data successful, add it to watching list. + if let Some(watch_data) = watch_data { + watches.insert(watch_path.absolute, watch_data); } + + Ok(()) } /// Unwatch a path. /// /// Return `Err(_)` if given path has't be monitored. fn unwatch_inner(&mut self, path: &Path) -> crate::Result<()> { + let path = absolute_path(path)?; + // FIXME: inconsistent: some place mutex poison cause panic, some place just ignore. self.watches .lock() - .unwrap() - .remove(path) + .unwrap_or_else(|e| e.into_inner()) + .remove(&path) .map(|_| ()) .ok_or_else(crate::Error::watch_not_found) } @@ -709,15 +738,21 @@ impl Watcher for PollWatcher { } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> crate::Result<()> { - self.watch_inner(path, recursive_mode); - - Ok(()) + self.watch_inner(path, recursive_mode) } fn unwatch(&mut self, path: &Path) -> crate::Result<()> { self.unwatch_inner(path) } + fn watched_paths(&self) -> crate::Result> { + let watches = self.watches.lock().map_err(crate::Error::from)?; + Ok(watches + .values() + .map(|watch| (watch.requested_root().to_path_buf(), watch.recursive_mode())) + .collect()) + } + fn kind() -> crate::WatcherKind { crate::WatcherKind::PollWatcher } @@ -732,7 +767,7 @@ impl Drop for PollWatcher { #[cfg(test)] mod tests { use super::PollWatcher; - use crate::test::*; + use crate::{test::*, Config, RecursiveMode, Watcher}; fn watcher() -> (TestWatcher, Receiver) { poll_watcher_channel() @@ -744,6 +779,93 @@ mod tests { check::(); } + #[test] + fn unwatch_with_poisoned_mutex_does_not_panic() { + use std::{path::Path, sync::Arc}; + + let mut watcher = PollWatcher::new(|_| {}, Config::default()).expect("create watcher"); + + let watches = Arc::clone(&watcher.watches); + let _ = std::thread::spawn(move || { + let _guard = watches.lock().expect("lock watches"); + panic!("poison watches mutex for test"); + }) + .join(); + + // Ensure poisoned mutex recovery path does not panic in unwatch_inner. + let result = watcher.unwatch_inner(Path::new("/path/that/is/not/watched")); + assert!(result.is_err()); + } + + #[test] + fn watched_paths_reflect_watch_and_unwatch() { + let tmpdir = testdir(); + let dir_a = tmpdir.path().join("a"); + let dir_b = tmpdir.path().join("b"); + std::fs::create_dir(&dir_a).expect("create dir a"); + std::fs::create_dir(&dir_b).expect("create dir b"); + + let mut watcher = PollWatcher::new(|_| {}, Config::default()).expect("create watcher"); + + watcher + .watch(&dir_a, RecursiveMode::Recursive) + .expect("watch dir a"); + watcher + .watch(&dir_b, RecursiveMode::NonRecursive) + .expect("watch dir b"); + + let watched = watcher.watched_paths().expect("list watched paths"); + assert!(watched.contains(&( + dir_a.canonicalize().expect("canonicalize dir a"), + RecursiveMode::Recursive, + ))); + assert!(watched.contains(&( + dir_b.canonicalize().expect("canonicalize dir b"), + RecursiveMode::NonRecursive, + ))); + + watcher.unwatch(&dir_a).expect("unwatch dir a"); + + let watched = watcher + .watched_paths() + .expect("list watched paths after unwatch"); + assert!(!watched.contains(&( + dir_a.canonicalize().expect("canonicalize dir a"), + RecursiveMode::Recursive, + ))); + assert!(watched.contains(&( + dir_b.canonicalize().expect("canonicalize dir b"), + RecursiveMode::NonRecursive, + ))); + } + + #[test] + fn rewatching_same_path_replaces_recursive_mode() { + let tmpdir = testdir(); + let root = tmpdir.path().canonicalize().expect("canonicalize root"); + + let mut watcher = PollWatcher::new(|_| {}, Config::default()).expect("create watcher"); + + watcher + .watch(tmpdir.path(), RecursiveMode::Recursive) + .expect("watch recursively"); + watcher + .watch(tmpdir.path(), RecursiveMode::NonRecursive) + .expect("watch non-recursively"); + + let watched = watcher.watched_paths().expect("list watched paths"); + assert!(watched.contains(&(root.clone(), RecursiveMode::NonRecursive))); + assert!(!watched.contains(&(root.clone(), RecursiveMode::Recursive))); + assert_eq!( + watched.iter().filter(|(path, _mode)| path == &root).count(), + 1 + ); + + watcher.unwatch(tmpdir.path()).expect("unwatch"); + let watched = watcher.watched_paths().expect("list watched paths"); + assert!(!watched.iter().any(|(path, _mode)| path == &root)); + } + #[test] fn create_file() { let tmpdir = testdir(); @@ -914,8 +1036,7 @@ mod tests { .expect("event should not be an error"); assert!( event.kind.is_create(), - "Expected CREATE event, got: {:?}", - event + "Expected CREATE event, got: {event:?}" ); // Modify the file - should NOT generate event (filtered by mask) @@ -929,8 +1050,7 @@ mod tests { let remaining: Vec<_> = rx.try_iter().filter_map(|r| r.ok()).collect(); assert!( !remaining.iter().any(|e| e.kind.is_modify()), - "Should not receive MODIFY events with CREATE-only mask, got: {:?}", - remaining + "Should not receive MODIFY events with CREATE-only mask, got: {remaining:?}" ); } } diff --git a/notify/src/test.rs b/notify/src/test.rs index 4dbee078..51d54652 100644 --- a/notify/src/test.rs +++ b/notify/src/test.rs @@ -13,7 +13,9 @@ use std::{ use notify_types::event::Event; use walkdir::WalkDir; -use crate::{Config, Error, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind}; +use crate::{ + Config, Error, ErrorKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind, +}; use pretty_assertions::assert_eq; pub use expect::*; @@ -45,10 +47,25 @@ impl Receiver { trackers.try_push(&event); state.check(event) } - Err(err) => panic!( - "Got an error from the watcher {:?}: {err:?}. State: {state:#?}", - self.kind - ), + Err(err) => { + let is_bad_file_descriptor = self.kind == WatcherKind::PollWatcher + && matches!( + &err.kind, + ErrorKind::Io(io_err) + if io_err + .to_string() + .contains("Bad file descriptor (os error 9)") + ); + + if is_bad_file_descriptor { + continue; + } + + panic!( + "Got an error from the watcher {:?}: {err:?}. State: {state:#?}", + self.kind + ) + } }, Err(e) => panic!( "Recv error: {e:?}. Watcher: {:?}. State: {state:#?}", @@ -342,9 +359,33 @@ impl TestWatcher { pub fn watch(&mut self, path: impl AsRef, recursive_mode: RecursiveMode) { let path = path.as_ref(); - self.watcher - .watch(path, recursive_mode) - .unwrap_or_else(|e| panic!("Unable to watch {:?}: {e:#?}", path)) + + const FSEVENT_WATCH_RETRIES: usize = 5; + const FSEVENT_WATCH_RETRY_BASE_DELAY: Duration = Duration::from_millis(50); + for attempt in 0..=FSEVENT_WATCH_RETRIES { + match self.watcher.watch(path, recursive_mode) { + Ok(()) => return, + Err(err) => { + let is_transient_fsevent_start_error = self.kind == WatcherKind::Fsevent + && matches!( + &err.kind, + ErrorKind::Generic(message) + if message == "unable to start FSEvent stream" + ); + + if is_transient_fsevent_start_error && attempt < FSEVENT_WATCH_RETRIES { + let _ = self.watcher.unwatch(path); + let delay_factor = 1u32 << attempt; + thread::sleep(FSEVENT_WATCH_RETRY_BASE_DELAY * delay_factor); + continue; + } + + panic!("Unable to watch {path:?}: {err:#?}") + } + } + } + + unreachable!("watch() retries must return or panic") } } @@ -580,7 +621,7 @@ mod expect { if expected.is_none() && self.unexpected_event_behaviour == UnexpectedEventBehaviour::Panic { - panic!("Unexpected event. State: {:#?}", self) + panic!("Unexpected event. State: {self:#?}") } } diff --git a/notify/src/windows.rs b/notify/src/windows.rs index 453f3cad..f67b12f6 100644 --- a/notify/src/windows.rs +++ b/notify/src/windows.rs @@ -5,12 +5,12 @@ //! //! [ref]: https://msdn.microsoft.com/en-us/library/windows/desktop/aa363950(v=vs.85).aspx +use crate::paths::{absolute_path, WatchPath}; use crate::{bounded, unbounded, BoundSender, Config, Receiver, Sender}; use crate::{event::*, WatcherKind}; -use crate::{Error, EventHandler, RecursiveMode, Result, Watcher}; +use crate::{Error, EventHandler, RecursiveMode, Result, Watcher, WindowsPathSeparatorStyle}; use std::alloc; use std::collections::HashMap; -use std::env; use std::ffi::OsString; use std::os::raw::c_void; use std::os::windows::ffi::{OsStrExt, OsStringExt}; @@ -39,12 +39,98 @@ use windows_sys::Win32::System::IO::{CancelIo, OVERLAPPED}; const BUF_SIZE: u32 = 16384; +#[derive(Clone, Copy)] +enum SeparatorStyle { + Slash, + Backslash, +} + +impl SeparatorStyle { + fn resolve(configured_style: WindowsPathSeparatorStyle, path: &Path) -> Self { + match configured_style { + WindowsPathSeparatorStyle::Auto => Self::from_path(path), + WindowsPathSeparatorStyle::Slash => Self::Slash, + WindowsPathSeparatorStyle::Backslash => Self::Backslash, + } + } + + fn from_path(path: &Path) -> Self { + let mut has_forward_slash = false; + let mut has_backslash = false; + + for ch in path.as_os_str().encode_wide() { + if ch == '/' as u16 { + has_forward_slash = true; + } else if ch == '\\' as u16 { + has_backslash = true; + } + + if has_forward_slash && has_backslash { + return Self::Backslash; + } + } + + if has_forward_slash { + Self::Slash + } else { + Self::Backslash + } + } + + fn as_u16(self) -> u16 { + match self { + Self::Slash => '/' as u16, + Self::Backslash => '\\' as u16, + } + } +} + +fn trim_leading_separators(path: &[u16]) -> &[u16] { + let mut start = 0; + while start < path.len() && (path[start] == '/' as u16 || path[start] == '\\' as u16) { + start += 1; + } + &path[start..] +} + +fn windows_namespace_prefix_len(path: &[u16]) -> usize { + let is_separator = |ch: u16| ch == '/' as u16 || ch == '\\' as u16; + + if path.len() >= 4 + && is_separator(path[0]) + && is_separator(path[1]) + && (path[2] == '?' as u16 || path[2] == '.' as u16) + && is_separator(path[3]) + { + 4 + } else { + 0 + } +} + +fn normalize_path_separators(path: PathBuf, separator_style: SeparatorStyle) -> PathBuf { + let separator = separator_style.as_u16(); + let mut encoded_path: Vec = path.as_os_str().encode_wide().collect(); + let prefix_len = windows_namespace_prefix_len(&encoded_path); + + for ch in encoded_path.iter_mut().skip(prefix_len) { + if *ch == '/' as u16 || *ch == '\\' as u16 { + *ch = separator; + } + } + + PathBuf::from(OsString::from_wide(&encoded_path)) +} + #[derive(Clone)] struct ReadData { + watch_path: PathBuf, // key used in the server watch map dir: PathBuf, // directory that is being watched - file: Option, // if a file is being watched, this is its full path + reported_dir: PathBuf, // directory prefix used in emitted event paths + file: Option, // if a file is being watched, this is its reported path complete_sem: HANDLE, is_recursive: bool, + separator_style: SeparatorStyle, } struct ReadDirectoryRequest { @@ -58,13 +144,20 @@ struct ReadDirectoryRequest { impl ReadDirectoryRequest { fn unwatch(&self) { - let _ = self.action_tx.send(Action::Unwatch(self.data.dir.clone())); + let _ = self + .action_tx + .send(Action::Unwatch(self.data.watch_path.clone())); } } enum Action { - Watch(PathBuf, RecursiveMode), + Watch(WatchPath, RecursiveMode, SeparatorStyle), + // Internal self-unwatch from the completion callback. Unwatch(PathBuf), + // Public `Watcher::unwatch` path. This variant must ack only after `remove_watch` finishes so + // the caller does not observe events after `unwatch()` returns. + UnwatchAck(PathBuf), + GetWatchedPaths(Sender>), Stop, Configure(Config, BoundSender>), } @@ -78,6 +171,8 @@ pub enum MetaEvent { struct WatchState { dir_handle: HANDLE, complete_sem: HANDLE, + recursive_mode: RecursiveMode, + reported_path: PathBuf, } struct ReadDirectoryChangesServer { @@ -131,11 +226,26 @@ impl ReadDirectoryChangesServer { while let Ok(action) = self.rx.try_recv() { match action { - Action::Watch(path, recursive_mode) => { - let res = self.add_watch(path, recursive_mode.is_recursive()); + Action::Watch(path, recursive_mode, separator_style) => { + let res = + self.add_watch(path, recursive_mode.is_recursive(), separator_style); let _ = self.cmd_tx.send(res); } Action::Unwatch(path) => self.remove_watch(path), + Action::UnwatchAck(path) => { + self.remove_watch(path.clone()); + let _ = self.cmd_tx.send(Ok(path)); + } + Action::GetWatchedPaths(tx) => { + let _ = tx.send( + self.watches + .iter() + .map(|(_path, state)| { + (state.reported_path.clone(), state.recursive_mode) + }) + .collect(), + ); + } Action::Stop => { stopped = true; for ws in self.watches.values() { @@ -168,23 +278,35 @@ impl ReadDirectoryChangesServer { } } - fn add_watch(&mut self, path: PathBuf, is_recursive: bool) -> Result { + fn add_watch( + &mut self, + path: WatchPath, + is_recursive: bool, + separator_style: SeparatorStyle, + ) -> Result { // path must exist and be either a file or directory - if !path.is_dir() && !path.is_file() { + if !path.absolute.is_dir() && !path.absolute.is_file() { return Err( Error::generic("Input watch path is neither a file nor a directory.") - .add_path(path), + .add_path(path.requested), ); } let (watching_file, dir_target) = { - if path.is_dir() { - (false, path.clone()) + if path.absolute.is_dir() { + (false, path.absolute.clone()) } else { // emulate file watching by watching the parent directory - (true, path.parent().unwrap().to_path_buf()) + (true, path.absolute.parent().unwrap().to_path_buf()) } }; + let reported_dir = if watching_file { + path.requested + .parent() + .map_or_else(PathBuf::new, Path::to_path_buf) + } else { + path.requested.clone() + }; let encoded_path: Vec = dir_target .as_os_str() @@ -209,45 +331,69 @@ impl ReadDirectoryChangesServer { "You attempted to watch a single file, but parent \ directory could not be opened.", ) - .add_path(path) + .add_path(path.requested) } else { // TODO: Call GetLastError for better error info? - Error::path_not_found().add_path(path) + Error::path_not_found().add_path(path.requested) }); } } let wf = if watching_file { - Some(path.clone()) + Some(normalize_path_separators( + path.requested.clone(), + separator_style, + )) } else { None }; + let watched_path = path.absolute.clone(); // every watcher gets its own semaphore to signal completion let semaphore = unsafe { CreateSemaphoreW(ptr::null_mut(), 0, 1, ptr::null_mut()) }; if semaphore.is_null() || semaphore == INVALID_HANDLE_VALUE { unsafe { CloseHandle(handle); } - return Err(Error::generic("Failed to create semaphore for watch.").add_path(path)); + return Err( + Error::generic("Failed to create semaphore for watch.").add_path(path.requested) + ); } let rd = ReadData { + watch_path: watched_path.clone(), dir: dir_target, + reported_dir, file: wf, complete_sem: semaphore, is_recursive, + separator_style, }; let ws = WatchState { dir_handle: handle, complete_sem: semaphore, + recursive_mode: if is_recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }, + reported_path: path.requested, }; - self.watches.insert(path.clone(), ws); - start_read( + if let Err(err) = start_read( &rd, self.event_handler.clone(), self.event_kinds, handle, self.tx.clone(), - ); - Ok(path) + ) { + unsafe { + CloseHandle(handle); + CloseHandle(semaphore); + } + return Err(err); + } + if let Some(ws) = self.watches.remove(&watched_path) { + stop_watch(&ws, &self.meta_tx); + } + self.watches.insert(watched_path.clone(), ws); + Ok(watched_path) } fn remove_watch(&mut self, path: PathBuf) { @@ -283,7 +429,7 @@ fn start_read( event_kinds: EventKindMask, handle: HANDLE, action_tx: Sender, -) { +) -> Result<()> { let request = Box::new(ReadDirectoryRequest { event_handler, event_kinds, @@ -329,15 +475,24 @@ fn start_read( ); if ret == 0 { + let err = std::io::Error::last_os_error(); // error reading. retransmute request memory to allow drop. // Because of the error, ownership of the `overlapped` alloc was not passed // over to `ReadDirectoryChangesW`. // So we can claim ownership back. let _overlapped = Box::from_raw(overlapped); let request = Box::from_raw(request); + let path = request + .data + .file + .clone() + .unwrap_or_else(|| request.data.reported_dir.clone()); ReleaseSemaphore(request.data.complete_sem, 1, ptr::null_mut()); + return Err(Error::io(err).add_path(path)); } } + + Ok(()) } unsafe extern "system" fn handle_event( @@ -348,6 +503,13 @@ unsafe extern "system" fn handle_event( let overlapped: Box = Box::from_raw(overlapped); let request: Box = Box::from_raw(overlapped.hEvent as *mut _); + fn emit_event(event_handler: &Mutex, res: Result) { + if let Ok(mut guard) = event_handler.lock() { + let f: &mut dyn EventHandler = &mut *guard; + f.handle_event(res); + } + } + match error_code { ERROR_OPERATION_ABORTED => { // received when dir is unwatched or watcher is shutdown; return and let overlapped/request get drop-cleaned @@ -355,9 +517,25 @@ unsafe extern "system" fn handle_event( return; } ERROR_ACCESS_DENIED => { - // This could happen when the watched directory is deleted or trashed, first check if it's the case. - // If so, unwatch the directory and return, otherwise, continue to handle the event. - if !request.data.dir.exists() { + // ReadDirectoryChangesW returns ERROR_ACCESS_DENIED both when the handle + // has been invalidated (usually because the watched dir was deleted) and + // when access has been revoked; use successful dir absence to tell which. + // For directory watches, emit a Remove event so consumers learn the + // watched path is gone, matching inotify IN_DELETE_SELF (#540) and + // FSEvents ROOT_CHANGED+ITEM_REMOVED. File watches are excluded because + // FILE_ACTION_REMOVED already fires for the file's parent. + if matches!(request.data.dir.try_exists(), Ok(false)) { + if request.data.file.is_none() { + const KIND: EventKind = EventKind::Remove(RemoveKind::Folder); + if request.event_kinds.matches(&KIND) { + let path = normalize_path_separators( + request.data.reported_dir.clone(), + request.data.separator_style, + ); + let event = Event::new(KIND).add_path(path); + emit_event(&request.event_handler, Ok(event)); + } + } request.unwatch(); ReleaseSemaphore(request.data.complete_sem, 1, ptr::null_mut()); return; @@ -380,13 +558,14 @@ unsafe extern "system" fn handle_event( } // Get the next request queued up as soon as possible - start_read( + let rearm_error = start_read( &request.data, request.event_handler.clone(), request.event_kinds, request.handle, - request.action_tx, - ); + request.action_tx.clone(), + ) + .err(); // The FILE_NOTIFY_INFORMATION struct has a variable length due to the variable length // string as its last member. Each struct contains an offset for getting the next entry in @@ -405,10 +584,13 @@ unsafe extern "system" fn handle_event( len, ); // prepend root to get a full path - let path = request - .data - .dir - .join(PathBuf::from(OsString::from_wide(encoded_path))); + let relative_path = + PathBuf::from(OsString::from_wide(trim_leading_separators(encoded_path))); + let absolute_path = request.data.dir.join(&relative_path); + let path = normalize_path_separators( + request.data.reported_dir.join(relative_path), + request.data.separator_style, + ); // if we are watching a single file, ignore the event unless the path is exactly // the watched file @@ -426,13 +608,6 @@ unsafe extern "system" fn handle_event( let newe = Event::new(EventKind::Any).add_path(path); - fn emit_event(event_handler: &Mutex, res: Result) { - if let Ok(mut guard) = event_handler.lock() { - let f: &mut dyn EventHandler = &mut *guard; - f.handle_event(res); - } - } - // Filter events based on EventKindMask let event_kinds = request.event_kinds; let event_handler = |res: Result| { @@ -462,7 +637,17 @@ unsafe extern "system" fn handle_event( event_handler(Ok(ev)); } FILE_ACTION_ADDED => { - let kind = EventKind::Create(CreateKind::Any); + let kind = + std::fs::metadata(&absolute_path).map_or(CreateKind::Any, |metadata| { + if metadata.is_dir() { + CreateKind::Folder + } else if metadata.is_file() { + CreateKind::File + } else { + CreateKind::Any + } + }); + let kind = EventKind::Create(kind); let ev = newe.set_kind(kind); event_handler(Ok(ev)); } @@ -487,6 +672,11 @@ unsafe extern "system" fn handle_event( cur_offset = cur_offset.offset(cur_entry.NextEntryOffset as isize); cur_entry = ptr::read_unaligned(cur_offset as *const FILE_NOTIFY_INFORMATION); } + + if let Some(err) = rearm_error { + emit_event(&request.event_handler, Err(err)); + request.unwatch(); + } } /// Watcher implementation based on ReadDirectoryChanges @@ -495,6 +685,7 @@ pub struct ReadDirectoryChangesWatcher { tx: Sender, cmd_rx: Receiver>, wakeup_sem: HANDLE, + windows_path_separator_style: WindowsPathSeparatorStyle, } impl ReadDirectoryChangesWatcher { @@ -502,6 +693,20 @@ impl ReadDirectoryChangesWatcher { event_handler: Arc>, event_kinds: EventKindMask, meta_tx: Sender, + ) -> Result { + Self::create_inner( + event_handler, + event_kinds, + WindowsPathSeparatorStyle::Auto, + meta_tx, + ) + } + + fn create_inner( + event_handler: Arc>, + event_kinds: EventKindMask, + windows_path_separator_style: WindowsPathSeparatorStyle, + meta_tx: Sender, ) -> Result { let (cmd_tx, cmd_rx) = unbounded(); @@ -522,10 +727,11 @@ impl ReadDirectoryChangesWatcher { tx: action_tx, cmd_rx, wakeup_sem, + windows_path_separator_style, }) } - fn wakeup_server(&mut self) { + fn wakeup_server(&self) { // breaks the server out of its wait state. right now this is really just an optimization, // so that if you add a watch you don't block for 100ms in watch() while the // server sleeps. @@ -545,8 +751,7 @@ impl ReadDirectoryChangesWatcher { let ack_pb = self .cmd_rx .recv() - .map_err(|_| Error::generic("Error receiving from command channel"))? - .map_err(|e| Error::generic(&format!("Error in watcher: {:?}", e)))?; + .map_err(|_| Error::generic("Error receiving from command channel"))??; if pb.as_path() != ack_pb.as_path() { Err(Error::generic(&format!( @@ -560,34 +765,32 @@ impl ReadDirectoryChangesWatcher { } fn watch_inner(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { - let pb = if path.is_absolute() { - path.to_owned() - } else { - let p = env::current_dir().map_err(Error::io)?; - p.join(path) - }; + let separator_style = SeparatorStyle::resolve(self.windows_path_separator_style, path); + let pb = WatchPath::new(path)?; // path must exist and be either a file or directory - if !pb.is_dir() && !pb.is_file() { + if !pb.absolute.is_dir() && !pb.absolute.is_file() { return Err(Error::generic( "Input watch path is neither a file nor a directory.", )); } - self.send_action_require_ack(Action::Watch(pb.clone(), recursive_mode), &pb) + self.send_action_require_ack( + Action::Watch(pb.clone(), recursive_mode, separator_style), + &pb.absolute, + ) } fn unwatch_inner(&mut self, path: &Path) -> Result<()> { - let pb = if path.is_absolute() { - path.to_owned() - } else { - let p = env::current_dir().map_err(Error::io)?; - p.join(path) - }; - let res = self - .tx - .send(Action::Unwatch(pb)) - .map_err(|_| Error::generic("Error sending to internal channel")); + let pb = absolute_path(path)?; + self.send_action_require_ack(Action::UnwatchAck(pb.clone()), &pb) + } + + fn watched_paths_inner(&self) -> Result> { + let (tx, rx) = unbounded(); + self.tx + .send(Action::GetWatchedPaths(tx)) + .map_err(|_| Error::generic("Error sending to internal channel"))?; self.wakeup_server(); - res + rx.recv().map_err(Error::from) } } @@ -597,7 +800,12 @@ impl Watcher for ReadDirectoryChangesWatcher { // TODO: determine the original purpose of this - can we remove it? let (meta_tx, _) = unbounded(); let event_handler = Arc::new(Mutex::new(event_handler)); - Self::create(event_handler, config.event_kinds(), meta_tx) + Self::create_inner( + event_handler, + config.event_kinds(), + config.windows_path_separator_style(), + meta_tx, + ) } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { @@ -614,6 +822,10 @@ impl Watcher for ReadDirectoryChangesWatcher { rx.recv()? } + fn watched_paths(&self) -> Result> { + self.watched_paths_inner() + } + fn kind() -> crate::WatcherKind { WatcherKind::ReadDirectoryChangesWatcher } @@ -635,9 +847,19 @@ unsafe impl Sync for ReadDirectoryChangesWatcher {} #[cfg(test)] pub mod tests { - use tempfile::tempdir; - - use crate::{test::*, ReadDirectoryChangesWatcher, RecursiveMode, Watcher}; + use std::env; + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{mpsc, Arc, Mutex}; + use std::thread; + use tempfile::{tempdir, tempdir_in}; + + use super::{normalize_path_separators, trim_leading_separators, SeparatorStyle}; + use crate::{ + test::*, ReadDirectoryChangesWatcher, RecursiveMode, Watcher, WindowsPathSeparatorStyle, + }; use std::time::Duration; @@ -669,6 +891,231 @@ pub mod tests { check::(); } + #[test] + fn posix_watch_path_uses_slash_separator_style() { + let style = SeparatorStyle::from_path(Path::new("G:/Feature film/")); + assert!(matches!(style, SeparatorStyle::Slash)); + } + + #[test] + fn explicit_separator_style_overrides_watch_path_style() { + let style = SeparatorStyle::resolve( + WindowsPathSeparatorStyle::Backslash, + Path::new("G:/Feature film/"), + ); + assert!(matches!(style, SeparatorStyle::Backslash)); + } + + #[test] + fn trim_leading_separators_removes_root_separators() { + let input = [ + '\\' as u16, + '/' as u16, + 's' as u16, + 'u' as u16, + 'b' as u16, + '\\' as u16, + 'f' as u16, + 'i' as u16, + 'l' as u16, + 'e' as u16, + ]; + let trimmed = trim_leading_separators(&input); + assert_eq!( + trimmed, + [ + 's' as u16, + 'u' as u16, + 'b' as u16, + '\\' as u16, + 'f' as u16, + 'i' as u16, + 'l' as u16, + 'e' as u16, + ] + ); + } + + #[test] + fn normalize_joined_event_path_for_posix_watch_path() { + let dir = PathBuf::from("G:/Feature"); + let raw_event_name: Vec = "\\22.mp4".encode_utf16().collect(); + let relative = PathBuf::from(OsString::from_wide(trim_leading_separators( + &raw_event_name, + ))); + let path = normalize_path_separators(dir.join(relative), SeparatorStyle::Slash); + + assert_eq!(path, PathBuf::from("G:/Feature/22.mp4")); + } + + #[test] + fn normalize_path_separators_keeps_windows_namespace_prefix() { + let path = PathBuf::from(r"\\?\C:\very\long\file"); + let normalized = normalize_path_separators(path, SeparatorStyle::Slash); + assert_eq!(normalized, PathBuf::from(r"\\?\C:/very/long/file")); + } + + #[test] + fn access_denied_existence_error_does_not_emit_remove() { + use crate::event::{EventKind, RemoveKind}; + use std::ptr; + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::System::Threading::CreateSemaphoreW; + use windows_sys::Win32::System::IO::OVERLAPPED; + + let invalid_path = PathBuf::from(OsString::from_wide(&[0])); + assert!(invalid_path.try_exists().is_err()); + + let complete_sem = unsafe { CreateSemaphoreW(ptr::null_mut(), 0, 1, ptr::null_mut()) }; + assert!(!complete_sem.is_null()); + assert_ne!(complete_sem, INVALID_HANDLE_VALUE); + + let (event_tx, event_rx) = mpsc::channel(); + let (action_tx, _action_rx) = crate::unbounded(); + let event_handler: Arc> = Arc::new(Mutex::new(event_tx)); + let request = Box::new(super::ReadDirectoryRequest { + event_handler, + event_kinds: crate::EventKindMask::ALL, + buffer: [0u8; super::BUF_SIZE as usize], + handle: INVALID_HANDLE_VALUE, + data: super::ReadData { + watch_path: invalid_path.clone(), + dir: invalid_path.clone(), + reported_dir: invalid_path, + file: None, + complete_sem, + is_recursive: false, + separator_style: SeparatorStyle::Backslash, + }, + action_tx, + }); + let mut overlapped = Box::new(unsafe { std::mem::zeroed::() }); + overlapped.hEvent = Box::into_raw(request) as _; + + unsafe { + super::handle_event(ERROR_ACCESS_DENIED, 0, Box::into_raw(overlapped)); + CloseHandle(complete_sem); + } + + let events = event_rx.try_iter().collect::>(); + assert!( + !events.iter().any(|res| matches!( + res, + Ok(event) if event.kind == EventKind::Remove(RemoveKind::Folder) + )), + "unexpected remove event: {events:#?}" + ); + } + + #[test] + fn request_unwatch_uses_watch_key_not_read_directory() { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + + let watch_path = PathBuf::from(r"C:\tmp\file.txt"); + let read_dir = PathBuf::from(r"C:\tmp"); + let (event_tx, _event_rx) = mpsc::channel::>(); + let (action_tx, action_rx) = crate::unbounded(); + let event_handler: Arc> = Arc::new(Mutex::new(event_tx)); + + let request = super::ReadDirectoryRequest { + event_handler, + event_kinds: crate::EventKindMask::ALL, + buffer: [0u8; super::BUF_SIZE as usize], + handle: INVALID_HANDLE_VALUE, + data: super::ReadData { + watch_path: watch_path.clone(), + dir: read_dir, + reported_dir: PathBuf::from(r"C:\tmp"), + file: Some(watch_path.clone()), + complete_sem: INVALID_HANDLE_VALUE, + is_recursive: false, + separator_style: SeparatorStyle::Backslash, + }, + action_tx, + }; + + request.unwatch(); + + match action_rx.recv().expect("receive action") { + super::Action::Unwatch(path) => assert_eq!(path, watch_path), + _ => panic!("unexpected action"), + } + } + + #[test] + fn auto_separator_style_keeps_relative_slash_watch_style( + ) -> std::result::Result<(), Box> { + let cwd = env::current_dir()?; + let root = tempdir_in(&cwd)?; + let watched_dir = root.path().join("sub").join("dir"); + std::fs::create_dir_all(&watched_dir)?; + let watched_file = watched_dir.join("entry"); + let relative_watch_path = watched_dir.strip_prefix(&cwd)?; + let slash_watch_path = + PathBuf::from(relative_watch_path.to_string_lossy().replace('\\', "/")); + + let (mut watcher, mut rx) = channel_with_config::( + ChannelConfig::default().with_watcher_config( + crate::Config::default() + .with_windows_path_separator_style(WindowsPathSeparatorStyle::Auto), + ), + ); + watcher.watch_nonrecursively(&slash_watch_path); + + std::fs::File::create_new(&watched_file)?; + + let expected_path = + normalize_path_separators(slash_watch_path.join("entry"), SeparatorStyle::Slash); + rx.wait_unordered([expected(&expected_path).create_file()]); + + Ok(()) + } + + #[test] + fn single_file_filter_matches_with_explicit_separator_style() { + let watched_file = PathBuf::from(r"G:\Feature\a.txt"); + let watched_dir = PathBuf::from(r"G:\Feature"); + + let separator_style = + SeparatorStyle::resolve(WindowsPathSeparatorStyle::Slash, &watched_file); + let normalized_watch_path = + normalize_path_separators(watched_file.clone(), separator_style); + + let raw_event_name: Vec = "\\a.txt".encode_utf16().collect(); + let relative_path = PathBuf::from(OsString::from_wide(trim_leading_separators( + &raw_event_name, + ))); + let normalized_event_path = + normalize_path_separators(watched_dir.join(relative_path), separator_style); + + assert_eq!(normalized_watch_path, normalized_event_path); + assert_eq!(normalized_event_path, PathBuf::from("G:/Feature/a.txt")); + } + + #[test] + fn single_file_filter_matches_bare_relative_file() { + let watched_file = PathBuf::from("a.txt"); + let reported_dir = watched_file + .parent() + .map_or_else(PathBuf::new, Path::to_path_buf); + let separator_style = + SeparatorStyle::resolve(WindowsPathSeparatorStyle::Auto, &watched_file); + let normalized_watch_path = + normalize_path_separators(watched_file.clone(), separator_style); + + let raw_event_name: Vec = "a.txt".encode_utf16().collect(); + let relative_path = PathBuf::from(OsString::from_wide(trim_leading_separators( + &raw_event_name, + ))); + let normalized_event_path = + normalize_path_separators(reported_dir.join(relative_path), separator_style); + + assert_eq!(normalized_watch_path, normalized_event_path); + assert_eq!(normalized_event_path, watched_file); + } + #[test] fn create_file() { let tmpdir = testdir(); @@ -678,10 +1125,22 @@ pub mod tests { let path = tmpdir.path().join("entry"); std::fs::File::create_new(&path).expect("create"); - rx.wait_ordered_exact([expected(&path).create_any()]) + rx.wait_ordered_exact([expected(&path).create_file()]) .ensure_no_tail(); } + #[test] + fn recursive_temp_dir_write_reports_created_file_kind() { + let tmpdir = tempdir().expect("create tempdir"); + let (mut watcher, mut rx) = watcher(); + watcher.watch_recursively(tmpdir.path()); + + let path = tmpdir.path().join("new.txt"); + std::fs::write(&path, b"hello").expect("write"); + + rx.wait_ordered([expected(&path).create_file()]); + } + #[test] fn write_file() { let tmpdir = testdir(); @@ -763,6 +1222,20 @@ pub mod tests { rx.wait_ordered_exact([expected(&file).remove_any()]); } + #[test] + fn delete_self_dir() { + let tmpdir = testdir(); + let dir = tmpdir.path().join("dir"); + std::fs::create_dir(&dir).expect("create"); + + let (mut watcher, mut rx) = watcher(); + watcher.watch_nonrecursively(&dir); + + std::fs::remove_dir(&dir).expect("remove"); + + rx.wait_unordered([expected(&dir).remove_folder()]); + } + #[test] fn create_write_overwrite() { let tmpdir = testdir(); @@ -778,7 +1251,7 @@ pub mod tests { std::fs::rename(&overwriting_file, &overwritten_file).expect("rename"); rx.wait_ordered_exact([ - expected(&overwriting_file).create_any(), + expected(&overwriting_file).create(), expected(&overwriting_file).modify_any().multiple(), expected(&overwritten_file).remove_any(), expected(&overwriting_file).rename_from(), @@ -796,7 +1269,7 @@ pub mod tests { let path = tmpdir.path().join("entry"); std::fs::create_dir(&path).expect("create"); - rx.wait_ordered_exact([expected(&path).create_any()]) + rx.wait_ordered_exact([expected(&path).create_folder()]) .ensure_no_tail(); } @@ -913,7 +1386,7 @@ pub mod tests { std::fs::remove_file(&new_path).expect("remove"); rx.wait_ordered_exact([ - expected(&file1).create_any(), + expected(&file1).create(), expected(&file1).modify_any().multiple(), expected(&file2).modify_any().multiple(), expected(&file1).rename_from(), @@ -1023,17 +1496,100 @@ pub mod tests { watcher.watch_recursively(&tmpdir); std::fs::create_dir_all(&nested9).expect("create_dir_all"); - rx.wait_ordered_exact([ - expected(&nested1).create_any(), - expected(&nested2).create_any(), - expected(&nested3).create_any(), - expected(&nested4).create_any(), - expected(&nested5).create_any(), - expected(&nested6).create_any(), - expected(&nested7).create_any(), - expected(&nested8).create_any(), - expected(&nested9).create_any(), - ]) - .ensure_no_tail(); + rx.wait_ordered([ + expected(&nested1).create_folder(), + expected(&nested2).create_folder(), + expected(&nested3).create_folder(), + expected(&nested4).create_folder(), + expected(&nested5).create_folder(), + expected(&nested6).create_folder(), + expected(&nested7).create_folder(), + expected(&nested8).create_folder(), + expected(&nested9).create_folder(), + ]); + } + + #[test] + fn unwatch_waits_for_pending_callback_before_returning() { + let tmpdir = testdir(); + let watched_dir = tmpdir.path().join("watched"); + std::fs::create_dir(&watched_dir).expect("create watched dir"); + + let first = watched_dir.join("new_dir"); + let second = watched_dir.join("should_not_be_seen"); + let first_for_handler = first.clone(); + + let (event_tx, event_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (unwatch_done_tx, unwatch_done_rx) = mpsc::channel(); + let (finish_tx, finish_rx) = mpsc::channel(); + let blocked_once = Arc::new(AtomicBool::new(false)); + let blocked_once_for_handler = blocked_once.clone(); + + let mut watcher = ReadDirectoryChangesWatcher::new( + move |res: crate::Result| { + if let Ok(event) = &res { + if event.paths.iter().any(|path| path == &first_for_handler) + && !blocked_once_for_handler.swap(true, Ordering::SeqCst) + { + started_tx.send(()).expect("signal callback start"); + release_rx.recv().expect("release callback"); + } + } + + event_tx.send(res).expect("forward event"); + }, + crate::Config::default(), + ) + .expect("create watcher"); + watcher + .watch(&watched_dir, RecursiveMode::NonRecursive) + .expect("watch dir"); + + std::fs::create_dir(&first).expect("create first dir"); + started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("wait for callback to block"); + + let unwatch_path = watched_dir.clone(); + let join = thread::spawn(move || { + let mut watcher = watcher; + let result = watcher.unwatch(&unwatch_path); + unwatch_done_tx.send(result).expect("send unwatch result"); + finish_rx.recv().expect("finish watcher thread"); + }); + + assert!( + unwatch_done_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "unwatch returned before the pending callback finished" + ); + + release_tx.send(()).expect("release callback"); + unwatch_done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("wait for unwatch result") + .expect("unwatch dir"); + + std::fs::create_dir(&second).expect("create second dir"); + + let first_event = event_rx + .recv_timeout(Duration::from_secs(5)) + .expect("receive first event") + .expect("first event result"); + assert_eq!(first_event, expected(&first).create_folder()); + + while let Ok(res) = event_rx.recv_timeout(Duration::from_millis(200)) { + let event = res.expect("event result"); + assert!( + !event.paths.iter().any(|path| path == &second), + "unexpected event after unwatch: {event:#?}" + ); + } + + finish_tx.send(()).expect("finish watcher thread"); + join.join().expect("join watcher thread"); } } diff --git a/renovate.json b/renovate.json index 691b9cc2..58a9d3ed 100644 --- a/renovate.json +++ b/renovate.json @@ -2,7 +2,8 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", - "helpers:pinGitHubActionDigests" + "helpers:pinGitHubActionDigests", + "schedule:weekly" ], "minimumReleaseAge": "2 days", "packageRules": [] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 5e8fa873..91bc2bdb 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.85" +channel = "1.88" profile = "minimal" components = [ "clippy",