From 6d53c60f3885c3fcf833c80b514f1d99c5905da0 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sun, 23 Aug 2026 14:11:53 +0100 Subject: [PATCH 1/3] gate-auto-on-113: superseded by #88 #88 removes the 1.13 ceiling instead of gating it off, so this branch's gate-tightening and README caveat no longer apply. Its README version line is updated in #88. --- .JuliaFormatter.toml | 2 - .github/dependabot.yml | 7 - .github/workflows/CI.yml | 134 -- .github/workflows/CompatHelper.yml | 16 - .github/workflows/release-please.yml | 59 - .gitignore | 9 - .release-please-manifest.json | 3 - CHANGELOG.md | 515 ----- LICENSE | 201 -- Project.toml | 15 - README.md | 340 --- coverage.jl | 20 - docs/Project.toml | 2 - docs/make.jl | 55 - docs/src/api.md | 19 - docs/src/auto.md | 100 - release-please-config.json | 11 - scripts/collect_broken_cases.jl | 592 ------ src/BorrowChecker.jl | 56 - src/preferences.jl | 88 - src/safe/alias.jl | 360 ---- src/safe/auto_ir.jl | 19 - src/safe/callsite.jl | 519 ----- src/safe/checker.jl | 529 ----- src/safe/debug.jl | 336 --- src/safe/defs.jl | 295 --- src/safe/diagnostics.jl | 379 ---- src/safe/frontend.jl | 780 ------- src/safe/generated.jl | 114 - src/safe/ir_primitives.jl | 667 ------ src/safe/refine_types.jl | 402 ---- src/safe/summaries.jl | 939 --------- src/safe/utils.jl | 22 - test/FakeModule/LocalPreferences.toml | 2 - test/FakeModule/Project.toml | 8 - test/FakeModule/src/FakeModule.jl | 64 - test/Project.toml | 14 - test/auto_borrow_checker_tests.jl | 1874 ----------------- test/auto_hygiene_integration_tests.jl | 48 - test/auto_llvm_ir_tests.jl | 85 - test/auto_llvm_tests.jl | 8 - test/auto_printing_tests.jl | 313 --- test/auto_unsafe_api_tests.jl | 154 -- test/dynamic_expressions_integration_tests.jl | 36 - test/runtests.jl | 46 - test/test_jet.jl | 24 - 46 files changed, 10281 deletions(-) delete mode 100644 .JuliaFormatter.toml delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/CI.yml delete mode 100644 .github/workflows/CompatHelper.yml delete mode 100644 .github/workflows/release-please.yml delete mode 100644 .gitignore delete mode 100644 .release-please-manifest.json delete mode 100644 CHANGELOG.md delete mode 100644 LICENSE delete mode 100644 Project.toml delete mode 100644 README.md delete mode 100644 coverage.jl delete mode 100644 docs/Project.toml delete mode 100644 docs/make.jl delete mode 100644 docs/src/api.md delete mode 100644 docs/src/auto.md delete mode 100644 release-please-config.json delete mode 100644 scripts/collect_broken_cases.jl delete mode 100644 src/BorrowChecker.jl delete mode 100644 src/preferences.jl delete mode 100644 src/safe/alias.jl delete mode 100644 src/safe/auto_ir.jl delete mode 100644 src/safe/callsite.jl delete mode 100644 src/safe/checker.jl delete mode 100644 src/safe/debug.jl delete mode 100644 src/safe/defs.jl delete mode 100644 src/safe/diagnostics.jl delete mode 100644 src/safe/frontend.jl delete mode 100644 src/safe/generated.jl delete mode 100644 src/safe/ir_primitives.jl delete mode 100644 src/safe/refine_types.jl delete mode 100644 src/safe/summaries.jl delete mode 100644 src/safe/utils.jl delete mode 100644 test/FakeModule/LocalPreferences.toml delete mode 100644 test/FakeModule/Project.toml delete mode 100644 test/FakeModule/src/FakeModule.jl delete mode 100644 test/Project.toml delete mode 100644 test/auto_borrow_checker_tests.jl delete mode 100644 test/auto_hygiene_integration_tests.jl delete mode 100644 test/auto_llvm_ir_tests.jl delete mode 100644 test/auto_llvm_tests.jl delete mode 100644 test/auto_printing_tests.jl delete mode 100644 test/auto_unsafe_api_tests.jl delete mode 100644 test/dynamic_expressions_integration_tests.jl delete mode 100644 test/runtests.jl delete mode 100644 test/test_jet.jl diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml deleted file mode 100644 index d808d22..0000000 --- a/.JuliaFormatter.toml +++ /dev/null @@ -1,2 +0,0 @@ -# See https://domluna.github.io/JuliaFormatter.jl/stable/ for a list of options -style = "blue" diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 700707c..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml deleted file mode 100644 index 5565437..0000000 --- a/.github/workflows/CI.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: CI -on: - push: - branches: - - main - tags: ['*'] - pull_request: - workflow_dispatch: -concurrency: - # Skip intermediate builds: always. - # Cancel intermediate builds: only if it is a pull request build. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} -jobs: - test: - name: jl${{ matrix.version }}-${{ matrix.os }}-${{ matrix.arch }}-dd=${{ matrix.dispatch_doctor }}-${{ github.event_name }} - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created - actions: write - contents: read - strategy: - fail-fast: false - matrix: - version: - - '1.10' - - '1' - os: - - ubuntu-latest - arch: - - x64 - dispatch_doctor: - - 'disabled' - include: - - version: '1' - os: ubuntu-latest - arch: x64 - dispatch_doctor: 'enabled' - steps: - - uses: actions/checkout@v6 - - name: "Disable DispatchDoctor if needed" - if: ${{ matrix.dispatch_doctor == 'disabled' }} - run: sed -i 's/dispatch_doctor_mode = "error"/dispatch_doctor_mode = "disable"/' test/Project.toml - shell: bash - - uses: julia-actions/setup-julia@v3 - with: - version: ${{ matrix.version }} - arch: ${{ matrix.arch }} - - uses: julia-actions/cache@v3 - - uses: julia-actions/julia-buildpkg@v1 - - name: "Run tests + coverage" - run: | - julia --color=yes -e 'import Pkg; Pkg.add("Coverage")' - julia --color=yes --threads=auto --check-bounds=yes --depwarn=yes --code-coverage=user -e 'import Coverage; import Pkg; Pkg.activate("."); Pkg.test(coverage=true)' - julia --color=yes coverage.jl - shell: bash - - name: Upload coverage artifact - uses: actions/upload-artifact@v7 - with: - name: coverage-${{ matrix.version }}-${{ matrix.os }}-${{ matrix.arch }}-dd-${{ matrix.dispatch_doctor }} - path: lcov.info - - codecov: - name: Codecov upload - runs-on: ubuntu-latest - needs: - - test - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - steps: - - uses: actions/checkout@v6 - - name: Download all coverage artifacts - uses: actions/download-artifact@v8 - with: - pattern: coverage-* - path: coverage - - uses: codecov/codecov-action@v7 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: ./coverage - fail_ci_if_error: true - verbose: true - - nightly: - name: nightly-ubuntu-x64 - runs-on: ubuntu-latest - # Use an explicit `timeout` around the test command so that if nightly runs long, - # the job fails (which we tolerate) rather than the whole workflow being cancelled. - timeout-minutes: 70 - continue-on-error: true - steps: - - uses: actions/checkout@v6 - - uses: julia-actions/setup-julia@v3 - with: - version: 'nightly' - arch: x64 - - uses: julia-actions/cache@v3 - - uses: julia-actions/julia-buildpkg@v1 - - name: "Run tests (nightly)" - run: | - timeout 60m julia --color=yes --threads=auto --check-bounds=yes --depwarn=yes -e ' - import Pkg - Pkg.activate(".") - Pkg.test()' - shell: bash - - docs: - name: Documentation - runs-on: ubuntu-latest - needs: test - permissions: - contents: write - statuses: write - steps: - - uses: actions/checkout@v6 - - uses: julia-actions/setup-julia@v3 - with: - version: '1' - - name: Configure doc environment - run: | - julia --project=docs/ -e ' - using Pkg - Pkg.develop(PackageSpec(path=pwd())) - Pkg.instantiate()' - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-docdeploy@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DOCUMENTER_KEY_CAM: ${{ secrets.DAMTP_DEPLOY_KEY }} - - run: | - julia --project=docs -e ' - using Documenter: DocMeta, doctest - using BorrowChecker - DocMeta.setdocmeta!(BorrowChecker, :DocTestSetup, :(using BorrowChecker); recursive=true) - doctest(BorrowChecker)' diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml deleted file mode 100644 index cba9134..0000000 --- a/.github/workflows/CompatHelper.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: CompatHelper -on: - schedule: - - cron: 0 0 * * * - workflow_dispatch: -jobs: - CompatHelper: - runs-on: ubuntu-latest - steps: - - name: Pkg.add("CompatHelper") - run: julia -e 'using Pkg; Pkg.add("CompatHelper")' - - name: CompatHelper.main() - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} - run: julia -e 'using CompatHelper; CompatHelper.main()' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index b704925..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: release-please - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - issues: write - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: actions/checkout@v6 - with: - repository: MilesCranmerBot/release-please - ref: 911ad18bdc3bb52bfef564cfbcb35f31aac01df3 - path: release-please-src - - - uses: actions/setup-node@v6 - with: - node-version: '22' - - - name: Install release-please deps - working-directory: release-please-src - run: npm ci - - - name: Build release-please - working-directory: release-please-src - run: npm run compile - - - name: Create or update release PR - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node release-please-src/build/src/bin/release-please.js release-pr \ - --token "$GITHUB_TOKEN" \ - --repo-url "https://github.com/${{ github.repository }}" \ - --target-branch main \ - --config-file release-please-config.json \ - --manifest-file .release-please-manifest.json - - - name: Create GitHub release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node release-please-src/build/src/bin/release-please.js github-release \ - --token "$GITHUB_TOKEN" \ - --repo-url "https://github.com/${{ github.repository }}" \ - --target-branch main \ - --config-file release-please-config.json \ - --manifest-file .release-please-manifest.json diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 8ce8b9a..0000000 --- a/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.jl.*.cov -*.jl.cov -*.jl.mem -**/Manifest*.toml -target -Cargo.* -**/*.rs -**/tmp.jl -docs/src/index.md diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 8579006..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.4.6" -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 217fb4e..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,515 +0,0 @@ -# Changelog - -## [0.4.6](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.5...v0.4.6) (2026-06-10) - - -### Bug Fixes - -* handle PhiCNode liveness parity ([#72](https://github.com/MilesCranmer/BorrowChecker.jl/issues/72)) ([31058c3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/31058c3c20a627d38876f6a0fc0cf4ef4689fd94)) -* include root module in checked cache key ([#73](https://github.com/MilesCranmer/BorrowChecker.jl/issues/73)) ([f89f9e1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f89f9e18c8abdc403d95b84f23a822ef03000dda)) -* preserve aliases from memoryrefget ([#74](https://github.com/MilesCranmer/BorrowChecker.jl/issues/74)) ([010c5af](https://github.com/MilesCranmer/BorrowChecker.jl/commit/010c5affc05cb3b04daed5d732da686358ddacd6)) - -## [0.4.5](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.4...v0.4.5) (2026-03-23) - - -### Bug Fixes - -* handle missing LineNumberNode file/line ([#58](https://github.com/MilesCranmer/BorrowChecker.jl/issues/58)) ([855c156](https://github.com/MilesCranmer/BorrowChecker.jl/commit/855c1562ce7af4e1e1660ee81bbeb7912333833d)) - -## [0.4.4](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.3...v0.4.4) (2026-01-26) - - -### Bug Fixes - -* issue [#49](https://github.com/MilesCranmer/BorrowChecker.jl/issues/49) ([0e8bbeb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0e8bbebdc993c4a909478027bdcb59ac82506ee6)) -* issue [#49](https://github.com/MilesCranmer/BorrowChecker.jl/issues/49) ([9ae3e90](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9ae3e90017be72e1a7ef6531f159c8cfe28578c9)) - -## [0.4.3](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.2...v0.4.3) (2026-01-25) - - -### Bug Fixes - -* much cleaner treatment of unsafe ([5f7234c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5f7234c4499ee6e91298a1bbe518d9850f59572d)) -* poorly masked unsafe blocks ([4234dec](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4234decb679c5f38f91be33f12c85ddbb511e913)) -* poorly masked unsafe blocks ([0240a1c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0240a1cabe6bd038357ca901dd0962c8c4880af3)) -* safer bindings ([eedddfa](https://github.com/MilesCranmer/BorrowChecker.jl/commit/eedddfa66809e1de951d47b84069c46b82c0ef3d)) - -## [0.4.2](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.1...v0.4.2) (2026-01-24) - - -### Features - -* create `[@unsafe](https://github.com/unsafe)` macro and matching `[@safe](https://github.com/safe)` macro ([cea4efb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cea4efb58f2d27b609e8ee8dc96355c50c48c311)) -* rename other `[@auto](https://github.com/auto)` to `[@safe](https://github.com/safe)` ([244137f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/244137f332aee20b36a563aba8f3cd9eb3bc7aa6)) - - -### Bug Fixes - -* unsafe operation for semicolons ([10e5303](https://github.com/MilesCranmer/BorrowChecker.jl/commit/10e5303a3d626b3bf8de694fd67cd962f9d31999)) - -## [0.4.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.0...v0.4.1) (2026-01-20) - - -### Features - -* create debug system ([49ab087](https://github.com/MilesCranmer/BorrowChecker.jl/commit/49ab087c1e6f1460fc521ca78abf26201a382901)) - - -### Bug Fixes - -* additional edge cases ([cbc1de6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cbc1de60287dacf71143a70438e1a50afa1ad1a6)) -* additional edge cases ([ceab23f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ceab23f16ef66e636c84a6f9ab15c058592e6ef2)) -* eliminate assumptions about Base methods ([a62ff44](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a62ff44e77981f5df66b64bb5be086f646594fab)) -* ignore `Core` for `:user` scope ([e217d69](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e217d69abe0dfeea5a0418f919daf36fcc030fad)) -* ignore `Core` for `:user` scope ([a4c2b06](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a4c2b067c27ec0513d9e390d01fab5493ea6a33e)) -* JET identified error ([3811b9a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3811b9a798f5acf7598ec8e5cd211fd8f6cf97b8)) -* more failure cases ([5d45ee2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5d45ee24d3a55add1db2d963599951c7ff5b4a15)) -* special-case Tasks ([adac867](https://github.com/MilesCranmer/BorrowChecker.jl/commit/adac867539b8edc6f945c73827724bb00619349c)) -* special-case Tasks ([a2d656f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a2d656f64daefaf45c7bee5a17329db94b719f31)) -* work around a variety of edge cases ([c96ef7a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c96ef7aa4ed1e04d16be76364f38bf3d64bfce24)) - -## [0.4.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.3.1...v0.4.0) (2026-01-16) - - -### Features - -* better handling of foreigncall ([e457204](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e457204f2f86b981062978406d2a39e952c53156)) -* handle a subset of foreigncall effects ([5852b81](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5852b818e7200e517d9ec2e58d0f9626dd4fc6f1)) -* handle a subset of foreigncall effects ([cd467c2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cd467c229edc560c0b3f21e5414f0ef462da23e0)) -* permit recursive borrow checking ([4878584](https://github.com/MilesCranmer/BorrowChecker.jl/commit/48785847b64a004656d482025e0084cfd8b2098a)) - - -### Bug Fixes - -* add missing `Core.isa` ([4126463](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4126463e192699942ecc175a39368e4bccd19b6c)) -* add missing BoundsError ([1ab5ca5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1ab5ca5dd069df4b8a340eea6104dd505799a860)) -* behavior for module scoping and add test ([dc2b5a3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/dc2b5a3c3c7a28cbf056eaf6cd07e6e04b4f615a)) -* handle PhiCNode ([9e9aa4e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9e9aa4ec6d9afacd2cb56c47eaf492fd90e2600f)) -* incorrect return from generated ([89008fd](https://github.com/MilesCranmer/BorrowChecker.jl/commit/89008fd2848826dd8b0b31de98a78249edb70ec5)) -* optimization pass normalization ([5c86b33](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5c86b337820246047a7fde627962ce3c7e3c4b9f)) -* register Typeof ([7715a5c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7715a5cabe93ab754f11e9159064dfeface82174)) - -## [0.3.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.3.0...v0.3.1) (2026-01-14) - - -### Features - -* add simple tracking for Ptr objects ([4fb8391](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4fb839132dfc011ca9e6618d2c43e5480acd730c)) -* handle pointers better ([b2fa53b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b2fa53b000aeff58b481b52f05a8eb1b9af74d1d)) -* much faster caching ([71b7e96](https://github.com/MilesCranmer/BorrowChecker.jl/commit/71b7e96d4064f06c755001b54c6c386e4ac9704c)) - - -### Bug Fixes - -* returning duplicate tuples ([a9d7955](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a9d795517887ee6d7cffd76204616194fc3ff4b4)) - -## [0.3.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.2.1...v0.3.0) (2026-01-14) - - -### Features - -* better debug info ([e50152a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e50152a62135f3bf57e7d04bc00431322229144a)) -* cover additional cases ([0d30050](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0d300506518e760237d6e69ba756701dd202a376)) -* create IR borrowchecker ([90d5ea2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/90d5ea21fcc8f37137198c4d4467e70366456b08)) -* detecting borrow check violations recursively ([d0bd6af](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d0bd6af1dce3f2e1cc5d3ff53a06bc8f3ce1438a)) -* greatly accelerate analysis with local cache ([d421da0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d421da0ba1f8ac2868add3b356b5b904357443db)) -* handle file changes ([b1a5cb1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b1a5cb15d303a357585c1010c0ebe8c820fc424b)) -* handle more edgecases ([96bfdfd](https://github.com/MilesCranmer/BorrowChecker.jl/commit/96bfdfdc0acf56c9b159cef623135e8c527fe7c5)) -* more general version of kwcall ([9574332](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9574332f50cb012fafe5097dc3255d3481441f7d)) -* no need for wrapping blocks ([d90fc7c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d90fc7ccbd9b663f8d6760158f463dd7e1755294)) -* some closure compatibility ([1893534](https://github.com/MilesCranmer/BorrowChecker.jl/commit/18935348d5d36ccc15fb3e1baec1cc992c50b421)) -* track consumed values better ([3c928fb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3c928fb2683c657e3cfa52dba80151eb8167eda4)) -* try to improve printing ([832f656](https://github.com/MilesCranmer/BorrowChecker.jl/commit/832f656179c7fa244680058bad8aac92aa6bdb0e)) - - -### Bug Fixes - -* aliasing through kwcall ([6075967](https://github.com/MilesCranmer/BorrowChecker.jl/commit/607596786257a0adb4960f6e6d91e18aa4e95b33)) -* behavior for nested closures ([0b6a3dc](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0b6a3dc1955eb24a9b3d9a3bb403bce92df9ecbe)) -* behavior for some Ptr operations ([74e2ac0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/74e2ac076bb43f01f24a7cd406eb9359c77cb2b6)) -* caching of files ([4377014](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4377014912939e78725f935b36a948162790b169)) -* dont assume ! means anything ([549eee3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/549eee3caca93e89989c3a382f3c439ce5913e19)) -* foreigncall bug ([1c2637e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1c2637e4e98bc80461b6c440c43e922292387773)) -* handle some kw alias detection ([d455ac5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d455ac5f6162a5f7d47bce96d0bd639d8d5d5649)) -* inference barrier effects ([42f33dc](https://github.com/MilesCranmer/BorrowChecker.jl/commit/42f33dc7216bd4cf6ae5a07a49616272ce50bab1)) -* non-determinism of caching limit based on depth ([6b23311](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6b23311d143cce7c3a9234457133c6cd384c17d7)) -* only define core IR methods ([cb9a308](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cb9a308fd2aced343bb3ee3dd38d7a04f28da24a)) -* only enable automatic checks on valid julia ([741054e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/741054e52f06887786ba3f7a59860c6a27f8a133)) -* prevent cache cycles ([50d4629](https://github.com/MilesCranmer/BorrowChecker.jl/commit/50d4629fd6f91e256800a8bbf2fd76cc08286c60)) -* printing on nightly ([d5983e1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d5983e13b636df74a8584812484e64fdd22f2067)) -* repl printing ([74b2737](https://github.com/MilesCranmer/BorrowChecker.jl/commit/74b2737eb75c8c7c1ebdb00ccfd8525bc2c4c7af)) -* some printing issues in ir ([e3b9cff](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e3b9cff90828b541e776f9af2cf71b0feec0ac08)) -* use compact 1 on 1.12 ([56cdcdf](https://github.com/MilesCranmer/BorrowChecker.jl/commit/56cdcdff9a7b779dab53a6efaa95905690bd1f32)) - -## [0.2.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.2.0...v0.2.1) (2025-04-27) - - -### Features - -* add additional numerics overloads ([b8a6607](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b8a66077e5ecdda093f390cb16e52e8364538f00)) - - -### Bug Fixes - -* nested property writes in LazyAccessor ([f0787ec](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f0787ec31dc3c95072bb1de889fc64f3cf9e0ef4)) - -## [0.2.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.5...v0.2.0) (2025-04-27) - - -### ⚠ BREAKING CHANGES - -* make `@bc` default to immutable - -### Features - -* allow immutable borrow of mutable borrow ([009986d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/009986df16767afcbd75b7e7435f5f4b19af2b50)) -* allow immutable borrow of mutable borrow ([5b5f63c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5b5f63c07aaa84e53f9b671dff15614f493dd759)) -* make `[@bc](https://github.com/bc)` default to immutable ([b88c107](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b88c107be8ab0bd8895464e7969e3081bdbbe837)) - - -### Bug Fixes - -* forwarding of `randn` ([c9ed13a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c9ed13a628c9750969893eaabe58126d15e237a9)) -* forwarding of `randn` ([fb2eb29](https://github.com/MilesCranmer/BorrowChecker.jl/commit/fb2eb294931821f5c1d959c8303e58b58d9bc278)) - -## [0.1.5](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.4...v0.1.5) (2025-04-26) - - -### Bug Fixes - -* `@&` when wrapping type parameters ([fbdd908](https://github.com/MilesCranmer/BorrowChecker.jl/commit/fbdd908adea2c7b0e4441fa15e88cb0933488c8e)) -* `@&` when wrapping type parameters ([ebc1c60](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ebc1c607295ac0ef36a56f089f842dc5a40cdc30)) - -## [0.1.4](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.3...v0.1.4) (2025-04-25) - - -### Features - -* add basic broadcasting compatibility ([1a16166](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1a1616600c15eebe9c3c7d6d62ac60db99761050)) -* add basic broadcasting compatibility ([d8c15c5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d8c15c5db7038207505e97183d8b09468a0d6d77)) -* block wrapper objects from being captured ([4ba37e6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4ba37e60c53ceaf41e1a4f3470c408ec96a23011)) -* create `@&` macro for borrowed types ([f6cc68d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f6cc68dd0cd176d52574257593a9be9271ac016b)) -* create `Mutex` object for safe mutable references ([0681a74](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0681a749b6bbf51933ce6c351ab77323edf698f2)) -* create new `@&` shorthand ([4eac033](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4eac03364cbeb415d1852dbd3ab5de7d229d577b)) -* more locking API ([69795d8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/69795d85aa9bb679a0e5960b56f5255361d3b8b5)) -* more overloads of types ([f615bde](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f615bde2c80bf04176ef51a5904826026ab1503c)) -* more streamlined mutex interface ([bd02443](https://github.com/MilesCranmer/BorrowChecker.jl/commit/bd02443a431bddb080b54f759db2118a1fd8a9dd)) - -## [0.1.3](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.2...v0.1.3) (2025-04-13) - - -### Features - -* create `[@spawn](https://github.com/spawn)` macro for wrapped `Threads.[@spawn](https://github.com/spawn)` ([2857f83](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2857f833d967506ddba980ea78919621d533eb38)) -* draft `[@cc](https://github.com/cc)` macro for checking closures ([7e1d4a1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7e1d4a1688101b27a59cdd018b9eb49308376dd6)) -* overload `reshape` ([29d9bf4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/29d9bf4d1af470fab2e4a0de70ec3854b7cf3b2c)) -* overload `reshape` ([da394ef](https://github.com/MilesCranmer/BorrowChecker.jl/commit/da394efa62c76f36530f886f7ed3e77cdddfe134)) - - -### Bug Fixes - -* avoid expression parsing, use dynamic approach ([2253208](https://github.com/MilesCranmer/BorrowChecker.jl/commit/225320836dac93ffddd8d0d8be4706ef485f2a8f)) - -## [0.1.2](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.1...v0.1.2) (2025-04-12) - - -### Features - -* overload `adjoint` and `transpose` ([0c1e912](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0c1e9120d49c277be462261d26a4569bdc00fb50)) -* overload `adjoint` and `transpose` ([91bf818](https://github.com/MilesCranmer/BorrowChecker.jl/commit/91bf8183367ad51a76e84b58ca8b901cd0b5fd7d)) - -## [0.1.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.0...v0.1.1) (2025-04-11) - - -### Features - -* more collection overloads ([11820f0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/11820f06a4476313c48780cf26ca8f672b31eae7)) - -## [0.1.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.13...v0.1.0) (2025-04-10) - - -### Features - -* add `shuffle!(rng, ...)` overload ([22dc377](https://github.com/MilesCranmer/BorrowChecker.jl/commit/22dc3771c1c16208b43fa574cad34ee3434be9b7)) -* more overloads ([d2a4294](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d2a42946b4dc5dbdaeb48e70f399ac3011effa2d)) - -## [0.0.13](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.12...v0.0.13) (2025-04-09) - - -### Features - -* add `isless` to operators ([30cb625](https://github.com/MilesCranmer/BorrowChecker.jl/commit/30cb625e6c7baa61cb8916ba9d18197175da0fb6)) -* add `shuffle!` overload ([f409a6c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f409a6cadffa08696679d757be148c1bb80e2413)) -* have `[@bc](https://github.com/bc)` pass through static values ([573e089](https://github.com/MilesCranmer/BorrowChecker.jl/commit/573e08905932aec215a3fea69628cd9dc2ebbccb)) -* make `[@bc](https://github.com/bc)` work for shorthand kwargs ([90967c2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/90967c246531aa5ec4a064646a9e67def37a0263)) - -## [0.0.12](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.11...v0.0.12) (2025-04-08) - - -### ⚠ BREAKING CHANGES - -* remove experimental managed feature - -### Features - -* add safe `Base.copy` ([a03ed93](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a03ed93d92fb1fec88a166e1fdd6e6684b9a2640)) -* better error messages ([d13679b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d13679b29750a36a59afb04cf43014a24ac8320b)) -* better errors for mixed tuples in ref ([20cafc8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/20cafc8b5e511c1fe8538499815a55ed4b79c2df)) -* more operators for Number ([acc5829](https://github.com/MilesCranmer/BorrowChecker.jl/commit/acc5829cdf6033ba14ee9630f83ed07c16469ae8)) -* remove experimental managed feature ([86832d9](https://github.com/MilesCranmer/BorrowChecker.jl/commit/86832d9e65a92aa441c930f535c2fabc411d3afe)) - - -### Bug Fixes - -* ensure deepcopy inside `copy!` ([3b8ad27](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3b8ad2710eca5652e2a77eef42fd1ae53073b3e5)) -* some ambiguities ([50fbd22](https://github.com/MilesCranmer/BorrowChecker.jl/commit/50fbd223d86647fccc8924c0e4553758a9e2be0e)) - -## [0.0.11](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.10...v0.0.11) (2025-01-20) - - -### ⚠ BREAKING CHANGES - -* remove `@set` syntax - -### Features - -* remove `[@set](https://github.com/set)` syntax ([59f9b81](https://github.com/MilesCranmer/BorrowChecker.jl/commit/59f9b810804f3160add7bf0e27bd89adba73c85e)) - -## [0.0.10](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.9...v0.0.10) (2025-01-20) - - -### Features - -* add `Module` to `is_static = true` category ([a71ea45](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a71ea4550ff58f72fc8e8f7651ae8d9a37c6de1e)) -* allow `[@own](https://github.com/own)` on nested for loops ([b0c1412](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b0c1412846e441fefeecccfe2cc03fd3459d8806)) -* make String is_static ([aac4c5d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/aac4c5d903eb08317bf7d086ce63afc9804f8ca4)), closes [#4](https://github.com/MilesCranmer/BorrowChecker.jl/issues/4) - - -### Bug Fixes - -* cache collision with default UUID ([8bc21d6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/8bc21d6f496bca20191178fe6cb5d43eb50abfaa)) - -## [0.0.9](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.8...v0.0.9) (2025-01-19) - - -### Features - -* allow tuple assignment for `[@ref](https://github.com/ref)` ([3e7f0fb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3e7f0fb3414ea89482ecf9c7865afb4248ea9f26)) -* enable single-arg `[@own](https://github.com/own) x` macro ([6f7ae59](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6f7ae592220625e51d43a7b38721ce77e6c35bd2)) -* more overloads ([405746f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/405746f7e3592a178926700d0724e172566b1a71)) - -## [0.0.8](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.7...v0.0.8) (2025-01-18) - - -### ⚠ BREAKING CHANGES - -* change `disable_borrow_checker!` to `disable_by_default!` - -### Features - -* change `disable_borrow_checker!` to `disable_by_default!` ([b2062e5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b2062e5aab55410154fd3efdcede351d4fc60a54)) - -## [0.0.7](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.6...v0.0.7) (2025-01-18) - - -### ⚠ BREAKING CHANGES - -* remove unneccessary promote_rule -* mark moved through lazy access in `@managed` context -* `@managed` maps keywords -* get `empty!` and `resize!` to not return vector -* more `nothing` returns -* change `@ref` syntax to use `~lt` instead of `lt` -* fix incorrect version increment - -### deps - -* fix incorrect version increment ([b9024eb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b9024eb54dde0e17ed15bc6abc68c845e3161406)) - - -### Features - -* `[@managed](https://github.com/managed)` maps keywords ([f97d437](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f97d437ca2e72bd22423d8b63b8f1f7ff89f08fa)) -* change `[@ref](https://github.com/ref)` syntax to use `~lt` instead of `lt` ([5a6a011](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5a6a011a3206ee25a7fe60decb5e67f6ca085f74)) -* correct `hash` definition ([5fbb747](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5fbb747117bd6e939b852968bbd307c75242b8fd)) -* mark moved through lazy access in `[@managed](https://github.com/managed)` context ([479fb9b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/479fb9b4bf1d4f870b85517d531e2da8bef52243)) -* prevent capturing lazy accessor of owned variables ([6de885f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6de885fd5e498a562c4da1b581fbb41f1bb78292)) - - -### Bug Fixes - -* get `empty!` and `resize!` to not return vector ([965e088](https://github.com/MilesCranmer/BorrowChecker.jl/commit/965e088adc9d42433b1231948346f7f04391fea1)) -* improved error message mentioning `[@ref](https://github.com/ref)` ([3e8e43c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3e8e43c04a082722ff227a422421cbf0d6189c0c)) -* more `nothing` returns ([080663b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/080663bae5e3a456082fc9ed062237d256ef8ea3)) -* property set on owned ([2613eff](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2613effcfc1e19b8a0e4798bd4edb577b1f308a2)) -* remove unneccessary promote_rule ([2064f15](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2064f15f7fd3ce5aa4379570eb94d9664884a3d5)) - -## [0.0.6](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.5...v0.0.6) (2025-01-16) - - -### ⚠ BREAKING CHANGES - -* move `@managed` to experimental submodule -* rename bind to own - -### Features - -* rename bind to own ([cbe3bf6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cbe3bf6e2f900396596363e7049f2e6e6a28fa0a)) - - -### Code Refactoring - -* move `[@managed](https://github.com/managed)` to experimental submodule ([256d5c0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/256d5c0d8288a9bf9f732b2d98f02946326f17ff)) - -## [0.0.5](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.4...v0.0.5) (2025-01-12) - - -### ⚠ BREAKING CHANGES - -* dont validate symbols for borrowed values -* avoid deepcopy on static when turned off - -### Features - -* avoid deepcopy on static when turned off ([e9fefa4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e9fefa4ad88ef8acd51e118a77f93e00255baed6)) -* dont validate symbols for borrowed values ([094ddce](https://github.com/MilesCranmer/BorrowChecker.jl/commit/094ddce1dafa03d97543c41c71f51cec0d1cf85f)) - -## [0.0.4](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.3...v0.0.4) (2025-01-12) - - -### ⚠ BREAKING CHANGES - -* expand definition of automatically copyable types - -### Features - -* add a couple fallback options ([85b2565](https://github.com/MilesCranmer/BorrowChecker.jl/commit/85b256575400d15134a38df27421b9cf58f1caac)) -* add abstract types ([feebb7d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/feebb7d89b50a79528452befa0f0a2fa4c57ca8a)) -* expand definition of automatically copyable types ([42e2cce](https://github.com/MilesCranmer/BorrowChecker.jl/commit/42e2ccefee5370f197d4e114c2188ed9f1bf0c7d)) -* extensions of LazyAccessorOf ([33eba5c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/33eba5cb9f026d3bf2e9d6d70e54117a3092f2b3)) -* flag captured bound variables in closures ([9db33a1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9db33a121620e4dab2a9a04a913a5ce9c4a043be)) -* various quality of life overloads ([cb73219](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cb732197bb2bd549b984c162d3d4f2c360dc5852)) - - -### Bug Fixes - -* view of LazyAccessor ([744ee47](https://github.com/MilesCranmer/BorrowChecker.jl/commit/744ee47a41881fc3b94306ee29d8df353cb8352e)) - -## [0.0.3](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.2...v0.0.3) (2025-01-12) - - -### ⚠ BREAKING CHANGES - -* change syntax `@take` -> `@take!` -* create `LazyAccessor` to allow subproperty mutation - -### Features - -* allow `[@bind](https://github.com/bind)` used like `[@move](https://github.com/move)` ([cd3ea50](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cd3ea506e83ff25f9a70c898e844f473bbf147ab)) -* allow `[@ref](https://github.com/ref)` for loops ([7779280](https://github.com/MilesCranmer/BorrowChecker.jl/commit/77792803a629414b95abee41ecc6639c5bf3530e)) -* allow tuple unpacking for `[@bind](https://github.com/bind)` ([266c7db](https://github.com/MilesCranmer/BorrowChecker.jl/commit/266c7dbca61d9df422fb4b9dcb3eb4a6c097f41f)) -* better errors for misuse ([7c5adde](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7c5adde726b6cd75291f002fc71f7048f6601e6f)) -* change syntax `[@take](https://github.com/take)` -> `[@take](https://github.com/take)!` ([3740550](https://github.com/MilesCranmer/BorrowChecker.jl/commit/37405508eb23d1c2ec0325fc7d188e27c6d71b4d)) -* create `LazyAccessor` to allow subproperty mutation ([496c287](https://github.com/MilesCranmer/BorrowChecker.jl/commit/496c2878daba94366ba35c828a251dc5dae9174d)) -* ensure `deepcopy` still happens when turned off ([641f800](https://github.com/MilesCranmer/BorrowChecker.jl/commit/641f8009ae0bda5fef17eb34fe60de951297a942)) -* helpful error for misuse of `bind` ([21e34c6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/21e34c61981dea798af904018a3e473801dbeb35)) -* iterator of mutable references ([ba09d5b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ba09d5b0778ec1e6945fb540dad89a50013b1348)) -* prevent borrowed object from being bound ([a583d34](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a583d3439103b826f59a85936e1358bbf0b20f31)) -* printing for LazyAccessor ([7d70f18](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7d70f18568f94c31a4efdbaa3f6a7fb3a8cbdb7c)) - - -### Bug Fixes - -* validate symbol missing anonymous ([a8d17c9](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a8d17c9dd3542baef00810fc71a62f5f9dc8bd11)) - -## [0.0.2](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.1...v0.0.2) (2025-01-10) - - -### ⚠ BREAKING CHANGES - -* change syntax `@own const` -> `@bind`, `@own` -> `@bind @mut` -* change `Owned` -> `Bound`, `OwnedMut` -> `BoundMut` -* change `@bind @mut` to `@bind :mut` -* symbol tracking in more macros -* mutable collection functions return nothing -* make `managed` a macro -* different syntax for `@ref` - -### Features - -* `[@atomic](https://github.com/atomic)` operations for mutable, just in case ([1501798](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1501798c865fd0fe0a017d4ca4898468edf6070e)) -* `bind` for for loops ([47c7919](https://github.com/MilesCranmer/BorrowChecker.jl/commit/47c7919c074d9698e1dbd14c0b8aff645dccdb4b)) -* add missing `eachindex` ([639351f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/639351fcedca7fd2faad4f053275725d1d10c796)) -* add more overloads ([01511a3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/01511a3c188228ac8cd6336593e1c7d932c1c1fa)) -* allow `[@managed](https://github.com/managed)` to work with isbits ([e35e23c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e35e23cafa53679762959866612a7835924306e9)) -* allow disabling borrow checker ([6ae8a29](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6ae8a29e3d41c180925ba44983eda4f7210e4be6)) -* automatically clone `isbits` ([9d919a8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9d919a8984b57c3530b50c88c8384c418de44d10)) -* change `[@bind](https://github.com/bind) [@mut](https://github.com/mut)` to `[@bind](https://github.com/bind) :mut` ([15b3c70](https://github.com/MilesCranmer/BorrowChecker.jl/commit/15b3c7093acd29e829a5e8b8099d4eb8b80b7876)) -* change `Owned` -> `Bound`, `OwnedMut` -> `BoundMut` ([18bb37c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/18bb37c4fa4e872134f6964b2edd9565a1c44601)) -* change syntax `[@own](https://github.com/own) const` -> `[@bind](https://github.com/bind)`, `[@own](https://github.com/own)` -> `[@bind](https://github.com/bind) [@mut](https://github.com/mut)` ([d111edd](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d111edd8cadd0658737f1c1baabbaabbb0f0c7eb)) -* create `[@clone](https://github.com/clone)` operator ([40301f0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/40301f0b424700661d141a88bdaae2d6d09b5911)) -* create `managed()` context with Cassette.jl ([68d7aa9](https://github.com/MilesCranmer/BorrowChecker.jl/commit/68d7aa9e2c1f9596618e12d322fef5387d04aa6b)) -* different syntax for `[@ref](https://github.com/ref)` ([31f1ef4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/31f1ef40cfe1ed79e86d1e39d357d26fb6d0f24c)) -* disable `managed` too ([def2317](https://github.com/MilesCranmer/BorrowChecker.jl/commit/def2317a5e377c7156bb1c87aabae04ba44b0498)) -* feature to disable manually ([cc5f581](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cc5f5813b48a23b97e4807a900cb5b0632c56709)) -* iteration for `Borrowed` ([ee30237](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ee302378e6fc1de36d605cbba97232c43fd956c3)) -* make `managed` a macro ([db41870](https://github.com/MilesCranmer/BorrowChecker.jl/commit/db41870aa4768edbe59dd6c98b0ca923dcf7533a)) -* mutable bindings in loop ([6cd0f92](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6cd0f9277e9f0165a51f6c7918abdf54708b0ccf)) -* symbol tracking in more macros ([3d99c07](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3d99c07080c9414c135c6be01f2b98f65f98f53c)) - - -### Bug Fixes - -* additional uses of `isbits` ([9371368](https://github.com/MilesCranmer/BorrowChecker.jl/commit/937136853f88092f85ac7dba9f28e674be5e0520)) -* additional uses of `isbits` ([349df2a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/349df2aec816bfd1702714041931c9e12f8226c0)) -* additional uses of `isbits` ([995824b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/995824b124ec63d141e78c001ee1d8c57fd47db1)) -* avoid using `threadid` which can change ([f2fa07d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f2fa07d557a4ab5ec5d460e6c5edf6579baa143c)) -* bad signature for lifetime ([a7a2470](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a7a24705000809cdaf153d79ef282a74a8b036f0)) -* better error ([b493532](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b493532046e9044f9f4bdae5e2ebc0bda9b223e6)) -* check for moved in `managed()` ([7cf3a33](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7cf3a33823146db11dad87e9826476f432acd231)) -* iter for AllBound ([eb8c6b3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/eb8c6b315d062096fb9e57d7b7d773a63ec5d391)) -* managed borrows ([42cfd40](https://github.com/MilesCranmer/BorrowChecker.jl/commit/42cfd405272558f7558c5bac132ede2e6416f2b3)) -* mutable collection functions return nothing ([2aff2f8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2aff2f8249825dc322add81a38aafcff0c15729c)) -* old error message ([a4af972](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a4af972ddca93908b18a3e0d05f73a227cc30f33)) -* test of `[@managed](https://github.com/managed)` ([196fe06](https://github.com/MilesCranmer/BorrowChecker.jl/commit/196fe069f2bcbc4416dbce80a74f429c99c47a0f)) - -## 0.0.1 (2025-01-10) - - -### ⚠ BREAKING CHANGES - -* ban single-arg `@move` -* tweak `@ref` syntax -* replace `@own` -> `@own const`, `@own_mut` -> `@own` -* replace `@ref` -> `@ref const`, `@ref_mut` -> `@ref` -* change `@move` symantics to specify mutability - -### Features - -* add 2-arg `rem` ([60b7595](https://github.com/MilesCranmer/BorrowChecker.jl/commit/60b7595fdb71e48433478350a7a287dd25db129c)) -* add basic math operations ([91c57f0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/91c57f0f1964ecb2cd4a9c04135a3fc3cc5b4cd7)) -* allow references in threads ([c9bf188](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c9bf188f8dddf9ad40a3bc3da0f6e0bee23177b5)) -* ban single-arg `[@move](https://github.com/move)` ([c813362](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c8133624e1844b0a72351f4ab3a6a751ba38658d)) -* block mutable references in threads ([5d1450f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5d1450f63c2fbe054244d4359cf3f5c527e61693)) -* change `[@move](https://github.com/move)` symantics to specify mutability ([24006b3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/24006b31593501beea36ab296262e4aa318a33e7)) -* implement more parts of array interface ([33ed363](https://github.com/MilesCranmer/BorrowChecker.jl/commit/33ed363cbf4c5b9d9bd571159a5461c4c9768681)) -* init sync and send traits ([a21e4a1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a21e4a1f901510888b23eb431b6ba0cb2aee6907)) -* let blocks for lifetime ([be79388](https://github.com/MilesCranmer/BorrowChecker.jl/commit/be79388a3cc9060d2c160c05a4e353ab9f38a51d)) -* more 3-arg operations on ::Number ([21afdc0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/21afdc0df7e1e12da9fcd2e9fb757f7fbc4700f8)) -* prevent passing to thread ([85a2b3e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/85a2b3e229e844a364d2db1256b05d3d8582fdd7)) -* prevent variable reassignment ([df43417](https://github.com/MilesCranmer/BorrowChecker.jl/commit/df434177d9c8fde8b9f2aeb0ec280ec4aa6681dc)) -* replace `[@own](https://github.com/own)` -> `[@own](https://github.com/own) const`, `[@own](https://github.com/own)_mut` -> `[@own](https://github.com/own)` ([974e683](https://github.com/MilesCranmer/BorrowChecker.jl/commit/974e6839519f5e2e8bc13292442d822fa9db0ac8)) -* replace `[@ref](https://github.com/ref)` -> `[@ref](https://github.com/ref) const`, `[@ref](https://github.com/ref)_mut` -> `[@ref](https://github.com/ref)` ([5c3b6f8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5c3b6f8b705fc5fe88fd3191349f00502092d4b9)) -* safer borrow checker with stored lifetime ([665f8ca](https://github.com/MilesCranmer/BorrowChecker.jl/commit/665f8ca52a1d4f2e595d4fe3bd1a2beda34842f7)) -* simple borrow checker ([fecc149](https://github.com/MilesCranmer/BorrowChecker.jl/commit/fecc149a37c03a3367312ff42962722d836250c8)) -* track symbol in `Owned` for debugging ([b348c65](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b348c65d24606cf1b4e043345fbacf79aa472dac)) -* tweak `[@ref](https://github.com/ref)` syntax ([6363629](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6363629a356b797865dd9e51ba60abb840bf6920)) - - -### Bug Fixes - -* ambiguity in `==` ([2715246](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2715246ad408365578e232dee3b66ae7352f74a1)) -* marked move on wrong scenario ([41938a0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/41938a02495f1b0c3721b291d761619d78331f93)) -* out-of-place import ([296bf1f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/296bf1f00e735bcc4a61f3bacc77a1a99e72bd0f)) -* prevent nested lifetimes ([3faeed4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3faeed411677ee5f5d8daffbcf3c7d2497b43767)) -* some macro hygiene issues ([c02a3f5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c02a3f58f774f06312eaec2141b056a7dcdde8df)) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 4eedc01..0000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/Project.toml b/Project.toml deleted file mode 100644 index 1228e7c..0000000 --- a/Project.toml +++ /dev/null @@ -1,15 +0,0 @@ -name = "BorrowChecker" -uuid = "7bdcaa52-c310-4bb0-bf54-d941056ed284" -version = "0.5.0" -authors = ["MilesCranmer "] - -[deps] -DispatchDoctor = "8d63f2c5-f18a-4cf2-ba9d-b3f60fc568c8" -Preferences = "21216c6a-2e73-6563-6e65-726566657250" -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" - -[compat] -DispatchDoctor = "0.4.19" -Preferences = "1.4" -Random = "1" -julia = "1.10" diff --git a/README.md b/README.md deleted file mode 100644 index 9664108..0000000 --- a/README.md +++ /dev/null @@ -1,340 +0,0 @@ -
- - - -# BorrowChecker.jl - -[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://astroautomata.com/BorrowChecker.jl/dev) -[![Build Status](https://github.com/MilesCranmer/BorrowChecker.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/MilesCranmer/BorrowChecker.jl/actions/workflows/CI.yml?query=branch%3Amain) -[![Coverage](https://coveralls.io/repos/github/MilesCranmer/BorrowChecker.jl/badge.svg?branch=main)](https://coveralls.io/github/MilesCranmer/BorrowChecker.jl?branch=main) - -
- -This is an experimental package for emulating a runtime borrow checker in Julia, using a macro layer over regular code. This is built to mimic Rust's ownership, lifetime, and borrowing semantics. This tool is mainly to be used in development and testing to flag memory safety issues, and help you design safer code. - -BorrowChecker.jl provides one layer: - -**Automatic checking (`BorrowChecker.@safe`)** -- Drop-in for existing Julia code: wrap a function and BorrowChecker will run a best-effort borrow check when that method specialization executes. -- It does not change program behavior (except for throwing when it finds a violation). - -In Julia, when you write `x = [1, 2, 3]`, the actual _object_ exists completely independently of the variable, and you can refer to it from as many variables as you want without issue: - -```julia -x = [1, 2, 3] -y = x -println(length(x)) -# 3 -``` - -Once there are no more references to the object, the "garbage collector" will work to free the memory. - -Rust is much different. For example, the equivalent code is **invalid** in Rust - -```rust -let x = vec![1, 2, 3]; -let y = x; -println!("{}", x.len()); -// error[E0382]: borrow of moved value: `x` -``` - -Rust refuses to compile this code. Why? Because in Rust, objects (`vec![1, 2, 3]`) are _owned_ by variables. When you write `let y = x`, the ownership of `vec![1, 2, 3]` is _moved_ to `y`. Now `x` is no longer allowed to access it. - -To fix this, we would either write - -```rust -let y = x.clone(); -// OR -let y = &x; -``` - -to either create a copy of the vector, or _borrow_ `x` using the `&` operator to create a reference. You can create as many references as you want, but there can only be one original object. - -This "ownership" paradigm can help improve safety of code. Especially in complex, multithreaded codebases, it is easy to shoot yourself in the foot and modify objects which are "owned" (editable) by something else. Rust's ownership and lifetime model makes it so that you can _prove_ memory safety of code! Standard thread races are literally impossible. (Assuming you are not using `unsafe { ... }` to disable safety features, or the borrow checker itself has a bug, etc.) - -In BorrowChecker.jl, we demonstrate an implementation of some of these ideas. The aim is to build a development layer that can help prevent a few classes of memory safety issues, without affecting runtime behavior of code. - -## Automatic Checking: `BorrowChecker.@safe` - -`BorrowChecker.@safe` automatically instruments a function by analyzing the compiler IR and runs a best-effort borrow check at runtime. This requires Julia 1.12.x (on 1.13+ the checker falls back to warn-and-pass-through stubs until support lands). - -> [!WARNING] -> This macro is highly experimental and compiler-dependent. There are likely bugs and false positives. It is intended for development and testing, and does not guarantee memory safety. - -### Options - -`@safe` supports a few options that are compiled into a `BorrowChecker.Config`: - -- `scope` (default `:function`): whether to recursively borrow-check callees (`:none`, `:function`, `:module`, `:user`, `:all`). -- `max_summary_depth` (default `12`): recursion depth limit for effect summarization when effects cannot be directly resolved. -- `optimize_until` (default varies): which compiler pass to stop at when fetching IR (`Base.code_ircode_by_type`). - -`scope` meanings: - -- `:none`: disable `@safe` entirely. -- `:function`: check only the annotated method. -- `:module`: recursively check callees defined in the module where `@safe` is used. -- `:user`: recursively check callees, but ignore `Core` and `Base` (including their submodules). -- `:all`: recursively check callees across all modules (very aggressive). - -The `@safe` checked-cache is keyed by specialization *and these options*, so checking a function once under `scope=:function` will not incorrectly skip a later recursive check under `scope=:module` / `:all`. - -`@safe` is meant to be a *drop-in tripwire* for existing code: - -- **Aliasing violations**: mutating a value while another live binding may observe that mutation. -- **Escapes / "moves"**: storing a mutable value somewhere that outlives the current scope (e.g. a global cache / a field / a container), then continuing to reference it locally. - -This analyzes the compiler’s IR, so it can catch patterns that are "hidden" by lowering (keyword calls, closure captures, views, etc.). It is intentionally **best-effort**: when it cannot determine what a call does, it will be conservative (and may throw false positives). For regions where the checker is overly conservative, silence them with `@unsafe` blocks. - -### How it works - -When you write: - -```julia -BorrowChecker.@safe function f(args...) - # ... -end -``` - -the macro rewrites the function so that: - -1. **On entry**, it runs a borrow check for the *current method specialization* (e.g. `f(::Vector{Int})`), and caches the result (so future calls are faster). -2. The checker asks Julia for the function's **typed compiler IR** (the lowered form the compiler optimizes). -3. It walks that IR and tracks two key things: - - Which bindings may refer to the **same mutable object** (aliasing). - - Which operations **write** to a tracked object or cause it to **escape** (be treated like a move). -4. When it sees an operation that would be illegal under Rust-like rules (e.g. "write while aliased", or "use after escape"), it throws a `BorrowCheckError` with a source-level-ish diagnostic. - -### Aliasing Detection - -BorrowChecker.jl's `@safe` macro can detect when values are modified through aliased bindings, and throw an error: - -```julia -julia> import BorrowChecker - -julia> BorrowChecker.@safe function f() - x = [1, 2, 3] - y = x - push!(x, 4) - return y - end -f (generic function with 1 method) - -julia> f() # errors -``` - -This will generate a helpful error pointing out the location of the borrow check violation, and the statement that violated the rule: - -``` -ERROR: BorrowCheckError for specialization Tuple{typeof(f)} - - method: f() @ Main REPL[7]:1 - - [1] stmt#7: cannot perform write: value is aliased by another live binding at REPL[7]:4 - 2 x = [1, 2, 3] - 3 y = x - > 4 push!(x, 4) - 5 return y - 6 end - - stmt: Main.push!(%5, 4) - -``` - -To fix it, simply copy the value, which will avoid the error: - -```julia -julia> BorrowChecker.@safe function f() - x = [1, 2, 3] - y = copy(x) - push!(x, 4) - return y - end -f (generic function with 1 method) - -julia> f() -3-element Vector{Int64}: - 1 - 2 - 3 -``` - -### Escape Detection - -Much like Rust's ownership model, BorrowChecker.jl's `@safe` macro attempts to infer when values escape their scope (moved/consumed) and throw an error if they are used afterwards. - -```julia -julia> const CACHE = Dict() -Dict{Any, Any}() - -julia> foo(x) = (CACHE[x] = 1; nothing) -foo (generic function with 1 method) - -julia> BorrowChecker.@safe function bar() - x = [1, 2] - foo(x) - return x - end -bar (generic function with 1 method) - -julia> bar() # errors -``` - -This generates the following error: - -``` -ERROR: BorrowCheckError for specialization Tuple{typeof(bar)} - - method: bar() @ Main REPL[13]:1 - - [1] stmt#6: value escapes/consumed by unknown call; it (or an alias) is used later at REPL[13]:3 - 1 BorrowChecker.@safe function bar() - 2 x = [1, 2] - > 3 foo(x) - 4 return x - 5 end - - stmt: Main.foo(%5) -``` - -Why is this an error? Because `x` was stored as a key in the cache, but is _mutable externally_. Furthermore, it is returned by `bar`! This is a violation of borrowing rules. Once the value gets stored in the cache, its ownership is _transferred_ to the cache, and is no longer accessible by `bar`. So this example is illegal. - -How can we fix it? We have two options. The first is we can copy the value before storing it: - -```julia -julia> BorrowChecker.@safe function bar() - x = [1, 2] - foo(copy(x)) - return x - end -bar (generic function with 1 method) - -julia> bar() # ok -``` - -We no longer have access to the object created by `copy(x)`, so the borrow check passes. -Alternatively, we can use immutable objects, which are safe to pass around: - -```julia -julia> BorrowChecker.@safe function bar() - x = (1, 2) - foo(x) - return x - end -bar (generic function with 1 method) - -julia> bar() # ok -``` - -### More `@safe` examples - -
-@safe analyzes the entire callstack - -BorrowChecker doesn't rely on naming conventions, such as the presence of `!` in the function name. It tries to infer effects from IR: - -```julia -julia> h(x) = (push!(x, 1); nothing) # no "!" in the name -h (generic function with 1 method) - -julia> BorrowChecker.@safe function demo() - x = [1, 2, 3] - y = x - h(x) - return y - end -demo (generic function with 1 method) - -julia> demo() # errors -``` -
- -
-Keyword arguments are handled (the checker sees lowered kwcall IR) - -Keyword calls get lowered into a `NamedTuple` + `Core.kwcall(...)`. `@safe` analyzes the lowered IR, so aliasing via keyword arguments is still visible: - -```julia -julia> f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) -f (generic function with 1 method) - -julia> BorrowChecker.@safe function kw_demo() - x = [1, 2, 3] - y = x - return sum(f(; x=x, y=y)) - end -kw_demo (generic function with 1 method) - -julia> kw_demo() # errors -``` -
- -
-Aliasing isn't only y = x: views can alias too - -```julia -julia> BorrowChecker.@safe function view_demo() - x = [1, 2, 3, 4] - y = view(x, 1:2) # aliases x - push!(x, 9) - return collect(y) - end -view_demo (generic function with 1 method) - -julia> view_demo() # errors -``` -
- -
-Closures are analyzed too - -```julia -julia> BorrowChecker.@safe function closure_demo() - x = [1, 2, 3] - y = x - f = () -> (push!(x, 9); nothing) - f() - return y - end -closure_demo (generic function with 1 method) - -julia> closure_demo() # errors -``` - -Read-only captures are typically fine. -
- - -## Opting out: `@unsafe` blocks - -`@unsafe` is an escape hatch analogous to `@inbounds`: inside the block, the -checker skips aliasing and escape validation entirely, and effects inside it -(writes, consumes, new aliases) do not propagate outward into the surrounding -analysis. You take responsibility for upholding the borrow rules yourself. - -```julia -julia> BorrowChecker.@safe function add_halves!(a::Vector{Float64}) - n = length(a) ÷ 2 - BorrowChecker.@unsafe begin - left = @view a[1:n] - right = @view a[(n + 1):(2n)] - left .+= right - end - return a - end -``` - -`@unsafe` also works on a single expression, e.g. to silence one unanalyzable -call in an otherwise checked function: - -```julia -julia> BorrowChecker.@safe w_gradient(f, x) = - BorrowChecker.@unsafe gradient(f, backend, x) -``` - -## Disabling BorrowChecker - -Set `borrow_checker = false` in your `LocalPreferences.toml` (via -Preferences.jl) to make every macro pass through untouched, or call -`disable_by_default!(@__MODULE__)` at the top of a module to ship it disabled -and enable checking in your test suite. diff --git a/coverage.jl b/coverage.jl deleted file mode 100644 index fb60bcc..0000000 --- a/coverage.jl +++ /dev/null @@ -1,20 +0,0 @@ -using Coverage -# process '*.cov' files -coverage = process_folder() # defaults to src/; alternatively, supply the folder name as argument -#=push!(coverage, process_folder("ext")...)=# - -LCOV.writefile("lcov.info", coverage) - -# process '*.info' files -coverage = merge_coverage_counts( - coverage, - filter!( - let prefixes = (joinpath(pwd(), "src", ""),) #=joinpath(pwd(), "ext", "")=# - c -> any(p -> startswith(c.filename, p), prefixes) - end, - LCOV.readfolder("test"), - ), -) -# Get total coverage for all Julia files -covered_lines, total_lines = get_summary(coverage) -@show covered_lines, total_lines diff --git a/docs/Project.toml b/docs/Project.toml deleted file mode 100644 index dfa65cd..0000000 --- a/docs/Project.toml +++ /dev/null @@ -1,2 +0,0 @@ -[deps] -Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" diff --git a/docs/make.jl b/docs/make.jl deleted file mode 100644 index 906a3fb..0000000 --- a/docs/make.jl +++ /dev/null @@ -1,55 +0,0 @@ -using Documenter -using BorrowChecker - -DocMeta.setdocmeta!(BorrowChecker, :DocTestSetup, :(using BorrowChecker); recursive=true) - -# Read and process README.md -readme = open(dirname(@__FILE__) * "/../README.md") do io - read(io, String) -end - -# Replace HTML image tags with markdown -readme = replace(readme, r"]+>.*" => s"![](\1)") - -# Remove div tags -readme = replace(readme, r"<[/]?div.*" => s"") - -# Create the index.md -open(dirname(@__FILE__) * "/src/index.md", "w") do io - # Add meta information - write( - io, - """ -```@meta -CurrentModule = BorrowChecker -``` - -""", - ) - write(io, readme) -end - -makedocs(; - modules=[BorrowChecker], - authors="Miles Cranmer and contributors", - repo="https://github.com/MilesCranmer/BorrowChecker.jl/blob/{commit}{path}#{line}", - sitename="BorrowChecker.jl", - format=Documenter.HTML(; - prettyurls=get(ENV, "CI", "false") == "true", - canonical="https://ai.damtp.cam.ac.uk/borrowcheckerjl", - edit_link="main", - assets=String[], - repolink="https://github.com/mcranmer/BorrowChecker.jl", - ), - pages=["Home" => "index.md", "`@safe`" => "auto.md", "API Reference" => "api.md"], - warnonly=[:missing_docs], # Allow missing docstrings -) - -deploydocs(; repo="github.com/MilesCranmer/BorrowChecker.jl", devbranch="main") - -# Mirror to DAMTP: -if haskey(ENV, "DOCUMENTER_KEY_CAM") - ENV["DOCUMENTER_KEY"] = ENV["DOCUMENTER_KEY_CAM"] - ENV["GITHUB_REPOSITORY"] = "ai-damtp-cam-ac-uk/borrowcheckerjl.git" - deploydocs(; repo="github.com/ai-damtp-cam-ac-uk/borrowcheckerjl.git", devbranch="main") -end diff --git a/docs/src/api.md b/docs/src/api.md deleted file mode 100644 index 4d750b9..0000000 --- a/docs/src/api.md +++ /dev/null @@ -1,19 +0,0 @@ -# API Reference - -```@meta -CurrentModule = BorrowChecker -``` - -## Automatic Checking - -```@docs -BorrowChecker.@safe -BorrowChecker.@unsafe -BorrowChecker.BorrowCheckError -``` - -## Preferences - -```@docs -BorrowChecker.PreferencesModule.disable_by_default! -``` diff --git a/docs/src/auto.md b/docs/src/auto.md deleted file mode 100644 index a043fcc..0000000 --- a/docs/src/auto.md +++ /dev/null @@ -1,100 +0,0 @@ -# `@safe` (IR Borrow Checker) - -```@meta -CurrentModule = BorrowChecker -``` - -`@safe` is an experimental, compiler-IR-based borrow checker intended as a **development tripwire** for ordinary Julia code. -On function entry it borrow-checks the current specialization and caches the result so subsequent calls are fast. -The cache key includes the *active `@safe` options* (so e.g. a later call with `scope=:module` will not be skipped just because `scope=:function` previously checked the same specialization). - -!!! warning - `@safe` is highly compiler-dependent. Expect false positives and false negatives. - It is for testing/debugging, not a safety guarantee. - -## Basic Usage - -```julia -using BorrowChecker: @safe - -@safe function f(x) - y = x - x[1] = 0 # may error if `y` can observe this mutation - return y -end -``` - -On failure, `@safe` throws `BorrowChecker.BorrowCheckError` with best-effort source context. - -## Options - -Options are parsed by the macro and compiled into a `BorrowChecker.Config`. - -### `scope` - -Controls whether the checker recursively borrow-checks callees (call-graph traversal): - -- `scope=:none`: disable `@safe` entirely (no IR borrow-checking). -- `scope=:function` (default): check only the annotated method. -- `scope=:module`: recursively check callees whose defining module matches the module where `@safe` is used. -- `scope=:user`: recursively check callees, but **ignore `Core` and `Base`** (including their submodules). -- `scope=:all`: recursively check callees across all modules (very aggressive; expect more work/edge cases). - -!!! note - For `scope=:module` / `scope=:user`, callees are filtered by the **defining module of the resolved method** (so user-defined extensions of `Base` functions are still treated as “in-module” when appropriate). - -Example: - -```julia -@safe scope=:module function outer(x) - return inner(x) -end -``` - -### `max_summary_depth` - -Limits recursive effect summarization depth used when the checker cannot directly resolve effects. - -```julia -@safe max_summary_depth=4 function f(x) - return g(x) -end -``` - -### `optimize_until` - -Controls which compiler pass to stop at when fetching IR via `Base.code_ircode_by_type`. - -```julia -@safe optimize_until="compact 1" function f(x) - return g(x) -end -``` - -Pass names vary across Julia versions; `@safe` normalizes common spellings like `"compact 1"` / `"compact_1"` when possible. - -### `debug` - -Enable debug logging (best-effort) to a JSONL file: - -```julia -@safe debug=true function f(x) - return g(x) -end -``` - -The output path is controlled by the `BORROWCHECKER_AUTO_DEBUG_PATH` environment variable (otherwise a file in `tempdir()` is used). -If `BORROWCHECKER_AUTO_DEBUG_PATH` is not set, `@safe debug=true` will emit a warning telling you where it is writing the file. - -### `debug_callee_depth` - -When `debug=true`, controls how deep in the recursive effect summarizer `@safe` also dumps IR (0 = only the entrypoint specialization). - -## Registry Overrides (advanced) - -The checker uses a small registry of effect specs for non-overloadable primitives. -You can add or override specs with: - -```julia -using BorrowChecker: register_effects! -``` diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index fa71632..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "bootstrap-sha": "855c1562ce7af4e1e1660ee81bbeb7912333833d", - "packages": { - ".": { - "release-type": "julia", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "include-component-in-tag": false - } - } -} diff --git a/scripts/collect_broken_cases.jl b/scripts/collect_broken_cases.jl deleted file mode 100644 index ebbe80d..0000000 --- a/scripts/collect_broken_cases.jl +++ /dev/null @@ -1,592 +0,0 @@ -#!/usr/bin/env julia - -using Pkg: Pkg - -Pkg.activate(@__DIR__; io=devnull) -Pkg.develop(Pkg.PackageSpec(; path=abspath(joinpath(@__DIR__, ".."))); io=devnull) -Pkg.instantiate(; io=devnull) - -using Dates -using TOML - -using BorrowChecker -using DynamicExpressions - -function _arg_value( - args::Vector{String}, flag::String, default::Union{Nothing,String}=nothing -) - for i in 1:length(args) - if args[i] == flag - return (i < length(args)) ? args[i + 1] : default - end - end - return default -end - -function _required_arg(args::Vector{String}, flag::String)::String - v = _arg_value(args, flag, nothing) - v === nothing && error("Missing required argument: $flag") - return v -end - -function _first_line_matching(path::String, needle::AbstractString) - i = 0 - for ln in eachline(path) - i += 1 - occursin(needle, ln) && return i - end - return nothing -end - -function _nearest_testset_name(path::String, line::Int) - lines = readlines(path) - i = min(line, length(lines)) - while i >= 1 - m = match(r"@testset\\s+\"([^\"]+)\"", lines[i]) - m === nothing || return m.captures[1] - i -= 1 - end - return nothing -end - -function _event_counts(jsonl_path::String) - isfile(jsonl_path) || return Dict{String,Int}() - counts = Dict{String,Int}() - for ln in eachline(jsonl_path) - m = match(r"\"event\":\"([^\"]+)\"", ln) - m === nothing && continue - ev = m.captures[1] - counts[ev] = get(counts, ev, 0) + 1 - end - return counts -end - -function _violation_dicts(err) - err isa BorrowChecker.BorrowCheckError || return Any[] - out = Any[] - for v in err.violations - file, line = if v.lineinfo === nothing - (nothing, nothing) - else - try - BorrowChecker._lineinfo_file_line(v.lineinfo) - catch - (nothing, nothing) - end - end - d = Dict{String,Any}("idx" => v.idx, "msg" => v.msg, "stmt" => string(v.stmt)) - file === nothing || (d["file"] = file) - line === nothing || (d["line"] = line) - push!(out, d) - end - return out -end - -function _toml_dict(pairs::Pair...) - d = Dict{String,Any}() - for (k, v) in pairs - v === nothing && continue - d[string(k)] = v - end - return d -end - -function run_case!( - case_id::String; - title::String, - source_file::String, - broken_marker_needle::String, - invoke::Function, - outdir::String, -) - jsonl_dir = joinpath(outdir, "jsonl") - meta_dir = joinpath(outdir, "meta") - mkpath(jsonl_dir) - mkpath(meta_dir) - - jsonl_path = joinpath(jsonl_dir, "$(case_id).jsonl") - rm(jsonl_path; force=true) - # Ensure the file exists even if no debug events are emitted (useful for tooling/reporting). - open(jsonl_path, "w") do _io - end - - err = nothing - ret = nothing - start = Dates.now(Dates.UTC) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => jsonl_path) do - try - ret = invoke() - catch e - err = e - end - end - stop = Dates.now(Dates.UTC) - - marker_line = _first_line_matching(source_file, broken_marker_needle) - testset = - marker_line === nothing ? nothing : _nearest_testset_name(source_file, marker_line) - - counts = _event_counts(jsonl_path) - ok = (err === nothing) - meta = _toml_dict( - "case_id" => case_id, - "title" => title, - "source_file" => source_file, - "broken_marker_needle" => broken_marker_needle, - "broken_marker_line" => marker_line, - "testset" => testset, - "julia_version" => string(VERSION), - "started_utc" => Dates.format(start, dateformat"yyyy-mm-ddTHH:MM:SS"), - "finished_utc" => Dates.format(stop, dateformat"yyyy-mm-ddTHH:MM:SS"), - "ok" => ok, - "return_value" => (ok ? string(ret) : nothing), - "error_type" => (ok ? nothing : string(typeof(err))), - "error" => (ok ? nothing : sprint(showerror, err)), - "borrowcheck_error" => (err isa BorrowChecker.BorrowCheckError), - "violation_count" => - (err isa BorrowChecker.BorrowCheckError ? length(err.violations) : 0), - "violations" => _violation_dicts(err), - "jsonl_path" => jsonl_path, - "jsonl_bytes" => (isfile(jsonl_path) ? filesize(jsonl_path) : 0), - "jsonl_event_counts" => counts, - "debug_cfg" => _toml_dict( - "debug" => true, "debug_callee_depth" => 2, "optimize_until" => "compact 1" - ), - ) - - open(joinpath(meta_dir, "$(case_id).toml"), "w") do io - TOML.print(io, meta) - end - - return meta -end - -Base.@noinline fakewrite(x) = Base.inferencebarrier(x) - -struct _BCBoxedField - n::Int -end - -struct _BCBoxedBroadcast - n::Int -end - -struct _BCThreadsBoxedRange - n::Int -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_boxed_getproperty_dim( - x::_BCBoxedField -) - g = () -> getfield(x, :n) - x = fakewrite(x) - a = zeros(Float64, getfield(x, :n)) - return (g(), length(a)) -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_boxed_broadcast_ok( - x::_BCBoxedBroadcast -) - g = () -> getfield(x, :n) - x = fakewrite(x) - b = rand(getfield(x, :n)) .< 0.5 - return (g(), sum(b)) -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_threads_boxed_range_ok( - x::_BCThreadsBoxedRange, flag::Bool -) - g = () -> getfield(x, :n) - x = fakewrite(x) - - r = 1:(getfield(x, :n)) - if flag - Base.Threads.@threads for i in r - fakewrite(i) - end - else - for i in r - fakewrite(i) - end - end - - return g() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_array_value_dim_ctor( - x -) - l = 1 - return Array{Int,l}(x) -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" bc_copy_ok( - ex -) = copy(ex) - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_lambda_arglist_symbol() - f = x -> x + 1 - return f(1) -end - -const _BC_LAMBDA_ARGLIST_NOTHING_EXPR = Expr(:(->), nothing, :(1)) -eval( - quote - BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_lambda_arglist_nothing() - f = $_BC_LAMBDA_ARGLIST_NOTHING_EXPR - return f() - end - end, -) - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_nested_function_bad() - function _bc_inner() - x = [1, 2, 3] - y = x - x[1] = 0 - return y - end - return _bc_inner() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_local_oneliner_bad() - _bc_inner() = begin - x = [1, 2, 3] - y = x - x[1] = 0 - return y - end - return _bc_inner() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_body_0arg() - f = () -> begin - x = [1, 2, 3] - y = x - push!(x, 9) - return y - end - return f() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_body_with_arg( - z -) - f = () -> begin - x = z - y = x - push!(x, 9) - return y - end - return f() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_ok_closure_body_0arg() - f = () -> begin - x = [1, 2, 3] - y = copy(x) - push!(x, 9) - return y - end - return f() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_ok_closure_body_with_arg( - z -) - f = () -> begin - x = copy(z) - y = copy(x) - push!(x, 9) - return y - end - return f() -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_view_alias() - x = [1, 2, 3, 4] - y = view(x, 1:2) - push!(x, 9) - return collect(y) -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_capture() - x = [1, 2, 3] - y = x - f = () -> (push!(x, 9); nothing) - f() - return y -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_capture_nested() - x = [1, 2, 3] - y = x - f = () -> begin - g = () -> (push!(x, 9); nothing) - g() - return nothing - end - f() - return y -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_ok_closure_capture_readonly() - x = [1, 2, 3] - y = x - f = () -> begin - s = 0 - for i in 1:length(y) - s += y[i] - end - return s - end - f() - return x -end - -BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_module_not_owned() - m = Base - g = Base.inferencebarrier(identity) - g(m) - return getproperty(m, :Math) -end - -function main() - outdir = _required_arg(ARGS, "--outdir") - mkpath(outdir) - - cases = Any[] - - push!( - cases, - run_case!( - "auto_boxed_getproperty_dim"; - title="boxed captured variable: getproperty field type refinement", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="boxed captured variable: getproperty field type refinement", - invoke=() -> _bc_boxed_getproperty_dim(_BCBoxedField(3)), - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_boxed_broadcast_ok"; - title="boxed captured variable: broadcast materialize should not consume", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="boxed captured variable: broadcast materialize should not consume", - invoke=() -> _bc_boxed_broadcast_ok(_BCBoxedBroadcast(10)), - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_threads_boxed_range_ok"; - title="Threads.@threads plumbing should not spuriously consume", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="Threads.@threads plumbing should not spuriously consume", - invoke=() -> _bc_threads_boxed_range_ok(_BCThreadsBoxedRange(5), false), - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_array_value_dim_ctor"; - title="known failure: Array{Int,l}(x) with value l", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="known failure: Array{Int,l}(x) with value l", - invoke=() -> _bc_array_value_dim_ctor([1]), - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_lambda_arglist_symbol"; - title="lambda arglist: single argument", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="lambda arglist: single argument", - invoke=_bc_lambda_arglist_symbol, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_lambda_arglist_nothing"; - title="lambda arglist: args_expr === nothing", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="lambda arglist: args_expr === nothing", - invoke=_bc_lambda_arglist_nothing, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_nested_function_bad"; - title="nested function definitions are instrumented", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="nested function definitions are instrumented", - invoke=_bc_nested_function_bad, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_local_oneliner_bad"; - title="local one-line method definitions are instrumented", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="local one-line method definitions are instrumented", - invoke=_bc_local_oneliner_bad, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_bad_closure_body_0arg"; - title="_bc_bad_closure_body_0arg", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_bad_closure_body_0arg", - invoke=_bc_bad_closure_body_0arg, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_bad_closure_body_with_arg"; - title="_bc_bad_closure_body_with_arg", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_bad_closure_body_with_arg", - invoke=() -> _bc_bad_closure_body_with_arg([1, 2, 3]), - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_ok_closure_body_0arg"; - title="_bc_ok_closure_body_0arg", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_ok_closure_body_0arg", - invoke=_bc_ok_closure_body_0arg, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_ok_closure_body_with_arg"; - title="_bc_ok_closure_body_with_arg", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_ok_closure_body_with_arg", - invoke=() -> _bc_ok_closure_body_with_arg([1, 2, 3]), - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_bad_view_alias"; - title="_bc_bad_view_alias", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_bad_view_alias", - invoke=_bc_bad_view_alias, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_bad_closure_capture"; - title="_bc_bad_closure_capture", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_bad_closure_capture()", - invoke=_bc_bad_closure_capture, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_bad_closure_capture_nested"; - title="_bc_bad_closure_capture_nested", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_bad_closure_capture_nested", - invoke=_bc_bad_closure_capture_nested, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_ok_closure_capture_readonly"; - title="_bc_ok_closure_capture_readonly", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="_bc_ok_closure_capture_readonly", - invoke=_bc_ok_closure_capture_readonly, - outdir=outdir, - ), - ) - - push!( - cases, - run_case!( - "auto_module_not_owned"; - title="modules are not owned (avoid spurious consumes)", - source_file=joinpath("test", "auto_borrow_checker_tests.jl"), - broken_marker_needle="modules are not owned (avoid spurious consumes)", - invoke=_bc_module_not_owned, - outdir=outdir, - ), - ) - - operators = OperatorEnum(1 => [exp], 2 => [+, -, *]) - x1 = Expression(Node{Float64}(; feature=1); operators) - push!( - cases, - run_case!( - "dynamic_expressions_copy_ok"; - title="DynamicExpressions: copy(::Expression) should not spuriously consume", - source_file=joinpath("test", "dynamic_expressions_integration_tests.jl"), - broken_marker_needle="BorrowChecker.@safe bc_copy_ok", - invoke=() -> bc_copy_ok(x1), - outdir=outdir, - ), - ) - - open(joinpath(outdir, "summary.toml"), "w") do io - TOML.print( - io, - Dict( - "toolchain_label" => _arg_value(ARGS, "--label", ""), - "julia_version" => string(VERSION), - "finished_utc" => - Dates.format(Dates.now(Dates.UTC), dateformat"yyyy-mm-ddTHH:MM:SS"), - "cases" => cases, - ), - ) - end -end - -main() diff --git a/src/BorrowChecker.jl b/src/BorrowChecker.jl deleted file mode 100644 index c0c7bd2..0000000 --- a/src/BorrowChecker.jl +++ /dev/null @@ -1,56 +0,0 @@ -module BorrowChecker - -include("preferences.jl") - -using .PreferencesModule: disable_by_default!, is_borrow_checker_enabled -using DispatchDoctor: @unstable - -# The borrow checker: `@safe` instruments methods with a compiler-IR borrow -# check at runtime. This is the entire library. -export @safe, @unsafe, disable_by_default! - -@static if isdefined(Base, :code_ircode_by_type) && v"1.12.0-" <= VERSION < v"1.13.0-" - @unstable include("safe/auto_ir.jl") - # `BorrowCheckError` and friends are defined by safe/auto_ir.jl. - export BorrowCheckError -else - # COV_EXCL_START - """ - BorrowChecker.@auto - - Deprecated alias for [`BorrowChecker.@safe`](@ref). - """ - "Unavailable `@auto` stub for unsupported Julia versions." - macro auto(args...) - ex = args[end] - is_borrow_checker_enabled(__module__) || return esc(ex) - Base.depwarn( - "`BorrowChecker.@auto` is deprecated; use `BorrowChecker.@safe` instead.", :auto - ) - @warn( - "BorrowChecker.@safe is not supported on this version of Julia.", - maxlog = 1, - ) - return esc(ex) - end - - "Unavailable `@safe` stub for unsupported Julia versions." - macro safe(args...) - ex = args[end] - is_borrow_checker_enabled(__module__) || return esc(ex) - @warn( - "BorrowChecker.@safe is not supported on this version of Julia.", - maxlog = 1, - ) - return esc(ex) - end - - "Unavailable `@unsafe` stub for unsupported Julia versions." - macro unsafe(ex) - # When the auto-IR checker is unavailable, `@unsafe` is a no-op. - return esc(ex) - end - # COV_EXCL_STOP -end - -end diff --git a/src/preferences.jl b/src/preferences.jl deleted file mode 100644 index da795f7..0000000 --- a/src/preferences.jl +++ /dev/null @@ -1,88 +0,0 @@ -""" - BorrowChecker.PreferencesModule - -Module for managing BorrowChecker preferences, including enabling/disabling borrow checking per module. -""" -module PreferencesModule # Largely borrowed from DispatchDoctor.jl - -using Preferences: load_preference, has_preference, get_uuid - -@enum IsCached::Bool begin - Cached - NotCached -end - -struct Cache{A,B} - cache::Dict{A,B} - lock::Threads.SpinLock - - Cache{A,B}() where {A,B} = new{A,B}(Dict{A,B}(), Threads.SpinLock()) # COV_EXCL_LINE -end - -const DEFAULT_UUID = Base.UUID(0) - -const UUID_CACHE = Cache{UInt64,Base.UUID}() -const PREFERENCE_CACHE = Cache{Base.UUID,Tuple{Bool,IsCached}}() -const MODULE_CACHE = Cache{Module,Bool}() - -function _cached_call(f::F, cache::Cache, key) where {F} - lock(cache.lock) do - key == DEFAULT_UUID ? f() : get!(f, cache.cache, key) - end -end - -function _cached_get_uuid(m) - _cached_call(UUID_CACHE, objectid(m)) do - try - get_uuid(m) - catch - DEFAULT_UUID - end - end -end - -function is_borrow_checker_enabled(calling_module) - uuid = _cached_get_uuid(calling_module) - (value, cached) = _cached_call(PREFERENCE_CACHE, uuid) do - if uuid == DEFAULT_UUID || !has_preference(uuid, "borrow_checker") - (true, NotCached) - else - (load_preference(uuid, "borrow_checker")::Bool, Cached) - end - end - if cached == Cached - return value - else - Base.@lock MODULE_CACHE.lock begin - if haskey(MODULE_CACHE.cache, calling_module) - return MODULE_CACHE.cache[calling_module] - else - MODULE_CACHE.cache[calling_module] = true - return value - end - end - end -end - -""" - disable_by_default!(m::Module) - -Make all BorrowChecker macros expand to pass-through within module `m` unless a -`LocalPreferences.toml` explicitly sets `borrow_checker = true`. Intended for -libraries that ship with checking disabled and enable it in their test suite. -Must be called before any BorrowChecker macro is used in `m`. -""" -function disable_by_default!(m::Module) - Base.@lock MODULE_CACHE.lock begin - if haskey(MODULE_CACHE.cache, m) && MODULE_CACHE.cache[m] - error( - "BorrowChecker preferences were already cached for module $m. " * - "Please call this function before any other BorrowChecker macros are used.", - ) - end - MODULE_CACHE.cache[m] = false - end - return nothing -end - -end diff --git a/src/safe/alias.jl b/src/safe/alias.jl deleted file mode 100644 index 022abec..0000000 --- a/src/safe/alias.jl +++ /dev/null @@ -1,360 +0,0 @@ -@inline _field_sym(x) = x isa QuoteNode ? x.value : x - -function _box_key(@nospecialize(box), ir::CC.IRCode, nargs::Int)::Int - box = _canonical_ref(box, ir) - if box isa Core.Argument - return box.n - elseif box isa Core.SSAValue - return _ssa_handle(nargs, box.id) - end - return 0 -end - -function _maybe_record_box_contents!( - box_contents::Dict{Int,Int}, - @nospecialize(f), - raw_args, - ir::CC.IRCode, - nargs::Int, - track_arg, - track_ssa, -) - f === Core.setfield! || return nothing - length(raw_args) >= 4 || return nothing - - _field_sym(raw_args[3]) === :contents || return nothing - - key = _box_key(raw_args[2], ir, nargs) - key == 0 && return nothing - - vh = _handle_index(raw_args[4], nargs, track_arg, track_ssa) - vh == 0 && return nothing - - box_contents[key] = vh - return nothing -end - -function _maybe_alias_box_contents!( - uf::UnionFind, - out_h::Int, - box_contents::Dict{Int,Int}, - @nospecialize(f), - raw_args, - ir::CC.IRCode, - nargs::Int, -) - f === Core.getfield || return nothing - length(raw_args) >= 3 || return nothing - - _field_sym(raw_args[3]) === :contents || return nothing - - key = _box_key(raw_args[2], ir, nargs) - key == 0 && return nothing - - in_h = get(box_contents, key, 0) - in_h == 0 && return nothing - - _uf_union!(uf, out_h, in_h) - return nothing -end - -function _push_all_user_args!(dest::Vector{Int}, raw_args) - for p in 2:length(raw_args) - push!(dest, p) - end - return dest -end - -function _maybe_ret_alias_summary( - stmt, - ir::CC.IRCode, - cfg::Config, - @nospecialize(f), - raw_args; - depth::Int, - budget_state=nothing, -) - if depth < cfg.max_summary_depth - if stmt.head === :invoke - return _summary_for_mi( - stmt.args[1], cfg; depth=depth + 1, budget_state=budget_state - ) - end - - if f === Core.kwcall - tt = _kwcall_tt_from_raw_args(raw_args, ir) - tt !== nothing && return _summary_for_tt( - tt, cfg; depth=depth + 1, budget_state=budget_state, allow_core=true - ) - return nothing - end - - tt = _call_tt_from_raw_args(raw_args, ir, f) - tt !== nothing && - return _summary_for_tt(tt, cfg; depth=depth + 1, budget_state=budget_state) - return nothing - end - - if stmt.head === :invoke - _mark_budget_hit!(budget_state) - else - tt = if f === Core.kwcall - _kwcall_tt_from_raw_args(raw_args, ir) - else - _call_tt_from_raw_args(raw_args, ir, f) - end - tt === nothing || _mark_budget_hit!(budget_state) - end - - return nothing -end - -function _ret_alias_positions_for_call( - stmt, - ir::CC.IRCode, - cfg::Config, - @nospecialize(f), - raw_args; - depth::Int, - budget_state=nothing, -) - alias_args = Int[] - - if f !== nothing - eff = _known_effects_get(f) - if eff !== nothing - for p in eff.ret_aliases - push!(alias_args, p) - end - return alias_args - end - end - - s = _maybe_ret_alias_summary( - stmt, ir, cfg, f, raw_args; depth=depth, budget_state=budget_state - ) - if s !== nothing - for p in s.ret_aliases - push!(alias_args, p) - end - return alias_args - end - return _push_all_user_args!(alias_args, raw_args) -end - -function _build_alias_classes!( - uf::UnionFind, - ir::CC.IRCode, - cfg::Config, - track_arg, - track_ssa, - nargs::Int; - unsafe_stmt::Union{Nothing,AbstractVector{Bool}}=nothing, - depth::Int=0, - budget_state=nothing, -) - box_contents = Dict{Int,Int}() - - nstmts = length(ir.stmts) - for i in 1:nstmts - if unsafe_stmt !== nothing && unsafe_stmt[i] - # Opaque/unchecked region: do not propagate aliases from within. - continue - end - out_h = track_ssa[i] ? _ssa_handle(nargs, i) : 0 - out_h == 0 && continue - - stmt = ir[Core.SSAValue(i)][:stmt] - - if stmt isa Core.PiNode - in_h = _handle_index(stmt.val, nargs, track_arg, track_ssa) - _uf_union!(uf, out_h, in_h) - continue - end - - if stmt isa Core.PhiNode || stmt isa Core.PhiCNode - vals = getfield(stmt, :values) - for v in vals - in_h = _handle_index(v, nargs, track_arg, track_ssa) - _uf_union!(uf, out_h, in_h) - end - continue - end - - if stmt isa Core.SSAValue || stmt isa Core.Argument - in_h = _handle_index(stmt, nargs, track_arg, track_ssa) - _uf_union!(uf, out_h, in_h) - continue - end - - if stmt isa Expr && (stmt.head === :new || stmt.head === :splatnew) - for j in 2:length(stmt.args) - in_h = _handle_index(stmt.args[j], nargs, track_arg, track_ssa) - _uf_union!(uf, out_h, in_h) - end - continue - end - - if stmt isa Expr && stmt.head === :foreigncall - name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) - eff = - (name_sym === nothing) ? nothing : _known_foreigncall_effects_get(name_sym) - eff === nothing && continue - for p in eff.ret_aliases - (1 <= p <= length(ccall_args)) || continue - in_h = _handle_index(ccall_args[p], nargs, track_arg, track_ssa) - _uf_union!(uf, out_h, in_h) - end - continue - end - - if stmt isa Expr && (stmt.head === :call || stmt.head === :invoke) - raw_args = (stmt.head === :invoke) ? stmt.args[2:end] : stmt.args - f = _resolve_callee(stmt, ir) - - # Tuples are immutable containers. We intentionally do NOT union tuples with all - # tracked elements (that would incorrectly merge distinct tracked values). - # However, a common compiler pattern is to return a tuple that contains exactly - # one tracked value (e.g. `(ptr, stride)`), which is then immediately projected - # with `getfield` / `indexed_iterate`. In that case we conservatively union the - # tuple with that single tracked element so effects can flow through the - # intermediate tuple value. - if f === Core.tuple - only_h = 0 - for p in 2:length(raw_args) - in_h = _handle_index(raw_args[p], nargs, track_arg, track_ssa) - in_h == 0 && continue - if only_h == 0 - only_h = in_h - elseif in_h != only_h - only_h = -1 - break - end - end - (only_h > 0) && _uf_union!(uf, out_h, only_h) - continue - end - - if f !== nothing && _is_namedtuple_ctor(f) - continue - end - - _maybe_record_box_contents!( - box_contents, f, raw_args, ir, nargs, track_arg, track_ssa - ) - _maybe_alias_box_contents!(uf, out_h, box_contents, f, raw_args, ir, nargs) - - alias_args = _ret_alias_positions_for_call( - stmt, ir, cfg, f, raw_args; depth=depth, budget_state=budget_state - ) - - isempty(alias_args) && continue - for p in alias_args - (1 <= p <= length(raw_args)) || continue - in_h = _handle_index(raw_args[p], nargs, track_arg, track_ssa) - _uf_union!(uf, out_h, in_h) - end - end - end - return uf -end - -function _binding_origins( - ir::CC.IRCode, - nargs::Int, - track_arg, - track_ssa; - unsafe_stmt::Union{Nothing,AbstractVector{Bool}}=nothing, -) - nstmts = length(ir.stmts) - origins = collect(1:(nargs + nstmts)) - closure_field_origins = Dict{Symbol,Int}() - - for idx in 1:nstmts - track_ssa[idx] || continue - hdef = _ssa_handle(nargs, idx) - stmt = ir[Core.SSAValue(idx)][:stmt] - - if unsafe_stmt !== nothing && unsafe_stmt[idx] - # In unchecked regions, treat new tracked SSA values as fresh bindings. - origins[hdef] = hdef - continue - end - - if stmt isa Core.PiNode - hsrc = _handle_index(stmt.val, nargs, track_arg, track_ssa) - origins[hdef] = (hsrc == 0) ? hdef : origins[hsrc] - continue - end - - if stmt isa Core.SSAValue || stmt isa Core.Argument - hsrc = _handle_index(stmt, nargs, track_arg, track_ssa) - origins[hdef] = (hsrc == 0) ? hdef : origins[hsrc] - continue - end - - if stmt isa Expr && stmt.head === :foreigncall - name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) - eff = if name_sym === nothing - nothing - else - _known_foreigncall_effects_get(name_sym) - end - if eff !== nothing && !isempty(eff.ret_aliases) - for p in eff.ret_aliases - (1 <= p <= length(ccall_args)) || continue - hsrc = _handle_index(ccall_args[p], nargs, track_arg, track_ssa) - hsrc == 0 && continue - origins[hdef] = origins[hsrc] - break - end - origins[hdef] != hdef && continue - end - end - - if stmt isa Expr && (stmt.head === :call || stmt.head === :invoke) - head, _mi, raw_args = _call_parts(stmt) - f = _resolve_callee(stmt, ir) - - # Compiler-generated closures repeatedly `getfield` captured environment - # fields from the callable object (`_1`). Treat those as the same binding - # origin so they don't spuriously look like multiple live bindings. - if f === Core.getfield && raw_args !== nothing && length(raw_args) >= 3 - recv = _canonical_ref(raw_args[2], ir) - fld = raw_args[3] - fldsym = fld isa QuoteNode ? fld.value : fld - if recv isa Core.Argument && recv.n == 1 && fldsym isa Symbol - origins[hdef] = get!(closure_field_origins, fldsym, hdef) - continue - end - end - - if f === __bc_bind__ || - (isdefined(Base, :inferencebarrier) && f === Base.inferencebarrier) - # Binding barriers are treated as producing a fresh identity for tracking. - origins[hdef] = hdef - continue - end - - # If this statement produces an alias of an existing handle (per ret-alias - # analysis), propagate binding origin from that input handle. - if raw_args !== nothing && f !== nothing - eff = _known_effects_get(f) - if eff !== nothing && !isempty(eff.ret_aliases) - for p in eff.ret_aliases - (1 <= p <= length(raw_args)) || continue - hsrc = _handle_index(raw_args[p], nargs, track_arg, track_ssa) - hsrc == 0 && continue - origins[hdef] = origins[hsrc] - break - end - origins[hdef] != hdef && continue - end - end - end - - origins[hdef] = hdef - end - - return origins -end diff --git a/src/safe/auto_ir.jl b/src/safe/auto_ir.jl deleted file mode 100644 index ea776c4..0000000 --- a/src/safe/auto_ir.jl +++ /dev/null @@ -1,19 +0,0 @@ -export Config, BorrowCheckError, register_effects!, register_foreigncall_effects! - -import Core.Compiler -const CC = Core.Compiler - -include("utils.jl") -include("defs.jl") -include("diagnostics.jl") -include("ir_primitives.jl") -include("callsite.jl") -include("generated.jl") -include("summaries.jl") -include("refine_types.jl") -include("debug.jl") -include("alias.jl") -include("checker.jl") -include("frontend.jl") - -_refresh_generated_assert_safe() diff --git a/src/safe/callsite.jl b/src/safe/callsite.jl deleted file mode 100644 index 624cff8..0000000 --- a/src/safe/callsite.jl +++ /dev/null @@ -1,519 +0,0 @@ -function _call_parts(stmt) - if stmt isa Expr && stmt.head === :invoke - mi = stmt.args[1] - raw_args = stmt.args[2:end] - return (:invoke, mi, raw_args) - elseif stmt isa Expr && stmt.head === :call - raw_args = stmt.args - return (:call, nothing, raw_args) - else - return (nothing, nothing, nothing) - end -end - -# === foreigncall / ccall helpers === -# -# `ccall` / `llvmcall` lower to `Expr(:foreigncall, ...)`. -# The first five fields are metadata: -# 1: callee name / pointer -# 2: return type -# 3: argument types (typically a `Core.SimpleVector`, printed as `svec(...)`) -# 4: number of required args -# 5: calling convention (`:ccall`, `:llvmcall`, ...) -# The remaining fields are the C arguments followed by GC roots. - -function _foreigncall_nccallargs(argtypes) - argtypes isa QuoteNode && (argtypes = argtypes.value) - if argtypes isa Core.SimpleVector - return length(argtypes) - end - if argtypes isa Tuple || argtypes isa AbstractVector - return length(argtypes) - end - if argtypes isa Expr && argtypes.head === :call && !isempty(argtypes.args) - f = argtypes.args[1] - if f === Core.svec || - f === :svec || - (f isa GlobalRef && f.mod === Core && f.name === :svec) - return length(argtypes.args) - 1 - end - end - return 0 -end - -function _foreigncall_name_symbol(@nospecialize(name_expr)) - function _sym_from_tuple(@nospecialize(v)) - return if (v isa Symbol) - v - elseif (v isa Tuple && !isempty(v)) - _sym_from_tuple(v[1]) - else - nothing - end - end - - if name_expr isa QuoteNode - v = name_expr.value - # Julia lowers `ccall` names in several formats depending on version, e.g. - # - `QuoteNode(:jl_foo)` - # - `QuoteNode((:jl_foo, "libc"))` - # - `QuoteNode(((:jl_foo,),))` (nested tuples on newer nightlies) - return _sym_from_tuple(v) - end - if name_expr isa Symbol - return name_expr - end - if name_expr isa Tuple - return _sym_from_tuple(name_expr) - end - if name_expr isa Expr && name_expr.head === :tuple && !isempty(name_expr.args) - return _foreigncall_name_symbol(name_expr.args[1]) - end - if name_expr isa Expr && name_expr.head === :call && !isempty(name_expr.args) - # Library calls are often encoded as `Core.tuple(:name, lib)`; keep just the name. - f = name_expr.args[1] - if ( - f === Core.tuple || - f === :tuple || - (f isa GlobalRef && f.mod === Core && f.name === :tuple) - ) && length(name_expr.args) >= 2 - return _foreigncall_name_symbol(name_expr.args[2]) - end - end - return nothing -end - -function _foreigncall_parts(stmt::Expr) - @assert stmt.head === :foreigncall - - name_sym = _foreigncall_name_symbol(stmt.args[1]) - - argtypes = stmt.args[3] - nccallargs = _foreigncall_nccallargs(argtypes) - - ccall_start = 6 - nrem = max(length(stmt.args) - (ccall_start - 1), 0) - - # If we can't parse `argtypes`, fall back conservatively: treat *all* remaining fields - # as C arguments rather than silently dropping them as GC roots. - if nccallargs <= 0 - nreq = stmt.args[4] - if nreq isa Integer && nreq > 0 - nccallargs = Int(nreq) - else - nccallargs = nrem - end - end - - nccallargs = max(min(nccallargs, nrem), 0) - if nccallargs == 0 || ccall_start > length(stmt.args) - return name_sym, Any[], Any[], 0 - end - - ccall_stop = min(ccall_start + nccallargs - 1, length(stmt.args)) - ccall_args = stmt.args[ccall_start:ccall_stop] - gc_roots = (ccall_stop < length(stmt.args)) ? stmt.args[(ccall_stop + 1):end] : Any[] - - return name_sym, ccall_args, gc_roots, nccallargs -end - -function _foreigncall_group_used_handles( - ccall_args, group::BitSet, ir::CC.IRCode, nargs::Int, track_arg, track_ssa -) - used = BitSet() - for p in group - (1 <= p <= length(ccall_args)) || continue - union!(used, _backward_used_handles(ccall_args[p], ir, nargs, track_arg, track_ssa)) - end - return used -end - -function _resolve_callee(@nospecialize(stmt), ir::CC.IRCode) - head, mi, raw_args = _call_parts(stmt) - raw_args === nothing && return nothing - fexpr = raw_args[1] - - try - ft = _safe_argextype(fexpr, ir) - return CC.singleton_type(ft) - catch - end - - # Some calls remain "dynamic" in IR because the callee is a mutable global binding - # (e.g. `Main.eachindex`), even though at runtime it usually points to a concrete - # function value like `Base.eachindex`. For `@safe`, resolve such callees from the - # current binding to avoid spurious "unknown call" conservatism. - if fexpr isa GlobalRef - try - return getfield(fexpr.mod, fexpr.name) - catch - end - end - return nothing -end - -function _unwrap_unionall_datatype(@nospecialize(x)) - try - dt = Base.unwrap_unionall(x) - return dt isa DataType ? dt : nothing - catch - return nothing - end -end - -function _is_namedtuple_ctor(@nospecialize(f))::Bool - dt = _unwrap_unionall_datatype(f) - dt === nothing && return false - return dt.name === Base.unwrap_unionall(NamedTuple).name -end - -function _maybe_tuple_elements(@nospecialize(tup), ir::CC.IRCode) - tup isa Core.SSAValue || return nothing - sid = tup.id - 1 <= sid <= length(ir.stmts) || return nothing - def = try - ir[Core.SSAValue(sid)][:stmt] - catch - return nothing - end - def isa Expr || return nothing - if def.head === :call - f = _resolve_callee(def, ir) - if f === Core.tuple - return def.args[2:end] - end - elseif def.head === :tuple - return def.args - end - return nothing -end - -function _maybe_namedtuple_value_exprs(@nospecialize(nt), ir::CC.IRCode) - nt = _canonical_ref(nt, ir) - nt isa Core.SSAValue || return nothing - - sid = nt.id - 1 <= sid <= length(ir.stmts) || return nothing - def = try - ir[Core.SSAValue(sid)][:stmt] - catch - return nothing - end - def isa Expr || return nothing - - raw_args = if def.head === :invoke - def.args[2:end] - elseif def.head === :call - def.args - else - return nothing - end - isempty(raw_args) && return nothing - - f = raw_args[1] - if !_is_namedtuple_ctor(f) - f2 = _resolve_callee(def, ir) - (f2 !== nothing && _is_namedtuple_ctor(f2)) || return nothing - end - - if length(raw_args) == 2 - return _maybe_tuple_elements(raw_args[2], ir) - end - - return raw_args[2:end] -end - -function _kwcall_value_exprs(@nospecialize(stmt), ir::CC.IRCode) - head, _mi, raw_args = _call_parts(stmt) - raw_args === nothing && return nothing - - f = _resolve_callee(stmt, ir) - f === Core.kwcall || return nothing - - length(raw_args) >= 2 || return nothing - return _maybe_namedtuple_value_exprs(raw_args[2], ir) -end - -function _backward_used_handles(seed_expr, ir::CC.IRCode, nargs::Int, track_arg, track_ssa) - s = BitSet() - - _collect_used_handles!(s, seed_expr, nargs, track_arg, track_ssa) - - # Walk backwards through SSA definitions starting from all SSA values referenced - # by `seed_expr` and include any tracked handles reachable in their defining expressions. - nstmts = length(ir.stmts) - seed = Int[] - _collect_ssa_ids!(seed, seed_expr) - isempty(seed) && return s - - seen = falses(nstmts) - work = copy(seed) - while !isempty(work) - sid = pop!(work) - (1 <= sid <= nstmts) || continue - seen[sid] && continue - seen[sid] = true - - def = try - ir[Core.SSAValue(sid)][:stmt] - catch - continue - end - - # Do not expand through binding-barriers: they intentionally create a distinct - # binding identity. Traversing into their argument can introduce a spurious - # "second live binding" (e.g. the array literal SSA) and trigger false - # uniqueness violations (especially for foreigncalls). - if def isa Expr && (def.head === :call || def.head === :invoke) - f = _resolve_callee(def, ir) - if f === __bc_bind__ || - (isdefined(Base, :inferencebarrier) && f === Base.inferencebarrier) - hv = _handle_index(Core.SSAValue(sid), nargs, track_arg, track_ssa) - hv != 0 && push!(s, hv) - continue - end - end - - _collect_used_handles!(s, def, nargs, track_arg, track_ssa) - _collect_ssa_ids!(work, def) - end - - return s -end - -function _used_handles(stmt, ir::CC.IRCode, nargs::Int, track_arg, track_ssa) - s = if stmt isa Expr && stmt.head === :foreigncall - _backward_used_handles(stmt, ir, nargs, track_arg, track_ssa) - else - s = BitSet() - _collect_used_handles!(s, stmt, nargs, track_arg, track_ssa) - s - end - - vals = _kwcall_value_exprs(stmt, ir) - if vals !== nothing - for v in vals - _collect_used_handles!(s, v, nargs, track_arg, track_ssa) - end - end - - return s -end - -function _kwcall_tt_from_raw_args(raw_args, ir::CC.IRCode) - length(raw_args) >= 3 || return nothing - - fexpr = raw_args[3] - ft = try - _safe_argextype(fexpr, ir) - catch - return nothing - end - - fobj = try - CC.singleton_type(ft) - catch - nothing - end - if fobj === nothing - # If inference lost the singleton, fall back to resolving a GlobalRef binding. - if fexpr isa GlobalRef && isdefined(fexpr.mod, fexpr.name) - fobj = getfield(fexpr.mod, fexpr.name) - end - end - fobj === nothing && return nothing - - kwf = try - Core.kwfunc(fobj) - catch - return nothing - end - - # Build the kwfunc call tuple type: `kwf(kwargs, f, args...)` - argtypes = Any[typeof(kwf)] - - # 1) kw container - kw_t = try - CC.widenconst(_safe_argextype(raw_args[2], ir)) - catch - Any - end - push!(argtypes, (kw_t isa Type) ? kw_t : Any) - - # 2) the function value itself: use the resolved singleton's type - f_t = (fobj isa Type) ? Type{fobj} : Core.Typeof(fobj) - push!(argtypes, f_t) - - # 3) positional arguments - for i in 4:length(raw_args) - ti = try - CC.widenconst(_safe_argextype(raw_args[i], ir)) - catch - Any - end - push!(argtypes, (ti isa Type) ? ti : Any) - end - return Core.apply_type(Tuple, argtypes...) -end - -function _maybe_box_contents_type(x::Core.SSAValue, ir::CC.IRCode) - x = _canonical_ref(x, ir) - stmt = try - ir[x][:stmt] - catch - return Any - end - stmt isa Expr && stmt.head === :call || return Any - (stmt.args[1] === Core.getfield || stmt.args[1] == GlobalRef(Core, :getfield)) || - return Any - length(stmt.args) >= 3 || return Any - fld = stmt.args[3] - fldsym = fld isa QuoteNode ? fld.value : fld - fldsym === :contents || return Any - box = _canonical_ref(stmt.args[2], ir) - - # First try to recover the type from the Core.Box(init) constructor, if available. - init_ty = Any - if box isa Core.SSAValue - bstmt = try - ir[box][:stmt] - catch - nothing - end - if bstmt isa Expr && - bstmt.head === :call && - (bstmt.args[1] === Core.Box || bstmt.args[1] == GlobalRef(Core, :Box)) - if length(bstmt.args) >= 2 - init = bstmt.args[2] - init_ty = try - CC.widenconst(_safe_argextype(init, ir)) - catch - Any - end - init_ty = (init_ty isa Type) ? init_ty : Any - end - end - end - - # Otherwise (or additionally), look for writes to `box.contents`. - for i in 1:length(ir.stmts) - st = ir[Core.SSAValue(i)][:stmt] - st isa Expr && st.head === :call || continue - (st.args[1] === Core.setfield! || st.args[1] == GlobalRef(Core, :setfield!)) || - continue - length(st.args) >= 4 || continue - f = st.args[3] - fsym = f isa QuoteNode ? f.value : f - fsym === :contents || continue - box2 = _canonical_ref(st.args[2], ir) - box2 == box || continue - v = st.args[4] - t = try - CC.widenconst(_safe_argextype(v, ir)) - catch - Any - end - t = (t isa Type) ? t : Any - # If inference lost precision (t === Any), keep searching; we may still have a useful init type. - t === Any && continue - return t - end - - return init_ty -end - -function _maybe_const_type_object(fexpr, ir::CC.IRCode) - if fexpr isa GlobalRef - v = try - getfield(fexpr.mod, fexpr.name) - catch - nothing - end - return (v isa Type) ? v : nothing - end - if fexpr isa QuoteNode - v = fexpr.value - return (v isa Type) ? v : nothing - end - if fexpr isa Core.SSAValue - def = try - ir[fexpr][:stmt] - catch - nothing - end - def === nothing && return nothing - return _maybe_const_type_object(def, ir) - end - return nothing -end - -function _call_tt_from_raw_args(raw_args, ir::CC.IRCode, f_override=nothing) - types = Any[] - for (i, a) in enumerate(raw_args) - t = Any - try - at = _safe_argextype(a, ir) - if i == 1 - if f_override !== nothing - t = (f_override isa Type) ? Type{f_override} : Core.Typeof(f_override) - else - fobj = _maybe_const_type_object(a, ir) - if fobj !== nothing - t = Type{fobj} - else - fval = try - CC.singleton_type(at) - catch - nothing - end - if fval isa Type - # Constructors dispatch on `Type{T}` / `Type{UnionAll(...)}` rather than - # `DataType`, so use the singleton type of the type object when it can be - # resolved. - t = Type{fval} - else - t = CC.widenconst(at) - end - end - - @assert !(a isa GlobalRef) ( - "BorrowChecker: unexpected GlobalRef callee in call signature inference. " * - "globalref=$(a.mod).$(a.name) inferred=$(t)" - ) - end - else - t = CC.widenconst(at) - end - catch - t = Any - end - if t === Any && a isa Core.SSAValue - t = _maybe_box_contents_type(a, ir) - end - (t isa Type) || (t = Any) - push!(types, t) - end - isempty(types) && return nothing - - fT = types[1] - if fT === Any || fT isa Union - return nothing - end - dt = try - Base.unwrap_unionall(fT) - catch - return nothing - end - dt isa DataType || return nothing - if Base.isabstracttype(dt) - if !(dt.name === Base.unwrap_unionall(Type).name && !isempty(dt.parameters)) - return nothing - end - end - - try - return Tuple{types...} - catch - return nothing - end -end diff --git a/src/safe/checker.jl b/src/safe/checker.jl deleted file mode 100644 index b8ecbe9..0000000 --- a/src/safe/checker.jl +++ /dev/null @@ -1,529 +0,0 @@ -function _compute_liveness( - ir::CC.IRCode, - nargs::Int, - track_arg, - track_ssa; - unsafe_stmt::Union{Nothing,AbstractVector{Bool}}=nothing, -) - blocks = ir.cfg.blocks - nblocks = length(blocks) - - phi_edge_use = [BitSet() for _ in 1:nblocks] - use = [BitSet() for _ in 1:nblocks] - def = [BitSet() for _ in 1:nblocks] - - inst2bb = zeros(Int, length(ir.stmts)) - for b in 1:nblocks - for idx in blocks[b].stmts - inst2bb[idx] = b - end - end - - for b in 1:nblocks - r = blocks[b].stmts - for idx in r - if unsafe_stmt !== nothing && unsafe_stmt[idx] - continue - end - stmt = ir[Core.SSAValue(idx)][:stmt] - if stmt isa Core.PhiNode - edges = getfield(stmt, :edges) - vals = getfield(stmt, :values) - for k in 1:length(edges) - isassigned(vals, k) || continue - edge = edges[k] - v = vals[k] - h = _handle_index(v, nargs, track_arg, track_ssa) - h == 0 && continue - @assert 1 <= edge <= length(inst2bb) && inst2bb[edge] != 0 "Unexpected IR: PhiNode.edges should contain predecessor terminator statement indices (not block IDs)." - pred_bb = inst2bb[edge] - push!(phi_edge_use[pred_bb], h) - end - elseif stmt isa Core.PhiCNode - # `PhiCNode` does not store explicit `edges` (unlike `PhiNode`). - # Conservatively attribute each value to predecessor blocks. - vals = getfield(stmt, :values) - preds = blocks[b].preds - for k in 1:length(vals) - isassigned(vals, k) || continue - v = vals[k] - h = _handle_index(v, nargs, track_arg, track_ssa) - h == 0 && continue - for pred_bb in preds - (1 <= pred_bb <= nblocks) || continue - push!(phi_edge_use[pred_bb], h) - end - end - else - break - end - end - end - - for b in 1:nblocks - seen_defs = BitSet() - for idx in blocks[b].stmts - if 1 <= idx <= length(track_ssa) && track_ssa[idx] - hdef = _ssa_handle(nargs, idx) - push!(def[b], hdef) - push!(seen_defs, hdef) - end - stmt = ir[Core.SSAValue(idx)][:stmt] - if unsafe_stmt !== nothing && unsafe_stmt[idx] - continue - end - if stmt isa Core.PhiNode || stmt isa Core.PhiCNode - continue - end - uses = _used_handles(stmt, ir, nargs, track_arg, track_ssa) - for u in uses - (u in seen_defs) || push!(use[b], u) - end - end - end - - live_in = [BitSet() for _ in 1:nblocks] - live_out = [BitSet() for _ in 1:nblocks] - - changed = true - while changed - changed = false - for b in nblocks:-1:1 - out = BitSet() - union!(out, phi_edge_use[b]) - for s in blocks[b].succs - union!(out, live_in[s]) - end - inn = BitSet() - union!(inn, use[b]) - tmp = BitSet(out) - for d in def[b] - delete!(tmp, d) - end - union!(inn, tmp) - if out != live_out[b] || inn != live_in[b] - live_out[b] = out - live_in[b] = inn - changed = true - end - end - end - - return live_in, live_out -end - -function check_ir(ir::CC.IRCode, cfg::Config)::Vector{BorrowViolation} - nargs = length(ir.argtypes) - nstmts = length(ir.stmts) - - # Improve local IR typing around Core.Box/captured values and dynamic GlobalRef calls. - refine_types!(ir, cfg) - - track_arg, track_ssa = compute_tracking_masks(ir) - - # Statements inside `@unsafe` regions are treated as opaque: we do not validate - # borrow rules within them, and we avoid propagating aliasing/escape facts from - # within them into the surrounding checked code. - unsafe_stmt = _unsafe_stmt_mask(ir) - - uf = UnionFind(nargs + nstmts) - _build_alias_classes!(uf, ir, cfg, track_arg, track_ssa, nargs; unsafe_stmt=unsafe_stmt) - origins = _binding_origins(ir, nargs, track_arg, track_ssa; unsafe_stmt=unsafe_stmt) - - live_in, live_out = _compute_liveness( - ir, nargs, track_arg, track_ssa; unsafe_stmt=unsafe_stmt - ) - - viols = BorrowViolation[] - - blocks = ir.cfg.blocks - for b in 1:length(blocks) - live = BitSet(live_out[b]) - for idx in reverse(blocks[b].stmts) - stmt = ir[Core.SSAValue(idx)][:stmt] - - in_unsafe = (1 <= idx <= length(unsafe_stmt)) && unsafe_stmt[idx] - - uses = if in_unsafe || (stmt isa Core.PhiNode || stmt isa Core.PhiCNode) - BitSet() - else - _used_handles(stmt, ir, nargs, track_arg, track_ssa) - end - live_during = BitSet(live) - union!(live_during, uses) - - if !in_unsafe - _check_stmt!( - viols, - ir, - idx, - stmt, - uf, - origins, - cfg, - nargs, - track_arg, - track_ssa, - live, - live_during, - ) - end - - if 1 <= idx <= length(track_ssa) && track_ssa[idx] - delete!(live, _ssa_handle(nargs, idx)) - end - union!(live, uses) - end - end - - return viols -end - -function _args_safe_under_unknown_consume( - args, nargs, track_arg, track_ssa, uf, origins, live_during::BitSet, live_after::BitSet -)::Bool - for arg in args - hv = _handle_index(arg, nargs, track_arg, track_ssa) - hv == 0 && continue - - rv = _uf_find(uf, hv) - ohv = origins[hv] - - for h2 in live_during - h2 == hv && continue - if _uf_find(uf, h2) == rv && origins[h2] != ohv - return false - end - end - - for h2 in live_after - if _uf_find(uf, h2) == rv - return false - end - end - end - - return true -end - -function _call_safe_under_unknown_consume( - raw_args, - extra_args, - nargs, - track_arg, - track_ssa, - uf, - origins, - live_during::BitSet, - live_after::BitSet, -) - _args_safe_under_unknown_consume( - raw_args, nargs, track_arg, track_ssa, uf, origins, live_during, live_after - ) || return false - - extra_args === nothing && return true - - return _args_safe_under_unknown_consume( - extra_args, nargs, track_arg, track_ssa, uf, origins, live_during, live_after - ) -end - -function _push_violation!( - viols::Vector{BorrowViolation}, ir::CC.IRCode, idx::Int, stmt, msg::String -) - li = _stmt_lineinfo(ir, idx) - push!(viols, BorrowViolation(idx, msg, li, stmt)) - return nothing -end - -function _check_stmt!( - viols, - ir::CC.IRCode, - idx::Int, - stmt, - uf::UnionFind, - origins::AbstractVector{Int}, - cfg::Config, - nargs::Int, - track_arg, - track_ssa, - live_after::BitSet, - live_during::BitSet, -) - if stmt isa Expr && stmt.head === :foreigncall - name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) - eff = (name_sym === nothing) ? nothing : _known_foreigncall_effects_get(name_sym) - - out_h = - (1 <= idx <= length(track_ssa) && track_ssa[idx]) ? _ssa_handle(nargs, idx) : 0 - - # Enforce uniqueness for each alias-root touched by `group_handles`, - # but allow any binding origins that arise within the same group. - function require_unique_group!(group_handles::BitSet; context::String) - roots_allowed = Dict{Int,BitSet}() - reps = Dict{Int,Int}() - - for hv in group_handles - hv == 0 && continue - hroot = _uf_find(uf, hv) - allowed = get!(roots_allowed, hroot, BitSet()) - push!(allowed, origins[hv]) - reps[hroot] = get(reps, hroot, hv) - end - - for (hroot, allowed) in roots_allowed - for h2 in live_during - (h2 == out_h) && continue - (h2 == 1) && continue - if _uf_find(uf, h2) == hroot && !(origins[h2] in allowed) - _push_violation!( - viols, - ir, - idx, - stmt, - "cannot perform $context: value is aliased by another live binding", - ) - return reps - end - end - end - - return reps - end - - # Unknown foreigncall: keep old conservative behavior. - if eff === nothing - # Treat as write to the C arguments only (ignore GC roots), but allow redundant - # `(obj, ptr, ...)` argument representations to coexist within the call. - hs_all = BitSet() - for v in ccall_args - hs = _backward_used_handles(v, ir, nargs, track_arg, track_ssa) - union!(hs_all, hs) - end - require_unique_group!(hs_all; context="write") - return nothing - end - - for grp in eff.write_groups - hs = _foreigncall_group_used_handles( - ccall_args, grp, ir, nargs, track_arg, track_ssa - ) - require_unique_group!(hs; context="write") - end - - for grp in eff.consume_groups - hs = _foreigncall_group_used_handles( - ccall_args, grp, ir, nargs, track_arg, track_ssa - ) - reps = require_unique_group!(hs; context="consume") - for (_, rep) in reps - _require_not_used_later!(viols, ir, idx, stmt, uf, origins, rep, live_after) - end - end - - return nothing - end - - head, mi, raw_args = _call_parts(stmt) - raw_args === nothing && return nothing - - f = _resolve_callee(stmt, ir) - kw_vals = (f === Core.kwcall) ? _kwcall_value_exprs(stmt, ir) : nothing - (kw_vals === nothing || isempty(kw_vals)) && (kw_vals = nothing) - - eff = _effects_for_call(stmt, ir, cfg, track_arg, track_ssa, nargs; idx=idx) - moved_positions = _moved_positions_for_eval_order_check(f, raw_args, eff, ir) - _check_call_eval_order_moves!( - viols, ir, idx, stmt, uf, moved_positions, raw_args, nargs, track_arg, track_ssa - ) - - if _call_safe_under_unknown_consume( - raw_args, kw_vals, nargs, track_arg, track_ssa, uf, origins, live_during, live_after - ) - return nothing - end - - out_h = (1 <= idx <= length(track_ssa) && track_ssa[idx]) ? _ssa_handle(nargs, idx) : 0 - - for p in eff.writes - if f === Core.kwcall && p == 2 && kw_vals !== nothing - for vkw in kw_vals - hv = _handle_index(vkw, nargs, track_arg, track_ssa) - hv == 0 && continue - _require_unique!( - viols, - ir, - idx, - stmt, - uf, - origins, - hv, - live_during; - context="write", - ignore_h=out_h, - ) - end - continue - end - - v = raw_args[p] - hv = _handle_index(v, nargs, track_arg, track_ssa) - hv == 0 && continue - _require_unique!( - viols, - ir, - idx, - stmt, - uf, - origins, - hv, - live_during; - context="write", - ignore_h=out_h, - ) - end - - for p in eff.consumes - if f === Core.kwcall && p == 2 && kw_vals !== nothing - for vkw in kw_vals - hv = _handle_index(vkw, nargs, track_arg, track_ssa) - hv == 0 && continue - _require_unique!( - viols, ir, idx, stmt, uf, origins, hv, live_during; context="consume" - ) - _require_not_used_later!(viols, ir, idx, stmt, uf, origins, hv, live_after) - end - continue - end - - v = raw_args[p] - hv = _handle_index(v, nargs, track_arg, track_ssa) - hv == 0 && continue - _require_unique!( - viols, ir, idx, stmt, uf, origins, hv, live_during; context="consume" - ) - _require_not_used_later!(viols, ir, idx, stmt, uf, origins, hv, live_after) - end - - return nothing -end - -function _moved_positions_for_eval_order_check( - @nospecialize(f), raw_args, eff::EffectSummary, ir::CC.IRCode -)::BitSet - # For most calls, use the effect summary's consume set. - # Special case: tuple construction should not allow using the same owned value - # in later elements after it has already been used in an earlier element. - if f === Core.tuple - # Ignore empty / 1-element tuples (these show up in compiler plumbing, e.g. splat containers). - length(raw_args) <= 2 && return BitSet() - - moved = BitSet() - for p in 2:length(raw_args) - Tv = _widenargtype_or_any(raw_args[p], ir) - is_owned_type(Tv) || continue - push!(moved, p) - end - return moved - end - - return eff.consumes -end - -function _check_call_eval_order_moves!( - viols, - ir::CC.IRCode, - idx::Int, - stmt, - uf::UnionFind, - moved_positions::BitSet, - raw_args, - nargs::Int, - track_arg, - track_ssa, -) - isempty(moved_positions) && return nothing - - for p in moved_positions - # raw_args[1] is the function value; treat only user arguments as moved values. - p >= 2 || continue - p <= length(raw_args) || continue - - vp = raw_args[p] - hp = _handle_index(vp, nargs, track_arg, track_ssa) - hp == 0 && continue - - rp = _uf_find(uf, hp) - - for q in (p + 1):length(raw_args) - deps = _backward_used_handles(raw_args[q], ir, nargs, track_arg, track_ssa) - for hq in deps - _uf_find(uf, hq) == rp || continue - _push_violation!( - viols, - ir, - idx, - stmt, - "call argument uses a value after it was moved by an earlier argument", - ) - return nothing - end - end - end - - return nothing -end - -function _require_unique!( - viols, - ir::CC.IRCode, - idx::Int, - stmt, - uf::UnionFind, - origins::AbstractVector{Int}, - hv::Int, - live_during::BitSet; - context::String, - ignore_h::Int=0, -) - rv = _uf_find(uf, hv) - ohv = origins[hv] - for h2 in live_during - (h2 == hv || h2 == ignore_h || h2 == 1) && continue - if _uf_find(uf, h2) == rv && origins[h2] != ohv - _push_violation!( - viols, - ir, - idx, - stmt, - "cannot perform $context: value is aliased by another live binding", - ) - return nothing - end - end -end - -function _require_not_used_later!( - viols, - ir::CC.IRCode, - idx::Int, - stmt, - uf::UnionFind, - origins::AbstractVector{Int}, - hv::Int, - live_after::BitSet, -) - rv = _uf_find(uf, hv) - for h2 in live_after - if _uf_find(uf, h2) == rv - _push_violation!( - viols, - ir, - idx, - stmt, - "value escapes/consumed by unknown call; it (or an alias) is used later", - ) - return nothing - end - end -end diff --git a/src/safe/debug.jl b/src/safe/debug.jl deleted file mode 100644 index 8fd5034..0000000 --- a/src/safe/debug.jl +++ /dev/null @@ -1,336 +0,0 @@ -const AUTO_DEBUG_LOCK = ReentrantLock() - -function _auto_debug_path(warn::Bool=false) - p = get(ENV, "BORROWCHECKER_AUTO_DEBUG_PATH", "") - if isempty(p) - path = joinpath(tempdir(), "BorrowChecker.auto.debug.$(getpid()).jsonl") - warn && @warn( - "BorrowChecker.@safe debug enabled; writing JSONL debug log to $path", - maxlog = 1, - ) - return path - end - return p -end - -function _auto_debug_write_json(io::IO, x) - if x === nothing - write(io, "null") - elseif x === true - write(io, "true") - elseif x === false - write(io, "false") - elseif x isa Integer - print(io, x) - elseif x isa AbstractFloat - print(io, x) - elseif x isa AbstractString - write(io, '"') - for c in x - if c == '"' - write(io, "\\\"") - elseif c == '\\' - write(io, "\\\\") - elseif c == '\n' - write(io, "\\n") - elseif c == '\r' - write(io, "\\r") - elseif c == '\t' - write(io, "\\t") - elseif c == '\b' - write(io, "\\b") - elseif c == '\f' - write(io, "\\f") - elseif Int(c) < 0x20 - print(io, "\\u", lpad(string(Int(c); base=16), 4, '0')) - else - write(io, c) - end - end - write(io, '"') - elseif x isa AbstractVector - write(io, '[') - first = true - for v in x - first || write(io, ',') - first = false - _auto_debug_write_json(io, v) - end - write(io, ']') - elseif x isa AbstractDict - write(io, '{') - first = true - for (k, v) in x - first || write(io, ',') - first = false - _auto_debug_write_json(io, String(k)) - write(io, ':') - _auto_debug_write_json(io, v) - end - write(io, '}') - else - _auto_debug_write_json(io, string(x)) - end - return nothing -end - -function _auto_debug_emit(cfg::Config, obj) - lock(AUTO_DEBUG_LOCK) do - try - open(_auto_debug_path(), "a") do io - _auto_debug_write_json(io, obj) - write(io, '\n') - end - catch - end - end - return nothing -end - -function _auto_debug_effect_summary_dict(s::EffectSummary) - return Dict( - "writes" => collect(s.writes), - "consumes" => collect(s.consumes), - "ret_aliases" => collect(s.ret_aliases), - ) -end - -function _auto_debug_cfg_dict(cfg::Config) - return Dict( - "optimize_until" => cfg.optimize_until, - "max_summary_depth" => cfg.max_summary_depth, - "scope" => String(cfg.scope), - "debug" => cfg.debug, - "debug_callee_depth" => cfg.debug_callee_depth, - ) -end - -function _auto_debug_borrow_violation_dict(v::BorrowViolation) - li = v.lineinfo - file, line = if li === nothing - (nothing, nothing) - else - try - _lineinfo_file_line(li) - catch - (nothing, nothing) - end - end - return Dict( - "idx" => v.idx, - "msg" => v.msg, - "file" => file, - "line" => line, - "stmt" => string(v.stmt), - ) -end - -function _auto_debug_summary_keys(world::UInt, cfg::Config) - Base.@lock SUMMARY_STATE begin - mi_keys = Set{Any}() - tt_keys = Set{Any}() - for k in keys(SUMMARY_STATE[].summary_cache) - (k[2] == world && k[3] == cfg) && push!(mi_keys, k) - end - for k in keys(SUMMARY_STATE[].tt_summary_cache) - (k[2] == world && k[3] == cfg) && push!(tt_keys, k) - end - return (mi_keys, tt_keys) - end -end - -function _auto_debug_collect_new_summaries(world::UInt, cfg::Config, snapshot) - snapshot === nothing && return (Any[], Any[]) - (mi0, tt0) = snapshot - new_mi = Any[] - new_tt = Any[] - - @inline function push_new!(dest, kind::String, k, entry) - push!( - dest, - Dict( - "kind" => kind, - "key" => string(k[1]), - "depth" => entry.depth, - "over_budget" => entry.over_budget, - "summary" => _auto_debug_effect_summary_dict(entry.summary), - ), - ) - return nothing - end - - Base.@lock SUMMARY_STATE begin - for (k, entry) in SUMMARY_STATE[].summary_cache - (k[2] == world && k[3] == cfg && !(k in mi0)) || continue - push_new!(new_mi, "mi", k, entry) - end - for (k, entry) in SUMMARY_STATE[].tt_summary_cache - (k[2] == world && k[3] == cfg && !(k in tt0)) || continue - push_new!(new_tt, "tt", k, entry) - end - end - return (new_mi, new_tt) -end - -function _auto_debug_ir_string(ir::CC.IRCode) - return sprint(show, ir) -end - -function _auto_debug_emit_ir_for_codes( - cfg::Config, world::UInt, tt::Type{<:Tuple}, depth::Int, codes -) - ir_entries = Any[] - for entry in codes - ir_or_err = entry.first - ty = entry.second - if ir_or_err isa CC.IRCode - push!( - ir_entries, - Dict( - "ir" => _auto_debug_ir_string(ir_or_err), "inferred_type" => string(ty) - ), - ) - else - push!( - ir_entries, - Dict("ir_error" => string(ir_or_err), "inferred_type" => string(ty)), - ) - end - end - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_ir", - "time_ns" => time_ns(), - "tt" => string(tt), - "depth" => depth, - "optimize_until" => cfg.optimize_until, - "entries" => ir_entries, - ), - ) - return nothing -end - -function _auto_debug_emit_ir_for_tt(cfg::Config, world::UInt, tt::Type{<:Tuple}, depth::Int) - codes = try - _code_ircode_by_type(tt; optimize_until=cfg.optimize_until, world=world, cfg) - catch e - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_ir_error", - "time_ns" => time_ns(), - "tt" => string(tt), - "depth" => depth, - "error" => sprint(showerror, e), - ), - ) - return nothing - end - return _auto_debug_emit_ir_for_codes(cfg, world, tt, depth, codes) -end - -function _auto_debug_emit_check!( - tt::Type{<:Tuple}, - cfg::Config, - world::UInt, - summary_snapshot, - ok::Bool, - violations::Vector{BorrowViolation}, - err, - bt, - entry_codes, -) - t_ns = time_ns() - err_s = if err === nothing - nothing - else - try - sprint(showerror, err, bt) - catch - try - sprint(showerror, err) - catch - string(err) - end - end - end - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_check", - "time_ns" => t_ns, - "time_s" => t_ns * 1e-9, - "path" => _auto_debug_path(), - "julia_version" => VERSION, - "world" => world, - "tt" => string(tt), - "cfg" => _auto_debug_cfg_dict(cfg), - "ok" => ok, - "error" => err_s, - ), - ) - - if !ok && err_s !== nothing - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_error", - "time_ns" => time_ns(), - "tt" => string(tt), - "error" => err_s, - ), - ) - end - - if !isempty(violations) - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_violations", - "time_ns" => time_ns(), - "tt" => string(tt), - "violations" => map(_auto_debug_borrow_violation_dict, violations), - ), - ) - end - - (new_mi, new_tt) = _auto_debug_collect_new_summaries(world, cfg, summary_snapshot) - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_summaries", - "time_ns" => time_ns(), - "tt" => string(tt), - "new_mi_summaries" => new_mi, - "new_tt_summaries" => new_tt, - ), - ) - - # IR dumping can be *very* expensive. We already computed `entry_codes` in the - # normal checking path; reuse it rather than calling back into inference again. - if entry_codes !== nothing - try - _auto_debug_emit_ir_for_codes(cfg, world, tt, 0, entry_codes) - catch - end - end - if cfg.debug_callee_depth > 0 - tt0 = summary_snapshot === nothing ? Set{Any}() : summary_snapshot[2] - Base.@lock SUMMARY_STATE begin - for (k, entry) in SUMMARY_STATE[].tt_summary_cache - (k[2] == world && k[3] == cfg) || continue - (k in tt0) && continue - entry.depth <= cfg.debug_callee_depth || continue - k1 = k[1] - k1 isa Type{<:Tuple} || continue - try - _auto_debug_emit_ir_for_tt(cfg, world, k1, entry.depth) - catch - end - end - end - end - - return nothing -end diff --git a/src/safe/defs.jl b/src/safe/defs.jl deleted file mode 100644 index b9306c3..0000000 --- a/src/safe/defs.jl +++ /dev/null @@ -1,295 +0,0 @@ -function _default_optimize_until() - if isdefined(CC, :ALL_PASS_NAMES) - for nm in CC.ALL_PASS_NAMES - s = String(nm) - endswith(s, "COMPACT_1") && return s - end - for nm in CC.ALL_PASS_NAMES - s = String(nm) - occursin("COMPACT", s) && occursin("1", s) && return s - end - return String(CC.ALL_PASS_NAMES[begin + 2]) # best-effort guess for new compiler pass name - end - return "compact 1" -end - -Base.@kwdef struct Config - "Which compiler pass to stop at when fetching IR (`Base.code_ircode_by_type`)." - optimize_until::String = _default_optimize_until() - - "Max depth for recursive effect summarization." - max_summary_depth::Int = 12 - - "Recursively borrow-check callees (call graph) within this scope." - scope::Symbol = :function - - "Root module used by `scope=:module`." - root_module::Module = Main - - """ - Enable debug logging to a JSONL file (best-effort). - - The output path is controlled by the `BORROWCHECKER_AUTO_DEBUG_PATH` environment variable. - """ - debug::Bool = false - - """ - Max depth of summary-recursion for which `@safe debug=true` also dumps IR. - - Depth is measured in the recursive effect summarizer (0 = the entrypoint specialization). - """ - debug_callee_depth::Int = 2 -end - -@generated function __bc_bind__(x::T) where {T} - # Preserve constant propagation for isbits values (e.g. value type parameters). - # For non-isbits values, keep the inference barrier so the compiler doesn't - # collapse bindings in ways that confuse our alias/origin tracking. - if Base.isbitstype(T) - return quote - Base.@_inline_meta - x - end - end - # Type objects are also immutable - if T <: Type - return quote - Base.@_inline_meta - x - end - end - if isdefined(Base, :inferencebarrier) - return quote - Base.@_inline_meta - Base.inferencebarrier(x)::T - end - else - return quote - Base.@_inline_meta - x - end - end -end - -struct EffectSummary - # Indices are in the *raw call argument list* used by the SSA form: - # raw_args[1] is the function value, raw_args[2] is the first user argument, etc. - writes::BitSet # arguments that may be mutated during the call - consumes::BitSet # arguments that may escape/need to be treated as consumed - ret_aliases::BitSet # arguments that the return value may alias -end -function EffectSummary(; writes=Int[], consumes=Int[], ret_aliases=Int[]) - return EffectSummary(BitSet(writes), BitSet(consumes), BitSet(ret_aliases)) -end - -""" - ForeigncallEffectSummary - -Effect summary for `Expr(:foreigncall, ...)` nodes (lowered `ccall` / `llvmcall`). - -Positions are **1-based C-argument positions**, where position 1 corresponds to the first -actual C argument (`stmt.args[6]`) after the foreigncall metadata. - -`writes` / `consumes` are specified as *groups*: each element may be an `Int` (singleton -group) or an iterable of `Int`s (multi-arg group). Grouping is important for common -patterns like `(obj, ptr)` argument pairs that must be treated as one logical resource -for uniqueness. -""" -struct ForeigncallEffectSummary - write_groups::Vector{BitSet} - consume_groups::Vector{BitSet} - ret_aliases::BitSet -end - -function _normalize_foreigncall_groups(spec) - spec isa Integer && return BitSet[BitSet((Int(spec),))] - groups = BitSet[] - for g in spec - if g isa Integer - push!(groups, BitSet((Int(g),))) - else - push!(groups, BitSet(collect(Int, g))) - end - end - return groups -end - -function ForeigncallEffectSummary(; writes=(), consumes=(), ret_aliases=()) - ret_aliases isa Integer && (ret_aliases = (Int(ret_aliases),)) - return ForeigncallEffectSummary( - _normalize_foreigncall_groups(writes), - _normalize_foreigncall_groups(consumes), - BitSet(collect(Int, ret_aliases)), - ) -end - -const KNOWN_EFFECTS = Lockable(IdDict{Any,EffectSummary}()) -const KNOWN_FOREIGNCALL_EFFECTS = Lockable(Dict{Symbol,ForeigncallEffectSummary}()) - -@inline function _known_effects_get(@nospecialize(f)) - return @lock KNOWN_EFFECTS get(KNOWN_EFFECTS[], f, nothing) -end - -@inline function _known_effects_has(@nospecialize(f))::Bool - return @lock KNOWN_EFFECTS haskey(KNOWN_EFFECTS[], f) -end - -function register_effects!(@nospecialize(f); writes=(), consumes=(), ret_aliases=()) - @lock KNOWN_EFFECTS begin - dict = KNOWN_EFFECTS[] - dict[f] = EffectSummary(; - writes=collect(Int, writes), - consumes=collect(Int, consumes), - ret_aliases=collect(Int, ret_aliases), - ) - end - return f -end - -@inline function _known_foreigncall_effects_get(name::Symbol) - return @lock KNOWN_FOREIGNCALL_EFFECTS get(KNOWN_FOREIGNCALL_EFFECTS[], name, nothing) -end - -@inline function _known_foreigncall_effects_has(name::Symbol)::Bool - return @lock KNOWN_FOREIGNCALL_EFFECTS haskey(KNOWN_FOREIGNCALL_EFFECTS[], name) -end - -function register_foreigncall_effects!(name::Symbol; writes=(), consumes=(), ret_aliases=()) - @lock KNOWN_FOREIGNCALL_EFFECTS begin - KNOWN_FOREIGNCALL_EFFECTS[][name] = ForeigncallEffectSummary(; - writes=writes, consumes=consumes, ret_aliases=ret_aliases - ) - end - return name -end - -const REGISTRY_INITED = Lockable(Ref{Bool}(false)) - -function _populate_registry!() - _known_effects_has(__bc_bind__) || register_effects!(__bc_bind__; ret_aliases=(2,)) - # `@safe scope=...` builds a `Config` object at runtime for the prologue check. - # This constructor is internal plumbing and should be treated as pure. - _known_effects_has(Config) || register_effects!(Config; ret_aliases=()) - - if isdefined(BorrowChecker, :__bc_assert_safe__) - f = BorrowChecker.__bc_assert_safe__ - _known_effects_has(f) || register_effects!(f; ret_aliases=()) - end - - # NOTE: For a Rust-like borrow checker, *storing* a tracked value into mutable memory - # must be treated as an escape/move of that value. - specs = [ - (Core, :tuple, (), (), ()), - (Core, :apply_type, (), (), ()), - (Core, :typeof, (), (), ()), - (Core, :_typeof_captured_variable, (), (), ()), - (Core, :Typeof, (), (), ()), - (Core, :isa, (), (), ()), - (Core, :has_free_typevars, (), (), ()), - (Core, :_typevar, (), (), ()), - (Core, :ArgumentError, (), (), ()), - (Core, :InexactError, (), (), ()), - (Core, :BoundsError, (), (), ()), - # Core builtins/intrinsics that are used throughout Base and may not be reflectable. - (Core, :bitcast, (3,), (), ()), - (Core, :compilerbarrier, (3,), (), ()), - (Core, :_svec_ref, (), (), ()), - (Core, :_svec_len, (), (), ()), - (Core, :isdefined, (), (), ()), - (Core, :throw, (), (), ()), - (Core, :(<:), (), (), ()), - (Core, :(===), (), (), ()), - (Core, :(!==), (), (), ()), - (Core, :typeassert, (2,), (), ()), - (Core, :getfield, (2,), (), ()), - # setfield!(obj, field, val) mutates `obj` (arg2) and stores `val` (arg4). - # Storing an owned value is treated as a move/escape (filtered by `is_owned_type`). - (Core, :setfield!, (), (2,), (4,)), - # Field "write" family. All mutate the receiver (arg2) and store a value argument. - (Core, :swapfield!, (), (2,), (4,)), # swapfield!(obj, field, val, ...) - (Core, :modifyfield!, (), (2,), (5,)), # modifyfield!(obj, field, op, val, ...) - (Core, :replacefield!, (), (2,), (5,)), # replacefield!(obj, field, expected, val, ...) - (Core, :setfieldonce!, (), (2,), (4,)), # setfieldonce!(obj, field, val, ...) - - # `memoryref*` family. These are used by Base array code. They exist in `Core` - # on Julia 1.12+; some are also exported from `Base` as aliases of the same function. - (Core, :memoryrefnew, (2,), (), ()), - (Core, :memoryref, (2,), (), ()), - (Core, :memoryrefoffset, (2,), (), ()), - (Core, :memoryrefget, (2,), (), ()), - (Core, :memoryrefset!, (), (2,), (3,)), - (Core, :memoryrefswap!, (), (2,), (3,)), - (Core, :memoryrefmodify!, (), (2,), (4,)), - (Core, :memoryrefreplace!, (), (2,), (4,)), - (Core, :memoryrefsetonce!, (), (2,), (3,)), - (Core, :memorynew, (), (), ()), - - # Pointer intrinsics: - # `pointerset(ptr, val, idx, align)` mutates memory through `ptr` and often - # appears as an intrinsic (no reflectable IR), so register it explicitly. - (Core.Intrinsics, :pointerset, (2,), (2,), ()), - - # Misc: - # `Task(f)` needs special handling because it relies on unsafe operations internally. - (Base, :Task, (), (), (2,)), - ] - - for (mod, nm, ret_aliases, writes, consumes) in specs - isdefined(mod, nm) || continue - f = getfield(mod, nm) - _known_effects_has(f) || - register_effects!(f; writes=writes, consumes=consumes, ret_aliases=ret_aliases) - end - - # Foreigncall effects (lowered `ccall` / `llvmcall`). - # Positions are 1-based in the foreigncall C argument list (position 1 == stmt.args[6]). - foreigncall_specs = [ - (:memmove, (1,), (1,), ()), - (:memcpy, (1,), (1,), ()), - (:memset, (1,), (1,), ()), - - # Mutates destination represented by the `(dest_mem, dest_ptr)` pair in: - # jl_genericmemory_copyto(dest_mem::Any, dest_ptr::Ptr, src_mem::Any, src_ptr::Ptr, n::Int) - (:jl_genericmemory_copyto, (), ((1, 2),), ()), - - # Read-only foreigncalls used throughout Base. - (:jl_object_id, (), (), ()), - (:jl_type_hash, (), (), ()), - (:jl_type_unionall, (), (), ()), - (:jl_eqtable_get, (), (), ()), - (:jl_eqtable_nextind, (), (), ()), - (:jl_get_fieldtypes, (), (), ()), - (:jl_field_index, (), (), ()), - (:jl_gc_new_weakref_th, (), (), ()), - (:jl_value_ptr, (), (), ()), - ] - - for (nm, ret_aliases, writes, consumes) in foreigncall_specs - _known_foreigncall_effects_has(nm) || register_foreigncall_effects!( - nm; writes=writes, consumes=consumes, ret_aliases=ret_aliases - ) - end - - return nothing -end - -function _ensure_registry_initialized() - @lock REGISTRY_INITED begin - r = REGISTRY_INITED[] - if !r[] - _populate_registry!() - r[] = true - end - end - return nothing -end - -# === `@unsafe` (auto-IR) support === -# -# `BorrowChecker.@unsafe` expands to: -# - `Expr(:meta, :borrow_checker_unsafe, unsafe_file_sym)` (not executed) -# - `Expr(:meta, :push_loc, unsafe_file_sym, :borrow_checker_unsafe)` / `Expr(:meta, :pop_loc)` (not executed) -# - the real executable block (executed normally) -# -# The compiler preserves `Expr(:meta, ...)` entries in `IRCode.meta`. -const BC_UNSAFE_META = :borrow_checker_unsafe diff --git a/src/safe/diagnostics.jl b/src/safe/diagnostics.jl deleted file mode 100644 index 90fb852..0000000 --- a/src/safe/diagnostics.jl +++ /dev/null @@ -1,379 +0,0 @@ -struct BorrowViolation - idx::Int - msg::String - lineinfo::Union{Nothing,Any} - stmt::Any -end - -""" - BorrowCheckError <: Exception - -Thrown by [`BorrowChecker.@safe`](@ref) when a method specialization violates -borrow-checking rules. Carries the checked signature (`tt`) and the list of -individual `BorrowViolation`s; `showerror` renders a source-level diagnostic -for each violation. -""" -struct BorrowCheckError <: Exception - tt::Any - violations::Vector{BorrowViolation} -end - -struct CachedFileLines - mtime::Float64 - size::Int64 - lines::Vector{String} -end - -const SRCFILE_CACHE = Lockable(Dict{String,CachedFileLines}()) - -@inline function _lineinfo_file_line(li) - file = try - f = getproperty(li, :file) - f === nothing ? nothing : String(f) - catch - nothing - end - line = try - l = getproperty(li, :line) - l === nothing ? nothing : Int(l) - catch - nothing - end - return file, line -end - -const REPL_FILE_RE = r"^REPL\[(\d+)\]$" -const REPL_LINEMARK_RE = r"^\s*#=\s*REPL\[\d+\]:\d+\s*=#\s*$" - -function _repl_hist_entry_content(entry) - entry isa AbstractString && return String(entry) - if hasproperty(entry, :content) - c = try - getproperty(entry, :content) - catch - nothing - end - c isa AbstractString && return String(c) - end - return nothing -end - -function _try_repl_history_provider() - isdefined(Base, :active_repl) || return nothing - repl = Base.active_repl - repl === nothing && return nothing - - hp = nothing - try - iface = getproperty(repl, :interface) - modes = getproperty(iface, :modes) - if modes isa AbstractVector - for mode in modes - hasproperty(mode, :hist) || continue - cand = getproperty(mode, :hist) - cand === nothing && continue - hasproperty(cand, :history) || continue - hp = cand - break - end - end - catch - hp = nothing - end - return hp -end - -function _try_repl_source_lines(file::AbstractString, line::Int) - line <= 0 && return nothing - - m = match(REPL_FILE_RE, file) - m === nothing && return nothing - - hp = _try_repl_history_provider() - hp === nothing && return nothing - - cap = m.captures[1] - cap === nothing && return nothing - n = parse(Int, cap) - n <= 0 && return nothing - - hist = try - getproperty(hp, :history) - catch - return nothing - end - hist isa AbstractVector || return nothing - isempty(hist) && return nothing - - function usable_lines(src) - src_str = _repl_hist_entry_content(src) - src_str === nothing && return nothing - src_str = replace(src_str, '\r' => "") - lines = split(src_str, '\n'; keepempty=true) - (1 <= line <= length(lines)) || return nothing - l = strip(lines[line]) - isempty(l) && return nothing - occursin(REPL_LINEMARK_RE, l) && return nothing - return lines - end - - # First try the direct "REPL[n]" indexing heuristics. - if hasproperty(hp, :start_idx) - start_idx = try - getproperty(hp, :start_idx) - catch - nothing - end - if start_idx isa Integer - for idx in (Int(start_idx) + n, Int(start_idx) + n - 1) - (1 <= idx <= length(hist)) || continue - lines = usable_lines(hist[idx]) - lines !== nothing && return lines - end - end - end - - for idx in (n, n - 1) - (1 <= idx <= length(hist)) || continue - lines = usable_lines(hist[idx]) - lines !== nothing && return lines - end - - # Fallback: scan recent history for a multi-line entry that has a usable target line. - # This is robust against REPL/history behavior changes across Julia versions. - for idx in length(hist):-1:1 - src = _repl_hist_entry_content(hist[idx]) - src === nothing && continue - occursin('\n', src) || continue - lines = usable_lines(src) - lines !== nothing && return lines - end - - return nothing -end - -function _try_source_lines(file::AbstractString, line::Int) - if isfile(file) - return _read_file_lines(String(file)) - end - return _try_repl_source_lines(file, line) -end - -function _lineinfo_chain(li::Core.LineInfoNode) - chain = Core.LineInfoNode[] - cur = li - while cur isa Core.LineInfoNode - push!(chain, cur) - cur = try - getproperty(cur, :inlined_at) - catch - nothing - end - end - return chain -end - -function _read_file_lines(file::String) - st = try - stat(file) - catch - return String[] - end - - mtime = Float64(st.mtime) - size = Int64(st.size) - - @lock SRCFILE_CACHE begin - cache = SRCFILE_CACHE[] - entry = get(cache, file, nothing) - if entry !== nothing && entry.mtime == mtime && entry.size == size - return entry.lines - end - - lines = try - readlines(file) - catch - String[] - end - cache[file] = CachedFileLines(mtime, size, lines) - return lines - end -end - -function _recover_callee_from_tt(tt) - try - tt_u = Base.unwrap_unionall(tt) - tt_u isa DataType || return (nothing, nothing) - ps = tt_u.parameters - isempty(ps) && return (nothing, nothing) - fT = ps[1] - Base.issingletontype(fT) || return (nothing, nothing) - f = getfield(fT, :instance) - argT = Tuple{ps[2:end]...} - return (f, argT) - catch - return (nothing, nothing) - end -end - -function _print_source_context(io::IO, tt, li; context::Int=0) - file, line = if li isa Core.LineInfoNode || li isa LineNumberNode - _lineinfo_file_line(li) - else - return nothing - end - (file === nothing || line === nothing) && return nothing - - if li isa Core.LineInfoNode - chain = _lineinfo_chain(li) - for (k, c) in enumerate(chain) - f, l = _lineinfo_file_line(c) - (f === nothing || l === nothing) && continue - if k == 1 - println(io, " at ", f, ":", l) - else - println(io, " inlined at ", f, ":", l) - end - end - else - println(io, " at ", file, ":", line) - end - - lines = _try_source_lines(file, line) - if lines !== nothing && 1 <= line <= length(lines) - lo = max(1, line - context) - hi = min(length(lines), line + context) - for ln in lo:hi - prefix = (ln == line) ? " > " : " " - println(io, prefix, rpad(string(ln), 5), " ", lines[ln]) - end - return nothing - end - - f, argT = _recover_callee_from_tt(tt) - (f === nothing || argT === nothing) && return nothing - - cis = try - Base.code_lowered(f, argT; debuginfo=:source) - catch - try - Base.code_lowered(f, argT) - catch - Any[] - end - end - isempty(cis) && return nothing - - filesym = Symbol(file) - for ci in cis - ci isa Core.CodeInfo || continue - buf = Any[] - - collecting = false - for st in ci.code - if st isa LineNumberNode - if collecting - break - end - collecting = (st.file == filesym && st.line == line) - continue - end - collecting || continue - push!(buf, st) - end - - if isempty(buf) - def = try - which(f, argT) - catch - nothing - end - - if def !== nothing - last_file = Symbol("") - last_line = 0 - first_idx = 0 - for i in 1:length(ci.code) - scopes = Base.Compiler.IRShow.buildLineInfoNode(ci.debuginfo, def, i) - if !isempty(scopes) - li = scopes[1] - last_file = li.file - last_line = Int(li.line) - end - if last_file == filesym && last_line == line - first_idx = i - break - end - end - - if first_idx != 0 - for j in first_idx:length(ci.code) - scopes = Base.Compiler.IRShow.buildLineInfoNode( - ci.debuginfo, def, j - ) - if !isempty(scopes) - li = scopes[1] - last_file = li.file - last_line = Int(li.line) - end - (last_file == filesym && last_line == line) || break - push!(buf, ci.code[j]) - end - end - end - end - - if !isempty(buf) - println(io, " lowered:") - for ex in buf - s = try - sprint(show, ex) - catch - "" - end - isempty(s) || println(io, " ", s) - end - break - end - - println(io, " lowered:") - n = min(6, length(ci.code)) - for i in 1:n - s = try - sprint(show, ci.code[i]) - catch - "" - end - isempty(s) || println(io, " ", s) - end - break - end - - return nothing -end - -function Base.showerror(io::IO, e::BorrowCheckError) - print(io, "BorrowCheckError for specialization ", e.tt) - - try - (f, argT) = _recover_callee_from_tt(e.tt) - m = which(f, argT) - print(io, "\n\n method: ", m) - catch - end - - for (i, v) in enumerate(e.violations) - println(io) - println(io) - print(io, " [", i, "] stmt#", v.idx, ": ", v.msg) - if v.lineinfo !== nothing - try - _print_source_context(io, e.tt, v.lineinfo; context=2) - catch - println(io, " ", v.lineinfo) - end - end - println(io) - print(io, " stmt: ", v.stmt) - end -end diff --git a/src/safe/frontend.jl b/src/safe/frontend.jl deleted file mode 100644 index 59211d3..0000000 --- a/src/safe/frontend.jl +++ /dev/null @@ -1,780 +0,0 @@ -""" -Run BorrowCheck on a concrete specialization `tt::Type{<:Tuple}`. - -Returns `true` on success; throws `BorrowCheckError` on failure. -""" -const CheckedCacheSig = Tuple{String,Int,Symbol,Module,Bool,Int} - -@inline function _checked_cache_sig(cfg::Config) - return ( - cfg.optimize_until, - cfg.max_summary_depth, - cfg.scope, - cfg.root_module, - cfg.debug, - cfg.debug_callee_depth, - )::CheckedCacheSig -end - -const CHECKED_CACHE = Lockable(IdDict{Any,Tuple{UInt,CheckedCacheSig}}()) # Type{Tuple...} => (world, sig) -const PER_TASK_CHECKED_CACHE = PerTaskCache{IdDict{Any,Tuple{UInt,CheckedCacheSig}}}() - -# Marker for "currently being checked". Prevents infinite recursion when `scope` -# triggers re-entrant borrow-checking of the same specialization. -const BC_INPROGRESS_WORLD = typemax(UInt) - -function _tt_module(tt::Type{<:Tuple}) - tt_u = Base.unwrap_unionall(tt) - tt_u isa DataType || return nothing - isempty(tt_u.parameters) && return nothing - - fT = tt_u.parameters[1] - dt = Base.unwrap_unionall(fT) - dt isa DataType || return nothing - - m = dt.name.module - if dt.name === Base.unwrap_unionall(Type).name && !isempty(dt.parameters) - targ = Base.unwrap_unionall(dt.parameters[1]) - targ isa DataType && (m = targ.name.module) - end - return m -end - -function _module_is_under(m::Module, root::Module)::Bool - mm = m - while true - mm === root && return true - parent = Base.parentmodule(mm) - parent === mm && return false - mm = parent - end -end - -function _scope_allows_module(m::Module, cfg::Config)::Bool - # Never recursively borrow-check BorrowChecker itself. - m === BorrowChecker && return false - - cfg.scope === :all && return true - if cfg.scope === :none || cfg.scope === :function - return false - elseif cfg.scope === :module - return m === cfg.root_module - elseif cfg.scope === :user - # "user" means: only recurse into user code (no Core/Base, including submodules). - return !(_module_is_under(m, Base) || _module_is_under(m, Core)) - end - throw(ArgumentError("unknown scope: $(cfg.scope)")) -end - -function _scope_allows_tt(tt::Type{<:Tuple}, cfg::Config)::Bool - m = _tt_module(tt) - m === nothing && return false - return _scope_allows_module(m, cfg) -end - -function _callsite_method_module(i::Int, head, mi, ir::CC.IRCode) - if head === :invoke && mi !== nothing - try - return getfield(getfield(mi, :def), :module) - catch - end - return nothing - end - - info = try - ir[Core.SSAValue(i)][:info] - catch - nothing - end - try - info === nothing && return nothing - - callinfo = if hasproperty(info, :call) - getproperty(info, :call) - else - info - end - - hasproperty(callinfo, :results) || return nothing - lr = getproperty(callinfo, :results) - hasproperty(lr, :matches) || return nothing - matches = getproperty(lr, :matches) - length(matches) == 1 || return nothing - mm = matches[1] - hasproperty(mm, :method) || return nothing - meth = getproperty(mm, :method) - hasproperty(meth, :module) || return nothing - return getproperty(meth, :module) - catch - return nothing - end -end - -function _apply_iterate_inner_tt(raw_args, ir::CC.IRCode) - length(raw_args) >= 3 || return nothing - inner_f = try - CC.singleton_type(_safe_argextype(raw_args[3], ir)) - catch - nothing - end - inner_f === nothing && return nothing - - expanded_types = Any[typeof(inner_f)] - for j in 4:length(raw_args) - argj = raw_args[j] - elems = _maybe_tuple_elements(argj, ir) - if elems !== nothing - for e in elems - push!(expanded_types, _widenargtype_or_any(e, ir)) - end - continue - end - - Tj = _widenargtype_or_any(argj, ir) - Tj === Tuple{} && continue - dt = Base.unwrap_unionall(Tj) - if dt isa DataType && dt.name === Tuple.name - params = dt.parameters - has_vararg = any(p -> p isa Core.TypeofVararg, params) - if !has_vararg - for te in params - te2 = Base.unwrap_unionall(te) - push!(expanded_types, (te2 isa Type) ? te2 : Any) - end - continue - end - end - - push!(expanded_types, Tj) - end - - try - return Core.apply_type(Tuple, expanded_types...) - catch - return nothing - end -end - -function _check_ir_callees!(ir::CC.IRCode, cfg::Config, world::UInt) - (cfg.scope === :none || cfg.scope === :function) && return nothing - - unsafe_stmt = _unsafe_stmt_mask(ir) - - nstmts = length(ir.stmts) - for i in 1:nstmts - (1 <= i <= length(unsafe_stmt) && unsafe_stmt[i]) && continue - stmt = ir[Core.SSAValue(i)][:stmt] - head, mi, raw_args = _call_parts(stmt) - raw_args === nothing && continue - - f = _resolve_callee(stmt, ir) - f === __bc_bind__ && continue - f === __bc_assert_safe__ && continue - - tt = if f === Core._apply_iterate - _apply_iterate_inner_tt(raw_args, ir) - elseif f === Core.kwcall - _kwcall_tt_from_raw_args(raw_args, ir) - elseif head === :invoke && mi !== nothing - try - mi.specTypes - catch - nothing - end - else - _call_tt_from_raw_args(raw_args, ir, f) - end - tt === nothing && continue - tt isa Type{<:Tuple} || continue - m = _callsite_method_module(i, head, mi, ir) - m_precise = m !== nothing - if m === nothing - m = _tt_module(tt) - m === nothing && continue - end - - # Performance guard: for `scope=:all`, avoid recursively checking Base/Core callees. - # Base/Core borrow-check errors are treated as non-fatal anyway, and walking the entire - # Base/Core call graph can make `scope=:all` unusably slow. - if cfg.scope === :all && - m_precise && - (_module_is_under(m, Base) || _module_is_under(m, Core)) - continue - end - _scope_allows_module(m, cfg) || continue - - try - __bc_assert_safe__(tt; cfg=cfg, world=world) - catch e - # `scope=:all` is intentionally aggressive and compiler-dependent. Base/Core IR - # routinely uses low-level memory primitives that can trigger spurious violations. - # Treat these as non-fatal so `scope=:all` remains usable for user-code debugging. - if cfg.scope === :all && - (e isa BorrowCheckError) && - (_module_is_under(m, Base) || _module_is_under(m, Core)) - continue - end - rethrow() - end - end - - return nothing -end - -function check_signature( - tt::Type{<:Tuple}; cfg::Config=Config(), world::UInt=Base.get_world_counter() -) - @nospecialize tt - _ensure_registry_initialized() - return _with_reflection_ctx(world) do - summary_snapshot = cfg.debug ? _auto_debug_summary_keys(UInt(world), cfg) : nothing - debug_ok = true - debug_violations = BorrowViolation[] - debug_err = nothing - debug_bt = nothing - entry_codes = nothing - - try - codes = _code_ircode_by_type( - tt; optimize_until=cfg.optimize_until, world=world, cfg - ) - entry_codes = codes - viols = BorrowViolation[] - for entry in codes - ir = entry.first - ir isa CC.IRCode || continue - append!(viols, check_ir(ir, cfg)) - _check_ir_callees!(ir, cfg, world) - end - isempty(viols) || throw(BorrowCheckError(tt, viols)) - return true - catch e - debug_ok = false - debug_err = e - debug_bt = catch_backtrace() - if e isa BorrowCheckError - append!(debug_violations, e.violations) - end - rethrow() - finally - if cfg.debug - try - _auto_debug_emit_check!( - tt, - cfg, - UInt(world), - summary_snapshot, - debug_ok, - debug_violations, - debug_err, - debug_bt, - entry_codes, - ) - catch - end - end - end - end -end - -Base.@noinline function __bc_assert_safe__( - tt::Type{<:Tuple}; cfg::Config=Config(), world::UInt=Base.get_world_counter() -) - @nospecialize tt - task_cache = PER_TASK_CHECKED_CACHE[] - sig = _checked_cache_sig(cfg) - - # Fast path: per-task cache (no locking). - state = get(task_cache, tt, nothing) - if state !== nothing - world0, sig0 = state - if world0 == world && sig0 == sig - return nothing - end - end - - # Slow path: shared cache (locked). - # Lock spans the entire inference so we avoid repeated inference. - Base.@lock CHECKED_CACHE begin - dict = CHECKED_CACHE[] - state = get(dict, tt, nothing) - if state !== nothing - world0, sig0 = state - if world0 == world && sig0 == sig - task_cache[tt] = state - return nothing - end - if world0 == BC_INPROGRESS_WORLD && sig0 == sig - return nothing - end - end - - dict[tt] = (BC_INPROGRESS_WORLD, sig) - try - check_signature(tt; cfg=cfg, world=world) - catch - delete!(dict, tt) - rethrow() - end - new_state = (world, sig) - dict[tt] = new_state - task_cache[tt] = new_state - return nothing - end -end - -# Extract the call expression from a signature (handles where/return-type annotations). -function _sig_call(sig) - while sig isa Expr && sig.head === :where - sig = sig.args[1] - end - if sig isa Expr && sig.head === :(::) - sig = sig.args[1] - end - return sig -end - -function _fval_expr_from_sigcall(call) - fhead = call.args[1] - if fhead isa Symbol - return fhead - elseif fhead isa Expr && fhead.head === :(::) - # (f::T)(args...) form - return fhead.args[1] - else - return fhead - end -end - -function _argref_expr(arg) - if arg isa Symbol - return arg - elseif arg isa Expr && arg.head === :(::) - return arg.args[1] - elseif arg isa Expr && arg.head === :kw - return _argref_expr(arg.args[1]) - elseif arg isa Expr && arg.head === :... - inner = _argref_expr(arg.args[1]) - return Expr(:..., inner) - elseif arg isa Expr && arg.head === :parameters - # keyword argument container; ignore for type tuple construction - return nothing - else - return arg - end -end - -function _tt_expr_from_signature(sig, cfg_tag) - call = _sig_call(sig) - call isa Expr && call.head === :call || - error("@safe currently supports standard function signatures") - fval = _fval_expr_from_sigcall(call) - - params = Any[cfg_tag, :(Core.Typeof($fval))] - for a in call.args[2:end] - # Anonymous typed arguments appear as `(::T)` or `(::T=default)` in the AST. - # These do not have a runtime value binding, so we cannot take `Core.Typeof` of them. - if a isa Expr && a.head === :kw - a = a.args[1] - end - - if a isa Expr && a.head === :(::) && length(a.args) == 1 - push!(params, a.args[1]) - continue - end - - r = _argref_expr(a) - r === nothing && continue - - if r isa Expr && r.head === :... - t = Expr(:tuple, r) - push!(params, Expr(:..., :(map(Core.Typeof, $t)))) - else - push!(params, :(Core.Typeof($r))) - end - end - - return Expr(:curly, :Tuple, params...) -end - -function _is_method_definition_lhs(lhs) - lhs isa Expr || return false - # Local method definition forms appear as assignment with a call-like LHS. - # Be careful not to treat typed variable assignments like `x::T = rhs` as a - # method definition. - call = lhs - while call isa Expr && call.head === :where - call = call.args[1] - end - if call isa Expr && call.head === :(::) - call = call.args[1] - end - return call isa Expr && call.head === :call -end - -function _lambda_arglist(args_expr) - if args_expr isa Expr && args_expr.head === :tuple - return Any[args_expr.args...] - elseif args_expr === nothing - return Any[] - else - return Any[args_expr] - end -end - -function _instrument_lambda(ex::Expr, cfg_tag) - @assert ex.head === :(->) - args_expr = ex.args[1] - body = ex.args[2] - - fname = gensym(:__bc_lambda__) - arglist = _lambda_arglist(args_expr) - sig = Expr(:call, fname, arglist...) - inst_body = _prepend_check_stmt(sig, body, cfg_tag) - fdef = Expr(:function, sig, inst_body) - return Expr(:block, fdef, fname) -end - -function _instrument_assignments(ex, cfg_tag) - ex isa Expr || return ex - - if ex.head === :quote || ex.head === :inert - return ex - end - - # Already-expanded `@unsafe` regions: these are blocks that start with our meta marker. - # Avoid instrumenting the unsafe region (including the stored meta AST). - if ex.head === :block && !isempty(ex.args) - first = ex.args[1] - if first isa Expr && - first.head === :meta && - !isempty(first.args) && - first.args[1] === BC_UNSAFE_META - return ex - end - end - - # Avoid recursing into the stored meta AST for `@unsafe`. - if ex.head === :meta && !isempty(ex.args) && ex.args[1] === BC_UNSAFE_META - return ex - end - - # `@unsafe ...` regions are deliberately excluded from `@safe`'s recursive - # instrumentation (no prologue checks for inner lambdas/functions, and we do - # not insert `__bc_bind__` barriers inside). - if ex.head === :macrocall - # Forms: - # @unsafe begin ... end - # Mod.@unsafe begin ... end - # AST forms use `Symbol("@unsafe")` or a `GlobalRef` for qualified macros. - m = ex.args[1] - if m === Symbol("@unsafe") || (m isa GlobalRef && m.name === Symbol("@unsafe")) - return ex - end - end - - if ex.head === :function - sig = ex.args[1] - body = ex.args[2] - inst_body = _prepend_check_stmt(sig, body, cfg_tag) - return Expr(:function, sig, inst_body) - end - - if ex.head === :(->) - return _instrument_lambda(ex, cfg_tag) - end - - if ex.head === :(=) && length(ex.args) == 2 - lhs, rhs = ex.args - if _is_method_definition_lhs(lhs) - sig = lhs - body = rhs - inst_body = _prepend_check_stmt(sig, body, cfg_tag) - return Expr(:function, sig, inst_body) - end - lhs2 = _instrument_assignments(lhs, cfg_tag) - rhs2 = _instrument_assignments(rhs, cfg_tag) - - # If the RHS is an instrumented lambda block, don't wrap it in `__bc_bind__`. - # Wrapping forces the value to `Any` and breaks call resolution, which makes - # `f(x)` look like an unknown call that consumes tracked arguments. - if rhs2 isa Expr && rhs2.head === :block && length(rhs2.args) >= 2 - last = rhs2.args[end] - if last isa Symbol && any( - a -> ( - a isa Expr && - a.head === :function && - a.args[1] isa Expr && - a.args[1].head === :call && - a.args[1].args[1] == last - ), - rhs2.args[1:(end - 1)], - ) - return Expr(:(=), lhs2, rhs2) - end - end - - bind_ref = GlobalRef(@__MODULE__, :__bc_bind__) - return Expr(:(=), lhs2, Expr(:call, bind_ref, rhs2)) - end - - # Recurse - return Expr(ex.head, map(a -> _instrument_assignments(a, cfg_tag), ex.args)...) -end - -function _prepend_check_stmt(sig, body, cfg_tag, debug::Bool=false) - tt_expr = _tt_expr_from_signature(sig, cfg_tag) - assert_ref = GlobalRef(@__MODULE__, :_generated_assert_safe) - check_stmt = Expr(:call, assert_ref, tt_expr) - - body_block = (body isa Expr && body.head === :block) ? body : Expr(:block, body) - debug_warn_stmt = if debug - path_ref = GlobalRef(@__MODULE__, :_auto_debug_path) - Expr(:call, path_ref, true) - else - nothing - end - new_body = if debug_warn_stmt === nothing - Expr(:block, check_stmt, body_block.args...) - else - Expr(:block, debug_warn_stmt, check_stmt, body_block.args...) - end - return _instrument_assignments(new_body, cfg_tag) -end - -function _parse_cfg_value(x, calling_module) - if x isa QuoteNode - return x.value - elseif x isa Expr - return Core.eval(calling_module, x) - else - return x - end -end - -""" -Parse `@safe` macro options into `Config` field overrides. - -Returns a fully-specified `Config`. -""" -function parse_config(options, calling_module)::Config - cfg0 = Config() - scope = cfg0.scope - max_summary_depth = cfg0.max_summary_depth - optimize_until = cfg0.optimize_until - debug = cfg0.debug - debug_callee_depth = cfg0.debug_callee_depth - for option in options - if option isa Expr && - length(option.args) == 2 && - (option.head === :(=) || option.head === :kw) - k = option.args[1] - v = option.args[2] - if k === :scope - scope = _parse_cfg_value(v, calling_module)::Symbol - continue - elseif k === :max_summary_depth - max_summary_depth = _parse_cfg_value(v, calling_module)::Int - continue - elseif k === :optimize_until - optimize_until = _parse_cfg_value(v, calling_module)::String - continue - elseif k === :debug - debug = _parse_cfg_value(v, calling_module)::Bool - continue - elseif k === :debug_callee_depth - debug_callee_depth = _parse_cfg_value(v, calling_module)::Int - continue - end - end - error( - "@safe only supports `scope=...`, `max_summary_depth=...`, `optimize_until=...`, `debug=...`, `debug_callee_depth=...`; got: $option", - ) - end - - scope ∈ (:none, :function, :module, :user, :all) || error( - "invalid `scope` for @safe: $scope (expected :none, :function, :module, :user, or :all)", - ) - - root_module = (scope === :module) ? calling_module : cfg0.root_module - debug_callee_depth >= 0 || - error("`debug_callee_depth` must be >= 0; got: $debug_callee_depth") - return Config( - optimize_until, max_summary_depth, scope, root_module, debug, debug_callee_depth - ) -end - -function _auto(args...; calling_module, source_info=nothing) - _ = source_info - - ex = args[end] - is_borrow_checker_enabled(calling_module) || return ex - - raw_options = args[begin:(end - 1)] - cfg = parse_config(raw_options, calling_module) - if cfg.scope === :none - return ex - end - - cfg_tag = let - tag_ref = GlobalRef(@__MODULE__, :GeneratedCfgTag) - Expr( - :curly, - tag_ref, - QuoteNode(cfg.scope), - cfg.max_summary_depth, - QuoteNode(Symbol(cfg.optimize_until)), - cfg.debug, - cfg.debug_callee_depth, - ) - end - - # Function form - if ex isa Expr && ex.head === :function - sig = ex.args[1] - body = ex.args[2] - inst_body = _prepend_check_stmt(sig, body, cfg_tag, cfg.debug) - return Expr(:function, sig, inst_body) - end - - # One-line method form: f(args...) = body - if ex isa Expr && ex.head === :(=) && _is_method_definition_lhs(ex.args[1]) - sig = ex.args[1] - body = ex.args[2] - inst_body = _prepend_check_stmt(sig, body, cfg_tag, cfg.debug) - return Expr(:function, sig, inst_body) - end - - return error("@safe must wrap a function/method definition") -end - -""" -Automatically borrow-check a function (best-effort). - -`BorrowChecker.@safe` is a *drop-in tripwire* for existing code: - -- **Aliasing violations**: mutating a value while another live binding may observe that mutation. -- **Escapes / “moves”**: storing a mutable value somewhere that outlives the current scope - (e.g. a global cache / a field / a container), then continuing to reference it locally. - -On function entry, it checks the current specialization and caches the result so future -calls are fast. On failure it throws `BorrowCheckError` with best-effort source context. - -## Options - -Options are parsed by the macro and compiled into a `BorrowChecker.Config` (and are -part of the checked-cache key). - -- `scope` (default: `:function`): controls whether the checker recursively borrow-checks - callees (call-graph traversal). - - `:none`: disable `@safe` entirely (no IR borrow-checking; returns the original definition). - - `:function`: check only the annotated method. - - `:module`: recursively check callees whose defining module matches the module where `@safe` is used. - - `:user`: recursively check callees, but ignore `Core` and `Base` (including their submodules). - - `:all`: recursively check callees across all modules (very aggressive). -- `max_summary_depth` (default: `12`): limits recursive effect summarization depth used - when the checker cannot directly resolve effects. -- `debug` (default: `false`): enable best-effort debug logging to a JSONL file - (path controlled by `BORROWCHECKER_AUTO_DEBUG_PATH`). -- `debug_callee_depth` (default: `2`): when `debug=true`, also dump IR for summary-recursion - entries up to this depth (0 = only the entrypoint specialization). - -Examples: - -```julia -BorrowChecker.@safe scope=:module function f(x) - g(x) -end - -BorrowChecker.@safe max_summary_depth=4 optimize_until="compact 1" function h(x) - g(x) -end -``` - -# Extended help - -### `optimize_until` - -`optimize_until` (default: `BorrowChecker.DEFAULT_CONFIG.optimize_until`) controls -which compiler pass to stop at when fetching IR via `Base.code_ircode_by_type`. - -Pass names vary across Julia versions; `@safe` tries to normalize common spellings like -`"compact 1"` / `"compact_1"` when possible. - -!!! warning - This macro is highly experimental and compiler-dependent. There are likely bugs and - false positives. It is intended for development and testing, and does not guarantee - memory safety. -""" -macro safe(args...) - return esc(_auto(args...; calling_module=__module__, source_info=__source__)) -end - -""" - BorrowChecker.@auto [options...] function f(args...) - ... - end - -Deprecated alias for [`BorrowChecker.@safe`](@ref). Emits a depwarn and forwards. -""" -macro auto(args...) - Base.depwarn( - "`BorrowChecker.@auto` is deprecated; use `BorrowChecker.@safe` instead.", :auto - ) - return esc(_auto(args...; calling_module=__module__, source_info=__source__)) -end - -""" - @unsafe begin - ... - end - -Mark a lexical region as *unchecked* by `BorrowChecker.@safe`. - -Semantics (auto-IR checker only): - -- The borrow checker does **not** validate aliasing / uniqueness rules for statements - inside the `@unsafe` region. -- The borrow checker does **not** enforce escape/consume ("move") rules inside the - `@unsafe` region. -- The unsafe region is treated as **opaque** to surrounding checked code: effects inside - the region (writes, consumes, escapes, new aliases) are not propagated outward into the - surrounding analysis. -- The checker does **not** recursively borrow-check callees that are only reachable from - within the `@unsafe` region. -- The unsafe region is still executed normally at runtime and evaluates to the value of - its last expression (like a `begin ... end` block). - -This is intentionally analogous to `@inbounds`: it is an escape hatch for low-level -code or for cases where the checker is overly conservative. The responsibility to -uphold the usual invariants is on you. -""" -macro unsafe(ex) - is_borrow_checker_enabled(__module__) || return esc(ex) - - # Tag unsafe regions via debug "location stack" metadata (`:push_loc` / `:pop_loc`) - # plus a unique synthetic file symbol. This survives `@safe` macro rewriting, - # handles same-line `;` cases, and remains visible through inlining. - unsafe_file = gensym(:borrow_checker_unsafe_file) - - # Normalize to a block and ensure it has a leading line node (important for one-liners). - body0 = if (ex isa Expr && ex.head === :block) - ex - else - Expr(:block, ex) - end - if isempty(body0.args) || !(body0.args[1] isa LineNumberNode) - body0 = Expr( - :block, LineNumberNode(__source__.line, __source__.file), body0.args... - ) - end - - meta = Expr(:meta, BC_UNSAFE_META, unsafe_file) - push_loc = Expr(:meta, :push_loc, unsafe_file, __source__.line) - pop_loc = Expr(:meta, :pop_loc) - - # `@unsafe` must behave like a plain `begin ... end` (no new scope), but we also need - # to emit `:pop_loc` after the region without changing the block's value. - inner = Expr(:ref, Expr(:tuple, body0, pop_loc), 1) - return esc(Expr(:block, meta, push_loc, inner)) -end diff --git a/src/safe/generated.jl b/src/safe/generated.jl deleted file mode 100644 index 3e9d7c4..0000000 --- a/src/safe/generated.jl +++ /dev/null @@ -1,114 +0,0 @@ -using Core.Compiler -using Core.IR - -struct BCInterpOwner end -Base.@kwdef struct BCInterp <: Compiler.AbstractInterpreter - world::UInt = Base.get_world_counter() - inf_params::Compiler.InferenceParams = Compiler.InferenceParams() - opt_params::Compiler.OptimizationParams = Compiler.OptimizationParams() - inf_cache::Vector{Compiler.InferenceResult} = Compiler.InferenceResult[] - codegen_cache::IdDict{CodeInstance,CodeInfo} = IdDict{CodeInstance,CodeInfo}() -end -Base.Experimental.@MethodTable BCMT - -struct GeneratedCfgTag{S,MSD,OPT,DBG,DCD} end - -Compiler.InferenceParams(interp::BCInterp) = interp.inf_params -Compiler.OptimizationParams(interp::BCInterp) = interp.opt_params -Compiler.get_inference_world(interp::BCInterp) = interp.world -Compiler.get_inference_cache(interp::BCInterp) = interp.inf_cache -Compiler.cache_owner(::BCInterp) = BCInterpOwner() -Compiler.codegen_cache(interp::BCInterp) = interp.codegen_cache -Compiler.method_table(interp::BCInterp) = Compiler.OverlayMethodTable(interp.world, BCMT) - -function _cfg_from_tag( - ::Type{GeneratedCfgTag{S,MSD,OPT,DBG,DCD}}, tt::Type{<:Tuple}, world::UInt -) where {S,MSD,OPT,DBG,DCD} - @nospecialize tt - scope = S::Symbol - max_summary_depth = MSD::Int - optimize_until = String(OPT::Symbol) - debug = DBG::Bool - debug_callee_depth = DCD::Int - - root_module = if scope === :module - matches = Base._methods_by_ftype(tt, -1, world) - if isnothing(matches) || isempty(matches) - Main - else - (matches[1]::Core.MethodMatch).method.module - end - else - Main - end - - return Config( - optimize_until, max_summary_depth, scope, root_module, debug, debug_callee_depth - ) -end - -function _tt_cfg_from_sig(sig::DataType, world::UInt) - @nospecialize sig - # `sig` is a type like: - # Tuple{GeneratedCfgTag{...}, typeof(f), typeof(x), ...} - tt = try - Core.apply_type(Tuple, sig.parameters[2:end]...) - catch - sig - end - tt isa Type{<:Tuple} || - error("_generated_assert_safe expected a config-tagged Tuple type; got $sig") - return tt, _cfg_from_tag(sig.parameters[1]::Type{<:GeneratedCfgTag}, tt, world) -end - -function _generated_assert_safe_body(world::UInt, lnn, this, sig) - sig = sig.parameters[1] - - tt, cfg = _tt_cfg_from_sig(sig, world) - - check_signature(tt; cfg, world) # Do the actual checking - - ci = _expr_to_codeinfo( - @__MODULE__(), [Symbol("#self#"), :sig], [], :(return nothing), false - ) - - matches = Base._methods_by_ftype(tt, -1, world) - if !isnothing(matches) - ci.edges = Any[] - for match in matches - mi = Base.specialize_method(match) - push!(ci.edges, mi) - end - end - return ci -end - -function _expr_to_codeinfo(m::Module, argnames, spnames, e::Expr, isva) - body = Expr(:block, Expr(:return, Expr(:block, e))) - scope = Expr(Symbol("scope-block"), body) - lambda = Expr(:lambda, argnames, scope) - ex = if isnothing(spnames) || isempty(spnames) - lambda - else - Expr(Symbol("with-static-parameters"), lambda, spnames...) - end - ci = Base.generated_body_to_codeinfo(ex, @__MODULE__(), isva) - @assert ci isa Core.CodeInfo "Failed to create a CodeInfo from the given expression. This might mean it contains a closure or comprehension?\n Offending expression: $e" - return ci -end - -#! format: off -function _refresh_generated_assert_safe() - @eval function _generated_assert_safe(sig::Type{<:Tuple{<:GeneratedCfgTag,Vararg{Any}}}) - $(Expr(:meta, :generated_only)) - $(Expr(:meta, :generated, _generated_assert_safe_body)) - end - - # Don't recursively borrow check the borrow checking! - @eval Base.Experimental.@overlay BCMT _generated_assert_safe(sig) = nothing -end -#! format: on -# -# NOTE: `check_signature` is defined in `frontend.jl`, and Julia 1.12+ is stricter -# about calling "too-new" methods from generated-function contexts. We therefore -# delay defining `_generated_assert_safe` until after `frontend.jl` is loaded. diff --git a/src/safe/ir_primitives.jl b/src/safe/ir_primitives.jl deleted file mode 100644 index 81ecf8f..0000000 --- a/src/safe/ir_primitives.jl +++ /dev/null @@ -1,667 +0,0 @@ -Base.@kwdef struct TypeTracker - seen::Base.IdSet{Any} = Base.IdSet{Any}() -end - -""" - _is_shareable_handle_type(T) -> Bool - -Return `true` for types that behave like shareable concurrency handles. - -These values routinely escape into globally-reachable runtime state (e.g. scheduler -queues) as an implementation detail, while remaining safe to use via additional -aliases held by user code. They should not participate in `@safe`'s Rust-like -ownership/move rules. -""" -function _is_shareable_handle_type(@nospecialize(T))::Bool - # Type slots in `IRCode` may contain compiler lattice elements - # (e.g. `Core.PartialStruct`, `Core.Const`, ...). Only actual Julia `Type`s - # are eligible for this shareable-handle fast-path. - T isa Type || return false - - # Task handles are stored in the scheduler run queues by `@async`/`schedule`. - (T <: Task) && return true - - # Atomics provide synchronized interior mutability and are intended to be aliased. - if isdefined(Base, :Threads) && isdefined(Base.Threads, :Atomic) - (T <: Base.Threads.Atomic) && return true - end - - return false -end - -function (tt::TypeTracker)(@nospecialize(T))::Bool - T === Union{} && return false - T === Any && return true - if T isa Union - return any(tt, Base.uniontypes(T)) - end - - @assert (T isa Type) ( - "BorrowChecker: expected `Type` in TypeTracker, got $(typeof(T))" - ) - - if T isa UnionAll - return tt(Base.unwrap_unionall(T)) - end - T === Symbol && return false - - # Modules and type objects are globally-shareable handles. - # Treat them as *not tracked* so they don't participate in move/consume rules. - (T <: Module) && return false - (T <: Type) && return false - - _is_shareable_handle_type(T) && return false - - # Low-level references. We treat these as tracked because they can point to mutable - # memory even though the value itself is isbits. - if T <: Ptr - return true - end - if isdefined(Base, :RefValue) && (T <: Base.RefValue) - return true - end - if isdefined(Core, :MemoryRef) && (T <: Core.MemoryRef) - return true - end - if isdefined(Core, :GenericMemoryRef) && (T <: Core.GenericMemoryRef) - return true - end - if isdefined(Core, :GenericMemory) && (T <: Core.GenericMemory) - return true - end - - dt = Base.unwrap_unionall(T) - if dt isa DataType - if isdefined(Core, :Box) && (dt === Core.Box || T <: Core.Box) - # Some low-level compiler artifacts behave more like borrows than owned resources. - return false - end - Base.isconcretetype(dt) || return true - Base.ismutabletype(dt) && return true - if Base.isbitstype(dt) - return any(tt, fieldtypes(dt)) - end - dt in tt.seen && return true - push!(tt.seen, dt) - return any(tt, fieldtypes(dt)) - end - - return true -end - -is_tracked_type(@nospecialize T)::Bool = TypeTracker()(T) - -# "Tracking" answers: should we include this value in alias/liveness tracking? -# -# For move-like checks we also need a notion of "owned" values. Some low-level compiler -# artifacts (e.g. `Core.MemoryRef`) behave more like borrows of an owned object rather than -# independently-owned resources. -Base.@kwdef struct OwnedTypeTracker - seen::Base.IdSet{Any} = Base.IdSet{Any}() -end - -function _is_nonowning_ref_type(@nospecialize(T))::Bool - if isdefined(Core, :MemoryRef) && (T <: Core.MemoryRef) - return true - end - if isdefined(Core, :GenericMemoryRef) && (T <: Core.GenericMemoryRef) - return true - end - if isdefined(Core, :GenericMemory) && (T <: Core.GenericMemory) - return true - end - if T <: Ptr - return true - end - return false -end - -function (tt::OwnedTypeTracker)(@nospecialize(T))::Bool - T === Union{} && return false - T === Any && return true - T isa Union && return any(tt, Base.uniontypes(T)) - - @assert (T isa Type) ( - "BorrowChecker: expected `Type` in OwnedTypeTracker, got $(typeof(T))" - ) - - if T isa UnionAll - return tt(Base.unwrap_unionall(T)) - end - T === Symbol && return false - - # Modules and type objects are globally-shareable handles. - # Treat them as *not owned* so unknown/dynamic calls don't spuriously consume them. - (T <: Module) && return false - (T <: Type) && return false - - _is_shareable_handle_type(T) && return false - - # These low-level reference types are never treated as owned. - if T <: Ptr - return false - end - if isdefined(Base, :RefValue) && (T <: Base.RefValue) - return false - end - if isdefined(Core, :MemoryRef) && (T <: Core.MemoryRef) - return false - end - if isdefined(Core, :GenericMemoryRef) && (T <: Core.GenericMemoryRef) - return false - end - if isdefined(Core, :GenericMemory) && (T <: Core.GenericMemory) - return false - end - - dt = Base.unwrap_unionall(T) - if dt isa DataType - if isdefined(Core, :Box) && (dt === Core.Box || T <: Core.Box) - # Some low-level compiler artifacts behave more like borrows than owned resources. - return false - end - Base.isconcretetype(dt) || return true - Base.ismutabletype(dt) && return true - Base.isbitstype(dt) && return false - dt in tt.seen && return true - push!(tt.seen, dt) - return any(tt, fieldtypes(dt)) - end - - return true -end - -is_owned_type(@nospecialize T)::Bool = OwnedTypeTracker()(T) - -@inline _ssa_handle(nargs::Int, id::Int) = nargs + id -@inline _arg_handle(id::Int) = id - -function _handle_index( - x, nargs::Int, track_arg::AbstractVector{Bool}, track_ssa::AbstractVector{Bool} -) - if x isa Core.Argument - n = x.n - return (1 <= n <= length(track_arg) && track_arg[n]) ? _arg_handle(n) : 0 - elseif x isa Core.SSAValue - i = x.id - return (1 <= i <= length(track_ssa) && track_ssa[i]) ? _ssa_handle(nargs, i) : 0 - else - return 0 - end -end - -function _inst_get(@nospecialize(inst), sym::Symbol, default=nothing) - try - return inst[sym] - catch - end - if Base.hasproperty(inst, sym) - return getproperty(inst, sym) - end - return default -end - -@inline function _safe_argextype(@nospecialize(x), ir::CC.IRCode) - # `Core.Compiler.argextype` expects a valid IR argument (SSAValue/Argument/Const/...), - # not an `Expr(:call, ...)`. On newer Julia versions this can throw/warn loudly. - x isa Expr && return Any - return CC.argextype(x, ir) -end - -function _lineinfo_from_debuginfo(ir::CC.IRCode, pc::Int) - pc <= 0 && return nothing - builder = if isdefined(CC, :IRShow) && isdefined(CC.IRShow, :buildLineInfoNode) - CC.IRShow.buildLineInfoNode - elseif isdefined(CC, :buildLineInfoNode) - CC.buildLineInfoNode - else - nothing - end - builder === nothing && return nothing - try - di = getproperty(ir, :debuginfo) - stack = builder(di, nothing, pc) - isempty(stack) && return nothing - chosen = nothing - for node in stack - file = try - getproperty(node, :file) - catch - nothing - end - line = try - getproperty(node, :line) - catch - nothing - end - if file isa Symbol && - line isa Integer && - line > 0 && - file !== Symbol("none") && - file !== Symbol("unknown") - chosen = node - break - end - end - - chosen === nothing && return nothing - file = getproperty(chosen, :file)::Symbol - line = getproperty(chosen, :line)::Integer - return LineNumberNode(Int(line), file) - catch - return nothing - end -end - -function _normalize_lineinfo(ir::CC.IRCode, li, pc::Int=0) - if li isa Core.LineInfoNode - file = try - String(getproperty(li, :file)) - catch - "" - end - line = try - Int(getproperty(li, :line)) - catch - 0 - end - if !isempty(file) && file != "none" && file != "unknown" && line > 0 - return li - end - elseif li isa LineNumberNode - if li.line > 0 && li.file !== Symbol("none") && li.file !== Symbol("unknown") - return li - end - end - - if pc > 0 - tmp = _lineinfo_from_debuginfo(ir, pc) - tmp !== nothing && return tmp - end - - if li isa Integer - lii = Int(li) - lii <= 0 && return nothing - if Base.hasproperty(ir, :linetable) - linetable = getproperty(ir, :linetable) - if lii <= length(linetable) - linfo = linetable[lii] - return (linfo isa Core.LineInfoNode) ? linfo : nothing - end - end - return nothing - elseif li isa NTuple{3,<:Integer} - return _lineinfo_from_debuginfo(ir, Int(li[1])) - end - - return nothing -end - -function _stmt_lineinfo(ir::CC.IRCode, idx::Int) - try - inst = ir[Core.SSAValue(idx)] - li = _inst_get(inst, :line, nothing) - return _normalize_lineinfo(ir, li, idx) - catch - return nothing - end -end - -function _raw_line_id(ir::CC.IRCode, idx::Int) - inst = ir[Core.SSAValue(idx)] - return _inst_get(inst, :line, nothing) -end - -function _debuginfo_has_unsafe_file( - ir::CC.IRCode, pc::Int, unsafe_files::Set{Symbol}, debuginfo_builder -)::Union{Bool,Nothing} - (debuginfo_builder === nothing || pc <= 0) && return nothing - stack = debuginfo_builder(ir.debuginfo, nothing, pc) - isempty(stack) && return nothing - for node in stack - file = getproperty(node, :file) - file_sym = (file isa Symbol) ? file : Symbol(file) - (file_sym in unsafe_files) && return true - end - return false -end - -function _stmt_unsafe_status( - ir::CC.IRCode, idx::Int, raw, unsafe_files::Set{Symbol}, debuginfo_builder -)::Union{Bool,Nothing} - # Prefer the IR statement index for `debuginfo`: this is what `IRShow.buildLineInfoNode` - # expects, and it's the most robust to debug-info compression. - has = _debuginfo_has_unsafe_file(ir, idx, unsafe_files, debuginfo_builder) - has !== nothing && return has - - @assert raw isa NTuple{3,<:Integer} "Expected NTuple{3,<:Integer} in _stmt_unsafe_status, got $(typeof(raw))" - - pc = Int(raw[1]) - has = _debuginfo_has_unsafe_file(ir, pc, unsafe_files, debuginfo_builder) - has !== nothing && return has - return nothing -end - -function _propagate_unlabeled_unsafe!( - unsafe_stmt::AbstractVector{Bool}, known_stmt::AbstractVector{Bool}, ir::CC.IRCode -) - blocks = ir.cfg.blocks - for b in 1:length(blocks) - r = blocks[b].stmts - - first_known = 0 - first_status = false - for idx in r - if known_stmt[idx] - first_known = idx - first_status = unsafe_stmt[idx] - break - end - end - first_known == 0 && continue - - # Leading unlabeled nodes inherit from first known stmt. - for idx in r - idx == first_known && break - known_stmt[idx] || (unsafe_stmt[idx] |= first_status) - end - - # Interior unlabeled nodes inherit from the most recent known stmt. - cur = first_status - for idx in r - if known_stmt[idx] - cur = unsafe_stmt[idx] - else - unsafe_stmt[idx] |= cur - end - end - end - - return unsafe_stmt -end - -"""Return a statement mask `unsafe_stmt[i]` indicating IR stmt `i` is inside an `@unsafe` region.""" -function _unsafe_stmt_mask(ir::CC.IRCode)::Vector{Bool} - nstmts = length(ir.stmts) - (nstmts == 0) && return Bool[] - - meta = ir.meta - - unsafe_files = Set{Symbol}() - for m in meta - (m isa Expr && m.head === :meta && !isempty(m.args)) || continue - m.args[1] === BC_UNSAFE_META || continue - if length(m.args) == 1 - return trues(nstmts) - end - for j in 2:length(m.args) - a = m.args[j] - if a isa Symbol - push!(unsafe_files, a) - break - end - end - end - - isempty(unsafe_files) && return falses(nstmts) - - debuginfo_builder = - if isdefined(CC, :IRShow) && isdefined(CC.IRShow, :buildLineInfoNode) - CC.IRShow.buildLineInfoNode - elseif isdefined(CC, :buildLineInfoNode) - CC.buildLineInfoNode - else - nothing - end - - unsafe_stmt = falses(nstmts) - known_stmt = falses(nstmts) - for i in 1:nstmts - raw = _raw_line_id(ir, i) - raw === nothing && continue - (raw isa Integer && raw == 0) && continue - - status = _stmt_unsafe_status(ir, i, raw, unsafe_files, debuginfo_builder) - status === nothing && continue - known_stmt[i] = true - unsafe_stmt[i] = status - end - - return _propagate_unlabeled_unsafe!(unsafe_stmt, known_stmt, ir) -end - -mutable struct UnionFind - parent::Vector{Int} - rank::Vector{UInt8} -end - -function UnionFind(n::Int) - parent = collect(1:n) - rank = fill(UInt8(0), n) - return UnionFind(parent, rank) -end - -function _uf_find(uf::UnionFind, x::Int) - p = uf.parent[x] - if p == x - return x - end - r = _uf_find(uf, p) - uf.parent[x] = r - return r -end - -function _uf_union!(uf::UnionFind, a::Int, b::Int) - ((a == 0) || (b == 0) || (a == b)) && return nothing - ra = _uf_find(uf, a) - rb = _uf_find(uf, b) - ra == rb && return nothing - if uf.rank[ra] < uf.rank[rb] - uf.parent[ra] = rb - elseif uf.rank[ra] > uf.rank[rb] - uf.parent[rb] = ra - else - uf.parent[rb] = ra - uf.rank[ra] += 1 - end - return nothing -end - -@inline function _phi_values(@nospecialize(x)) - if Base.hasproperty(x, :values) - return getproperty(x, :values) - end - if Base.hasproperty(x, :vals) - return getproperty(x, :vals) - end - return () -end - -function _collect_used_handles!(s::BitSet, x, nargs::Int, track_arg, track_ssa) - if x isa Core.PhiNode - vals = _phi_values(x) - if vals isa AbstractArray - for k in eachindex(vals) - isassigned(vals, k) || continue - _collect_used_handles!(s, vals[k], nargs, track_arg, track_ssa) - end - else - for v in vals - _collect_used_handles!(s, v, nargs, track_arg, track_ssa) - end - end - return nothing - end - if isdefined(Core, :PhiCNode) && x isa Core.PhiCNode - vals = _phi_values(x) - if vals isa AbstractArray - for k in eachindex(vals) - isassigned(vals, k) || continue - _collect_used_handles!(s, vals[k], nargs, track_arg, track_ssa) - end - else - for v in vals - _collect_used_handles!(s, v, nargs, track_arg, track_ssa) - end - end - return nothing - end - if x isa Core.Argument || x isa Core.SSAValue - h = _handle_index(x, nargs, track_arg, track_ssa) - h != 0 && push!(s, h) - return nothing - end - if x isa Core.ReturnNode - if isdefined(x, :val) - _collect_used_handles!(s, getfield(x, :val), nargs, track_arg, track_ssa) - end - return nothing - end - if x isa Core.PiNode - if isdefined(x, :val) - _collect_used_handles!(s, getfield(x, :val), nargs, track_arg, track_ssa) - end - return nothing - end - if x isa Core.UpsilonNode - if isdefined(x, :val) - _collect_used_handles!(s, getfield(x, :val), nargs, track_arg, track_ssa) - end - return nothing - end - if x isa Core.GotoIfNot - if isdefined(x, :cond) - _collect_used_handles!(s, getfield(x, :cond), nargs, track_arg, track_ssa) - end - return nothing - end - if x isa Expr - for a in x.args - _collect_used_handles!(s, a, nargs, track_arg, track_ssa) - end - return nothing - end - if x isa Tuple - for a in x - _collect_used_handles!(s, a, nargs, track_arg, track_ssa) - end - return nothing - end - if x isa AbstractArray - for a in x - _collect_used_handles!(s, a, nargs, track_arg, track_ssa) - end - return nothing - end - return nothing -end - -function _collect_ssa_ids!(ids::Vector{Int}, x) - if x isa Core.PhiNode - for v in _phi_values(x) - _collect_ssa_ids!(ids, v) - end - return nothing - end - if isdefined(Core, :PhiCNode) && x isa Core.PhiCNode - for v in _phi_values(x) - _collect_ssa_ids!(ids, v) - end - return nothing - end - if x isa Core.SSAValue - push!(ids, x.id) - return nothing - end - if x isa Core.ReturnNode - if isdefined(x, :val) - _collect_ssa_ids!(ids, getfield(x, :val)) - end - return nothing - end - if x isa Core.PiNode - if isdefined(x, :val) - _collect_ssa_ids!(ids, getfield(x, :val)) - end - return nothing - end - if x isa Core.UpsilonNode - if isdefined(x, :val) - _collect_ssa_ids!(ids, getfield(x, :val)) - end - return nothing - end - if x isa Core.GotoIfNot - if isdefined(x, :cond) - _collect_ssa_ids!(ids, getfield(x, :cond)) - end - return nothing - end - if x isa Expr - for a in x.args - _collect_ssa_ids!(ids, a) - end - return nothing - end - if x isa Tuple - for a in x - _collect_ssa_ids!(ids, a) - end - return nothing - end - if x isa AbstractArray - for a in x - _collect_ssa_ids!(ids, a) - end - return nothing - end - return nothing -end - -function _canonical_ref(@nospecialize(x), ir::CC.IRCode) - while x isa Core.SSAValue - stmt = try - ir[x][:stmt] - catch - break - end - if stmt isa Core.SSAValue - x = stmt - continue - end - if stmt isa Core.PiNode - x = stmt.val - continue - end - break - end - return x -end - -function compute_tracking_masks(ir::CC.IRCode) - nargs = length(ir.argtypes) - nstmts = length(ir.stmts) - - track_arg = Vector{Bool}(undef, nargs) - for a in 1:nargs - T = try - CC.widenconst(ir.argtypes[a]) - catch - Any - end - track_arg[a] = is_tracked_type(T) - end - - track_ssa = Vector{Bool}(undef, nstmts) - for i in 1:nstmts - T = try - inst = ir[Core.SSAValue(i)] - CC.widenconst(_inst_get(inst, :type, Any)) - catch - Any - end - track_ssa[i] = is_tracked_type(T) - end - - return track_arg, track_ssa -end diff --git a/src/safe/refine_types.jl b/src/safe/refine_types.jl deleted file mode 100644 index 7c23a8c..0000000 --- a/src/safe/refine_types.jl +++ /dev/null @@ -1,402 +0,0 @@ -"""BorrowChecker: IR type refinement. - -Julia's type inference sometimes intentionally loses precision around boxed captured -variables (`Core.Box`) and inference barriers. That is correct for the compiler, but it -hurts BorrowChecker's ability to resolve call targets and avoid spurious "unknown call" -effects. - -This file implements a small, conservative refinement pass that *only* uses Core -semantics: - -* Track `Core.Box` contents types from their constructors and (non-`Any`) writes. -* Refine `getfield(box, :contents)` return types using that tracked contents type. -* Refine `getfield(x, :field)` return types when `x` has a concrete type and the field - name/index is statically known. - -The pass never attempts to interpret overloadable Base operations like `getproperty`. -""" - -# NOTE: This file is included from `auto_ir.jl` after `summaries.jl` and -# `ir_primitives.jl`, so we can use internal helpers like `_inst_get` and -# `_canonical_ref`. - -const _MaybeType = Union{Nothing,Type} - -@inline function _as_type_or_any(@nospecialize(T))::Type - T = CC.widenconst(T) - return (T isa Type) ? T : Any -end - -@inline function _widen_type_slot(@nospecialize(T)) - # IR type slots can contain lattice elements; we only need the widened type. - return CC.widenconst(T) -end - -@inline function _is_any_slot(@nospecialize(T))::Bool - return _widen_type_slot(T) === Any -end - -@inline function _field_is_contents(field_expr)::Bool - if field_expr isa QuoteNode - return field_expr.value === :contents - end - return field_expr === :contents -end - -@inline function _field_is_const_symbol(field_expr) - if field_expr isa QuoteNode - v = field_expr.value - return (v isa Symbol) ? v : nothing - end - return (field_expr isa Symbol) ? field_expr : nothing -end - -@inline function _field_is_const_int(field_expr) - if field_expr isa QuoteNode - v = field_expr.value - return (v isa Integer) ? Int(v) : nothing - end - return (field_expr isa Integer) ? Int(field_expr) : nothing -end - -function _fieldtype_if_known(@nospecialize(objT), field_expr) - objT = _as_type_or_any(objT) - objT === Any && return nothing - dt = Base.unwrap_unionall(objT) - dt isa DataType || return nothing - - # Only refine for concrete object types. (If inference didn't narrow it, we won't.) - Base.isconcretetype(dt) || return nothing - - # Symbol field - sym = _field_is_const_symbol(field_expr) - if sym !== nothing - return try - Base.fieldtype(dt, sym) - catch - nothing - end - end - - # Integer index field - idx = _field_is_const_int(field_expr) - if idx !== nothing - return try - Base.fieldtype(dt, idx) - catch - nothing - end - end - - return nothing -end - -function _is_box_ctor(stmt, ir::CC.IRCode) - stmt isa Expr || return false - stmt.head === :call || return false - isempty(stmt.args) && return false - f = stmt.args[1] - - # Resolve the callee; we only treat the *actual* Core.Box constructor as a box. - fobj = _resolve_callee(stmt, ir) - return fobj === Core.Box -end - -function _is_builtin_getfield_call(stmt, ir::CC.IRCode) - stmt isa Expr || return false - stmt.head === :call || return false - length(stmt.args) >= 3 || return false - fobj = _resolve_callee(stmt, ir) - return fobj === Core.getfield -end - -function _is_builtin_setfield_call(stmt, ir::CC.IRCode) - stmt isa Expr || return false - stmt.head === :call || return false - length(stmt.args) >= 4 || return false - fobj = _resolve_callee(stmt, ir) - return fobj === Core.setfield! -end - -function _maybe_set_inst_type!(ir::CC.IRCode, idx::Int, newT::Type)::Bool - newT === Any && return false - inst = ir.stmts[idx] - cur = _inst_get(inst, :type, Any) - _is_any_slot(cur) || return false - - # Use the instruction indexing API, which is stable across Julia versions. - try - inst[:type] = newT - catch - # Fallback for older IR representations. - try - setproperty!(inst, :type, newT) - catch - return false - end - end - return true -end - -@inline function _join_box_type(old::Type, new::Type)::Type - # Ignore uninformative `Any` writes, otherwise merge. - new === Any && return old - old === Any && return new - return Base.typejoin(old, new) -end - -"""Refine types in-place. - -This pass is intentionally small and conservative; it is only used to recover precision -around boxed captured variables and concrete field accesses. -""" -function refine_types!(ir::CC.IRCode, cfg::Config) - n = length(ir.stmts) - n == 0 && return ir - - world = _reflection_world() - - # Map `SSAValue` ids that hold a `Core.Box` object to their best-known `:contents` type. - box_contents = Vector{_MaybeType}(undef, n) - fill!(box_contents, nothing) - - # Track which SSA statements we've refined to something more precise than `Any`. - # We use this to gate expensive return-type inference so `scope=:all` stays fast. - interesting = falses(n) - - # Cache `return_type` results within this IR to avoid repeated compiler work. - rt_cache = Dict{DataType,Type}() - rt_calls = 0 - rt_cache_hits = 0 - - # Optional debug log of refinements. - refine_log = cfg.debug ? Vector{Dict{String,Any}}() : nothing - - @inline function _log_change( - kind::String, idx::Int, stmt, @nospecialize(oldT), newT::Type - ) - refine_log === nothing && return nothing - push!( - refine_log, - Dict( - "kind" => kind, - "stmt_idx" => idx, - "stmt" => string(stmt), - "old_type" => string(_widen_type_slot(oldT)), - "new_type" => string(newT), - ), - ) - return nothing - end - - @inline function _concrete_enough_for_return_refinement(tt_u::DataType)::Bool - params = tt_u.parameters - for p in params - if p === Any || p isa Union || p isa Core.TypeVar || p isa Core.TypeofVararg - return false - end - end - return true - end - - # A few iterations are enough for simple forward propagation. - max_iter = 3 - for _iter in 1:max_iter - changed = false - - for i in 1:n - inst = ir.stmts[i] - stmt = _inst_get(inst, :stmt, nothing) - stmt === nothing && continue - - # (1) Track box init types. - if _is_box_ctor(stmt, ir) - initT = Any - if length(stmt.args) >= 2 - initT = _as_type_or_any(_safe_argextype(stmt.args[2], ir)) - end - old = box_contents[i] - if old === nothing - box_contents[i] = initT - changed = true - else - new = _join_box_type(old::Type, initT) - if new !== old - box_contents[i] = new - changed = true - end - end - end - - # (2) Track writes to `box.contents`. - if _is_builtin_setfield_call(stmt, ir) - # setfield!(obj, field, val) - obj = _canonical_ref(stmt.args[2], ir) - field = stmt.args[3] - if obj isa Core.SSAValue && _field_is_contents(field) - bid = obj.id - # Only track boxes we already recognized (via constructor). - old = box_contents[bid] - if old !== nothing - valT = _as_type_or_any(_safe_argextype(stmt.args[4], ir)) - new = _join_box_type(old::Type, valT) - if new !== old - box_contents[bid] = new - changed = true - end - end - end - end - - # (3) Refine `getfield(box, :contents)`. - if _is_builtin_getfield_call(stmt, ir) - obj = _canonical_ref(stmt.args[2], ir) - field = stmt.args[3] - - if obj isa Core.SSAValue && _field_is_contents(field) - bid = obj.id - bt = box_contents[bid] - if bt !== nothing - oldT = _inst_get(inst, :type, Any) - if _maybe_set_inst_type!(ir, i, bt::Type) - changed = true - interesting[i] = true - _log_change("box_contents_getfield", i, stmt, oldT, bt::Type) - end - continue - end - end - - # (4) Refine concrete struct field loads: getfield(x, :n) where typeof(x) is concrete. - if _is_any_slot(_inst_get(inst, :type, Any)) - objT = _as_type_or_any(_safe_argextype(obj, ir)) - ft = _fieldtype_if_known(objT, field) - if ft !== nothing - oldT = _inst_get(inst, :type, Any) - if _maybe_set_inst_type!(ir, i, ft) - changed = true - interesting[i] = true - _log_change("struct_getfield", i, stmt, oldT, ft) - end - end - end - end - - # (5) Refine __bc_bind__ to propagate the argument type. - if stmt isa Expr && - stmt.head === :call && - _is_any_slot(_inst_get(inst, :type, Any)) - fobj = _resolve_callee(stmt, ir) - if fobj === __bc_bind__ && length(stmt.args) >= 2 - arg_expr = _canonical_ref(stmt.args[2], ir) - argT = _as_type_or_any(_safe_argextype(arg_expr, ir)) - oldT = _inst_get(inst, :type, Any) - if _maybe_set_inst_type!(ir, i, argT) - changed = true - if arg_expr isa Core.SSAValue - sid = arg_expr.id - if 1 <= sid <= n && interesting[sid] - interesting[i] = true - end - end - _log_change("__bc_bind__", i, stmt, oldT, argT) - end - end - end - - # (6) Refine call return types when the call tuple is concrete enough. - # - # IMPORTANT: `Core.Compiler.return_type` is expensive. To keep `scope=:all` runs - # fast, we only attempt return-type refinement for call sites that *depend on* - # previously-refined SSA values (e.g. values coming from boxed `:contents` loads). - if stmt isa Expr && - (stmt.head === :call || stmt.head === :invoke) && - _is_any_slot(_inst_get(inst, :type, Any)) - head, _mi, raw_args = _call_parts(stmt) - head === nothing && continue - raw_args === nothing && continue - - # Gate on dataflow from refined SSA values. - has_interest = false - if length(raw_args) >= 2 - for a in raw_args[2:end] # skip callee - ca = _canonical_ref(a, ir) - if ca isa Core.SSAValue - sid = ca.id - if 1 <= sid <= n && interesting[sid] - has_interest = true - break - end - end - end - end - has_interest || continue - - fobj = _resolve_callee(stmt, ir) - fobj === nothing && continue - - # Skip primitives we already handle explicitly. - if fobj === Core.getfield || fobj === Core.setfield! || fobj === __bc_bind__ - continue - end - - tt = _call_tt_from_raw_args(raw_args, ir, fobj) - tt === nothing && continue - - tt_u = Base.unwrap_unionall(tt) - tt_u isa DataType || continue - Base.has_free_typevars(tt_u) && continue - _concrete_enough_for_return_refinement(tt_u) || continue - - local rt::Type - if haskey(rt_cache, tt_u) - rt_cache_hits += 1 - rt = rt_cache[tt_u] - else - rt_calls += 1 - rt = try - CC.return_type(tt_u, world) - catch - Any - end - rt = _as_type_or_any(rt) - rt_cache[tt_u] = rt - end - rt === Any && continue - - oldT = _inst_get(inst, :type, Any) - if _maybe_set_inst_type!(ir, i, rt) - changed = true - _log_change("call_return", i, stmt, oldT, rt) - interesting[i] = true - end - end - end - - changed || break - end - - if cfg.debug && refine_log !== nothing - if !isempty(refine_log) || rt_calls > 0 || rt_cache_hits > 0 - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_refine_types", - "tt" => try - string(Tuple{ir.argtypes...}) - catch - nothing - end, - "world" => world, - "stats" => Dict( - "return_type_calls" => rt_calls, - "return_type_cache_hits" => rt_cache_hits, - ), - "changes" => refine_log, - ), - ) - end - end - - return ir -end diff --git a/src/safe/summaries.jl b/src/safe/summaries.jl deleted file mode 100644 index 65cc50e..0000000 --- a/src/safe/summaries.jl +++ /dev/null @@ -1,939 +0,0 @@ -struct SummaryCacheEntry - summary::EffectSummary - depth::Int - over_budget::Bool -end - -const SUMMARY_CACHE_KEY = Tuple{Any,UInt,Config} - -const SUMMARY_STATE = Lockable(( - summary_cache=Dict{SUMMARY_CACHE_KEY,SummaryCacheEntry}(), - tt_summary_cache=Dict{SUMMARY_CACHE_KEY,SummaryCacheEntry}(), - summary_inprogress=Set{SUMMARY_CACHE_KEY}(), - tt_summary_inprogress=Set{SUMMARY_CACHE_KEY}(), -)) - -const PER_TASK_REFLECTION_CACHE = PerTaskCache{Dict{UInt,Any}}() -const PER_TASK_REFLECTION_CTX = PerTaskCache{Base.RefValue{Any}}(() -> Ref{Any}(nothing)) - -function _reflection_ctx() - return PER_TASK_REFLECTION_CTX[][] -end - -function _reflection_world(default::UInt=Base.get_world_counter()) - ctx = _reflection_ctx() - return (ctx === nothing) ? default : ctx.world -end - -function _with_reflection_ctx(f::Function, world::UInt) - @nospecialize f - ctx_ref = PER_TASK_REFLECTION_CTX[] - old = ctx_ref[] - # Fast path: nested borrow-checking frequently calls `check_signature` recursively. - # Reuse the existing reflection context to avoid repeatedly constructing interpreters. - if old !== nothing && getproperty(old, :world) === world - return f() - end - - cache = PER_TASK_REFLECTION_CACHE[] - entry = get!(cache, world) do - (; interp=BCInterp(; world), methods_cache=IdDict{Any,Any}()) - end - ctx_ref[] = (; world=world, interp=entry.interp, methods_cache=entry.methods_cache) - try - return f() - finally - ctx_ref[] = old - end -end - -function _code_ircode_by_type(tt::Type; optimize_until, world::UInt, cfg::Config) - ctx = _reflection_ctx() - interp = (ctx !== nothing && ctx.world === world) ? ctx.interp : BCInterp(; world) - - methods_cache = - if ctx !== nothing && ctx.world === world && hasproperty(ctx, :methods_cache) - getproperty(ctx, :methods_cache) - else - nothing - end - - matches = if methods_cache === nothing - Base._methods_by_ftype(tt, -1, world) - else - get!(methods_cache, tt) do - Base._methods_by_ftype(tt, -1, world) - end - end - if isnothing(matches) - error("No method found matching signature $tt in world $world") - end - - # For concrete call signatures, `Base._methods_by_ftype` can still return multiple - # applicable methods (e.g. both `f(::Any, ::Any)` and `f(::Any, ::Symbol)` for - # `Tuple{typeof(f), T, Symbol}`). At runtime dispatch will pick the most-specific - # method; unioning effects across all matches can introduce large, spurious - # conservatism (common for `getproperty`, keyword wrappers, etc.). - # - # When the tuple type is concrete enough, keep only the most-specific match. - matches = let tt_u = Base.unwrap_unionall(tt) - if tt_u isa DataType - params = tt_u.parameters - concrete = true - for p in params - if p === Any || p isa Union || p isa Core.TypeVar || p isa Core.TypeofVararg - concrete = false - break - end - end - concrete ? (matches[1:1]) : matches - else - matches - end - end - - optimize_until = _normalize_optimize_until_for_ir(optimize_until) - asts = Pair{Any,Any}[] - for match in matches - match = match::Core.MethodMatch - (code, ty) = CC.typeinf_ircode(interp, match, optimize_until) - if code === nothing - ir = nothing - mod = match.method.module - @assert (mod === Core || mod === Base || mod === BorrowChecker) ( - "BorrowChecker: unexpected `typeinf_ircode` returned `nothing` for " * - "non-Base/Core method. method=$(match.method) module=$(mod) tt=$(tt) " * - "world=$(world) optimize_until=$(optimize_until)" - ) - if ir === nothing - push!(asts, match.method => ty) - if cfg.debug - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_no_ircode", - "tt" => string(tt), - "method" => string(match.method), - "return_type" => string(ty), - "world" => world, - "optimize_until" => optimize_until, - ), - ) - end - else - push!(asts, ir => ty) - if cfg.debug - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_inflated_ir", - "tt" => string(tt), - "method" => string(match.method), - "return_type" => string(ty), - "world" => world, - "optimize_until" => optimize_until, - ), - ) - end - end - else - push!(asts, code => ty) - end - end - return asts -end - -const _FALLBACK_PASS_NAMES = String[ - # Julia's default `run_passes_ipo_safe` pipeline as of 1.12.x. - "convert", - "slot2reg", - "compact 1", - "Inlining", - "compact 2", - "SROA", - "ADCE", - "compact 3", -] - -const _PASS_NAMES_CACHE = Ref{Vector{String}}(String[]) - -@inline function _compiler_pass_names()::Vector{String} - names = _PASS_NAMES_CACHE[] - isempty(names) || return names - - if isdefined(CC, :ALL_PASS_NAMES) - names = [String(nm) for nm in CC.ALL_PASS_NAMES] - else - names = _FALLBACK_PASS_NAMES - end - - _PASS_NAMES_CACHE[] = names - return names -end - -function _normalize_optimize_until_for_ir(optimize_until) - optimize_until isa String || return optimize_until - - pass_names = _compiler_pass_names() - for s in pass_names - s == optimize_until && return s - end - - # Normalize common spellings like "compact_1" and "COMPACT_1". - @inline function _norm(s::AbstractString) - return replace(lowercase(String(s)), r"[^a-z0-9]+" => "") - end - - optn = _norm(optimize_until) - matches = String[] - for s in pass_names - endswith(_norm(s), optn) && push!(matches, s) - end - - if length(matches) == 1 - return matches[1] - elseif length(matches) > 1 - throw( - ArgumentError( - "BorrowChecker.@safe: optimize_until=\"$optimize_until\" is ambiguous. " * - "Candidates: $(join(matches, ", "))", - ), - ) - end - - throw( - ArgumentError( - "BorrowChecker.@safe: optimize_until=\"$optimize_until\" is not a known compiler pass name. " * - "Known passes: $(join(pass_names, ", "))", - ), - ) -end - -mutable struct BudgetTracker - hit::Bool -end - -function _mark_budget_hit!(@nospecialize(budget_state)) - budget_state === nothing && return nothing - budget_state.hit = true - return nothing -end - -function _choose_summary_entry(old::SummaryCacheEntry, new::SummaryCacheEntry) - # NOTE: `_choose_summary_entry` is only called when there is an existing cache entry - # and we're recomputing because the existing entry was over budget at a deeper - # summary depth. Therefore `old.over_budget` is expected to be true here. - @assert old.over_budget - if !new.over_budget - return new - end - return (new.depth < old.depth) ? new : old -end - -function _summary_state_get_tt(key::SUMMARY_CACHE_KEY) - Base.@lock SUMMARY_STATE begin - return get(SUMMARY_STATE[].tt_summary_cache, key, nothing) - end -end - -function _summary_state_get_mi(key::SUMMARY_CACHE_KEY) - Base.@lock SUMMARY_STATE begin - return get(SUMMARY_STATE[].summary_cache, key, nothing) - end -end - -function _summary_state_set_tt!(key::SUMMARY_CACHE_KEY, new_entry::SummaryCacheEntry) - Base.@lock SUMMARY_STATE begin - cache = SUMMARY_STATE[].tt_summary_cache - old = get(cache, key, nothing) - cache[key] = (old === nothing) ? new_entry : _choose_summary_entry(old, new_entry) - end - return nothing -end - -function _summary_state_set_mi!(key::SUMMARY_CACHE_KEY, new_entry::SummaryCacheEntry) - Base.@lock SUMMARY_STATE begin - cache = SUMMARY_STATE[].summary_cache - old = get(cache, key, nothing) - cache[key] = (old === nothing) ? new_entry : _choose_summary_entry(old, new_entry) - end - return nothing -end - -function _summary_state_tt_inprogress_enter!(key::SUMMARY_CACHE_KEY)::Bool - reentered = false - Base.@lock SUMMARY_STATE begin - inprog = SUMMARY_STATE[].tt_summary_inprogress - reentered = (key in inprog) - reentered || push!(inprog, key) - end - return reentered -end - -function _summary_state_tt_inprogress_exit!(key::SUMMARY_CACHE_KEY) - Base.@lock SUMMARY_STATE begin - delete!(SUMMARY_STATE[].tt_summary_inprogress, key) - end - return nothing -end - -function _summary_state_mi_inprogress_enter!(key::SUMMARY_CACHE_KEY)::Bool - reentered = false - Base.@lock SUMMARY_STATE begin - inprog = SUMMARY_STATE[].summary_inprogress - reentered = (key in inprog) - reentered || push!(inprog, key) - end - return reentered -end - -function _summary_state_mi_inprogress_exit!(key::SUMMARY_CACHE_KEY) - Base.@lock SUMMARY_STATE begin - delete!(SUMMARY_STATE[].summary_inprogress, key) - end - return nothing -end - -function _summary_cached( - compute::FCompute, - key, - cfg::Config; - depth::Int, - budget_state=nothing, - get_cached::FGet, - set_cached::FSet, - inprogress_enter::FEnter, - inprogress_exit::FExit, -) where {FCompute<:Function,FGet<:Function,FSet<:Function,FEnter<:Function,FExit<:Function} - cached = get_cached(key) - if cached !== nothing - if !cached.over_budget || depth >= cached.depth - cached.over_budget && _mark_budget_hit!(budget_state) - return cached.summary - end - end - - budget_state !== nothing && budget_state.hit && return nothing - - if inprogress_enter(key) - _mark_budget_hit!(budget_state) - return nothing - end - - summ = nothing - local_budget = BudgetTracker(false) - try - summ = compute(local_budget) - catch e - if cfg.debug - world = _reflection_world() - bt = catch_backtrace() - st = try - Base.stacktrace(bt) - catch - nothing - end - frames = (st === nothing) ? nothing : [string(fr) for fr in st[1:min(end, 8)]] - _auto_debug_emit( - cfg, - Dict( - "event" => "auto_debug_summary_exception", - "key" => string(key), - "world" => world, - "depth" => depth, - "error" => sprint(showerror, e), - "backtrace" => frames, - ), - ) - end - summ = nothing - finally - inprogress_exit(key) - end - - if summ !== nothing - set_cached(key, SummaryCacheEntry(summ, depth, local_budget.hit)) - end - - cached2 = get_cached(key) - cached2 !== nothing && cached2.over_budget && _mark_budget_hit!(budget_state) - return cached2 === nothing ? summ : cached2.summary -end - -function _summary_cached_tt( - compute::Function, key, cfg::Config; depth::Int, budget_state=nothing, allow_core::Bool -) - return _summary_cached( - compute, - key, - cfg; - depth=depth, - budget_state=budget_state, - get_cached=_summary_state_get_tt, - set_cached=_summary_state_set_tt!, - inprogress_enter=_summary_state_tt_inprogress_enter!, - inprogress_exit=_summary_state_tt_inprogress_exit!, - ) -end - -function _summary_cached_mi( - compute::Function, key, cfg::Config; depth::Int, budget_state=nothing -) - return _summary_cached( - compute, - key, - cfg; - depth=depth, - budget_state=budget_state, - get_cached=_summary_state_get_mi, - set_cached=_summary_state_set_mi!, - inprogress_enter=_summary_state_mi_inprogress_enter!, - inprogress_exit=_summary_state_mi_inprogress_exit!, - ) -end - -function _summarize_entries(codes, cfg::Config; depth::Int, budget_state=nothing) - writes = BitSet() - consumes = BitSet() - ret_aliases = BitSet() - got = false - - for entry in codes - ir = entry.first - ir isa CC.IRCode || continue - got = true - s = _summarize_ir_effects(ir, cfg; depth=depth, budget_state=budget_state) - union!(writes, s.writes) - union!(consumes, s.consumes) - union!(ret_aliases, s.ret_aliases) - end - - got || return nothing - return EffectSummary(; writes=writes, consumes=consumes, ret_aliases=ret_aliases) -end - -function _summary_for_tt( - tt::Type{<:Tuple}, cfg::Config; depth::Int, budget_state=nothing, allow_core::Bool=false -) - world = _reflection_world() - key = (tt, UInt(world), cfg) - - try - tt_u = Base.unwrap_unionall(tt) - if tt_u isa DataType && !isempty(tt_u.parameters) - fT = tt_u.parameters[1] - dt = Base.unwrap_unionall(fT) - if dt isa DataType - m = dt.name.module - if dt.name === Base.unwrap_unionall(Type).name && !isempty(dt.parameters) - targ = Base.unwrap_unionall(dt.parameters[1]) - if targ isa DataType - m = targ.name.module - end - end - if m === BorrowChecker || (!allow_core && m === Core) - return nothing - end - end - end - catch - end - - return _summary_cached_tt( - key, cfg; depth=depth, budget_state=budget_state, allow_core=allow_core - ) do local_budget - codes = _code_ircode_by_type( - tt; optimize_until=cfg.optimize_until, world=world, cfg - ) - return _summarize_entries(codes, cfg; depth=depth, budget_state=local_budget) - end -end - -function _summary_for_mi(mi, cfg::Config; depth::Int, budget_state=nothing) - try - if mi isa Core.MethodInstance - m = mi.def - if (m isa Method) && (m.module === Core || m.module === BorrowChecker) - return nothing - end - end - catch - return nothing - end - - world = _reflection_world() - key = (mi, UInt(world), cfg) - - return _summary_cached_mi( - key, cfg; depth=depth, budget_state=budget_state - ) do local_budget - tt = mi.specTypes - codes = _code_ircode_by_type( - tt; optimize_until=cfg.optimize_until, world=world, cfg - ) - return _summarize_entries(codes, cfg; depth=depth, budget_state=local_budget) - end -end - -function _widenargtype_or_any(@nospecialize(x), ir::CC.IRCode) - try - t = CC.widenconst(_safe_argextype(x, ir)) - return (t isa Type) ? t : Any - catch - return Any - end -end - -function _is_box_contents_setfield!( - @nospecialize(f), raw_args::AbstractVector, ir::CC.IRCode -)::Bool - f === Core.setfield! || return false - length(raw_args) >= 4 || return false - fld = raw_args[3] - fldsym = fld isa QuoteNode ? fld.value : fld - fldsym === :contents || return false - - obj = raw_args[2] - Tobj = _widenargtype_or_any(obj, ir) - if isdefined(Core, :Box) - try - return Tobj <: Core.Box - catch - return false - end - end - return false -end - -function _filter_consumes_for_call( - @nospecialize(f), - raw_args::AbstractVector, - eff::EffectSummary, - ir::CC.IRCode, - nargs::Int, - track_arg, - track_ssa, -)::EffectSummary - isempty(eff.consumes) && return eff - - consumes = BitSet() - for p in eff.consumes - (1 <= p <= length(raw_args)) || continue - - # Captured-variable boxing uses `setfield!(box, :contents, val)` as an - # implementation detail. Treat this as aliasing, not an ownership move. - if p == 4 && _is_box_contents_setfield!(f, raw_args, ir) - continue - end - - v = raw_args[p] - hv = _handle_index(v, nargs, track_arg, track_ssa) - hv == 0 && continue - - Tv = _widenargtype_or_any(v, ir) - is_owned_type(Tv) || continue - - push!(consumes, p) - end - - consumes == eff.consumes && return eff - return EffectSummary(; - writes=eff.writes, consumes=consumes, ret_aliases=eff.ret_aliases - ) -end - -function _effects_for_call( - stmt, - ir::CC.IRCode, - cfg::Config, - track_arg, - track_ssa, - nargs::Int; - idx::Int=0, - depth::Int=0, - budget_state=nothing, -)::EffectSummary - head, mi, raw_args = _call_parts(stmt) - raw_args === nothing && return EffectSummary() - - # Fast path: if this call does not involve any tracked values from the current IR, - # it cannot influence borrow checking (consume/write/alias) for this caller. - any_tracked = false - # NOTE: include the callee expression itself. This matters for functors/closures - # where `f()` can mutate/alias through captured state or `f`'s own fields. - @inbounds for v in raw_args - _handle_index(v, nargs, track_arg, track_ssa) != 0 && (any_tracked = true; break) - end - any_tracked || return EffectSummary() - f = _resolve_callee(stmt, ir) - - if idx != 0 - Tret = try - inst = ir[Core.SSAValue(idx)] - CC.widenconst(_inst_get(inst, :type, Any)) - catch - Any - end - if Tret === Union{} - return EffectSummary() - end - end - - if f === __bc_bind__ - return EffectSummary() - end - - # Treat most `Type(...)` calls (constructors/conversions) as pure with respect to - # ownership: constructing a new object that (may) hold references to inputs should - # create aliases, not "move"/consume the inputs. - # - # Exception: callable objects (subtypes of `Function`) model captured environments - # (closures/functors) that can escape and outlive the current scope, so keep the - # normal conservative behavior for those. - if head === :call && f isa Type - # If a `Type(...)` call has explicit known effects (e.g. `Task(f)`), honor them. - if _known_effects_get(f) === nothing - is_functor = (f <: Function) - is_functor || return EffectSummary() - end - end - - # `_apply_iterate` is Core plumbing used for splatting/varargs and some wrappers. - # Treat it as a transparent call wrapper: infer effects for the callee (`raw_args[3]`) - # on the expanded argument list, then map those effects back to the original - # `_apply_iterate` argument positions. This avoids spurious "consume" results for - # Base wrappers like `setindex!` that route through `_apply_iterate`. - if f === Core._apply_iterate && length(raw_args) >= 3 - # Inner callee - inner_f = try - CC.singleton_type(_safe_argextype(raw_args[3], ir)) - catch - nothing - end - if inner_f === nothing && raw_args[3] isa GlobalRef - inner_f = try - getfield(raw_args[3].mod, raw_args[3].name) - catch - nothing - end - end - if inner_f !== nothing - expanded_types = Any[typeof(inner_f)] - posmap = Int[3] - - # Expand tuple arguments when possible; otherwise treat as a splat-container - # described by its tuple type (when statically known). - for j in 4:length(raw_args) - argj = raw_args[j] - elems = _maybe_tuple_elements(argj, ir) - if elems !== nothing - for e in elems - push!(expanded_types, _widenargtype_or_any(e, ir)) - push!(posmap, j) - end - continue - end - - # In `_apply_iterate`, arguments after the callee are typically splat-containers. - # If we know this container is a concrete Tuple type, expand its element types - # so the inferred callee TT matches the post-splat argument list. - Tj = _widenargtype_or_any(argj, ir) - if Tj === Tuple{} - continue - end - dt = Base.unwrap_unionall(Tj) - if dt isa DataType && dt.name === Tuple.name - params = dt.parameters - has_vararg = any(p -> p isa Core.TypeofVararg, params) - if !has_vararg - for te in params - te2 = Base.unwrap_unionall(te) - push!(expanded_types, (te2 isa Type) ? te2 : Any) - push!(posmap, j) - end - continue - end - end - - # Unknown or non-tuple splat-container: treat as a single argument. - push!(expanded_types, Tj) - push!(posmap, j) - end - - tt = Core.apply_type(Tuple, expanded_types...) - s_inner = _known_effects_get(inner_f) - if s_inner === nothing && tt !== nothing && depth < cfg.max_summary_depth - s_inner = _summary_for_tt( - tt, cfg; depth=depth + 1, budget_state=budget_state - ) - end - - if s_inner !== nothing - writes = BitSet() - consumes = BitSet() - ret_aliases = BitSet() - for p in s_inner.writes - (1 <= p <= length(posmap)) || continue - push!(writes, posmap[p]) - end - for p in s_inner.consumes - (1 <= p <= length(posmap)) || continue - push!(consumes, posmap[p]) - end - for p in s_inner.ret_aliases - (1 <= p <= length(posmap)) || continue - push!(ret_aliases, posmap[p]) - end - return EffectSummary(; - writes=writes, consumes=consumes, ret_aliases=ret_aliases - ) - end - end - end - - if f !== nothing - s = _known_effects_get(f) - s === nothing || return _filter_consumes_for_call( - f, raw_args, s, ir, nargs, track_arg, track_ssa - ) - end - - if f !== nothing && _is_namedtuple_ctor(f) - return EffectSummary() - end - - if f === Core.kwcall - tt_kw = _kwcall_tt_from_raw_args(raw_args, ir) - if tt_kw !== nothing - if depth < cfg.max_summary_depth - s = _summary_for_tt( - tt_kw, cfg; depth=depth + 1, budget_state=budget_state, allow_core=true - ) - if s !== nothing - return _filter_consumes_for_call( - f, raw_args, s, ir, nargs, track_arg, track_ssa - ) - end - else - _mark_budget_hit!(budget_state) - end - end - end - - if head === :invoke && (mi !== nothing) - if depth < cfg.max_summary_depth - s = _summary_for_mi(mi, cfg; depth=depth + 1, budget_state=budget_state) - if s !== nothing - return _filter_consumes_for_call( - f, raw_args, s, ir, nargs, track_arg, track_ssa - ) - end - else - _mark_budget_hit!(budget_state) - end - end - - if head === :call && f === nothing - fexpr = raw_args[1] - if fexpr isa Core.SSAValue - tt = _call_tt_from_raw_args(raw_args, ir) - if tt !== nothing - if depth < cfg.max_summary_depth - s = _summary_for_tt(tt, cfg; depth=depth + 1, budget_state=budget_state) - if s !== nothing - return _filter_consumes_for_call( - f, raw_args, s, ir, nargs, track_arg, track_ssa - ) - end - else - _mark_budget_hit!(budget_state) - end - end - end - end - - if head === :call && f !== nothing - tt = _call_tt_from_raw_args(raw_args, ir, f) - if tt !== nothing - if depth < cfg.max_summary_depth - s = _summary_for_tt(tt, cfg; depth=depth + 1, budget_state=budget_state) - if s !== nothing - return _filter_consumes_for_call( - f, raw_args, s, ir, nargs, track_arg, track_ssa - ) - end - else - _mark_budget_hit!(budget_state) - end - end - end - - consumes = Int[] - # `raw_args[1]` is the function value. Calling a function does not (by itself) - # consume/move the function object, so treat only user arguments as candidates. - for p in 2:length(raw_args) - v = raw_args[p] - h = _handle_index(v, nargs, track_arg, track_ssa) - h == 0 && continue - Tv = _widenargtype_or_any(v, ir) - is_owned_type(Tv) || continue - push!(consumes, p) - end - return EffectSummary(; consumes=consumes) -end - -function _push_arg_aliases!(dest::BitSet, uf::UnionFind, root::Int, nargs::Int, track_arg) - for a in 1:nargs - track_arg[a] || continue - if _uf_find(uf, a) == root - push!(dest, a) - end - end - return nothing -end - -function _push_arg_aliases_for_handle!( - dest::BitSet, uf::UnionFind, hv::Int, nargs::Int, track_arg -) - hv == 0 && return nothing - root = _uf_find(uf, hv) - return _push_arg_aliases!(dest, uf, root, nargs, track_arg) -end - -function _summarize_ir_effects( - ir::CC.IRCode, cfg::Config; depth::Int, budget_state=nothing -)::EffectSummary - nargs = length(ir.argtypes) - nstmts = length(ir.stmts) - track_arg, track_ssa = compute_tracking_masks(ir) - - # Treat `@unsafe` regions as opaque/effectless for summary purposes. - # This matches the main checker behavior: effects inside the region are not - # propagated outward (the user is taking responsibility for invariants). - unsafe_stmt = _unsafe_stmt_mask(ir) - - uf = UnionFind(nargs + nstmts) - _build_alias_classes!( - uf, - ir, - cfg, - track_arg, - track_ssa, - nargs; - unsafe_stmt=unsafe_stmt, - depth=depth, - budget_state=budget_state, - ) - - writes = BitSet() - consumes = BitSet() - ret_aliases = BitSet() - - for i in 1:nstmts - (1 <= i <= length(unsafe_stmt) && unsafe_stmt[i]) && continue - stmt = ir[Core.SSAValue(i)][:stmt] - if stmt isa Expr && stmt.head === :foreigncall - name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) - eff = - (name_sym === nothing) ? nothing : _known_foreigncall_effects_get(name_sym) - - if eff === nothing - # Unknown foreigncall: treat as write to the C arguments only (ignore GC roots). - for v in ccall_args - hs = _backward_used_handles(v, ir, nargs, track_arg, track_ssa) - for hv in hs - _push_arg_aliases!(writes, uf, _uf_find(uf, hv), nargs, track_arg) - end - end - continue - end - - for grp in eff.write_groups - hs = _foreigncall_group_used_handles( - ccall_args, grp, ir, nargs, track_arg, track_ssa - ) - for hv in hs - _push_arg_aliases!(writes, uf, _uf_find(uf, hv), nargs, track_arg) - end - end - - for grp in eff.consume_groups - hs = _foreigncall_group_used_handles( - ccall_args, grp, ir, nargs, track_arg, track_ssa - ) - for hv in hs - _push_arg_aliases!(consumes, uf, _uf_find(uf, hv), nargs, track_arg) - end - end - - continue - end - - head, _mi, raw_args = _call_parts(stmt) - raw_args === nothing && continue - - eff = _effects_for_call( - stmt, - ir, - cfg, - track_arg, - track_ssa, - nargs; - idx=i, - depth=depth, - budget_state=budget_state, - ) - - kw_vals = _kwcall_value_exprs(stmt, ir) - (kw_vals === nothing || isempty(kw_vals)) && (kw_vals = nothing) - - for p in eff.writes - if kw_vals !== nothing && p == 2 - for vkw in kw_vals - hv = _handle_index(vkw, nargs, track_arg, track_ssa) - _push_arg_aliases_for_handle!(writes, uf, hv, nargs, track_arg) - end - continue - end - - v = raw_args[p] - hv = _handle_index(v, nargs, track_arg, track_ssa) - _push_arg_aliases_for_handle!(writes, uf, hv, nargs, track_arg) - end - for p in eff.consumes - if kw_vals !== nothing && p == 2 - for vkw in kw_vals - hv = _handle_index(vkw, nargs, track_arg, track_ssa) - _push_arg_aliases_for_handle!(consumes, uf, hv, nargs, track_arg) - end - continue - end - - v = raw_args[p] - hv = _handle_index(v, nargs, track_arg, track_ssa) - _push_arg_aliases_for_handle!(consumes, uf, hv, nargs, track_arg) - end - end - - for i in 1:nstmts - stmt = ir[Core.SSAValue(i)][:stmt] - rv = if stmt isa Core.ReturnNode - isdefined(stmt, :val) ? stmt.val : nothing - elseif stmt isa Expr && stmt.head === :return && !isempty(stmt.args) - stmt.args[1] - else - continue - end - hrv = _handle_index(rv, nargs, track_arg, track_ssa) - hrv == 0 && continue - rroot = _uf_find(uf, hrv) - for a in 1:nargs - track_arg[a] || continue - if _uf_find(uf, a) == rroot - push!(ret_aliases, a) - end - end - end - - return EffectSummary(; writes=writes, consumes=consumes, ret_aliases=ret_aliases) -end diff --git a/src/safe/utils.jl b/src/safe/utils.jl deleted file mode 100644 index 3ecd76f..0000000 --- a/src/safe/utils.jl +++ /dev/null @@ -1,22 +0,0 @@ -""" - PerTaskCache{T,F} - -A per-task cache that allows us to avoid repeated locking. -""" -struct PerTaskCache{T,F<:Function} - constructor::F - - PerTaskCache{T}(constructor::F) where {T,F} = new{T,F}(constructor) -end -PerTaskCache{T}() where {T} = PerTaskCache{T}(() -> T()) - -function Base.getindex(cache::PerTaskCache{T}) where {T} - tls = Base.task_local_storage() - if haskey(tls, cache) - return tls[cache]::T - else - value = cache.constructor()::T - tls[cache] = value - return value - end -end diff --git a/test/FakeModule/LocalPreferences.toml b/test/FakeModule/LocalPreferences.toml deleted file mode 100644 index c9087af..0000000 --- a/test/FakeModule/LocalPreferences.toml +++ /dev/null @@ -1,2 +0,0 @@ -[FakeModule] -borrow_checker = false \ No newline at end of file diff --git a/test/FakeModule/Project.toml b/test/FakeModule/Project.toml deleted file mode 100644 index b15dd3e..0000000 --- a/test/FakeModule/Project.toml +++ /dev/null @@ -1,8 +0,0 @@ -name = "FakeModule" -uuid = "9a2c8f72-9e38-4a5d-a85d-5858daab90a4" -authors = ["MilesCranmer "] -version = "0.1.0" - -[deps] -BorrowChecker = "7bdcaa52-c310-4bb0-bf54-d941056ed284" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/FakeModule/src/FakeModule.jl b/test/FakeModule/src/FakeModule.jl deleted file mode 100644 index e108d54..0000000 --- a/test/FakeModule/src/FakeModule.jl +++ /dev/null @@ -1,64 +0,0 @@ -module FakeModule - -using BorrowChecker -using BorrowChecker: @spawn, LockNotHeldError -using Test - -function test() - @own x = Ref(1) - @test x isa Base.RefValue{Int} - @test !(x isa Owned{Base.RefValue{Int}}) - @move y = x - @test y isa Base.RefValue{Int} - @test !(y isa Owned) - # Since borrow checker is disabled, x should still be accessible - @test x[] == 1 - # @take! should just return the value directly - @test (@take! x)[] == 1 - # This error now goes undetected: - @test x[] == 1 - - @own :mut z = Ref(1) - z[] = 2 - @test z[] == 2 - @test z isa Base.RefValue{Int} - @test !(z isa OwnedMut{Base.RefValue{Int}}) - - # Test @lifetime and @ref - @lifetime l begin - @ref ~l r = z - @test r[] == 2 - @test r isa Base.RefValue{Int} - @test !(r isa Borrowed{Base.RefValue{Int}}) - # Should be able to modify z since borrow checker is disabled - z[] = 3 - @test z[] == 3 - @test r[] == 3 - end - - let - @own z = [1, 2, 3] - @own :mut z_mut = [1, 2, 3] - f(y) = (@test y isa Vector{Int}; y) - @test @bc(f(z)) === f(z) - @test @bc(f(@mut(z_mut))) === f(z_mut) - end - - let - # This spawn still works because the borrow checker is disabled - @own x = 1 - @test fetch(@spawn x + 1) == 2 - end - - let - m = Mutex([1, 2, 3]) - # Locking should still work: - @test_throws LockNotHeldError @ref_into :mut arr = m[] - @test !islocked(m) - lock(m) - @test islocked(m) - @ref_into :mut arr = m[] - end -end - -end diff --git a/test/Project.toml b/test/Project.toml deleted file mode 100644 index 1597244..0000000 --- a/test/Project.toml +++ /dev/null @@ -1,14 +0,0 @@ -[deps] -Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" -DispatchDoctor = "8d63f2c5-f18a-4cf2-ba9d-b3f60fc568c8" -DynamicExpressions = "a40a106e-89c9-4ca8-8020-a735e8728b6b" -InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" -LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -PerformanceTestTools = "dc46b164-d16f-48ec-a853-60448fc869fe" -Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" -TestItems = "1c621080-faea-4a02-84b6-bbd5e436b8fe" - diff --git a/test/auto_borrow_checker_tests.jl b/test/auto_borrow_checker_tests.jl deleted file mode 100644 index 7e930c6..0000000 --- a/test/auto_borrow_checker_tests.jl +++ /dev/null @@ -1,1874 +0,0 @@ -@testitem "Auto @safe" tags = [:auto] begin - using TestItems - using BorrowChecker - using LinearAlgebra - - using BorrowChecker: BorrowCheckError, @safe - - const BC_TEST_TIMINGS = - lowercase(get(ENV, "BORROWCHECKER_TEST_TIMINGS", "")) in ("1", "true", "yes") - const BC_TEST_TIMING_BASE_DEPTH = Ref{Int}(-1) - - if BC_TEST_TIMINGS - using Test - - mutable struct BCTimedTestSet <: Test.AbstractTestSet - inner::Test.DefaultTestSet - start_ns::UInt64 - end - - function BCTimedTestSet( - desc::AbstractString; - verbose::Bool=false, - showtiming::Bool=true, - failfast::Union{Nothing,Bool}=nothing, - source=nothing, - rng=nothing, - ) - # Only print timings for the direct children of the timing wrapper (top-level - # `@testset` blocks in this file). - depth = Test.get_testset_depth() - if depth == BC_TEST_TIMING_BASE_DEPTH[] + 1 - println("[BC_TEST_BEGIN] ", desc) - end - inner = Test.DefaultTestSet( - desc; - verbose=verbose, - showtiming=showtiming, - failfast=failfast, - source=source, - rng=rng, - ) - return BCTimedTestSet(inner, time_ns()) - end - - Test.record(ts::BCTimedTestSet, t) = Test.record(ts.inner, t) - Test.print_verbose(ts::BCTimedTestSet) = Test.print_verbose(ts.inner) - Test.results(ts::BCTimedTestSet) = Test.results(ts.inner) - - function Test.finish( - ts::BCTimedTestSet; print_results::Bool=Test.TESTSET_PRINT_ENABLE[] - ) - elapsed_s = (time_ns() - ts.start_ns) / 1e9 - depth = Test.get_testset_depth() - if depth == BC_TEST_TIMING_BASE_DEPTH[] + 1 - println( - "[BC_TEST_END] ", - ts.inner.description, - " ", - round(elapsed_s; digits=3), - "s", - ) - end - return Test.finish(ts.inner; print_results=print_results) - end - end - - local _bc_timing_ts = nothing - if BC_TEST_TIMINGS - using Test - BC_TEST_TIMING_BASE_DEPTH[] = Test.get_testset_depth() - _bc_timing_ts = BCTimedTestSet( - "BorrowChecker @safe (timings)"; showtiming=false - ) - Test.push_testset(_bc_timing_ts) - end - - Base.@noinline fakewrite(x) = Base.inferencebarrier(x) - - BorrowChecker._ensure_registry_initialized() - const BC_BUILTIN_EFFECT_KEYS = Base.@lock BorrowChecker.KNOWN_EFFECTS begin - collect(keys(BorrowChecker.KNOWN_EFFECTS[])) - end - - mutable struct Box - x::Int - end - - mutable struct A - x::Int - end - - struct B - a::A - end - - mutable struct C - v - end - - struct Wrap - x::Vector{Int} - end - - BorrowChecker.@safe function _bc_bad_alias() - x = [1, 2, 3] - y = x - x[1] = 0 - return y - end - - BorrowChecker.@safe function _bc_ok_copy() - x = [1, 2, 3] - y = copy(x) - x[1] = 0 - return y - end - - BorrowChecker.@safe function _bc_bad_unknown_call(vf) - x = [1, 2, 3] - f = only(vf) - f(x) - x[1] = 0 - return x - end - - BorrowChecker.@safe function _bc_bad_alias_mutable_struct() - x = Box(1) - y = x - x.x = 0 - return y - end - - BorrowChecker.@safe function _bc_ok_copy_mutable_struct() - x = Box(1) - y = Box(x.x) - x.x = 0 - return y - end - - BorrowChecker.@safe function _bc_bad_struct_of_struct() - a = A(1) - b = B(a) - c = b - b.a.x = 0 - return c - end - - BorrowChecker.@safe function _bc_ok_struct_of_struct() - a = A(1) - b = B(a) - c = B(A(b.a.x)) - b.a.x = 0 - return c - end - - g!(x) = (push!(x, 1); nothing) - - const _BC_ESCAPE_CACHE = Any[] - _bc_consumes(x) = (push!(_BC_ESCAPE_CACHE, x); nothing) - const D = Dict{Any,Any}() - - @testset "g!(y) should not require deleting x" begin - BorrowChecker.@safe function _bc_g_alias_ok() - x = [1, 2, 3] - y = x - g!(y) - return y - end - - @test _bc_g_alias_ok() == [1, 2, 3, 1] - end - - @testset "effects inferred from IR (no naming heuristics)" begin - h(x) = (push!(x, 1); nothing) - - BorrowChecker.@safe function _bc_nonbang_mutator_bad() - x = [1, 2, 3] - y = x - h(x) - return y - end - - mut_second!(a, b) = (push!(b, 1); nothing) - - BorrowChecker.@safe function _bc_bang_mutates_second_bad() - x = [1, 2, 3] - y = [4] - z = y - mut_second!(x, y) - return z - end - - @test_throws BorrowCheckError _bc_nonbang_mutator_bad() - @test_throws BorrowCheckError _bc_bang_mutates_second_bad() - end - - @testset "array element extraction preserves aliases" begin - @safe function _bc_array_getindex_alias_bad(outer::Vector{Vector{Int}}) - a = outer[1] - b = outer[1] - push!(a, 2) - return b - end - - @test_throws BorrowCheckError _bc_array_getindex_alias_bad([[1]]) - end - - @testset "adversarial overloads (no special-casing overloadables)" begin - mutable struct _BCGetPropMutates - x::Vector{Int} - end - - function Base.getproperty(g::_BCGetPropMutates, s::Symbol) - if s === :x - v = getfield(g, :x) - push!(v, 999) - return v - end - return getfield(g, s) - end - - BorrowChecker.@safe function _bc_getproperty_mutates_bad() - g = _BCGetPropMutates([1, 2, 3]) - y = getfield(g, :x) - g.x # calls overloaded getproperty (mutates) - return y - end - - @test_throws BorrowCheckError _bc_getproperty_mutates_bad() - - mutable struct _BCSetPropDoesNotMutate - x::Vector{Int} - end - - Base.setproperty!(::_BCSetPropDoesNotMutate, ::Symbol, v) = v - - BorrowChecker.@safe function _bc_setproperty_no_mut_ok() - g = _BCSetPropDoesNotMutate([1, 2, 3]) - y = g - g.x = [4, 5, 6] # calls overloaded setproperty! (does not mutate) - return y - end - - @test _bc_setproperty_no_mut_ok().x == [1, 2, 3] - - mutable struct _BCCopyAliases - x::Vector{Int} - end - - Base.copy(x::_BCCopyAliases) = x - - BorrowChecker.@safe function _bc_copy_aliases_bad() - x = _BCCopyAliases([1, 2, 3]) - y = copy(x) # aliases by definition - x.x[1] = 0 - return y - end - - @test_throws BorrowCheckError _bc_copy_aliases_bad() - - mutable struct _BCIterateWeird - x::Vector{Int} - end - - Base.iterate(w::_BCIterateWeird) = (push!(getfield(w, :x), 1); (0, w)) - Base.iterate(::_BCIterateWeird, _) = nothing - - BorrowChecker.@safe function _bc_iterate_mutates_bad() - w = _BCIterateWeird([1, 2, 3]) - y = w - iterate(w) # calls overloaded iterate (mutates) - return y - end - - @test_throws BorrowCheckError _bc_iterate_mutates_bad() - end - - @testset "macro signature parsing: varargs" begin - @safe function _bc_varargs_signature(xs...) - return 0 - end - @test _bc_varargs_signature() == 0 - @test _bc_varargs_signature(1) == 0 - @test _bc_varargs_signature(1, 2) == 0 - end - - @testset "macro signature parsing: default args" begin - @safe function _bc_default_arg_signature(x=1) - return x + 1 - end - - @test _bc_default_arg_signature() == 2 - end - - @testset "macro signature parsing: keyword-only signature" begin - @safe function _bc_keyword_only_signature(; x, y) - return x + y - end - - @test _bc_keyword_only_signature(; x=1, y=2) == 3 - end - - @testset "macro signature parsing: anonymous typed arg" begin - @safe function _bc_anon_typed_arg_signature(x, ::Type{T}=Int) where {T} - return T - end - - @test _bc_anon_typed_arg_signature(1) == Int - @test _bc_anon_typed_arg_signature(1, Float64) == Float64 - end - - @testset "macro signature parsing: destructuring arg" begin - @safe function _bc_destructure_signature((a, b)) - return a + b - end - - @test _bc_destructure_signature((1, 2)) == 3 - end - - @testset "macro signature parsing: where + return type" begin - @safe function _bc_where_ret_signature(x::T)::T where {T} - return x - end - - @test _bc_where_ret_signature(1) == 1 - end - - @testset "macro signature parsing: functor call method" begin - struct _BCFun end - - @safe (f::_BCFun)(x) = x + 1 - - @test _BCFun()(1) == 2 - end - - @testset "macro signature parsing: dotted function name" begin - struct _BCAutoDotT end - - BorrowChecker.@safe function Base.identity(x::_BCAutoDotT) - return x - end - - @test Base.identity(_BCAutoDotT()) isa _BCAutoDotT - end - - @testset "boxed captured variable: getproperty field type refinement" begin - struct _BCBoxedField - n::Int - end - - @safe function _bc_boxed_getproperty_dim(x::_BCBoxedField) - g = () -> getfield(x, :n) - x = fakewrite(x) # capture + assign forces Core.Box lowering - a = zeros(Float64, getfield(x, :n)) - return (g(), length(a)) - end - - @test begin - try - _bc_boxed_getproperty_dim(_BCBoxedField(3)) == (3, 3) - catch - false - end - end - end - - @testset "boxed captured variable: broadcast materialize should not consume" begin - struct _BCBoxedBroadcast - n::Int - end - - @safe function _bc_boxed_broadcast_ok(x::_BCBoxedBroadcast) - g = () -> getfield(x, :n) - x = fakewrite(x) # capture + assign forces Core.Box lowering - b = rand(getfield(x, :n)) .< 0.5 - return (g(), sum(b)) - end - - @test begin - try - (n, s) = _bc_boxed_broadcast_ok(_BCBoxedBroadcast(10)) - n == 10 && s isa Real - catch - false - end - end - end - - @testset "Threads.@threads plumbing should not spuriously consume" begin - struct _BCThreadsBoxedRange - n::Int - end - - @safe function _bc_threads_boxed_range_ok(x::_BCThreadsBoxedRange, flag::Bool) - g = () -> getfield(x, :n) - x = fakewrite(x) # capture + assign forces Core.Box lowering - - r = 1:(getfield(x, :n)) - if flag - Base.Threads.@threads for i in r - fakewrite(i) - end - else - for i in r - fakewrite(i) - end - end - - return g() - end - - @test_broken begin - try - _bc_threads_boxed_range_ok(_BCThreadsBoxedRange(5), false) == 5 - catch - false - end - end - end - - @testset "known failure: Array{Int,l}(x) with value l" begin - @safe scope = :function function _bc_array_value_dim_ctor(x) - l = 1 - return Array{Int,l}(x) - end - - @test begin - try - _bc_array_value_dim_ctor([1]) == [1] - catch - false - end - end - end - - @testset "@safe assignment instrumentation: store should not create fresh origins" begin - mutable struct _BCProjectionS - a::Vector{Int} - end - - bump!(a::Vector{Int}) = (a[1] += 1; nothing) - - @safe function _bc_projection_store_ok(s::_BCProjectionS) - a = copy(s.a) # avoid aliasing `s.a` during mutation - bump!(a) - s.a = a - return s.a[1] - end - - @test _bc_projection_store_ok(_BCProjectionS([1, 2, 3])) == 2 - end - - @testset "@safe known effects: eachindex should not consume/escape" begin - @safe function _bc_eachindex_ok(refs, constants) - for i in eachindex(refs, constants) - refs[i] = constants[i] - end - return refs - end - - @test _bc_eachindex_ok([1, 2, 3], [4, 5, 6]) == [4, 5, 6] - end - - @testset "@safe known effects: copy should not consume" begin - @safe function _bc_copy_call_ok(x) - y = copy(x) - return (x, y) - end - - (x, y) = _bc_copy_call_ok([1, 2, 3]) - @test x == [1, 2, 3] - @test y == [1, 2, 3] - end - - @testset "macro rejects non-function inputs" begin - @test_throws LoadError eval(:(BorrowChecker.@safe begin - x = 1 - end)) - end - - @testset "macro option parsing: Config overrides" begin - BorrowChecker.@safe max_summary_depth = 1 function _bc_macro_opt_max_depth(x) - return x - end - @test _bc_macro_opt_max_depth(1) == 1 - - opt = BorrowChecker.Config().optimize_until - @eval BorrowChecker.@safe( - optimize_until = $opt, _bc_macro_opt_optimize_until(x) = x - ) - @test _bc_macro_opt_optimize_until(2) == 2 - end - - @testset "@safe debug logging" begin - using Test - - Base.@noinline _bc_dbg_localfun(x) = x - - @safe debug = true debug_callee_depth = 1 function _bc_dbg_fail(n::Int) - x = zeros(Float64, n) - x2 = _bc_dbg_localfun(x) - y = x2 - x2[1] = 0.0 - return length(y) - end - - BC_TEST_TIMINGS && println("[BC_DEBUG] case: dbg_fail depth=1 (begin)") - mktemp() do path, io - close(io) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do - BC_TEST_TIMINGS && - println("[BC_DEBUG] calling _bc_dbg_fail(3) (expect throw)") - @test_throws BorrowCheckError _bc_dbg_fail(3) - BC_TEST_TIMINGS && println("[BC_DEBUG] _bc_dbg_fail(3) threw as expected") - end - - BC_TEST_TIMINGS && println("[BC_DEBUG] reading jsonl output (dbg_fail depth=1)") - s = read(path, String) - ir_lines = filter( - l -> occursin("\"event\":\"auto_debug_ir\"", l), - split(s, '\n'; keepempty=false), - ) - @test occursin("\"event\":\"auto_debug_check\"", s) - @test occursin("\"event\":\"auto_debug_violations\"", s) - @test occursin("\"event\":\"auto_debug_summaries\"", s) - @test occursin("_bc_dbg_localfun", s) - @test any(l -> occursin("\"depth\":0", l), ir_lines) - @test any(l -> occursin("\"depth\":1", l), ir_lines) - end - - @safe debug = true debug_callee_depth = 0 function _bc_dbg_depth0(n::Int) - x = zeros(Float64, n) - x2 = _bc_dbg_localfun(x) - y = x2 - x2[1] = 0.0 - return length(y) - end - - BC_TEST_TIMINGS && println("[BC_DEBUG] case: dbg_depth0 depth=0 (begin)") - mktemp() do path, io - close(io) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do - BC_TEST_TIMINGS && - println("[BC_DEBUG] calling _bc_dbg_depth0(3) (expect throw)") - _ = try - _bc_dbg_depth0(3) - nothing - catch - nothing - end - end - BC_TEST_TIMINGS && - println("[BC_DEBUG] reading jsonl output (dbg_depth0 depth=0)") - s = read(path, String) - ir_lines = filter( - l -> occursin("\"event\":\"auto_debug_ir\"", l), - split(s, '\n'; keepempty=false), - ) - @test any(l -> occursin("\"depth\":0", l), ir_lines) - @test !any(l -> occursin("\"depth\":1", l), ir_lines) - end - - @safe debug = true debug_callee_depth = 0 function _bc_dbg_ok(x) - return x + 1 - end - BC_TEST_TIMINGS && println("[BC_DEBUG] case: dbg_ok (begin)") - mktemp() do path, io - close(io) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do - BC_TEST_TIMINGS && println("[BC_DEBUG] calling _bc_dbg_ok(1)") - @test _bc_dbg_ok(1) == 2 - end - BC_TEST_TIMINGS && println("[BC_DEBUG] reading jsonl output (dbg_ok)") - s = read(path, String) - @test occursin("\"event\":\"auto_debug_check\"", s) && - occursin("\"ok\":true", s) - @test !occursin("\"event\":\"auto_debug_error\"", s) - end - - # Warning + default-path behavior when env var is unset. - BC_TEST_TIMINGS && println("[BC_DEBUG] case: warn default-path (begin)") - default_path = joinpath(tempdir(), "BorrowChecker.auto.debug.$(getpid()).jsonl") - rm(default_path; force=true) - old = pop!(ENV, "BORROWCHECKER_AUTO_DEBUG_PATH", nothing) - logs, _ = Test.collect_test_logs() do - try - BC_TEST_TIMINGS && - println("[BC_DEBUG] calling _bc_dbg_depth0(3) with env unset (1)") - _bc_dbg_depth0(3) - catch - end - try - BC_TEST_TIMINGS && - println("[BC_DEBUG] calling _bc_dbg_depth0(3) with env unset (2)") - _bc_dbg_depth0(3) - catch - end - end - old === nothing || (ENV["BORROWCHECKER_AUTO_DEBUG_PATH"] = old) - @test count( - lr -> - lr.level == Base.CoreLogging.Warn && occursin( - "BorrowChecker.@safe debug enabled; writing JSONL debug log to", - lr.message, - ), - logs, - ) == 1 - @test isfile(default_path) - - # Exercise the error-swallowing path in debug logging by pointing the log path to a directory. - BC_TEST_TIMINGS && println("[BC_DEBUG] case: debug path is directory (begin)") - mktempdir() do d - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => d) do - @test _bc_dbg_ok(1) == 2 - end - end - - # `optimize_until` is logged exactly as provided. - BC_TEST_TIMINGS && println("[BC_DEBUG] case: invalid optimize_until logged (begin)") - @safe debug = true optimize_until = "definitely_invalid_pass" function _bc_dbg_bad_opt( - x - ) - return x + 1 - end - mktemp() do path, io - close(io) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do - BC_TEST_TIMINGS && - println("[BC_DEBUG] calling _bc_dbg_bad_opt(1) (expect error logged)") - _ = try - _bc_dbg_bad_opt(1) - nothing - catch - nothing - end - end - BC_TEST_TIMINGS && - println("[BC_DEBUG] reading jsonl output (invalid optimize_until)") - s = read(path, String) - @test occursin("\"event\":\"auto_debug_check\"", s) - @test occursin("\"optimize_until\":\"definitely_invalid_pass\"", s) - @test occursin("\"error\":", s) - @test occursin("\"time_s\":", s) - end - - # Summary exceptions are logged (best-effort) rather than crashing debug mode. - @generated _bc_dbg_badgen(x) = error("boom") - @safe debug = true scope = :function function _bc_dbg_summary_exception(x) - return _bc_dbg_badgen(x) - end - mktemp() do path, io - close(io) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do - _ = try - _bc_dbg_summary_exception(Int[1]) - nothing - catch - nothing - end - end - s = read(path, String) - @test occursin("\"event\":\"auto_debug_summary_exception\"", s) - end - - # Type refinement debug event is emitted when refinement makes changes. - @safe debug = true scope = :function function _bc_dbg_refine_types_event() - x = (g = () -> 3; g()) - return x - end - mktemp() do path, io - close(io) - withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do - _bc_dbg_refine_types_event() - end - s = read(path, String) - @test occursin("\"event\":\"auto_debug_refine_types\"", s) - end - end - - @testset "scope=:none disables @safe" begin - # This would normally fail borrow checking due to aliasing + mutation. - BorrowChecker.@safe scope = :none function _bc_auto_disabled() - x = [1, 2, 3] - y = fakewrite(x) - x[1] = 0 - return y - end - @test _bc_auto_disabled() == [0, 2, 3] - end - - @testset "callable structs: good/bad for mutable/immutable" begin - mutable struct _BCMutCallable - n::Int - end - (f::_BCMutCallable)() = (f.n += 1; f.n) - (f::_BCMutCallable)(::Val{:peek}) = f.n - - struct _BCImmCallable - v::Vector{Int} - end - (f::_BCImmCallable)() = (f.v[1] += 1; f.v[1]) - (f::_BCImmCallable)(::Val{:peek}) = f.v - - # Mutable functor: updated when called => should error if an alias is live. - @safe function _bc_mut_callable_bad() - f = _BCMutCallable(0) - g = f - f() - return g.n - end - @test_throws BorrowCheckError _bc_mut_callable_bad() - - # Mutable functor: read-only call => should be OK even with an alias. - @safe function _bc_mut_callable_good() - f = _BCMutCallable(0) - g = f - f(Val(:peek)) - return g.n - end - @test _bc_mut_callable_good() == 0 - - # Immutable functor: can still mutate reachable state (e.g. a Vector field). - @safe function _bc_imm_callable_bad() - f = _BCImmCallable([0]) - g = f - f() - return g.v - end - @test_throws BorrowCheckError _bc_imm_callable_bad() - - # Immutable functor: read-only call should be OK even with an alias. - @safe function _bc_imm_callable_good() - f = _BCImmCallable([0]) - g = f - f(Val(:peek)) - return g.v - end - @test _bc_imm_callable_good() == [0] - end - - @testset "@generated callee mutation is detected" begin - Base.@noinline @generated function _bc_gen_my_push!(x, v) - return :(Base.push!(x, v)) - end - - @safe function _bc_generated_mutation_bad() - x = [1, 2, 3] - y = x - _bc_gen_my_push!(x, 4) - return y - end - - @test_throws BorrowCheckError _bc_generated_mutation_bad() - end - - @testset "checked-cache respects cfg (scope affects recursion)" begin - # Repro: if `f` is checked once with `scope=:function`, then later recursion into `f` - # under `scope=:module` must not be skipped due to a tt/world-only cache key. - m = Module(gensym(:BCCacheCfg)) - Core.eval(m, :(import BorrowChecker as BC)) - Core.eval( - m, - quote - function inner_bad() - x = [1, 2, 3] - f = () -> x - push!(x, 4) - return f - end - - BC.@safe scope = :function f() = inner_bad() - BC.@safe scope = :module g() = f() - end, - ) - - # Warm the checked-cache for `f` under scope=:function (no recursion). - @test m.f()() == [1, 2, 3, 4] - # Now `g`'s recursive checking should re-check `f` under scope=:module and fail. - @test_throws BorrowCheckError m.g() - end - - @testset "__bc_assert_safe__ cache respects root_module" begin - m = Module(gensym(:BCRootCache)) - other = Module(gensym(:BCOtherRootCache)) - Core.eval(m, :(import BorrowChecker as BC)) - Core.eval(m, :(const CACHE = Dict{Int,Vector{Int}}())) - Core.eval( - m, - quote - g() = begin - x = [1, 2, 3] - CACHE[1] = x - push!(x, 4) - return x - end - helper() = (g(); nothing) - end, - ) - - tt = Tuple{typeof(getfield(m, :helper))} - - function clear_checked_cache!() - Base.@lock BorrowChecker.CHECKED_CACHE begin - empty!(BorrowChecker.CHECKED_CACHE[]) - end - empty!(BorrowChecker.PER_TASK_CHECKED_CACHE[]) - return nothing - end - - function check_with_root(root) - try - BorrowChecker.__bc_assert_safe__( - tt; - cfg=BorrowChecker.Config(; scope=:module, root_module=root), - ) - return :passed - catch e - e isa BorrowCheckError || rethrow() - return :failed - end - end - - function probe_stable_world() - clear_checked_cache!() - out_of_scope = check_with_root(other) - in_scope_after_cache_hit = check_with_root(m) - clear_checked_cache!() - in_scope_fresh = check_with_root(m) - return (out_of_scope, in_scope_after_cache_hit, in_scope_fresh) - end - - @test probe_stable_world() == (:passed, :failed, :failed) - end - - @testset "scope=:module catches unannotated callee with closure alias" begin - m = Module(gensym(:BCModuleScope)) - Core.eval(m, :(import BorrowChecker as BC)) - Core.eval( - m, - quote - function foo() - x = [1, 2, 3] - f = () -> x - push!(x, 4) - return f - end - end, - ) - Core.eval(m, :(BC.@safe scope = :module bar() = foo())) - - @test_throws BorrowCheckError m.bar() - end - - @testset "scope=:module recurses into Base extension methods" begin - # Repro: methods defined in the current module for Base functions (e.g. getindex) - # should be considered "in-module" for `scope=:module` recursion. - m = Module(gensym(:BCBaseExtScope)) - Core.eval(m, :(import BorrowChecker as BC)) - Core.eval( - m, - quote - struct T end - - function Base.getindex(::T) - x = [1, 2, 3] - f = () -> x - push!(x, 4) - return f - end - - BC.@safe scope = :module outer() = (T())[] - end, - ) - - @test_throws BorrowCheckError m.outer() - end - - @testset "try/catch/finally PhiCNode liveness does not assert" begin - @safe function _bc_try_finally_phicnode_ok() - a = [1, 2] - try - push!(a, 1) - catch - a = copy(a) - finally - sum(a) - end - return a - end - - result = try - _bc_try_finally_phicnode_ok() - :ok - catch e - if e isa AssertionError - :asserted - elseif e isa BorrowCheckError - :borrow_error - else - rethrow() - end - end - @test result != :asserted - end - - @testset "macro one-line method parsing: where clause" begin - BorrowChecker.@safe _bc_oneliner_where(x::T) where {T} = x - @test _bc_oneliner_where(1) == 1 - end - - @testset "macro one-line method parsing: return type" begin - BorrowChecker.@safe _bc_oneliner_ret(x)::Int = x - @test _bc_oneliner_ret(1) == 1 - end - - @testset "lambda arglist: single argument" begin - @safe function _bc_lambda_arglist_symbol() - f = x -> x + 1 - return f(1) - end - - @test _bc_lambda_arglist_symbol() == 2 - end - - @testset "lambda arglist: args_expr === nothing" begin - # This form doesn't occur from the surface syntax, but older/lower-level - # IR can contain lambdas represented as `Expr(:(->), nothing, body)`. - # Ensure our lambda instrumentation handles it. - fexpr = Expr(:(->), nothing, :(1)) - - eval( - quote - BorrowChecker.@safe function _bc_lambda_arglist_nothing() - f = $fexpr - return f() - end - end, - ) - - @test _bc_lambda_arglist_nothing() == 1 - end - - @testset "instrumentation leaves quoted code alone" begin - @safe function _bc_quote_expr() - q = quote - x = 1 - end - return q isa Expr - end - - @test _bc_quote_expr() - end - - @testset "nested function definitions are instrumented" begin - BorrowChecker.@safe function _bc_nested_function_bad() - function _bc_inner() - x = [1, 2, 3] - y = x - x[1] = 0 - return y - end - return _bc_inner() - end - - @test_throws BorrowCheckError _bc_nested_function_bad() - end - - @testset "local one-line method definitions are instrumented" begin - BorrowChecker.@safe function _bc_local_oneliner_bad() - _bc_inner() = begin - x = [1, 2, 3] - y = x - x[1] = 0 - return y - end - return _bc_inner() - end - - @test_throws BorrowCheckError _bc_local_oneliner_bad() - end - - @testset "LinearAlgebra in-place ops" begin - # Vector scaling (BLAS foreigncall) should be treated as a write. - @safe function _bc_la_scal_ok() - x = rand(3) - y = copy(x) - LinearAlgebra.BLAS.scal!(2.0, y) - return y - end - @test length(_bc_la_scal_ok()) == 3 - - @safe function _bc_la_scal_bad() - x = rand(3) - y = x - LinearAlgebra.BLAS.scal!(2.0, x) - # Use both bindings after the mutation so the alias is live. - return y - end - @test_throws BorrowCheckError _bc_la_scal_bad() - - @safe function _bc_la_triu_ok() - A = [1.0 2.0 3.0; 4.0 5.0 6.0; 7.0 8.0 9.0] - B = copy(A) - LinearAlgebra.triu!(B) - return B - end - @test _bc_la_triu_ok()[2, 1] == 0.0 - - @safe function _bc_la_triu_bad() - A = [1.0 2.0 3.0; 4.0 5.0 6.0; 7.0 8.0 9.0] - B = A - LinearAlgebra.triu!(A) - return (A, B) - end - @test_throws BorrowCheckError _bc_la_triu_bad() - end - - @test_throws BorrowCheckError _bc_bad_alias() - @test _bc_ok_copy() == [1, 2, 3] - @test_throws BorrowCheckError _bc_bad_unknown_call(Any[identity]) - - @test_throws BorrowCheckError _bc_bad_alias_mutable_struct() - @test _bc_ok_copy_mutable_struct().x == 1 - - @test_throws BorrowCheckError _bc_bad_struct_of_struct() - @test _bc_ok_struct_of_struct().a.x == 1 - - BorrowChecker.@safe function _bc_bad_closure_body_0arg() - f = () -> begin - x = [1, 2, 3] - y = x - push!(x, 9) - return y - end - return f() - end - - BorrowChecker.@safe function _bc_bad_closure_body_with_arg(z) - f = () -> begin - x = z - y = x - push!(x, 9) - return y - end - return f() - end - - BorrowChecker.@safe function _bc_ok_closure_body_0arg() - f = () -> begin - x = [1, 2, 3] - y = copy(x) - push!(x, 9) - return y - end - return f() - end - - BorrowChecker.@safe function _bc_ok_closure_body_with_arg(z) - f = () -> begin - x = copy(z) - y = copy(x) - push!(x, 9) - return y - end - return f() - end - - @test_throws BorrowCheckError _bc_bad_closure_body_0arg() - @test_throws BorrowCheckError _bc_bad_closure_body_with_arg([1, 2, 3]) - @test _bc_ok_closure_body_0arg() == [1, 2, 3] - @test _bc_ok_closure_body_with_arg([1, 2, 3]) == [1, 2, 3] - - # Regression test for https://github.com/MilesCranmer/BorrowChecker.jl/issues/49 - BorrowChecker.@safe function _bc_eltype_used_in_array_constructor(x) - T = eltype(x) - y = Vector{T}(x) - return y - end - @test _bc_eltype_used_in_array_constructor([1, 2]) == [1, 2] - - BorrowChecker.@safe function _bc_ok_phi_ternary(cond::Bool) - x = [1, 2, 3] - y = cond ? x : x - push!(y, 1) - return y - end - - @noinline _ret1(x) = x - - BorrowChecker.@safe function _bc_ok_identity_call() - x = [1, 2, 3] - y = _ret1(x) - push!(y, 1) - return y - end - - BorrowChecker.@safe function _bc_bad_view_alias() - x = [1, 2, 3, 4] - y = view(x, 1:2) - push!(x, 9) - return collect(y) - end - - BorrowChecker.@safe function _bc_bad_closure_capture() - x = [1, 2, 3] - y = x - f = () -> (push!(x, 9); nothing) - f() - return y - end - - BorrowChecker.@safe function _bc_bad_closure_capture_nested() - x = [1, 2, 3] - y = x - f = () -> begin - g = () -> (push!(x, 9); nothing) - g() - return nothing - end - f() - return y - end - - BorrowChecker.@safe function _bc_ok_closure_capture_readonly() - x = [1, 2, 3] - y = x - f = () -> begin - s = 0 - for i in 1:length(y) - s += y[i] - end - return s - end - f() - return x - end - - @test _bc_ok_phi_ternary(true) == [1, 2, 3, 1] - @test _bc_ok_phi_ternary(false) == [1, 2, 3, 1] - @test _bc_ok_identity_call() == [1, 2, 3, 1] - @test_throws BorrowCheckError _bc_bad_view_alias() - @test_throws BorrowCheckError _bc_bad_closure_capture() - @test_throws BorrowCheckError _bc_bad_closure_capture_nested() - @test _bc_ok_closure_capture_readonly() == [1, 2, 3] - - f_kwcall_ok(; x, y) = x .+ y - f_kwcall_ok_mut(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) - f_kwcall_alias_bad(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) - - @testset "kwcall unknown-call consume expands to keyword values" begin - fkw_nothing(; x, y) = nothing - - @safe function _bc_kwcall_unknown_consume_should_error(vf) - x = [1, 2, 3] - y = x - g = only(vf) - g(; x=x, y=y) - return y - end - - @test_throws BorrowCheckError _bc_kwcall_unknown_consume_should_error( - Any[fkw_nothing] - ) - end - - @safe function _bc_ok_kwcall() - x = [1, 2, 3] - y = copy(x) - return sum(f_kwcall_ok(; x=x, y=y)) - end - - @safe function _bc_ok_kwcall_mut() - x = [1, 2, 3] - y = copy(x) - return sum(f_kwcall_ok_mut(; x=x, y=y)) - end - - @safe function _bc_bad_kwcall_alias_should_error() - x = [1, 2, 3] - y = x - return sum(f_kwcall_alias_bad(; x=x, y=y)) - end - - @test _bc_ok_kwcall() == 12 - @test _bc_ok_kwcall_mut() == 14 - @test_throws BorrowCheckError _bc_bad_kwcall_alias_should_error() - - @testset "escape/store is treated as consume (move)" begin - empty!(_BC_ESCAPE_CACHE) - - @safe function _bc_escape_after_store_should_error() - x = [1, 2, 3] - _bc_consumes(x) - return x - end - - @test_throws BorrowCheckError _bc_escape_after_store_should_error() - end - - @testset "escape/store does not move non-owned values" begin - empty!(_BC_ESCAPE_CACHE) - - @safe function _bc_escape_bits_ok() - x = (1, 2, 3) - _bc_consumes(x) - return x - end - - @test _bc_escape_bits_ok() == (1, 2, 3) - end - - @testset "setfield!/Ref store moves owned values" begin - @safe function _bc_ref_store_moves_owned() - r = Ref{Any}() - x = [1, 2, 3] - r[] = x - return x - end - - @test_throws BorrowCheckError _bc_ref_store_moves_owned() - end - - @testset "setfield!/Ref store does not move isbits" begin - @safe function _bc_ref_store_bits_ok() - r = Ref{Any}() - x = (1, 2, 3) - r[] = x - return x - end - - @test _bc_ref_store_bits_ok() == (1, 2, 3) - end - - @testset "mutable field store moves owned values" begin - @safe function _bc_mutable_field_store_moves_owned() - c = C(nothing) - x = [1, 2, 3] - c.v = x - return x - end - - @test_throws BorrowCheckError _bc_mutable_field_store_moves_owned() - end - - @testset "unknown call does not consume non-owned values" begin - @safe function _bc_unknown_call_bits_ok(vf) - x = (1, 2, 3) - f = only(vf) - f(x) - return x - end - - @test _bc_unknown_call_bits_ok(Any[identity]) == (1, 2, 3) - end - - @testset "foreigncall treated as write (uniqueness enforced)" begin - @safe function _bc_foreigncall_bad(flag::Bool) - x = [1, 2, 3] - y = x - if flag - ccall(:jl_typeof_str, Cstring, (Any,), x) - end - return y - end - - @safe function _bc_foreigncall_ok(flag::Bool) - x = [1, 2, 3] - if flag - ccall(:jl_typeof_str, Cstring, (Any,), x) - end - return x - end - - @test_throws BorrowCheckError _bc_foreigncall_bad(false) - @test _bc_foreigncall_ok(false) == [1, 2, 3] - end - - @testset "_collect_ssa_ids! handles IR node objects (coverage)" begin - # This is a real `:foreigncall` that embeds Core IR node objects as *constants* - # inside a tuple argument. The borrow checker doesn't care about these values, - # but the foreigncall backslice should traverse them without error. - # - # This exercises `_collect_ssa_ids!` branches for: - # - `Core.ReturnNode` (val) - # - `Core.PiNode` (val) - # - `Core.UpsilonNode` (val) - # - `Core.GotoIfNot` (cond) - # - `Tuple` recursion - @safe function _bc_foreigncall_node_constants_ok() - ccall( - :jl_typeof_str, - Cstring, - (Any,), - ( - Core.ReturnNode(Core.SSAValue(0)), - Core.PiNode(Core.SSAValue(0), Any), - Core.UpsilonNode(Core.SSAValue(0)), - Core.GotoIfNot(Core.SSAValue(0), 1), - (Core.SSAValue(0),), - ), - ) - return nothing - end - - @test _bc_foreigncall_node_constants_ok() === nothing - end - - @testset "immutable wrapper containing owned field is owned" begin - empty!(_BC_ESCAPE_CACHE) - - @safe function _bc_escape_wrap_should_error() - w = Wrap([1, 2, 3]) - _bc_consumes(w) - return w - end - - @test_throws BorrowCheckError _bc_escape_wrap_should_error() - end - - @testset "symbols are not moved" begin - empty!(_BC_ESCAPE_CACHE) - - @safe function _bc_escape_symbol_ok() - x = :a - _bc_consumes(x) - return x - end - - @test _bc_escape_symbol_ok() == :a - end - - @testset "Dict setindex! key escapes" begin - empty!(D) - - @safe function _bc_dict_key_escape_should_error() - x = [1, 2, 3] - D[x] = 4 - return x - end - - @test_throws BorrowCheckError _bc_dict_key_escape_should_error() - - empty!(D) - - @safe function _bc_dict_key_copy_ok() - x = [1, 2, 3] - D[copy(x)] = 4 - return x - end - - @test _bc_dict_key_copy_ok() == [1, 2, 3] - end - - @testset "Tasks are shareable handles (do not trigger move/escape errors)" begin - @safe function _bc_async_handle_ok() - t = @async 1 - return fetch(t) - end - - @test _bc_async_handle_ok() == 1 - - @safe function _bc_async_copy_after_spawn_bad() - x = [1, 2, 3] - t = @async begin - push!(x, 4) - return sum(x) - end - y = copy(x) # unsafe: task may mutate `x` before this copy happens - return fetch(t), y - end - - @test_throws BorrowCheckError _bc_async_copy_after_spawn_bad() - - @safe function _bc_async_copy_before_spawn_ok() - x = [1, 2, 3] - y = copy(x) - t = @async begin - push!(x, 4) - return sum(x) - end - return fetch(t), y - end - - @test _bc_async_copy_before_spawn_ok() == (10, [1, 2, 3]) - end - - @testset "Atomics are shareable handles (aliasing is allowed)" begin - @safe function _bc_atomic_alias_ok() - a = Threads.Atomic{Int}(0) - b = a - Threads.atomic_add!(a, 1) - return b[] - end - - @test _bc_atomic_alias_ok() == 1 - end - - @testset "__bc_assert_safe__ short-circuits on cache hit" begin - local_f(x) = x - tt = Tuple{typeof(local_f),Int} - - BorrowChecker.__bc_assert_safe__(tt) - GC.gc() - - alloc = @allocated BorrowChecker.__bc_assert_safe__(tt) - @test alloc < 200_000 - end - - @testset "__bc_assert_safe__ thread-safety" begin - Threads.nthreads() < 2 && return nothing - - Base.@lock BorrowChecker.CHECKED_CACHE begin - empty!(BorrowChecker.CHECKED_CACHE[]) - end - - fs = [ - (x::Int) -> x, - (x::Int) -> x + 1, - (x::Int) -> x + 2, - (x::Int) -> x + 3, - (x::Int) -> x + 4, - (x::Int) -> x + 5, - (x::Int) -> x + 6, - (x::Int) -> x + 7, - (x::Int) -> x + 8, - (x::Int) -> x + 9, - ] - tts = map(f -> Tuple{typeof(f),Int}, fs) - - turn1 = Channel{Int}(1) - turn2 = Channel{Int}(1) - done = Channel{Int}(1) # idx - - function worker(which::Int) - turn = (which == 1) ? turn1 : turn2 - for _ in 1:length(tts) - idx = take!(turn) - BorrowChecker.__bc_assert_safe__(tts[idx]) - put!(done, idx) - end - return nothing - end - - task1 = Threads.@spawn worker(1) - task2 = Threads.@spawn worker(2) - - for i in 1:length(tts) - first = isodd(i) ? 1 : 2 - second = (first == 1) ? 2 : 1 - - put!((first == 1) ? turn1 : turn2, i) - @test take!(done) == i - - put!((second == 1) ? turn1 : turn2, i) - @test take!(done) == i - end - - wait(task1) - wait(task2) - - # Free-for-all: lots of concurrent hits/misses should not throw or deadlock. - Base.@lock BorrowChecker.CHECKED_CACHE begin - empty!(BorrowChecker.CHECKED_CACHE[]) - end - - nworkers = 16 - jobs = 60 - errs = Channel{Any}(nworkers) - @sync for _ in 1:nworkers - Threads.@spawn begin - err = nothing - try - for j in 1:jobs - BorrowChecker.__bc_assert_safe__(tts[(j % length(tts)) + 1]) - end - catch e - err = e - end - put!(errs, err) - end - end - for _ in 1:nworkers - e = take!(errs) - e === nothing || rethrow(e) - end - end - - @testset "@safe scope=:module recursive callees" begin - # Without recursion, this outer method doesn't observe the inner violation. - Base.@noinline function _bc_scope_inner_bad() - x = [1, 2, 3] - y = fakewrite(x) - x[1] = 0 - return y - end - - @safe function _bc_scope_outer_norec_ok() - return _bc_scope_inner_bad() - end - @test _bc_scope_outer_norec_ok() == [0, 2, 3] - - @safe scope = :module function _bc_scope_outer_rec_bad() - return _bc_scope_inner_bad() - end - @test_throws BorrowCheckError _bc_scope_outer_rec_bad() - end - - @testset "modules are not owned (avoid spurious consumes)" begin - @safe function _bc_module_not_owned() - m = Base - g = Base.inferencebarrier(identity) - g(m) # unknown/dynamic call site should NOT consume `m` - return getproperty(m, :Math) - end - - @test _bc_module_not_owned() === Base.Math - end - - @testset "isa is pure (does not consume)" begin - @safe function _bc_isa_does_not_consume() - x = [1, 2, 3] - y = fakewrite(x) - if y isa Vector{Int} - y[1] = 0 - return y - else - error("unexpected") - end - end - - @test _bc_isa_does_not_consume() == [0, 2, 3] - end - - @testset "scope=:user excludes Core/Base recursion" begin - cfg = BorrowChecker.Config(; scope=:user) - @test BorrowChecker._scope_allows_module(Core, cfg) == false - @test BorrowChecker._scope_allows_module(Base, cfg) == false - @test BorrowChecker._scope_allows_module(Main, cfg) == true - - @static if isdefined(Core, :Compiler) - @test BorrowChecker._scope_allows_module(Core.Compiler, cfg) == false - end - @static if isdefined(Base, :Iterators) - @test BorrowChecker._scope_allows_module(Base.Iterators, cfg) == false - end - end - - @testset "scope=:all does not crash on PhiCNode" begin - @safe scope = :all _bc_scope_all_sin(x) = sin(x) - err = try - _bc_scope_all_sin(1.0) - nothing - catch e - e - end - @test isnothing(err) - end - - @testset "PhiCNode liveness accounting" begin - BorrowChecker.@safe function _bc_phicnode_liveness(x) - y = x - try - error("boom") - catch - return y - end - end - - @test _bc_phicnode_liveness(1) == 1 - end - - @testset "apply_iterate + kwcall callee scanning" begin - mod = Module(:_BCAutoCalleeScanMod) - Core.eval(mod, :(using BorrowChecker)) - - Base.include_string( - mod, - """ - g(a, b, c) = a + b + c - kwsum(; x, y) = x + y - - BorrowChecker.@safe scope = :module function caller(t::Tuple{Int,Int,Int}) - s = g(t...) - return kwsum(; x=s, y=1) - end - """, - "REPL[1001]", - ) - - caller = getfield(mod, :caller) - @test caller((1, 2, 3)) == 7 - end - - @testset "Core.throw_inexacterror does not BorrowCheckError" begin - # This should throw an `InexactError` at runtime, but borrow checking (including - # recursive checking of user code) should not fail. - @safe scope = :user _bc_inexact_int64(x::UInt64) = Int64(x) - @test_throws InexactError _bc_inexact_int64(typemax(UInt64)) - end - - @testset "summary cache determinism" begin - Base.@lock BorrowChecker.SUMMARY_STATE begin - empty!(BorrowChecker.SUMMARY_STATE[].summary_cache) - empty!(BorrowChecker.SUMMARY_STATE[].tt_summary_cache) - empty!(BorrowChecker.SUMMARY_STATE[].summary_inprogress) - empty!(BorrowChecker.SUMMARY_STATE[].tt_summary_inprogress) - end - - deep1(x) = x - deep2(x) = deep1(x) - deep3(x) = deep2(x) - - cfg = BorrowChecker.Config(; max_summary_depth=2) - tt = Tuple{typeof(deep3),Vector{Int}} - - BorrowChecker._summary_for_tt(tt, cfg; depth=cfg.max_summary_depth) - - function latest_entry() - Base.@lock BorrowChecker.SUMMARY_STATE begin - best_key = nothing - for k in keys(BorrowChecker.SUMMARY_STATE[].tt_summary_cache) - (k[1] === tt && k[3] == cfg) || continue - (best_key === nothing || k[2] > best_key[2]) && (best_key = k) - end - best_key === nothing && error("missing cache entry") - return BorrowChecker.SUMMARY_STATE[].tt_summary_cache[best_key] - end - end - - entry1 = latest_entry() - @test entry1.over_budget == true - - BorrowChecker._summary_for_tt(tt, cfg; depth=0) - entry2 = latest_entry() - @test entry2.over_budget == false - end - - @testset "Registry override API" begin - BorrowChecker.register_effects!(fakewrite; writes=(2,)) - - @safe function bc_registry_override() - x = [1, 2, 3] - y = x - z = fakewrite(x) - z === y || error("unexpected") - return y - end - - @test_throws BorrowCheckError bc_registry_override() - end - - @testset "@safe one-line method form" begin - # Hits the `ex.head === :(=)` + `_is_method_definition_lhs` branch in the macro. - BorrowChecker.@safe _bc_oneliner_bad() = begin - x = [1, 2, 3] - y = x - x[1] = 0 - y - end - - @test_throws BorrowCheckError _bc_oneliner_bad() - end - - @testset "Pointer intrinsics + known issues" begin - @safe function _bc_pointerset_ok() - A = [1, 2, 3] - p = pointer(A) - unsafe_store!(p, 99, 1) - return nothing - end - @test _bc_pointerset_ok() === nothing - - @safe function _bc_pointer_unsafe_store_regression() - A = [1, 2, 3] - B = A - p = pointer(A) - unsafe_store!(p, 99, 1) - return B - end - @test_throws BorrowCheckError _bc_pointer_unsafe_store_regression() - - @safe function _bc_pointerset_alias_bad() - A = [1, 2, 3] - p = pointer(A) - q = p - unsafe_store!(p, 99, 1) - return q - end - @test_throws BorrowCheckError _bc_pointerset_alias_bad() - - @safe function _bc_reinterpret_write_bad() - A = Int32[1, 2, 3, 4] - B = A - R = reinterpret(UInt8, A) # shares memory with A - R[1] = 0x7f - return B - end - @test_throws BorrowCheckError _bc_reinterpret_write_bad() - end - - @testset "Tuple duplicates aliasing" begin - @safe function _bc_return_tuple_copy_order_bad(x) - return (x, copy(x)) - end - @test_throws BorrowCheckError _bc_return_tuple_copy_order_bad([1, 2, 3]) - - @safe function _bc_return_tuple_copy_order_ok(x) - return (copy(x), x) - end - a, b = _bc_return_tuple_copy_order_ok([1, 2, 3]) - @test a == b == [1, 2, 3] - - @safe function _bc_return_tuple_duplicates_bad() - x = [1, 2, 3] - return (x, x) - end - @test_throws BorrowCheckError _bc_return_tuple_duplicates_bad() - - @safe function _bc_array_literal_duplicates_bad() - x = [1, 2, 3] - return [x, x] - end - @test_throws BorrowCheckError _bc_array_literal_duplicates_bad() - - @safe function _bc_array_literal_copy_order_bad(x) - return [x, copy(x)] - end - @test_throws BorrowCheckError _bc_array_literal_copy_order_bad([1, 2, 3]) - - @safe function _bc_array_literal_copy_order_ok(x) - return [copy(x), x] - end - ys = _bc_array_literal_copy_order_ok([1, 2, 3]) - @test ys[1] == ys[2] == [1, 2, 3] - end - - @testset "Known foreigncall effects" begin - @testset "jl_genericmemory_copyto writes destination only" begin - @safe scope = :all _bc_wrap_in_vec(x) = [x] - @test _bc_wrap_in_vec([1, 2, 3]) == [[1, 2, 3]] - end - - @testset "BoundsError constructor is treated as pure" begin - @safe scope = :all _bc_sin(x) = sin(x) - @test _bc_sin(1.0) == sin(1.0) - end - end - - @testset "Core._typevar does not consume" begin - @safe scope = :all _bc_mk_typevar() = (TypeVar(:T, Int); true) - @test _bc_mk_typevar() - end - - @testset "read-only Base foreigncalls" begin - @testset "jl_object_id" begin - @safe scope = :all function _bc_objectid_alias_ok(x) - y = x - objectid(x) - return x === y - end - v = Any[1] - @test _bc_objectid_alias_ok(v) - end - - @testset "jl_type_hash" begin - @safe scope = :all function _bc_hash_type_alias_ok(T) - S = T - hash(T) - return T === S - end - @test _bc_hash_type_alias_ok(Int) - end - - @testset "jl_type_unionall" begin - @safe scope = :all function _bc_unionall_typearg_alias_ok(t) - u = t - UnionAll(TypeVar(:T), t) - return t === u - end - @test _bc_unionall_typearg_alias_ok(Int) - end - - @testset "jl_eqtable_get" begin - @safe scope = :all function _bc_iddict_get_alias_ok(d, k) - d2 = d - get(d, k, nothing) - return d === d2 - end - key = Any[1] - d = IdDict{Any,Any}(key => 2) - @test _bc_iddict_get_alias_ok(d, key) - end - - @testset "jl_eqtable_nextind (via iterate)" begin - @safe scope = :all function _bc_iddict_iterate_alias_ok(d) - d2 = d - iterate(d) - return d === d2 - end - d = IdDict{Any,Any}(Any[1] => 2) - @test _bc_iddict_iterate_alias_ok(d) - end - - @testset "jl_get_fieldtypes" begin - @safe scope = :all function _bc_fieldtypes_alias_ok(T) - S = T - fieldtypes(T) - return T === S - end - @test _bc_fieldtypes_alias_ok(ComplexF64) - end - - @testset "jl_field_index" begin - @safe scope = :all function _bc_fieldindex_alias_ok(T) - S = T - Base.fieldindex(T, :re, true) - return T === S - end - @test _bc_fieldindex_alias_ok(ComplexF64) - end - - @testset "jl_gc_new_weakref_th" begin - @safe scope = :all function _bc_weakref_alias_ok(x) - y = x - WeakRef(x) - return x === y - end - v = Any[1] - @test _bc_weakref_alias_ok(v) - end - - @testset "jl_value_ptr" begin - @safe scope = :all function _bc_ptr_from_objref_alias_ok(x) - y = x - pointer_from_objref(x) - return x === y - end - v = Any[1] - @test _bc_ptr_from_objref_alias_ok(v) - end - end - - @testset "Known effects registry only uses Core" begin - allowed_auto = Set{Any}([BorrowChecker.Config, BorrowChecker.__bc_bind__]) - if isdefined(BorrowChecker, :__bc_assert_safe__) - push!(allowed_auto, BorrowChecker.__bc_assert_safe__) - end - - bad = Any[] - for f in BC_BUILTIN_EFFECT_KEYS - m = try - parentmodule(f) - catch - nothing - end - - if m === BorrowChecker - (f in allowed_auto) || push!(bad, (f, m)) - continue - end - - (m === Core || m === Core.Intrinsics) || push!(bad, (f, m)) - end - - @test isempty(bad) - end - - if (@isdefined(_bc_timing_ts)) && _bc_timing_ts !== nothing - using Test - Test.pop_testset() - # Don't print the full nested summary; we only want the timing lines above. - Test.finish(_bc_timing_ts; print_results=false) - end -end diff --git a/test/auto_hygiene_integration_tests.jl b/test/auto_hygiene_integration_tests.jl deleted file mode 100644 index 2d1a413..0000000 --- a/test/auto_hygiene_integration_tests.jl +++ /dev/null @@ -1,48 +0,0 @@ -@testitem "Auto @safe hygiene and tracking" tags = [:auto] begin - using TestItems - using BorrowChecker: @safe - - @testset "macro hygiene: no unqualified BorrowChecker reference" begin - user_mod = Module(:_BCHygieneUser) - - Core.eval(user_mod, :(using BorrowChecker: @safe)) - - ex = :(@safe function f(x) - y = x - return y - end) - - expanded = macroexpand(user_mod, ex) - - function has_unqualified_borrowchecker_ref(node) - if node === :BorrowChecker - return true - end - if node isa Expr - return any(has_unqualified_borrowchecker_ref, node.args) - end - return false - end - - # Fully-qualified `GlobalRef(BorrowChecker, ...)` references are fine: - # the macro must not require an unqualified `BorrowChecker` binding in - # the user's module. - @test !has_unqualified_borrowchecker_ref(expanded) - end - - @testset "runtime hygiene: no `BorrowChecker` binding needed" begin - user_mod = Module(:_BCHygieneRuntimeUser) - Core.eval(user_mod, :(using BorrowChecker: @safe)) - Core.eval(user_mod, :(@safe function f(x) - y = x - return y - end)) - @test Core.eval(user_mod, :(f([1, 2, 3]))) == [1, 2, 3] - end - - @testset "is_tracked_type doesn't error on abstract" begin - # Regression: fieldtypes(fieldcount) throws for abstract types. - @test BorrowChecker.is_tracked_type(AbstractArray) === true - @test BorrowChecker.is_tracked_type(AbstractVector) === true - end -end diff --git a/test/auto_llvm_ir_tests.jl b/test/auto_llvm_ir_tests.jl deleted file mode 100644 index 1d183b3..0000000 --- a/test/auto_llvm_ir_tests.jl +++ /dev/null @@ -1,85 +0,0 @@ -using BorrowChecker -using InteractiveUtils: code_llvm -using Test: @test - -function llvm_ir_minimal() - m = Module(gensym(:BCLLVM)) - Core.eval(m, :(import BorrowChecker as BC)) - Core.eval(m, :(BC.@safe f(x::Int) = x)) - - f = Core.eval(m, :f) - - function normalize_llvm(ll::AbstractString) - lines = split(ll, "\n") - filter!(l -> !isempty(l), lines) - filter!(l -> !startswith(l, ";"), lines) - return lines - end - - llvm_ir = sprint((args...) -> code_llvm(args...; debuginfo=:none), f, (Int,)) - lines = normalize_llvm(llvm_ir) - joined = join(lines, "\n") - - @test !occursin("gc_pool_alloc", llvm_ir) - @test !occursin("_generated_assert_safe", joined) - @test !occursin("BorrowChecker", joined) - - # For a trivial function, we expect just: - # define ... - # top: - # ret ... - # } - if length(lines) != 4 - @show lines - end - @test length(lines) == 4 - - return nothing -end - -llvm_ir_minimal() - -function llvm_ir_unsafe_idempotent() - m = Module(gensym(:BCLLVMUnsafe)) - Core.eval(m, :(import BorrowChecker as BC)) - - body = quote - x = Ref(0) - y = x - x[] = 1 - y[] - end - - Core.eval(m, :(f_plain_plain() = $body)) - Core.eval(m, :(BC.@safe f_safe_plain() = $body)) - Core.eval(m, :(BC.@safe f_safe_unsafe() = BC.@unsafe $body)) - - plain = Core.eval(m, :f_plain_plain) - safe_plain = Core.eval(m, :f_safe_plain) - safe_unsafe = Core.eval(m, :f_safe_unsafe) - - ll_plain = sprint((args...) -> code_llvm(args...; debuginfo=:none), plain, Tuple{}) - ll_safe_plain = sprint( - (args...) -> code_llvm(args...; debuginfo=:none), safe_plain, Tuple{} - ) - ll_safe_unsafe = sprint( - (args...) -> code_llvm(args...; debuginfo=:none), safe_unsafe, Tuple{} - ) - - function normalize_llvm(ll::AbstractString) - lines = split(ll, "\n") - filter!(l -> !isempty(l), lines) - filter!(l -> !startswith(l, ";"), lines) - joined = join(lines, "\n") - return replace(joined, r"^(define\s+.*\s+@)[^\s\(]+"m => s"\1FUNC") - end - - @test normalize_llvm(ll_plain) == normalize_llvm(ll_safe_unsafe) - @test !occursin("_generated_assert_safe", ll_plain) - @test !occursin("_generated_assert_safe", ll_safe_unsafe) - @test occursin("_generated_assert_safe", ll_safe_plain) - - return nothing -end - -llvm_ir_unsafe_idempotent() diff --git a/test/auto_llvm_tests.jl b/test/auto_llvm_tests.jl deleted file mode 100644 index 4a47206..0000000 --- a/test/auto_llvm_tests.jl +++ /dev/null @@ -1,8 +0,0 @@ -@testitem "Auto LLVM IR" tags = [:auto] begin - using BorrowChecker - using PerformanceTestTools: @include - - @include("auto_llvm_ir_tests.jl") - # Important to run the LLVM IR tests in a new julia process with - # things like --code-coverage disabled. -end diff --git a/test/auto_printing_tests.jl b/test/auto_printing_tests.jl deleted file mode 100644 index ea6918d..0000000 --- a/test/auto_printing_tests.jl +++ /dev/null @@ -1,313 +0,0 @@ -@testitem "Auto @safe printing" tags = [:auto] begin - using TestItems - using BorrowChecker - - # This test targets error printing helpers in the experimental borrow checker. - using BorrowChecker: BorrowCheckError, BorrowViolation - - @testset "file source line" begin - (path, io) = mktemp() - close(io) - write(path, "line1\nSENTINEL_FILE_LINE\nline3\n") - - li = LineNumberNode(2, Symbol(path)) - v = BorrowViolation(1, "msg", li, :(dummy_stmt)) - e = BorrowCheckError(Any, [v]) - - s = sprint(showerror, e) - @test occursin("at $path:2", s) - @test occursin("SENTINEL_FILE_LINE", s) - end - - @testset "lowered fallback (non-file source)" begin - mod = Module(:_BCPrintUserMod) - code = "foo!(x) = x\nfunction bar(x)\n foo!(x)\n return x\nend\n" - Base.include_string(mod, code, "REPL[6]") - - bar = getfield(mod, :bar) - tt = Tuple{typeof(bar),Int} - - li = LineNumberNode(3, Symbol("REPL[6]")) - v = BorrowViolation(1, "msg", li, :(dummy_stmt)) - e = BorrowCheckError(tt, [v]) - - s = sprint(showerror, e) - @test occursin("at REPL[6]:3", s) - @test occursin("lowered:", s) - @test occursin("foo!", s) - end - - @testset "BorrowCheckError includes REPL context (real checker)" begin - mod = Module(:_BCPrintRealMod) - Core.eval(mod, :(using BorrowChecker: @safe)) - Base.include_string( - mod, - """ - @safe function foo() - x = [1, 2, 3] - y = x - push!(x, 9) - return y - end - """, - "REPL[999]", - ) - - err = try - getfield(mod, :foo)() - nothing - catch e - e - end - - @test err isa BorrowCheckError - s = sprint(showerror, err) - @test occursin("REPL[999]", s) - @test occursin("lowered:", s) - @test occursin("push!", s) - end - - @testset "BorrowCheckError prints multiple violations" begin - mod = Module(:_BCPrintMultiMod) - Core.eval(mod, :(using BorrowChecker: @safe)) - Base.include_string( - mod, - """ - @safe function multi() - x = [1, 2, 3] - y = x - push!(x, 9) - - a = [1, 2, 3] - b = a - a[1] = 0 - - return (y, b) - end - """, - "REPL[998]", - ) - - err = try - getfield(mod, :multi)() - nothing - catch e - e - end - - @test err isa BorrowCheckError - s = sprint(showerror, err) - - n = length(collect(eachmatch(r"(?m)^ \[[0-9]+\] stmt#", s))) - @test n >= 2 - @test count("cannot perform write", s) >= 2 - end - - @testset "BorrowCheckError prints file-backed source context (real checker)" begin - (path, io) = mktemp() - close(io) - - write( - path, - """ - module _BCFilePrintMod - using BorrowChecker: @safe - - f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) - - @safe function foo() - x = [1, 2, 3] - y = x - return sum(f(; x=x, y=y)) - end - end - """, - ) - - mod = Module(:_BCFilePrintHost) - Base.include(mod, path) - inner = getfield(mod, :_BCFilePrintMod) - foo = getfield(inner, :foo) - - err = try - foo() - nothing - catch e - e - end - - @test err isa BorrowCheckError - s = sprint(showerror, err) - @test occursin("at $path:", s) - @test occursin("return sum(f(; x=x, y=y))", s) - @test occursin(r"(?m)^\s*>\s*9\s+return sum\(f\(; x=x, y=y\)\)", s) - end - - @testset "BorrowCheckError prints REPL source (real REPL)" begin - using REPL - import REPL.LineEdit - using Base.Terminals - - function _strip_ansi(s::AbstractString) - # Strip ANSI CSI sequences (good enough for our assertions). - return replace(String(s), r"\e\[[0-9;?]*[ -/]*[@-~]" => "") - end - - old_repl = isdefined(Base, :active_repl) ? Base.active_repl : nothing - - input = Pipe() - output = Pipe() - err = Pipe() - Base.link_pipe!(input; reader_supports_async=true, writer_supports_async=true) - Base.link_pipe!(output; reader_supports_async=true, writer_supports_async=true) - Base.link_pipe!(err; reader_supports_async=true, writer_supports_async=true) - - term = REPL.Terminals.TTYTerminal("dumb", input.out, output.in, err.in) - repl = REPL.LineEditREPL(term, false) - repl.options = REPL.Options(; confirm_exit=false) - repl.history_file = false - Base.active_repl = repl - - repltask = @async REPL.run_repl(repl) - - write(input.in, "using BorrowChecker: @safe\r") - write(input.in, "f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y)\r") - write( - input.in, - "@safe function foo()\n x = [1,2,3]\n y = x\n return sum(f(; x=x, y=y))\nend\r", - ) - write(input.in, "foo()\r") - close(input.in) - - Base.wait(repltask) - close(output.in) - close(err.in) - - out = _strip_ansi(read(output.out, String)) - errout = read(err.out, String) - isempty(errout) || @test false - - @test occursin("BorrowCheckError for specialization", out) - @test occursin("at REPL[3]:4", out) - @test occursin("return sum(f(; x=x, y=y))", out) - @test occursin(r"(?m)^\s*>\s*4\s+return sum\(f\(; x=x, y=y", out) - - if isdefined(Base, :active_repl) - try - Base.active_repl = old_repl - catch - end - end - end - - @testset "REPL history source fallback (mock active_repl)" begin - # This test does not require an interactive REPL. We mock `Base.active_repl` - # so `_try_repl_source` can pull text for `REPL[n]`. - - struct _BCEntryMock - content::String - end - struct _BCThrowEntryMock end - Base.propertynames(::_BCThrowEntryMock; private::Bool=false) = (:content,) - Base.getproperty(::_BCThrowEntryMock, ::Symbol) = error("boom") - struct _BCHistMock - history::Vector{Any} - start_idx::Int - end - struct _BCModeMock - hist::_BCHistMock - end - struct _BCInterfaceMock - modes::Vector{_BCModeMock} - end - struct _BCReplMock - interface::_BCInterfaceMock - end - - old_repl = isdefined(Base, :active_repl) ? Base.active_repl : nothing - - # Try to set `Base.active_repl`. If this ever becomes non-assignable on some - # Julia version, just skip this test. - set_ok = true - try - src = "line1\nSENTINEL_REPL_LINE\nline3\n" - # In real REPL sessions `start_idx` is the number of entries loaded from - # the history file, and `REPL[1]` corresponds to the first entry *after* - # that baseline: history[start_idx + 1]. - hist = Any["OLD_ENTRY_1", _BCEntryMock(src), _BCThrowEntryMock()] - mock = _BCReplMock(_BCInterfaceMock([_BCModeMock(_BCHistMock(hist, 2))])) - Base.active_repl = mock - catch - set_ok = false - end - - if set_ok - li = LineNumberNode(2, Symbol("REPL[1]")) - v = BorrowViolation(1, "msg", li, :(dummy_stmt)) - e = BorrowCheckError(Any, [v]) - - s = sprint(showerror, e) - @test occursin("SENTINEL_REPL_LINE", s) - end - - if isdefined(Base, :active_repl) - try - Base.active_repl = old_repl - catch - end - end - end - - @testset "REPL history source fallback (scan history)" begin - # Exercise the non-`start_idx` fallback paths in `_try_repl_source_lines`. - # - # We arrange things so: - # - `hp.start_idx` does not exist, so the direct indexing heuristic is skipped. - # - The `(n, n-1)` candidates are unusable for the requested line. - # - A later multi-line history entry is usable, so the scan fallback returns it. - - struct _BCHist2 - history::Vector{Any} - end - struct _BCMode2 - hist::_BCHist2 - end - struct _BCInterface2 - modes::Vector{Any} - end - struct _BCRepl2 - interface::_BCInterface2 - end - - old_repl = isdefined(Base, :active_repl) ? Base.active_repl : nothing - try - history = Any[ - "x = 1", - "y = 2", - # `REPL[6]` first tries (n, n-1) = (6, 5). Make both unusable. - "line1\n#= REPL[6]:2 =#\nline3\n", # line 2 is a line marker => unusable - "z = 4", - "line1\n\nline3\n", # line 2 is empty => unusable - "w = 6", - # Scan fallback should find this usable multi-line entry for line 2. - "line1\nSENTINEL_REPL_FALLBACK\nline3\n", - ] - - Base.active_repl = _BCRepl2(_BCInterface2(Any[_BCMode2(_BCHist2(history))])) - - li = LineNumberNode(2, Symbol("REPL[6]")) - v = BorrowViolation(1, "msg", li, :(dummy_stmt)) - e = BorrowCheckError(Any, [v]) - - s = sprint(showerror, e) - @test occursin("SENTINEL_REPL_FALLBACK", s) - finally - if isdefined(Base, :active_repl) - try - Base.active_repl = old_repl - catch - end - end - end - end -end diff --git a/test/auto_unsafe_api_tests.jl b/test/auto_unsafe_api_tests.jl deleted file mode 100644 index 4407c04..0000000 --- a/test/auto_unsafe_api_tests.jl +++ /dev/null @@ -1,154 +0,0 @@ -@testitem "Auto @safe/@unsafe API" tags = [:auto] begin - using Test - using BorrowChecker - - @test Symbol("@safe") in names(BorrowChecker) - @test Symbol("@unsafe") in names(BorrowChecker) - @test Symbol("@safe") in names(BorrowChecker) - @test Symbol("@unsafe") in names(BorrowChecker) - - # Regression test: `@unsafe` should be valid at module top-level (no `local` binding). - @test (@eval BorrowChecker.@unsafe begin - 1 + 2 - end) == 3 - - @test_deprecated macroexpand( - @__MODULE__, :(BorrowChecker.@auto function _bc_depwarn_auto() - return 1 - end) - ) - - BorrowChecker.@safe function _bc_safe_violation_should_error() - x = [1, 2, 3] - y = x - push!(x, 1) - return y - end - - @test_throws BorrowChecker.BorrowCheckError _bc_safe_violation_should_error() - - BorrowChecker.@safe function _bc_safe_with_unsafe_should_pass() - x = [1, 2, 3] - y = x - @unsafe begin - push!(x, 1) - end - return y - end - - @test _bc_safe_with_unsafe_should_pass() == [1, 2, 3, 1] - - BorrowChecker.@safe function _bc_safe_with_unsafe_inner_should_pass() - x = [1, 2, 3] - y = x - @unsafe begin - inner() = (push!(x, 1); y) - inner() - end - end - - @test _bc_safe_with_unsafe_inner_should_pass() == [1, 2, 3, 1] - - let unsafe_call = macroexpand(@__MODULE__, :(BorrowChecker.@unsafe begin - push!(x, 1) - end)) - @test occursin("borrow_checker_unsafe", sprint(show, unsafe_call)) - @eval BorrowChecker.@safe function _bc_safe_with_preexpanded_unsafe_should_pass() - x = [1, 2, 3] - y = x - $unsafe_call - return y - end - end - - @test _bc_safe_with_preexpanded_unsafe_should_pass() == [1, 2, 3, 1] - - @eval BorrowChecker.@safe function _bc_bare_meta_unsafe_whole_method_should_pass() - $(Expr(:meta, :borrow_checker_unsafe)) - x = [1, 2, 3] - y = x - push!(x, 1) # would normally be a borrow-check violation - return y - end - - @test _bc_bare_meta_unsafe_whole_method_should_pass() == [1, 2, 3, 1] - - module _BCUnsafeDisabled - using BorrowChecker - - BorrowChecker.disable_by_default!(@__MODULE__) - - const expanded = macroexpand(@__MODULE__, :(BorrowChecker.@unsafe begin - 1 - end)) - - BorrowChecker.@safe function f() - x = [1, 2, 3] - y = x - @unsafe begin - push!(x, 1) - end - return y - end - end - - @test !occursin("borrow_checker_unsafe", sprint(show, _BCUnsafeDisabled.expanded)) - @test _BCUnsafeDisabled.f() == [1, 2, 3, 1] - - BorrowChecker.@safe function _bc_unsafe_line_mask_demo() - x = [1, 2, 3] - y = x - #! format: off - @unsafe begin push!(x, 1) end; push!(x, 2) # shares a source line with the unsafe block - #! format: on - return y - end - - @test_throws BorrowChecker.BorrowCheckError _bc_unsafe_line_mask_demo() -end - -@testitem "More complex unsafe branches" tags = [:auto] begin - using Test - using BorrowChecker - using BorrowChecker: BorrowCheckError - - @safe function add_halves!(a::Vector) - n = length(a) ÷ 2 - @unsafe begin - left = @view a[1:n] - right = @view a[(n + 1):(2n)] - left .+= right - end - return a - end - - @test add_halves!([1, 2, 3, 4, 5, 6])[1:3] == [5, 7, 9] - - @safe function add_halves_bad!(a::Vector) - n = length(a) ÷ 2 - begin - left = @view a[1:n] - right = @view a[(n + 1):(2n)] - left .+= right - end - return a - end - - @test_throws BorrowCheckError add_halves_bad!([1, 2, 3, 4, 5, 6]) - - @safe function _bc_unsafe_within_tuple() - x = [1, 2, 3] - y = x - ((@unsafe push!(x, 1)), push!(x, 2)) - return y - end - @test_throws BorrowCheckError _bc_unsafe_within_tuple() - - @safe function _bc_unsafe_within_tuple_2() - x = [1, 2, 3] - y = x - ((@unsafe push!(x, 1)), (@unsafe push!(x, 2))) - return y - end - @test _bc_unsafe_within_tuple_2() == [1, 2, 3, 1, 2] -end diff --git a/test/dynamic_expressions_integration_tests.jl b/test/dynamic_expressions_integration_tests.jl deleted file mode 100644 index b8ed857..0000000 --- a/test/dynamic_expressions_integration_tests.jl +++ /dev/null @@ -1,36 +0,0 @@ -@testitem "DynamicExpressions integration" tags = [:auto] begin - using TestItems - using BorrowChecker - - @static if isdefined(BorrowChecker, :BorrowCheckError) - # This integration test exercises the experimental IR borrow checker on a - # real external package type (DynamicExpressions.Expression). - - using DynamicExpressions - using BorrowChecker: BorrowCheckError - - operators = OperatorEnum(1 => [exp], 2 => [+, -, *]) - x1 = Expression(Node{Float64}(; feature=1); operators) - x2 = Expression(Node{Float64}(; feature=2); operators) - - BorrowChecker.@safe bat(ex) = begin - (c1, r1) = get_scalar_constants(ex) - ex2 = ex - set_scalar_constants!(ex, c1 .* 2, r1) - ex2 - end - - @test_throws BorrowCheckError bat(x1 + x2 * 3.2) - - # MWE: `copy(::Expression)` currently triggers a spurious "consume" violation when - # analyzed under `@safe` (likely via the compiler-generated keyword wrapper). - # This should not be a move/escape: `copy` is expected to produce a fresh object. - BorrowChecker.@safe bc_copy_ok(ex) = copy(ex) - @test_broken try - bc_copy_ok(x1) - true - catch e - !(e isa BorrowCheckError) - end - end -end diff --git a/test/runtests.jl b/test/runtests.jl deleted file mode 100644 index 7ce7406..0000000 --- a/test/runtests.jl +++ /dev/null @@ -1,46 +0,0 @@ -using TestItems -using TestItemRunner -using BorrowChecker - -include("auto_borrow_checker_tests.jl") -include("auto_llvm_tests.jl") -include("auto_printing_tests.jl") -include("auto_hygiene_integration_tests.jl") -include("dynamic_expressions_integration_tests.jl") -include("auto_unsafe_api_tests.jl") - -@static if VERSION < v"1.14.0-" - @testitem "Aqua" begin - using Aqua - - Aqua.test_all(BorrowChecker) - end -end - -@testitem "JET tests" begin - if VERSION >= v"1.10.0" && VERSION < v"1.13.0-DEV.0" - test_jet_file = joinpath((@__DIR__), "test_jet.jl") - run(`$(Base.julia_cmd()) --startup-file=no $test_jet_file`) - end -end - -const testitem_name_filter = get(ENV, "BORROWCHECKER_TESTITEM", "") -const only_auto = lowercase(get(ENV, "BORROWCHECKER_ONLY_AUTO", "")) in ("1", "true", "yes") -const auto_supported = - VERSION >= v"1.12.0-" && VERSION < v"1.13.0-" && isdefined(Base, :code_ircode_by_type) - -if only_auto && !auto_supported - error("BORROWCHECKER_ONLY_AUTO requires Julia 1.12.x with Base.code_ircode_by_type (unsupported on 1.13+)") -end - -filter = if !isempty(testitem_name_filter) - ti -> ti.name == testitem_name_filter && (auto_supported || !(:auto in ti.tags)) -elseif only_auto - ti -> :auto in ti.tags -elseif !auto_supported - ti -> !(:auto in ti.tags) -else - nothing -end - -@run_package_tests filter = filter diff --git a/test/test_jet.jl b/test/test_jet.jl deleted file mode 100644 index 11a9ce5..0000000 --- a/test/test_jet.jl +++ /dev/null @@ -1,24 +0,0 @@ -using Pkg -@info "Creating environment..." -dir = mktempdir() -Pkg.activate(dir; io=devnull) -Pkg.develop(; path=dirname(@__DIR__), io=devnull) -Pkg.add(["JET", "Preferences"]; io=devnull) -@info "Done!" - -using Preferences - -cd(dir) - -Preferences.set_preferences!( - "BorrowChecker", "dispatch_doctor_mode" => "disable"; force=true -) - -using BorrowChecker -using JET - -@info "Running tests..." -JET.test_package(BorrowChecker; target_modules=(BorrowChecker,)) -@info "Done!" - -@info "test_jet.jl finished" From b88b41b2adb671ad1586132157449203bb5cf222 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sun, 23 Aug 2026 14:19:04 +0100 Subject: [PATCH 2/3] no-op: trigger CI From 93f276db48fac7225133bd366d4e54b296fbd594 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sun, 23 Aug 2026 14:48:46 +0100 Subject: [PATCH 3/3] rebase: content absorbed upstream (see #88) --- .JuliaFormatter.toml | 2 + .github/dependabot.yml | 7 + .github/workflows/CI.yml | 134 ++ .github/workflows/CompatHelper.yml | 16 + .github/workflows/release-please.yml | 59 + .gitignore | 9 + .release-please-manifest.json | 3 + CHANGELOG.md | 515 +++++ LICENSE | 201 ++ Project.toml | 15 + README.md | 340 +++ coverage.jl | 20 + docs/Project.toml | 2 + docs/make.jl | 55 + docs/src/api.md | 19 + docs/src/auto.md | 100 + release-please-config.json | 11 + scripts/collect_broken_cases.jl | 592 ++++++ src/BorrowChecker.jl | 56 + src/preferences.jl | 88 + src/safe/alias.jl | 360 ++++ src/safe/auto_ir.jl | 19 + src/safe/callsite.jl | 519 +++++ src/safe/checker.jl | 529 +++++ src/safe/debug.jl | 336 +++ src/safe/defs.jl | 295 +++ src/safe/diagnostics.jl | 379 ++++ src/safe/frontend.jl | 780 +++++++ src/safe/generated.jl | 114 + src/safe/ir_primitives.jl | 667 ++++++ src/safe/refine_types.jl | 402 ++++ src/safe/summaries.jl | 939 +++++++++ src/safe/utils.jl | 22 + test/FakeModule/LocalPreferences.toml | 2 + test/FakeModule/Project.toml | 8 + test/FakeModule/src/FakeModule.jl | 64 + test/Project.toml | 14 + test/auto_borrow_checker_tests.jl | 1874 +++++++++++++++++ test/auto_hygiene_integration_tests.jl | 48 + test/auto_llvm_ir_tests.jl | 85 + test/auto_llvm_tests.jl | 8 + test/auto_printing_tests.jl | 313 +++ test/auto_unsafe_api_tests.jl | 154 ++ test/dynamic_expressions_integration_tests.jl | 36 + test/runtests.jl | 46 + test/test_jet.jl | 24 + 46 files changed, 10281 insertions(+) create mode 100644 .JuliaFormatter.toml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/CI.yml create mode 100644 .github/workflows/CompatHelper.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Project.toml create mode 100644 README.md create mode 100644 coverage.jl create mode 100644 docs/Project.toml create mode 100644 docs/make.jl create mode 100644 docs/src/api.md create mode 100644 docs/src/auto.md create mode 100644 release-please-config.json create mode 100644 scripts/collect_broken_cases.jl create mode 100644 src/BorrowChecker.jl create mode 100644 src/preferences.jl create mode 100644 src/safe/alias.jl create mode 100644 src/safe/auto_ir.jl create mode 100644 src/safe/callsite.jl create mode 100644 src/safe/checker.jl create mode 100644 src/safe/debug.jl create mode 100644 src/safe/defs.jl create mode 100644 src/safe/diagnostics.jl create mode 100644 src/safe/frontend.jl create mode 100644 src/safe/generated.jl create mode 100644 src/safe/ir_primitives.jl create mode 100644 src/safe/refine_types.jl create mode 100644 src/safe/summaries.jl create mode 100644 src/safe/utils.jl create mode 100644 test/FakeModule/LocalPreferences.toml create mode 100644 test/FakeModule/Project.toml create mode 100644 test/FakeModule/src/FakeModule.jl create mode 100644 test/Project.toml create mode 100644 test/auto_borrow_checker_tests.jl create mode 100644 test/auto_hygiene_integration_tests.jl create mode 100644 test/auto_llvm_ir_tests.jl create mode 100644 test/auto_llvm_tests.jl create mode 100644 test/auto_printing_tests.jl create mode 100644 test/auto_unsafe_api_tests.jl create mode 100644 test/dynamic_expressions_integration_tests.jl create mode 100644 test/runtests.jl create mode 100644 test/test_jet.jl diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml new file mode 100644 index 0000000..d808d22 --- /dev/null +++ b/.JuliaFormatter.toml @@ -0,0 +1,2 @@ +# See https://domluna.github.io/JuliaFormatter.jl/stable/ for a list of options +style = "blue" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..700707c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..5565437 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,134 @@ +name: CI +on: + push: + branches: + - main + tags: ['*'] + pull_request: + workflow_dispatch: +concurrency: + # Skip intermediate builds: always. + # Cancel intermediate builds: only if it is a pull request build. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} +jobs: + test: + name: jl${{ matrix.version }}-${{ matrix.os }}-${{ matrix.arch }}-dd=${{ matrix.dispatch_doctor }}-${{ github.event_name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created + actions: write + contents: read + strategy: + fail-fast: false + matrix: + version: + - '1.10' + - '1' + os: + - ubuntu-latest + arch: + - x64 + dispatch_doctor: + - 'disabled' + include: + - version: '1' + os: ubuntu-latest + arch: x64 + dispatch_doctor: 'enabled' + steps: + - uses: actions/checkout@v6 + - name: "Disable DispatchDoctor if needed" + if: ${{ matrix.dispatch_doctor == 'disabled' }} + run: sed -i 's/dispatch_doctor_mode = "error"/dispatch_doctor_mode = "disable"/' test/Project.toml + shell: bash + - uses: julia-actions/setup-julia@v3 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v3 + - uses: julia-actions/julia-buildpkg@v1 + - name: "Run tests + coverage" + run: | + julia --color=yes -e 'import Pkg; Pkg.add("Coverage")' + julia --color=yes --threads=auto --check-bounds=yes --depwarn=yes --code-coverage=user -e 'import Coverage; import Pkg; Pkg.activate("."); Pkg.test(coverage=true)' + julia --color=yes coverage.jl + shell: bash + - name: Upload coverage artifact + uses: actions/upload-artifact@v7 + with: + name: coverage-${{ matrix.version }}-${{ matrix.os }}-${{ matrix.arch }}-dd-${{ matrix.dispatch_doctor }} + path: lcov.info + + codecov: + name: Codecov upload + runs-on: ubuntu-latest + needs: + - test + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v6 + - name: Download all coverage artifacts + uses: actions/download-artifact@v8 + with: + pattern: coverage-* + path: coverage + - uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./coverage + fail_ci_if_error: true + verbose: true + + nightly: + name: nightly-ubuntu-x64 + runs-on: ubuntu-latest + # Use an explicit `timeout` around the test command so that if nightly runs long, + # the job fails (which we tolerate) rather than the whole workflow being cancelled. + timeout-minutes: 70 + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: julia-actions/setup-julia@v3 + with: + version: 'nightly' + arch: x64 + - uses: julia-actions/cache@v3 + - uses: julia-actions/julia-buildpkg@v1 + - name: "Run tests (nightly)" + run: | + timeout 60m julia --color=yes --threads=auto --check-bounds=yes --depwarn=yes -e ' + import Pkg + Pkg.activate(".") + Pkg.test()' + shell: bash + + docs: + name: Documentation + runs-on: ubuntu-latest + needs: test + permissions: + contents: write + statuses: write + steps: + - uses: actions/checkout@v6 + - uses: julia-actions/setup-julia@v3 + with: + version: '1' + - name: Configure doc environment + run: | + julia --project=docs/ -e ' + using Pkg + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate()' + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-docdeploy@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCUMENTER_KEY_CAM: ${{ secrets.DAMTP_DEPLOY_KEY }} + - run: | + julia --project=docs -e ' + using Documenter: DocMeta, doctest + using BorrowChecker + DocMeta.setdocmeta!(BorrowChecker, :DocTestSetup, :(using BorrowChecker); recursive=true) + doctest(BorrowChecker)' diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml new file mode 100644 index 0000000..cba9134 --- /dev/null +++ b/.github/workflows/CompatHelper.yml @@ -0,0 +1,16 @@ +name: CompatHelper +on: + schedule: + - cron: 0 0 * * * + workflow_dispatch: +jobs: + CompatHelper: + runs-on: ubuntu-latest + steps: + - name: Pkg.add("CompatHelper") + run: julia -e 'using Pkg; Pkg.add("CompatHelper")' + - name: CompatHelper.main() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} + run: julia -e 'using CompatHelper; CompatHelper.main()' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..b704925 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,59 @@ +name: release-please + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/checkout@v6 + with: + repository: MilesCranmerBot/release-please + ref: 911ad18bdc3bb52bfef564cfbcb35f31aac01df3 + path: release-please-src + + - uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install release-please deps + working-directory: release-please-src + run: npm ci + + - name: Build release-please + working-directory: release-please-src + run: npm run compile + + - name: Create or update release PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node release-please-src/build/src/bin/release-please.js release-pr \ + --token "$GITHUB_TOKEN" \ + --repo-url "https://github.com/${{ github.repository }}" \ + --target-branch main \ + --config-file release-please-config.json \ + --manifest-file .release-please-manifest.json + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node release-please-src/build/src/bin/release-please.js github-release \ + --token "$GITHUB_TOKEN" \ + --repo-url "https://github.com/${{ github.repository }}" \ + --target-branch main \ + --config-file release-please-config.json \ + --manifest-file .release-please-manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ce8b9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.jl.*.cov +*.jl.cov +*.jl.mem +**/Manifest*.toml +target +Cargo.* +**/*.rs +**/tmp.jl +docs/src/index.md diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..8579006 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.4.6" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..217fb4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,515 @@ +# Changelog + +## [0.4.6](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.5...v0.4.6) (2026-06-10) + + +### Bug Fixes + +* handle PhiCNode liveness parity ([#72](https://github.com/MilesCranmer/BorrowChecker.jl/issues/72)) ([31058c3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/31058c3c20a627d38876f6a0fc0cf4ef4689fd94)) +* include root module in checked cache key ([#73](https://github.com/MilesCranmer/BorrowChecker.jl/issues/73)) ([f89f9e1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f89f9e18c8abdc403d95b84f23a822ef03000dda)) +* preserve aliases from memoryrefget ([#74](https://github.com/MilesCranmer/BorrowChecker.jl/issues/74)) ([010c5af](https://github.com/MilesCranmer/BorrowChecker.jl/commit/010c5affc05cb3b04daed5d732da686358ddacd6)) + +## [0.4.5](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.4...v0.4.5) (2026-03-23) + + +### Bug Fixes + +* handle missing LineNumberNode file/line ([#58](https://github.com/MilesCranmer/BorrowChecker.jl/issues/58)) ([855c156](https://github.com/MilesCranmer/BorrowChecker.jl/commit/855c1562ce7af4e1e1660ee81bbeb7912333833d)) + +## [0.4.4](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.3...v0.4.4) (2026-01-26) + + +### Bug Fixes + +* issue [#49](https://github.com/MilesCranmer/BorrowChecker.jl/issues/49) ([0e8bbeb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0e8bbebdc993c4a909478027bdcb59ac82506ee6)) +* issue [#49](https://github.com/MilesCranmer/BorrowChecker.jl/issues/49) ([9ae3e90](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9ae3e90017be72e1a7ef6531f159c8cfe28578c9)) + +## [0.4.3](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.2...v0.4.3) (2026-01-25) + + +### Bug Fixes + +* much cleaner treatment of unsafe ([5f7234c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5f7234c4499ee6e91298a1bbe518d9850f59572d)) +* poorly masked unsafe blocks ([4234dec](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4234decb679c5f38f91be33f12c85ddbb511e913)) +* poorly masked unsafe blocks ([0240a1c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0240a1cabe6bd038357ca901dd0962c8c4880af3)) +* safer bindings ([eedddfa](https://github.com/MilesCranmer/BorrowChecker.jl/commit/eedddfa66809e1de951d47b84069c46b82c0ef3d)) + +## [0.4.2](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.1...v0.4.2) (2026-01-24) + + +### Features + +* create `[@unsafe](https://github.com/unsafe)` macro and matching `[@safe](https://github.com/safe)` macro ([cea4efb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cea4efb58f2d27b609e8ee8dc96355c50c48c311)) +* rename other `[@auto](https://github.com/auto)` to `[@safe](https://github.com/safe)` ([244137f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/244137f332aee20b36a563aba8f3cd9eb3bc7aa6)) + + +### Bug Fixes + +* unsafe operation for semicolons ([10e5303](https://github.com/MilesCranmer/BorrowChecker.jl/commit/10e5303a3d626b3bf8de694fd67cd962f9d31999)) + +## [0.4.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.4.0...v0.4.1) (2026-01-20) + + +### Features + +* create debug system ([49ab087](https://github.com/MilesCranmer/BorrowChecker.jl/commit/49ab087c1e6f1460fc521ca78abf26201a382901)) + + +### Bug Fixes + +* additional edge cases ([cbc1de6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cbc1de60287dacf71143a70438e1a50afa1ad1a6)) +* additional edge cases ([ceab23f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ceab23f16ef66e636c84a6f9ab15c058592e6ef2)) +* eliminate assumptions about Base methods ([a62ff44](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a62ff44e77981f5df66b64bb5be086f646594fab)) +* ignore `Core` for `:user` scope ([e217d69](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e217d69abe0dfeea5a0418f919daf36fcc030fad)) +* ignore `Core` for `:user` scope ([a4c2b06](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a4c2b067c27ec0513d9e390d01fab5493ea6a33e)) +* JET identified error ([3811b9a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3811b9a798f5acf7598ec8e5cd211fd8f6cf97b8)) +* more failure cases ([5d45ee2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5d45ee24d3a55add1db2d963599951c7ff5b4a15)) +* special-case Tasks ([adac867](https://github.com/MilesCranmer/BorrowChecker.jl/commit/adac867539b8edc6f945c73827724bb00619349c)) +* special-case Tasks ([a2d656f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a2d656f64daefaf45c7bee5a17329db94b719f31)) +* work around a variety of edge cases ([c96ef7a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c96ef7aa4ed1e04d16be76364f38bf3d64bfce24)) + +## [0.4.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.3.1...v0.4.0) (2026-01-16) + + +### Features + +* better handling of foreigncall ([e457204](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e457204f2f86b981062978406d2a39e952c53156)) +* handle a subset of foreigncall effects ([5852b81](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5852b818e7200e517d9ec2e58d0f9626dd4fc6f1)) +* handle a subset of foreigncall effects ([cd467c2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cd467c229edc560c0b3f21e5414f0ef462da23e0)) +* permit recursive borrow checking ([4878584](https://github.com/MilesCranmer/BorrowChecker.jl/commit/48785847b64a004656d482025e0084cfd8b2098a)) + + +### Bug Fixes + +* add missing `Core.isa` ([4126463](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4126463e192699942ecc175a39368e4bccd19b6c)) +* add missing BoundsError ([1ab5ca5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1ab5ca5dd069df4b8a340eea6104dd505799a860)) +* behavior for module scoping and add test ([dc2b5a3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/dc2b5a3c3c7a28cbf056eaf6cd07e6e04b4f615a)) +* handle PhiCNode ([9e9aa4e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9e9aa4ec6d9afacd2cb56c47eaf492fd90e2600f)) +* incorrect return from generated ([89008fd](https://github.com/MilesCranmer/BorrowChecker.jl/commit/89008fd2848826dd8b0b31de98a78249edb70ec5)) +* optimization pass normalization ([5c86b33](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5c86b337820246047a7fde627962ce3c7e3c4b9f)) +* register Typeof ([7715a5c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7715a5cabe93ab754f11e9159064dfeface82174)) + +## [0.3.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.3.0...v0.3.1) (2026-01-14) + + +### Features + +* add simple tracking for Ptr objects ([4fb8391](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4fb839132dfc011ca9e6618d2c43e5480acd730c)) +* handle pointers better ([b2fa53b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b2fa53b000aeff58b481b52f05a8eb1b9af74d1d)) +* much faster caching ([71b7e96](https://github.com/MilesCranmer/BorrowChecker.jl/commit/71b7e96d4064f06c755001b54c6c386e4ac9704c)) + + +### Bug Fixes + +* returning duplicate tuples ([a9d7955](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a9d795517887ee6d7cffd76204616194fc3ff4b4)) + +## [0.3.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.2.1...v0.3.0) (2026-01-14) + + +### Features + +* better debug info ([e50152a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e50152a62135f3bf57e7d04bc00431322229144a)) +* cover additional cases ([0d30050](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0d300506518e760237d6e69ba756701dd202a376)) +* create IR borrowchecker ([90d5ea2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/90d5ea21fcc8f37137198c4d4467e70366456b08)) +* detecting borrow check violations recursively ([d0bd6af](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d0bd6af1dce3f2e1cc5d3ff53a06bc8f3ce1438a)) +* greatly accelerate analysis with local cache ([d421da0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d421da0ba1f8ac2868add3b356b5b904357443db)) +* handle file changes ([b1a5cb1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b1a5cb15d303a357585c1010c0ebe8c820fc424b)) +* handle more edgecases ([96bfdfd](https://github.com/MilesCranmer/BorrowChecker.jl/commit/96bfdfdc0acf56c9b159cef623135e8c527fe7c5)) +* more general version of kwcall ([9574332](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9574332f50cb012fafe5097dc3255d3481441f7d)) +* no need for wrapping blocks ([d90fc7c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d90fc7ccbd9b663f8d6760158f463dd7e1755294)) +* some closure compatibility ([1893534](https://github.com/MilesCranmer/BorrowChecker.jl/commit/18935348d5d36ccc15fb3e1baec1cc992c50b421)) +* track consumed values better ([3c928fb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3c928fb2683c657e3cfa52dba80151eb8167eda4)) +* try to improve printing ([832f656](https://github.com/MilesCranmer/BorrowChecker.jl/commit/832f656179c7fa244680058bad8aac92aa6bdb0e)) + + +### Bug Fixes + +* aliasing through kwcall ([6075967](https://github.com/MilesCranmer/BorrowChecker.jl/commit/607596786257a0adb4960f6e6d91e18aa4e95b33)) +* behavior for nested closures ([0b6a3dc](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0b6a3dc1955eb24a9b3d9a3bb403bce92df9ecbe)) +* behavior for some Ptr operations ([74e2ac0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/74e2ac076bb43f01f24a7cd406eb9359c77cb2b6)) +* caching of files ([4377014](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4377014912939e78725f935b36a948162790b169)) +* dont assume ! means anything ([549eee3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/549eee3caca93e89989c3a382f3c439ce5913e19)) +* foreigncall bug ([1c2637e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1c2637e4e98bc80461b6c440c43e922292387773)) +* handle some kw alias detection ([d455ac5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d455ac5f6162a5f7d47bce96d0bd639d8d5d5649)) +* inference barrier effects ([42f33dc](https://github.com/MilesCranmer/BorrowChecker.jl/commit/42f33dc7216bd4cf6ae5a07a49616272ce50bab1)) +* non-determinism of caching limit based on depth ([6b23311](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6b23311d143cce7c3a9234457133c6cd384c17d7)) +* only define core IR methods ([cb9a308](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cb9a308fd2aced343bb3ee3dd38d7a04f28da24a)) +* only enable automatic checks on valid julia ([741054e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/741054e52f06887786ba3f7a59860c6a27f8a133)) +* prevent cache cycles ([50d4629](https://github.com/MilesCranmer/BorrowChecker.jl/commit/50d4629fd6f91e256800a8bbf2fd76cc08286c60)) +* printing on nightly ([d5983e1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d5983e13b636df74a8584812484e64fdd22f2067)) +* repl printing ([74b2737](https://github.com/MilesCranmer/BorrowChecker.jl/commit/74b2737eb75c8c7c1ebdb00ccfd8525bc2c4c7af)) +* some printing issues in ir ([e3b9cff](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e3b9cff90828b541e776f9af2cf71b0feec0ac08)) +* use compact 1 on 1.12 ([56cdcdf](https://github.com/MilesCranmer/BorrowChecker.jl/commit/56cdcdff9a7b779dab53a6efaa95905690bd1f32)) + +## [0.2.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.2.0...v0.2.1) (2025-04-27) + + +### Features + +* add additional numerics overloads ([b8a6607](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b8a66077e5ecdda093f390cb16e52e8364538f00)) + + +### Bug Fixes + +* nested property writes in LazyAccessor ([f0787ec](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f0787ec31dc3c95072bb1de889fc64f3cf9e0ef4)) + +## [0.2.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.5...v0.2.0) (2025-04-27) + + +### ⚠ BREAKING CHANGES + +* make `@bc` default to immutable + +### Features + +* allow immutable borrow of mutable borrow ([009986d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/009986df16767afcbd75b7e7435f5f4b19af2b50)) +* allow immutable borrow of mutable borrow ([5b5f63c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5b5f63c07aaa84e53f9b671dff15614f493dd759)) +* make `[@bc](https://github.com/bc)` default to immutable ([b88c107](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b88c107be8ab0bd8895464e7969e3081bdbbe837)) + + +### Bug Fixes + +* forwarding of `randn` ([c9ed13a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c9ed13a628c9750969893eaabe58126d15e237a9)) +* forwarding of `randn` ([fb2eb29](https://github.com/MilesCranmer/BorrowChecker.jl/commit/fb2eb294931821f5c1d959c8303e58b58d9bc278)) + +## [0.1.5](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.4...v0.1.5) (2025-04-26) + + +### Bug Fixes + +* `@&` when wrapping type parameters ([fbdd908](https://github.com/MilesCranmer/BorrowChecker.jl/commit/fbdd908adea2c7b0e4441fa15e88cb0933488c8e)) +* `@&` when wrapping type parameters ([ebc1c60](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ebc1c607295ac0ef36a56f089f842dc5a40cdc30)) + +## [0.1.4](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.3...v0.1.4) (2025-04-25) + + +### Features + +* add basic broadcasting compatibility ([1a16166](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1a1616600c15eebe9c3c7d6d62ac60db99761050)) +* add basic broadcasting compatibility ([d8c15c5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d8c15c5db7038207505e97183d8b09468a0d6d77)) +* block wrapper objects from being captured ([4ba37e6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4ba37e60c53ceaf41e1a4f3470c408ec96a23011)) +* create `@&` macro for borrowed types ([f6cc68d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f6cc68dd0cd176d52574257593a9be9271ac016b)) +* create `Mutex` object for safe mutable references ([0681a74](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0681a749b6bbf51933ce6c351ab77323edf698f2)) +* create new `@&` shorthand ([4eac033](https://github.com/MilesCranmer/BorrowChecker.jl/commit/4eac03364cbeb415d1852dbd3ab5de7d229d577b)) +* more locking API ([69795d8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/69795d85aa9bb679a0e5960b56f5255361d3b8b5)) +* more overloads of types ([f615bde](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f615bde2c80bf04176ef51a5904826026ab1503c)) +* more streamlined mutex interface ([bd02443](https://github.com/MilesCranmer/BorrowChecker.jl/commit/bd02443a431bddb080b54f759db2118a1fd8a9dd)) + +## [0.1.3](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.2...v0.1.3) (2025-04-13) + + +### Features + +* create `[@spawn](https://github.com/spawn)` macro for wrapped `Threads.[@spawn](https://github.com/spawn)` ([2857f83](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2857f833d967506ddba980ea78919621d533eb38)) +* draft `[@cc](https://github.com/cc)` macro for checking closures ([7e1d4a1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7e1d4a1688101b27a59cdd018b9eb49308376dd6)) +* overload `reshape` ([29d9bf4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/29d9bf4d1af470fab2e4a0de70ec3854b7cf3b2c)) +* overload `reshape` ([da394ef](https://github.com/MilesCranmer/BorrowChecker.jl/commit/da394efa62c76f36530f886f7ed3e77cdddfe134)) + + +### Bug Fixes + +* avoid expression parsing, use dynamic approach ([2253208](https://github.com/MilesCranmer/BorrowChecker.jl/commit/225320836dac93ffddd8d0d8be4706ef485f2a8f)) + +## [0.1.2](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.1...v0.1.2) (2025-04-12) + + +### Features + +* overload `adjoint` and `transpose` ([0c1e912](https://github.com/MilesCranmer/BorrowChecker.jl/commit/0c1e9120d49c277be462261d26a4569bdc00fb50)) +* overload `adjoint` and `transpose` ([91bf818](https://github.com/MilesCranmer/BorrowChecker.jl/commit/91bf8183367ad51a76e84b58ca8b901cd0b5fd7d)) + +## [0.1.1](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.1.0...v0.1.1) (2025-04-11) + + +### Features + +* more collection overloads ([11820f0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/11820f06a4476313c48780cf26ca8f672b31eae7)) + +## [0.1.0](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.13...v0.1.0) (2025-04-10) + + +### Features + +* add `shuffle!(rng, ...)` overload ([22dc377](https://github.com/MilesCranmer/BorrowChecker.jl/commit/22dc3771c1c16208b43fa574cad34ee3434be9b7)) +* more overloads ([d2a4294](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d2a42946b4dc5dbdaeb48e70f399ac3011effa2d)) + +## [0.0.13](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.12...v0.0.13) (2025-04-09) + + +### Features + +* add `isless` to operators ([30cb625](https://github.com/MilesCranmer/BorrowChecker.jl/commit/30cb625e6c7baa61cb8916ba9d18197175da0fb6)) +* add `shuffle!` overload ([f409a6c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f409a6cadffa08696679d757be148c1bb80e2413)) +* have `[@bc](https://github.com/bc)` pass through static values ([573e089](https://github.com/MilesCranmer/BorrowChecker.jl/commit/573e08905932aec215a3fea69628cd9dc2ebbccb)) +* make `[@bc](https://github.com/bc)` work for shorthand kwargs ([90967c2](https://github.com/MilesCranmer/BorrowChecker.jl/commit/90967c246531aa5ec4a064646a9e67def37a0263)) + +## [0.0.12](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.11...v0.0.12) (2025-04-08) + + +### ⚠ BREAKING CHANGES + +* remove experimental managed feature + +### Features + +* add safe `Base.copy` ([a03ed93](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a03ed93d92fb1fec88a166e1fdd6e6684b9a2640)) +* better error messages ([d13679b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d13679b29750a36a59afb04cf43014a24ac8320b)) +* better errors for mixed tuples in ref ([20cafc8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/20cafc8b5e511c1fe8538499815a55ed4b79c2df)) +* more operators for Number ([acc5829](https://github.com/MilesCranmer/BorrowChecker.jl/commit/acc5829cdf6033ba14ee9630f83ed07c16469ae8)) +* remove experimental managed feature ([86832d9](https://github.com/MilesCranmer/BorrowChecker.jl/commit/86832d9e65a92aa441c930f535c2fabc411d3afe)) + + +### Bug Fixes + +* ensure deepcopy inside `copy!` ([3b8ad27](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3b8ad2710eca5652e2a77eef42fd1ae53073b3e5)) +* some ambiguities ([50fbd22](https://github.com/MilesCranmer/BorrowChecker.jl/commit/50fbd223d86647fccc8924c0e4553758a9e2be0e)) + +## [0.0.11](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.10...v0.0.11) (2025-01-20) + + +### ⚠ BREAKING CHANGES + +* remove `@set` syntax + +### Features + +* remove `[@set](https://github.com/set)` syntax ([59f9b81](https://github.com/MilesCranmer/BorrowChecker.jl/commit/59f9b810804f3160add7bf0e27bd89adba73c85e)) + +## [0.0.10](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.9...v0.0.10) (2025-01-20) + + +### Features + +* add `Module` to `is_static = true` category ([a71ea45](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a71ea4550ff58f72fc8e8f7651ae8d9a37c6de1e)) +* allow `[@own](https://github.com/own)` on nested for loops ([b0c1412](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b0c1412846e441fefeecccfe2cc03fd3459d8806)) +* make String is_static ([aac4c5d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/aac4c5d903eb08317bf7d086ce63afc9804f8ca4)), closes [#4](https://github.com/MilesCranmer/BorrowChecker.jl/issues/4) + + +### Bug Fixes + +* cache collision with default UUID ([8bc21d6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/8bc21d6f496bca20191178fe6cb5d43eb50abfaa)) + +## [0.0.9](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.8...v0.0.9) (2025-01-19) + + +### Features + +* allow tuple assignment for `[@ref](https://github.com/ref)` ([3e7f0fb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3e7f0fb3414ea89482ecf9c7865afb4248ea9f26)) +* enable single-arg `[@own](https://github.com/own) x` macro ([6f7ae59](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6f7ae592220625e51d43a7b38721ce77e6c35bd2)) +* more overloads ([405746f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/405746f7e3592a178926700d0724e172566b1a71)) + +## [0.0.8](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.7...v0.0.8) (2025-01-18) + + +### ⚠ BREAKING CHANGES + +* change `disable_borrow_checker!` to `disable_by_default!` + +### Features + +* change `disable_borrow_checker!` to `disable_by_default!` ([b2062e5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b2062e5aab55410154fd3efdcede351d4fc60a54)) + +## [0.0.7](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.6...v0.0.7) (2025-01-18) + + +### ⚠ BREAKING CHANGES + +* remove unneccessary promote_rule +* mark moved through lazy access in `@managed` context +* `@managed` maps keywords +* get `empty!` and `resize!` to not return vector +* more `nothing` returns +* change `@ref` syntax to use `~lt` instead of `lt` +* fix incorrect version increment + +### deps + +* fix incorrect version increment ([b9024eb](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b9024eb54dde0e17ed15bc6abc68c845e3161406)) + + +### Features + +* `[@managed](https://github.com/managed)` maps keywords ([f97d437](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f97d437ca2e72bd22423d8b63b8f1f7ff89f08fa)) +* change `[@ref](https://github.com/ref)` syntax to use `~lt` instead of `lt` ([5a6a011](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5a6a011a3206ee25a7fe60decb5e67f6ca085f74)) +* correct `hash` definition ([5fbb747](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5fbb747117bd6e939b852968bbd307c75242b8fd)) +* mark moved through lazy access in `[@managed](https://github.com/managed)` context ([479fb9b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/479fb9b4bf1d4f870b85517d531e2da8bef52243)) +* prevent capturing lazy accessor of owned variables ([6de885f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6de885fd5e498a562c4da1b581fbb41f1bb78292)) + + +### Bug Fixes + +* get `empty!` and `resize!` to not return vector ([965e088](https://github.com/MilesCranmer/BorrowChecker.jl/commit/965e088adc9d42433b1231948346f7f04391fea1)) +* improved error message mentioning `[@ref](https://github.com/ref)` ([3e8e43c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3e8e43c04a082722ff227a422421cbf0d6189c0c)) +* more `nothing` returns ([080663b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/080663bae5e3a456082fc9ed062237d256ef8ea3)) +* property set on owned ([2613eff](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2613effcfc1e19b8a0e4798bd4edb577b1f308a2)) +* remove unneccessary promote_rule ([2064f15](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2064f15f7fd3ce5aa4379570eb94d9664884a3d5)) + +## [0.0.6](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.5...v0.0.6) (2025-01-16) + + +### ⚠ BREAKING CHANGES + +* move `@managed` to experimental submodule +* rename bind to own + +### Features + +* rename bind to own ([cbe3bf6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cbe3bf6e2f900396596363e7049f2e6e6a28fa0a)) + + +### Code Refactoring + +* move `[@managed](https://github.com/managed)` to experimental submodule ([256d5c0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/256d5c0d8288a9bf9f732b2d98f02946326f17ff)) + +## [0.0.5](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.4...v0.0.5) (2025-01-12) + + +### ⚠ BREAKING CHANGES + +* dont validate symbols for borrowed values +* avoid deepcopy on static when turned off + +### Features + +* avoid deepcopy on static when turned off ([e9fefa4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e9fefa4ad88ef8acd51e118a77f93e00255baed6)) +* dont validate symbols for borrowed values ([094ddce](https://github.com/MilesCranmer/BorrowChecker.jl/commit/094ddce1dafa03d97543c41c71f51cec0d1cf85f)) + +## [0.0.4](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.3...v0.0.4) (2025-01-12) + + +### ⚠ BREAKING CHANGES + +* expand definition of automatically copyable types + +### Features + +* add a couple fallback options ([85b2565](https://github.com/MilesCranmer/BorrowChecker.jl/commit/85b256575400d15134a38df27421b9cf58f1caac)) +* add abstract types ([feebb7d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/feebb7d89b50a79528452befa0f0a2fa4c57ca8a)) +* expand definition of automatically copyable types ([42e2cce](https://github.com/MilesCranmer/BorrowChecker.jl/commit/42e2ccefee5370f197d4e114c2188ed9f1bf0c7d)) +* extensions of LazyAccessorOf ([33eba5c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/33eba5cb9f026d3bf2e9d6d70e54117a3092f2b3)) +* flag captured bound variables in closures ([9db33a1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9db33a121620e4dab2a9a04a913a5ce9c4a043be)) +* various quality of life overloads ([cb73219](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cb732197bb2bd549b984c162d3d4f2c360dc5852)) + + +### Bug Fixes + +* view of LazyAccessor ([744ee47](https://github.com/MilesCranmer/BorrowChecker.jl/commit/744ee47a41881fc3b94306ee29d8df353cb8352e)) + +## [0.0.3](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.2...v0.0.3) (2025-01-12) + + +### ⚠ BREAKING CHANGES + +* change syntax `@take` -> `@take!` +* create `LazyAccessor` to allow subproperty mutation + +### Features + +* allow `[@bind](https://github.com/bind)` used like `[@move](https://github.com/move)` ([cd3ea50](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cd3ea506e83ff25f9a70c898e844f473bbf147ab)) +* allow `[@ref](https://github.com/ref)` for loops ([7779280](https://github.com/MilesCranmer/BorrowChecker.jl/commit/77792803a629414b95abee41ecc6639c5bf3530e)) +* allow tuple unpacking for `[@bind](https://github.com/bind)` ([266c7db](https://github.com/MilesCranmer/BorrowChecker.jl/commit/266c7dbca61d9df422fb4b9dcb3eb4a6c097f41f)) +* better errors for misuse ([7c5adde](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7c5adde726b6cd75291f002fc71f7048f6601e6f)) +* change syntax `[@take](https://github.com/take)` -> `[@take](https://github.com/take)!` ([3740550](https://github.com/MilesCranmer/BorrowChecker.jl/commit/37405508eb23d1c2ec0325fc7d188e27c6d71b4d)) +* create `LazyAccessor` to allow subproperty mutation ([496c287](https://github.com/MilesCranmer/BorrowChecker.jl/commit/496c2878daba94366ba35c828a251dc5dae9174d)) +* ensure `deepcopy` still happens when turned off ([641f800](https://github.com/MilesCranmer/BorrowChecker.jl/commit/641f8009ae0bda5fef17eb34fe60de951297a942)) +* helpful error for misuse of `bind` ([21e34c6](https://github.com/MilesCranmer/BorrowChecker.jl/commit/21e34c61981dea798af904018a3e473801dbeb35)) +* iterator of mutable references ([ba09d5b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ba09d5b0778ec1e6945fb540dad89a50013b1348)) +* prevent borrowed object from being bound ([a583d34](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a583d3439103b826f59a85936e1358bbf0b20f31)) +* printing for LazyAccessor ([7d70f18](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7d70f18568f94c31a4efdbaa3f6a7fb3a8cbdb7c)) + + +### Bug Fixes + +* validate symbol missing anonymous ([a8d17c9](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a8d17c9dd3542baef00810fc71a62f5f9dc8bd11)) + +## [0.0.2](https://github.com/MilesCranmer/BorrowChecker.jl/compare/v0.0.1...v0.0.2) (2025-01-10) + + +### ⚠ BREAKING CHANGES + +* change syntax `@own const` -> `@bind`, `@own` -> `@bind @mut` +* change `Owned` -> `Bound`, `OwnedMut` -> `BoundMut` +* change `@bind @mut` to `@bind :mut` +* symbol tracking in more macros +* mutable collection functions return nothing +* make `managed` a macro +* different syntax for `@ref` + +### Features + +* `[@atomic](https://github.com/atomic)` operations for mutable, just in case ([1501798](https://github.com/MilesCranmer/BorrowChecker.jl/commit/1501798c865fd0fe0a017d4ca4898468edf6070e)) +* `bind` for for loops ([47c7919](https://github.com/MilesCranmer/BorrowChecker.jl/commit/47c7919c074d9698e1dbd14c0b8aff645dccdb4b)) +* add missing `eachindex` ([639351f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/639351fcedca7fd2faad4f053275725d1d10c796)) +* add more overloads ([01511a3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/01511a3c188228ac8cd6336593e1c7d932c1c1fa)) +* allow `[@managed](https://github.com/managed)` to work with isbits ([e35e23c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/e35e23cafa53679762959866612a7835924306e9)) +* allow disabling borrow checker ([6ae8a29](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6ae8a29e3d41c180925ba44983eda4f7210e4be6)) +* automatically clone `isbits` ([9d919a8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/9d919a8984b57c3530b50c88c8384c418de44d10)) +* change `[@bind](https://github.com/bind) [@mut](https://github.com/mut)` to `[@bind](https://github.com/bind) :mut` ([15b3c70](https://github.com/MilesCranmer/BorrowChecker.jl/commit/15b3c7093acd29e829a5e8b8099d4eb8b80b7876)) +* change `Owned` -> `Bound`, `OwnedMut` -> `BoundMut` ([18bb37c](https://github.com/MilesCranmer/BorrowChecker.jl/commit/18bb37c4fa4e872134f6964b2edd9565a1c44601)) +* change syntax `[@own](https://github.com/own) const` -> `[@bind](https://github.com/bind)`, `[@own](https://github.com/own)` -> `[@bind](https://github.com/bind) [@mut](https://github.com/mut)` ([d111edd](https://github.com/MilesCranmer/BorrowChecker.jl/commit/d111edd8cadd0658737f1c1baabbaabbb0f0c7eb)) +* create `[@clone](https://github.com/clone)` operator ([40301f0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/40301f0b424700661d141a88bdaae2d6d09b5911)) +* create `managed()` context with Cassette.jl ([68d7aa9](https://github.com/MilesCranmer/BorrowChecker.jl/commit/68d7aa9e2c1f9596618e12d322fef5387d04aa6b)) +* different syntax for `[@ref](https://github.com/ref)` ([31f1ef4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/31f1ef40cfe1ed79e86d1e39d357d26fb6d0f24c)) +* disable `managed` too ([def2317](https://github.com/MilesCranmer/BorrowChecker.jl/commit/def2317a5e377c7156bb1c87aabae04ba44b0498)) +* feature to disable manually ([cc5f581](https://github.com/MilesCranmer/BorrowChecker.jl/commit/cc5f5813b48a23b97e4807a900cb5b0632c56709)) +* iteration for `Borrowed` ([ee30237](https://github.com/MilesCranmer/BorrowChecker.jl/commit/ee302378e6fc1de36d605cbba97232c43fd956c3)) +* make `managed` a macro ([db41870](https://github.com/MilesCranmer/BorrowChecker.jl/commit/db41870aa4768edbe59dd6c98b0ca923dcf7533a)) +* mutable bindings in loop ([6cd0f92](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6cd0f9277e9f0165a51f6c7918abdf54708b0ccf)) +* symbol tracking in more macros ([3d99c07](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3d99c07080c9414c135c6be01f2b98f65f98f53c)) + + +### Bug Fixes + +* additional uses of `isbits` ([9371368](https://github.com/MilesCranmer/BorrowChecker.jl/commit/937136853f88092f85ac7dba9f28e674be5e0520)) +* additional uses of `isbits` ([349df2a](https://github.com/MilesCranmer/BorrowChecker.jl/commit/349df2aec816bfd1702714041931c9e12f8226c0)) +* additional uses of `isbits` ([995824b](https://github.com/MilesCranmer/BorrowChecker.jl/commit/995824b124ec63d141e78c001ee1d8c57fd47db1)) +* avoid using `threadid` which can change ([f2fa07d](https://github.com/MilesCranmer/BorrowChecker.jl/commit/f2fa07d557a4ab5ec5d460e6c5edf6579baa143c)) +* bad signature for lifetime ([a7a2470](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a7a24705000809cdaf153d79ef282a74a8b036f0)) +* better error ([b493532](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b493532046e9044f9f4bdae5e2ebc0bda9b223e6)) +* check for moved in `managed()` ([7cf3a33](https://github.com/MilesCranmer/BorrowChecker.jl/commit/7cf3a33823146db11dad87e9826476f432acd231)) +* iter for AllBound ([eb8c6b3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/eb8c6b315d062096fb9e57d7b7d773a63ec5d391)) +* managed borrows ([42cfd40](https://github.com/MilesCranmer/BorrowChecker.jl/commit/42cfd405272558f7558c5bac132ede2e6416f2b3)) +* mutable collection functions return nothing ([2aff2f8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2aff2f8249825dc322add81a38aafcff0c15729c)) +* old error message ([a4af972](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a4af972ddca93908b18a3e0d05f73a227cc30f33)) +* test of `[@managed](https://github.com/managed)` ([196fe06](https://github.com/MilesCranmer/BorrowChecker.jl/commit/196fe069f2bcbc4416dbce80a74f429c99c47a0f)) + +## 0.0.1 (2025-01-10) + + +### ⚠ BREAKING CHANGES + +* ban single-arg `@move` +* tweak `@ref` syntax +* replace `@own` -> `@own const`, `@own_mut` -> `@own` +* replace `@ref` -> `@ref const`, `@ref_mut` -> `@ref` +* change `@move` symantics to specify mutability + +### Features + +* add 2-arg `rem` ([60b7595](https://github.com/MilesCranmer/BorrowChecker.jl/commit/60b7595fdb71e48433478350a7a287dd25db129c)) +* add basic math operations ([91c57f0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/91c57f0f1964ecb2cd4a9c04135a3fc3cc5b4cd7)) +* allow references in threads ([c9bf188](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c9bf188f8dddf9ad40a3bc3da0f6e0bee23177b5)) +* ban single-arg `[@move](https://github.com/move)` ([c813362](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c8133624e1844b0a72351f4ab3a6a751ba38658d)) +* block mutable references in threads ([5d1450f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5d1450f63c2fbe054244d4359cf3f5c527e61693)) +* change `[@move](https://github.com/move)` symantics to specify mutability ([24006b3](https://github.com/MilesCranmer/BorrowChecker.jl/commit/24006b31593501beea36ab296262e4aa318a33e7)) +* implement more parts of array interface ([33ed363](https://github.com/MilesCranmer/BorrowChecker.jl/commit/33ed363cbf4c5b9d9bd571159a5461c4c9768681)) +* init sync and send traits ([a21e4a1](https://github.com/MilesCranmer/BorrowChecker.jl/commit/a21e4a1f901510888b23eb431b6ba0cb2aee6907)) +* let blocks for lifetime ([be79388](https://github.com/MilesCranmer/BorrowChecker.jl/commit/be79388a3cc9060d2c160c05a4e353ab9f38a51d)) +* more 3-arg operations on ::Number ([21afdc0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/21afdc0df7e1e12da9fcd2e9fb757f7fbc4700f8)) +* prevent passing to thread ([85a2b3e](https://github.com/MilesCranmer/BorrowChecker.jl/commit/85a2b3e229e844a364d2db1256b05d3d8582fdd7)) +* prevent variable reassignment ([df43417](https://github.com/MilesCranmer/BorrowChecker.jl/commit/df434177d9c8fde8b9f2aeb0ec280ec4aa6681dc)) +* replace `[@own](https://github.com/own)` -> `[@own](https://github.com/own) const`, `[@own](https://github.com/own)_mut` -> `[@own](https://github.com/own)` ([974e683](https://github.com/MilesCranmer/BorrowChecker.jl/commit/974e6839519f5e2e8bc13292442d822fa9db0ac8)) +* replace `[@ref](https://github.com/ref)` -> `[@ref](https://github.com/ref) const`, `[@ref](https://github.com/ref)_mut` -> `[@ref](https://github.com/ref)` ([5c3b6f8](https://github.com/MilesCranmer/BorrowChecker.jl/commit/5c3b6f8b705fc5fe88fd3191349f00502092d4b9)) +* safer borrow checker with stored lifetime ([665f8ca](https://github.com/MilesCranmer/BorrowChecker.jl/commit/665f8ca52a1d4f2e595d4fe3bd1a2beda34842f7)) +* simple borrow checker ([fecc149](https://github.com/MilesCranmer/BorrowChecker.jl/commit/fecc149a37c03a3367312ff42962722d836250c8)) +* track symbol in `Owned` for debugging ([b348c65](https://github.com/MilesCranmer/BorrowChecker.jl/commit/b348c65d24606cf1b4e043345fbacf79aa472dac)) +* tweak `[@ref](https://github.com/ref)` syntax ([6363629](https://github.com/MilesCranmer/BorrowChecker.jl/commit/6363629a356b797865dd9e51ba60abb840bf6920)) + + +### Bug Fixes + +* ambiguity in `==` ([2715246](https://github.com/MilesCranmer/BorrowChecker.jl/commit/2715246ad408365578e232dee3b66ae7352f74a1)) +* marked move on wrong scenario ([41938a0](https://github.com/MilesCranmer/BorrowChecker.jl/commit/41938a02495f1b0c3721b291d761619d78331f93)) +* out-of-place import ([296bf1f](https://github.com/MilesCranmer/BorrowChecker.jl/commit/296bf1f00e735bcc4a61f3bacc77a1a99e72bd0f)) +* prevent nested lifetimes ([3faeed4](https://github.com/MilesCranmer/BorrowChecker.jl/commit/3faeed411677ee5f5d8daffbcf3c7d2497b43767)) +* some macro hygiene issues ([c02a3f5](https://github.com/MilesCranmer/BorrowChecker.jl/commit/c02a3f58f774f06312eaec2141b056a7dcdde8df)) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4eedc01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..1228e7c --- /dev/null +++ b/Project.toml @@ -0,0 +1,15 @@ +name = "BorrowChecker" +uuid = "7bdcaa52-c310-4bb0-bf54-d941056ed284" +version = "0.5.0" +authors = ["MilesCranmer "] + +[deps] +DispatchDoctor = "8d63f2c5-f18a-4cf2-ba9d-b3f60fc568c8" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[compat] +DispatchDoctor = "0.4.19" +Preferences = "1.4" +Random = "1" +julia = "1.10" diff --git a/README.md b/README.md new file mode 100644 index 0000000..9664108 --- /dev/null +++ b/README.md @@ -0,0 +1,340 @@ +
+ + + +# BorrowChecker.jl + +[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://astroautomata.com/BorrowChecker.jl/dev) +[![Build Status](https://github.com/MilesCranmer/BorrowChecker.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/MilesCranmer/BorrowChecker.jl/actions/workflows/CI.yml?query=branch%3Amain) +[![Coverage](https://coveralls.io/repos/github/MilesCranmer/BorrowChecker.jl/badge.svg?branch=main)](https://coveralls.io/github/MilesCranmer/BorrowChecker.jl?branch=main) + +
+ +This is an experimental package for emulating a runtime borrow checker in Julia, using a macro layer over regular code. This is built to mimic Rust's ownership, lifetime, and borrowing semantics. This tool is mainly to be used in development and testing to flag memory safety issues, and help you design safer code. + +BorrowChecker.jl provides one layer: + +**Automatic checking (`BorrowChecker.@safe`)** +- Drop-in for existing Julia code: wrap a function and BorrowChecker will run a best-effort borrow check when that method specialization executes. +- It does not change program behavior (except for throwing when it finds a violation). + +In Julia, when you write `x = [1, 2, 3]`, the actual _object_ exists completely independently of the variable, and you can refer to it from as many variables as you want without issue: + +```julia +x = [1, 2, 3] +y = x +println(length(x)) +# 3 +``` + +Once there are no more references to the object, the "garbage collector" will work to free the memory. + +Rust is much different. For example, the equivalent code is **invalid** in Rust + +```rust +let x = vec![1, 2, 3]; +let y = x; +println!("{}", x.len()); +// error[E0382]: borrow of moved value: `x` +``` + +Rust refuses to compile this code. Why? Because in Rust, objects (`vec![1, 2, 3]`) are _owned_ by variables. When you write `let y = x`, the ownership of `vec![1, 2, 3]` is _moved_ to `y`. Now `x` is no longer allowed to access it. + +To fix this, we would either write + +```rust +let y = x.clone(); +// OR +let y = &x; +``` + +to either create a copy of the vector, or _borrow_ `x` using the `&` operator to create a reference. You can create as many references as you want, but there can only be one original object. + +This "ownership" paradigm can help improve safety of code. Especially in complex, multithreaded codebases, it is easy to shoot yourself in the foot and modify objects which are "owned" (editable) by something else. Rust's ownership and lifetime model makes it so that you can _prove_ memory safety of code! Standard thread races are literally impossible. (Assuming you are not using `unsafe { ... }` to disable safety features, or the borrow checker itself has a bug, etc.) + +In BorrowChecker.jl, we demonstrate an implementation of some of these ideas. The aim is to build a development layer that can help prevent a few classes of memory safety issues, without affecting runtime behavior of code. + +## Automatic Checking: `BorrowChecker.@safe` + +`BorrowChecker.@safe` automatically instruments a function by analyzing the compiler IR and runs a best-effort borrow check at runtime. This requires Julia 1.12.x (on 1.13+ the checker falls back to warn-and-pass-through stubs until support lands). + +> [!WARNING] +> This macro is highly experimental and compiler-dependent. There are likely bugs and false positives. It is intended for development and testing, and does not guarantee memory safety. + +### Options + +`@safe` supports a few options that are compiled into a `BorrowChecker.Config`: + +- `scope` (default `:function`): whether to recursively borrow-check callees (`:none`, `:function`, `:module`, `:user`, `:all`). +- `max_summary_depth` (default `12`): recursion depth limit for effect summarization when effects cannot be directly resolved. +- `optimize_until` (default varies): which compiler pass to stop at when fetching IR (`Base.code_ircode_by_type`). + +`scope` meanings: + +- `:none`: disable `@safe` entirely. +- `:function`: check only the annotated method. +- `:module`: recursively check callees defined in the module where `@safe` is used. +- `:user`: recursively check callees, but ignore `Core` and `Base` (including their submodules). +- `:all`: recursively check callees across all modules (very aggressive). + +The `@safe` checked-cache is keyed by specialization *and these options*, so checking a function once under `scope=:function` will not incorrectly skip a later recursive check under `scope=:module` / `:all`. + +`@safe` is meant to be a *drop-in tripwire* for existing code: + +- **Aliasing violations**: mutating a value while another live binding may observe that mutation. +- **Escapes / "moves"**: storing a mutable value somewhere that outlives the current scope (e.g. a global cache / a field / a container), then continuing to reference it locally. + +This analyzes the compiler’s IR, so it can catch patterns that are "hidden" by lowering (keyword calls, closure captures, views, etc.). It is intentionally **best-effort**: when it cannot determine what a call does, it will be conservative (and may throw false positives). For regions where the checker is overly conservative, silence them with `@unsafe` blocks. + +### How it works + +When you write: + +```julia +BorrowChecker.@safe function f(args...) + # ... +end +``` + +the macro rewrites the function so that: + +1. **On entry**, it runs a borrow check for the *current method specialization* (e.g. `f(::Vector{Int})`), and caches the result (so future calls are faster). +2. The checker asks Julia for the function's **typed compiler IR** (the lowered form the compiler optimizes). +3. It walks that IR and tracks two key things: + - Which bindings may refer to the **same mutable object** (aliasing). + - Which operations **write** to a tracked object or cause it to **escape** (be treated like a move). +4. When it sees an operation that would be illegal under Rust-like rules (e.g. "write while aliased", or "use after escape"), it throws a `BorrowCheckError` with a source-level-ish diagnostic. + +### Aliasing Detection + +BorrowChecker.jl's `@safe` macro can detect when values are modified through aliased bindings, and throw an error: + +```julia +julia> import BorrowChecker + +julia> BorrowChecker.@safe function f() + x = [1, 2, 3] + y = x + push!(x, 4) + return y + end +f (generic function with 1 method) + +julia> f() # errors +``` + +This will generate a helpful error pointing out the location of the borrow check violation, and the statement that violated the rule: + +``` +ERROR: BorrowCheckError for specialization Tuple{typeof(f)} + + method: f() @ Main REPL[7]:1 + + [1] stmt#7: cannot perform write: value is aliased by another live binding at REPL[7]:4 + 2 x = [1, 2, 3] + 3 y = x + > 4 push!(x, 4) + 5 return y + 6 end + + stmt: Main.push!(%5, 4) + +``` + +To fix it, simply copy the value, which will avoid the error: + +```julia +julia> BorrowChecker.@safe function f() + x = [1, 2, 3] + y = copy(x) + push!(x, 4) + return y + end +f (generic function with 1 method) + +julia> f() +3-element Vector{Int64}: + 1 + 2 + 3 +``` + +### Escape Detection + +Much like Rust's ownership model, BorrowChecker.jl's `@safe` macro attempts to infer when values escape their scope (moved/consumed) and throw an error if they are used afterwards. + +```julia +julia> const CACHE = Dict() +Dict{Any, Any}() + +julia> foo(x) = (CACHE[x] = 1; nothing) +foo (generic function with 1 method) + +julia> BorrowChecker.@safe function bar() + x = [1, 2] + foo(x) + return x + end +bar (generic function with 1 method) + +julia> bar() # errors +``` + +This generates the following error: + +``` +ERROR: BorrowCheckError for specialization Tuple{typeof(bar)} + + method: bar() @ Main REPL[13]:1 + + [1] stmt#6: value escapes/consumed by unknown call; it (or an alias) is used later at REPL[13]:3 + 1 BorrowChecker.@safe function bar() + 2 x = [1, 2] + > 3 foo(x) + 4 return x + 5 end + + stmt: Main.foo(%5) +``` + +Why is this an error? Because `x` was stored as a key in the cache, but is _mutable externally_. Furthermore, it is returned by `bar`! This is a violation of borrowing rules. Once the value gets stored in the cache, its ownership is _transferred_ to the cache, and is no longer accessible by `bar`. So this example is illegal. + +How can we fix it? We have two options. The first is we can copy the value before storing it: + +```julia +julia> BorrowChecker.@safe function bar() + x = [1, 2] + foo(copy(x)) + return x + end +bar (generic function with 1 method) + +julia> bar() # ok +``` + +We no longer have access to the object created by `copy(x)`, so the borrow check passes. +Alternatively, we can use immutable objects, which are safe to pass around: + +```julia +julia> BorrowChecker.@safe function bar() + x = (1, 2) + foo(x) + return x + end +bar (generic function with 1 method) + +julia> bar() # ok +``` + +### More `@safe` examples + +
+@safe analyzes the entire callstack + +BorrowChecker doesn't rely on naming conventions, such as the presence of `!` in the function name. It tries to infer effects from IR: + +```julia +julia> h(x) = (push!(x, 1); nothing) # no "!" in the name +h (generic function with 1 method) + +julia> BorrowChecker.@safe function demo() + x = [1, 2, 3] + y = x + h(x) + return y + end +demo (generic function with 1 method) + +julia> demo() # errors +``` +
+ +
+Keyword arguments are handled (the checker sees lowered kwcall IR) + +Keyword calls get lowered into a `NamedTuple` + `Core.kwcall(...)`. `@safe` analyzes the lowered IR, so aliasing via keyword arguments is still visible: + +```julia +julia> f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) +f (generic function with 1 method) + +julia> BorrowChecker.@safe function kw_demo() + x = [1, 2, 3] + y = x + return sum(f(; x=x, y=y)) + end +kw_demo (generic function with 1 method) + +julia> kw_demo() # errors +``` +
+ +
+Aliasing isn't only y = x: views can alias too + +```julia +julia> BorrowChecker.@safe function view_demo() + x = [1, 2, 3, 4] + y = view(x, 1:2) # aliases x + push!(x, 9) + return collect(y) + end +view_demo (generic function with 1 method) + +julia> view_demo() # errors +``` +
+ +
+Closures are analyzed too + +```julia +julia> BorrowChecker.@safe function closure_demo() + x = [1, 2, 3] + y = x + f = () -> (push!(x, 9); nothing) + f() + return y + end +closure_demo (generic function with 1 method) + +julia> closure_demo() # errors +``` + +Read-only captures are typically fine. +
+ + +## Opting out: `@unsafe` blocks + +`@unsafe` is an escape hatch analogous to `@inbounds`: inside the block, the +checker skips aliasing and escape validation entirely, and effects inside it +(writes, consumes, new aliases) do not propagate outward into the surrounding +analysis. You take responsibility for upholding the borrow rules yourself. + +```julia +julia> BorrowChecker.@safe function add_halves!(a::Vector{Float64}) + n = length(a) ÷ 2 + BorrowChecker.@unsafe begin + left = @view a[1:n] + right = @view a[(n + 1):(2n)] + left .+= right + end + return a + end +``` + +`@unsafe` also works on a single expression, e.g. to silence one unanalyzable +call in an otherwise checked function: + +```julia +julia> BorrowChecker.@safe w_gradient(f, x) = + BorrowChecker.@unsafe gradient(f, backend, x) +``` + +## Disabling BorrowChecker + +Set `borrow_checker = false` in your `LocalPreferences.toml` (via +Preferences.jl) to make every macro pass through untouched, or call +`disable_by_default!(@__MODULE__)` at the top of a module to ship it disabled +and enable checking in your test suite. diff --git a/coverage.jl b/coverage.jl new file mode 100644 index 0000000..fb60bcc --- /dev/null +++ b/coverage.jl @@ -0,0 +1,20 @@ +using Coverage +# process '*.cov' files +coverage = process_folder() # defaults to src/; alternatively, supply the folder name as argument +#=push!(coverage, process_folder("ext")...)=# + +LCOV.writefile("lcov.info", coverage) + +# process '*.info' files +coverage = merge_coverage_counts( + coverage, + filter!( + let prefixes = (joinpath(pwd(), "src", ""),) #=joinpath(pwd(), "ext", "")=# + c -> any(p -> startswith(c.filename, p), prefixes) + end, + LCOV.readfolder("test"), + ), +) +# Get total coverage for all Julia files +covered_lines, total_lines = get_summary(coverage) +@show covered_lines, total_lines diff --git a/docs/Project.toml b/docs/Project.toml new file mode 100644 index 0000000..dfa65cd --- /dev/null +++ b/docs/Project.toml @@ -0,0 +1,2 @@ +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" diff --git a/docs/make.jl b/docs/make.jl new file mode 100644 index 0000000..906a3fb --- /dev/null +++ b/docs/make.jl @@ -0,0 +1,55 @@ +using Documenter +using BorrowChecker + +DocMeta.setdocmeta!(BorrowChecker, :DocTestSetup, :(using BorrowChecker); recursive=true) + +# Read and process README.md +readme = open(dirname(@__FILE__) * "/../README.md") do io + read(io, String) +end + +# Replace HTML image tags with markdown +readme = replace(readme, r"]+>.*" => s"![](\1)") + +# Remove div tags +readme = replace(readme, r"<[/]?div.*" => s"") + +# Create the index.md +open(dirname(@__FILE__) * "/src/index.md", "w") do io + # Add meta information + write( + io, + """ +```@meta +CurrentModule = BorrowChecker +``` + +""", + ) + write(io, readme) +end + +makedocs(; + modules=[BorrowChecker], + authors="Miles Cranmer and contributors", + repo="https://github.com/MilesCranmer/BorrowChecker.jl/blob/{commit}{path}#{line}", + sitename="BorrowChecker.jl", + format=Documenter.HTML(; + prettyurls=get(ENV, "CI", "false") == "true", + canonical="https://ai.damtp.cam.ac.uk/borrowcheckerjl", + edit_link="main", + assets=String[], + repolink="https://github.com/mcranmer/BorrowChecker.jl", + ), + pages=["Home" => "index.md", "`@safe`" => "auto.md", "API Reference" => "api.md"], + warnonly=[:missing_docs], # Allow missing docstrings +) + +deploydocs(; repo="github.com/MilesCranmer/BorrowChecker.jl", devbranch="main") + +# Mirror to DAMTP: +if haskey(ENV, "DOCUMENTER_KEY_CAM") + ENV["DOCUMENTER_KEY"] = ENV["DOCUMENTER_KEY_CAM"] + ENV["GITHUB_REPOSITORY"] = "ai-damtp-cam-ac-uk/borrowcheckerjl.git" + deploydocs(; repo="github.com/ai-damtp-cam-ac-uk/borrowcheckerjl.git", devbranch="main") +end diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..4d750b9 --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,19 @@ +# API Reference + +```@meta +CurrentModule = BorrowChecker +``` + +## Automatic Checking + +```@docs +BorrowChecker.@safe +BorrowChecker.@unsafe +BorrowChecker.BorrowCheckError +``` + +## Preferences + +```@docs +BorrowChecker.PreferencesModule.disable_by_default! +``` diff --git a/docs/src/auto.md b/docs/src/auto.md new file mode 100644 index 0000000..a043fcc --- /dev/null +++ b/docs/src/auto.md @@ -0,0 +1,100 @@ +# `@safe` (IR Borrow Checker) + +```@meta +CurrentModule = BorrowChecker +``` + +`@safe` is an experimental, compiler-IR-based borrow checker intended as a **development tripwire** for ordinary Julia code. +On function entry it borrow-checks the current specialization and caches the result so subsequent calls are fast. +The cache key includes the *active `@safe` options* (so e.g. a later call with `scope=:module` will not be skipped just because `scope=:function` previously checked the same specialization). + +!!! warning + `@safe` is highly compiler-dependent. Expect false positives and false negatives. + It is for testing/debugging, not a safety guarantee. + +## Basic Usage + +```julia +using BorrowChecker: @safe + +@safe function f(x) + y = x + x[1] = 0 # may error if `y` can observe this mutation + return y +end +``` + +On failure, `@safe` throws `BorrowChecker.BorrowCheckError` with best-effort source context. + +## Options + +Options are parsed by the macro and compiled into a `BorrowChecker.Config`. + +### `scope` + +Controls whether the checker recursively borrow-checks callees (call-graph traversal): + +- `scope=:none`: disable `@safe` entirely (no IR borrow-checking). +- `scope=:function` (default): check only the annotated method. +- `scope=:module`: recursively check callees whose defining module matches the module where `@safe` is used. +- `scope=:user`: recursively check callees, but **ignore `Core` and `Base`** (including their submodules). +- `scope=:all`: recursively check callees across all modules (very aggressive; expect more work/edge cases). + +!!! note + For `scope=:module` / `scope=:user`, callees are filtered by the **defining module of the resolved method** (so user-defined extensions of `Base` functions are still treated as “in-module” when appropriate). + +Example: + +```julia +@safe scope=:module function outer(x) + return inner(x) +end +``` + +### `max_summary_depth` + +Limits recursive effect summarization depth used when the checker cannot directly resolve effects. + +```julia +@safe max_summary_depth=4 function f(x) + return g(x) +end +``` + +### `optimize_until` + +Controls which compiler pass to stop at when fetching IR via `Base.code_ircode_by_type`. + +```julia +@safe optimize_until="compact 1" function f(x) + return g(x) +end +``` + +Pass names vary across Julia versions; `@safe` normalizes common spellings like `"compact 1"` / `"compact_1"` when possible. + +### `debug` + +Enable debug logging (best-effort) to a JSONL file: + +```julia +@safe debug=true function f(x) + return g(x) +end +``` + +The output path is controlled by the `BORROWCHECKER_AUTO_DEBUG_PATH` environment variable (otherwise a file in `tempdir()` is used). +If `BORROWCHECKER_AUTO_DEBUG_PATH` is not set, `@safe debug=true` will emit a warning telling you where it is writing the file. + +### `debug_callee_depth` + +When `debug=true`, controls how deep in the recursive effect summarizer `@safe` also dumps IR (0 = only the entrypoint specialization). + +## Registry Overrides (advanced) + +The checker uses a small registry of effect specs for non-overloadable primitives. +You can add or override specs with: + +```julia +using BorrowChecker: register_effects! +``` diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..fa71632 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,11 @@ +{ + "bootstrap-sha": "855c1562ce7af4e1e1660ee81bbeb7912333833d", + "packages": { + ".": { + "release-type": "julia", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-component-in-tag": false + } + } +} diff --git a/scripts/collect_broken_cases.jl b/scripts/collect_broken_cases.jl new file mode 100644 index 0000000..ebbe80d --- /dev/null +++ b/scripts/collect_broken_cases.jl @@ -0,0 +1,592 @@ +#!/usr/bin/env julia + +using Pkg: Pkg + +Pkg.activate(@__DIR__; io=devnull) +Pkg.develop(Pkg.PackageSpec(; path=abspath(joinpath(@__DIR__, ".."))); io=devnull) +Pkg.instantiate(; io=devnull) + +using Dates +using TOML + +using BorrowChecker +using DynamicExpressions + +function _arg_value( + args::Vector{String}, flag::String, default::Union{Nothing,String}=nothing +) + for i in 1:length(args) + if args[i] == flag + return (i < length(args)) ? args[i + 1] : default + end + end + return default +end + +function _required_arg(args::Vector{String}, flag::String)::String + v = _arg_value(args, flag, nothing) + v === nothing && error("Missing required argument: $flag") + return v +end + +function _first_line_matching(path::String, needle::AbstractString) + i = 0 + for ln in eachline(path) + i += 1 + occursin(needle, ln) && return i + end + return nothing +end + +function _nearest_testset_name(path::String, line::Int) + lines = readlines(path) + i = min(line, length(lines)) + while i >= 1 + m = match(r"@testset\\s+\"([^\"]+)\"", lines[i]) + m === nothing || return m.captures[1] + i -= 1 + end + return nothing +end + +function _event_counts(jsonl_path::String) + isfile(jsonl_path) || return Dict{String,Int}() + counts = Dict{String,Int}() + for ln in eachline(jsonl_path) + m = match(r"\"event\":\"([^\"]+)\"", ln) + m === nothing && continue + ev = m.captures[1] + counts[ev] = get(counts, ev, 0) + 1 + end + return counts +end + +function _violation_dicts(err) + err isa BorrowChecker.BorrowCheckError || return Any[] + out = Any[] + for v in err.violations + file, line = if v.lineinfo === nothing + (nothing, nothing) + else + try + BorrowChecker._lineinfo_file_line(v.lineinfo) + catch + (nothing, nothing) + end + end + d = Dict{String,Any}("idx" => v.idx, "msg" => v.msg, "stmt" => string(v.stmt)) + file === nothing || (d["file"] = file) + line === nothing || (d["line"] = line) + push!(out, d) + end + return out +end + +function _toml_dict(pairs::Pair...) + d = Dict{String,Any}() + for (k, v) in pairs + v === nothing && continue + d[string(k)] = v + end + return d +end + +function run_case!( + case_id::String; + title::String, + source_file::String, + broken_marker_needle::String, + invoke::Function, + outdir::String, +) + jsonl_dir = joinpath(outdir, "jsonl") + meta_dir = joinpath(outdir, "meta") + mkpath(jsonl_dir) + mkpath(meta_dir) + + jsonl_path = joinpath(jsonl_dir, "$(case_id).jsonl") + rm(jsonl_path; force=true) + # Ensure the file exists even if no debug events are emitted (useful for tooling/reporting). + open(jsonl_path, "w") do _io + end + + err = nothing + ret = nothing + start = Dates.now(Dates.UTC) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => jsonl_path) do + try + ret = invoke() + catch e + err = e + end + end + stop = Dates.now(Dates.UTC) + + marker_line = _first_line_matching(source_file, broken_marker_needle) + testset = + marker_line === nothing ? nothing : _nearest_testset_name(source_file, marker_line) + + counts = _event_counts(jsonl_path) + ok = (err === nothing) + meta = _toml_dict( + "case_id" => case_id, + "title" => title, + "source_file" => source_file, + "broken_marker_needle" => broken_marker_needle, + "broken_marker_line" => marker_line, + "testset" => testset, + "julia_version" => string(VERSION), + "started_utc" => Dates.format(start, dateformat"yyyy-mm-ddTHH:MM:SS"), + "finished_utc" => Dates.format(stop, dateformat"yyyy-mm-ddTHH:MM:SS"), + "ok" => ok, + "return_value" => (ok ? string(ret) : nothing), + "error_type" => (ok ? nothing : string(typeof(err))), + "error" => (ok ? nothing : sprint(showerror, err)), + "borrowcheck_error" => (err isa BorrowChecker.BorrowCheckError), + "violation_count" => + (err isa BorrowChecker.BorrowCheckError ? length(err.violations) : 0), + "violations" => _violation_dicts(err), + "jsonl_path" => jsonl_path, + "jsonl_bytes" => (isfile(jsonl_path) ? filesize(jsonl_path) : 0), + "jsonl_event_counts" => counts, + "debug_cfg" => _toml_dict( + "debug" => true, "debug_callee_depth" => 2, "optimize_until" => "compact 1" + ), + ) + + open(joinpath(meta_dir, "$(case_id).toml"), "w") do io + TOML.print(io, meta) + end + + return meta +end + +Base.@noinline fakewrite(x) = Base.inferencebarrier(x) + +struct _BCBoxedField + n::Int +end + +struct _BCBoxedBroadcast + n::Int +end + +struct _BCThreadsBoxedRange + n::Int +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_boxed_getproperty_dim( + x::_BCBoxedField +) + g = () -> getfield(x, :n) + x = fakewrite(x) + a = zeros(Float64, getfield(x, :n)) + return (g(), length(a)) +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_boxed_broadcast_ok( + x::_BCBoxedBroadcast +) + g = () -> getfield(x, :n) + x = fakewrite(x) + b = rand(getfield(x, :n)) .< 0.5 + return (g(), sum(b)) +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_threads_boxed_range_ok( + x::_BCThreadsBoxedRange, flag::Bool +) + g = () -> getfield(x, :n) + x = fakewrite(x) + + r = 1:(getfield(x, :n)) + if flag + Base.Threads.@threads for i in r + fakewrite(i) + end + else + for i in r + fakewrite(i) + end + end + + return g() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_array_value_dim_ctor( + x +) + l = 1 + return Array{Int,l}(x) +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" bc_copy_ok( + ex +) = copy(ex) + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_lambda_arglist_symbol() + f = x -> x + 1 + return f(1) +end + +const _BC_LAMBDA_ARGLIST_NOTHING_EXPR = Expr(:(->), nothing, :(1)) +eval( + quote + BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_lambda_arglist_nothing() + f = $_BC_LAMBDA_ARGLIST_NOTHING_EXPR + return f() + end + end, +) + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_nested_function_bad() + function _bc_inner() + x = [1, 2, 3] + y = x + x[1] = 0 + return y + end + return _bc_inner() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_local_oneliner_bad() + _bc_inner() = begin + x = [1, 2, 3] + y = x + x[1] = 0 + return y + end + return _bc_inner() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_body_0arg() + f = () -> begin + x = [1, 2, 3] + y = x + push!(x, 9) + return y + end + return f() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_body_with_arg( + z +) + f = () -> begin + x = z + y = x + push!(x, 9) + return y + end + return f() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_ok_closure_body_0arg() + f = () -> begin + x = [1, 2, 3] + y = copy(x) + push!(x, 9) + return y + end + return f() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_ok_closure_body_with_arg( + z +) + f = () -> begin + x = copy(z) + y = copy(x) + push!(x, 9) + return y + end + return f() +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_view_alias() + x = [1, 2, 3, 4] + y = view(x, 1:2) + push!(x, 9) + return collect(y) +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_capture() + x = [1, 2, 3] + y = x + f = () -> (push!(x, 9); nothing) + f() + return y +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_bad_closure_capture_nested() + x = [1, 2, 3] + y = x + f = () -> begin + g = () -> (push!(x, 9); nothing) + g() + return nothing + end + f() + return y +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_ok_closure_capture_readonly() + x = [1, 2, 3] + y = x + f = () -> begin + s = 0 + for i in 1:length(y) + s += y[i] + end + return s + end + f() + return x +end + +BorrowChecker.@safe debug = true debug_callee_depth = 2 optimize_until = "compact 1" function _bc_module_not_owned() + m = Base + g = Base.inferencebarrier(identity) + g(m) + return getproperty(m, :Math) +end + +function main() + outdir = _required_arg(ARGS, "--outdir") + mkpath(outdir) + + cases = Any[] + + push!( + cases, + run_case!( + "auto_boxed_getproperty_dim"; + title="boxed captured variable: getproperty field type refinement", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="boxed captured variable: getproperty field type refinement", + invoke=() -> _bc_boxed_getproperty_dim(_BCBoxedField(3)), + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_boxed_broadcast_ok"; + title="boxed captured variable: broadcast materialize should not consume", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="boxed captured variable: broadcast materialize should not consume", + invoke=() -> _bc_boxed_broadcast_ok(_BCBoxedBroadcast(10)), + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_threads_boxed_range_ok"; + title="Threads.@threads plumbing should not spuriously consume", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="Threads.@threads plumbing should not spuriously consume", + invoke=() -> _bc_threads_boxed_range_ok(_BCThreadsBoxedRange(5), false), + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_array_value_dim_ctor"; + title="known failure: Array{Int,l}(x) with value l", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="known failure: Array{Int,l}(x) with value l", + invoke=() -> _bc_array_value_dim_ctor([1]), + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_lambda_arglist_symbol"; + title="lambda arglist: single argument", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="lambda arglist: single argument", + invoke=_bc_lambda_arglist_symbol, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_lambda_arglist_nothing"; + title="lambda arglist: args_expr === nothing", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="lambda arglist: args_expr === nothing", + invoke=_bc_lambda_arglist_nothing, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_nested_function_bad"; + title="nested function definitions are instrumented", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="nested function definitions are instrumented", + invoke=_bc_nested_function_bad, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_local_oneliner_bad"; + title="local one-line method definitions are instrumented", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="local one-line method definitions are instrumented", + invoke=_bc_local_oneliner_bad, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_bad_closure_body_0arg"; + title="_bc_bad_closure_body_0arg", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_bad_closure_body_0arg", + invoke=_bc_bad_closure_body_0arg, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_bad_closure_body_with_arg"; + title="_bc_bad_closure_body_with_arg", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_bad_closure_body_with_arg", + invoke=() -> _bc_bad_closure_body_with_arg([1, 2, 3]), + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_ok_closure_body_0arg"; + title="_bc_ok_closure_body_0arg", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_ok_closure_body_0arg", + invoke=_bc_ok_closure_body_0arg, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_ok_closure_body_with_arg"; + title="_bc_ok_closure_body_with_arg", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_ok_closure_body_with_arg", + invoke=() -> _bc_ok_closure_body_with_arg([1, 2, 3]), + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_bad_view_alias"; + title="_bc_bad_view_alias", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_bad_view_alias", + invoke=_bc_bad_view_alias, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_bad_closure_capture"; + title="_bc_bad_closure_capture", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_bad_closure_capture()", + invoke=_bc_bad_closure_capture, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_bad_closure_capture_nested"; + title="_bc_bad_closure_capture_nested", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_bad_closure_capture_nested", + invoke=_bc_bad_closure_capture_nested, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_ok_closure_capture_readonly"; + title="_bc_ok_closure_capture_readonly", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="_bc_ok_closure_capture_readonly", + invoke=_bc_ok_closure_capture_readonly, + outdir=outdir, + ), + ) + + push!( + cases, + run_case!( + "auto_module_not_owned"; + title="modules are not owned (avoid spurious consumes)", + source_file=joinpath("test", "auto_borrow_checker_tests.jl"), + broken_marker_needle="modules are not owned (avoid spurious consumes)", + invoke=_bc_module_not_owned, + outdir=outdir, + ), + ) + + operators = OperatorEnum(1 => [exp], 2 => [+, -, *]) + x1 = Expression(Node{Float64}(; feature=1); operators) + push!( + cases, + run_case!( + "dynamic_expressions_copy_ok"; + title="DynamicExpressions: copy(::Expression) should not spuriously consume", + source_file=joinpath("test", "dynamic_expressions_integration_tests.jl"), + broken_marker_needle="BorrowChecker.@safe bc_copy_ok", + invoke=() -> bc_copy_ok(x1), + outdir=outdir, + ), + ) + + open(joinpath(outdir, "summary.toml"), "w") do io + TOML.print( + io, + Dict( + "toolchain_label" => _arg_value(ARGS, "--label", ""), + "julia_version" => string(VERSION), + "finished_utc" => + Dates.format(Dates.now(Dates.UTC), dateformat"yyyy-mm-ddTHH:MM:SS"), + "cases" => cases, + ), + ) + end +end + +main() diff --git a/src/BorrowChecker.jl b/src/BorrowChecker.jl new file mode 100644 index 0000000..c0c7bd2 --- /dev/null +++ b/src/BorrowChecker.jl @@ -0,0 +1,56 @@ +module BorrowChecker + +include("preferences.jl") + +using .PreferencesModule: disable_by_default!, is_borrow_checker_enabled +using DispatchDoctor: @unstable + +# The borrow checker: `@safe` instruments methods with a compiler-IR borrow +# check at runtime. This is the entire library. +export @safe, @unsafe, disable_by_default! + +@static if isdefined(Base, :code_ircode_by_type) && v"1.12.0-" <= VERSION < v"1.13.0-" + @unstable include("safe/auto_ir.jl") + # `BorrowCheckError` and friends are defined by safe/auto_ir.jl. + export BorrowCheckError +else + # COV_EXCL_START + """ + BorrowChecker.@auto + + Deprecated alias for [`BorrowChecker.@safe`](@ref). + """ + "Unavailable `@auto` stub for unsupported Julia versions." + macro auto(args...) + ex = args[end] + is_borrow_checker_enabled(__module__) || return esc(ex) + Base.depwarn( + "`BorrowChecker.@auto` is deprecated; use `BorrowChecker.@safe` instead.", :auto + ) + @warn( + "BorrowChecker.@safe is not supported on this version of Julia.", + maxlog = 1, + ) + return esc(ex) + end + + "Unavailable `@safe` stub for unsupported Julia versions." + macro safe(args...) + ex = args[end] + is_borrow_checker_enabled(__module__) || return esc(ex) + @warn( + "BorrowChecker.@safe is not supported on this version of Julia.", + maxlog = 1, + ) + return esc(ex) + end + + "Unavailable `@unsafe` stub for unsupported Julia versions." + macro unsafe(ex) + # When the auto-IR checker is unavailable, `@unsafe` is a no-op. + return esc(ex) + end + # COV_EXCL_STOP +end + +end diff --git a/src/preferences.jl b/src/preferences.jl new file mode 100644 index 0000000..da795f7 --- /dev/null +++ b/src/preferences.jl @@ -0,0 +1,88 @@ +""" + BorrowChecker.PreferencesModule + +Module for managing BorrowChecker preferences, including enabling/disabling borrow checking per module. +""" +module PreferencesModule # Largely borrowed from DispatchDoctor.jl + +using Preferences: load_preference, has_preference, get_uuid + +@enum IsCached::Bool begin + Cached + NotCached +end + +struct Cache{A,B} + cache::Dict{A,B} + lock::Threads.SpinLock + + Cache{A,B}() where {A,B} = new{A,B}(Dict{A,B}(), Threads.SpinLock()) # COV_EXCL_LINE +end + +const DEFAULT_UUID = Base.UUID(0) + +const UUID_CACHE = Cache{UInt64,Base.UUID}() +const PREFERENCE_CACHE = Cache{Base.UUID,Tuple{Bool,IsCached}}() +const MODULE_CACHE = Cache{Module,Bool}() + +function _cached_call(f::F, cache::Cache, key) where {F} + lock(cache.lock) do + key == DEFAULT_UUID ? f() : get!(f, cache.cache, key) + end +end + +function _cached_get_uuid(m) + _cached_call(UUID_CACHE, objectid(m)) do + try + get_uuid(m) + catch + DEFAULT_UUID + end + end +end + +function is_borrow_checker_enabled(calling_module) + uuid = _cached_get_uuid(calling_module) + (value, cached) = _cached_call(PREFERENCE_CACHE, uuid) do + if uuid == DEFAULT_UUID || !has_preference(uuid, "borrow_checker") + (true, NotCached) + else + (load_preference(uuid, "borrow_checker")::Bool, Cached) + end + end + if cached == Cached + return value + else + Base.@lock MODULE_CACHE.lock begin + if haskey(MODULE_CACHE.cache, calling_module) + return MODULE_CACHE.cache[calling_module] + else + MODULE_CACHE.cache[calling_module] = true + return value + end + end + end +end + +""" + disable_by_default!(m::Module) + +Make all BorrowChecker macros expand to pass-through within module `m` unless a +`LocalPreferences.toml` explicitly sets `borrow_checker = true`. Intended for +libraries that ship with checking disabled and enable it in their test suite. +Must be called before any BorrowChecker macro is used in `m`. +""" +function disable_by_default!(m::Module) + Base.@lock MODULE_CACHE.lock begin + if haskey(MODULE_CACHE.cache, m) && MODULE_CACHE.cache[m] + error( + "BorrowChecker preferences were already cached for module $m. " * + "Please call this function before any other BorrowChecker macros are used.", + ) + end + MODULE_CACHE.cache[m] = false + end + return nothing +end + +end diff --git a/src/safe/alias.jl b/src/safe/alias.jl new file mode 100644 index 0000000..022abec --- /dev/null +++ b/src/safe/alias.jl @@ -0,0 +1,360 @@ +@inline _field_sym(x) = x isa QuoteNode ? x.value : x + +function _box_key(@nospecialize(box), ir::CC.IRCode, nargs::Int)::Int + box = _canonical_ref(box, ir) + if box isa Core.Argument + return box.n + elseif box isa Core.SSAValue + return _ssa_handle(nargs, box.id) + end + return 0 +end + +function _maybe_record_box_contents!( + box_contents::Dict{Int,Int}, + @nospecialize(f), + raw_args, + ir::CC.IRCode, + nargs::Int, + track_arg, + track_ssa, +) + f === Core.setfield! || return nothing + length(raw_args) >= 4 || return nothing + + _field_sym(raw_args[3]) === :contents || return nothing + + key = _box_key(raw_args[2], ir, nargs) + key == 0 && return nothing + + vh = _handle_index(raw_args[4], nargs, track_arg, track_ssa) + vh == 0 && return nothing + + box_contents[key] = vh + return nothing +end + +function _maybe_alias_box_contents!( + uf::UnionFind, + out_h::Int, + box_contents::Dict{Int,Int}, + @nospecialize(f), + raw_args, + ir::CC.IRCode, + nargs::Int, +) + f === Core.getfield || return nothing + length(raw_args) >= 3 || return nothing + + _field_sym(raw_args[3]) === :contents || return nothing + + key = _box_key(raw_args[2], ir, nargs) + key == 0 && return nothing + + in_h = get(box_contents, key, 0) + in_h == 0 && return nothing + + _uf_union!(uf, out_h, in_h) + return nothing +end + +function _push_all_user_args!(dest::Vector{Int}, raw_args) + for p in 2:length(raw_args) + push!(dest, p) + end + return dest +end + +function _maybe_ret_alias_summary( + stmt, + ir::CC.IRCode, + cfg::Config, + @nospecialize(f), + raw_args; + depth::Int, + budget_state=nothing, +) + if depth < cfg.max_summary_depth + if stmt.head === :invoke + return _summary_for_mi( + stmt.args[1], cfg; depth=depth + 1, budget_state=budget_state + ) + end + + if f === Core.kwcall + tt = _kwcall_tt_from_raw_args(raw_args, ir) + tt !== nothing && return _summary_for_tt( + tt, cfg; depth=depth + 1, budget_state=budget_state, allow_core=true + ) + return nothing + end + + tt = _call_tt_from_raw_args(raw_args, ir, f) + tt !== nothing && + return _summary_for_tt(tt, cfg; depth=depth + 1, budget_state=budget_state) + return nothing + end + + if stmt.head === :invoke + _mark_budget_hit!(budget_state) + else + tt = if f === Core.kwcall + _kwcall_tt_from_raw_args(raw_args, ir) + else + _call_tt_from_raw_args(raw_args, ir, f) + end + tt === nothing || _mark_budget_hit!(budget_state) + end + + return nothing +end + +function _ret_alias_positions_for_call( + stmt, + ir::CC.IRCode, + cfg::Config, + @nospecialize(f), + raw_args; + depth::Int, + budget_state=nothing, +) + alias_args = Int[] + + if f !== nothing + eff = _known_effects_get(f) + if eff !== nothing + for p in eff.ret_aliases + push!(alias_args, p) + end + return alias_args + end + end + + s = _maybe_ret_alias_summary( + stmt, ir, cfg, f, raw_args; depth=depth, budget_state=budget_state + ) + if s !== nothing + for p in s.ret_aliases + push!(alias_args, p) + end + return alias_args + end + return _push_all_user_args!(alias_args, raw_args) +end + +function _build_alias_classes!( + uf::UnionFind, + ir::CC.IRCode, + cfg::Config, + track_arg, + track_ssa, + nargs::Int; + unsafe_stmt::Union{Nothing,AbstractVector{Bool}}=nothing, + depth::Int=0, + budget_state=nothing, +) + box_contents = Dict{Int,Int}() + + nstmts = length(ir.stmts) + for i in 1:nstmts + if unsafe_stmt !== nothing && unsafe_stmt[i] + # Opaque/unchecked region: do not propagate aliases from within. + continue + end + out_h = track_ssa[i] ? _ssa_handle(nargs, i) : 0 + out_h == 0 && continue + + stmt = ir[Core.SSAValue(i)][:stmt] + + if stmt isa Core.PiNode + in_h = _handle_index(stmt.val, nargs, track_arg, track_ssa) + _uf_union!(uf, out_h, in_h) + continue + end + + if stmt isa Core.PhiNode || stmt isa Core.PhiCNode + vals = getfield(stmt, :values) + for v in vals + in_h = _handle_index(v, nargs, track_arg, track_ssa) + _uf_union!(uf, out_h, in_h) + end + continue + end + + if stmt isa Core.SSAValue || stmt isa Core.Argument + in_h = _handle_index(stmt, nargs, track_arg, track_ssa) + _uf_union!(uf, out_h, in_h) + continue + end + + if stmt isa Expr && (stmt.head === :new || stmt.head === :splatnew) + for j in 2:length(stmt.args) + in_h = _handle_index(stmt.args[j], nargs, track_arg, track_ssa) + _uf_union!(uf, out_h, in_h) + end + continue + end + + if stmt isa Expr && stmt.head === :foreigncall + name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) + eff = + (name_sym === nothing) ? nothing : _known_foreigncall_effects_get(name_sym) + eff === nothing && continue + for p in eff.ret_aliases + (1 <= p <= length(ccall_args)) || continue + in_h = _handle_index(ccall_args[p], nargs, track_arg, track_ssa) + _uf_union!(uf, out_h, in_h) + end + continue + end + + if stmt isa Expr && (stmt.head === :call || stmt.head === :invoke) + raw_args = (stmt.head === :invoke) ? stmt.args[2:end] : stmt.args + f = _resolve_callee(stmt, ir) + + # Tuples are immutable containers. We intentionally do NOT union tuples with all + # tracked elements (that would incorrectly merge distinct tracked values). + # However, a common compiler pattern is to return a tuple that contains exactly + # one tracked value (e.g. `(ptr, stride)`), which is then immediately projected + # with `getfield` / `indexed_iterate`. In that case we conservatively union the + # tuple with that single tracked element so effects can flow through the + # intermediate tuple value. + if f === Core.tuple + only_h = 0 + for p in 2:length(raw_args) + in_h = _handle_index(raw_args[p], nargs, track_arg, track_ssa) + in_h == 0 && continue + if only_h == 0 + only_h = in_h + elseif in_h != only_h + only_h = -1 + break + end + end + (only_h > 0) && _uf_union!(uf, out_h, only_h) + continue + end + + if f !== nothing && _is_namedtuple_ctor(f) + continue + end + + _maybe_record_box_contents!( + box_contents, f, raw_args, ir, nargs, track_arg, track_ssa + ) + _maybe_alias_box_contents!(uf, out_h, box_contents, f, raw_args, ir, nargs) + + alias_args = _ret_alias_positions_for_call( + stmt, ir, cfg, f, raw_args; depth=depth, budget_state=budget_state + ) + + isempty(alias_args) && continue + for p in alias_args + (1 <= p <= length(raw_args)) || continue + in_h = _handle_index(raw_args[p], nargs, track_arg, track_ssa) + _uf_union!(uf, out_h, in_h) + end + end + end + return uf +end + +function _binding_origins( + ir::CC.IRCode, + nargs::Int, + track_arg, + track_ssa; + unsafe_stmt::Union{Nothing,AbstractVector{Bool}}=nothing, +) + nstmts = length(ir.stmts) + origins = collect(1:(nargs + nstmts)) + closure_field_origins = Dict{Symbol,Int}() + + for idx in 1:nstmts + track_ssa[idx] || continue + hdef = _ssa_handle(nargs, idx) + stmt = ir[Core.SSAValue(idx)][:stmt] + + if unsafe_stmt !== nothing && unsafe_stmt[idx] + # In unchecked regions, treat new tracked SSA values as fresh bindings. + origins[hdef] = hdef + continue + end + + if stmt isa Core.PiNode + hsrc = _handle_index(stmt.val, nargs, track_arg, track_ssa) + origins[hdef] = (hsrc == 0) ? hdef : origins[hsrc] + continue + end + + if stmt isa Core.SSAValue || stmt isa Core.Argument + hsrc = _handle_index(stmt, nargs, track_arg, track_ssa) + origins[hdef] = (hsrc == 0) ? hdef : origins[hsrc] + continue + end + + if stmt isa Expr && stmt.head === :foreigncall + name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) + eff = if name_sym === nothing + nothing + else + _known_foreigncall_effects_get(name_sym) + end + if eff !== nothing && !isempty(eff.ret_aliases) + for p in eff.ret_aliases + (1 <= p <= length(ccall_args)) || continue + hsrc = _handle_index(ccall_args[p], nargs, track_arg, track_ssa) + hsrc == 0 && continue + origins[hdef] = origins[hsrc] + break + end + origins[hdef] != hdef && continue + end + end + + if stmt isa Expr && (stmt.head === :call || stmt.head === :invoke) + head, _mi, raw_args = _call_parts(stmt) + f = _resolve_callee(stmt, ir) + + # Compiler-generated closures repeatedly `getfield` captured environment + # fields from the callable object (`_1`). Treat those as the same binding + # origin so they don't spuriously look like multiple live bindings. + if f === Core.getfield && raw_args !== nothing && length(raw_args) >= 3 + recv = _canonical_ref(raw_args[2], ir) + fld = raw_args[3] + fldsym = fld isa QuoteNode ? fld.value : fld + if recv isa Core.Argument && recv.n == 1 && fldsym isa Symbol + origins[hdef] = get!(closure_field_origins, fldsym, hdef) + continue + end + end + + if f === __bc_bind__ || + (isdefined(Base, :inferencebarrier) && f === Base.inferencebarrier) + # Binding barriers are treated as producing a fresh identity for tracking. + origins[hdef] = hdef + continue + end + + # If this statement produces an alias of an existing handle (per ret-alias + # analysis), propagate binding origin from that input handle. + if raw_args !== nothing && f !== nothing + eff = _known_effects_get(f) + if eff !== nothing && !isempty(eff.ret_aliases) + for p in eff.ret_aliases + (1 <= p <= length(raw_args)) || continue + hsrc = _handle_index(raw_args[p], nargs, track_arg, track_ssa) + hsrc == 0 && continue + origins[hdef] = origins[hsrc] + break + end + origins[hdef] != hdef && continue + end + end + end + + origins[hdef] = hdef + end + + return origins +end diff --git a/src/safe/auto_ir.jl b/src/safe/auto_ir.jl new file mode 100644 index 0000000..ea776c4 --- /dev/null +++ b/src/safe/auto_ir.jl @@ -0,0 +1,19 @@ +export Config, BorrowCheckError, register_effects!, register_foreigncall_effects! + +import Core.Compiler +const CC = Core.Compiler + +include("utils.jl") +include("defs.jl") +include("diagnostics.jl") +include("ir_primitives.jl") +include("callsite.jl") +include("generated.jl") +include("summaries.jl") +include("refine_types.jl") +include("debug.jl") +include("alias.jl") +include("checker.jl") +include("frontend.jl") + +_refresh_generated_assert_safe() diff --git a/src/safe/callsite.jl b/src/safe/callsite.jl new file mode 100644 index 0000000..624cff8 --- /dev/null +++ b/src/safe/callsite.jl @@ -0,0 +1,519 @@ +function _call_parts(stmt) + if stmt isa Expr && stmt.head === :invoke + mi = stmt.args[1] + raw_args = stmt.args[2:end] + return (:invoke, mi, raw_args) + elseif stmt isa Expr && stmt.head === :call + raw_args = stmt.args + return (:call, nothing, raw_args) + else + return (nothing, nothing, nothing) + end +end + +# === foreigncall / ccall helpers === +# +# `ccall` / `llvmcall` lower to `Expr(:foreigncall, ...)`. +# The first five fields are metadata: +# 1: callee name / pointer +# 2: return type +# 3: argument types (typically a `Core.SimpleVector`, printed as `svec(...)`) +# 4: number of required args +# 5: calling convention (`:ccall`, `:llvmcall`, ...) +# The remaining fields are the C arguments followed by GC roots. + +function _foreigncall_nccallargs(argtypes) + argtypes isa QuoteNode && (argtypes = argtypes.value) + if argtypes isa Core.SimpleVector + return length(argtypes) + end + if argtypes isa Tuple || argtypes isa AbstractVector + return length(argtypes) + end + if argtypes isa Expr && argtypes.head === :call && !isempty(argtypes.args) + f = argtypes.args[1] + if f === Core.svec || + f === :svec || + (f isa GlobalRef && f.mod === Core && f.name === :svec) + return length(argtypes.args) - 1 + end + end + return 0 +end + +function _foreigncall_name_symbol(@nospecialize(name_expr)) + function _sym_from_tuple(@nospecialize(v)) + return if (v isa Symbol) + v + elseif (v isa Tuple && !isempty(v)) + _sym_from_tuple(v[1]) + else + nothing + end + end + + if name_expr isa QuoteNode + v = name_expr.value + # Julia lowers `ccall` names in several formats depending on version, e.g. + # - `QuoteNode(:jl_foo)` + # - `QuoteNode((:jl_foo, "libc"))` + # - `QuoteNode(((:jl_foo,),))` (nested tuples on newer nightlies) + return _sym_from_tuple(v) + end + if name_expr isa Symbol + return name_expr + end + if name_expr isa Tuple + return _sym_from_tuple(name_expr) + end + if name_expr isa Expr && name_expr.head === :tuple && !isempty(name_expr.args) + return _foreigncall_name_symbol(name_expr.args[1]) + end + if name_expr isa Expr && name_expr.head === :call && !isempty(name_expr.args) + # Library calls are often encoded as `Core.tuple(:name, lib)`; keep just the name. + f = name_expr.args[1] + if ( + f === Core.tuple || + f === :tuple || + (f isa GlobalRef && f.mod === Core && f.name === :tuple) + ) && length(name_expr.args) >= 2 + return _foreigncall_name_symbol(name_expr.args[2]) + end + end + return nothing +end + +function _foreigncall_parts(stmt::Expr) + @assert stmt.head === :foreigncall + + name_sym = _foreigncall_name_symbol(stmt.args[1]) + + argtypes = stmt.args[3] + nccallargs = _foreigncall_nccallargs(argtypes) + + ccall_start = 6 + nrem = max(length(stmt.args) - (ccall_start - 1), 0) + + # If we can't parse `argtypes`, fall back conservatively: treat *all* remaining fields + # as C arguments rather than silently dropping them as GC roots. + if nccallargs <= 0 + nreq = stmt.args[4] + if nreq isa Integer && nreq > 0 + nccallargs = Int(nreq) + else + nccallargs = nrem + end + end + + nccallargs = max(min(nccallargs, nrem), 0) + if nccallargs == 0 || ccall_start > length(stmt.args) + return name_sym, Any[], Any[], 0 + end + + ccall_stop = min(ccall_start + nccallargs - 1, length(stmt.args)) + ccall_args = stmt.args[ccall_start:ccall_stop] + gc_roots = (ccall_stop < length(stmt.args)) ? stmt.args[(ccall_stop + 1):end] : Any[] + + return name_sym, ccall_args, gc_roots, nccallargs +end + +function _foreigncall_group_used_handles( + ccall_args, group::BitSet, ir::CC.IRCode, nargs::Int, track_arg, track_ssa +) + used = BitSet() + for p in group + (1 <= p <= length(ccall_args)) || continue + union!(used, _backward_used_handles(ccall_args[p], ir, nargs, track_arg, track_ssa)) + end + return used +end + +function _resolve_callee(@nospecialize(stmt), ir::CC.IRCode) + head, mi, raw_args = _call_parts(stmt) + raw_args === nothing && return nothing + fexpr = raw_args[1] + + try + ft = _safe_argextype(fexpr, ir) + return CC.singleton_type(ft) + catch + end + + # Some calls remain "dynamic" in IR because the callee is a mutable global binding + # (e.g. `Main.eachindex`), even though at runtime it usually points to a concrete + # function value like `Base.eachindex`. For `@safe`, resolve such callees from the + # current binding to avoid spurious "unknown call" conservatism. + if fexpr isa GlobalRef + try + return getfield(fexpr.mod, fexpr.name) + catch + end + end + return nothing +end + +function _unwrap_unionall_datatype(@nospecialize(x)) + try + dt = Base.unwrap_unionall(x) + return dt isa DataType ? dt : nothing + catch + return nothing + end +end + +function _is_namedtuple_ctor(@nospecialize(f))::Bool + dt = _unwrap_unionall_datatype(f) + dt === nothing && return false + return dt.name === Base.unwrap_unionall(NamedTuple).name +end + +function _maybe_tuple_elements(@nospecialize(tup), ir::CC.IRCode) + tup isa Core.SSAValue || return nothing + sid = tup.id + 1 <= sid <= length(ir.stmts) || return nothing + def = try + ir[Core.SSAValue(sid)][:stmt] + catch + return nothing + end + def isa Expr || return nothing + if def.head === :call + f = _resolve_callee(def, ir) + if f === Core.tuple + return def.args[2:end] + end + elseif def.head === :tuple + return def.args + end + return nothing +end + +function _maybe_namedtuple_value_exprs(@nospecialize(nt), ir::CC.IRCode) + nt = _canonical_ref(nt, ir) + nt isa Core.SSAValue || return nothing + + sid = nt.id + 1 <= sid <= length(ir.stmts) || return nothing + def = try + ir[Core.SSAValue(sid)][:stmt] + catch + return nothing + end + def isa Expr || return nothing + + raw_args = if def.head === :invoke + def.args[2:end] + elseif def.head === :call + def.args + else + return nothing + end + isempty(raw_args) && return nothing + + f = raw_args[1] + if !_is_namedtuple_ctor(f) + f2 = _resolve_callee(def, ir) + (f2 !== nothing && _is_namedtuple_ctor(f2)) || return nothing + end + + if length(raw_args) == 2 + return _maybe_tuple_elements(raw_args[2], ir) + end + + return raw_args[2:end] +end + +function _kwcall_value_exprs(@nospecialize(stmt), ir::CC.IRCode) + head, _mi, raw_args = _call_parts(stmt) + raw_args === nothing && return nothing + + f = _resolve_callee(stmt, ir) + f === Core.kwcall || return nothing + + length(raw_args) >= 2 || return nothing + return _maybe_namedtuple_value_exprs(raw_args[2], ir) +end + +function _backward_used_handles(seed_expr, ir::CC.IRCode, nargs::Int, track_arg, track_ssa) + s = BitSet() + + _collect_used_handles!(s, seed_expr, nargs, track_arg, track_ssa) + + # Walk backwards through SSA definitions starting from all SSA values referenced + # by `seed_expr` and include any tracked handles reachable in their defining expressions. + nstmts = length(ir.stmts) + seed = Int[] + _collect_ssa_ids!(seed, seed_expr) + isempty(seed) && return s + + seen = falses(nstmts) + work = copy(seed) + while !isempty(work) + sid = pop!(work) + (1 <= sid <= nstmts) || continue + seen[sid] && continue + seen[sid] = true + + def = try + ir[Core.SSAValue(sid)][:stmt] + catch + continue + end + + # Do not expand through binding-barriers: they intentionally create a distinct + # binding identity. Traversing into their argument can introduce a spurious + # "second live binding" (e.g. the array literal SSA) and trigger false + # uniqueness violations (especially for foreigncalls). + if def isa Expr && (def.head === :call || def.head === :invoke) + f = _resolve_callee(def, ir) + if f === __bc_bind__ || + (isdefined(Base, :inferencebarrier) && f === Base.inferencebarrier) + hv = _handle_index(Core.SSAValue(sid), nargs, track_arg, track_ssa) + hv != 0 && push!(s, hv) + continue + end + end + + _collect_used_handles!(s, def, nargs, track_arg, track_ssa) + _collect_ssa_ids!(work, def) + end + + return s +end + +function _used_handles(stmt, ir::CC.IRCode, nargs::Int, track_arg, track_ssa) + s = if stmt isa Expr && stmt.head === :foreigncall + _backward_used_handles(stmt, ir, nargs, track_arg, track_ssa) + else + s = BitSet() + _collect_used_handles!(s, stmt, nargs, track_arg, track_ssa) + s + end + + vals = _kwcall_value_exprs(stmt, ir) + if vals !== nothing + for v in vals + _collect_used_handles!(s, v, nargs, track_arg, track_ssa) + end + end + + return s +end + +function _kwcall_tt_from_raw_args(raw_args, ir::CC.IRCode) + length(raw_args) >= 3 || return nothing + + fexpr = raw_args[3] + ft = try + _safe_argextype(fexpr, ir) + catch + return nothing + end + + fobj = try + CC.singleton_type(ft) + catch + nothing + end + if fobj === nothing + # If inference lost the singleton, fall back to resolving a GlobalRef binding. + if fexpr isa GlobalRef && isdefined(fexpr.mod, fexpr.name) + fobj = getfield(fexpr.mod, fexpr.name) + end + end + fobj === nothing && return nothing + + kwf = try + Core.kwfunc(fobj) + catch + return nothing + end + + # Build the kwfunc call tuple type: `kwf(kwargs, f, args...)` + argtypes = Any[typeof(kwf)] + + # 1) kw container + kw_t = try + CC.widenconst(_safe_argextype(raw_args[2], ir)) + catch + Any + end + push!(argtypes, (kw_t isa Type) ? kw_t : Any) + + # 2) the function value itself: use the resolved singleton's type + f_t = (fobj isa Type) ? Type{fobj} : Core.Typeof(fobj) + push!(argtypes, f_t) + + # 3) positional arguments + for i in 4:length(raw_args) + ti = try + CC.widenconst(_safe_argextype(raw_args[i], ir)) + catch + Any + end + push!(argtypes, (ti isa Type) ? ti : Any) + end + return Core.apply_type(Tuple, argtypes...) +end + +function _maybe_box_contents_type(x::Core.SSAValue, ir::CC.IRCode) + x = _canonical_ref(x, ir) + stmt = try + ir[x][:stmt] + catch + return Any + end + stmt isa Expr && stmt.head === :call || return Any + (stmt.args[1] === Core.getfield || stmt.args[1] == GlobalRef(Core, :getfield)) || + return Any + length(stmt.args) >= 3 || return Any + fld = stmt.args[3] + fldsym = fld isa QuoteNode ? fld.value : fld + fldsym === :contents || return Any + box = _canonical_ref(stmt.args[2], ir) + + # First try to recover the type from the Core.Box(init) constructor, if available. + init_ty = Any + if box isa Core.SSAValue + bstmt = try + ir[box][:stmt] + catch + nothing + end + if bstmt isa Expr && + bstmt.head === :call && + (bstmt.args[1] === Core.Box || bstmt.args[1] == GlobalRef(Core, :Box)) + if length(bstmt.args) >= 2 + init = bstmt.args[2] + init_ty = try + CC.widenconst(_safe_argextype(init, ir)) + catch + Any + end + init_ty = (init_ty isa Type) ? init_ty : Any + end + end + end + + # Otherwise (or additionally), look for writes to `box.contents`. + for i in 1:length(ir.stmts) + st = ir[Core.SSAValue(i)][:stmt] + st isa Expr && st.head === :call || continue + (st.args[1] === Core.setfield! || st.args[1] == GlobalRef(Core, :setfield!)) || + continue + length(st.args) >= 4 || continue + f = st.args[3] + fsym = f isa QuoteNode ? f.value : f + fsym === :contents || continue + box2 = _canonical_ref(st.args[2], ir) + box2 == box || continue + v = st.args[4] + t = try + CC.widenconst(_safe_argextype(v, ir)) + catch + Any + end + t = (t isa Type) ? t : Any + # If inference lost precision (t === Any), keep searching; we may still have a useful init type. + t === Any && continue + return t + end + + return init_ty +end + +function _maybe_const_type_object(fexpr, ir::CC.IRCode) + if fexpr isa GlobalRef + v = try + getfield(fexpr.mod, fexpr.name) + catch + nothing + end + return (v isa Type) ? v : nothing + end + if fexpr isa QuoteNode + v = fexpr.value + return (v isa Type) ? v : nothing + end + if fexpr isa Core.SSAValue + def = try + ir[fexpr][:stmt] + catch + nothing + end + def === nothing && return nothing + return _maybe_const_type_object(def, ir) + end + return nothing +end + +function _call_tt_from_raw_args(raw_args, ir::CC.IRCode, f_override=nothing) + types = Any[] + for (i, a) in enumerate(raw_args) + t = Any + try + at = _safe_argextype(a, ir) + if i == 1 + if f_override !== nothing + t = (f_override isa Type) ? Type{f_override} : Core.Typeof(f_override) + else + fobj = _maybe_const_type_object(a, ir) + if fobj !== nothing + t = Type{fobj} + else + fval = try + CC.singleton_type(at) + catch + nothing + end + if fval isa Type + # Constructors dispatch on `Type{T}` / `Type{UnionAll(...)}` rather than + # `DataType`, so use the singleton type of the type object when it can be + # resolved. + t = Type{fval} + else + t = CC.widenconst(at) + end + end + + @assert !(a isa GlobalRef) ( + "BorrowChecker: unexpected GlobalRef callee in call signature inference. " * + "globalref=$(a.mod).$(a.name) inferred=$(t)" + ) + end + else + t = CC.widenconst(at) + end + catch + t = Any + end + if t === Any && a isa Core.SSAValue + t = _maybe_box_contents_type(a, ir) + end + (t isa Type) || (t = Any) + push!(types, t) + end + isempty(types) && return nothing + + fT = types[1] + if fT === Any || fT isa Union + return nothing + end + dt = try + Base.unwrap_unionall(fT) + catch + return nothing + end + dt isa DataType || return nothing + if Base.isabstracttype(dt) + if !(dt.name === Base.unwrap_unionall(Type).name && !isempty(dt.parameters)) + return nothing + end + end + + try + return Tuple{types...} + catch + return nothing + end +end diff --git a/src/safe/checker.jl b/src/safe/checker.jl new file mode 100644 index 0000000..b8ecbe9 --- /dev/null +++ b/src/safe/checker.jl @@ -0,0 +1,529 @@ +function _compute_liveness( + ir::CC.IRCode, + nargs::Int, + track_arg, + track_ssa; + unsafe_stmt::Union{Nothing,AbstractVector{Bool}}=nothing, +) + blocks = ir.cfg.blocks + nblocks = length(blocks) + + phi_edge_use = [BitSet() for _ in 1:nblocks] + use = [BitSet() for _ in 1:nblocks] + def = [BitSet() for _ in 1:nblocks] + + inst2bb = zeros(Int, length(ir.stmts)) + for b in 1:nblocks + for idx in blocks[b].stmts + inst2bb[idx] = b + end + end + + for b in 1:nblocks + r = blocks[b].stmts + for idx in r + if unsafe_stmt !== nothing && unsafe_stmt[idx] + continue + end + stmt = ir[Core.SSAValue(idx)][:stmt] + if stmt isa Core.PhiNode + edges = getfield(stmt, :edges) + vals = getfield(stmt, :values) + for k in 1:length(edges) + isassigned(vals, k) || continue + edge = edges[k] + v = vals[k] + h = _handle_index(v, nargs, track_arg, track_ssa) + h == 0 && continue + @assert 1 <= edge <= length(inst2bb) && inst2bb[edge] != 0 "Unexpected IR: PhiNode.edges should contain predecessor terminator statement indices (not block IDs)." + pred_bb = inst2bb[edge] + push!(phi_edge_use[pred_bb], h) + end + elseif stmt isa Core.PhiCNode + # `PhiCNode` does not store explicit `edges` (unlike `PhiNode`). + # Conservatively attribute each value to predecessor blocks. + vals = getfield(stmt, :values) + preds = blocks[b].preds + for k in 1:length(vals) + isassigned(vals, k) || continue + v = vals[k] + h = _handle_index(v, nargs, track_arg, track_ssa) + h == 0 && continue + for pred_bb in preds + (1 <= pred_bb <= nblocks) || continue + push!(phi_edge_use[pred_bb], h) + end + end + else + break + end + end + end + + for b in 1:nblocks + seen_defs = BitSet() + for idx in blocks[b].stmts + if 1 <= idx <= length(track_ssa) && track_ssa[idx] + hdef = _ssa_handle(nargs, idx) + push!(def[b], hdef) + push!(seen_defs, hdef) + end + stmt = ir[Core.SSAValue(idx)][:stmt] + if unsafe_stmt !== nothing && unsafe_stmt[idx] + continue + end + if stmt isa Core.PhiNode || stmt isa Core.PhiCNode + continue + end + uses = _used_handles(stmt, ir, nargs, track_arg, track_ssa) + for u in uses + (u in seen_defs) || push!(use[b], u) + end + end + end + + live_in = [BitSet() for _ in 1:nblocks] + live_out = [BitSet() for _ in 1:nblocks] + + changed = true + while changed + changed = false + for b in nblocks:-1:1 + out = BitSet() + union!(out, phi_edge_use[b]) + for s in blocks[b].succs + union!(out, live_in[s]) + end + inn = BitSet() + union!(inn, use[b]) + tmp = BitSet(out) + for d in def[b] + delete!(tmp, d) + end + union!(inn, tmp) + if out != live_out[b] || inn != live_in[b] + live_out[b] = out + live_in[b] = inn + changed = true + end + end + end + + return live_in, live_out +end + +function check_ir(ir::CC.IRCode, cfg::Config)::Vector{BorrowViolation} + nargs = length(ir.argtypes) + nstmts = length(ir.stmts) + + # Improve local IR typing around Core.Box/captured values and dynamic GlobalRef calls. + refine_types!(ir, cfg) + + track_arg, track_ssa = compute_tracking_masks(ir) + + # Statements inside `@unsafe` regions are treated as opaque: we do not validate + # borrow rules within them, and we avoid propagating aliasing/escape facts from + # within them into the surrounding checked code. + unsafe_stmt = _unsafe_stmt_mask(ir) + + uf = UnionFind(nargs + nstmts) + _build_alias_classes!(uf, ir, cfg, track_arg, track_ssa, nargs; unsafe_stmt=unsafe_stmt) + origins = _binding_origins(ir, nargs, track_arg, track_ssa; unsafe_stmt=unsafe_stmt) + + live_in, live_out = _compute_liveness( + ir, nargs, track_arg, track_ssa; unsafe_stmt=unsafe_stmt + ) + + viols = BorrowViolation[] + + blocks = ir.cfg.blocks + for b in 1:length(blocks) + live = BitSet(live_out[b]) + for idx in reverse(blocks[b].stmts) + stmt = ir[Core.SSAValue(idx)][:stmt] + + in_unsafe = (1 <= idx <= length(unsafe_stmt)) && unsafe_stmt[idx] + + uses = if in_unsafe || (stmt isa Core.PhiNode || stmt isa Core.PhiCNode) + BitSet() + else + _used_handles(stmt, ir, nargs, track_arg, track_ssa) + end + live_during = BitSet(live) + union!(live_during, uses) + + if !in_unsafe + _check_stmt!( + viols, + ir, + idx, + stmt, + uf, + origins, + cfg, + nargs, + track_arg, + track_ssa, + live, + live_during, + ) + end + + if 1 <= idx <= length(track_ssa) && track_ssa[idx] + delete!(live, _ssa_handle(nargs, idx)) + end + union!(live, uses) + end + end + + return viols +end + +function _args_safe_under_unknown_consume( + args, nargs, track_arg, track_ssa, uf, origins, live_during::BitSet, live_after::BitSet +)::Bool + for arg in args + hv = _handle_index(arg, nargs, track_arg, track_ssa) + hv == 0 && continue + + rv = _uf_find(uf, hv) + ohv = origins[hv] + + for h2 in live_during + h2 == hv && continue + if _uf_find(uf, h2) == rv && origins[h2] != ohv + return false + end + end + + for h2 in live_after + if _uf_find(uf, h2) == rv + return false + end + end + end + + return true +end + +function _call_safe_under_unknown_consume( + raw_args, + extra_args, + nargs, + track_arg, + track_ssa, + uf, + origins, + live_during::BitSet, + live_after::BitSet, +) + _args_safe_under_unknown_consume( + raw_args, nargs, track_arg, track_ssa, uf, origins, live_during, live_after + ) || return false + + extra_args === nothing && return true + + return _args_safe_under_unknown_consume( + extra_args, nargs, track_arg, track_ssa, uf, origins, live_during, live_after + ) +end + +function _push_violation!( + viols::Vector{BorrowViolation}, ir::CC.IRCode, idx::Int, stmt, msg::String +) + li = _stmt_lineinfo(ir, idx) + push!(viols, BorrowViolation(idx, msg, li, stmt)) + return nothing +end + +function _check_stmt!( + viols, + ir::CC.IRCode, + idx::Int, + stmt, + uf::UnionFind, + origins::AbstractVector{Int}, + cfg::Config, + nargs::Int, + track_arg, + track_ssa, + live_after::BitSet, + live_during::BitSet, +) + if stmt isa Expr && stmt.head === :foreigncall + name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) + eff = (name_sym === nothing) ? nothing : _known_foreigncall_effects_get(name_sym) + + out_h = + (1 <= idx <= length(track_ssa) && track_ssa[idx]) ? _ssa_handle(nargs, idx) : 0 + + # Enforce uniqueness for each alias-root touched by `group_handles`, + # but allow any binding origins that arise within the same group. + function require_unique_group!(group_handles::BitSet; context::String) + roots_allowed = Dict{Int,BitSet}() + reps = Dict{Int,Int}() + + for hv in group_handles + hv == 0 && continue + hroot = _uf_find(uf, hv) + allowed = get!(roots_allowed, hroot, BitSet()) + push!(allowed, origins[hv]) + reps[hroot] = get(reps, hroot, hv) + end + + for (hroot, allowed) in roots_allowed + for h2 in live_during + (h2 == out_h) && continue + (h2 == 1) && continue + if _uf_find(uf, h2) == hroot && !(origins[h2] in allowed) + _push_violation!( + viols, + ir, + idx, + stmt, + "cannot perform $context: value is aliased by another live binding", + ) + return reps + end + end + end + + return reps + end + + # Unknown foreigncall: keep old conservative behavior. + if eff === nothing + # Treat as write to the C arguments only (ignore GC roots), but allow redundant + # `(obj, ptr, ...)` argument representations to coexist within the call. + hs_all = BitSet() + for v in ccall_args + hs = _backward_used_handles(v, ir, nargs, track_arg, track_ssa) + union!(hs_all, hs) + end + require_unique_group!(hs_all; context="write") + return nothing + end + + for grp in eff.write_groups + hs = _foreigncall_group_used_handles( + ccall_args, grp, ir, nargs, track_arg, track_ssa + ) + require_unique_group!(hs; context="write") + end + + for grp in eff.consume_groups + hs = _foreigncall_group_used_handles( + ccall_args, grp, ir, nargs, track_arg, track_ssa + ) + reps = require_unique_group!(hs; context="consume") + for (_, rep) in reps + _require_not_used_later!(viols, ir, idx, stmt, uf, origins, rep, live_after) + end + end + + return nothing + end + + head, mi, raw_args = _call_parts(stmt) + raw_args === nothing && return nothing + + f = _resolve_callee(stmt, ir) + kw_vals = (f === Core.kwcall) ? _kwcall_value_exprs(stmt, ir) : nothing + (kw_vals === nothing || isempty(kw_vals)) && (kw_vals = nothing) + + eff = _effects_for_call(stmt, ir, cfg, track_arg, track_ssa, nargs; idx=idx) + moved_positions = _moved_positions_for_eval_order_check(f, raw_args, eff, ir) + _check_call_eval_order_moves!( + viols, ir, idx, stmt, uf, moved_positions, raw_args, nargs, track_arg, track_ssa + ) + + if _call_safe_under_unknown_consume( + raw_args, kw_vals, nargs, track_arg, track_ssa, uf, origins, live_during, live_after + ) + return nothing + end + + out_h = (1 <= idx <= length(track_ssa) && track_ssa[idx]) ? _ssa_handle(nargs, idx) : 0 + + for p in eff.writes + if f === Core.kwcall && p == 2 && kw_vals !== nothing + for vkw in kw_vals + hv = _handle_index(vkw, nargs, track_arg, track_ssa) + hv == 0 && continue + _require_unique!( + viols, + ir, + idx, + stmt, + uf, + origins, + hv, + live_during; + context="write", + ignore_h=out_h, + ) + end + continue + end + + v = raw_args[p] + hv = _handle_index(v, nargs, track_arg, track_ssa) + hv == 0 && continue + _require_unique!( + viols, + ir, + idx, + stmt, + uf, + origins, + hv, + live_during; + context="write", + ignore_h=out_h, + ) + end + + for p in eff.consumes + if f === Core.kwcall && p == 2 && kw_vals !== nothing + for vkw in kw_vals + hv = _handle_index(vkw, nargs, track_arg, track_ssa) + hv == 0 && continue + _require_unique!( + viols, ir, idx, stmt, uf, origins, hv, live_during; context="consume" + ) + _require_not_used_later!(viols, ir, idx, stmt, uf, origins, hv, live_after) + end + continue + end + + v = raw_args[p] + hv = _handle_index(v, nargs, track_arg, track_ssa) + hv == 0 && continue + _require_unique!( + viols, ir, idx, stmt, uf, origins, hv, live_during; context="consume" + ) + _require_not_used_later!(viols, ir, idx, stmt, uf, origins, hv, live_after) + end + + return nothing +end + +function _moved_positions_for_eval_order_check( + @nospecialize(f), raw_args, eff::EffectSummary, ir::CC.IRCode +)::BitSet + # For most calls, use the effect summary's consume set. + # Special case: tuple construction should not allow using the same owned value + # in later elements after it has already been used in an earlier element. + if f === Core.tuple + # Ignore empty / 1-element tuples (these show up in compiler plumbing, e.g. splat containers). + length(raw_args) <= 2 && return BitSet() + + moved = BitSet() + for p in 2:length(raw_args) + Tv = _widenargtype_or_any(raw_args[p], ir) + is_owned_type(Tv) || continue + push!(moved, p) + end + return moved + end + + return eff.consumes +end + +function _check_call_eval_order_moves!( + viols, + ir::CC.IRCode, + idx::Int, + stmt, + uf::UnionFind, + moved_positions::BitSet, + raw_args, + nargs::Int, + track_arg, + track_ssa, +) + isempty(moved_positions) && return nothing + + for p in moved_positions + # raw_args[1] is the function value; treat only user arguments as moved values. + p >= 2 || continue + p <= length(raw_args) || continue + + vp = raw_args[p] + hp = _handle_index(vp, nargs, track_arg, track_ssa) + hp == 0 && continue + + rp = _uf_find(uf, hp) + + for q in (p + 1):length(raw_args) + deps = _backward_used_handles(raw_args[q], ir, nargs, track_arg, track_ssa) + for hq in deps + _uf_find(uf, hq) == rp || continue + _push_violation!( + viols, + ir, + idx, + stmt, + "call argument uses a value after it was moved by an earlier argument", + ) + return nothing + end + end + end + + return nothing +end + +function _require_unique!( + viols, + ir::CC.IRCode, + idx::Int, + stmt, + uf::UnionFind, + origins::AbstractVector{Int}, + hv::Int, + live_during::BitSet; + context::String, + ignore_h::Int=0, +) + rv = _uf_find(uf, hv) + ohv = origins[hv] + for h2 in live_during + (h2 == hv || h2 == ignore_h || h2 == 1) && continue + if _uf_find(uf, h2) == rv && origins[h2] != ohv + _push_violation!( + viols, + ir, + idx, + stmt, + "cannot perform $context: value is aliased by another live binding", + ) + return nothing + end + end +end + +function _require_not_used_later!( + viols, + ir::CC.IRCode, + idx::Int, + stmt, + uf::UnionFind, + origins::AbstractVector{Int}, + hv::Int, + live_after::BitSet, +) + rv = _uf_find(uf, hv) + for h2 in live_after + if _uf_find(uf, h2) == rv + _push_violation!( + viols, + ir, + idx, + stmt, + "value escapes/consumed by unknown call; it (or an alias) is used later", + ) + return nothing + end + end +end diff --git a/src/safe/debug.jl b/src/safe/debug.jl new file mode 100644 index 0000000..8fd5034 --- /dev/null +++ b/src/safe/debug.jl @@ -0,0 +1,336 @@ +const AUTO_DEBUG_LOCK = ReentrantLock() + +function _auto_debug_path(warn::Bool=false) + p = get(ENV, "BORROWCHECKER_AUTO_DEBUG_PATH", "") + if isempty(p) + path = joinpath(tempdir(), "BorrowChecker.auto.debug.$(getpid()).jsonl") + warn && @warn( + "BorrowChecker.@safe debug enabled; writing JSONL debug log to $path", + maxlog = 1, + ) + return path + end + return p +end + +function _auto_debug_write_json(io::IO, x) + if x === nothing + write(io, "null") + elseif x === true + write(io, "true") + elseif x === false + write(io, "false") + elseif x isa Integer + print(io, x) + elseif x isa AbstractFloat + print(io, x) + elseif x isa AbstractString + write(io, '"') + for c in x + if c == '"' + write(io, "\\\"") + elseif c == '\\' + write(io, "\\\\") + elseif c == '\n' + write(io, "\\n") + elseif c == '\r' + write(io, "\\r") + elseif c == '\t' + write(io, "\\t") + elseif c == '\b' + write(io, "\\b") + elseif c == '\f' + write(io, "\\f") + elseif Int(c) < 0x20 + print(io, "\\u", lpad(string(Int(c); base=16), 4, '0')) + else + write(io, c) + end + end + write(io, '"') + elseif x isa AbstractVector + write(io, '[') + first = true + for v in x + first || write(io, ',') + first = false + _auto_debug_write_json(io, v) + end + write(io, ']') + elseif x isa AbstractDict + write(io, '{') + first = true + for (k, v) in x + first || write(io, ',') + first = false + _auto_debug_write_json(io, String(k)) + write(io, ':') + _auto_debug_write_json(io, v) + end + write(io, '}') + else + _auto_debug_write_json(io, string(x)) + end + return nothing +end + +function _auto_debug_emit(cfg::Config, obj) + lock(AUTO_DEBUG_LOCK) do + try + open(_auto_debug_path(), "a") do io + _auto_debug_write_json(io, obj) + write(io, '\n') + end + catch + end + end + return nothing +end + +function _auto_debug_effect_summary_dict(s::EffectSummary) + return Dict( + "writes" => collect(s.writes), + "consumes" => collect(s.consumes), + "ret_aliases" => collect(s.ret_aliases), + ) +end + +function _auto_debug_cfg_dict(cfg::Config) + return Dict( + "optimize_until" => cfg.optimize_until, + "max_summary_depth" => cfg.max_summary_depth, + "scope" => String(cfg.scope), + "debug" => cfg.debug, + "debug_callee_depth" => cfg.debug_callee_depth, + ) +end + +function _auto_debug_borrow_violation_dict(v::BorrowViolation) + li = v.lineinfo + file, line = if li === nothing + (nothing, nothing) + else + try + _lineinfo_file_line(li) + catch + (nothing, nothing) + end + end + return Dict( + "idx" => v.idx, + "msg" => v.msg, + "file" => file, + "line" => line, + "stmt" => string(v.stmt), + ) +end + +function _auto_debug_summary_keys(world::UInt, cfg::Config) + Base.@lock SUMMARY_STATE begin + mi_keys = Set{Any}() + tt_keys = Set{Any}() + for k in keys(SUMMARY_STATE[].summary_cache) + (k[2] == world && k[3] == cfg) && push!(mi_keys, k) + end + for k in keys(SUMMARY_STATE[].tt_summary_cache) + (k[2] == world && k[3] == cfg) && push!(tt_keys, k) + end + return (mi_keys, tt_keys) + end +end + +function _auto_debug_collect_new_summaries(world::UInt, cfg::Config, snapshot) + snapshot === nothing && return (Any[], Any[]) + (mi0, tt0) = snapshot + new_mi = Any[] + new_tt = Any[] + + @inline function push_new!(dest, kind::String, k, entry) + push!( + dest, + Dict( + "kind" => kind, + "key" => string(k[1]), + "depth" => entry.depth, + "over_budget" => entry.over_budget, + "summary" => _auto_debug_effect_summary_dict(entry.summary), + ), + ) + return nothing + end + + Base.@lock SUMMARY_STATE begin + for (k, entry) in SUMMARY_STATE[].summary_cache + (k[2] == world && k[3] == cfg && !(k in mi0)) || continue + push_new!(new_mi, "mi", k, entry) + end + for (k, entry) in SUMMARY_STATE[].tt_summary_cache + (k[2] == world && k[3] == cfg && !(k in tt0)) || continue + push_new!(new_tt, "tt", k, entry) + end + end + return (new_mi, new_tt) +end + +function _auto_debug_ir_string(ir::CC.IRCode) + return sprint(show, ir) +end + +function _auto_debug_emit_ir_for_codes( + cfg::Config, world::UInt, tt::Type{<:Tuple}, depth::Int, codes +) + ir_entries = Any[] + for entry in codes + ir_or_err = entry.first + ty = entry.second + if ir_or_err isa CC.IRCode + push!( + ir_entries, + Dict( + "ir" => _auto_debug_ir_string(ir_or_err), "inferred_type" => string(ty) + ), + ) + else + push!( + ir_entries, + Dict("ir_error" => string(ir_or_err), "inferred_type" => string(ty)), + ) + end + end + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_ir", + "time_ns" => time_ns(), + "tt" => string(tt), + "depth" => depth, + "optimize_until" => cfg.optimize_until, + "entries" => ir_entries, + ), + ) + return nothing +end + +function _auto_debug_emit_ir_for_tt(cfg::Config, world::UInt, tt::Type{<:Tuple}, depth::Int) + codes = try + _code_ircode_by_type(tt; optimize_until=cfg.optimize_until, world=world, cfg) + catch e + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_ir_error", + "time_ns" => time_ns(), + "tt" => string(tt), + "depth" => depth, + "error" => sprint(showerror, e), + ), + ) + return nothing + end + return _auto_debug_emit_ir_for_codes(cfg, world, tt, depth, codes) +end + +function _auto_debug_emit_check!( + tt::Type{<:Tuple}, + cfg::Config, + world::UInt, + summary_snapshot, + ok::Bool, + violations::Vector{BorrowViolation}, + err, + bt, + entry_codes, +) + t_ns = time_ns() + err_s = if err === nothing + nothing + else + try + sprint(showerror, err, bt) + catch + try + sprint(showerror, err) + catch + string(err) + end + end + end + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_check", + "time_ns" => t_ns, + "time_s" => t_ns * 1e-9, + "path" => _auto_debug_path(), + "julia_version" => VERSION, + "world" => world, + "tt" => string(tt), + "cfg" => _auto_debug_cfg_dict(cfg), + "ok" => ok, + "error" => err_s, + ), + ) + + if !ok && err_s !== nothing + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_error", + "time_ns" => time_ns(), + "tt" => string(tt), + "error" => err_s, + ), + ) + end + + if !isempty(violations) + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_violations", + "time_ns" => time_ns(), + "tt" => string(tt), + "violations" => map(_auto_debug_borrow_violation_dict, violations), + ), + ) + end + + (new_mi, new_tt) = _auto_debug_collect_new_summaries(world, cfg, summary_snapshot) + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_summaries", + "time_ns" => time_ns(), + "tt" => string(tt), + "new_mi_summaries" => new_mi, + "new_tt_summaries" => new_tt, + ), + ) + + # IR dumping can be *very* expensive. We already computed `entry_codes` in the + # normal checking path; reuse it rather than calling back into inference again. + if entry_codes !== nothing + try + _auto_debug_emit_ir_for_codes(cfg, world, tt, 0, entry_codes) + catch + end + end + if cfg.debug_callee_depth > 0 + tt0 = summary_snapshot === nothing ? Set{Any}() : summary_snapshot[2] + Base.@lock SUMMARY_STATE begin + for (k, entry) in SUMMARY_STATE[].tt_summary_cache + (k[2] == world && k[3] == cfg) || continue + (k in tt0) && continue + entry.depth <= cfg.debug_callee_depth || continue + k1 = k[1] + k1 isa Type{<:Tuple} || continue + try + _auto_debug_emit_ir_for_tt(cfg, world, k1, entry.depth) + catch + end + end + end + end + + return nothing +end diff --git a/src/safe/defs.jl b/src/safe/defs.jl new file mode 100644 index 0000000..b9306c3 --- /dev/null +++ b/src/safe/defs.jl @@ -0,0 +1,295 @@ +function _default_optimize_until() + if isdefined(CC, :ALL_PASS_NAMES) + for nm in CC.ALL_PASS_NAMES + s = String(nm) + endswith(s, "COMPACT_1") && return s + end + for nm in CC.ALL_PASS_NAMES + s = String(nm) + occursin("COMPACT", s) && occursin("1", s) && return s + end + return String(CC.ALL_PASS_NAMES[begin + 2]) # best-effort guess for new compiler pass name + end + return "compact 1" +end + +Base.@kwdef struct Config + "Which compiler pass to stop at when fetching IR (`Base.code_ircode_by_type`)." + optimize_until::String = _default_optimize_until() + + "Max depth for recursive effect summarization." + max_summary_depth::Int = 12 + + "Recursively borrow-check callees (call graph) within this scope." + scope::Symbol = :function + + "Root module used by `scope=:module`." + root_module::Module = Main + + """ + Enable debug logging to a JSONL file (best-effort). + + The output path is controlled by the `BORROWCHECKER_AUTO_DEBUG_PATH` environment variable. + """ + debug::Bool = false + + """ + Max depth of summary-recursion for which `@safe debug=true` also dumps IR. + + Depth is measured in the recursive effect summarizer (0 = the entrypoint specialization). + """ + debug_callee_depth::Int = 2 +end + +@generated function __bc_bind__(x::T) where {T} + # Preserve constant propagation for isbits values (e.g. value type parameters). + # For non-isbits values, keep the inference barrier so the compiler doesn't + # collapse bindings in ways that confuse our alias/origin tracking. + if Base.isbitstype(T) + return quote + Base.@_inline_meta + x + end + end + # Type objects are also immutable + if T <: Type + return quote + Base.@_inline_meta + x + end + end + if isdefined(Base, :inferencebarrier) + return quote + Base.@_inline_meta + Base.inferencebarrier(x)::T + end + else + return quote + Base.@_inline_meta + x + end + end +end + +struct EffectSummary + # Indices are in the *raw call argument list* used by the SSA form: + # raw_args[1] is the function value, raw_args[2] is the first user argument, etc. + writes::BitSet # arguments that may be mutated during the call + consumes::BitSet # arguments that may escape/need to be treated as consumed + ret_aliases::BitSet # arguments that the return value may alias +end +function EffectSummary(; writes=Int[], consumes=Int[], ret_aliases=Int[]) + return EffectSummary(BitSet(writes), BitSet(consumes), BitSet(ret_aliases)) +end + +""" + ForeigncallEffectSummary + +Effect summary for `Expr(:foreigncall, ...)` nodes (lowered `ccall` / `llvmcall`). + +Positions are **1-based C-argument positions**, where position 1 corresponds to the first +actual C argument (`stmt.args[6]`) after the foreigncall metadata. + +`writes` / `consumes` are specified as *groups*: each element may be an `Int` (singleton +group) or an iterable of `Int`s (multi-arg group). Grouping is important for common +patterns like `(obj, ptr)` argument pairs that must be treated as one logical resource +for uniqueness. +""" +struct ForeigncallEffectSummary + write_groups::Vector{BitSet} + consume_groups::Vector{BitSet} + ret_aliases::BitSet +end + +function _normalize_foreigncall_groups(spec) + spec isa Integer && return BitSet[BitSet((Int(spec),))] + groups = BitSet[] + for g in spec + if g isa Integer + push!(groups, BitSet((Int(g),))) + else + push!(groups, BitSet(collect(Int, g))) + end + end + return groups +end + +function ForeigncallEffectSummary(; writes=(), consumes=(), ret_aliases=()) + ret_aliases isa Integer && (ret_aliases = (Int(ret_aliases),)) + return ForeigncallEffectSummary( + _normalize_foreigncall_groups(writes), + _normalize_foreigncall_groups(consumes), + BitSet(collect(Int, ret_aliases)), + ) +end + +const KNOWN_EFFECTS = Lockable(IdDict{Any,EffectSummary}()) +const KNOWN_FOREIGNCALL_EFFECTS = Lockable(Dict{Symbol,ForeigncallEffectSummary}()) + +@inline function _known_effects_get(@nospecialize(f)) + return @lock KNOWN_EFFECTS get(KNOWN_EFFECTS[], f, nothing) +end + +@inline function _known_effects_has(@nospecialize(f))::Bool + return @lock KNOWN_EFFECTS haskey(KNOWN_EFFECTS[], f) +end + +function register_effects!(@nospecialize(f); writes=(), consumes=(), ret_aliases=()) + @lock KNOWN_EFFECTS begin + dict = KNOWN_EFFECTS[] + dict[f] = EffectSummary(; + writes=collect(Int, writes), + consumes=collect(Int, consumes), + ret_aliases=collect(Int, ret_aliases), + ) + end + return f +end + +@inline function _known_foreigncall_effects_get(name::Symbol) + return @lock KNOWN_FOREIGNCALL_EFFECTS get(KNOWN_FOREIGNCALL_EFFECTS[], name, nothing) +end + +@inline function _known_foreigncall_effects_has(name::Symbol)::Bool + return @lock KNOWN_FOREIGNCALL_EFFECTS haskey(KNOWN_FOREIGNCALL_EFFECTS[], name) +end + +function register_foreigncall_effects!(name::Symbol; writes=(), consumes=(), ret_aliases=()) + @lock KNOWN_FOREIGNCALL_EFFECTS begin + KNOWN_FOREIGNCALL_EFFECTS[][name] = ForeigncallEffectSummary(; + writes=writes, consumes=consumes, ret_aliases=ret_aliases + ) + end + return name +end + +const REGISTRY_INITED = Lockable(Ref{Bool}(false)) + +function _populate_registry!() + _known_effects_has(__bc_bind__) || register_effects!(__bc_bind__; ret_aliases=(2,)) + # `@safe scope=...` builds a `Config` object at runtime for the prologue check. + # This constructor is internal plumbing and should be treated as pure. + _known_effects_has(Config) || register_effects!(Config; ret_aliases=()) + + if isdefined(BorrowChecker, :__bc_assert_safe__) + f = BorrowChecker.__bc_assert_safe__ + _known_effects_has(f) || register_effects!(f; ret_aliases=()) + end + + # NOTE: For a Rust-like borrow checker, *storing* a tracked value into mutable memory + # must be treated as an escape/move of that value. + specs = [ + (Core, :tuple, (), (), ()), + (Core, :apply_type, (), (), ()), + (Core, :typeof, (), (), ()), + (Core, :_typeof_captured_variable, (), (), ()), + (Core, :Typeof, (), (), ()), + (Core, :isa, (), (), ()), + (Core, :has_free_typevars, (), (), ()), + (Core, :_typevar, (), (), ()), + (Core, :ArgumentError, (), (), ()), + (Core, :InexactError, (), (), ()), + (Core, :BoundsError, (), (), ()), + # Core builtins/intrinsics that are used throughout Base and may not be reflectable. + (Core, :bitcast, (3,), (), ()), + (Core, :compilerbarrier, (3,), (), ()), + (Core, :_svec_ref, (), (), ()), + (Core, :_svec_len, (), (), ()), + (Core, :isdefined, (), (), ()), + (Core, :throw, (), (), ()), + (Core, :(<:), (), (), ()), + (Core, :(===), (), (), ()), + (Core, :(!==), (), (), ()), + (Core, :typeassert, (2,), (), ()), + (Core, :getfield, (2,), (), ()), + # setfield!(obj, field, val) mutates `obj` (arg2) and stores `val` (arg4). + # Storing an owned value is treated as a move/escape (filtered by `is_owned_type`). + (Core, :setfield!, (), (2,), (4,)), + # Field "write" family. All mutate the receiver (arg2) and store a value argument. + (Core, :swapfield!, (), (2,), (4,)), # swapfield!(obj, field, val, ...) + (Core, :modifyfield!, (), (2,), (5,)), # modifyfield!(obj, field, op, val, ...) + (Core, :replacefield!, (), (2,), (5,)), # replacefield!(obj, field, expected, val, ...) + (Core, :setfieldonce!, (), (2,), (4,)), # setfieldonce!(obj, field, val, ...) + + # `memoryref*` family. These are used by Base array code. They exist in `Core` + # on Julia 1.12+; some are also exported from `Base` as aliases of the same function. + (Core, :memoryrefnew, (2,), (), ()), + (Core, :memoryref, (2,), (), ()), + (Core, :memoryrefoffset, (2,), (), ()), + (Core, :memoryrefget, (2,), (), ()), + (Core, :memoryrefset!, (), (2,), (3,)), + (Core, :memoryrefswap!, (), (2,), (3,)), + (Core, :memoryrefmodify!, (), (2,), (4,)), + (Core, :memoryrefreplace!, (), (2,), (4,)), + (Core, :memoryrefsetonce!, (), (2,), (3,)), + (Core, :memorynew, (), (), ()), + + # Pointer intrinsics: + # `pointerset(ptr, val, idx, align)` mutates memory through `ptr` and often + # appears as an intrinsic (no reflectable IR), so register it explicitly. + (Core.Intrinsics, :pointerset, (2,), (2,), ()), + + # Misc: + # `Task(f)` needs special handling because it relies on unsafe operations internally. + (Base, :Task, (), (), (2,)), + ] + + for (mod, nm, ret_aliases, writes, consumes) in specs + isdefined(mod, nm) || continue + f = getfield(mod, nm) + _known_effects_has(f) || + register_effects!(f; writes=writes, consumes=consumes, ret_aliases=ret_aliases) + end + + # Foreigncall effects (lowered `ccall` / `llvmcall`). + # Positions are 1-based in the foreigncall C argument list (position 1 == stmt.args[6]). + foreigncall_specs = [ + (:memmove, (1,), (1,), ()), + (:memcpy, (1,), (1,), ()), + (:memset, (1,), (1,), ()), + + # Mutates destination represented by the `(dest_mem, dest_ptr)` pair in: + # jl_genericmemory_copyto(dest_mem::Any, dest_ptr::Ptr, src_mem::Any, src_ptr::Ptr, n::Int) + (:jl_genericmemory_copyto, (), ((1, 2),), ()), + + # Read-only foreigncalls used throughout Base. + (:jl_object_id, (), (), ()), + (:jl_type_hash, (), (), ()), + (:jl_type_unionall, (), (), ()), + (:jl_eqtable_get, (), (), ()), + (:jl_eqtable_nextind, (), (), ()), + (:jl_get_fieldtypes, (), (), ()), + (:jl_field_index, (), (), ()), + (:jl_gc_new_weakref_th, (), (), ()), + (:jl_value_ptr, (), (), ()), + ] + + for (nm, ret_aliases, writes, consumes) in foreigncall_specs + _known_foreigncall_effects_has(nm) || register_foreigncall_effects!( + nm; writes=writes, consumes=consumes, ret_aliases=ret_aliases + ) + end + + return nothing +end + +function _ensure_registry_initialized() + @lock REGISTRY_INITED begin + r = REGISTRY_INITED[] + if !r[] + _populate_registry!() + r[] = true + end + end + return nothing +end + +# === `@unsafe` (auto-IR) support === +# +# `BorrowChecker.@unsafe` expands to: +# - `Expr(:meta, :borrow_checker_unsafe, unsafe_file_sym)` (not executed) +# - `Expr(:meta, :push_loc, unsafe_file_sym, :borrow_checker_unsafe)` / `Expr(:meta, :pop_loc)` (not executed) +# - the real executable block (executed normally) +# +# The compiler preserves `Expr(:meta, ...)` entries in `IRCode.meta`. +const BC_UNSAFE_META = :borrow_checker_unsafe diff --git a/src/safe/diagnostics.jl b/src/safe/diagnostics.jl new file mode 100644 index 0000000..90fb852 --- /dev/null +++ b/src/safe/diagnostics.jl @@ -0,0 +1,379 @@ +struct BorrowViolation + idx::Int + msg::String + lineinfo::Union{Nothing,Any} + stmt::Any +end + +""" + BorrowCheckError <: Exception + +Thrown by [`BorrowChecker.@safe`](@ref) when a method specialization violates +borrow-checking rules. Carries the checked signature (`tt`) and the list of +individual `BorrowViolation`s; `showerror` renders a source-level diagnostic +for each violation. +""" +struct BorrowCheckError <: Exception + tt::Any + violations::Vector{BorrowViolation} +end + +struct CachedFileLines + mtime::Float64 + size::Int64 + lines::Vector{String} +end + +const SRCFILE_CACHE = Lockable(Dict{String,CachedFileLines}()) + +@inline function _lineinfo_file_line(li) + file = try + f = getproperty(li, :file) + f === nothing ? nothing : String(f) + catch + nothing + end + line = try + l = getproperty(li, :line) + l === nothing ? nothing : Int(l) + catch + nothing + end + return file, line +end + +const REPL_FILE_RE = r"^REPL\[(\d+)\]$" +const REPL_LINEMARK_RE = r"^\s*#=\s*REPL\[\d+\]:\d+\s*=#\s*$" + +function _repl_hist_entry_content(entry) + entry isa AbstractString && return String(entry) + if hasproperty(entry, :content) + c = try + getproperty(entry, :content) + catch + nothing + end + c isa AbstractString && return String(c) + end + return nothing +end + +function _try_repl_history_provider() + isdefined(Base, :active_repl) || return nothing + repl = Base.active_repl + repl === nothing && return nothing + + hp = nothing + try + iface = getproperty(repl, :interface) + modes = getproperty(iface, :modes) + if modes isa AbstractVector + for mode in modes + hasproperty(mode, :hist) || continue + cand = getproperty(mode, :hist) + cand === nothing && continue + hasproperty(cand, :history) || continue + hp = cand + break + end + end + catch + hp = nothing + end + return hp +end + +function _try_repl_source_lines(file::AbstractString, line::Int) + line <= 0 && return nothing + + m = match(REPL_FILE_RE, file) + m === nothing && return nothing + + hp = _try_repl_history_provider() + hp === nothing && return nothing + + cap = m.captures[1] + cap === nothing && return nothing + n = parse(Int, cap) + n <= 0 && return nothing + + hist = try + getproperty(hp, :history) + catch + return nothing + end + hist isa AbstractVector || return nothing + isempty(hist) && return nothing + + function usable_lines(src) + src_str = _repl_hist_entry_content(src) + src_str === nothing && return nothing + src_str = replace(src_str, '\r' => "") + lines = split(src_str, '\n'; keepempty=true) + (1 <= line <= length(lines)) || return nothing + l = strip(lines[line]) + isempty(l) && return nothing + occursin(REPL_LINEMARK_RE, l) && return nothing + return lines + end + + # First try the direct "REPL[n]" indexing heuristics. + if hasproperty(hp, :start_idx) + start_idx = try + getproperty(hp, :start_idx) + catch + nothing + end + if start_idx isa Integer + for idx in (Int(start_idx) + n, Int(start_idx) + n - 1) + (1 <= idx <= length(hist)) || continue + lines = usable_lines(hist[idx]) + lines !== nothing && return lines + end + end + end + + for idx in (n, n - 1) + (1 <= idx <= length(hist)) || continue + lines = usable_lines(hist[idx]) + lines !== nothing && return lines + end + + # Fallback: scan recent history for a multi-line entry that has a usable target line. + # This is robust against REPL/history behavior changes across Julia versions. + for idx in length(hist):-1:1 + src = _repl_hist_entry_content(hist[idx]) + src === nothing && continue + occursin('\n', src) || continue + lines = usable_lines(src) + lines !== nothing && return lines + end + + return nothing +end + +function _try_source_lines(file::AbstractString, line::Int) + if isfile(file) + return _read_file_lines(String(file)) + end + return _try_repl_source_lines(file, line) +end + +function _lineinfo_chain(li::Core.LineInfoNode) + chain = Core.LineInfoNode[] + cur = li + while cur isa Core.LineInfoNode + push!(chain, cur) + cur = try + getproperty(cur, :inlined_at) + catch + nothing + end + end + return chain +end + +function _read_file_lines(file::String) + st = try + stat(file) + catch + return String[] + end + + mtime = Float64(st.mtime) + size = Int64(st.size) + + @lock SRCFILE_CACHE begin + cache = SRCFILE_CACHE[] + entry = get(cache, file, nothing) + if entry !== nothing && entry.mtime == mtime && entry.size == size + return entry.lines + end + + lines = try + readlines(file) + catch + String[] + end + cache[file] = CachedFileLines(mtime, size, lines) + return lines + end +end + +function _recover_callee_from_tt(tt) + try + tt_u = Base.unwrap_unionall(tt) + tt_u isa DataType || return (nothing, nothing) + ps = tt_u.parameters + isempty(ps) && return (nothing, nothing) + fT = ps[1] + Base.issingletontype(fT) || return (nothing, nothing) + f = getfield(fT, :instance) + argT = Tuple{ps[2:end]...} + return (f, argT) + catch + return (nothing, nothing) + end +end + +function _print_source_context(io::IO, tt, li; context::Int=0) + file, line = if li isa Core.LineInfoNode || li isa LineNumberNode + _lineinfo_file_line(li) + else + return nothing + end + (file === nothing || line === nothing) && return nothing + + if li isa Core.LineInfoNode + chain = _lineinfo_chain(li) + for (k, c) in enumerate(chain) + f, l = _lineinfo_file_line(c) + (f === nothing || l === nothing) && continue + if k == 1 + println(io, " at ", f, ":", l) + else + println(io, " inlined at ", f, ":", l) + end + end + else + println(io, " at ", file, ":", line) + end + + lines = _try_source_lines(file, line) + if lines !== nothing && 1 <= line <= length(lines) + lo = max(1, line - context) + hi = min(length(lines), line + context) + for ln in lo:hi + prefix = (ln == line) ? " > " : " " + println(io, prefix, rpad(string(ln), 5), " ", lines[ln]) + end + return nothing + end + + f, argT = _recover_callee_from_tt(tt) + (f === nothing || argT === nothing) && return nothing + + cis = try + Base.code_lowered(f, argT; debuginfo=:source) + catch + try + Base.code_lowered(f, argT) + catch + Any[] + end + end + isempty(cis) && return nothing + + filesym = Symbol(file) + for ci in cis + ci isa Core.CodeInfo || continue + buf = Any[] + + collecting = false + for st in ci.code + if st isa LineNumberNode + if collecting + break + end + collecting = (st.file == filesym && st.line == line) + continue + end + collecting || continue + push!(buf, st) + end + + if isempty(buf) + def = try + which(f, argT) + catch + nothing + end + + if def !== nothing + last_file = Symbol("") + last_line = 0 + first_idx = 0 + for i in 1:length(ci.code) + scopes = Base.Compiler.IRShow.buildLineInfoNode(ci.debuginfo, def, i) + if !isempty(scopes) + li = scopes[1] + last_file = li.file + last_line = Int(li.line) + end + if last_file == filesym && last_line == line + first_idx = i + break + end + end + + if first_idx != 0 + for j in first_idx:length(ci.code) + scopes = Base.Compiler.IRShow.buildLineInfoNode( + ci.debuginfo, def, j + ) + if !isempty(scopes) + li = scopes[1] + last_file = li.file + last_line = Int(li.line) + end + (last_file == filesym && last_line == line) || break + push!(buf, ci.code[j]) + end + end + end + end + + if !isempty(buf) + println(io, " lowered:") + for ex in buf + s = try + sprint(show, ex) + catch + "" + end + isempty(s) || println(io, " ", s) + end + break + end + + println(io, " lowered:") + n = min(6, length(ci.code)) + for i in 1:n + s = try + sprint(show, ci.code[i]) + catch + "" + end + isempty(s) || println(io, " ", s) + end + break + end + + return nothing +end + +function Base.showerror(io::IO, e::BorrowCheckError) + print(io, "BorrowCheckError for specialization ", e.tt) + + try + (f, argT) = _recover_callee_from_tt(e.tt) + m = which(f, argT) + print(io, "\n\n method: ", m) + catch + end + + for (i, v) in enumerate(e.violations) + println(io) + println(io) + print(io, " [", i, "] stmt#", v.idx, ": ", v.msg) + if v.lineinfo !== nothing + try + _print_source_context(io, e.tt, v.lineinfo; context=2) + catch + println(io, " ", v.lineinfo) + end + end + println(io) + print(io, " stmt: ", v.stmt) + end +end diff --git a/src/safe/frontend.jl b/src/safe/frontend.jl new file mode 100644 index 0000000..59211d3 --- /dev/null +++ b/src/safe/frontend.jl @@ -0,0 +1,780 @@ +""" +Run BorrowCheck on a concrete specialization `tt::Type{<:Tuple}`. + +Returns `true` on success; throws `BorrowCheckError` on failure. +""" +const CheckedCacheSig = Tuple{String,Int,Symbol,Module,Bool,Int} + +@inline function _checked_cache_sig(cfg::Config) + return ( + cfg.optimize_until, + cfg.max_summary_depth, + cfg.scope, + cfg.root_module, + cfg.debug, + cfg.debug_callee_depth, + )::CheckedCacheSig +end + +const CHECKED_CACHE = Lockable(IdDict{Any,Tuple{UInt,CheckedCacheSig}}()) # Type{Tuple...} => (world, sig) +const PER_TASK_CHECKED_CACHE = PerTaskCache{IdDict{Any,Tuple{UInt,CheckedCacheSig}}}() + +# Marker for "currently being checked". Prevents infinite recursion when `scope` +# triggers re-entrant borrow-checking of the same specialization. +const BC_INPROGRESS_WORLD = typemax(UInt) + +function _tt_module(tt::Type{<:Tuple}) + tt_u = Base.unwrap_unionall(tt) + tt_u isa DataType || return nothing + isempty(tt_u.parameters) && return nothing + + fT = tt_u.parameters[1] + dt = Base.unwrap_unionall(fT) + dt isa DataType || return nothing + + m = dt.name.module + if dt.name === Base.unwrap_unionall(Type).name && !isempty(dt.parameters) + targ = Base.unwrap_unionall(dt.parameters[1]) + targ isa DataType && (m = targ.name.module) + end + return m +end + +function _module_is_under(m::Module, root::Module)::Bool + mm = m + while true + mm === root && return true + parent = Base.parentmodule(mm) + parent === mm && return false + mm = parent + end +end + +function _scope_allows_module(m::Module, cfg::Config)::Bool + # Never recursively borrow-check BorrowChecker itself. + m === BorrowChecker && return false + + cfg.scope === :all && return true + if cfg.scope === :none || cfg.scope === :function + return false + elseif cfg.scope === :module + return m === cfg.root_module + elseif cfg.scope === :user + # "user" means: only recurse into user code (no Core/Base, including submodules). + return !(_module_is_under(m, Base) || _module_is_under(m, Core)) + end + throw(ArgumentError("unknown scope: $(cfg.scope)")) +end + +function _scope_allows_tt(tt::Type{<:Tuple}, cfg::Config)::Bool + m = _tt_module(tt) + m === nothing && return false + return _scope_allows_module(m, cfg) +end + +function _callsite_method_module(i::Int, head, mi, ir::CC.IRCode) + if head === :invoke && mi !== nothing + try + return getfield(getfield(mi, :def), :module) + catch + end + return nothing + end + + info = try + ir[Core.SSAValue(i)][:info] + catch + nothing + end + try + info === nothing && return nothing + + callinfo = if hasproperty(info, :call) + getproperty(info, :call) + else + info + end + + hasproperty(callinfo, :results) || return nothing + lr = getproperty(callinfo, :results) + hasproperty(lr, :matches) || return nothing + matches = getproperty(lr, :matches) + length(matches) == 1 || return nothing + mm = matches[1] + hasproperty(mm, :method) || return nothing + meth = getproperty(mm, :method) + hasproperty(meth, :module) || return nothing + return getproperty(meth, :module) + catch + return nothing + end +end + +function _apply_iterate_inner_tt(raw_args, ir::CC.IRCode) + length(raw_args) >= 3 || return nothing + inner_f = try + CC.singleton_type(_safe_argextype(raw_args[3], ir)) + catch + nothing + end + inner_f === nothing && return nothing + + expanded_types = Any[typeof(inner_f)] + for j in 4:length(raw_args) + argj = raw_args[j] + elems = _maybe_tuple_elements(argj, ir) + if elems !== nothing + for e in elems + push!(expanded_types, _widenargtype_or_any(e, ir)) + end + continue + end + + Tj = _widenargtype_or_any(argj, ir) + Tj === Tuple{} && continue + dt = Base.unwrap_unionall(Tj) + if dt isa DataType && dt.name === Tuple.name + params = dt.parameters + has_vararg = any(p -> p isa Core.TypeofVararg, params) + if !has_vararg + for te in params + te2 = Base.unwrap_unionall(te) + push!(expanded_types, (te2 isa Type) ? te2 : Any) + end + continue + end + end + + push!(expanded_types, Tj) + end + + try + return Core.apply_type(Tuple, expanded_types...) + catch + return nothing + end +end + +function _check_ir_callees!(ir::CC.IRCode, cfg::Config, world::UInt) + (cfg.scope === :none || cfg.scope === :function) && return nothing + + unsafe_stmt = _unsafe_stmt_mask(ir) + + nstmts = length(ir.stmts) + for i in 1:nstmts + (1 <= i <= length(unsafe_stmt) && unsafe_stmt[i]) && continue + stmt = ir[Core.SSAValue(i)][:stmt] + head, mi, raw_args = _call_parts(stmt) + raw_args === nothing && continue + + f = _resolve_callee(stmt, ir) + f === __bc_bind__ && continue + f === __bc_assert_safe__ && continue + + tt = if f === Core._apply_iterate + _apply_iterate_inner_tt(raw_args, ir) + elseif f === Core.kwcall + _kwcall_tt_from_raw_args(raw_args, ir) + elseif head === :invoke && mi !== nothing + try + mi.specTypes + catch + nothing + end + else + _call_tt_from_raw_args(raw_args, ir, f) + end + tt === nothing && continue + tt isa Type{<:Tuple} || continue + m = _callsite_method_module(i, head, mi, ir) + m_precise = m !== nothing + if m === nothing + m = _tt_module(tt) + m === nothing && continue + end + + # Performance guard: for `scope=:all`, avoid recursively checking Base/Core callees. + # Base/Core borrow-check errors are treated as non-fatal anyway, and walking the entire + # Base/Core call graph can make `scope=:all` unusably slow. + if cfg.scope === :all && + m_precise && + (_module_is_under(m, Base) || _module_is_under(m, Core)) + continue + end + _scope_allows_module(m, cfg) || continue + + try + __bc_assert_safe__(tt; cfg=cfg, world=world) + catch e + # `scope=:all` is intentionally aggressive and compiler-dependent. Base/Core IR + # routinely uses low-level memory primitives that can trigger spurious violations. + # Treat these as non-fatal so `scope=:all` remains usable for user-code debugging. + if cfg.scope === :all && + (e isa BorrowCheckError) && + (_module_is_under(m, Base) || _module_is_under(m, Core)) + continue + end + rethrow() + end + end + + return nothing +end + +function check_signature( + tt::Type{<:Tuple}; cfg::Config=Config(), world::UInt=Base.get_world_counter() +) + @nospecialize tt + _ensure_registry_initialized() + return _with_reflection_ctx(world) do + summary_snapshot = cfg.debug ? _auto_debug_summary_keys(UInt(world), cfg) : nothing + debug_ok = true + debug_violations = BorrowViolation[] + debug_err = nothing + debug_bt = nothing + entry_codes = nothing + + try + codes = _code_ircode_by_type( + tt; optimize_until=cfg.optimize_until, world=world, cfg + ) + entry_codes = codes + viols = BorrowViolation[] + for entry in codes + ir = entry.first + ir isa CC.IRCode || continue + append!(viols, check_ir(ir, cfg)) + _check_ir_callees!(ir, cfg, world) + end + isempty(viols) || throw(BorrowCheckError(tt, viols)) + return true + catch e + debug_ok = false + debug_err = e + debug_bt = catch_backtrace() + if e isa BorrowCheckError + append!(debug_violations, e.violations) + end + rethrow() + finally + if cfg.debug + try + _auto_debug_emit_check!( + tt, + cfg, + UInt(world), + summary_snapshot, + debug_ok, + debug_violations, + debug_err, + debug_bt, + entry_codes, + ) + catch + end + end + end + end +end + +Base.@noinline function __bc_assert_safe__( + tt::Type{<:Tuple}; cfg::Config=Config(), world::UInt=Base.get_world_counter() +) + @nospecialize tt + task_cache = PER_TASK_CHECKED_CACHE[] + sig = _checked_cache_sig(cfg) + + # Fast path: per-task cache (no locking). + state = get(task_cache, tt, nothing) + if state !== nothing + world0, sig0 = state + if world0 == world && sig0 == sig + return nothing + end + end + + # Slow path: shared cache (locked). + # Lock spans the entire inference so we avoid repeated inference. + Base.@lock CHECKED_CACHE begin + dict = CHECKED_CACHE[] + state = get(dict, tt, nothing) + if state !== nothing + world0, sig0 = state + if world0 == world && sig0 == sig + task_cache[tt] = state + return nothing + end + if world0 == BC_INPROGRESS_WORLD && sig0 == sig + return nothing + end + end + + dict[tt] = (BC_INPROGRESS_WORLD, sig) + try + check_signature(tt; cfg=cfg, world=world) + catch + delete!(dict, tt) + rethrow() + end + new_state = (world, sig) + dict[tt] = new_state + task_cache[tt] = new_state + return nothing + end +end + +# Extract the call expression from a signature (handles where/return-type annotations). +function _sig_call(sig) + while sig isa Expr && sig.head === :where + sig = sig.args[1] + end + if sig isa Expr && sig.head === :(::) + sig = sig.args[1] + end + return sig +end + +function _fval_expr_from_sigcall(call) + fhead = call.args[1] + if fhead isa Symbol + return fhead + elseif fhead isa Expr && fhead.head === :(::) + # (f::T)(args...) form + return fhead.args[1] + else + return fhead + end +end + +function _argref_expr(arg) + if arg isa Symbol + return arg + elseif arg isa Expr && arg.head === :(::) + return arg.args[1] + elseif arg isa Expr && arg.head === :kw + return _argref_expr(arg.args[1]) + elseif arg isa Expr && arg.head === :... + inner = _argref_expr(arg.args[1]) + return Expr(:..., inner) + elseif arg isa Expr && arg.head === :parameters + # keyword argument container; ignore for type tuple construction + return nothing + else + return arg + end +end + +function _tt_expr_from_signature(sig, cfg_tag) + call = _sig_call(sig) + call isa Expr && call.head === :call || + error("@safe currently supports standard function signatures") + fval = _fval_expr_from_sigcall(call) + + params = Any[cfg_tag, :(Core.Typeof($fval))] + for a in call.args[2:end] + # Anonymous typed arguments appear as `(::T)` or `(::T=default)` in the AST. + # These do not have a runtime value binding, so we cannot take `Core.Typeof` of them. + if a isa Expr && a.head === :kw + a = a.args[1] + end + + if a isa Expr && a.head === :(::) && length(a.args) == 1 + push!(params, a.args[1]) + continue + end + + r = _argref_expr(a) + r === nothing && continue + + if r isa Expr && r.head === :... + t = Expr(:tuple, r) + push!(params, Expr(:..., :(map(Core.Typeof, $t)))) + else + push!(params, :(Core.Typeof($r))) + end + end + + return Expr(:curly, :Tuple, params...) +end + +function _is_method_definition_lhs(lhs) + lhs isa Expr || return false + # Local method definition forms appear as assignment with a call-like LHS. + # Be careful not to treat typed variable assignments like `x::T = rhs` as a + # method definition. + call = lhs + while call isa Expr && call.head === :where + call = call.args[1] + end + if call isa Expr && call.head === :(::) + call = call.args[1] + end + return call isa Expr && call.head === :call +end + +function _lambda_arglist(args_expr) + if args_expr isa Expr && args_expr.head === :tuple + return Any[args_expr.args...] + elseif args_expr === nothing + return Any[] + else + return Any[args_expr] + end +end + +function _instrument_lambda(ex::Expr, cfg_tag) + @assert ex.head === :(->) + args_expr = ex.args[1] + body = ex.args[2] + + fname = gensym(:__bc_lambda__) + arglist = _lambda_arglist(args_expr) + sig = Expr(:call, fname, arglist...) + inst_body = _prepend_check_stmt(sig, body, cfg_tag) + fdef = Expr(:function, sig, inst_body) + return Expr(:block, fdef, fname) +end + +function _instrument_assignments(ex, cfg_tag) + ex isa Expr || return ex + + if ex.head === :quote || ex.head === :inert + return ex + end + + # Already-expanded `@unsafe` regions: these are blocks that start with our meta marker. + # Avoid instrumenting the unsafe region (including the stored meta AST). + if ex.head === :block && !isempty(ex.args) + first = ex.args[1] + if first isa Expr && + first.head === :meta && + !isempty(first.args) && + first.args[1] === BC_UNSAFE_META + return ex + end + end + + # Avoid recursing into the stored meta AST for `@unsafe`. + if ex.head === :meta && !isempty(ex.args) && ex.args[1] === BC_UNSAFE_META + return ex + end + + # `@unsafe ...` regions are deliberately excluded from `@safe`'s recursive + # instrumentation (no prologue checks for inner lambdas/functions, and we do + # not insert `__bc_bind__` barriers inside). + if ex.head === :macrocall + # Forms: + # @unsafe begin ... end + # Mod.@unsafe begin ... end + # AST forms use `Symbol("@unsafe")` or a `GlobalRef` for qualified macros. + m = ex.args[1] + if m === Symbol("@unsafe") || (m isa GlobalRef && m.name === Symbol("@unsafe")) + return ex + end + end + + if ex.head === :function + sig = ex.args[1] + body = ex.args[2] + inst_body = _prepend_check_stmt(sig, body, cfg_tag) + return Expr(:function, sig, inst_body) + end + + if ex.head === :(->) + return _instrument_lambda(ex, cfg_tag) + end + + if ex.head === :(=) && length(ex.args) == 2 + lhs, rhs = ex.args + if _is_method_definition_lhs(lhs) + sig = lhs + body = rhs + inst_body = _prepend_check_stmt(sig, body, cfg_tag) + return Expr(:function, sig, inst_body) + end + lhs2 = _instrument_assignments(lhs, cfg_tag) + rhs2 = _instrument_assignments(rhs, cfg_tag) + + # If the RHS is an instrumented lambda block, don't wrap it in `__bc_bind__`. + # Wrapping forces the value to `Any` and breaks call resolution, which makes + # `f(x)` look like an unknown call that consumes tracked arguments. + if rhs2 isa Expr && rhs2.head === :block && length(rhs2.args) >= 2 + last = rhs2.args[end] + if last isa Symbol && any( + a -> ( + a isa Expr && + a.head === :function && + a.args[1] isa Expr && + a.args[1].head === :call && + a.args[1].args[1] == last + ), + rhs2.args[1:(end - 1)], + ) + return Expr(:(=), lhs2, rhs2) + end + end + + bind_ref = GlobalRef(@__MODULE__, :__bc_bind__) + return Expr(:(=), lhs2, Expr(:call, bind_ref, rhs2)) + end + + # Recurse + return Expr(ex.head, map(a -> _instrument_assignments(a, cfg_tag), ex.args)...) +end + +function _prepend_check_stmt(sig, body, cfg_tag, debug::Bool=false) + tt_expr = _tt_expr_from_signature(sig, cfg_tag) + assert_ref = GlobalRef(@__MODULE__, :_generated_assert_safe) + check_stmt = Expr(:call, assert_ref, tt_expr) + + body_block = (body isa Expr && body.head === :block) ? body : Expr(:block, body) + debug_warn_stmt = if debug + path_ref = GlobalRef(@__MODULE__, :_auto_debug_path) + Expr(:call, path_ref, true) + else + nothing + end + new_body = if debug_warn_stmt === nothing + Expr(:block, check_stmt, body_block.args...) + else + Expr(:block, debug_warn_stmt, check_stmt, body_block.args...) + end + return _instrument_assignments(new_body, cfg_tag) +end + +function _parse_cfg_value(x, calling_module) + if x isa QuoteNode + return x.value + elseif x isa Expr + return Core.eval(calling_module, x) + else + return x + end +end + +""" +Parse `@safe` macro options into `Config` field overrides. + +Returns a fully-specified `Config`. +""" +function parse_config(options, calling_module)::Config + cfg0 = Config() + scope = cfg0.scope + max_summary_depth = cfg0.max_summary_depth + optimize_until = cfg0.optimize_until + debug = cfg0.debug + debug_callee_depth = cfg0.debug_callee_depth + for option in options + if option isa Expr && + length(option.args) == 2 && + (option.head === :(=) || option.head === :kw) + k = option.args[1] + v = option.args[2] + if k === :scope + scope = _parse_cfg_value(v, calling_module)::Symbol + continue + elseif k === :max_summary_depth + max_summary_depth = _parse_cfg_value(v, calling_module)::Int + continue + elseif k === :optimize_until + optimize_until = _parse_cfg_value(v, calling_module)::String + continue + elseif k === :debug + debug = _parse_cfg_value(v, calling_module)::Bool + continue + elseif k === :debug_callee_depth + debug_callee_depth = _parse_cfg_value(v, calling_module)::Int + continue + end + end + error( + "@safe only supports `scope=...`, `max_summary_depth=...`, `optimize_until=...`, `debug=...`, `debug_callee_depth=...`; got: $option", + ) + end + + scope ∈ (:none, :function, :module, :user, :all) || error( + "invalid `scope` for @safe: $scope (expected :none, :function, :module, :user, or :all)", + ) + + root_module = (scope === :module) ? calling_module : cfg0.root_module + debug_callee_depth >= 0 || + error("`debug_callee_depth` must be >= 0; got: $debug_callee_depth") + return Config( + optimize_until, max_summary_depth, scope, root_module, debug, debug_callee_depth + ) +end + +function _auto(args...; calling_module, source_info=nothing) + _ = source_info + + ex = args[end] + is_borrow_checker_enabled(calling_module) || return ex + + raw_options = args[begin:(end - 1)] + cfg = parse_config(raw_options, calling_module) + if cfg.scope === :none + return ex + end + + cfg_tag = let + tag_ref = GlobalRef(@__MODULE__, :GeneratedCfgTag) + Expr( + :curly, + tag_ref, + QuoteNode(cfg.scope), + cfg.max_summary_depth, + QuoteNode(Symbol(cfg.optimize_until)), + cfg.debug, + cfg.debug_callee_depth, + ) + end + + # Function form + if ex isa Expr && ex.head === :function + sig = ex.args[1] + body = ex.args[2] + inst_body = _prepend_check_stmt(sig, body, cfg_tag, cfg.debug) + return Expr(:function, sig, inst_body) + end + + # One-line method form: f(args...) = body + if ex isa Expr && ex.head === :(=) && _is_method_definition_lhs(ex.args[1]) + sig = ex.args[1] + body = ex.args[2] + inst_body = _prepend_check_stmt(sig, body, cfg_tag, cfg.debug) + return Expr(:function, sig, inst_body) + end + + return error("@safe must wrap a function/method definition") +end + +""" +Automatically borrow-check a function (best-effort). + +`BorrowChecker.@safe` is a *drop-in tripwire* for existing code: + +- **Aliasing violations**: mutating a value while another live binding may observe that mutation. +- **Escapes / “moves”**: storing a mutable value somewhere that outlives the current scope + (e.g. a global cache / a field / a container), then continuing to reference it locally. + +On function entry, it checks the current specialization and caches the result so future +calls are fast. On failure it throws `BorrowCheckError` with best-effort source context. + +## Options + +Options are parsed by the macro and compiled into a `BorrowChecker.Config` (and are +part of the checked-cache key). + +- `scope` (default: `:function`): controls whether the checker recursively borrow-checks + callees (call-graph traversal). + - `:none`: disable `@safe` entirely (no IR borrow-checking; returns the original definition). + - `:function`: check only the annotated method. + - `:module`: recursively check callees whose defining module matches the module where `@safe` is used. + - `:user`: recursively check callees, but ignore `Core` and `Base` (including their submodules). + - `:all`: recursively check callees across all modules (very aggressive). +- `max_summary_depth` (default: `12`): limits recursive effect summarization depth used + when the checker cannot directly resolve effects. +- `debug` (default: `false`): enable best-effort debug logging to a JSONL file + (path controlled by `BORROWCHECKER_AUTO_DEBUG_PATH`). +- `debug_callee_depth` (default: `2`): when `debug=true`, also dump IR for summary-recursion + entries up to this depth (0 = only the entrypoint specialization). + +Examples: + +```julia +BorrowChecker.@safe scope=:module function f(x) + g(x) +end + +BorrowChecker.@safe max_summary_depth=4 optimize_until="compact 1" function h(x) + g(x) +end +``` + +# Extended help + +### `optimize_until` + +`optimize_until` (default: `BorrowChecker.DEFAULT_CONFIG.optimize_until`) controls +which compiler pass to stop at when fetching IR via `Base.code_ircode_by_type`. + +Pass names vary across Julia versions; `@safe` tries to normalize common spellings like +`"compact 1"` / `"compact_1"` when possible. + +!!! warning + This macro is highly experimental and compiler-dependent. There are likely bugs and + false positives. It is intended for development and testing, and does not guarantee + memory safety. +""" +macro safe(args...) + return esc(_auto(args...; calling_module=__module__, source_info=__source__)) +end + +""" + BorrowChecker.@auto [options...] function f(args...) + ... + end + +Deprecated alias for [`BorrowChecker.@safe`](@ref). Emits a depwarn and forwards. +""" +macro auto(args...) + Base.depwarn( + "`BorrowChecker.@auto` is deprecated; use `BorrowChecker.@safe` instead.", :auto + ) + return esc(_auto(args...; calling_module=__module__, source_info=__source__)) +end + +""" + @unsafe begin + ... + end + +Mark a lexical region as *unchecked* by `BorrowChecker.@safe`. + +Semantics (auto-IR checker only): + +- The borrow checker does **not** validate aliasing / uniqueness rules for statements + inside the `@unsafe` region. +- The borrow checker does **not** enforce escape/consume ("move") rules inside the + `@unsafe` region. +- The unsafe region is treated as **opaque** to surrounding checked code: effects inside + the region (writes, consumes, escapes, new aliases) are not propagated outward into the + surrounding analysis. +- The checker does **not** recursively borrow-check callees that are only reachable from + within the `@unsafe` region. +- The unsafe region is still executed normally at runtime and evaluates to the value of + its last expression (like a `begin ... end` block). + +This is intentionally analogous to `@inbounds`: it is an escape hatch for low-level +code or for cases where the checker is overly conservative. The responsibility to +uphold the usual invariants is on you. +""" +macro unsafe(ex) + is_borrow_checker_enabled(__module__) || return esc(ex) + + # Tag unsafe regions via debug "location stack" metadata (`:push_loc` / `:pop_loc`) + # plus a unique synthetic file symbol. This survives `@safe` macro rewriting, + # handles same-line `;` cases, and remains visible through inlining. + unsafe_file = gensym(:borrow_checker_unsafe_file) + + # Normalize to a block and ensure it has a leading line node (important for one-liners). + body0 = if (ex isa Expr && ex.head === :block) + ex + else + Expr(:block, ex) + end + if isempty(body0.args) || !(body0.args[1] isa LineNumberNode) + body0 = Expr( + :block, LineNumberNode(__source__.line, __source__.file), body0.args... + ) + end + + meta = Expr(:meta, BC_UNSAFE_META, unsafe_file) + push_loc = Expr(:meta, :push_loc, unsafe_file, __source__.line) + pop_loc = Expr(:meta, :pop_loc) + + # `@unsafe` must behave like a plain `begin ... end` (no new scope), but we also need + # to emit `:pop_loc` after the region without changing the block's value. + inner = Expr(:ref, Expr(:tuple, body0, pop_loc), 1) + return esc(Expr(:block, meta, push_loc, inner)) +end diff --git a/src/safe/generated.jl b/src/safe/generated.jl new file mode 100644 index 0000000..3e9d7c4 --- /dev/null +++ b/src/safe/generated.jl @@ -0,0 +1,114 @@ +using Core.Compiler +using Core.IR + +struct BCInterpOwner end +Base.@kwdef struct BCInterp <: Compiler.AbstractInterpreter + world::UInt = Base.get_world_counter() + inf_params::Compiler.InferenceParams = Compiler.InferenceParams() + opt_params::Compiler.OptimizationParams = Compiler.OptimizationParams() + inf_cache::Vector{Compiler.InferenceResult} = Compiler.InferenceResult[] + codegen_cache::IdDict{CodeInstance,CodeInfo} = IdDict{CodeInstance,CodeInfo}() +end +Base.Experimental.@MethodTable BCMT + +struct GeneratedCfgTag{S,MSD,OPT,DBG,DCD} end + +Compiler.InferenceParams(interp::BCInterp) = interp.inf_params +Compiler.OptimizationParams(interp::BCInterp) = interp.opt_params +Compiler.get_inference_world(interp::BCInterp) = interp.world +Compiler.get_inference_cache(interp::BCInterp) = interp.inf_cache +Compiler.cache_owner(::BCInterp) = BCInterpOwner() +Compiler.codegen_cache(interp::BCInterp) = interp.codegen_cache +Compiler.method_table(interp::BCInterp) = Compiler.OverlayMethodTable(interp.world, BCMT) + +function _cfg_from_tag( + ::Type{GeneratedCfgTag{S,MSD,OPT,DBG,DCD}}, tt::Type{<:Tuple}, world::UInt +) where {S,MSD,OPT,DBG,DCD} + @nospecialize tt + scope = S::Symbol + max_summary_depth = MSD::Int + optimize_until = String(OPT::Symbol) + debug = DBG::Bool + debug_callee_depth = DCD::Int + + root_module = if scope === :module + matches = Base._methods_by_ftype(tt, -1, world) + if isnothing(matches) || isempty(matches) + Main + else + (matches[1]::Core.MethodMatch).method.module + end + else + Main + end + + return Config( + optimize_until, max_summary_depth, scope, root_module, debug, debug_callee_depth + ) +end + +function _tt_cfg_from_sig(sig::DataType, world::UInt) + @nospecialize sig + # `sig` is a type like: + # Tuple{GeneratedCfgTag{...}, typeof(f), typeof(x), ...} + tt = try + Core.apply_type(Tuple, sig.parameters[2:end]...) + catch + sig + end + tt isa Type{<:Tuple} || + error("_generated_assert_safe expected a config-tagged Tuple type; got $sig") + return tt, _cfg_from_tag(sig.parameters[1]::Type{<:GeneratedCfgTag}, tt, world) +end + +function _generated_assert_safe_body(world::UInt, lnn, this, sig) + sig = sig.parameters[1] + + tt, cfg = _tt_cfg_from_sig(sig, world) + + check_signature(tt; cfg, world) # Do the actual checking + + ci = _expr_to_codeinfo( + @__MODULE__(), [Symbol("#self#"), :sig], [], :(return nothing), false + ) + + matches = Base._methods_by_ftype(tt, -1, world) + if !isnothing(matches) + ci.edges = Any[] + for match in matches + mi = Base.specialize_method(match) + push!(ci.edges, mi) + end + end + return ci +end + +function _expr_to_codeinfo(m::Module, argnames, spnames, e::Expr, isva) + body = Expr(:block, Expr(:return, Expr(:block, e))) + scope = Expr(Symbol("scope-block"), body) + lambda = Expr(:lambda, argnames, scope) + ex = if isnothing(spnames) || isempty(spnames) + lambda + else + Expr(Symbol("with-static-parameters"), lambda, spnames...) + end + ci = Base.generated_body_to_codeinfo(ex, @__MODULE__(), isva) + @assert ci isa Core.CodeInfo "Failed to create a CodeInfo from the given expression. This might mean it contains a closure or comprehension?\n Offending expression: $e" + return ci +end + +#! format: off +function _refresh_generated_assert_safe() + @eval function _generated_assert_safe(sig::Type{<:Tuple{<:GeneratedCfgTag,Vararg{Any}}}) + $(Expr(:meta, :generated_only)) + $(Expr(:meta, :generated, _generated_assert_safe_body)) + end + + # Don't recursively borrow check the borrow checking! + @eval Base.Experimental.@overlay BCMT _generated_assert_safe(sig) = nothing +end +#! format: on +# +# NOTE: `check_signature` is defined in `frontend.jl`, and Julia 1.12+ is stricter +# about calling "too-new" methods from generated-function contexts. We therefore +# delay defining `_generated_assert_safe` until after `frontend.jl` is loaded. diff --git a/src/safe/ir_primitives.jl b/src/safe/ir_primitives.jl new file mode 100644 index 0000000..81ecf8f --- /dev/null +++ b/src/safe/ir_primitives.jl @@ -0,0 +1,667 @@ +Base.@kwdef struct TypeTracker + seen::Base.IdSet{Any} = Base.IdSet{Any}() +end + +""" + _is_shareable_handle_type(T) -> Bool + +Return `true` for types that behave like shareable concurrency handles. + +These values routinely escape into globally-reachable runtime state (e.g. scheduler +queues) as an implementation detail, while remaining safe to use via additional +aliases held by user code. They should not participate in `@safe`'s Rust-like +ownership/move rules. +""" +function _is_shareable_handle_type(@nospecialize(T))::Bool + # Type slots in `IRCode` may contain compiler lattice elements + # (e.g. `Core.PartialStruct`, `Core.Const`, ...). Only actual Julia `Type`s + # are eligible for this shareable-handle fast-path. + T isa Type || return false + + # Task handles are stored in the scheduler run queues by `@async`/`schedule`. + (T <: Task) && return true + + # Atomics provide synchronized interior mutability and are intended to be aliased. + if isdefined(Base, :Threads) && isdefined(Base.Threads, :Atomic) + (T <: Base.Threads.Atomic) && return true + end + + return false +end + +function (tt::TypeTracker)(@nospecialize(T))::Bool + T === Union{} && return false + T === Any && return true + if T isa Union + return any(tt, Base.uniontypes(T)) + end + + @assert (T isa Type) ( + "BorrowChecker: expected `Type` in TypeTracker, got $(typeof(T))" + ) + + if T isa UnionAll + return tt(Base.unwrap_unionall(T)) + end + T === Symbol && return false + + # Modules and type objects are globally-shareable handles. + # Treat them as *not tracked* so they don't participate in move/consume rules. + (T <: Module) && return false + (T <: Type) && return false + + _is_shareable_handle_type(T) && return false + + # Low-level references. We treat these as tracked because they can point to mutable + # memory even though the value itself is isbits. + if T <: Ptr + return true + end + if isdefined(Base, :RefValue) && (T <: Base.RefValue) + return true + end + if isdefined(Core, :MemoryRef) && (T <: Core.MemoryRef) + return true + end + if isdefined(Core, :GenericMemoryRef) && (T <: Core.GenericMemoryRef) + return true + end + if isdefined(Core, :GenericMemory) && (T <: Core.GenericMemory) + return true + end + + dt = Base.unwrap_unionall(T) + if dt isa DataType + if isdefined(Core, :Box) && (dt === Core.Box || T <: Core.Box) + # Some low-level compiler artifacts behave more like borrows than owned resources. + return false + end + Base.isconcretetype(dt) || return true + Base.ismutabletype(dt) && return true + if Base.isbitstype(dt) + return any(tt, fieldtypes(dt)) + end + dt in tt.seen && return true + push!(tt.seen, dt) + return any(tt, fieldtypes(dt)) + end + + return true +end + +is_tracked_type(@nospecialize T)::Bool = TypeTracker()(T) + +# "Tracking" answers: should we include this value in alias/liveness tracking? +# +# For move-like checks we also need a notion of "owned" values. Some low-level compiler +# artifacts (e.g. `Core.MemoryRef`) behave more like borrows of an owned object rather than +# independently-owned resources. +Base.@kwdef struct OwnedTypeTracker + seen::Base.IdSet{Any} = Base.IdSet{Any}() +end + +function _is_nonowning_ref_type(@nospecialize(T))::Bool + if isdefined(Core, :MemoryRef) && (T <: Core.MemoryRef) + return true + end + if isdefined(Core, :GenericMemoryRef) && (T <: Core.GenericMemoryRef) + return true + end + if isdefined(Core, :GenericMemory) && (T <: Core.GenericMemory) + return true + end + if T <: Ptr + return true + end + return false +end + +function (tt::OwnedTypeTracker)(@nospecialize(T))::Bool + T === Union{} && return false + T === Any && return true + T isa Union && return any(tt, Base.uniontypes(T)) + + @assert (T isa Type) ( + "BorrowChecker: expected `Type` in OwnedTypeTracker, got $(typeof(T))" + ) + + if T isa UnionAll + return tt(Base.unwrap_unionall(T)) + end + T === Symbol && return false + + # Modules and type objects are globally-shareable handles. + # Treat them as *not owned* so unknown/dynamic calls don't spuriously consume them. + (T <: Module) && return false + (T <: Type) && return false + + _is_shareable_handle_type(T) && return false + + # These low-level reference types are never treated as owned. + if T <: Ptr + return false + end + if isdefined(Base, :RefValue) && (T <: Base.RefValue) + return false + end + if isdefined(Core, :MemoryRef) && (T <: Core.MemoryRef) + return false + end + if isdefined(Core, :GenericMemoryRef) && (T <: Core.GenericMemoryRef) + return false + end + if isdefined(Core, :GenericMemory) && (T <: Core.GenericMemory) + return false + end + + dt = Base.unwrap_unionall(T) + if dt isa DataType + if isdefined(Core, :Box) && (dt === Core.Box || T <: Core.Box) + # Some low-level compiler artifacts behave more like borrows than owned resources. + return false + end + Base.isconcretetype(dt) || return true + Base.ismutabletype(dt) && return true + Base.isbitstype(dt) && return false + dt in tt.seen && return true + push!(tt.seen, dt) + return any(tt, fieldtypes(dt)) + end + + return true +end + +is_owned_type(@nospecialize T)::Bool = OwnedTypeTracker()(T) + +@inline _ssa_handle(nargs::Int, id::Int) = nargs + id +@inline _arg_handle(id::Int) = id + +function _handle_index( + x, nargs::Int, track_arg::AbstractVector{Bool}, track_ssa::AbstractVector{Bool} +) + if x isa Core.Argument + n = x.n + return (1 <= n <= length(track_arg) && track_arg[n]) ? _arg_handle(n) : 0 + elseif x isa Core.SSAValue + i = x.id + return (1 <= i <= length(track_ssa) && track_ssa[i]) ? _ssa_handle(nargs, i) : 0 + else + return 0 + end +end + +function _inst_get(@nospecialize(inst), sym::Symbol, default=nothing) + try + return inst[sym] + catch + end + if Base.hasproperty(inst, sym) + return getproperty(inst, sym) + end + return default +end + +@inline function _safe_argextype(@nospecialize(x), ir::CC.IRCode) + # `Core.Compiler.argextype` expects a valid IR argument (SSAValue/Argument/Const/...), + # not an `Expr(:call, ...)`. On newer Julia versions this can throw/warn loudly. + x isa Expr && return Any + return CC.argextype(x, ir) +end + +function _lineinfo_from_debuginfo(ir::CC.IRCode, pc::Int) + pc <= 0 && return nothing + builder = if isdefined(CC, :IRShow) && isdefined(CC.IRShow, :buildLineInfoNode) + CC.IRShow.buildLineInfoNode + elseif isdefined(CC, :buildLineInfoNode) + CC.buildLineInfoNode + else + nothing + end + builder === nothing && return nothing + try + di = getproperty(ir, :debuginfo) + stack = builder(di, nothing, pc) + isempty(stack) && return nothing + chosen = nothing + for node in stack + file = try + getproperty(node, :file) + catch + nothing + end + line = try + getproperty(node, :line) + catch + nothing + end + if file isa Symbol && + line isa Integer && + line > 0 && + file !== Symbol("none") && + file !== Symbol("unknown") + chosen = node + break + end + end + + chosen === nothing && return nothing + file = getproperty(chosen, :file)::Symbol + line = getproperty(chosen, :line)::Integer + return LineNumberNode(Int(line), file) + catch + return nothing + end +end + +function _normalize_lineinfo(ir::CC.IRCode, li, pc::Int=0) + if li isa Core.LineInfoNode + file = try + String(getproperty(li, :file)) + catch + "" + end + line = try + Int(getproperty(li, :line)) + catch + 0 + end + if !isempty(file) && file != "none" && file != "unknown" && line > 0 + return li + end + elseif li isa LineNumberNode + if li.line > 0 && li.file !== Symbol("none") && li.file !== Symbol("unknown") + return li + end + end + + if pc > 0 + tmp = _lineinfo_from_debuginfo(ir, pc) + tmp !== nothing && return tmp + end + + if li isa Integer + lii = Int(li) + lii <= 0 && return nothing + if Base.hasproperty(ir, :linetable) + linetable = getproperty(ir, :linetable) + if lii <= length(linetable) + linfo = linetable[lii] + return (linfo isa Core.LineInfoNode) ? linfo : nothing + end + end + return nothing + elseif li isa NTuple{3,<:Integer} + return _lineinfo_from_debuginfo(ir, Int(li[1])) + end + + return nothing +end + +function _stmt_lineinfo(ir::CC.IRCode, idx::Int) + try + inst = ir[Core.SSAValue(idx)] + li = _inst_get(inst, :line, nothing) + return _normalize_lineinfo(ir, li, idx) + catch + return nothing + end +end + +function _raw_line_id(ir::CC.IRCode, idx::Int) + inst = ir[Core.SSAValue(idx)] + return _inst_get(inst, :line, nothing) +end + +function _debuginfo_has_unsafe_file( + ir::CC.IRCode, pc::Int, unsafe_files::Set{Symbol}, debuginfo_builder +)::Union{Bool,Nothing} + (debuginfo_builder === nothing || pc <= 0) && return nothing + stack = debuginfo_builder(ir.debuginfo, nothing, pc) + isempty(stack) && return nothing + for node in stack + file = getproperty(node, :file) + file_sym = (file isa Symbol) ? file : Symbol(file) + (file_sym in unsafe_files) && return true + end + return false +end + +function _stmt_unsafe_status( + ir::CC.IRCode, idx::Int, raw, unsafe_files::Set{Symbol}, debuginfo_builder +)::Union{Bool,Nothing} + # Prefer the IR statement index for `debuginfo`: this is what `IRShow.buildLineInfoNode` + # expects, and it's the most robust to debug-info compression. + has = _debuginfo_has_unsafe_file(ir, idx, unsafe_files, debuginfo_builder) + has !== nothing && return has + + @assert raw isa NTuple{3,<:Integer} "Expected NTuple{3,<:Integer} in _stmt_unsafe_status, got $(typeof(raw))" + + pc = Int(raw[1]) + has = _debuginfo_has_unsafe_file(ir, pc, unsafe_files, debuginfo_builder) + has !== nothing && return has + return nothing +end + +function _propagate_unlabeled_unsafe!( + unsafe_stmt::AbstractVector{Bool}, known_stmt::AbstractVector{Bool}, ir::CC.IRCode +) + blocks = ir.cfg.blocks + for b in 1:length(blocks) + r = blocks[b].stmts + + first_known = 0 + first_status = false + for idx in r + if known_stmt[idx] + first_known = idx + first_status = unsafe_stmt[idx] + break + end + end + first_known == 0 && continue + + # Leading unlabeled nodes inherit from first known stmt. + for idx in r + idx == first_known && break + known_stmt[idx] || (unsafe_stmt[idx] |= first_status) + end + + # Interior unlabeled nodes inherit from the most recent known stmt. + cur = first_status + for idx in r + if known_stmt[idx] + cur = unsafe_stmt[idx] + else + unsafe_stmt[idx] |= cur + end + end + end + + return unsafe_stmt +end + +"""Return a statement mask `unsafe_stmt[i]` indicating IR stmt `i` is inside an `@unsafe` region.""" +function _unsafe_stmt_mask(ir::CC.IRCode)::Vector{Bool} + nstmts = length(ir.stmts) + (nstmts == 0) && return Bool[] + + meta = ir.meta + + unsafe_files = Set{Symbol}() + for m in meta + (m isa Expr && m.head === :meta && !isempty(m.args)) || continue + m.args[1] === BC_UNSAFE_META || continue + if length(m.args) == 1 + return trues(nstmts) + end + for j in 2:length(m.args) + a = m.args[j] + if a isa Symbol + push!(unsafe_files, a) + break + end + end + end + + isempty(unsafe_files) && return falses(nstmts) + + debuginfo_builder = + if isdefined(CC, :IRShow) && isdefined(CC.IRShow, :buildLineInfoNode) + CC.IRShow.buildLineInfoNode + elseif isdefined(CC, :buildLineInfoNode) + CC.buildLineInfoNode + else + nothing + end + + unsafe_stmt = falses(nstmts) + known_stmt = falses(nstmts) + for i in 1:nstmts + raw = _raw_line_id(ir, i) + raw === nothing && continue + (raw isa Integer && raw == 0) && continue + + status = _stmt_unsafe_status(ir, i, raw, unsafe_files, debuginfo_builder) + status === nothing && continue + known_stmt[i] = true + unsafe_stmt[i] = status + end + + return _propagate_unlabeled_unsafe!(unsafe_stmt, known_stmt, ir) +end + +mutable struct UnionFind + parent::Vector{Int} + rank::Vector{UInt8} +end + +function UnionFind(n::Int) + parent = collect(1:n) + rank = fill(UInt8(0), n) + return UnionFind(parent, rank) +end + +function _uf_find(uf::UnionFind, x::Int) + p = uf.parent[x] + if p == x + return x + end + r = _uf_find(uf, p) + uf.parent[x] = r + return r +end + +function _uf_union!(uf::UnionFind, a::Int, b::Int) + ((a == 0) || (b == 0) || (a == b)) && return nothing + ra = _uf_find(uf, a) + rb = _uf_find(uf, b) + ra == rb && return nothing + if uf.rank[ra] < uf.rank[rb] + uf.parent[ra] = rb + elseif uf.rank[ra] > uf.rank[rb] + uf.parent[rb] = ra + else + uf.parent[rb] = ra + uf.rank[ra] += 1 + end + return nothing +end + +@inline function _phi_values(@nospecialize(x)) + if Base.hasproperty(x, :values) + return getproperty(x, :values) + end + if Base.hasproperty(x, :vals) + return getproperty(x, :vals) + end + return () +end + +function _collect_used_handles!(s::BitSet, x, nargs::Int, track_arg, track_ssa) + if x isa Core.PhiNode + vals = _phi_values(x) + if vals isa AbstractArray + for k in eachindex(vals) + isassigned(vals, k) || continue + _collect_used_handles!(s, vals[k], nargs, track_arg, track_ssa) + end + else + for v in vals + _collect_used_handles!(s, v, nargs, track_arg, track_ssa) + end + end + return nothing + end + if isdefined(Core, :PhiCNode) && x isa Core.PhiCNode + vals = _phi_values(x) + if vals isa AbstractArray + for k in eachindex(vals) + isassigned(vals, k) || continue + _collect_used_handles!(s, vals[k], nargs, track_arg, track_ssa) + end + else + for v in vals + _collect_used_handles!(s, v, nargs, track_arg, track_ssa) + end + end + return nothing + end + if x isa Core.Argument || x isa Core.SSAValue + h = _handle_index(x, nargs, track_arg, track_ssa) + h != 0 && push!(s, h) + return nothing + end + if x isa Core.ReturnNode + if isdefined(x, :val) + _collect_used_handles!(s, getfield(x, :val), nargs, track_arg, track_ssa) + end + return nothing + end + if x isa Core.PiNode + if isdefined(x, :val) + _collect_used_handles!(s, getfield(x, :val), nargs, track_arg, track_ssa) + end + return nothing + end + if x isa Core.UpsilonNode + if isdefined(x, :val) + _collect_used_handles!(s, getfield(x, :val), nargs, track_arg, track_ssa) + end + return nothing + end + if x isa Core.GotoIfNot + if isdefined(x, :cond) + _collect_used_handles!(s, getfield(x, :cond), nargs, track_arg, track_ssa) + end + return nothing + end + if x isa Expr + for a in x.args + _collect_used_handles!(s, a, nargs, track_arg, track_ssa) + end + return nothing + end + if x isa Tuple + for a in x + _collect_used_handles!(s, a, nargs, track_arg, track_ssa) + end + return nothing + end + if x isa AbstractArray + for a in x + _collect_used_handles!(s, a, nargs, track_arg, track_ssa) + end + return nothing + end + return nothing +end + +function _collect_ssa_ids!(ids::Vector{Int}, x) + if x isa Core.PhiNode + for v in _phi_values(x) + _collect_ssa_ids!(ids, v) + end + return nothing + end + if isdefined(Core, :PhiCNode) && x isa Core.PhiCNode + for v in _phi_values(x) + _collect_ssa_ids!(ids, v) + end + return nothing + end + if x isa Core.SSAValue + push!(ids, x.id) + return nothing + end + if x isa Core.ReturnNode + if isdefined(x, :val) + _collect_ssa_ids!(ids, getfield(x, :val)) + end + return nothing + end + if x isa Core.PiNode + if isdefined(x, :val) + _collect_ssa_ids!(ids, getfield(x, :val)) + end + return nothing + end + if x isa Core.UpsilonNode + if isdefined(x, :val) + _collect_ssa_ids!(ids, getfield(x, :val)) + end + return nothing + end + if x isa Core.GotoIfNot + if isdefined(x, :cond) + _collect_ssa_ids!(ids, getfield(x, :cond)) + end + return nothing + end + if x isa Expr + for a in x.args + _collect_ssa_ids!(ids, a) + end + return nothing + end + if x isa Tuple + for a in x + _collect_ssa_ids!(ids, a) + end + return nothing + end + if x isa AbstractArray + for a in x + _collect_ssa_ids!(ids, a) + end + return nothing + end + return nothing +end + +function _canonical_ref(@nospecialize(x), ir::CC.IRCode) + while x isa Core.SSAValue + stmt = try + ir[x][:stmt] + catch + break + end + if stmt isa Core.SSAValue + x = stmt + continue + end + if stmt isa Core.PiNode + x = stmt.val + continue + end + break + end + return x +end + +function compute_tracking_masks(ir::CC.IRCode) + nargs = length(ir.argtypes) + nstmts = length(ir.stmts) + + track_arg = Vector{Bool}(undef, nargs) + for a in 1:nargs + T = try + CC.widenconst(ir.argtypes[a]) + catch + Any + end + track_arg[a] = is_tracked_type(T) + end + + track_ssa = Vector{Bool}(undef, nstmts) + for i in 1:nstmts + T = try + inst = ir[Core.SSAValue(i)] + CC.widenconst(_inst_get(inst, :type, Any)) + catch + Any + end + track_ssa[i] = is_tracked_type(T) + end + + return track_arg, track_ssa +end diff --git a/src/safe/refine_types.jl b/src/safe/refine_types.jl new file mode 100644 index 0000000..7c23a8c --- /dev/null +++ b/src/safe/refine_types.jl @@ -0,0 +1,402 @@ +"""BorrowChecker: IR type refinement. + +Julia's type inference sometimes intentionally loses precision around boxed captured +variables (`Core.Box`) and inference barriers. That is correct for the compiler, but it +hurts BorrowChecker's ability to resolve call targets and avoid spurious "unknown call" +effects. + +This file implements a small, conservative refinement pass that *only* uses Core +semantics: + +* Track `Core.Box` contents types from their constructors and (non-`Any`) writes. +* Refine `getfield(box, :contents)` return types using that tracked contents type. +* Refine `getfield(x, :field)` return types when `x` has a concrete type and the field + name/index is statically known. + +The pass never attempts to interpret overloadable Base operations like `getproperty`. +""" + +# NOTE: This file is included from `auto_ir.jl` after `summaries.jl` and +# `ir_primitives.jl`, so we can use internal helpers like `_inst_get` and +# `_canonical_ref`. + +const _MaybeType = Union{Nothing,Type} + +@inline function _as_type_or_any(@nospecialize(T))::Type + T = CC.widenconst(T) + return (T isa Type) ? T : Any +end + +@inline function _widen_type_slot(@nospecialize(T)) + # IR type slots can contain lattice elements; we only need the widened type. + return CC.widenconst(T) +end + +@inline function _is_any_slot(@nospecialize(T))::Bool + return _widen_type_slot(T) === Any +end + +@inline function _field_is_contents(field_expr)::Bool + if field_expr isa QuoteNode + return field_expr.value === :contents + end + return field_expr === :contents +end + +@inline function _field_is_const_symbol(field_expr) + if field_expr isa QuoteNode + v = field_expr.value + return (v isa Symbol) ? v : nothing + end + return (field_expr isa Symbol) ? field_expr : nothing +end + +@inline function _field_is_const_int(field_expr) + if field_expr isa QuoteNode + v = field_expr.value + return (v isa Integer) ? Int(v) : nothing + end + return (field_expr isa Integer) ? Int(field_expr) : nothing +end + +function _fieldtype_if_known(@nospecialize(objT), field_expr) + objT = _as_type_or_any(objT) + objT === Any && return nothing + dt = Base.unwrap_unionall(objT) + dt isa DataType || return nothing + + # Only refine for concrete object types. (If inference didn't narrow it, we won't.) + Base.isconcretetype(dt) || return nothing + + # Symbol field + sym = _field_is_const_symbol(field_expr) + if sym !== nothing + return try + Base.fieldtype(dt, sym) + catch + nothing + end + end + + # Integer index field + idx = _field_is_const_int(field_expr) + if idx !== nothing + return try + Base.fieldtype(dt, idx) + catch + nothing + end + end + + return nothing +end + +function _is_box_ctor(stmt, ir::CC.IRCode) + stmt isa Expr || return false + stmt.head === :call || return false + isempty(stmt.args) && return false + f = stmt.args[1] + + # Resolve the callee; we only treat the *actual* Core.Box constructor as a box. + fobj = _resolve_callee(stmt, ir) + return fobj === Core.Box +end + +function _is_builtin_getfield_call(stmt, ir::CC.IRCode) + stmt isa Expr || return false + stmt.head === :call || return false + length(stmt.args) >= 3 || return false + fobj = _resolve_callee(stmt, ir) + return fobj === Core.getfield +end + +function _is_builtin_setfield_call(stmt, ir::CC.IRCode) + stmt isa Expr || return false + stmt.head === :call || return false + length(stmt.args) >= 4 || return false + fobj = _resolve_callee(stmt, ir) + return fobj === Core.setfield! +end + +function _maybe_set_inst_type!(ir::CC.IRCode, idx::Int, newT::Type)::Bool + newT === Any && return false + inst = ir.stmts[idx] + cur = _inst_get(inst, :type, Any) + _is_any_slot(cur) || return false + + # Use the instruction indexing API, which is stable across Julia versions. + try + inst[:type] = newT + catch + # Fallback for older IR representations. + try + setproperty!(inst, :type, newT) + catch + return false + end + end + return true +end + +@inline function _join_box_type(old::Type, new::Type)::Type + # Ignore uninformative `Any` writes, otherwise merge. + new === Any && return old + old === Any && return new + return Base.typejoin(old, new) +end + +"""Refine types in-place. + +This pass is intentionally small and conservative; it is only used to recover precision +around boxed captured variables and concrete field accesses. +""" +function refine_types!(ir::CC.IRCode, cfg::Config) + n = length(ir.stmts) + n == 0 && return ir + + world = _reflection_world() + + # Map `SSAValue` ids that hold a `Core.Box` object to their best-known `:contents` type. + box_contents = Vector{_MaybeType}(undef, n) + fill!(box_contents, nothing) + + # Track which SSA statements we've refined to something more precise than `Any`. + # We use this to gate expensive return-type inference so `scope=:all` stays fast. + interesting = falses(n) + + # Cache `return_type` results within this IR to avoid repeated compiler work. + rt_cache = Dict{DataType,Type}() + rt_calls = 0 + rt_cache_hits = 0 + + # Optional debug log of refinements. + refine_log = cfg.debug ? Vector{Dict{String,Any}}() : nothing + + @inline function _log_change( + kind::String, idx::Int, stmt, @nospecialize(oldT), newT::Type + ) + refine_log === nothing && return nothing + push!( + refine_log, + Dict( + "kind" => kind, + "stmt_idx" => idx, + "stmt" => string(stmt), + "old_type" => string(_widen_type_slot(oldT)), + "new_type" => string(newT), + ), + ) + return nothing + end + + @inline function _concrete_enough_for_return_refinement(tt_u::DataType)::Bool + params = tt_u.parameters + for p in params + if p === Any || p isa Union || p isa Core.TypeVar || p isa Core.TypeofVararg + return false + end + end + return true + end + + # A few iterations are enough for simple forward propagation. + max_iter = 3 + for _iter in 1:max_iter + changed = false + + for i in 1:n + inst = ir.stmts[i] + stmt = _inst_get(inst, :stmt, nothing) + stmt === nothing && continue + + # (1) Track box init types. + if _is_box_ctor(stmt, ir) + initT = Any + if length(stmt.args) >= 2 + initT = _as_type_or_any(_safe_argextype(stmt.args[2], ir)) + end + old = box_contents[i] + if old === nothing + box_contents[i] = initT + changed = true + else + new = _join_box_type(old::Type, initT) + if new !== old + box_contents[i] = new + changed = true + end + end + end + + # (2) Track writes to `box.contents`. + if _is_builtin_setfield_call(stmt, ir) + # setfield!(obj, field, val) + obj = _canonical_ref(stmt.args[2], ir) + field = stmt.args[3] + if obj isa Core.SSAValue && _field_is_contents(field) + bid = obj.id + # Only track boxes we already recognized (via constructor). + old = box_contents[bid] + if old !== nothing + valT = _as_type_or_any(_safe_argextype(stmt.args[4], ir)) + new = _join_box_type(old::Type, valT) + if new !== old + box_contents[bid] = new + changed = true + end + end + end + end + + # (3) Refine `getfield(box, :contents)`. + if _is_builtin_getfield_call(stmt, ir) + obj = _canonical_ref(stmt.args[2], ir) + field = stmt.args[3] + + if obj isa Core.SSAValue && _field_is_contents(field) + bid = obj.id + bt = box_contents[bid] + if bt !== nothing + oldT = _inst_get(inst, :type, Any) + if _maybe_set_inst_type!(ir, i, bt::Type) + changed = true + interesting[i] = true + _log_change("box_contents_getfield", i, stmt, oldT, bt::Type) + end + continue + end + end + + # (4) Refine concrete struct field loads: getfield(x, :n) where typeof(x) is concrete. + if _is_any_slot(_inst_get(inst, :type, Any)) + objT = _as_type_or_any(_safe_argextype(obj, ir)) + ft = _fieldtype_if_known(objT, field) + if ft !== nothing + oldT = _inst_get(inst, :type, Any) + if _maybe_set_inst_type!(ir, i, ft) + changed = true + interesting[i] = true + _log_change("struct_getfield", i, stmt, oldT, ft) + end + end + end + end + + # (5) Refine __bc_bind__ to propagate the argument type. + if stmt isa Expr && + stmt.head === :call && + _is_any_slot(_inst_get(inst, :type, Any)) + fobj = _resolve_callee(stmt, ir) + if fobj === __bc_bind__ && length(stmt.args) >= 2 + arg_expr = _canonical_ref(stmt.args[2], ir) + argT = _as_type_or_any(_safe_argextype(arg_expr, ir)) + oldT = _inst_get(inst, :type, Any) + if _maybe_set_inst_type!(ir, i, argT) + changed = true + if arg_expr isa Core.SSAValue + sid = arg_expr.id + if 1 <= sid <= n && interesting[sid] + interesting[i] = true + end + end + _log_change("__bc_bind__", i, stmt, oldT, argT) + end + end + end + + # (6) Refine call return types when the call tuple is concrete enough. + # + # IMPORTANT: `Core.Compiler.return_type` is expensive. To keep `scope=:all` runs + # fast, we only attempt return-type refinement for call sites that *depend on* + # previously-refined SSA values (e.g. values coming from boxed `:contents` loads). + if stmt isa Expr && + (stmt.head === :call || stmt.head === :invoke) && + _is_any_slot(_inst_get(inst, :type, Any)) + head, _mi, raw_args = _call_parts(stmt) + head === nothing && continue + raw_args === nothing && continue + + # Gate on dataflow from refined SSA values. + has_interest = false + if length(raw_args) >= 2 + for a in raw_args[2:end] # skip callee + ca = _canonical_ref(a, ir) + if ca isa Core.SSAValue + sid = ca.id + if 1 <= sid <= n && interesting[sid] + has_interest = true + break + end + end + end + end + has_interest || continue + + fobj = _resolve_callee(stmt, ir) + fobj === nothing && continue + + # Skip primitives we already handle explicitly. + if fobj === Core.getfield || fobj === Core.setfield! || fobj === __bc_bind__ + continue + end + + tt = _call_tt_from_raw_args(raw_args, ir, fobj) + tt === nothing && continue + + tt_u = Base.unwrap_unionall(tt) + tt_u isa DataType || continue + Base.has_free_typevars(tt_u) && continue + _concrete_enough_for_return_refinement(tt_u) || continue + + local rt::Type + if haskey(rt_cache, tt_u) + rt_cache_hits += 1 + rt = rt_cache[tt_u] + else + rt_calls += 1 + rt = try + CC.return_type(tt_u, world) + catch + Any + end + rt = _as_type_or_any(rt) + rt_cache[tt_u] = rt + end + rt === Any && continue + + oldT = _inst_get(inst, :type, Any) + if _maybe_set_inst_type!(ir, i, rt) + changed = true + _log_change("call_return", i, stmt, oldT, rt) + interesting[i] = true + end + end + end + + changed || break + end + + if cfg.debug && refine_log !== nothing + if !isempty(refine_log) || rt_calls > 0 || rt_cache_hits > 0 + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_refine_types", + "tt" => try + string(Tuple{ir.argtypes...}) + catch + nothing + end, + "world" => world, + "stats" => Dict( + "return_type_calls" => rt_calls, + "return_type_cache_hits" => rt_cache_hits, + ), + "changes" => refine_log, + ), + ) + end + end + + return ir +end diff --git a/src/safe/summaries.jl b/src/safe/summaries.jl new file mode 100644 index 0000000..65cc50e --- /dev/null +++ b/src/safe/summaries.jl @@ -0,0 +1,939 @@ +struct SummaryCacheEntry + summary::EffectSummary + depth::Int + over_budget::Bool +end + +const SUMMARY_CACHE_KEY = Tuple{Any,UInt,Config} + +const SUMMARY_STATE = Lockable(( + summary_cache=Dict{SUMMARY_CACHE_KEY,SummaryCacheEntry}(), + tt_summary_cache=Dict{SUMMARY_CACHE_KEY,SummaryCacheEntry}(), + summary_inprogress=Set{SUMMARY_CACHE_KEY}(), + tt_summary_inprogress=Set{SUMMARY_CACHE_KEY}(), +)) + +const PER_TASK_REFLECTION_CACHE = PerTaskCache{Dict{UInt,Any}}() +const PER_TASK_REFLECTION_CTX = PerTaskCache{Base.RefValue{Any}}(() -> Ref{Any}(nothing)) + +function _reflection_ctx() + return PER_TASK_REFLECTION_CTX[][] +end + +function _reflection_world(default::UInt=Base.get_world_counter()) + ctx = _reflection_ctx() + return (ctx === nothing) ? default : ctx.world +end + +function _with_reflection_ctx(f::Function, world::UInt) + @nospecialize f + ctx_ref = PER_TASK_REFLECTION_CTX[] + old = ctx_ref[] + # Fast path: nested borrow-checking frequently calls `check_signature` recursively. + # Reuse the existing reflection context to avoid repeatedly constructing interpreters. + if old !== nothing && getproperty(old, :world) === world + return f() + end + + cache = PER_TASK_REFLECTION_CACHE[] + entry = get!(cache, world) do + (; interp=BCInterp(; world), methods_cache=IdDict{Any,Any}()) + end + ctx_ref[] = (; world=world, interp=entry.interp, methods_cache=entry.methods_cache) + try + return f() + finally + ctx_ref[] = old + end +end + +function _code_ircode_by_type(tt::Type; optimize_until, world::UInt, cfg::Config) + ctx = _reflection_ctx() + interp = (ctx !== nothing && ctx.world === world) ? ctx.interp : BCInterp(; world) + + methods_cache = + if ctx !== nothing && ctx.world === world && hasproperty(ctx, :methods_cache) + getproperty(ctx, :methods_cache) + else + nothing + end + + matches = if methods_cache === nothing + Base._methods_by_ftype(tt, -1, world) + else + get!(methods_cache, tt) do + Base._methods_by_ftype(tt, -1, world) + end + end + if isnothing(matches) + error("No method found matching signature $tt in world $world") + end + + # For concrete call signatures, `Base._methods_by_ftype` can still return multiple + # applicable methods (e.g. both `f(::Any, ::Any)` and `f(::Any, ::Symbol)` for + # `Tuple{typeof(f), T, Symbol}`). At runtime dispatch will pick the most-specific + # method; unioning effects across all matches can introduce large, spurious + # conservatism (common for `getproperty`, keyword wrappers, etc.). + # + # When the tuple type is concrete enough, keep only the most-specific match. + matches = let tt_u = Base.unwrap_unionall(tt) + if tt_u isa DataType + params = tt_u.parameters + concrete = true + for p in params + if p === Any || p isa Union || p isa Core.TypeVar || p isa Core.TypeofVararg + concrete = false + break + end + end + concrete ? (matches[1:1]) : matches + else + matches + end + end + + optimize_until = _normalize_optimize_until_for_ir(optimize_until) + asts = Pair{Any,Any}[] + for match in matches + match = match::Core.MethodMatch + (code, ty) = CC.typeinf_ircode(interp, match, optimize_until) + if code === nothing + ir = nothing + mod = match.method.module + @assert (mod === Core || mod === Base || mod === BorrowChecker) ( + "BorrowChecker: unexpected `typeinf_ircode` returned `nothing` for " * + "non-Base/Core method. method=$(match.method) module=$(mod) tt=$(tt) " * + "world=$(world) optimize_until=$(optimize_until)" + ) + if ir === nothing + push!(asts, match.method => ty) + if cfg.debug + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_no_ircode", + "tt" => string(tt), + "method" => string(match.method), + "return_type" => string(ty), + "world" => world, + "optimize_until" => optimize_until, + ), + ) + end + else + push!(asts, ir => ty) + if cfg.debug + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_inflated_ir", + "tt" => string(tt), + "method" => string(match.method), + "return_type" => string(ty), + "world" => world, + "optimize_until" => optimize_until, + ), + ) + end + end + else + push!(asts, code => ty) + end + end + return asts +end + +const _FALLBACK_PASS_NAMES = String[ + # Julia's default `run_passes_ipo_safe` pipeline as of 1.12.x. + "convert", + "slot2reg", + "compact 1", + "Inlining", + "compact 2", + "SROA", + "ADCE", + "compact 3", +] + +const _PASS_NAMES_CACHE = Ref{Vector{String}}(String[]) + +@inline function _compiler_pass_names()::Vector{String} + names = _PASS_NAMES_CACHE[] + isempty(names) || return names + + if isdefined(CC, :ALL_PASS_NAMES) + names = [String(nm) for nm in CC.ALL_PASS_NAMES] + else + names = _FALLBACK_PASS_NAMES + end + + _PASS_NAMES_CACHE[] = names + return names +end + +function _normalize_optimize_until_for_ir(optimize_until) + optimize_until isa String || return optimize_until + + pass_names = _compiler_pass_names() + for s in pass_names + s == optimize_until && return s + end + + # Normalize common spellings like "compact_1" and "COMPACT_1". + @inline function _norm(s::AbstractString) + return replace(lowercase(String(s)), r"[^a-z0-9]+" => "") + end + + optn = _norm(optimize_until) + matches = String[] + for s in pass_names + endswith(_norm(s), optn) && push!(matches, s) + end + + if length(matches) == 1 + return matches[1] + elseif length(matches) > 1 + throw( + ArgumentError( + "BorrowChecker.@safe: optimize_until=\"$optimize_until\" is ambiguous. " * + "Candidates: $(join(matches, ", "))", + ), + ) + end + + throw( + ArgumentError( + "BorrowChecker.@safe: optimize_until=\"$optimize_until\" is not a known compiler pass name. " * + "Known passes: $(join(pass_names, ", "))", + ), + ) +end + +mutable struct BudgetTracker + hit::Bool +end + +function _mark_budget_hit!(@nospecialize(budget_state)) + budget_state === nothing && return nothing + budget_state.hit = true + return nothing +end + +function _choose_summary_entry(old::SummaryCacheEntry, new::SummaryCacheEntry) + # NOTE: `_choose_summary_entry` is only called when there is an existing cache entry + # and we're recomputing because the existing entry was over budget at a deeper + # summary depth. Therefore `old.over_budget` is expected to be true here. + @assert old.over_budget + if !new.over_budget + return new + end + return (new.depth < old.depth) ? new : old +end + +function _summary_state_get_tt(key::SUMMARY_CACHE_KEY) + Base.@lock SUMMARY_STATE begin + return get(SUMMARY_STATE[].tt_summary_cache, key, nothing) + end +end + +function _summary_state_get_mi(key::SUMMARY_CACHE_KEY) + Base.@lock SUMMARY_STATE begin + return get(SUMMARY_STATE[].summary_cache, key, nothing) + end +end + +function _summary_state_set_tt!(key::SUMMARY_CACHE_KEY, new_entry::SummaryCacheEntry) + Base.@lock SUMMARY_STATE begin + cache = SUMMARY_STATE[].tt_summary_cache + old = get(cache, key, nothing) + cache[key] = (old === nothing) ? new_entry : _choose_summary_entry(old, new_entry) + end + return nothing +end + +function _summary_state_set_mi!(key::SUMMARY_CACHE_KEY, new_entry::SummaryCacheEntry) + Base.@lock SUMMARY_STATE begin + cache = SUMMARY_STATE[].summary_cache + old = get(cache, key, nothing) + cache[key] = (old === nothing) ? new_entry : _choose_summary_entry(old, new_entry) + end + return nothing +end + +function _summary_state_tt_inprogress_enter!(key::SUMMARY_CACHE_KEY)::Bool + reentered = false + Base.@lock SUMMARY_STATE begin + inprog = SUMMARY_STATE[].tt_summary_inprogress + reentered = (key in inprog) + reentered || push!(inprog, key) + end + return reentered +end + +function _summary_state_tt_inprogress_exit!(key::SUMMARY_CACHE_KEY) + Base.@lock SUMMARY_STATE begin + delete!(SUMMARY_STATE[].tt_summary_inprogress, key) + end + return nothing +end + +function _summary_state_mi_inprogress_enter!(key::SUMMARY_CACHE_KEY)::Bool + reentered = false + Base.@lock SUMMARY_STATE begin + inprog = SUMMARY_STATE[].summary_inprogress + reentered = (key in inprog) + reentered || push!(inprog, key) + end + return reentered +end + +function _summary_state_mi_inprogress_exit!(key::SUMMARY_CACHE_KEY) + Base.@lock SUMMARY_STATE begin + delete!(SUMMARY_STATE[].summary_inprogress, key) + end + return nothing +end + +function _summary_cached( + compute::FCompute, + key, + cfg::Config; + depth::Int, + budget_state=nothing, + get_cached::FGet, + set_cached::FSet, + inprogress_enter::FEnter, + inprogress_exit::FExit, +) where {FCompute<:Function,FGet<:Function,FSet<:Function,FEnter<:Function,FExit<:Function} + cached = get_cached(key) + if cached !== nothing + if !cached.over_budget || depth >= cached.depth + cached.over_budget && _mark_budget_hit!(budget_state) + return cached.summary + end + end + + budget_state !== nothing && budget_state.hit && return nothing + + if inprogress_enter(key) + _mark_budget_hit!(budget_state) + return nothing + end + + summ = nothing + local_budget = BudgetTracker(false) + try + summ = compute(local_budget) + catch e + if cfg.debug + world = _reflection_world() + bt = catch_backtrace() + st = try + Base.stacktrace(bt) + catch + nothing + end + frames = (st === nothing) ? nothing : [string(fr) for fr in st[1:min(end, 8)]] + _auto_debug_emit( + cfg, + Dict( + "event" => "auto_debug_summary_exception", + "key" => string(key), + "world" => world, + "depth" => depth, + "error" => sprint(showerror, e), + "backtrace" => frames, + ), + ) + end + summ = nothing + finally + inprogress_exit(key) + end + + if summ !== nothing + set_cached(key, SummaryCacheEntry(summ, depth, local_budget.hit)) + end + + cached2 = get_cached(key) + cached2 !== nothing && cached2.over_budget && _mark_budget_hit!(budget_state) + return cached2 === nothing ? summ : cached2.summary +end + +function _summary_cached_tt( + compute::Function, key, cfg::Config; depth::Int, budget_state=nothing, allow_core::Bool +) + return _summary_cached( + compute, + key, + cfg; + depth=depth, + budget_state=budget_state, + get_cached=_summary_state_get_tt, + set_cached=_summary_state_set_tt!, + inprogress_enter=_summary_state_tt_inprogress_enter!, + inprogress_exit=_summary_state_tt_inprogress_exit!, + ) +end + +function _summary_cached_mi( + compute::Function, key, cfg::Config; depth::Int, budget_state=nothing +) + return _summary_cached( + compute, + key, + cfg; + depth=depth, + budget_state=budget_state, + get_cached=_summary_state_get_mi, + set_cached=_summary_state_set_mi!, + inprogress_enter=_summary_state_mi_inprogress_enter!, + inprogress_exit=_summary_state_mi_inprogress_exit!, + ) +end + +function _summarize_entries(codes, cfg::Config; depth::Int, budget_state=nothing) + writes = BitSet() + consumes = BitSet() + ret_aliases = BitSet() + got = false + + for entry in codes + ir = entry.first + ir isa CC.IRCode || continue + got = true + s = _summarize_ir_effects(ir, cfg; depth=depth, budget_state=budget_state) + union!(writes, s.writes) + union!(consumes, s.consumes) + union!(ret_aliases, s.ret_aliases) + end + + got || return nothing + return EffectSummary(; writes=writes, consumes=consumes, ret_aliases=ret_aliases) +end + +function _summary_for_tt( + tt::Type{<:Tuple}, cfg::Config; depth::Int, budget_state=nothing, allow_core::Bool=false +) + world = _reflection_world() + key = (tt, UInt(world), cfg) + + try + tt_u = Base.unwrap_unionall(tt) + if tt_u isa DataType && !isempty(tt_u.parameters) + fT = tt_u.parameters[1] + dt = Base.unwrap_unionall(fT) + if dt isa DataType + m = dt.name.module + if dt.name === Base.unwrap_unionall(Type).name && !isempty(dt.parameters) + targ = Base.unwrap_unionall(dt.parameters[1]) + if targ isa DataType + m = targ.name.module + end + end + if m === BorrowChecker || (!allow_core && m === Core) + return nothing + end + end + end + catch + end + + return _summary_cached_tt( + key, cfg; depth=depth, budget_state=budget_state, allow_core=allow_core + ) do local_budget + codes = _code_ircode_by_type( + tt; optimize_until=cfg.optimize_until, world=world, cfg + ) + return _summarize_entries(codes, cfg; depth=depth, budget_state=local_budget) + end +end + +function _summary_for_mi(mi, cfg::Config; depth::Int, budget_state=nothing) + try + if mi isa Core.MethodInstance + m = mi.def + if (m isa Method) && (m.module === Core || m.module === BorrowChecker) + return nothing + end + end + catch + return nothing + end + + world = _reflection_world() + key = (mi, UInt(world), cfg) + + return _summary_cached_mi( + key, cfg; depth=depth, budget_state=budget_state + ) do local_budget + tt = mi.specTypes + codes = _code_ircode_by_type( + tt; optimize_until=cfg.optimize_until, world=world, cfg + ) + return _summarize_entries(codes, cfg; depth=depth, budget_state=local_budget) + end +end + +function _widenargtype_or_any(@nospecialize(x), ir::CC.IRCode) + try + t = CC.widenconst(_safe_argextype(x, ir)) + return (t isa Type) ? t : Any + catch + return Any + end +end + +function _is_box_contents_setfield!( + @nospecialize(f), raw_args::AbstractVector, ir::CC.IRCode +)::Bool + f === Core.setfield! || return false + length(raw_args) >= 4 || return false + fld = raw_args[3] + fldsym = fld isa QuoteNode ? fld.value : fld + fldsym === :contents || return false + + obj = raw_args[2] + Tobj = _widenargtype_or_any(obj, ir) + if isdefined(Core, :Box) + try + return Tobj <: Core.Box + catch + return false + end + end + return false +end + +function _filter_consumes_for_call( + @nospecialize(f), + raw_args::AbstractVector, + eff::EffectSummary, + ir::CC.IRCode, + nargs::Int, + track_arg, + track_ssa, +)::EffectSummary + isempty(eff.consumes) && return eff + + consumes = BitSet() + for p in eff.consumes + (1 <= p <= length(raw_args)) || continue + + # Captured-variable boxing uses `setfield!(box, :contents, val)` as an + # implementation detail. Treat this as aliasing, not an ownership move. + if p == 4 && _is_box_contents_setfield!(f, raw_args, ir) + continue + end + + v = raw_args[p] + hv = _handle_index(v, nargs, track_arg, track_ssa) + hv == 0 && continue + + Tv = _widenargtype_or_any(v, ir) + is_owned_type(Tv) || continue + + push!(consumes, p) + end + + consumes == eff.consumes && return eff + return EffectSummary(; + writes=eff.writes, consumes=consumes, ret_aliases=eff.ret_aliases + ) +end + +function _effects_for_call( + stmt, + ir::CC.IRCode, + cfg::Config, + track_arg, + track_ssa, + nargs::Int; + idx::Int=0, + depth::Int=0, + budget_state=nothing, +)::EffectSummary + head, mi, raw_args = _call_parts(stmt) + raw_args === nothing && return EffectSummary() + + # Fast path: if this call does not involve any tracked values from the current IR, + # it cannot influence borrow checking (consume/write/alias) for this caller. + any_tracked = false + # NOTE: include the callee expression itself. This matters for functors/closures + # where `f()` can mutate/alias through captured state or `f`'s own fields. + @inbounds for v in raw_args + _handle_index(v, nargs, track_arg, track_ssa) != 0 && (any_tracked = true; break) + end + any_tracked || return EffectSummary() + f = _resolve_callee(stmt, ir) + + if idx != 0 + Tret = try + inst = ir[Core.SSAValue(idx)] + CC.widenconst(_inst_get(inst, :type, Any)) + catch + Any + end + if Tret === Union{} + return EffectSummary() + end + end + + if f === __bc_bind__ + return EffectSummary() + end + + # Treat most `Type(...)` calls (constructors/conversions) as pure with respect to + # ownership: constructing a new object that (may) hold references to inputs should + # create aliases, not "move"/consume the inputs. + # + # Exception: callable objects (subtypes of `Function`) model captured environments + # (closures/functors) that can escape and outlive the current scope, so keep the + # normal conservative behavior for those. + if head === :call && f isa Type + # If a `Type(...)` call has explicit known effects (e.g. `Task(f)`), honor them. + if _known_effects_get(f) === nothing + is_functor = (f <: Function) + is_functor || return EffectSummary() + end + end + + # `_apply_iterate` is Core plumbing used for splatting/varargs and some wrappers. + # Treat it as a transparent call wrapper: infer effects for the callee (`raw_args[3]`) + # on the expanded argument list, then map those effects back to the original + # `_apply_iterate` argument positions. This avoids spurious "consume" results for + # Base wrappers like `setindex!` that route through `_apply_iterate`. + if f === Core._apply_iterate && length(raw_args) >= 3 + # Inner callee + inner_f = try + CC.singleton_type(_safe_argextype(raw_args[3], ir)) + catch + nothing + end + if inner_f === nothing && raw_args[3] isa GlobalRef + inner_f = try + getfield(raw_args[3].mod, raw_args[3].name) + catch + nothing + end + end + if inner_f !== nothing + expanded_types = Any[typeof(inner_f)] + posmap = Int[3] + + # Expand tuple arguments when possible; otherwise treat as a splat-container + # described by its tuple type (when statically known). + for j in 4:length(raw_args) + argj = raw_args[j] + elems = _maybe_tuple_elements(argj, ir) + if elems !== nothing + for e in elems + push!(expanded_types, _widenargtype_or_any(e, ir)) + push!(posmap, j) + end + continue + end + + # In `_apply_iterate`, arguments after the callee are typically splat-containers. + # If we know this container is a concrete Tuple type, expand its element types + # so the inferred callee TT matches the post-splat argument list. + Tj = _widenargtype_or_any(argj, ir) + if Tj === Tuple{} + continue + end + dt = Base.unwrap_unionall(Tj) + if dt isa DataType && dt.name === Tuple.name + params = dt.parameters + has_vararg = any(p -> p isa Core.TypeofVararg, params) + if !has_vararg + for te in params + te2 = Base.unwrap_unionall(te) + push!(expanded_types, (te2 isa Type) ? te2 : Any) + push!(posmap, j) + end + continue + end + end + + # Unknown or non-tuple splat-container: treat as a single argument. + push!(expanded_types, Tj) + push!(posmap, j) + end + + tt = Core.apply_type(Tuple, expanded_types...) + s_inner = _known_effects_get(inner_f) + if s_inner === nothing && tt !== nothing && depth < cfg.max_summary_depth + s_inner = _summary_for_tt( + tt, cfg; depth=depth + 1, budget_state=budget_state + ) + end + + if s_inner !== nothing + writes = BitSet() + consumes = BitSet() + ret_aliases = BitSet() + for p in s_inner.writes + (1 <= p <= length(posmap)) || continue + push!(writes, posmap[p]) + end + for p in s_inner.consumes + (1 <= p <= length(posmap)) || continue + push!(consumes, posmap[p]) + end + for p in s_inner.ret_aliases + (1 <= p <= length(posmap)) || continue + push!(ret_aliases, posmap[p]) + end + return EffectSummary(; + writes=writes, consumes=consumes, ret_aliases=ret_aliases + ) + end + end + end + + if f !== nothing + s = _known_effects_get(f) + s === nothing || return _filter_consumes_for_call( + f, raw_args, s, ir, nargs, track_arg, track_ssa + ) + end + + if f !== nothing && _is_namedtuple_ctor(f) + return EffectSummary() + end + + if f === Core.kwcall + tt_kw = _kwcall_tt_from_raw_args(raw_args, ir) + if tt_kw !== nothing + if depth < cfg.max_summary_depth + s = _summary_for_tt( + tt_kw, cfg; depth=depth + 1, budget_state=budget_state, allow_core=true + ) + if s !== nothing + return _filter_consumes_for_call( + f, raw_args, s, ir, nargs, track_arg, track_ssa + ) + end + else + _mark_budget_hit!(budget_state) + end + end + end + + if head === :invoke && (mi !== nothing) + if depth < cfg.max_summary_depth + s = _summary_for_mi(mi, cfg; depth=depth + 1, budget_state=budget_state) + if s !== nothing + return _filter_consumes_for_call( + f, raw_args, s, ir, nargs, track_arg, track_ssa + ) + end + else + _mark_budget_hit!(budget_state) + end + end + + if head === :call && f === nothing + fexpr = raw_args[1] + if fexpr isa Core.SSAValue + tt = _call_tt_from_raw_args(raw_args, ir) + if tt !== nothing + if depth < cfg.max_summary_depth + s = _summary_for_tt(tt, cfg; depth=depth + 1, budget_state=budget_state) + if s !== nothing + return _filter_consumes_for_call( + f, raw_args, s, ir, nargs, track_arg, track_ssa + ) + end + else + _mark_budget_hit!(budget_state) + end + end + end + end + + if head === :call && f !== nothing + tt = _call_tt_from_raw_args(raw_args, ir, f) + if tt !== nothing + if depth < cfg.max_summary_depth + s = _summary_for_tt(tt, cfg; depth=depth + 1, budget_state=budget_state) + if s !== nothing + return _filter_consumes_for_call( + f, raw_args, s, ir, nargs, track_arg, track_ssa + ) + end + else + _mark_budget_hit!(budget_state) + end + end + end + + consumes = Int[] + # `raw_args[1]` is the function value. Calling a function does not (by itself) + # consume/move the function object, so treat only user arguments as candidates. + for p in 2:length(raw_args) + v = raw_args[p] + h = _handle_index(v, nargs, track_arg, track_ssa) + h == 0 && continue + Tv = _widenargtype_or_any(v, ir) + is_owned_type(Tv) || continue + push!(consumes, p) + end + return EffectSummary(; consumes=consumes) +end + +function _push_arg_aliases!(dest::BitSet, uf::UnionFind, root::Int, nargs::Int, track_arg) + for a in 1:nargs + track_arg[a] || continue + if _uf_find(uf, a) == root + push!(dest, a) + end + end + return nothing +end + +function _push_arg_aliases_for_handle!( + dest::BitSet, uf::UnionFind, hv::Int, nargs::Int, track_arg +) + hv == 0 && return nothing + root = _uf_find(uf, hv) + return _push_arg_aliases!(dest, uf, root, nargs, track_arg) +end + +function _summarize_ir_effects( + ir::CC.IRCode, cfg::Config; depth::Int, budget_state=nothing +)::EffectSummary + nargs = length(ir.argtypes) + nstmts = length(ir.stmts) + track_arg, track_ssa = compute_tracking_masks(ir) + + # Treat `@unsafe` regions as opaque/effectless for summary purposes. + # This matches the main checker behavior: effects inside the region are not + # propagated outward (the user is taking responsibility for invariants). + unsafe_stmt = _unsafe_stmt_mask(ir) + + uf = UnionFind(nargs + nstmts) + _build_alias_classes!( + uf, + ir, + cfg, + track_arg, + track_ssa, + nargs; + unsafe_stmt=unsafe_stmt, + depth=depth, + budget_state=budget_state, + ) + + writes = BitSet() + consumes = BitSet() + ret_aliases = BitSet() + + for i in 1:nstmts + (1 <= i <= length(unsafe_stmt) && unsafe_stmt[i]) && continue + stmt = ir[Core.SSAValue(i)][:stmt] + if stmt isa Expr && stmt.head === :foreigncall + name_sym, ccall_args, _gc_roots, _nccallargs = _foreigncall_parts(stmt) + eff = + (name_sym === nothing) ? nothing : _known_foreigncall_effects_get(name_sym) + + if eff === nothing + # Unknown foreigncall: treat as write to the C arguments only (ignore GC roots). + for v in ccall_args + hs = _backward_used_handles(v, ir, nargs, track_arg, track_ssa) + for hv in hs + _push_arg_aliases!(writes, uf, _uf_find(uf, hv), nargs, track_arg) + end + end + continue + end + + for grp in eff.write_groups + hs = _foreigncall_group_used_handles( + ccall_args, grp, ir, nargs, track_arg, track_ssa + ) + for hv in hs + _push_arg_aliases!(writes, uf, _uf_find(uf, hv), nargs, track_arg) + end + end + + for grp in eff.consume_groups + hs = _foreigncall_group_used_handles( + ccall_args, grp, ir, nargs, track_arg, track_ssa + ) + for hv in hs + _push_arg_aliases!(consumes, uf, _uf_find(uf, hv), nargs, track_arg) + end + end + + continue + end + + head, _mi, raw_args = _call_parts(stmt) + raw_args === nothing && continue + + eff = _effects_for_call( + stmt, + ir, + cfg, + track_arg, + track_ssa, + nargs; + idx=i, + depth=depth, + budget_state=budget_state, + ) + + kw_vals = _kwcall_value_exprs(stmt, ir) + (kw_vals === nothing || isempty(kw_vals)) && (kw_vals = nothing) + + for p in eff.writes + if kw_vals !== nothing && p == 2 + for vkw in kw_vals + hv = _handle_index(vkw, nargs, track_arg, track_ssa) + _push_arg_aliases_for_handle!(writes, uf, hv, nargs, track_arg) + end + continue + end + + v = raw_args[p] + hv = _handle_index(v, nargs, track_arg, track_ssa) + _push_arg_aliases_for_handle!(writes, uf, hv, nargs, track_arg) + end + for p in eff.consumes + if kw_vals !== nothing && p == 2 + for vkw in kw_vals + hv = _handle_index(vkw, nargs, track_arg, track_ssa) + _push_arg_aliases_for_handle!(consumes, uf, hv, nargs, track_arg) + end + continue + end + + v = raw_args[p] + hv = _handle_index(v, nargs, track_arg, track_ssa) + _push_arg_aliases_for_handle!(consumes, uf, hv, nargs, track_arg) + end + end + + for i in 1:nstmts + stmt = ir[Core.SSAValue(i)][:stmt] + rv = if stmt isa Core.ReturnNode + isdefined(stmt, :val) ? stmt.val : nothing + elseif stmt isa Expr && stmt.head === :return && !isempty(stmt.args) + stmt.args[1] + else + continue + end + hrv = _handle_index(rv, nargs, track_arg, track_ssa) + hrv == 0 && continue + rroot = _uf_find(uf, hrv) + for a in 1:nargs + track_arg[a] || continue + if _uf_find(uf, a) == rroot + push!(ret_aliases, a) + end + end + end + + return EffectSummary(; writes=writes, consumes=consumes, ret_aliases=ret_aliases) +end diff --git a/src/safe/utils.jl b/src/safe/utils.jl new file mode 100644 index 0000000..3ecd76f --- /dev/null +++ b/src/safe/utils.jl @@ -0,0 +1,22 @@ +""" + PerTaskCache{T,F} + +A per-task cache that allows us to avoid repeated locking. +""" +struct PerTaskCache{T,F<:Function} + constructor::F + + PerTaskCache{T}(constructor::F) where {T,F} = new{T,F}(constructor) +end +PerTaskCache{T}() where {T} = PerTaskCache{T}(() -> T()) + +function Base.getindex(cache::PerTaskCache{T}) where {T} + tls = Base.task_local_storage() + if haskey(tls, cache) + return tls[cache]::T + else + value = cache.constructor()::T + tls[cache] = value + return value + end +end diff --git a/test/FakeModule/LocalPreferences.toml b/test/FakeModule/LocalPreferences.toml new file mode 100644 index 0000000..c9087af --- /dev/null +++ b/test/FakeModule/LocalPreferences.toml @@ -0,0 +1,2 @@ +[FakeModule] +borrow_checker = false \ No newline at end of file diff --git a/test/FakeModule/Project.toml b/test/FakeModule/Project.toml new file mode 100644 index 0000000..b15dd3e --- /dev/null +++ b/test/FakeModule/Project.toml @@ -0,0 +1,8 @@ +name = "FakeModule" +uuid = "9a2c8f72-9e38-4a5d-a85d-5858daab90a4" +authors = ["MilesCranmer "] +version = "0.1.0" + +[deps] +BorrowChecker = "7bdcaa52-c310-4bb0-bf54-d941056ed284" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/FakeModule/src/FakeModule.jl b/test/FakeModule/src/FakeModule.jl new file mode 100644 index 0000000..e108d54 --- /dev/null +++ b/test/FakeModule/src/FakeModule.jl @@ -0,0 +1,64 @@ +module FakeModule + +using BorrowChecker +using BorrowChecker: @spawn, LockNotHeldError +using Test + +function test() + @own x = Ref(1) + @test x isa Base.RefValue{Int} + @test !(x isa Owned{Base.RefValue{Int}}) + @move y = x + @test y isa Base.RefValue{Int} + @test !(y isa Owned) + # Since borrow checker is disabled, x should still be accessible + @test x[] == 1 + # @take! should just return the value directly + @test (@take! x)[] == 1 + # This error now goes undetected: + @test x[] == 1 + + @own :mut z = Ref(1) + z[] = 2 + @test z[] == 2 + @test z isa Base.RefValue{Int} + @test !(z isa OwnedMut{Base.RefValue{Int}}) + + # Test @lifetime and @ref + @lifetime l begin + @ref ~l r = z + @test r[] == 2 + @test r isa Base.RefValue{Int} + @test !(r isa Borrowed{Base.RefValue{Int}}) + # Should be able to modify z since borrow checker is disabled + z[] = 3 + @test z[] == 3 + @test r[] == 3 + end + + let + @own z = [1, 2, 3] + @own :mut z_mut = [1, 2, 3] + f(y) = (@test y isa Vector{Int}; y) + @test @bc(f(z)) === f(z) + @test @bc(f(@mut(z_mut))) === f(z_mut) + end + + let + # This spawn still works because the borrow checker is disabled + @own x = 1 + @test fetch(@spawn x + 1) == 2 + end + + let + m = Mutex([1, 2, 3]) + # Locking should still work: + @test_throws LockNotHeldError @ref_into :mut arr = m[] + @test !islocked(m) + lock(m) + @test islocked(m) + @ref_into :mut arr = m[] + end +end + +end diff --git a/test/Project.toml b/test/Project.toml new file mode 100644 index 0000000..1597244 --- /dev/null +++ b/test/Project.toml @@ -0,0 +1,14 @@ +[deps] +Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +DispatchDoctor = "8d63f2c5-f18a-4cf2-ba9d-b3f60fc568c8" +DynamicExpressions = "a40a106e-89c9-4ca8-8020-a735e8728b6b" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +PerformanceTestTools = "dc46b164-d16f-48ec-a853-60448fc869fe" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" +TestItems = "1c621080-faea-4a02-84b6-bbd5e436b8fe" + diff --git a/test/auto_borrow_checker_tests.jl b/test/auto_borrow_checker_tests.jl new file mode 100644 index 0000000..7e930c6 --- /dev/null +++ b/test/auto_borrow_checker_tests.jl @@ -0,0 +1,1874 @@ +@testitem "Auto @safe" tags = [:auto] begin + using TestItems + using BorrowChecker + using LinearAlgebra + + using BorrowChecker: BorrowCheckError, @safe + + const BC_TEST_TIMINGS = + lowercase(get(ENV, "BORROWCHECKER_TEST_TIMINGS", "")) in ("1", "true", "yes") + const BC_TEST_TIMING_BASE_DEPTH = Ref{Int}(-1) + + if BC_TEST_TIMINGS + using Test + + mutable struct BCTimedTestSet <: Test.AbstractTestSet + inner::Test.DefaultTestSet + start_ns::UInt64 + end + + function BCTimedTestSet( + desc::AbstractString; + verbose::Bool=false, + showtiming::Bool=true, + failfast::Union{Nothing,Bool}=nothing, + source=nothing, + rng=nothing, + ) + # Only print timings for the direct children of the timing wrapper (top-level + # `@testset` blocks in this file). + depth = Test.get_testset_depth() + if depth == BC_TEST_TIMING_BASE_DEPTH[] + 1 + println("[BC_TEST_BEGIN] ", desc) + end + inner = Test.DefaultTestSet( + desc; + verbose=verbose, + showtiming=showtiming, + failfast=failfast, + source=source, + rng=rng, + ) + return BCTimedTestSet(inner, time_ns()) + end + + Test.record(ts::BCTimedTestSet, t) = Test.record(ts.inner, t) + Test.print_verbose(ts::BCTimedTestSet) = Test.print_verbose(ts.inner) + Test.results(ts::BCTimedTestSet) = Test.results(ts.inner) + + function Test.finish( + ts::BCTimedTestSet; print_results::Bool=Test.TESTSET_PRINT_ENABLE[] + ) + elapsed_s = (time_ns() - ts.start_ns) / 1e9 + depth = Test.get_testset_depth() + if depth == BC_TEST_TIMING_BASE_DEPTH[] + 1 + println( + "[BC_TEST_END] ", + ts.inner.description, + " ", + round(elapsed_s; digits=3), + "s", + ) + end + return Test.finish(ts.inner; print_results=print_results) + end + end + + local _bc_timing_ts = nothing + if BC_TEST_TIMINGS + using Test + BC_TEST_TIMING_BASE_DEPTH[] = Test.get_testset_depth() + _bc_timing_ts = BCTimedTestSet( + "BorrowChecker @safe (timings)"; showtiming=false + ) + Test.push_testset(_bc_timing_ts) + end + + Base.@noinline fakewrite(x) = Base.inferencebarrier(x) + + BorrowChecker._ensure_registry_initialized() + const BC_BUILTIN_EFFECT_KEYS = Base.@lock BorrowChecker.KNOWN_EFFECTS begin + collect(keys(BorrowChecker.KNOWN_EFFECTS[])) + end + + mutable struct Box + x::Int + end + + mutable struct A + x::Int + end + + struct B + a::A + end + + mutable struct C + v + end + + struct Wrap + x::Vector{Int} + end + + BorrowChecker.@safe function _bc_bad_alias() + x = [1, 2, 3] + y = x + x[1] = 0 + return y + end + + BorrowChecker.@safe function _bc_ok_copy() + x = [1, 2, 3] + y = copy(x) + x[1] = 0 + return y + end + + BorrowChecker.@safe function _bc_bad_unknown_call(vf) + x = [1, 2, 3] + f = only(vf) + f(x) + x[1] = 0 + return x + end + + BorrowChecker.@safe function _bc_bad_alias_mutable_struct() + x = Box(1) + y = x + x.x = 0 + return y + end + + BorrowChecker.@safe function _bc_ok_copy_mutable_struct() + x = Box(1) + y = Box(x.x) + x.x = 0 + return y + end + + BorrowChecker.@safe function _bc_bad_struct_of_struct() + a = A(1) + b = B(a) + c = b + b.a.x = 0 + return c + end + + BorrowChecker.@safe function _bc_ok_struct_of_struct() + a = A(1) + b = B(a) + c = B(A(b.a.x)) + b.a.x = 0 + return c + end + + g!(x) = (push!(x, 1); nothing) + + const _BC_ESCAPE_CACHE = Any[] + _bc_consumes(x) = (push!(_BC_ESCAPE_CACHE, x); nothing) + const D = Dict{Any,Any}() + + @testset "g!(y) should not require deleting x" begin + BorrowChecker.@safe function _bc_g_alias_ok() + x = [1, 2, 3] + y = x + g!(y) + return y + end + + @test _bc_g_alias_ok() == [1, 2, 3, 1] + end + + @testset "effects inferred from IR (no naming heuristics)" begin + h(x) = (push!(x, 1); nothing) + + BorrowChecker.@safe function _bc_nonbang_mutator_bad() + x = [1, 2, 3] + y = x + h(x) + return y + end + + mut_second!(a, b) = (push!(b, 1); nothing) + + BorrowChecker.@safe function _bc_bang_mutates_second_bad() + x = [1, 2, 3] + y = [4] + z = y + mut_second!(x, y) + return z + end + + @test_throws BorrowCheckError _bc_nonbang_mutator_bad() + @test_throws BorrowCheckError _bc_bang_mutates_second_bad() + end + + @testset "array element extraction preserves aliases" begin + @safe function _bc_array_getindex_alias_bad(outer::Vector{Vector{Int}}) + a = outer[1] + b = outer[1] + push!(a, 2) + return b + end + + @test_throws BorrowCheckError _bc_array_getindex_alias_bad([[1]]) + end + + @testset "adversarial overloads (no special-casing overloadables)" begin + mutable struct _BCGetPropMutates + x::Vector{Int} + end + + function Base.getproperty(g::_BCGetPropMutates, s::Symbol) + if s === :x + v = getfield(g, :x) + push!(v, 999) + return v + end + return getfield(g, s) + end + + BorrowChecker.@safe function _bc_getproperty_mutates_bad() + g = _BCGetPropMutates([1, 2, 3]) + y = getfield(g, :x) + g.x # calls overloaded getproperty (mutates) + return y + end + + @test_throws BorrowCheckError _bc_getproperty_mutates_bad() + + mutable struct _BCSetPropDoesNotMutate + x::Vector{Int} + end + + Base.setproperty!(::_BCSetPropDoesNotMutate, ::Symbol, v) = v + + BorrowChecker.@safe function _bc_setproperty_no_mut_ok() + g = _BCSetPropDoesNotMutate([1, 2, 3]) + y = g + g.x = [4, 5, 6] # calls overloaded setproperty! (does not mutate) + return y + end + + @test _bc_setproperty_no_mut_ok().x == [1, 2, 3] + + mutable struct _BCCopyAliases + x::Vector{Int} + end + + Base.copy(x::_BCCopyAliases) = x + + BorrowChecker.@safe function _bc_copy_aliases_bad() + x = _BCCopyAliases([1, 2, 3]) + y = copy(x) # aliases by definition + x.x[1] = 0 + return y + end + + @test_throws BorrowCheckError _bc_copy_aliases_bad() + + mutable struct _BCIterateWeird + x::Vector{Int} + end + + Base.iterate(w::_BCIterateWeird) = (push!(getfield(w, :x), 1); (0, w)) + Base.iterate(::_BCIterateWeird, _) = nothing + + BorrowChecker.@safe function _bc_iterate_mutates_bad() + w = _BCIterateWeird([1, 2, 3]) + y = w + iterate(w) # calls overloaded iterate (mutates) + return y + end + + @test_throws BorrowCheckError _bc_iterate_mutates_bad() + end + + @testset "macro signature parsing: varargs" begin + @safe function _bc_varargs_signature(xs...) + return 0 + end + @test _bc_varargs_signature() == 0 + @test _bc_varargs_signature(1) == 0 + @test _bc_varargs_signature(1, 2) == 0 + end + + @testset "macro signature parsing: default args" begin + @safe function _bc_default_arg_signature(x=1) + return x + 1 + end + + @test _bc_default_arg_signature() == 2 + end + + @testset "macro signature parsing: keyword-only signature" begin + @safe function _bc_keyword_only_signature(; x, y) + return x + y + end + + @test _bc_keyword_only_signature(; x=1, y=2) == 3 + end + + @testset "macro signature parsing: anonymous typed arg" begin + @safe function _bc_anon_typed_arg_signature(x, ::Type{T}=Int) where {T} + return T + end + + @test _bc_anon_typed_arg_signature(1) == Int + @test _bc_anon_typed_arg_signature(1, Float64) == Float64 + end + + @testset "macro signature parsing: destructuring arg" begin + @safe function _bc_destructure_signature((a, b)) + return a + b + end + + @test _bc_destructure_signature((1, 2)) == 3 + end + + @testset "macro signature parsing: where + return type" begin + @safe function _bc_where_ret_signature(x::T)::T where {T} + return x + end + + @test _bc_where_ret_signature(1) == 1 + end + + @testset "macro signature parsing: functor call method" begin + struct _BCFun end + + @safe (f::_BCFun)(x) = x + 1 + + @test _BCFun()(1) == 2 + end + + @testset "macro signature parsing: dotted function name" begin + struct _BCAutoDotT end + + BorrowChecker.@safe function Base.identity(x::_BCAutoDotT) + return x + end + + @test Base.identity(_BCAutoDotT()) isa _BCAutoDotT + end + + @testset "boxed captured variable: getproperty field type refinement" begin + struct _BCBoxedField + n::Int + end + + @safe function _bc_boxed_getproperty_dim(x::_BCBoxedField) + g = () -> getfield(x, :n) + x = fakewrite(x) # capture + assign forces Core.Box lowering + a = zeros(Float64, getfield(x, :n)) + return (g(), length(a)) + end + + @test begin + try + _bc_boxed_getproperty_dim(_BCBoxedField(3)) == (3, 3) + catch + false + end + end + end + + @testset "boxed captured variable: broadcast materialize should not consume" begin + struct _BCBoxedBroadcast + n::Int + end + + @safe function _bc_boxed_broadcast_ok(x::_BCBoxedBroadcast) + g = () -> getfield(x, :n) + x = fakewrite(x) # capture + assign forces Core.Box lowering + b = rand(getfield(x, :n)) .< 0.5 + return (g(), sum(b)) + end + + @test begin + try + (n, s) = _bc_boxed_broadcast_ok(_BCBoxedBroadcast(10)) + n == 10 && s isa Real + catch + false + end + end + end + + @testset "Threads.@threads plumbing should not spuriously consume" begin + struct _BCThreadsBoxedRange + n::Int + end + + @safe function _bc_threads_boxed_range_ok(x::_BCThreadsBoxedRange, flag::Bool) + g = () -> getfield(x, :n) + x = fakewrite(x) # capture + assign forces Core.Box lowering + + r = 1:(getfield(x, :n)) + if flag + Base.Threads.@threads for i in r + fakewrite(i) + end + else + for i in r + fakewrite(i) + end + end + + return g() + end + + @test_broken begin + try + _bc_threads_boxed_range_ok(_BCThreadsBoxedRange(5), false) == 5 + catch + false + end + end + end + + @testset "known failure: Array{Int,l}(x) with value l" begin + @safe scope = :function function _bc_array_value_dim_ctor(x) + l = 1 + return Array{Int,l}(x) + end + + @test begin + try + _bc_array_value_dim_ctor([1]) == [1] + catch + false + end + end + end + + @testset "@safe assignment instrumentation: store should not create fresh origins" begin + mutable struct _BCProjectionS + a::Vector{Int} + end + + bump!(a::Vector{Int}) = (a[1] += 1; nothing) + + @safe function _bc_projection_store_ok(s::_BCProjectionS) + a = copy(s.a) # avoid aliasing `s.a` during mutation + bump!(a) + s.a = a + return s.a[1] + end + + @test _bc_projection_store_ok(_BCProjectionS([1, 2, 3])) == 2 + end + + @testset "@safe known effects: eachindex should not consume/escape" begin + @safe function _bc_eachindex_ok(refs, constants) + for i in eachindex(refs, constants) + refs[i] = constants[i] + end + return refs + end + + @test _bc_eachindex_ok([1, 2, 3], [4, 5, 6]) == [4, 5, 6] + end + + @testset "@safe known effects: copy should not consume" begin + @safe function _bc_copy_call_ok(x) + y = copy(x) + return (x, y) + end + + (x, y) = _bc_copy_call_ok([1, 2, 3]) + @test x == [1, 2, 3] + @test y == [1, 2, 3] + end + + @testset "macro rejects non-function inputs" begin + @test_throws LoadError eval(:(BorrowChecker.@safe begin + x = 1 + end)) + end + + @testset "macro option parsing: Config overrides" begin + BorrowChecker.@safe max_summary_depth = 1 function _bc_macro_opt_max_depth(x) + return x + end + @test _bc_macro_opt_max_depth(1) == 1 + + opt = BorrowChecker.Config().optimize_until + @eval BorrowChecker.@safe( + optimize_until = $opt, _bc_macro_opt_optimize_until(x) = x + ) + @test _bc_macro_opt_optimize_until(2) == 2 + end + + @testset "@safe debug logging" begin + using Test + + Base.@noinline _bc_dbg_localfun(x) = x + + @safe debug = true debug_callee_depth = 1 function _bc_dbg_fail(n::Int) + x = zeros(Float64, n) + x2 = _bc_dbg_localfun(x) + y = x2 + x2[1] = 0.0 + return length(y) + end + + BC_TEST_TIMINGS && println("[BC_DEBUG] case: dbg_fail depth=1 (begin)") + mktemp() do path, io + close(io) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do + BC_TEST_TIMINGS && + println("[BC_DEBUG] calling _bc_dbg_fail(3) (expect throw)") + @test_throws BorrowCheckError _bc_dbg_fail(3) + BC_TEST_TIMINGS && println("[BC_DEBUG] _bc_dbg_fail(3) threw as expected") + end + + BC_TEST_TIMINGS && println("[BC_DEBUG] reading jsonl output (dbg_fail depth=1)") + s = read(path, String) + ir_lines = filter( + l -> occursin("\"event\":\"auto_debug_ir\"", l), + split(s, '\n'; keepempty=false), + ) + @test occursin("\"event\":\"auto_debug_check\"", s) + @test occursin("\"event\":\"auto_debug_violations\"", s) + @test occursin("\"event\":\"auto_debug_summaries\"", s) + @test occursin("_bc_dbg_localfun", s) + @test any(l -> occursin("\"depth\":0", l), ir_lines) + @test any(l -> occursin("\"depth\":1", l), ir_lines) + end + + @safe debug = true debug_callee_depth = 0 function _bc_dbg_depth0(n::Int) + x = zeros(Float64, n) + x2 = _bc_dbg_localfun(x) + y = x2 + x2[1] = 0.0 + return length(y) + end + + BC_TEST_TIMINGS && println("[BC_DEBUG] case: dbg_depth0 depth=0 (begin)") + mktemp() do path, io + close(io) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do + BC_TEST_TIMINGS && + println("[BC_DEBUG] calling _bc_dbg_depth0(3) (expect throw)") + _ = try + _bc_dbg_depth0(3) + nothing + catch + nothing + end + end + BC_TEST_TIMINGS && + println("[BC_DEBUG] reading jsonl output (dbg_depth0 depth=0)") + s = read(path, String) + ir_lines = filter( + l -> occursin("\"event\":\"auto_debug_ir\"", l), + split(s, '\n'; keepempty=false), + ) + @test any(l -> occursin("\"depth\":0", l), ir_lines) + @test !any(l -> occursin("\"depth\":1", l), ir_lines) + end + + @safe debug = true debug_callee_depth = 0 function _bc_dbg_ok(x) + return x + 1 + end + BC_TEST_TIMINGS && println("[BC_DEBUG] case: dbg_ok (begin)") + mktemp() do path, io + close(io) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do + BC_TEST_TIMINGS && println("[BC_DEBUG] calling _bc_dbg_ok(1)") + @test _bc_dbg_ok(1) == 2 + end + BC_TEST_TIMINGS && println("[BC_DEBUG] reading jsonl output (dbg_ok)") + s = read(path, String) + @test occursin("\"event\":\"auto_debug_check\"", s) && + occursin("\"ok\":true", s) + @test !occursin("\"event\":\"auto_debug_error\"", s) + end + + # Warning + default-path behavior when env var is unset. + BC_TEST_TIMINGS && println("[BC_DEBUG] case: warn default-path (begin)") + default_path = joinpath(tempdir(), "BorrowChecker.auto.debug.$(getpid()).jsonl") + rm(default_path; force=true) + old = pop!(ENV, "BORROWCHECKER_AUTO_DEBUG_PATH", nothing) + logs, _ = Test.collect_test_logs() do + try + BC_TEST_TIMINGS && + println("[BC_DEBUG] calling _bc_dbg_depth0(3) with env unset (1)") + _bc_dbg_depth0(3) + catch + end + try + BC_TEST_TIMINGS && + println("[BC_DEBUG] calling _bc_dbg_depth0(3) with env unset (2)") + _bc_dbg_depth0(3) + catch + end + end + old === nothing || (ENV["BORROWCHECKER_AUTO_DEBUG_PATH"] = old) + @test count( + lr -> + lr.level == Base.CoreLogging.Warn && occursin( + "BorrowChecker.@safe debug enabled; writing JSONL debug log to", + lr.message, + ), + logs, + ) == 1 + @test isfile(default_path) + + # Exercise the error-swallowing path in debug logging by pointing the log path to a directory. + BC_TEST_TIMINGS && println("[BC_DEBUG] case: debug path is directory (begin)") + mktempdir() do d + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => d) do + @test _bc_dbg_ok(1) == 2 + end + end + + # `optimize_until` is logged exactly as provided. + BC_TEST_TIMINGS && println("[BC_DEBUG] case: invalid optimize_until logged (begin)") + @safe debug = true optimize_until = "definitely_invalid_pass" function _bc_dbg_bad_opt( + x + ) + return x + 1 + end + mktemp() do path, io + close(io) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do + BC_TEST_TIMINGS && + println("[BC_DEBUG] calling _bc_dbg_bad_opt(1) (expect error logged)") + _ = try + _bc_dbg_bad_opt(1) + nothing + catch + nothing + end + end + BC_TEST_TIMINGS && + println("[BC_DEBUG] reading jsonl output (invalid optimize_until)") + s = read(path, String) + @test occursin("\"event\":\"auto_debug_check\"", s) + @test occursin("\"optimize_until\":\"definitely_invalid_pass\"", s) + @test occursin("\"error\":", s) + @test occursin("\"time_s\":", s) + end + + # Summary exceptions are logged (best-effort) rather than crashing debug mode. + @generated _bc_dbg_badgen(x) = error("boom") + @safe debug = true scope = :function function _bc_dbg_summary_exception(x) + return _bc_dbg_badgen(x) + end + mktemp() do path, io + close(io) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do + _ = try + _bc_dbg_summary_exception(Int[1]) + nothing + catch + nothing + end + end + s = read(path, String) + @test occursin("\"event\":\"auto_debug_summary_exception\"", s) + end + + # Type refinement debug event is emitted when refinement makes changes. + @safe debug = true scope = :function function _bc_dbg_refine_types_event() + x = (g = () -> 3; g()) + return x + end + mktemp() do path, io + close(io) + withenv("BORROWCHECKER_AUTO_DEBUG_PATH" => path) do + _bc_dbg_refine_types_event() + end + s = read(path, String) + @test occursin("\"event\":\"auto_debug_refine_types\"", s) + end + end + + @testset "scope=:none disables @safe" begin + # This would normally fail borrow checking due to aliasing + mutation. + BorrowChecker.@safe scope = :none function _bc_auto_disabled() + x = [1, 2, 3] + y = fakewrite(x) + x[1] = 0 + return y + end + @test _bc_auto_disabled() == [0, 2, 3] + end + + @testset "callable structs: good/bad for mutable/immutable" begin + mutable struct _BCMutCallable + n::Int + end + (f::_BCMutCallable)() = (f.n += 1; f.n) + (f::_BCMutCallable)(::Val{:peek}) = f.n + + struct _BCImmCallable + v::Vector{Int} + end + (f::_BCImmCallable)() = (f.v[1] += 1; f.v[1]) + (f::_BCImmCallable)(::Val{:peek}) = f.v + + # Mutable functor: updated when called => should error if an alias is live. + @safe function _bc_mut_callable_bad() + f = _BCMutCallable(0) + g = f + f() + return g.n + end + @test_throws BorrowCheckError _bc_mut_callable_bad() + + # Mutable functor: read-only call => should be OK even with an alias. + @safe function _bc_mut_callable_good() + f = _BCMutCallable(0) + g = f + f(Val(:peek)) + return g.n + end + @test _bc_mut_callable_good() == 0 + + # Immutable functor: can still mutate reachable state (e.g. a Vector field). + @safe function _bc_imm_callable_bad() + f = _BCImmCallable([0]) + g = f + f() + return g.v + end + @test_throws BorrowCheckError _bc_imm_callable_bad() + + # Immutable functor: read-only call should be OK even with an alias. + @safe function _bc_imm_callable_good() + f = _BCImmCallable([0]) + g = f + f(Val(:peek)) + return g.v + end + @test _bc_imm_callable_good() == [0] + end + + @testset "@generated callee mutation is detected" begin + Base.@noinline @generated function _bc_gen_my_push!(x, v) + return :(Base.push!(x, v)) + end + + @safe function _bc_generated_mutation_bad() + x = [1, 2, 3] + y = x + _bc_gen_my_push!(x, 4) + return y + end + + @test_throws BorrowCheckError _bc_generated_mutation_bad() + end + + @testset "checked-cache respects cfg (scope affects recursion)" begin + # Repro: if `f` is checked once with `scope=:function`, then later recursion into `f` + # under `scope=:module` must not be skipped due to a tt/world-only cache key. + m = Module(gensym(:BCCacheCfg)) + Core.eval(m, :(import BorrowChecker as BC)) + Core.eval( + m, + quote + function inner_bad() + x = [1, 2, 3] + f = () -> x + push!(x, 4) + return f + end + + BC.@safe scope = :function f() = inner_bad() + BC.@safe scope = :module g() = f() + end, + ) + + # Warm the checked-cache for `f` under scope=:function (no recursion). + @test m.f()() == [1, 2, 3, 4] + # Now `g`'s recursive checking should re-check `f` under scope=:module and fail. + @test_throws BorrowCheckError m.g() + end + + @testset "__bc_assert_safe__ cache respects root_module" begin + m = Module(gensym(:BCRootCache)) + other = Module(gensym(:BCOtherRootCache)) + Core.eval(m, :(import BorrowChecker as BC)) + Core.eval(m, :(const CACHE = Dict{Int,Vector{Int}}())) + Core.eval( + m, + quote + g() = begin + x = [1, 2, 3] + CACHE[1] = x + push!(x, 4) + return x + end + helper() = (g(); nothing) + end, + ) + + tt = Tuple{typeof(getfield(m, :helper))} + + function clear_checked_cache!() + Base.@lock BorrowChecker.CHECKED_CACHE begin + empty!(BorrowChecker.CHECKED_CACHE[]) + end + empty!(BorrowChecker.PER_TASK_CHECKED_CACHE[]) + return nothing + end + + function check_with_root(root) + try + BorrowChecker.__bc_assert_safe__( + tt; + cfg=BorrowChecker.Config(; scope=:module, root_module=root), + ) + return :passed + catch e + e isa BorrowCheckError || rethrow() + return :failed + end + end + + function probe_stable_world() + clear_checked_cache!() + out_of_scope = check_with_root(other) + in_scope_after_cache_hit = check_with_root(m) + clear_checked_cache!() + in_scope_fresh = check_with_root(m) + return (out_of_scope, in_scope_after_cache_hit, in_scope_fresh) + end + + @test probe_stable_world() == (:passed, :failed, :failed) + end + + @testset "scope=:module catches unannotated callee with closure alias" begin + m = Module(gensym(:BCModuleScope)) + Core.eval(m, :(import BorrowChecker as BC)) + Core.eval( + m, + quote + function foo() + x = [1, 2, 3] + f = () -> x + push!(x, 4) + return f + end + end, + ) + Core.eval(m, :(BC.@safe scope = :module bar() = foo())) + + @test_throws BorrowCheckError m.bar() + end + + @testset "scope=:module recurses into Base extension methods" begin + # Repro: methods defined in the current module for Base functions (e.g. getindex) + # should be considered "in-module" for `scope=:module` recursion. + m = Module(gensym(:BCBaseExtScope)) + Core.eval(m, :(import BorrowChecker as BC)) + Core.eval( + m, + quote + struct T end + + function Base.getindex(::T) + x = [1, 2, 3] + f = () -> x + push!(x, 4) + return f + end + + BC.@safe scope = :module outer() = (T())[] + end, + ) + + @test_throws BorrowCheckError m.outer() + end + + @testset "try/catch/finally PhiCNode liveness does not assert" begin + @safe function _bc_try_finally_phicnode_ok() + a = [1, 2] + try + push!(a, 1) + catch + a = copy(a) + finally + sum(a) + end + return a + end + + result = try + _bc_try_finally_phicnode_ok() + :ok + catch e + if e isa AssertionError + :asserted + elseif e isa BorrowCheckError + :borrow_error + else + rethrow() + end + end + @test result != :asserted + end + + @testset "macro one-line method parsing: where clause" begin + BorrowChecker.@safe _bc_oneliner_where(x::T) where {T} = x + @test _bc_oneliner_where(1) == 1 + end + + @testset "macro one-line method parsing: return type" begin + BorrowChecker.@safe _bc_oneliner_ret(x)::Int = x + @test _bc_oneliner_ret(1) == 1 + end + + @testset "lambda arglist: single argument" begin + @safe function _bc_lambda_arglist_symbol() + f = x -> x + 1 + return f(1) + end + + @test _bc_lambda_arglist_symbol() == 2 + end + + @testset "lambda arglist: args_expr === nothing" begin + # This form doesn't occur from the surface syntax, but older/lower-level + # IR can contain lambdas represented as `Expr(:(->), nothing, body)`. + # Ensure our lambda instrumentation handles it. + fexpr = Expr(:(->), nothing, :(1)) + + eval( + quote + BorrowChecker.@safe function _bc_lambda_arglist_nothing() + f = $fexpr + return f() + end + end, + ) + + @test _bc_lambda_arglist_nothing() == 1 + end + + @testset "instrumentation leaves quoted code alone" begin + @safe function _bc_quote_expr() + q = quote + x = 1 + end + return q isa Expr + end + + @test _bc_quote_expr() + end + + @testset "nested function definitions are instrumented" begin + BorrowChecker.@safe function _bc_nested_function_bad() + function _bc_inner() + x = [1, 2, 3] + y = x + x[1] = 0 + return y + end + return _bc_inner() + end + + @test_throws BorrowCheckError _bc_nested_function_bad() + end + + @testset "local one-line method definitions are instrumented" begin + BorrowChecker.@safe function _bc_local_oneliner_bad() + _bc_inner() = begin + x = [1, 2, 3] + y = x + x[1] = 0 + return y + end + return _bc_inner() + end + + @test_throws BorrowCheckError _bc_local_oneliner_bad() + end + + @testset "LinearAlgebra in-place ops" begin + # Vector scaling (BLAS foreigncall) should be treated as a write. + @safe function _bc_la_scal_ok() + x = rand(3) + y = copy(x) + LinearAlgebra.BLAS.scal!(2.0, y) + return y + end + @test length(_bc_la_scal_ok()) == 3 + + @safe function _bc_la_scal_bad() + x = rand(3) + y = x + LinearAlgebra.BLAS.scal!(2.0, x) + # Use both bindings after the mutation so the alias is live. + return y + end + @test_throws BorrowCheckError _bc_la_scal_bad() + + @safe function _bc_la_triu_ok() + A = [1.0 2.0 3.0; 4.0 5.0 6.0; 7.0 8.0 9.0] + B = copy(A) + LinearAlgebra.triu!(B) + return B + end + @test _bc_la_triu_ok()[2, 1] == 0.0 + + @safe function _bc_la_triu_bad() + A = [1.0 2.0 3.0; 4.0 5.0 6.0; 7.0 8.0 9.0] + B = A + LinearAlgebra.triu!(A) + return (A, B) + end + @test_throws BorrowCheckError _bc_la_triu_bad() + end + + @test_throws BorrowCheckError _bc_bad_alias() + @test _bc_ok_copy() == [1, 2, 3] + @test_throws BorrowCheckError _bc_bad_unknown_call(Any[identity]) + + @test_throws BorrowCheckError _bc_bad_alias_mutable_struct() + @test _bc_ok_copy_mutable_struct().x == 1 + + @test_throws BorrowCheckError _bc_bad_struct_of_struct() + @test _bc_ok_struct_of_struct().a.x == 1 + + BorrowChecker.@safe function _bc_bad_closure_body_0arg() + f = () -> begin + x = [1, 2, 3] + y = x + push!(x, 9) + return y + end + return f() + end + + BorrowChecker.@safe function _bc_bad_closure_body_with_arg(z) + f = () -> begin + x = z + y = x + push!(x, 9) + return y + end + return f() + end + + BorrowChecker.@safe function _bc_ok_closure_body_0arg() + f = () -> begin + x = [1, 2, 3] + y = copy(x) + push!(x, 9) + return y + end + return f() + end + + BorrowChecker.@safe function _bc_ok_closure_body_with_arg(z) + f = () -> begin + x = copy(z) + y = copy(x) + push!(x, 9) + return y + end + return f() + end + + @test_throws BorrowCheckError _bc_bad_closure_body_0arg() + @test_throws BorrowCheckError _bc_bad_closure_body_with_arg([1, 2, 3]) + @test _bc_ok_closure_body_0arg() == [1, 2, 3] + @test _bc_ok_closure_body_with_arg([1, 2, 3]) == [1, 2, 3] + + # Regression test for https://github.com/MilesCranmer/BorrowChecker.jl/issues/49 + BorrowChecker.@safe function _bc_eltype_used_in_array_constructor(x) + T = eltype(x) + y = Vector{T}(x) + return y + end + @test _bc_eltype_used_in_array_constructor([1, 2]) == [1, 2] + + BorrowChecker.@safe function _bc_ok_phi_ternary(cond::Bool) + x = [1, 2, 3] + y = cond ? x : x + push!(y, 1) + return y + end + + @noinline _ret1(x) = x + + BorrowChecker.@safe function _bc_ok_identity_call() + x = [1, 2, 3] + y = _ret1(x) + push!(y, 1) + return y + end + + BorrowChecker.@safe function _bc_bad_view_alias() + x = [1, 2, 3, 4] + y = view(x, 1:2) + push!(x, 9) + return collect(y) + end + + BorrowChecker.@safe function _bc_bad_closure_capture() + x = [1, 2, 3] + y = x + f = () -> (push!(x, 9); nothing) + f() + return y + end + + BorrowChecker.@safe function _bc_bad_closure_capture_nested() + x = [1, 2, 3] + y = x + f = () -> begin + g = () -> (push!(x, 9); nothing) + g() + return nothing + end + f() + return y + end + + BorrowChecker.@safe function _bc_ok_closure_capture_readonly() + x = [1, 2, 3] + y = x + f = () -> begin + s = 0 + for i in 1:length(y) + s += y[i] + end + return s + end + f() + return x + end + + @test _bc_ok_phi_ternary(true) == [1, 2, 3, 1] + @test _bc_ok_phi_ternary(false) == [1, 2, 3, 1] + @test _bc_ok_identity_call() == [1, 2, 3, 1] + @test_throws BorrowCheckError _bc_bad_view_alias() + @test_throws BorrowCheckError _bc_bad_closure_capture() + @test_throws BorrowCheckError _bc_bad_closure_capture_nested() + @test _bc_ok_closure_capture_readonly() == [1, 2, 3] + + f_kwcall_ok(; x, y) = x .+ y + f_kwcall_ok_mut(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) + f_kwcall_alias_bad(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) + + @testset "kwcall unknown-call consume expands to keyword values" begin + fkw_nothing(; x, y) = nothing + + @safe function _bc_kwcall_unknown_consume_should_error(vf) + x = [1, 2, 3] + y = x + g = only(vf) + g(; x=x, y=y) + return y + end + + @test_throws BorrowCheckError _bc_kwcall_unknown_consume_should_error( + Any[fkw_nothing] + ) + end + + @safe function _bc_ok_kwcall() + x = [1, 2, 3] + y = copy(x) + return sum(f_kwcall_ok(; x=x, y=y)) + end + + @safe function _bc_ok_kwcall_mut() + x = [1, 2, 3] + y = copy(x) + return sum(f_kwcall_ok_mut(; x=x, y=y)) + end + + @safe function _bc_bad_kwcall_alias_should_error() + x = [1, 2, 3] + y = x + return sum(f_kwcall_alias_bad(; x=x, y=y)) + end + + @test _bc_ok_kwcall() == 12 + @test _bc_ok_kwcall_mut() == 14 + @test_throws BorrowCheckError _bc_bad_kwcall_alias_should_error() + + @testset "escape/store is treated as consume (move)" begin + empty!(_BC_ESCAPE_CACHE) + + @safe function _bc_escape_after_store_should_error() + x = [1, 2, 3] + _bc_consumes(x) + return x + end + + @test_throws BorrowCheckError _bc_escape_after_store_should_error() + end + + @testset "escape/store does not move non-owned values" begin + empty!(_BC_ESCAPE_CACHE) + + @safe function _bc_escape_bits_ok() + x = (1, 2, 3) + _bc_consumes(x) + return x + end + + @test _bc_escape_bits_ok() == (1, 2, 3) + end + + @testset "setfield!/Ref store moves owned values" begin + @safe function _bc_ref_store_moves_owned() + r = Ref{Any}() + x = [1, 2, 3] + r[] = x + return x + end + + @test_throws BorrowCheckError _bc_ref_store_moves_owned() + end + + @testset "setfield!/Ref store does not move isbits" begin + @safe function _bc_ref_store_bits_ok() + r = Ref{Any}() + x = (1, 2, 3) + r[] = x + return x + end + + @test _bc_ref_store_bits_ok() == (1, 2, 3) + end + + @testset "mutable field store moves owned values" begin + @safe function _bc_mutable_field_store_moves_owned() + c = C(nothing) + x = [1, 2, 3] + c.v = x + return x + end + + @test_throws BorrowCheckError _bc_mutable_field_store_moves_owned() + end + + @testset "unknown call does not consume non-owned values" begin + @safe function _bc_unknown_call_bits_ok(vf) + x = (1, 2, 3) + f = only(vf) + f(x) + return x + end + + @test _bc_unknown_call_bits_ok(Any[identity]) == (1, 2, 3) + end + + @testset "foreigncall treated as write (uniqueness enforced)" begin + @safe function _bc_foreigncall_bad(flag::Bool) + x = [1, 2, 3] + y = x + if flag + ccall(:jl_typeof_str, Cstring, (Any,), x) + end + return y + end + + @safe function _bc_foreigncall_ok(flag::Bool) + x = [1, 2, 3] + if flag + ccall(:jl_typeof_str, Cstring, (Any,), x) + end + return x + end + + @test_throws BorrowCheckError _bc_foreigncall_bad(false) + @test _bc_foreigncall_ok(false) == [1, 2, 3] + end + + @testset "_collect_ssa_ids! handles IR node objects (coverage)" begin + # This is a real `:foreigncall` that embeds Core IR node objects as *constants* + # inside a tuple argument. The borrow checker doesn't care about these values, + # but the foreigncall backslice should traverse them without error. + # + # This exercises `_collect_ssa_ids!` branches for: + # - `Core.ReturnNode` (val) + # - `Core.PiNode` (val) + # - `Core.UpsilonNode` (val) + # - `Core.GotoIfNot` (cond) + # - `Tuple` recursion + @safe function _bc_foreigncall_node_constants_ok() + ccall( + :jl_typeof_str, + Cstring, + (Any,), + ( + Core.ReturnNode(Core.SSAValue(0)), + Core.PiNode(Core.SSAValue(0), Any), + Core.UpsilonNode(Core.SSAValue(0)), + Core.GotoIfNot(Core.SSAValue(0), 1), + (Core.SSAValue(0),), + ), + ) + return nothing + end + + @test _bc_foreigncall_node_constants_ok() === nothing + end + + @testset "immutable wrapper containing owned field is owned" begin + empty!(_BC_ESCAPE_CACHE) + + @safe function _bc_escape_wrap_should_error() + w = Wrap([1, 2, 3]) + _bc_consumes(w) + return w + end + + @test_throws BorrowCheckError _bc_escape_wrap_should_error() + end + + @testset "symbols are not moved" begin + empty!(_BC_ESCAPE_CACHE) + + @safe function _bc_escape_symbol_ok() + x = :a + _bc_consumes(x) + return x + end + + @test _bc_escape_symbol_ok() == :a + end + + @testset "Dict setindex! key escapes" begin + empty!(D) + + @safe function _bc_dict_key_escape_should_error() + x = [1, 2, 3] + D[x] = 4 + return x + end + + @test_throws BorrowCheckError _bc_dict_key_escape_should_error() + + empty!(D) + + @safe function _bc_dict_key_copy_ok() + x = [1, 2, 3] + D[copy(x)] = 4 + return x + end + + @test _bc_dict_key_copy_ok() == [1, 2, 3] + end + + @testset "Tasks are shareable handles (do not trigger move/escape errors)" begin + @safe function _bc_async_handle_ok() + t = @async 1 + return fetch(t) + end + + @test _bc_async_handle_ok() == 1 + + @safe function _bc_async_copy_after_spawn_bad() + x = [1, 2, 3] + t = @async begin + push!(x, 4) + return sum(x) + end + y = copy(x) # unsafe: task may mutate `x` before this copy happens + return fetch(t), y + end + + @test_throws BorrowCheckError _bc_async_copy_after_spawn_bad() + + @safe function _bc_async_copy_before_spawn_ok() + x = [1, 2, 3] + y = copy(x) + t = @async begin + push!(x, 4) + return sum(x) + end + return fetch(t), y + end + + @test _bc_async_copy_before_spawn_ok() == (10, [1, 2, 3]) + end + + @testset "Atomics are shareable handles (aliasing is allowed)" begin + @safe function _bc_atomic_alias_ok() + a = Threads.Atomic{Int}(0) + b = a + Threads.atomic_add!(a, 1) + return b[] + end + + @test _bc_atomic_alias_ok() == 1 + end + + @testset "__bc_assert_safe__ short-circuits on cache hit" begin + local_f(x) = x + tt = Tuple{typeof(local_f),Int} + + BorrowChecker.__bc_assert_safe__(tt) + GC.gc() + + alloc = @allocated BorrowChecker.__bc_assert_safe__(tt) + @test alloc < 200_000 + end + + @testset "__bc_assert_safe__ thread-safety" begin + Threads.nthreads() < 2 && return nothing + + Base.@lock BorrowChecker.CHECKED_CACHE begin + empty!(BorrowChecker.CHECKED_CACHE[]) + end + + fs = [ + (x::Int) -> x, + (x::Int) -> x + 1, + (x::Int) -> x + 2, + (x::Int) -> x + 3, + (x::Int) -> x + 4, + (x::Int) -> x + 5, + (x::Int) -> x + 6, + (x::Int) -> x + 7, + (x::Int) -> x + 8, + (x::Int) -> x + 9, + ] + tts = map(f -> Tuple{typeof(f),Int}, fs) + + turn1 = Channel{Int}(1) + turn2 = Channel{Int}(1) + done = Channel{Int}(1) # idx + + function worker(which::Int) + turn = (which == 1) ? turn1 : turn2 + for _ in 1:length(tts) + idx = take!(turn) + BorrowChecker.__bc_assert_safe__(tts[idx]) + put!(done, idx) + end + return nothing + end + + task1 = Threads.@spawn worker(1) + task2 = Threads.@spawn worker(2) + + for i in 1:length(tts) + first = isodd(i) ? 1 : 2 + second = (first == 1) ? 2 : 1 + + put!((first == 1) ? turn1 : turn2, i) + @test take!(done) == i + + put!((second == 1) ? turn1 : turn2, i) + @test take!(done) == i + end + + wait(task1) + wait(task2) + + # Free-for-all: lots of concurrent hits/misses should not throw or deadlock. + Base.@lock BorrowChecker.CHECKED_CACHE begin + empty!(BorrowChecker.CHECKED_CACHE[]) + end + + nworkers = 16 + jobs = 60 + errs = Channel{Any}(nworkers) + @sync for _ in 1:nworkers + Threads.@spawn begin + err = nothing + try + for j in 1:jobs + BorrowChecker.__bc_assert_safe__(tts[(j % length(tts)) + 1]) + end + catch e + err = e + end + put!(errs, err) + end + end + for _ in 1:nworkers + e = take!(errs) + e === nothing || rethrow(e) + end + end + + @testset "@safe scope=:module recursive callees" begin + # Without recursion, this outer method doesn't observe the inner violation. + Base.@noinline function _bc_scope_inner_bad() + x = [1, 2, 3] + y = fakewrite(x) + x[1] = 0 + return y + end + + @safe function _bc_scope_outer_norec_ok() + return _bc_scope_inner_bad() + end + @test _bc_scope_outer_norec_ok() == [0, 2, 3] + + @safe scope = :module function _bc_scope_outer_rec_bad() + return _bc_scope_inner_bad() + end + @test_throws BorrowCheckError _bc_scope_outer_rec_bad() + end + + @testset "modules are not owned (avoid spurious consumes)" begin + @safe function _bc_module_not_owned() + m = Base + g = Base.inferencebarrier(identity) + g(m) # unknown/dynamic call site should NOT consume `m` + return getproperty(m, :Math) + end + + @test _bc_module_not_owned() === Base.Math + end + + @testset "isa is pure (does not consume)" begin + @safe function _bc_isa_does_not_consume() + x = [1, 2, 3] + y = fakewrite(x) + if y isa Vector{Int} + y[1] = 0 + return y + else + error("unexpected") + end + end + + @test _bc_isa_does_not_consume() == [0, 2, 3] + end + + @testset "scope=:user excludes Core/Base recursion" begin + cfg = BorrowChecker.Config(; scope=:user) + @test BorrowChecker._scope_allows_module(Core, cfg) == false + @test BorrowChecker._scope_allows_module(Base, cfg) == false + @test BorrowChecker._scope_allows_module(Main, cfg) == true + + @static if isdefined(Core, :Compiler) + @test BorrowChecker._scope_allows_module(Core.Compiler, cfg) == false + end + @static if isdefined(Base, :Iterators) + @test BorrowChecker._scope_allows_module(Base.Iterators, cfg) == false + end + end + + @testset "scope=:all does not crash on PhiCNode" begin + @safe scope = :all _bc_scope_all_sin(x) = sin(x) + err = try + _bc_scope_all_sin(1.0) + nothing + catch e + e + end + @test isnothing(err) + end + + @testset "PhiCNode liveness accounting" begin + BorrowChecker.@safe function _bc_phicnode_liveness(x) + y = x + try + error("boom") + catch + return y + end + end + + @test _bc_phicnode_liveness(1) == 1 + end + + @testset "apply_iterate + kwcall callee scanning" begin + mod = Module(:_BCAutoCalleeScanMod) + Core.eval(mod, :(using BorrowChecker)) + + Base.include_string( + mod, + """ + g(a, b, c) = a + b + c + kwsum(; x, y) = x + y + + BorrowChecker.@safe scope = :module function caller(t::Tuple{Int,Int,Int}) + s = g(t...) + return kwsum(; x=s, y=1) + end + """, + "REPL[1001]", + ) + + caller = getfield(mod, :caller) + @test caller((1, 2, 3)) == 7 + end + + @testset "Core.throw_inexacterror does not BorrowCheckError" begin + # This should throw an `InexactError` at runtime, but borrow checking (including + # recursive checking of user code) should not fail. + @safe scope = :user _bc_inexact_int64(x::UInt64) = Int64(x) + @test_throws InexactError _bc_inexact_int64(typemax(UInt64)) + end + + @testset "summary cache determinism" begin + Base.@lock BorrowChecker.SUMMARY_STATE begin + empty!(BorrowChecker.SUMMARY_STATE[].summary_cache) + empty!(BorrowChecker.SUMMARY_STATE[].tt_summary_cache) + empty!(BorrowChecker.SUMMARY_STATE[].summary_inprogress) + empty!(BorrowChecker.SUMMARY_STATE[].tt_summary_inprogress) + end + + deep1(x) = x + deep2(x) = deep1(x) + deep3(x) = deep2(x) + + cfg = BorrowChecker.Config(; max_summary_depth=2) + tt = Tuple{typeof(deep3),Vector{Int}} + + BorrowChecker._summary_for_tt(tt, cfg; depth=cfg.max_summary_depth) + + function latest_entry() + Base.@lock BorrowChecker.SUMMARY_STATE begin + best_key = nothing + for k in keys(BorrowChecker.SUMMARY_STATE[].tt_summary_cache) + (k[1] === tt && k[3] == cfg) || continue + (best_key === nothing || k[2] > best_key[2]) && (best_key = k) + end + best_key === nothing && error("missing cache entry") + return BorrowChecker.SUMMARY_STATE[].tt_summary_cache[best_key] + end + end + + entry1 = latest_entry() + @test entry1.over_budget == true + + BorrowChecker._summary_for_tt(tt, cfg; depth=0) + entry2 = latest_entry() + @test entry2.over_budget == false + end + + @testset "Registry override API" begin + BorrowChecker.register_effects!(fakewrite; writes=(2,)) + + @safe function bc_registry_override() + x = [1, 2, 3] + y = x + z = fakewrite(x) + z === y || error("unexpected") + return y + end + + @test_throws BorrowCheckError bc_registry_override() + end + + @testset "@safe one-line method form" begin + # Hits the `ex.head === :(=)` + `_is_method_definition_lhs` branch in the macro. + BorrowChecker.@safe _bc_oneliner_bad() = begin + x = [1, 2, 3] + y = x + x[1] = 0 + y + end + + @test_throws BorrowCheckError _bc_oneliner_bad() + end + + @testset "Pointer intrinsics + known issues" begin + @safe function _bc_pointerset_ok() + A = [1, 2, 3] + p = pointer(A) + unsafe_store!(p, 99, 1) + return nothing + end + @test _bc_pointerset_ok() === nothing + + @safe function _bc_pointer_unsafe_store_regression() + A = [1, 2, 3] + B = A + p = pointer(A) + unsafe_store!(p, 99, 1) + return B + end + @test_throws BorrowCheckError _bc_pointer_unsafe_store_regression() + + @safe function _bc_pointerset_alias_bad() + A = [1, 2, 3] + p = pointer(A) + q = p + unsafe_store!(p, 99, 1) + return q + end + @test_throws BorrowCheckError _bc_pointerset_alias_bad() + + @safe function _bc_reinterpret_write_bad() + A = Int32[1, 2, 3, 4] + B = A + R = reinterpret(UInt8, A) # shares memory with A + R[1] = 0x7f + return B + end + @test_throws BorrowCheckError _bc_reinterpret_write_bad() + end + + @testset "Tuple duplicates aliasing" begin + @safe function _bc_return_tuple_copy_order_bad(x) + return (x, copy(x)) + end + @test_throws BorrowCheckError _bc_return_tuple_copy_order_bad([1, 2, 3]) + + @safe function _bc_return_tuple_copy_order_ok(x) + return (copy(x), x) + end + a, b = _bc_return_tuple_copy_order_ok([1, 2, 3]) + @test a == b == [1, 2, 3] + + @safe function _bc_return_tuple_duplicates_bad() + x = [1, 2, 3] + return (x, x) + end + @test_throws BorrowCheckError _bc_return_tuple_duplicates_bad() + + @safe function _bc_array_literal_duplicates_bad() + x = [1, 2, 3] + return [x, x] + end + @test_throws BorrowCheckError _bc_array_literal_duplicates_bad() + + @safe function _bc_array_literal_copy_order_bad(x) + return [x, copy(x)] + end + @test_throws BorrowCheckError _bc_array_literal_copy_order_bad([1, 2, 3]) + + @safe function _bc_array_literal_copy_order_ok(x) + return [copy(x), x] + end + ys = _bc_array_literal_copy_order_ok([1, 2, 3]) + @test ys[1] == ys[2] == [1, 2, 3] + end + + @testset "Known foreigncall effects" begin + @testset "jl_genericmemory_copyto writes destination only" begin + @safe scope = :all _bc_wrap_in_vec(x) = [x] + @test _bc_wrap_in_vec([1, 2, 3]) == [[1, 2, 3]] + end + + @testset "BoundsError constructor is treated as pure" begin + @safe scope = :all _bc_sin(x) = sin(x) + @test _bc_sin(1.0) == sin(1.0) + end + end + + @testset "Core._typevar does not consume" begin + @safe scope = :all _bc_mk_typevar() = (TypeVar(:T, Int); true) + @test _bc_mk_typevar() + end + + @testset "read-only Base foreigncalls" begin + @testset "jl_object_id" begin + @safe scope = :all function _bc_objectid_alias_ok(x) + y = x + objectid(x) + return x === y + end + v = Any[1] + @test _bc_objectid_alias_ok(v) + end + + @testset "jl_type_hash" begin + @safe scope = :all function _bc_hash_type_alias_ok(T) + S = T + hash(T) + return T === S + end + @test _bc_hash_type_alias_ok(Int) + end + + @testset "jl_type_unionall" begin + @safe scope = :all function _bc_unionall_typearg_alias_ok(t) + u = t + UnionAll(TypeVar(:T), t) + return t === u + end + @test _bc_unionall_typearg_alias_ok(Int) + end + + @testset "jl_eqtable_get" begin + @safe scope = :all function _bc_iddict_get_alias_ok(d, k) + d2 = d + get(d, k, nothing) + return d === d2 + end + key = Any[1] + d = IdDict{Any,Any}(key => 2) + @test _bc_iddict_get_alias_ok(d, key) + end + + @testset "jl_eqtable_nextind (via iterate)" begin + @safe scope = :all function _bc_iddict_iterate_alias_ok(d) + d2 = d + iterate(d) + return d === d2 + end + d = IdDict{Any,Any}(Any[1] => 2) + @test _bc_iddict_iterate_alias_ok(d) + end + + @testset "jl_get_fieldtypes" begin + @safe scope = :all function _bc_fieldtypes_alias_ok(T) + S = T + fieldtypes(T) + return T === S + end + @test _bc_fieldtypes_alias_ok(ComplexF64) + end + + @testset "jl_field_index" begin + @safe scope = :all function _bc_fieldindex_alias_ok(T) + S = T + Base.fieldindex(T, :re, true) + return T === S + end + @test _bc_fieldindex_alias_ok(ComplexF64) + end + + @testset "jl_gc_new_weakref_th" begin + @safe scope = :all function _bc_weakref_alias_ok(x) + y = x + WeakRef(x) + return x === y + end + v = Any[1] + @test _bc_weakref_alias_ok(v) + end + + @testset "jl_value_ptr" begin + @safe scope = :all function _bc_ptr_from_objref_alias_ok(x) + y = x + pointer_from_objref(x) + return x === y + end + v = Any[1] + @test _bc_ptr_from_objref_alias_ok(v) + end + end + + @testset "Known effects registry only uses Core" begin + allowed_auto = Set{Any}([BorrowChecker.Config, BorrowChecker.__bc_bind__]) + if isdefined(BorrowChecker, :__bc_assert_safe__) + push!(allowed_auto, BorrowChecker.__bc_assert_safe__) + end + + bad = Any[] + for f in BC_BUILTIN_EFFECT_KEYS + m = try + parentmodule(f) + catch + nothing + end + + if m === BorrowChecker + (f in allowed_auto) || push!(bad, (f, m)) + continue + end + + (m === Core || m === Core.Intrinsics) || push!(bad, (f, m)) + end + + @test isempty(bad) + end + + if (@isdefined(_bc_timing_ts)) && _bc_timing_ts !== nothing + using Test + Test.pop_testset() + # Don't print the full nested summary; we only want the timing lines above. + Test.finish(_bc_timing_ts; print_results=false) + end +end diff --git a/test/auto_hygiene_integration_tests.jl b/test/auto_hygiene_integration_tests.jl new file mode 100644 index 0000000..2d1a413 --- /dev/null +++ b/test/auto_hygiene_integration_tests.jl @@ -0,0 +1,48 @@ +@testitem "Auto @safe hygiene and tracking" tags = [:auto] begin + using TestItems + using BorrowChecker: @safe + + @testset "macro hygiene: no unqualified BorrowChecker reference" begin + user_mod = Module(:_BCHygieneUser) + + Core.eval(user_mod, :(using BorrowChecker: @safe)) + + ex = :(@safe function f(x) + y = x + return y + end) + + expanded = macroexpand(user_mod, ex) + + function has_unqualified_borrowchecker_ref(node) + if node === :BorrowChecker + return true + end + if node isa Expr + return any(has_unqualified_borrowchecker_ref, node.args) + end + return false + end + + # Fully-qualified `GlobalRef(BorrowChecker, ...)` references are fine: + # the macro must not require an unqualified `BorrowChecker` binding in + # the user's module. + @test !has_unqualified_borrowchecker_ref(expanded) + end + + @testset "runtime hygiene: no `BorrowChecker` binding needed" begin + user_mod = Module(:_BCHygieneRuntimeUser) + Core.eval(user_mod, :(using BorrowChecker: @safe)) + Core.eval(user_mod, :(@safe function f(x) + y = x + return y + end)) + @test Core.eval(user_mod, :(f([1, 2, 3]))) == [1, 2, 3] + end + + @testset "is_tracked_type doesn't error on abstract" begin + # Regression: fieldtypes(fieldcount) throws for abstract types. + @test BorrowChecker.is_tracked_type(AbstractArray) === true + @test BorrowChecker.is_tracked_type(AbstractVector) === true + end +end diff --git a/test/auto_llvm_ir_tests.jl b/test/auto_llvm_ir_tests.jl new file mode 100644 index 0000000..1d183b3 --- /dev/null +++ b/test/auto_llvm_ir_tests.jl @@ -0,0 +1,85 @@ +using BorrowChecker +using InteractiveUtils: code_llvm +using Test: @test + +function llvm_ir_minimal() + m = Module(gensym(:BCLLVM)) + Core.eval(m, :(import BorrowChecker as BC)) + Core.eval(m, :(BC.@safe f(x::Int) = x)) + + f = Core.eval(m, :f) + + function normalize_llvm(ll::AbstractString) + lines = split(ll, "\n") + filter!(l -> !isempty(l), lines) + filter!(l -> !startswith(l, ";"), lines) + return lines + end + + llvm_ir = sprint((args...) -> code_llvm(args...; debuginfo=:none), f, (Int,)) + lines = normalize_llvm(llvm_ir) + joined = join(lines, "\n") + + @test !occursin("gc_pool_alloc", llvm_ir) + @test !occursin("_generated_assert_safe", joined) + @test !occursin("BorrowChecker", joined) + + # For a trivial function, we expect just: + # define ... + # top: + # ret ... + # } + if length(lines) != 4 + @show lines + end + @test length(lines) == 4 + + return nothing +end + +llvm_ir_minimal() + +function llvm_ir_unsafe_idempotent() + m = Module(gensym(:BCLLVMUnsafe)) + Core.eval(m, :(import BorrowChecker as BC)) + + body = quote + x = Ref(0) + y = x + x[] = 1 + y[] + end + + Core.eval(m, :(f_plain_plain() = $body)) + Core.eval(m, :(BC.@safe f_safe_plain() = $body)) + Core.eval(m, :(BC.@safe f_safe_unsafe() = BC.@unsafe $body)) + + plain = Core.eval(m, :f_plain_plain) + safe_plain = Core.eval(m, :f_safe_plain) + safe_unsafe = Core.eval(m, :f_safe_unsafe) + + ll_plain = sprint((args...) -> code_llvm(args...; debuginfo=:none), plain, Tuple{}) + ll_safe_plain = sprint( + (args...) -> code_llvm(args...; debuginfo=:none), safe_plain, Tuple{} + ) + ll_safe_unsafe = sprint( + (args...) -> code_llvm(args...; debuginfo=:none), safe_unsafe, Tuple{} + ) + + function normalize_llvm(ll::AbstractString) + lines = split(ll, "\n") + filter!(l -> !isempty(l), lines) + filter!(l -> !startswith(l, ";"), lines) + joined = join(lines, "\n") + return replace(joined, r"^(define\s+.*\s+@)[^\s\(]+"m => s"\1FUNC") + end + + @test normalize_llvm(ll_plain) == normalize_llvm(ll_safe_unsafe) + @test !occursin("_generated_assert_safe", ll_plain) + @test !occursin("_generated_assert_safe", ll_safe_unsafe) + @test occursin("_generated_assert_safe", ll_safe_plain) + + return nothing +end + +llvm_ir_unsafe_idempotent() diff --git a/test/auto_llvm_tests.jl b/test/auto_llvm_tests.jl new file mode 100644 index 0000000..4a47206 --- /dev/null +++ b/test/auto_llvm_tests.jl @@ -0,0 +1,8 @@ +@testitem "Auto LLVM IR" tags = [:auto] begin + using BorrowChecker + using PerformanceTestTools: @include + + @include("auto_llvm_ir_tests.jl") + # Important to run the LLVM IR tests in a new julia process with + # things like --code-coverage disabled. +end diff --git a/test/auto_printing_tests.jl b/test/auto_printing_tests.jl new file mode 100644 index 0000000..ea6918d --- /dev/null +++ b/test/auto_printing_tests.jl @@ -0,0 +1,313 @@ +@testitem "Auto @safe printing" tags = [:auto] begin + using TestItems + using BorrowChecker + + # This test targets error printing helpers in the experimental borrow checker. + using BorrowChecker: BorrowCheckError, BorrowViolation + + @testset "file source line" begin + (path, io) = mktemp() + close(io) + write(path, "line1\nSENTINEL_FILE_LINE\nline3\n") + + li = LineNumberNode(2, Symbol(path)) + v = BorrowViolation(1, "msg", li, :(dummy_stmt)) + e = BorrowCheckError(Any, [v]) + + s = sprint(showerror, e) + @test occursin("at $path:2", s) + @test occursin("SENTINEL_FILE_LINE", s) + end + + @testset "lowered fallback (non-file source)" begin + mod = Module(:_BCPrintUserMod) + code = "foo!(x) = x\nfunction bar(x)\n foo!(x)\n return x\nend\n" + Base.include_string(mod, code, "REPL[6]") + + bar = getfield(mod, :bar) + tt = Tuple{typeof(bar),Int} + + li = LineNumberNode(3, Symbol("REPL[6]")) + v = BorrowViolation(1, "msg", li, :(dummy_stmt)) + e = BorrowCheckError(tt, [v]) + + s = sprint(showerror, e) + @test occursin("at REPL[6]:3", s) + @test occursin("lowered:", s) + @test occursin("foo!", s) + end + + @testset "BorrowCheckError includes REPL context (real checker)" begin + mod = Module(:_BCPrintRealMod) + Core.eval(mod, :(using BorrowChecker: @safe)) + Base.include_string( + mod, + """ + @safe function foo() + x = [1, 2, 3] + y = x + push!(x, 9) + return y + end + """, + "REPL[999]", + ) + + err = try + getfield(mod, :foo)() + nothing + catch e + e + end + + @test err isa BorrowCheckError + s = sprint(showerror, err) + @test occursin("REPL[999]", s) + @test occursin("lowered:", s) + @test occursin("push!", s) + end + + @testset "BorrowCheckError prints multiple violations" begin + mod = Module(:_BCPrintMultiMod) + Core.eval(mod, :(using BorrowChecker: @safe)) + Base.include_string( + mod, + """ + @safe function multi() + x = [1, 2, 3] + y = x + push!(x, 9) + + a = [1, 2, 3] + b = a + a[1] = 0 + + return (y, b) + end + """, + "REPL[998]", + ) + + err = try + getfield(mod, :multi)() + nothing + catch e + e + end + + @test err isa BorrowCheckError + s = sprint(showerror, err) + + n = length(collect(eachmatch(r"(?m)^ \[[0-9]+\] stmt#", s))) + @test n >= 2 + @test count("cannot perform write", s) >= 2 + end + + @testset "BorrowCheckError prints file-backed source context (real checker)" begin + (path, io) = mktemp() + close(io) + + write( + path, + """ + module _BCFilePrintMod + using BorrowChecker: @safe + + f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y) + + @safe function foo() + x = [1, 2, 3] + y = x + return sum(f(; x=x, y=y)) + end + end + """, + ) + + mod = Module(:_BCFilePrintHost) + Base.include(mod, path) + inner = getfield(mod, :_BCFilePrintMod) + foo = getfield(inner, :foo) + + err = try + foo() + nothing + catch e + e + end + + @test err isa BorrowCheckError + s = sprint(showerror, err) + @test occursin("at $path:", s) + @test occursin("return sum(f(; x=x, y=y))", s) + @test occursin(r"(?m)^\s*>\s*9\s+return sum\(f\(; x=x, y=y\)\)", s) + end + + @testset "BorrowCheckError prints REPL source (real REPL)" begin + using REPL + import REPL.LineEdit + using Base.Terminals + + function _strip_ansi(s::AbstractString) + # Strip ANSI CSI sequences (good enough for our assertions). + return replace(String(s), r"\e\[[0-9;?]*[ -/]*[@-~]" => "") + end + + old_repl = isdefined(Base, :active_repl) ? Base.active_repl : nothing + + input = Pipe() + output = Pipe() + err = Pipe() + Base.link_pipe!(input; reader_supports_async=true, writer_supports_async=true) + Base.link_pipe!(output; reader_supports_async=true, writer_supports_async=true) + Base.link_pipe!(err; reader_supports_async=true, writer_supports_async=true) + + term = REPL.Terminals.TTYTerminal("dumb", input.out, output.in, err.in) + repl = REPL.LineEditREPL(term, false) + repl.options = REPL.Options(; confirm_exit=false) + repl.history_file = false + Base.active_repl = repl + + repltask = @async REPL.run_repl(repl) + + write(input.in, "using BorrowChecker: @safe\r") + write(input.in, "f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y)\r") + write( + input.in, + "@safe function foo()\n x = [1,2,3]\n y = x\n return sum(f(; x=x, y=y))\nend\r", + ) + write(input.in, "foo()\r") + close(input.in) + + Base.wait(repltask) + close(output.in) + close(err.in) + + out = _strip_ansi(read(output.out, String)) + errout = read(err.out, String) + isempty(errout) || @test false + + @test occursin("BorrowCheckError for specialization", out) + @test occursin("at REPL[3]:4", out) + @test occursin("return sum(f(; x=x, y=y))", out) + @test occursin(r"(?m)^\s*>\s*4\s+return sum\(f\(; x=x, y=y", out) + + if isdefined(Base, :active_repl) + try + Base.active_repl = old_repl + catch + end + end + end + + @testset "REPL history source fallback (mock active_repl)" begin + # This test does not require an interactive REPL. We mock `Base.active_repl` + # so `_try_repl_source` can pull text for `REPL[n]`. + + struct _BCEntryMock + content::String + end + struct _BCThrowEntryMock end + Base.propertynames(::_BCThrowEntryMock; private::Bool=false) = (:content,) + Base.getproperty(::_BCThrowEntryMock, ::Symbol) = error("boom") + struct _BCHistMock + history::Vector{Any} + start_idx::Int + end + struct _BCModeMock + hist::_BCHistMock + end + struct _BCInterfaceMock + modes::Vector{_BCModeMock} + end + struct _BCReplMock + interface::_BCInterfaceMock + end + + old_repl = isdefined(Base, :active_repl) ? Base.active_repl : nothing + + # Try to set `Base.active_repl`. If this ever becomes non-assignable on some + # Julia version, just skip this test. + set_ok = true + try + src = "line1\nSENTINEL_REPL_LINE\nline3\n" + # In real REPL sessions `start_idx` is the number of entries loaded from + # the history file, and `REPL[1]` corresponds to the first entry *after* + # that baseline: history[start_idx + 1]. + hist = Any["OLD_ENTRY_1", _BCEntryMock(src), _BCThrowEntryMock()] + mock = _BCReplMock(_BCInterfaceMock([_BCModeMock(_BCHistMock(hist, 2))])) + Base.active_repl = mock + catch + set_ok = false + end + + if set_ok + li = LineNumberNode(2, Symbol("REPL[1]")) + v = BorrowViolation(1, "msg", li, :(dummy_stmt)) + e = BorrowCheckError(Any, [v]) + + s = sprint(showerror, e) + @test occursin("SENTINEL_REPL_LINE", s) + end + + if isdefined(Base, :active_repl) + try + Base.active_repl = old_repl + catch + end + end + end + + @testset "REPL history source fallback (scan history)" begin + # Exercise the non-`start_idx` fallback paths in `_try_repl_source_lines`. + # + # We arrange things so: + # - `hp.start_idx` does not exist, so the direct indexing heuristic is skipped. + # - The `(n, n-1)` candidates are unusable for the requested line. + # - A later multi-line history entry is usable, so the scan fallback returns it. + + struct _BCHist2 + history::Vector{Any} + end + struct _BCMode2 + hist::_BCHist2 + end + struct _BCInterface2 + modes::Vector{Any} + end + struct _BCRepl2 + interface::_BCInterface2 + end + + old_repl = isdefined(Base, :active_repl) ? Base.active_repl : nothing + try + history = Any[ + "x = 1", + "y = 2", + # `REPL[6]` first tries (n, n-1) = (6, 5). Make both unusable. + "line1\n#= REPL[6]:2 =#\nline3\n", # line 2 is a line marker => unusable + "z = 4", + "line1\n\nline3\n", # line 2 is empty => unusable + "w = 6", + # Scan fallback should find this usable multi-line entry for line 2. + "line1\nSENTINEL_REPL_FALLBACK\nline3\n", + ] + + Base.active_repl = _BCRepl2(_BCInterface2(Any[_BCMode2(_BCHist2(history))])) + + li = LineNumberNode(2, Symbol("REPL[6]")) + v = BorrowViolation(1, "msg", li, :(dummy_stmt)) + e = BorrowCheckError(Any, [v]) + + s = sprint(showerror, e) + @test occursin("SENTINEL_REPL_FALLBACK", s) + finally + if isdefined(Base, :active_repl) + try + Base.active_repl = old_repl + catch + end + end + end + end +end diff --git a/test/auto_unsafe_api_tests.jl b/test/auto_unsafe_api_tests.jl new file mode 100644 index 0000000..4407c04 --- /dev/null +++ b/test/auto_unsafe_api_tests.jl @@ -0,0 +1,154 @@ +@testitem "Auto @safe/@unsafe API" tags = [:auto] begin + using Test + using BorrowChecker + + @test Symbol("@safe") in names(BorrowChecker) + @test Symbol("@unsafe") in names(BorrowChecker) + @test Symbol("@safe") in names(BorrowChecker) + @test Symbol("@unsafe") in names(BorrowChecker) + + # Regression test: `@unsafe` should be valid at module top-level (no `local` binding). + @test (@eval BorrowChecker.@unsafe begin + 1 + 2 + end) == 3 + + @test_deprecated macroexpand( + @__MODULE__, :(BorrowChecker.@auto function _bc_depwarn_auto() + return 1 + end) + ) + + BorrowChecker.@safe function _bc_safe_violation_should_error() + x = [1, 2, 3] + y = x + push!(x, 1) + return y + end + + @test_throws BorrowChecker.BorrowCheckError _bc_safe_violation_should_error() + + BorrowChecker.@safe function _bc_safe_with_unsafe_should_pass() + x = [1, 2, 3] + y = x + @unsafe begin + push!(x, 1) + end + return y + end + + @test _bc_safe_with_unsafe_should_pass() == [1, 2, 3, 1] + + BorrowChecker.@safe function _bc_safe_with_unsafe_inner_should_pass() + x = [1, 2, 3] + y = x + @unsafe begin + inner() = (push!(x, 1); y) + inner() + end + end + + @test _bc_safe_with_unsafe_inner_should_pass() == [1, 2, 3, 1] + + let unsafe_call = macroexpand(@__MODULE__, :(BorrowChecker.@unsafe begin + push!(x, 1) + end)) + @test occursin("borrow_checker_unsafe", sprint(show, unsafe_call)) + @eval BorrowChecker.@safe function _bc_safe_with_preexpanded_unsafe_should_pass() + x = [1, 2, 3] + y = x + $unsafe_call + return y + end + end + + @test _bc_safe_with_preexpanded_unsafe_should_pass() == [1, 2, 3, 1] + + @eval BorrowChecker.@safe function _bc_bare_meta_unsafe_whole_method_should_pass() + $(Expr(:meta, :borrow_checker_unsafe)) + x = [1, 2, 3] + y = x + push!(x, 1) # would normally be a borrow-check violation + return y + end + + @test _bc_bare_meta_unsafe_whole_method_should_pass() == [1, 2, 3, 1] + + module _BCUnsafeDisabled + using BorrowChecker + + BorrowChecker.disable_by_default!(@__MODULE__) + + const expanded = macroexpand(@__MODULE__, :(BorrowChecker.@unsafe begin + 1 + end)) + + BorrowChecker.@safe function f() + x = [1, 2, 3] + y = x + @unsafe begin + push!(x, 1) + end + return y + end + end + + @test !occursin("borrow_checker_unsafe", sprint(show, _BCUnsafeDisabled.expanded)) + @test _BCUnsafeDisabled.f() == [1, 2, 3, 1] + + BorrowChecker.@safe function _bc_unsafe_line_mask_demo() + x = [1, 2, 3] + y = x + #! format: off + @unsafe begin push!(x, 1) end; push!(x, 2) # shares a source line with the unsafe block + #! format: on + return y + end + + @test_throws BorrowChecker.BorrowCheckError _bc_unsafe_line_mask_demo() +end + +@testitem "More complex unsafe branches" tags = [:auto] begin + using Test + using BorrowChecker + using BorrowChecker: BorrowCheckError + + @safe function add_halves!(a::Vector) + n = length(a) ÷ 2 + @unsafe begin + left = @view a[1:n] + right = @view a[(n + 1):(2n)] + left .+= right + end + return a + end + + @test add_halves!([1, 2, 3, 4, 5, 6])[1:3] == [5, 7, 9] + + @safe function add_halves_bad!(a::Vector) + n = length(a) ÷ 2 + begin + left = @view a[1:n] + right = @view a[(n + 1):(2n)] + left .+= right + end + return a + end + + @test_throws BorrowCheckError add_halves_bad!([1, 2, 3, 4, 5, 6]) + + @safe function _bc_unsafe_within_tuple() + x = [1, 2, 3] + y = x + ((@unsafe push!(x, 1)), push!(x, 2)) + return y + end + @test_throws BorrowCheckError _bc_unsafe_within_tuple() + + @safe function _bc_unsafe_within_tuple_2() + x = [1, 2, 3] + y = x + ((@unsafe push!(x, 1)), (@unsafe push!(x, 2))) + return y + end + @test _bc_unsafe_within_tuple_2() == [1, 2, 3, 1, 2] +end diff --git a/test/dynamic_expressions_integration_tests.jl b/test/dynamic_expressions_integration_tests.jl new file mode 100644 index 0000000..b8ed857 --- /dev/null +++ b/test/dynamic_expressions_integration_tests.jl @@ -0,0 +1,36 @@ +@testitem "DynamicExpressions integration" tags = [:auto] begin + using TestItems + using BorrowChecker + + @static if isdefined(BorrowChecker, :BorrowCheckError) + # This integration test exercises the experimental IR borrow checker on a + # real external package type (DynamicExpressions.Expression). + + using DynamicExpressions + using BorrowChecker: BorrowCheckError + + operators = OperatorEnum(1 => [exp], 2 => [+, -, *]) + x1 = Expression(Node{Float64}(; feature=1); operators) + x2 = Expression(Node{Float64}(; feature=2); operators) + + BorrowChecker.@safe bat(ex) = begin + (c1, r1) = get_scalar_constants(ex) + ex2 = ex + set_scalar_constants!(ex, c1 .* 2, r1) + ex2 + end + + @test_throws BorrowCheckError bat(x1 + x2 * 3.2) + + # MWE: `copy(::Expression)` currently triggers a spurious "consume" violation when + # analyzed under `@safe` (likely via the compiler-generated keyword wrapper). + # This should not be a move/escape: `copy` is expected to produce a fresh object. + BorrowChecker.@safe bc_copy_ok(ex) = copy(ex) + @test_broken try + bc_copy_ok(x1) + true + catch e + !(e isa BorrowCheckError) + end + end +end diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..7ce7406 --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,46 @@ +using TestItems +using TestItemRunner +using BorrowChecker + +include("auto_borrow_checker_tests.jl") +include("auto_llvm_tests.jl") +include("auto_printing_tests.jl") +include("auto_hygiene_integration_tests.jl") +include("dynamic_expressions_integration_tests.jl") +include("auto_unsafe_api_tests.jl") + +@static if VERSION < v"1.14.0-" + @testitem "Aqua" begin + using Aqua + + Aqua.test_all(BorrowChecker) + end +end + +@testitem "JET tests" begin + if VERSION >= v"1.10.0" && VERSION < v"1.13.0-DEV.0" + test_jet_file = joinpath((@__DIR__), "test_jet.jl") + run(`$(Base.julia_cmd()) --startup-file=no $test_jet_file`) + end +end + +const testitem_name_filter = get(ENV, "BORROWCHECKER_TESTITEM", "") +const only_auto = lowercase(get(ENV, "BORROWCHECKER_ONLY_AUTO", "")) in ("1", "true", "yes") +const auto_supported = + VERSION >= v"1.12.0-" && VERSION < v"1.13.0-" && isdefined(Base, :code_ircode_by_type) + +if only_auto && !auto_supported + error("BORROWCHECKER_ONLY_AUTO requires Julia 1.12.x with Base.code_ircode_by_type (unsupported on 1.13+)") +end + +filter = if !isempty(testitem_name_filter) + ti -> ti.name == testitem_name_filter && (auto_supported || !(:auto in ti.tags)) +elseif only_auto + ti -> :auto in ti.tags +elseif !auto_supported + ti -> !(:auto in ti.tags) +else + nothing +end + +@run_package_tests filter = filter diff --git a/test/test_jet.jl b/test/test_jet.jl new file mode 100644 index 0000000..11a9ce5 --- /dev/null +++ b/test/test_jet.jl @@ -0,0 +1,24 @@ +using Pkg +@info "Creating environment..." +dir = mktempdir() +Pkg.activate(dir; io=devnull) +Pkg.develop(; path=dirname(@__DIR__), io=devnull) +Pkg.add(["JET", "Preferences"]; io=devnull) +@info "Done!" + +using Preferences + +cd(dir) + +Preferences.set_preferences!( + "BorrowChecker", "dispatch_doctor_mode" => "disable"; force=true +) + +using BorrowChecker +using JET + +@info "Running tests..." +JET.test_package(BorrowChecker; target_modules=(BorrowChecker,)) +@info "Done!" + +@info "test_jet.jl finished"