diff --git a/.cirrus.yml b/.cirrus.yml index 7cf07fbbc4..c53006335c 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -7,7 +7,9 @@ jvm_highcore_task: memory: 16G matrix: - name: JVM high-core-count 2.13 - script: sbt '++ 2.13' testsJVM/test + script: sbt '++ 2.13' testsJVM/test ioAppTestsJVM/test + - name: JVM high-core-count 3 + script: sbt '++ 3' testsJVM/test ioAppTestsJVM/test jvm_arm_highcore_task: only_if: $CIRRUS_TAG != '' || $CIRRUS_PR != '' @@ -18,9 +20,9 @@ jvm_arm_highcore_task: memory: 8G matrix: - name: JVM ARM high-core-count 2.13 - script: sbt '++ 2.13' testsJVM/test + script: sbt '++ 2.13' testsJVM/test ioAppTestsJVM/test - name: JVM ARM high-core-count 3 - script: sbt '++ 3' testsJVM/test + script: sbt '++ 3' testsJVM/test ioAppTestsJVM/test native_arm_task: only_if: $CIRRUS_TAG != '' || $CIRRUS_PR != '' @@ -31,4 +33,4 @@ native_arm_task: memory: 8G matrix: - name: Native ARM 3 - script: sbt '++ 3' testsNative/test + script: sbt '++ 3' testsNative/test ioAppTestsNative/test diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index c96433bdba..09caafe4da 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,3 +3,6 @@ c3404c6577af33d65017aeaca248d51dab770021 # Scala Steward: Reformat with scalafmt 3.7.1 52c851127a918b050f7b1d33ad71f128cb7bc48e + +# Scala Steward: Reformat with scalafmt 3.7.9 +db6c201aad98cc3d19f67cc688dfa6332e2fb939 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c8504ad85..1f4d3e45b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,150 +27,186 @@ jobs: name: Build and Test strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-12] - scala: [3.2.2, 2.12.17, 2.13.10] - java: [temurin@8, temurin@11, temurin@17] + os: [ubuntu-latest, windows-latest, macos-14] + scala: [3.3.4, 2.12.20, 2.13.15] + java: + - temurin@8 + - temurin@11 + - temurin@17 + - temurin@21 + - graalvm@21 ci: [ciJVM, ciNative, ciJS, ciFirefox, ciChrome] exclude: - - scala: 3.2.2 + - scala: 3.3.4 java: temurin@11 - - scala: 2.12.17 + - scala: 3.3.4 + java: temurin@21 + - scala: 3.3.4 + java: graalvm@21 + - scala: 2.12.20 java: temurin@11 - - scala: 2.12.17 + - scala: 2.12.20 java: temurin@17 + - scala: 2.12.20 + java: temurin@21 + - scala: 2.12.20 + java: graalvm@21 - os: windows-latest - scala: 3.2.2 - - os: macos-12 - scala: 3.2.2 + scala: 3.3.4 + ci: ciJVM + - os: macos-14 + scala: 3.3.4 + ci: ciJVM - os: windows-latest - scala: 2.12.17 - - os: macos-12 - scala: 2.12.17 + scala: 2.12.20 + ci: ciJVM + - os: macos-14 + scala: 2.12.20 + ci: ciJVM + - os: macos-14 + java: temurin@8 - ci: ciFirefox - scala: 3.2.2 + scala: 3.3.4 - ci: ciChrome - scala: 3.2.2 + scala: 3.3.4 - ci: ciFirefox - scala: 2.12.17 + scala: 2.12.20 - ci: ciChrome - scala: 2.12.17 + scala: 2.12.20 - ci: ciJS java: temurin@11 - ci: ciJS java: temurin@17 + - ci: ciJS + java: temurin@21 + - ci: ciJS + java: graalvm@21 - os: windows-latest ci: ciJS - - os: macos-12 + - os: macos-14 ci: ciJS - ci: ciFirefox java: temurin@11 - ci: ciFirefox java: temurin@17 + - ci: ciFirefox + java: temurin@21 + - ci: ciFirefox + java: graalvm@21 - os: windows-latest ci: ciFirefox - - os: macos-12 + - os: macos-14 ci: ciFirefox - ci: ciChrome java: temurin@11 - ci: ciChrome java: temurin@17 + - ci: ciChrome + java: temurin@21 + - ci: ciChrome + java: graalvm@21 - os: windows-latest ci: ciChrome - - os: macos-12 + - os: macos-14 ci: ciChrome - ci: ciNative java: temurin@11 - ci: ciNative java: temurin@17 + - ci: ciNative + java: temurin@21 + - ci: ciNative + java: graalvm@21 - os: windows-latest ci: ciNative - - os: macos-12 - ci: ciNative - scala: 2.12.17 - - os: macos-12 + - os: macos-14 ci: ciNative - scala: 3.2.2 + scala: 2.12.20 runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: + - name: Install sbt + uses: sbt/setup-sbt@v1 + - name: Ignore line ending differences in git if: contains(runner.os, 'windows') shell: bash run: git config --global core.autocrlf false - name: Checkout current branch (full) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - name: Setup Java (temurin@8) id: setup-java-temurin-8 if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' shell: bash - run: sbt '++ ${{ matrix.scala }}' reload +update - - - name: Download Java (temurin@11) - id: download-java-temurin-11 - if: matrix.java == 'temurin@11' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 11 + run: sbt +update - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 11 - jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' shell: bash - run: sbt '++ ${{ matrix.scala }}' reload +update - - - name: Download Java (temurin@17) - id: download-java-temurin-17 - if: matrix.java == 'temurin@17' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 17 + run: sbt +update - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 17 - jdkFile: ${{ steps.download-java-temurin-17.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' shell: bash - run: sbt '++ ${{ matrix.scala }}' reload +update + run: sbt +update + + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + shell: bash + run: sbt +update + + - name: Setup Java (graalvm@21) + id: setup-java-graalvm-21 + if: matrix.java == 'graalvm@21' + uses: actions/setup-java@v4 + with: + distribution: graalvm + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'graalvm@21' && steps.setup-java-graalvm-21.outputs.cache-hit == 'false' + shell: bash + run: sbt +update - name: Setup NodeJS v18 LTS if: matrix.ci == 'ciJS' @@ -196,24 +232,24 @@ jobs: run: sbt githubWorkflowCheck - name: Check that scalafix has been run on JVM - if: matrix.ci == 'ciJVM' && matrix.scala != '3.2.2' && matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + if: matrix.ci == 'ciJVM' && matrix.scala != '3.3.4' && matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' shell: bash run: sbt '++ ${{ matrix.scala }}' 'rootJVM/scalafixAll --check' - name: Check that scalafix has been run on JS - if: matrix.ci == 'ciJS' && matrix.scala != '3.2.2' && matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + if: matrix.ci == 'ciJS' && matrix.scala != '3.3.4' && matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' shell: bash run: sbt '++ ${{ matrix.scala }}' 'rootJS/scalafixAll --check' - name: Check that scalafix has been run on Native - if: matrix.ci == 'ciNative' && matrix.scala != '3.2.2' && matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + if: matrix.ci == 'ciNative' && matrix.scala != '3.3.4' && matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' shell: bash run: sbt '++ ${{ matrix.scala }}' 'rootNative/scalafixAll --check' - shell: bash run: sbt '++ ${{ matrix.scala }}' '${{ matrix.ci }}' - - if: (matrix.scala == '2.13.10' || matrix.scala == '3.2.2') && matrix.ci == 'ciJVM' + - if: (matrix.scala == '2.13.15' || matrix.scala == '3.3.4') && matrix.ci == 'ciJVM' && matrix.java == 'temurin@17' shell: bash run: sbt '++ ${{ matrix.scala }}' docs/mdoc @@ -233,7 +269,7 @@ jobs: run: example/test-native.sh ${{ matrix.scala }} - name: Scalafix tests - if: matrix.scala == '2.13.10' && matrix.ci == 'ciJVM' && matrix.os == 'ubuntu-latest' + if: matrix.scala == '2.13.15' && matrix.ci == 'ciJVM' && matrix.os == 'ubuntu-latest' shell: bash run: | cd scalafix @@ -242,16 +278,16 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) shell: bash - run: mkdir -p benchmarks/target testkit/native/target target stress-tests/target std/jvm/target example/js/target kernel-testkit/jvm/target testkit/js/target rootJS/target core/native/target site-docs/target std/js/target laws/native/target kernel-testkit/native/target kernel/jvm/target core/js/target kernel/js/target laws/js/target graalvm-example/target kernel-testkit/js/target core/jvm/target rootJVM/target rootNative/target example/native/target kernel/native/target example/jvm/target laws/jvm/target std/native/target testkit/jvm/target project/target + run: mkdir -p testkit/native/target std/jvm/target kernel-testkit/jvm/target testkit/js/target core/native/target site-docs/target std/js/target laws/native/target kernel-testkit/native/target kernel/jvm/target core/js/target kernel/js/target laws/js/target kernel-testkit/js/target core/jvm/target kernel/native/target laws/jvm/target std/native/target testkit/jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) shell: bash - run: tar cf targets.tar benchmarks/target testkit/native/target target stress-tests/target std/jvm/target example/js/target kernel-testkit/jvm/target testkit/js/target rootJS/target core/native/target site-docs/target std/js/target laws/native/target kernel-testkit/native/target kernel/jvm/target core/js/target kernel/js/target laws/js/target graalvm-example/target kernel-testkit/js/target core/jvm/target rootJVM/target rootNative/target example/native/target kernel/native/target example/jvm/target laws/jvm/target std/native/target testkit/jvm/target project/target + run: tar cf targets.tar testkit/native/target std/jvm/target kernel-testkit/jvm/target testkit/js/target core/native/target site-docs/target std/js/target laws/native/target kernel-testkit/native/target kernel/jvm/target core/js/target kernel/js/target laws/js/target kernel-testkit/js/target core/jvm/target kernel/native/target laws/jvm/target std/native/target testkit/jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.ci }} path: targets.tar @@ -263,191 +299,192 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.13.10] java: [temurin@8] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + uses: sbt/setup-sbt@v1 + - name: Ignore line ending differences in git if: contains(runner.os, 'windows') run: git config --global core.autocrlf false - name: Checkout current branch (full) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - name: Setup Java (temurin@8) id: setup-java-temurin-8 if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' - run: sbt '++ ${{ matrix.scala }}' reload +update - - - name: Download Java (temurin@11) - id: download-java-temurin-11 - if: matrix.java == 'temurin@11' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 11 + run: sbt +update - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 11 - jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' - run: sbt '++ ${{ matrix.scala }}' reload +update - - - name: Download Java (temurin@17) - id: download-java-temurin-17 - if: matrix.java == 'temurin@17' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 17 + run: sbt +update - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 17 - jdkFile: ${{ steps.download-java-temurin-17.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' - run: sbt '++ ${{ matrix.scala }}' reload +update + run: sbt +update + + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt - - name: Download target directories (3.2.2, ciJVM) - uses: actions/download-artifact@v3 + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + run: sbt +update + + - name: Setup Java (graalvm@21) + id: setup-java-graalvm-21 + if: matrix.java == 'graalvm@21' + uses: actions/setup-java@v4 + with: + distribution: graalvm + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'graalvm@21' && steps.setup-java-graalvm-21.outputs.cache-hit == 'false' + run: sbt +update + + - name: Download target directories (3.3.4, ciJVM) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.2-ciJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.4-ciJVM - - name: Inflate target directories (3.2.2, ciJVM) + - name: Inflate target directories (3.3.4, ciJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.2.2, ciNative) - uses: actions/download-artifact@v3 + - name: Download target directories (3.3.4, ciNative) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.2-ciNative + name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.4-ciNative - - name: Inflate target directories (3.2.2, ciNative) + - name: Inflate target directories (3.3.4, ciNative) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.2.2, ciJS) - uses: actions/download-artifact@v3 + - name: Download target directories (3.3.4, ciJS) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.2-ciJS + name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.4-ciJS - - name: Inflate target directories (3.2.2, ciJS) + - name: Inflate target directories (3.3.4, ciJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.17, ciJVM) - uses: actions/download-artifact@v3 + - name: Download target directories (2.12.20, ciJVM) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.17-ciJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.20-ciJVM - - name: Inflate target directories (2.12.17, ciJVM) + - name: Inflate target directories (2.12.20, ciJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.17, ciNative) - uses: actions/download-artifact@v3 + - name: Download target directories (2.12.20, ciNative) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.17-ciNative + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.20-ciNative - - name: Inflate target directories (2.12.17, ciNative) + - name: Inflate target directories (2.12.20, ciNative) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.17, ciJS) - uses: actions/download-artifact@v3 + - name: Download target directories (2.12.20, ciJS) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.17-ciJS + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.20-ciJS - - name: Inflate target directories (2.12.17, ciJS) + - name: Inflate target directories (2.12.20, ciJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.10, ciJVM) - uses: actions/download-artifact@v3 + - name: Download target directories (2.13.15, ciJVM) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.10-ciJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.15-ciJVM - - name: Inflate target directories (2.13.10, ciJVM) + - name: Inflate target directories (2.13.15, ciJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.10, ciNative) - uses: actions/download-artifact@v3 + - name: Download target directories (2.13.15, ciNative) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.10-ciNative + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.15-ciNative - - name: Inflate target directories (2.13.10, ciNative) + - name: Inflate target directories (2.13.15, ciNative) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.10, ciJS) - uses: actions/download-artifact@v3 + - name: Download target directories (2.13.15, ciJS) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.10-ciJS + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.15-ciJS - - name: Inflate target directories (2.13.10, ciJS) + - name: Inflate target directories (2.13.15, ciJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.10, ciFirefox) - uses: actions/download-artifact@v3 + - name: Download target directories (2.13.15, ciFirefox) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.10-ciFirefox + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.15-ciFirefox - - name: Inflate target directories (2.13.10, ciFirefox) + - name: Inflate target directories (2.13.15, ciFirefox) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.10, ciChrome) - uses: actions/download-artifact@v3 + - name: Download target directories (2.13.15, ciChrome) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.10-ciChrome + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.15-ciChrome - - name: Inflate target directories (2.13.10, ciChrome) + - name: Inflate target directories (2.13.15, ciChrome) run: | tar xf targets.tar rm targets.tar @@ -457,7 +494,7 @@ jobs: env: PGP_SECRET: ${{ secrets.PGP_SECRET }} PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} - run: echo $PGP_SECRET | base64 -di | gpg --import + run: echo $PGP_SECRET | base64 -d -i - | gpg --import - name: Import signing key and strip passphrase if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' @@ -465,7 +502,7 @@ jobs: PGP_SECRET: ${{ secrets.PGP_SECRET }} PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} run: | - echo "$PGP_SECRET" | base64 -di > /tmp/signing-key.gpg + echo "$PGP_SECRET" | base64 -d -i - > /tmp/signing-key.gpg echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) @@ -477,7 +514,7 @@ jobs: SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} - run: sbt '++ ${{ matrix.scala }}' tlCiRelease + run: sbt tlCiRelease - name: Post release to Discord env: @@ -486,88 +523,92 @@ jobs: dependency-submission: name: Submit Dependencies - if: github.event_name != 'pull_request' + if: github.event.repository.fork == false && github.event_name != 'pull_request' strategy: matrix: os: [ubuntu-latest] - scala: [2.13.10] java: [temurin@8] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + uses: sbt/setup-sbt@v1 + - name: Ignore line ending differences in git if: contains(runner.os, 'windows') run: git config --global core.autocrlf false - name: Checkout current branch (full) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - name: Setup Java (temurin@8) id: setup-java-temurin-8 if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' - run: sbt '++ ${{ matrix.scala }}' reload +update - - - name: Download Java (temurin@11) - id: download-java-temurin-11 - if: matrix.java == 'temurin@11' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 11 + run: sbt +update - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 11 - jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' - run: sbt '++ ${{ matrix.scala }}' reload +update - - - name: Download Java (temurin@17) - id: download-java-temurin-17 - if: matrix.java == 'temurin@17' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 17 + run: sbt +update - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 17 - jdkFile: ${{ steps.download-java-temurin-17.outputs.jdkFile }} cache: sbt - name: sbt update if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' - run: sbt '++ ${{ matrix.scala }}' reload +update + run: sbt +update + + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + run: sbt +update + + - name: Setup Java (graalvm@21) + id: setup-java-graalvm-21 + if: matrix.java == 'graalvm@21' + uses: actions/setup-java@v4 + with: + distribution: graalvm + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'graalvm@21' && steps.setup-java-graalvm-21.outputs.cache-hit == 'false' + run: sbt +update - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 + with: + modules-ignore: cats-effect-benchmarks_3 cats-effect-benchmarks_2.12 cats-effect-benchmarks_2.13 cats-effect_3 cats-effect_2.12 cats-effect_2.13 cats-effect-example_sjs1_3 cats-effect-example_sjs1_2.12 cats-effect-example_sjs1_2.13 rootjs_3 rootjs_2.12 rootjs_2.13 ioapptestsnative_3 ioapptestsnative_2.12 ioapptestsnative_2.13 cats-effect-graalvm-example_3 cats-effect-graalvm-example_2.12 cats-effect-graalvm-example_2.13 cats-effect-tests_sjs1_3 cats-effect-tests_sjs1_2.12 cats-effect-tests_sjs1_2.13 rootjvm_3 rootjvm_2.12 rootjvm_2.13 rootnative_3 rootnative_2.12 rootnative_2.13 cats-effect-example_native0.4_3 cats-effect-example_native0.4_2.12 cats-effect-example_native0.4_2.13 cats-effect-example_3 cats-effect-example_2.12 cats-effect-example_2.13 cats-effect-tests_3 cats-effect-tests_2.12 cats-effect-tests_2.13 ioapptestsjvm_3 ioapptestsjvm_2.12 ioapptestsjvm_2.13 ioapptestsjs_3 ioapptestsjs_2.12 ioapptestsjs_2.13 cats-effect-tests_native0.4_3 cats-effect-tests_native0.4_2.12 cats-effect-tests_native0.4_2.13 + configs-ignore: test scala-tool scala-doc-tool test-internal diff --git a/.mergify.yml b/.mergify.yml index 899bab69fd..2e090c8a4b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,10 +1,23 @@ pull_request_rules: - - name: label scala-steward's PRs + - name: label typelevel-steward's PRs conditions: - - author=scala-steward + - author=typelevel-steward[bot] + actions: + label: + add: [':robot:'] + - name: label typelevel-steward's update PRs + conditions: + - author=typelevel-steward[bot] + - body~=labels:.*library-update actions: label: add: [dependencies] + - name: label dependabots's PRs + conditions: + - author=dependabot[bot] + actions: + label: + add: [':robot:'] - name: automatically merge dependabot docs PRs conditions: - author=dependabot[bot] diff --git a/.scalafmt.conf b/.scalafmt.conf index bbca65b234..8a156a2732 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.7.3 +version = 3.8.3 runner.dialect = Scala213Source3 fileOverride { diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index f38f936e7e..0000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,18 +0,0 @@ -# Code of Conduct - -We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. - -Everyone is expected to follow the [Scala Code of Conduct] when discussing the project on the available communication channels. - -## Moderation - -Any questions, concerns, or moderation requests please contact a member of the project. - -- Ross A. Baker | [twitter](https://twitter.com/rossabaker) | [email](mailto:ross@rossabaker.com) -- Christopher Davenport | [twitter](https://twitter.com/davenpcm) | [email](mailto:chris@christopherdavenport.tech) -- Alexandru Nedelcu | [twitter](https://twitter.com/alexelcu) | [email](mailto:y20+coc@alexn.org) -- Daniel Spiewak | [twitter](https://twitter.com/djspiewak) | [email](mailto:djspiewak@gmail.com) - -And of course, always feel free to reach out to anyone with the **mods** role in [Discord](https://discord.gg/QNnHKHq5Ts). - -[Scala Code of Conduct]: https://typelevel.org/code-of-conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83b09a9f4d..61699b1db2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,18 @@ There's always lots to do! This is an incredibly exciting project used by countl Anything which is marked with [**good first issue**](https://github.com/typelevel/cats-effect/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) is something that the Cats Effect maintainers have evaluated and determined is likely to not require a significant amount of time or prior knowledge of the code in order to fix. If you want to take on one of these tasks, just leave a comment on the issue and we'll assign it to you! Additionally, whenever we mark issues with this label, we are committing to doing our best to be extra-responsive to questions and pull requests against the issue so as to best help you find your feet as a contributor. +When creating a fork on GitHub, be sure to uncheck the box marked _Copy the branch only_, or you may have see the following error when running [sbt](https://github.com/sbt/sbt) due to missing git tags: +```sbt +[error] fatal: No names found, cannot describe anything. +[error] Nonzero exit code (128) running git. +``` +If you do encounter this error, make sure you have the `typelevel/cats-effect` repo as a remote with `git remote -v`. To add it as a remote, run one of the following: +* `git remote add upstream git@github.com:typelevel/cats-effect.git` for ssh +* `git remote add upstream https://github.com/typelevel/cats-effect.git` for https + +When you are done run `git fetch --tags `, for the above case this would be `git fetch --tags upstream`. + + ## Tooling Cats Effect is built with [sbt](https://github.com/sbt/sbt), and you should be able to jump right in by running `sbt test`. I will note, however, that `sbt +test` takes about two hours on my laptop, so you probably *shouldn't* start there... diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000000..d3c1e1a527 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,8 @@ +cats-effect +Copyright 2020-2024 Typelevel +Licensed under Apache License 2.0 (see LICENSE) + +This software contains portions of code derived from scala-js +https://github.com/scala-js/scala-js +Copyright EPFL +Licensed under Apache License 2.0 (see LICENSE) diff --git a/README.md b/README.md index 845a13a52f..d830b913c7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.5" ``` -The above represents the core, stable dependency which brings in the entirety of Cats Effect. This is *most likely* what you want. All current Cats Effect releases are published for Scala 2.12, 2.13, 3.0, and Scala.js 1.7. +The above represents the core, stable dependency which brings in the entirety of Cats Effect. This is *most likely* what you want. All current Cats Effect releases are published for Scala 2.12, 2.13, 3.2, and Scala.js 1.13. Or, if you prefer a less bare-bones starting point, you can try [the Giter8 template](https://github.com/typelevel/ce3.g8): @@ -161,7 +161,7 @@ If everything goes well, your browser will open at the end of this. ## License ``` -Copyright 2017-2022 Typelevel +Copyright 2017-2024 Typelevel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/benchmarks/src/main/scala/cats/effect/benchmarks/AtomicCellBenchmark.scala b/benchmarks/src/main/scala/cats/effect/benchmarks/AtomicCellBenchmark.scala index 49f4702fde..4be8a362c1 100644 --- a/benchmarks/src/main/scala/cats/effect/benchmarks/AtomicCellBenchmark.scala +++ b/benchmarks/src/main/scala/cats/effect/benchmarks/AtomicCellBenchmark.scala @@ -19,7 +19,6 @@ package cats.effect.benchmarks import cats.effect.IO import cats.effect.std._ import cats.effect.unsafe.implicits.global -import cats.syntax.all._ import org.openjdk.jmh.annotations._ diff --git a/benchmarks/src/main/scala/cats/effect/benchmarks/MutexBenchmark.scala b/benchmarks/src/main/scala/cats/effect/benchmarks/MutexBenchmark.scala index 18157ff373..178c4e9c5e 100644 --- a/benchmarks/src/main/scala/cats/effect/benchmarks/MutexBenchmark.scala +++ b/benchmarks/src/main/scala/cats/effect/benchmarks/MutexBenchmark.scala @@ -19,7 +19,6 @@ package cats.effect.benchmarks import cats.effect.IO import cats.effect.std._ import cats.effect.unsafe.implicits.global -import cats.syntax.all._ import org.openjdk.jmh.annotations._ diff --git a/benchmarks/src/main/scala/cats/effect/benchmarks/QueueBenchmark.scala b/benchmarks/src/main/scala/cats/effect/benchmarks/QueueBenchmark.scala index 6a34c7fcca..cf932f6f80 100644 --- a/benchmarks/src/main/scala/cats/effect/benchmarks/QueueBenchmark.scala +++ b/benchmarks/src/main/scala/cats/effect/benchmarks/QueueBenchmark.scala @@ -115,6 +115,22 @@ class QueueBenchmark { def unboundedAsyncEnqueueDequeueContended(): Unit = Queue.unboundedForAsync[IO, Unit].flatMap(enqueueDequeueContended(_)).unsafeRunSync() + @Benchmark + def droppingConcurrentEnqueueDequeueOne(): Unit = + Queue.droppingForConcurrent[IO, Unit](size).flatMap(enqueueDequeueOne(_)).unsafeRunSync() + + @Benchmark + def droppingConcurrentEnqueueDequeueMany(): Unit = + Queue.droppingForConcurrent[IO, Unit](size).flatMap(enqueueDequeueMany(_)).unsafeRunSync() + + @Benchmark + def droppingAsyncEnqueueDequeueOne(): Unit = + Queue.droppingForAsync[IO, Unit](size).flatMap(enqueueDequeueOne(_)).unsafeRunSync() + + @Benchmark + def droppingAsyncEnqueueDequeueMany(): Unit = + Queue.droppingForAsync[IO, Unit](size).flatMap(enqueueDequeueMany(_)).unsafeRunSync() + private[this] def enqueueDequeueOne(q: Queue[IO, Unit]): IO[Unit] = { def loop(i: Int): IO[Unit] = if (i > 0) diff --git a/benchmarks/src/main/scala/cats/effect/benchmarks/WorkStealingBenchmark.scala b/benchmarks/src/main/scala/cats/effect/benchmarks/WorkStealingBenchmark.scala index 002cd377ac..0024abe8b5 100644 --- a/benchmarks/src/main/scala/cats/effect/benchmarks/WorkStealingBenchmark.scala +++ b/benchmarks/src/main/scala/cats/effect/benchmarks/WorkStealingBenchmark.scala @@ -165,12 +165,14 @@ class WorkStealingBenchmark { (ExecutionContext.fromExecutor(executor), () => executor.shutdown()) } - val compute = new WorkStealingThreadPool( + val compute = new WorkStealingThreadPool[AnyRef]( 256, "io-compute", "io-blocker", 60.seconds, false, + 1.second, + SleepSystem, _.printStackTrace()) val cancelationCheckThreshold = diff --git a/build.sbt b/build.sbt index 91529b7c12..7ed28d9d62 100644 --- a/build.sbt +++ b/build.sbt @@ -41,7 +41,7 @@ ThisBuild / git.gitUncommittedChanges := { } } -ThisBuild / tlBaseVersion := "3.5" +ThisBuild / tlBaseVersion := "3.6" ThisBuild / tlUntaggedAreSnapshots := false ThisBuild / organization := "org.typelevel" @@ -110,16 +110,17 @@ ThisBuild / developers := List( val PrimaryOS = "ubuntu-latest" val Windows = "windows-latest" -val MacOS = "macos-12" +val MacOS = "macos-14" -val Scala212 = "2.12.17" -val Scala213 = "2.13.10" -val Scala3 = "3.2.2" +val Scala212 = "2.12.20" +val Scala213 = "2.13.15" +val Scala3 = "3.3.4" ThisBuild / crossScalaVersions := Seq(Scala3, Scala212, Scala213) ThisBuild / githubWorkflowScalaVersions := crossScalaVersions.value ThisBuild / tlVersionIntroduced := Map("3" -> "3.1.1") ThisBuild / tlJdkRelease := Some(8) +ThisBuild / javacOptions += "-Xlint:-options" // --release 8 is deprecated on 21 ThisBuild / githubWorkflowTargetBranches := Seq("series/3.*") ThisBuild / tlCiReleaseTags := true @@ -135,10 +136,17 @@ ThisBuild / githubWorkflowPublishPreamble += val OldGuardJava = JavaSpec.temurin("8") val LTSJava = JavaSpec.temurin("11") val LatestJava = JavaSpec.temurin("17") +val LoomJava = JavaSpec.temurin("21") val ScalaJSJava = OldGuardJava val ScalaNativeJava = OldGuardJava +val GraalVM = JavaSpec.graalvm("21") -ThisBuild / githubWorkflowJavaVersions := Seq(OldGuardJava, LTSJava, LatestJava) +ThisBuild / githubWorkflowJavaVersions := Seq( + OldGuardJava, + LTSJava, + LatestJava, + LoomJava, + GraalVM) ThisBuild / githubWorkflowOSes := Seq(PrimaryOS, Windows, MacOS) ThisBuild / githubWorkflowBuildPreamble ++= Seq( @@ -177,7 +185,8 @@ ThisBuild / githubWorkflowBuild := Seq("JVM", "JS", "Native").map { platform => WorkflowStep.Sbt( List("docs/mdoc"), cond = Some( - s"(matrix.scala == '$Scala213' || matrix.scala == '$Scala3') && matrix.ci == 'ciJVM'")), + s"(matrix.scala == '$Scala213' || matrix.scala == '$Scala3') && matrix.ci == 'ciJVM' && matrix.java == '${LatestJava.render}'") + ), WorkflowStep.Run( List("example/test-jvm.sh ${{ matrix.scala }}"), name = Some("Test Example JVM App Within Sbt"), @@ -222,9 +231,9 @@ ThisBuild / githubWorkflowBuildMatrixExclusions := { val windowsAndMacScalaFilters = (ThisBuild / githubWorkflowScalaVersions).value.filterNot(Set(Scala213)).flatMap { scala => Seq( - MatrixExclude(Map("os" -> Windows, "scala" -> scala)), - MatrixExclude(Map("os" -> MacOS, "scala" -> scala))) - } + MatrixExclude(Map("os" -> Windows, "scala" -> scala, "ci" -> CI.JVM.command)), + MatrixExclude(Map("os" -> MacOS, "scala" -> scala, "ci" -> CI.JVM.command))) + } :+ MatrixExclude(Map("os" -> MacOS, "java" -> OldGuardJava.render)) val jsScalaFilters = for { scala <- (ThisBuild / githubWorkflowScalaVersions).value.filterNot(Set(Scala213)) @@ -252,9 +261,7 @@ ThisBuild / githubWorkflowBuildMatrixExclusions := { javaFilters ++ Seq( MatrixExclude(Map("os" -> Windows, "ci" -> ci)), - MatrixExclude(Map("os" -> MacOS, "ci" -> ci, "scala" -> Scala212)), - // keep a native+2.13+macos job - MatrixExclude(Map("os" -> MacOS, "ci" -> ci, "scala" -> Scala3)) + MatrixExclude(Map("os" -> MacOS, "ci" -> ci, "scala" -> Scala212)) ) } @@ -265,10 +272,6 @@ lazy val useJSEnv = settingKey[JSEnv]("Use Node.js or a headless browser for running Scala.js tests") Global / useJSEnv := NodeJS -lazy val testJSIOApp = - settingKey[Boolean]("Whether to test JVM (false) or Node.js (true) in IOAppSpec") -Global / testJSIOApp := false - ThisBuild / jsEnv := { useJSEnv.value match { case NodeJS => new NodeJSEnv(NodeJSEnv.Config().withSourceMap(true)) @@ -297,16 +300,14 @@ ThisBuild / apiURL := Some(url("https://typelevel.org/cats-effect/api/3.x/")) ThisBuild / autoAPIMappings := true -val CatsVersion = "2.9.0" -val Specs2Version = "4.20.0" -val ScalaCheckVersion = "1.17.0" +val CatsVersion = "2.11.0" +val Specs2Version = "4.20.5" +val ScalaCheckVersion = "1.17.1" val DisciplineVersion = "1.4.0" val CoopVersion = "1.2.0" val MacrotaskExecutorVersion = "1.1.1" -val ScalacCompatVersion = "0.1.0" - tlReplaceCommandAlias("ci", CI.AllCIs.map(_.toString).mkString) addCommandAlias("release", "tlRelease") @@ -321,7 +322,16 @@ tlReplaceCommandAlias( "; root/clean; +root/headerCreate; root/scalafixAll; scalafmtSbt; +root/scalafmtAll") val jsProjects: Seq[ProjectReference] = - Seq(kernel.js, kernelTestkit.js, laws.js, core.js, testkit.js, testsJS, std.js, example.js) + Seq( + kernel.js, + kernelTestkit.js, + laws.js, + core.js, + testkit.js, + tests.js, + ioAppTestsJS, + std.js, + example.js) val nativeProjects: Seq[ProjectReference] = Seq( @@ -331,13 +341,13 @@ val nativeProjects: Seq[ProjectReference] = core.native, testkit.native, tests.native, + ioAppTestsNative, std.native, example.native) val undocumentedRefs = jsProjects ++ nativeProjects ++ Seq[ProjectReference]( benchmarks, - stressTests, example.jvm, graalVMExample, tests.jvm, @@ -352,7 +362,8 @@ lazy val root = project name := "cats-effect", ScalaUnidoc / unidoc / unidocProjectFilter := { undocumentedRefs.foldLeft(inAnyProject)((acc, a) => acc -- inProjects(a)) - } + }, + scalacOptions -= "-Xsource:3" // bugged ) lazy val rootJVM = project @@ -362,12 +373,11 @@ lazy val rootJVM = project laws.jvm, core.jvm, testkit.jvm, - testsJVM, + tests.jvm, std.jvm, example.jvm, graalVMExample, - benchmarks, - stressTests) + benchmarks) .enablePlugins(NoPublishPlugin) lazy val rootJS = project.aggregate(jsProjects: _*).enablePlugins(NoPublishPlugin) @@ -391,7 +401,6 @@ lazy val kernel = crossProject(JSPlatform, JVMPlatform, NativePlatform) ProblemFilters.exclude[Problem]("cats.effect.kernel.GenConcurrent#Memoize*") ) ) - .disablePlugins(JCStressPlugin) .jsSettings( libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % MacrotaskExecutorVersion % Test ) @@ -412,6 +421,7 @@ lazy val kernelTestkit = crossProject(JSPlatform, JVMPlatform, NativePlatform) "org.typelevel" %%% "cats-free" % CatsVersion, "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, "org.typelevel" %%% "coop" % CoopVersion), + scalacOptions -= "-Xsource:3", // bugged mimaBinaryIssueFilters ++= Seq( ProblemFilters.exclude[DirectMissingMethodProblem]( "cats.effect.kernel.testkit.TestContext.this"), @@ -427,7 +437,6 @@ lazy val kernelTestkit = crossProject(JSPlatform, JVMPlatform, NativePlatform) "cats.effect.kernel.testkit.TestContext#Task.copy") ) ) - .disablePlugins(JCStressPlugin) /** * The laws which constrain the abstractions. This is split from kernel to avoid jar file and @@ -443,7 +452,6 @@ lazy val laws = crossProject(JSPlatform, JVMPlatform, NativePlatform) "org.typelevel" %%% "cats-laws" % CatsVersion, "org.typelevel" %%% "discipline-specs2" % DisciplineVersion % Test) ) - .disablePlugins(JCStressPlugin) /** * Concrete, production-grade implementations of the abstractions. Or, more simply-put: IO. Also @@ -455,9 +463,6 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) .dependsOn(kernel, std) .settings( name := "cats-effect", - libraryDependencies ++= Seq( - "org.typelevel" %% "scalac-compat-annotation" % ScalacCompatVersion % CompileTime - ), mimaBinaryIssueFilters ++= Seq( // introduced by #1837, removal of package private class ProblemFilters.exclude[MissingClassProblem]("cats.effect.AsyncPropagateCancelation"), @@ -642,12 +647,20 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) // #3787, internal utility that was no longer needed ProblemFilters.exclude[MissingClassProblem]("cats.effect.Thunk"), ProblemFilters.exclude[MissingClassProblem]("cats.effect.Thunk$"), + // #3781, replaced TimerSkipList with TimerHeap + ProblemFilters.exclude[MissingClassProblem]("cats.effect.unsafe.TimerSkipList*"), // #3943, refactored internal private CallbackStack data structure ProblemFilters.exclude[IncompatibleResultTypeProblem]("cats.effect.CallbackStack.push"), ProblemFilters.exclude[DirectMissingMethodProblem]( "cats.effect.CallbackStack.currentHandle"), // #3973, remove clear from internal private CallbackStack - ProblemFilters.exclude[DirectMissingMethodProblem]("cats.effect.CallbackStack.clear") + ProblemFilters.exclude[DirectMissingMethodProblem]("cats.effect.CallbackStack.clear"), + // introduced by #3332, polling system + ProblemFilters.exclude[DirectMissingMethodProblem]( + "cats.effect.unsafe.IORuntimeBuilder.this"), + // introduced by #3695, which enabled fiber dumps on native + ProblemFilters.exclude[MissingClassProblem]( + "cats.effect.unsafe.FiberMonitorCompanionPlatform") ) ++ { if (tlIsScala3.value) { // Scala 3 specific exclusions @@ -741,7 +754,12 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) ProblemFilters.exclude[DirectMissingMethodProblem]( "cats.effect.unsafe.WorkStealingThreadPool.this"), // annoying consequence of reverting #2473 - ProblemFilters.exclude[AbstractClassProblem]("cats.effect.ExitCode") + ProblemFilters.exclude[AbstractClassProblem]("cats.effect.ExitCode"), + // #3934 which made these internal vals into proper static fields + ProblemFilters.exclude[DirectMissingMethodProblem]( + "cats.effect.unsafe.IORuntime.allRuntimes"), + ProblemFilters.exclude[DirectMissingMethodProblem]( + "cats.effect.unsafe.IORuntime.globalFatalFailureHandled") ) } else Seq() } @@ -806,7 +824,10 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) ProblemFilters.exclude[MissingClassProblem]( "cats.effect.unsafe.BatchingMacrotaskExecutor$executeBatchTaskRunnable$"), // #3943, refactored internal private CallbackStack data structure - ProblemFilters.exclude[Problem]("cats.effect.CallbackStackOps.*") + ProblemFilters.exclude[Problem]("cats.effect.CallbackStackOps.*"), + // introduced by #3695, which ported fiber monitoring to Native + // internal API change + ProblemFilters.exclude[MissingClassProblem]("cats.effect.unsafe.ES2021FiberMonitor") ) }, mimaBinaryIssueFilters ++= { @@ -836,7 +857,14 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) } else Seq() } ) - .disablePlugins(JCStressPlugin) + .nativeSettings( + mimaBinaryIssueFilters ++= Seq( + ProblemFilters.exclude[MissingClassProblem]( + "cats.effect.unsafe.PollingExecutorScheduler$SleepTask"), + ProblemFilters.exclude[MissingClassProblem]("cats.effect.unsafe.QueueExecutorScheduler"), + ProblemFilters.exclude[MissingClassProblem]("cats.effect.unsafe.QueueExecutorScheduler$") + ) + ) /** * Test support for the core project, providing various helpful instances like ScalaCheck @@ -852,7 +880,6 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform, NativePlatform) "org.specs2" %%% "specs2-core" % Specs2Version % Test ) ) - .disablePlugins(JCStressPlugin) /** * Unit tests for the core project, utilizing the support provided by testkit. @@ -860,7 +887,7 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform, NativePlatform) lazy val tests: CrossProject = crossProject(JSPlatform, JVMPlatform, NativePlatform) .in(file("tests")) .dependsOn(core, laws % Test, kernelTestkit % Test, testkit % Test) - .enablePlugins(BuildInfoPlugin, NoPublishPlugin) + .enablePlugins(NoPublishPlugin) .settings( name := "cats-effect-tests", libraryDependencies ++= Seq( @@ -869,7 +896,6 @@ lazy val tests: CrossProject = crossProject(JSPlatform, JVMPlatform, NativePlatf "org.typelevel" %%% "discipline-specs2" % DisciplineVersion % Test, "org.typelevel" %%% "cats-kernel-laws" % CatsVersion % Test ), - buildInfoPackage := "catseffect", githubWorkflowArtifactUpload := false ) .jsSettings( @@ -879,28 +905,52 @@ lazy val tests: CrossProject = crossProject(JSPlatform, JVMPlatform, NativePlatf scalacOptions ~= { _.filterNot(_.startsWith("-P:scalajs:mapSourceURI")) } ) .jvmSettings( - Test / fork := true, - Test / javaOptions += s"-Dsbt.classpath=${(Test / fullClasspath).value.map(_.data.getAbsolutePath).mkString(File.pathSeparator)}" - // Test / javaOptions += "-XX:ActiveProcessorCount=2", + fork := true ) - -lazy val testsJS = tests.js -lazy val testsJVM = tests - .jvm - .enablePlugins(BuildInfoPlugin) - .settings( - Test / compile := { - if (testJSIOApp.value) - (Test / compile).dependsOn(testsJS / Compile / fastOptJS).value - else - (Test / compile).value - }, - buildInfoPackage := "cats.effect", - buildInfoKeys += testJSIOApp, - buildInfoKeys += - "jsRunner" -> (testsJS / Compile / fastOptJS / artifactPath).value + .nativeSettings( + Compile / mainClass := Some("catseffect.examples.NativeRunner") ) +def configureIOAppTests(p: Project): Project = + p.enablePlugins(NoPublishPlugin, BuildInfoPlugin) + .settings( + Test / unmanagedSourceDirectories += (LocalRootProject / baseDirectory).value / "ioapp-tests" / "src" / "test" / "scala", + libraryDependencies += "org.specs2" %%% "specs2-core" % Specs2Version % Test, + buildInfoPackage := "cats.effect", + buildInfoKeys ++= Seq( + "jsRunner" -> (tests.js / Compile / fastOptJS / artifactPath).value, + "nativeRunner" -> (tests.native / Compile / nativeLink / artifactPath).value + ) + ) + +lazy val ioAppTestsJVM = + project + .in(file("ioapp-tests/.jvm")) + .configure(configureIOAppTests) + .settings( + buildInfoKeys += "platform" -> "jvm", + Test / fork := true, + Test / javaOptions += s"-Dcatseffect.examples.classpath=${(tests.jvm / Compile / fullClasspath).value.map(_.data.getAbsolutePath).mkString(File.pathSeparator)}" + ) + +lazy val ioAppTestsJS = + project + .in(file("ioapp-tests/.js")) + .configure(configureIOAppTests) + .settings( + (Test / test) := (Test / test).dependsOn(tests.js / Compile / fastOptJS).value, + buildInfoKeys += "platform" -> "js" + ) + +lazy val ioAppTestsNative = + project + .in(file("ioapp-tests/.native")) + .configure(configureIOAppTests) + .settings( + (Test / test) := (Test / test).dependsOn(tests.native / Compile / nativeLink).value, + buildInfoKeys += "platform" -> "native" + ) + /** * Implementations of standard functionality (e.g. Semaphore, Console, Queue) purely in terms of * the typeclasses, with no dependency on IO. In most cases, the *tests* for these @@ -913,7 +963,6 @@ lazy val std = crossProject(JSPlatform, JVMPlatform, NativePlatform) .settings( name := "cats-effect-std", libraryDependencies ++= Seq( - "org.typelevel" %% "scalac-compat-annotation" % ScalacCompatVersion % CompileTime, "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion % Test, "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test ), @@ -1002,7 +1051,6 @@ lazy val std = crossProject(JSPlatform, JVMPlatform, NativePlatform) ProblemFilters.exclude[MissingClassProblem]("cats.effect.std.JavaSecureRandom$") ) ) - .disablePlugins(JCStressPlugin) /** * A trivial pair of trivial example apps primarily used to show that IOApp works as a practical @@ -1036,18 +1084,14 @@ lazy val benchmarks = project .dependsOn(core.jvm, std.jvm) .settings( name := "cats-effect-benchmarks", + fork := true, javaOptions ++= Seq( "-Dcats.effect.tracing.mode=none", "-Dcats.effect.tracing.exceptions.enhanced=false")) .enablePlugins(NoPublishPlugin, JmhPlugin) -lazy val stressTests = project - .in(file("stress-tests")) - .dependsOn(core.jvm, std.jvm) - .settings( - name := "cats-effect-stress-tests", - Jcstress / version := "0.16" - ) - .enablePlugins(NoPublishPlugin, JCStressPlugin) - -lazy val docs = project.in(file("site-docs")).dependsOn(core.jvm).enablePlugins(MdocPlugin) +lazy val docs = project + .in(file("site-docs")) + .dependsOn(core.jvm) + .enablePlugins(MdocPlugin) + .settings(tlFatalWarnings := { if (tlIsScala3.value) false else tlFatalWarnings.value }) diff --git a/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala b/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala index f3bea1e51c..c12106b1dd 100644 --- a/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala +++ b/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala @@ -16,6 +16,8 @@ package cats.effect +import org.typelevel.scalaccompat.annotation._ + // defined in Java for the JVM, Scala for ScalaJS (where object field access is faster) private object IOFiberConstants { @@ -43,4 +45,7 @@ private object IOFiberConstants { final val CedeR = 6 final val AutoCedeR = 7 final val DoneR = 8 + + @nowarn212 + @inline def isVirtualThread(t: Thread): Boolean = false } diff --git a/core/native/src/main/scala/cats/effect/unsafe/FiberMonitorCompanionPlatform.scala b/core/js-native/src/main/scala/cats/effect/unsafe/FiberExecutor.scala similarity index 72% rename from core/native/src/main/scala/cats/effect/unsafe/FiberMonitorCompanionPlatform.scala rename to core/js-native/src/main/scala/cats/effect/unsafe/FiberExecutor.scala index 2b689bb3cb..3328ec352e 100644 --- a/core/native/src/main/scala/cats/effect/unsafe/FiberMonitorCompanionPlatform.scala +++ b/core/js-native/src/main/scala/cats/effect/unsafe/FiberExecutor.scala @@ -14,13 +14,12 @@ * limitations under the License. */ -package cats.effect.unsafe +package cats.effect +package unsafe -import scala.concurrent.ExecutionContext - -private[unsafe] trait FiberMonitorCompanionPlatform { - def apply(compute: ExecutionContext): FiberMonitor = { - val _ = compute - new NoOpFiberMonitor - } +/** + * An introspectable executor that runs fibers. Useful for fiber dumps. + */ +private[unsafe] trait FiberExecutor { + def liveTraces(): Map[IOFiber[_], Trace] } diff --git a/core/js/src/main/scala/cats/effect/unsafe/FiberMonitor.scala b/core/js-native/src/main/scala/cats/effect/unsafe/FiberMonitor.scala similarity index 68% rename from core/js/src/main/scala/cats/effect/unsafe/FiberMonitor.scala rename to core/js-native/src/main/scala/cats/effect/unsafe/FiberMonitor.scala index 1a5867ff6f..70a1fe5257 100644 --- a/core/js/src/main/scala/cats/effect/unsafe/FiberMonitor.scala +++ b/core/js-native/src/main/scala/cats/effect/unsafe/FiberMonitor.scala @@ -17,10 +17,6 @@ package cats.effect package unsafe -import scala.collection.mutable -import scala.concurrent.ExecutionContext -import scala.scalajs.{js, LinkingInfo} - private[effect] sealed abstract class FiberMonitor extends FiberMonitorShared { /** @@ -43,24 +39,21 @@ private[effect] sealed abstract class FiberMonitor extends FiberMonitorShared { def liveFiberSnapshot(print: String => Unit): Unit } -/** - * Relies on features *standardized* in ES2021, although already offered in many environments - */ -private final class ES2021FiberMonitor( +private final class FiberMonitorImpl( // A reference to the compute pool of the `IORuntime` in which this suspended fiber bag - // operates. `null` if the compute pool of the `IORuntime` is not a `BatchingMacrotaskExecutor`. - private[this] val compute: BatchingMacrotaskExecutor + // operates. `null` if the compute pool of the `IORuntime` is not a `FiberExecutor`. + private[this] val compute: FiberExecutor ) extends FiberMonitor { private[this] val bag = new WeakBag[IOFiber[_]]() override def monitorSuspended(fiber: IOFiber[_]): WeakBag.Handle = bag.insert(fiber) - def foreignTraces(): Map[IOFiber[_], Trace] = { - val foreign = mutable.Map.empty[IOFiber[Any], Trace] + private[this] def foreignTraces(): Map[IOFiber[_], Trace] = { + val foreign = Map.newBuilder[IOFiber[_], Trace] bag.forEach(fiber => if (!fiber.isDone) foreign += (fiber.asInstanceOf[IOFiber[Any]] -> fiber.captureTrace())) - foreign.toMap + foreign.result() } def liveFiberSnapshot(print: String => Unit): Unit = @@ -70,7 +63,7 @@ private final class ES2021FiberMonitor( // We trust the sources of data in the following order, ordered from // most trustworthy to least trustworthy. - // 1. Fibers from the macrotask executor + // 1. Fibers from the fiber executor // 2. Fibers from the foreign fallback weak GC map val allForeign = rawForeign -- queued.keys @@ -92,33 +85,11 @@ private final class ES2021FiberMonitor( /** * A no-op implementation of an unordered bag used for tracking asynchronously suspended fiber - * instances on Scala.js. This is used as a fallback. + * instances on Scala Native. This is used as a fallback. */ private final class NoOpFiberMonitor extends FiberMonitor { override def monitorSuspended(fiber: IOFiber[_]): WeakBag.Handle = () => () def liveFiberSnapshot(print: String => Unit): Unit = () } -private[effect] object FiberMonitor { - def apply(compute: ExecutionContext): FiberMonitor = { - if (LinkingInfo.developmentMode && weakRefsAvailable) { - if (compute.isInstanceOf[BatchingMacrotaskExecutor]) { - val bmec = compute.asInstanceOf[BatchingMacrotaskExecutor] - new ES2021FiberMonitor(bmec) - } else { - new ES2021FiberMonitor(null) - } - } else { - new NoOpFiberMonitor() - } - } - - private[this] final val Undefined = "undefined" - - /** - * Feature-tests for all the required, well, features :) - */ - private[unsafe] def weakRefsAvailable: Boolean = - js.typeOf(js.Dynamic.global.WeakRef) != Undefined && - js.typeOf(js.Dynamic.global.FinalizationRegistry) != Undefined -} +private[effect] object FiberMonitor extends FiberMonitorPlatform diff --git a/core/js-native/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala b/core/js-native/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala index 638089eb6f..acbe94c077 100644 --- a/core/js-native/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala +++ b/core/js-native/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala @@ -23,7 +23,7 @@ import scala.concurrent.duration.FiniteDuration // Can you imagine a thread pool on JS? Have fun trying to extend or instantiate // this class. Unfortunately, due to the explicit branching, this type leaks // into the shared source code of IOFiber.scala. -private[effect] sealed abstract class WorkStealingThreadPool private () +private[effect] sealed abstract class WorkStealingThreadPool[P] private () extends ExecutionContext { def execute(runnable: Runnable): Unit def reportFailure(cause: Throwable): Unit @@ -39,12 +39,12 @@ private[effect] sealed abstract class WorkStealingThreadPool private () private[effect] def prepareForBlocking(): Unit private[unsafe] def liveTraces(): ( Map[Runnable, Trace], - Map[WorkerThread, (Thread.State, Option[(Runnable, Trace)], Map[Runnable, Trace])], + Map[WorkerThread[P], (Thread.State, Option[(Runnable, Trace)], Map[Runnable, Trace])], Map[Runnable, Trace]) } -private[unsafe] sealed abstract class WorkerThread private () extends Thread { - private[unsafe] def isOwnedBy(threadPool: WorkStealingThreadPool): Boolean +private[unsafe] sealed abstract class WorkerThread[P] private () extends Thread { + private[unsafe] def isOwnedBy(threadPool: WorkStealingThreadPool[_]): Boolean private[unsafe] def monitor(fiber: Runnable): WeakBag.Handle private[unsafe] def index: Int } diff --git a/core/js/src/main/scala/cats/effect/CallbackStack.scala b/core/js/src/main/scala/cats/effect/CallbackStack.scala index 96c58b5aee..a75d2db723 100644 --- a/core/js/src/main/scala/cats/effect/CallbackStack.scala +++ b/core/js/src/main/scala/cats/effect/CallbackStack.scala @@ -66,7 +66,7 @@ private final class CallbackStackOps[A](private val callbacks: js.Array[A => Uni } private object CallbackStack { - @inline def apply[A](cb: A => Unit): CallbackStack[A] = + @inline def of[A](cb: A => Unit): CallbackStack[A] = js.Array(cb).asInstanceOf[CallbackStack[A]] @inline implicit def ops[A](stack: CallbackStack[A]): CallbackStackOps[A] = diff --git a/core/js/src/main/scala/cats/effect/IOPlatform.scala b/core/js/src/main/scala/cats/effect/IOPlatform.scala index e91ada45f3..e3c114ab37 100644 --- a/core/js/src/main/scala/cats/effect/IOPlatform.scala +++ b/core/js/src/main/scala/cats/effect/IOPlatform.scala @@ -17,7 +17,7 @@ package cats.effect import scala.concurrent.Future -import scala.scalajs.js.{|, Function1, JavaScriptException, Promise, Thenable} +import scala.scalajs.js abstract private[effect] class IOPlatform[+A] { self: IO[A] => @@ -31,10 +31,10 @@ abstract private[effect] class IOPlatform[+A] { self: IO[A] => * @see * [[IO.fromPromise]] */ - def unsafeToPromise()(implicit runtime: unsafe.IORuntime): Promise[A] = - new Promise[A]((resolve: Function1[A | Thenable[A], _], reject: Function1[Any, _]) => + def unsafeToPromise()(implicit runtime: unsafe.IORuntime): js.Promise[A] = + new js.Promise[A]((resolve, reject) => self.unsafeRunAsync { - case Left(JavaScriptException(e)) => + case Left(js.JavaScriptException(e)) => reject(e) () @@ -76,10 +76,10 @@ abstract private[effect] class IOPlatform[+A] { self: IO[A] => * @see * [[IO.syncStep(limit:Int)*]] */ - def unsafeRunSyncToPromise()(implicit runtime: unsafe.IORuntime): Promise[A] = + def unsafeRunSyncToPromise()(implicit runtime: unsafe.IORuntime): js.Promise[A] = self.syncStep(runtime.config.autoYieldThreshold).attempt.unsafeRunSync() match { - case Left(t) => Promise.reject(t) + case Left(t) => js.Promise.reject(t) case Right(Left(ioa)) => ioa.unsafeToPromise() - case Right(Right(a)) => Promise.resolve[A](a) + case Right(Right(a)) => js.Promise.resolve[A](a) } } diff --git a/core/js/src/main/scala/cats/effect/Platform.scala b/core/js/src/main/scala/cats/effect/Platform.scala index 6b4a8e23ab..4693a3b706 100644 --- a/core/js/src/main/scala/cats/effect/Platform.scala +++ b/core/js/src/main/scala/cats/effect/Platform.scala @@ -20,4 +20,6 @@ private object Platform { final val isJs = true final val isJvm = false final val isNative = false + + class static extends scala.annotation.Annotation } diff --git a/core/js/src/main/scala/cats/effect/process.scala b/core/js/src/main/scala/cats/effect/process.scala index efc9994802..4616d05c57 100644 --- a/core/js/src/main/scala/cats/effect/process.scala +++ b/core/js/src/main/scala/cats/effect/process.scala @@ -18,7 +18,6 @@ package cats.effect import cats.data.OptionT import cats.effect.std.Env -import cats.syntax.all._ import scala.scalajs.js import scala.util.Try diff --git a/core/js/src/main/scala/cats/effect/syntax/DispatcherSyntax.scala b/core/js/src/main/scala/cats/effect/syntax/DispatcherSyntax.scala index 339ee0e169..a7dcc14f38 100644 --- a/core/js/src/main/scala/cats/effect/syntax/DispatcherSyntax.scala +++ b/core/js/src/main/scala/cats/effect/syntax/DispatcherSyntax.scala @@ -45,7 +45,7 @@ final class DispatcherOps[F[_]] private[syntax] (private[syntax] val wrapped: Di def unsafeRunSyncToFuture[A](fa: F[A], syncLimit: Int)(implicit F: Async[F]): Future[A] = F.syncStep[SyncIO, A](fa, syncLimit).attempt.unsafeRunSync() match { case Left(t) => Future.failed(t) - case Right(Left(fa)) => wrapped.unsafeToFuture(fa) + case Right(Left(fa1)) => wrapped.unsafeToFuture(fa1) case Right(Right(a)) => Future.successful(a) } @@ -63,7 +63,7 @@ final class DispatcherOps[F[_]] private[syntax] (private[syntax] val wrapped: Di def unsafeRunSyncToPromise[A](fa: F[A], syncLimit: Int)(implicit F: Async[F]): Promise[A] = F.syncStep[SyncIO, A](fa, syncLimit).attempt.unsafeRunSync() match { case Left(t) => Promise.reject(t) - case Right(Left(fa)) => wrapped.unsafeToPromise(fa) + case Right(Left(fa1)) => wrapped.unsafeToPromise(fa1) case Right(Right(a)) => Promise.resolve[A](a) } diff --git a/core/js/src/main/scala/cats/effect/unsafe/BatchingMacrotaskExecutor.scala b/core/js/src/main/scala/cats/effect/unsafe/BatchingMacrotaskExecutor.scala index 22608fe3e3..e9a8aa7e30 100644 --- a/core/js/src/main/scala/cats/effect/unsafe/BatchingMacrotaskExecutor.scala +++ b/core/js/src/main/scala/cats/effect/unsafe/BatchingMacrotaskExecutor.scala @@ -40,7 +40,8 @@ import scala.util.control.NonFatal private[effect] final class BatchingMacrotaskExecutor( batchSize: Int, reportFailure0: Throwable => Unit -) extends ExecutionContextExecutor { +) extends ExecutionContextExecutor + with FiberExecutor { private[this] val queueMicrotask: js.Function1[js.Function0[Any], Any] = if (js.typeOf(js.Dynamic.global.queueMicrotask) == "function") diff --git a/core/js/src/main/scala/cats/effect/unsafe/FiberMonitorPlatform.scala b/core/js/src/main/scala/cats/effect/unsafe/FiberMonitorPlatform.scala new file mode 100644 index 0000000000..a9cce1cb31 --- /dev/null +++ b/core/js/src/main/scala/cats/effect/unsafe/FiberMonitorPlatform.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +import scala.concurrent.ExecutionContext +import scala.scalajs.{js, LinkingInfo} + +private[effect] abstract class FiberMonitorPlatform { + def apply(compute: ExecutionContext): FiberMonitor = { + if (LinkingInfo.developmentMode && weakRefsAvailable) { + if (compute.isInstanceOf[BatchingMacrotaskExecutor]) { + val bmec = compute.asInstanceOf[BatchingMacrotaskExecutor] + new FiberMonitorImpl(bmec) + } else { + new FiberMonitorImpl(null) + } + } else { + new NoOpFiberMonitor() + } + } + + private[this] final val Undefined = "undefined" + + /** + * Feature-tests for all the required, well, features :) + */ + private[unsafe] def weakRefsAvailable: Boolean = + js.typeOf(js.Dynamic.global.WeakRef) != Undefined && + js.typeOf(js.Dynamic.global.FinalizationRegistry) != Undefined +} diff --git a/core/js/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala b/core/js/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala index 44b12649d0..4b6ea179d2 100644 --- a/core/js/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala +++ b/core/js/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala @@ -55,6 +55,7 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type () => resetGlobal(), IORuntimeConfig()) } + () } _global diff --git a/core/jvm-native/src/main/scala/cats/effect/ArrayStack.scala b/core/jvm-native/src/main/scala/cats/effect/ArrayStack.scala index 2331ff697f..ae5d5f7e25 100644 --- a/core/jvm-native/src/main/scala/cats/effect/ArrayStack.scala +++ b/core/jvm-native/src/main/scala/cats/effect/ArrayStack.scala @@ -16,6 +16,8 @@ package cats.effect +import Platform.static + private final class ArrayStack[A <: AnyRef]( private[this] var buffer: Array[AnyRef], private[this] var index: Int) { @@ -78,8 +80,8 @@ private final class ArrayStack[A <: AnyRef]( private object ArrayStack { - def apply[A <: AnyRef](): ArrayStack[A] = new ArrayStack() + @static def apply[A <: AnyRef](): ArrayStack[A] = new ArrayStack() - def apply[A <: AnyRef](size: Int): ArrayStack[A] = new ArrayStack(size) + @static def apply[A <: AnyRef](size: Int): ArrayStack[A] = new ArrayStack(size) } diff --git a/core/jvm-native/src/main/scala/cats/effect/ByteStack.scala b/core/jvm-native/src/main/scala/cats/effect/ByteStack.scala index 036a95be77..cfbb70cb39 100644 --- a/core/jvm-native/src/main/scala/cats/effect/ByteStack.scala +++ b/core/jvm-native/src/main/scala/cats/effect/ByteStack.scala @@ -16,11 +16,17 @@ package cats.effect +import Platform.static + +private[effect] final class ByteStack + private object ByteStack { type T = Array[Int] - final def toDebugString(stack: Array[Int], translate: Byte => String = _.toString): String = { + @static final def toDebugString( + stack: Array[Int], + translate: Byte => String = _.toString): String = { val count = size(stack) ((count - 1) to 0 by -1) .foldLeft( @@ -38,10 +44,10 @@ private object ByteStack { .toString } - final def create(initialMaxOps: Int): Array[Int] = + @static final def create(initialMaxOps: Int): Array[Int] = new Array[Int](1 + 1 + ((initialMaxOps - 1) >> 3)) // count-slot + 1 for each set of 8 ops - final def growIfNeeded(stack: Array[Int], count: Int): Array[Int] = { + @static final def growIfNeeded(stack: Array[Int], count: Int): Array[Int] = { if ((1 + ((count + 1) >> 3)) < stack.length) { stack } else { @@ -51,7 +57,7 @@ private object ByteStack { } } - final def push(stack: Array[Int], op: Byte): Array[Int] = { + @static final def push(stack: Array[Int], op: Byte): Array[Int] = { val c = stack(0) // current count of elements val use = growIfNeeded(stack, c) // alias so we add to the right place val s = (c >> 3) + 1 // current slot in `use` @@ -61,24 +67,24 @@ private object ByteStack { use } - final def size(stack: Array[Int]): Int = + @static final def size(stack: Array[Int]): Int = stack(0) - final def isEmpty(stack: Array[Int]): Boolean = + @static final def isEmpty(stack: Array[Int]): Boolean = stack(0) < 1 - final def read(stack: Array[Int], pos: Int): Byte = { + @static final def read(stack: Array[Int], pos: Int): Byte = { if (pos < 0 || pos >= stack(0)) throw new ArrayIndexOutOfBoundsException() ((stack((pos >> 3) + 1) >>> ((pos & 7) << 2)) & 0x0000000f).toByte } - final def peek(stack: Array[Int]): Byte = { + @static final def peek(stack: Array[Int]): Byte = { val c = stack(0) - 1 if (c < 0) throw new ArrayIndexOutOfBoundsException() ((stack((c >> 3) + 1) >>> ((c & 7) << 2)) & 0x0000000f).toByte } - final def pop(stack: Array[Int]): Byte = { + @static final def pop(stack: Array[Int]): Byte = { val op = peek(stack) stack(0) -= 1 op diff --git a/core/jvm-native/src/main/scala/cats/effect/CallbackStack.scala b/core/jvm-native/src/main/scala/cats/effect/CallbackStack.scala index b61099b30d..bad66991eb 100644 --- a/core/jvm-native/src/main/scala/cats/effect/CallbackStack.scala +++ b/core/jvm-native/src/main/scala/cats/effect/CallbackStack.scala @@ -22,6 +22,7 @@ import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import CallbackStack.Handle import CallbackStack.Node +import Platform.static private final class CallbackStack[A](private[this] var callback: A => Unit) extends AtomicReference[Node[A]] { @@ -155,8 +156,8 @@ private final class CallbackStack[A](private[this] var callback: A => Unit) } private object CallbackStack { - def apply[A](callback: A => Unit): CallbackStack[A] = - new CallbackStack(callback) + @static def of[A](cb: A => Unit): CallbackStack[A] = + new CallbackStack(cb) sealed abstract class Handle[A] { private[CallbackStack] def clear(): Unit diff --git a/core/jvm-native/src/main/scala/cats/effect/unsafe/PollingSystem.scala b/core/jvm-native/src/main/scala/cats/effect/unsafe/PollingSystem.scala new file mode 100644 index 0000000000..cc36ef2b8d --- /dev/null +++ b/core/jvm-native/src/main/scala/cats/effect/unsafe/PollingSystem.scala @@ -0,0 +1,123 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +/** + * Represents a stateful system for managing and interacting with a polling system. Polling + * systems are typically used in scenarios such as handling multiplexed blocking I/O or other + * event-driven systems, where one needs to repeatedly check (or "poll") some condition or + * state, blocking up to some timeout until it is ready. + * + * This class abstracts the general components and actions of a polling system, such as: + * - The user-facing interface (API) which interacts with the outside world + * - The thread-local data structure used for polling, which keeps track of the internal state + * of the system and its events + * - The lifecycle management methods, such as creating and closing the polling system and its + * components + * - The runtime interaction methods, such as polling events and interrupting the process + */ +abstract class PollingSystem { + + /** + * The user-facing interface. + */ + type Api <: AnyRef + + /** + * The thread-local data structure used for polling. + */ + type Poller <: AnyRef + + /** + * Closes the polling system. + */ + def close(): Unit + + /** + * Creates a new instance of the user-facing interface. + * + * @param access + * callback to obtain a thread-local `Poller`. + * @return + * an instance of the user-facing interface `Api`. + */ + def makeApi(access: (Poller => Unit) => Unit): Api + + /** + * Creates a new instance of the thread-local data structure used for polling. + * + * @return + * an instance of the poller `Poller`. + */ + def makePoller(): Poller + + /** + * Closes a specific poller. + * + * @param poller + * the poller to be closed. + */ + def closePoller(poller: Poller): Unit + + /** + * @param poller + * the thread-local [[Poller]] used to poll events. + * + * @param nanos + * the maximum duration for which to block, where `nanos == -1` indicates to block + * indefinitely. + * + * @param reportFailure + * callback that handles any failures that occur during polling. + * + * @return + * whether any events were polled. e.g. if the method returned due to timeout, this should + * be `false`. + */ + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean + + /** + * @return + * whether poll should be called again (i.e., there are more events to be polled) + */ + def needsPoll(poller: Poller): Boolean + + /** + * Interrupts a specific target poller running on a specific target thread. + * + * @param targetThread + * is the thread where the target poller is running. + * @param targetPoller + * is the poller to be interrupted. + */ + def interrupt(targetThread: Thread, targetPoller: Poller): Unit + +} + +private object PollingSystem { + + /** + * Type alias for a `PollingSystem` that has a specified `Poller` type. + * + * @tparam P + * The type of the `Poller` in the `PollingSystem`. + */ + type WithPoller[P] = PollingSystem { + type Poller = P + } +} diff --git a/core/jvm/src/main/java/cats/effect/IOFiberConstants.java b/core/jvm/src/main/java/cats/effect/IOFiberConstants.java index 5fa5950b17..92a7c861a5 100644 --- a/core/jvm/src/main/java/cats/effect/IOFiberConstants.java +++ b/core/jvm/src/main/java/cats/effect/IOFiberConstants.java @@ -16,6 +16,10 @@ package cats.effect; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + // defined in Java since Scala doesn't let us define static fields final class IOFiberConstants { @@ -43,4 +47,28 @@ final class IOFiberConstants { static final byte CedeR = 6; static final byte AutoCedeR = 7; static final byte DoneR = 8; + + static boolean isVirtualThread(final Thread thread) { + try { + return (boolean) THREAD_IS_VIRTUAL_HANDLE.invokeExact(thread); + } catch (Throwable t) { + return false; + } + } + + private static final MethodHandle THREAD_IS_VIRTUAL_HANDLE; + + static { + final MethodHandles.Lookup lookup = MethodHandles.publicLookup(); + final MethodType mt = MethodType.methodType(boolean.class); + MethodHandle mh; + try { + mh = lookup.findVirtual(Thread.class, "isVirtual", mt); + } catch (Throwable t) { + mh = + MethodHandles.dropArguments( + MethodHandles.constant(boolean.class, false), 0, Thread.class); + } + THREAD_IS_VIRTUAL_HANDLE = mh; + } } diff --git a/core/jvm/src/main/java/cats/effect/unsafe/TimerSkipListNodeBase.java b/core/jvm/src/main/java/cats/effect/unsafe/TimerSkipListNodeBase.java deleted file mode 100644 index 026f923467..0000000000 --- a/core/jvm/src/main/java/cats/effect/unsafe/TimerSkipListNodeBase.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe; - -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; - -/** - * Base class for `TimerSkipList#Node`, because we can't use `AtomicReferenceFieldUpdater` from - * Scala. - */ -@SuppressWarnings("serial") // do not serialize this! -abstract class TimerSkipListNodeBase> - extends AtomicReference { - - private volatile C callback; - - @SuppressWarnings("rawtypes") - private static final AtomicReferenceFieldUpdater CALLBACK = - AtomicReferenceFieldUpdater.newUpdater(TimerSkipListNodeBase.class, Object.class, "callback"); - - protected TimerSkipListNodeBase(C cb, N next) { - super(next); - this.callback = cb; - } - - public final N getNext() { - return this.get(); // could be `getAcquire` - } - - public final boolean casNext(N ov, N nv) { - return this.compareAndSet(ov, nv); - } - - public final C getCb() { - return this.callback; // could be `getAcquire` - } - - public final boolean casCb(C ov, C nv) { - return CALLBACK.compareAndSet(this, ov, nv); - } -} diff --git a/core/jvm/src/main/scala/cats/effect/IOApp.scala b/core/jvm/src/main/scala/cats/effect/IOApp.scala index fa30752ee1..7b4e4bab59 100644 --- a/core/jvm/src/main/scala/cats/effect/IOApp.scala +++ b/core/jvm/src/main/scala/cats/effect/IOApp.scala @@ -165,6 +165,9 @@ trait IOApp { */ protected def runtimeConfig: unsafe.IORuntimeConfig = unsafe.IORuntimeConfig() + protected def pollingSystem: unsafe.PollingSystem = + unsafe.IORuntime.createDefaultPollingSystem() + /** * Controls the number of worker threads which will be allocated to the compute pool in the * underlying runtime. In general, this should be no ''greater'' than the number of physical @@ -334,12 +337,13 @@ trait IOApp { * isn't the main process thread. This condition can happen when we are running inside of an * `sbt run` with `fork := false` */ + def warnOnNonMainThreadDetected: Boolean = + Option(System.getProperty("cats.effect.warnOnNonMainThreadDetected")) + .map(_.equalsIgnoreCase("true")) + .getOrElse(true) + private def onNonMainThreadDetected(): Unit = { - val shouldPrint = - Option(System.getProperty("cats.effect.warnOnNonMainThreadDetected")) - .map(_.equalsIgnoreCase("true")) - .getOrElse(true) - if (shouldPrint) + if (warnOnNonMainThreadDetected) System .err .println( @@ -380,11 +384,12 @@ trait IOApp { import unsafe.IORuntime val installed = IORuntime installGlobal { - val (compute, compDown) = + val (compute, poller, compDown) = IORuntime.createWorkStealingComputeThreadPool( threads = computeWorkerThreadCount, reportFailure = t => reportFailure(t).unsafeRunAndForgetWithoutCallback()(runtime), - blockedThreadDetectionEnabled = blockedThreadDetectionEnabled + blockedThreadDetectionEnabled = blockedThreadDetectionEnabled, + pollingSystem = pollingSystem ) val (blocking, blockDown) = @@ -398,6 +403,7 @@ trait IOApp { compute, blocking, compute, + List(poller), { () => compDown() blockDown() @@ -475,6 +481,8 @@ trait IOApp { if (isStackTracing) runtime.fiberMonitor.monitorSuspended(fiber) + else + () def handleShutdown(): Unit = { if (counter.compareAndSet(1, 0)) { diff --git a/core/jvm/src/main/scala/cats/effect/IOCompanionPlatform.scala b/core/jvm/src/main/scala/cats/effect/IOCompanionPlatform.scala index 14095482b6..01c7a46467 100644 --- a/core/jvm/src/main/scala/cats/effect/IOCompanionPlatform.scala +++ b/core/jvm/src/main/scala/cats/effect/IOCompanionPlatform.scala @@ -141,4 +141,5 @@ private[effect] abstract class IOCompanionPlatform { this: IO.type => */ def readLine: IO[String] = Console[IO].readLine + } diff --git a/core/jvm/src/main/scala/cats/effect/Platform.scala b/core/jvm/src/main/scala/cats/effect/Platform.scala index 01263078a3..38540a15b0 100644 --- a/core/jvm/src/main/scala/cats/effect/Platform.scala +++ b/core/jvm/src/main/scala/cats/effect/Platform.scala @@ -20,4 +20,6 @@ private object Platform { final val isJs = false final val isJvm = true final val isNative = false + + type static = org.typelevel.scalaccompat.annotation.static3 } diff --git a/core/jvm/src/main/scala/cats/effect/Selector.scala b/core/jvm/src/main/scala/cats/effect/Selector.scala new file mode 100644 index 0000000000..da39e74d90 --- /dev/null +++ b/core/jvm/src/main/scala/cats/effect/Selector.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +import java.nio.channels.SelectableChannel +import java.nio.channels.spi.SelectorProvider + +trait Selector { + + /** + * The [[java.nio.channels.spi.SelectorProvider]] that should be used to create + * [[java.nio.channels.SelectableChannel]]s that are compatible with this polling system. + */ + def provider: SelectorProvider + + /** + * Fiber-block until a [[java.nio.channels.SelectableChannel]] is ready on at least one of the + * designated operations. The returned value will indicate which operations are ready. + */ + def select(ch: SelectableChannel, ops: Int): IO[Int] + +} diff --git a/core/jvm-native/src/main/scala/cats/effect/unsafe/FiberMonitor.scala b/core/jvm/src/main/scala/cats/effect/unsafe/FiberMonitor.scala similarity index 93% rename from core/jvm-native/src/main/scala/cats/effect/unsafe/FiberMonitor.scala rename to core/jvm/src/main/scala/cats/effect/unsafe/FiberMonitor.scala index ee30441116..d2990b44e0 100644 --- a/core/jvm-native/src/main/scala/cats/effect/unsafe/FiberMonitor.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/FiberMonitor.scala @@ -19,6 +19,8 @@ package unsafe import cats.effect.tracing.TracingConstants +import scala.concurrent.ExecutionContext + /** * A slightly more involved implementation of an unordered bag used for tracking asynchronously * suspended fiber instances on the JVM. This bag is backed by an array of synchronized @@ -41,7 +43,7 @@ import cats.effect.tracing.TracingConstants private[effect] sealed class FiberMonitor( // A reference to the compute pool of the `IORuntime` in which this suspended fiber bag // operates. `null` if the compute pool of the `IORuntime` is not a `WorkStealingThreadPool`. - private[this] val compute: WorkStealingThreadPool + private[this] val compute: WorkStealingThreadPool[_] ) extends FiberMonitorShared { private[this] final val BagReferences = new WeakList[WeakBag[Runnable]] @@ -65,8 +67,8 @@ private[effect] sealed class FiberMonitor( */ def monitorSuspended(fiber: IOFiber[_]): WeakBag.Handle = { val thread = Thread.currentThread() - if (thread.isInstanceOf[WorkerThread]) { - val worker = thread.asInstanceOf[WorkerThread] + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[_]] // Guard against tracking errors when multiple work stealing thread pools exist. if (worker.isOwnedBy(compute)) { worker.monitor(fiber) @@ -112,14 +114,14 @@ private[effect] sealed class FiberMonitor( val externalFibers = external.collect(justFibers) val suspendedFibers = suspended.collect(justFibers) val workersMapping: Map[ - WorkerThread, + WorkerThread[_], (Thread.State, Option[(IOFiber[_], Trace)], Map[IOFiber[_], Trace])] = workers.map { case (thread, (state, opt, set)) => val filteredOpt = opt.collect(justFibers) val filteredSet = set.collect(justFibers) (thread, (state, filteredOpt, filteredSet)) - } + }.toMap (externalFibers, workersMapping, suspendedFibers) } @@ -212,4 +214,13 @@ private[effect] final class NoOpFiberMonitor extends FiberMonitor(null) { override def liveFiberSnapshot(print: String => Unit): Unit = {} } -private[effect] object FiberMonitor extends FiberMonitorCompanionPlatform +private[effect] object FiberMonitor { + def apply(compute: ExecutionContext): FiberMonitor = { + if (TracingConstants.isStackTracing && compute.isInstanceOf[WorkStealingThreadPool[_]]) { + val wstp = compute.asInstanceOf[WorkStealingThreadPool[_]] + new FiberMonitor(wstp) + } else { + new FiberMonitor(null) + } + } +} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala b/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala index d5369d4042..018c7932d9 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala @@ -18,11 +18,36 @@ package cats.effect.unsafe private[unsafe] abstract class IORuntimeBuilderPlatform { self: IORuntimeBuilder => + protected var customPollingSystem: Option[PollingSystem] = None + + /** + * Override the default [[PollingSystem]] + */ + def setPollingSystem(system: PollingSystem): IORuntimeBuilder = { + if (customPollingSystem.isDefined) { + throw new RuntimeException("Polling system can only be set once") + } + customPollingSystem = Some(system) + this + } + // TODO unify this with the defaults in IORuntime.global and IOApp protected def platformSpecificBuild: IORuntime = { - val (compute, computeShutdown) = - customCompute.getOrElse( - IORuntime.createWorkStealingComputeThreadPool(reportFailure = failureReporter)) + val (compute, poller, computeShutdown) = + customCompute + .map { + case (c, s) => + (c, Nil, s) + } + .getOrElse { + val (c, p, s) = + IORuntime.createWorkStealingComputeThreadPool( + pollingSystem = + customPollingSystem.getOrElse(IORuntime.createDefaultPollingSystem()), + reportFailure = failureReporter + ) + (c, List(p), s) + } val xformedCompute = computeTransform(compute) val (scheduler, schedulerShutdown) = xformedCompute match { @@ -36,6 +61,7 @@ private[unsafe] abstract class IORuntimeBuilderPlatform { self: IORuntimeBuilder computeShutdown() blockingShutdown() schedulerShutdown() + extraPollers.foreach(_._2()) extraShutdownHooks.reverse.foreach(_()) } val runtimeConfig = customConfig.getOrElse(IORuntimeConfig()) @@ -44,6 +70,7 @@ private[unsafe] abstract class IORuntimeBuilderPlatform { self: IORuntimeBuilder computeTransform(compute), blockingTransform(blocking), scheduler, + poller ::: extraPollers.map(_._1), shutdown, runtimeConfig ) diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala b/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala index 6260d6bdd8..a9f98d94ad 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala @@ -40,7 +40,7 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type blockerThreadPrefix: String, runtimeBlockingExpiration: Duration, reportFailure: Throwable => Unit - ): (WorkStealingThreadPool, () => Unit) = createWorkStealingComputeThreadPool( + ): (WorkStealingThreadPool[_], () => Unit) = createWorkStealingComputeThreadPool( threads, threadPrefix, blockerThreadPrefix, @@ -49,6 +49,27 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type false ) + @deprecated("Preserved for binary-compatibility", "3.6.0") + def createWorkStealingComputeThreadPool( + threads: Int, + threadPrefix: String, + blockerThreadPrefix: String, + runtimeBlockingExpiration: Duration, + reportFailure: Throwable => Unit, + blockedThreadDetectionEnabled: Boolean + ): (WorkStealingThreadPool[_], () => Unit) = { + val (pool, _, shutdown) = createWorkStealingComputeThreadPool( + threads, + threadPrefix, + blockerThreadPrefix, + runtimeBlockingExpiration, + reportFailure, + false, + 1.second, + SleepSystem + ) + (pool, shutdown) + } // The default compute thread pool on the JVM is now a work stealing thread pool. def createWorkStealingComputeThreadPool( threads: Int = Math.max(2, Runtime.getRuntime().availableProcessors()), @@ -56,15 +77,21 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type blockerThreadPrefix: String = DefaultBlockerPrefix, runtimeBlockingExpiration: Duration = 60.seconds, reportFailure: Throwable => Unit = _.printStackTrace(), - blockedThreadDetectionEnabled: Boolean = false): (WorkStealingThreadPool, () => Unit) = { + blockedThreadDetectionEnabled: Boolean = false, + shutdownTimeout: Duration = 1.second, + pollingSystem: PollingSystem = SelectorSystem()) + : (WorkStealingThreadPool[_], pollingSystem.Api, () => Unit) = { val threadPool = - new WorkStealingThreadPool( + new WorkStealingThreadPool[pollingSystem.Poller]( threads, threadPrefix, blockerThreadPrefix, runtimeBlockingExpiration, blockedThreadDetectionEnabled && (threads > 1), - reportFailure) + shutdownTimeout, + pollingSystem, + reportFailure + ) val unregisterMBeans = if (isStackTracing) { @@ -125,6 +152,7 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type ( threadPool, + pollingSystem.makeApi(threadPool.accessPoller), { () => unregisterMBeans() threadPool.shutdown() @@ -140,14 +168,21 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type threads: Int = Math.max(2, Runtime.getRuntime().availableProcessors()), threadPrefix: String = "io-compute", blockerThreadPrefix: String = DefaultBlockerPrefix) - : (WorkStealingThreadPool, () => Unit) = - createWorkStealingComputeThreadPool(threads, threadPrefix, blockerThreadPrefix) + : (WorkStealingThreadPool[_], () => Unit) = + createWorkStealingComputeThreadPool( + threads, + threadPrefix, + blockerThreadPrefix, + 60.seconds, + _.printStackTrace(), + false + ) @deprecated("bincompat shim for previous default method overload", "3.3.13") def createDefaultComputeThreadPool( self: () => IORuntime, threads: Int, - threadPrefix: String): (WorkStealingThreadPool, () => Unit) = + threadPrefix: String): (WorkStealingThreadPool[_], () => Unit) = createDefaultComputeThreadPool(self(), threads, threadPrefix) def createDefaultBlockingExecutionContext( @@ -183,6 +218,8 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type (Scheduler.fromScheduledExecutor(scheduler), { () => scheduler.shutdown() }) } + def createDefaultPollingSystem(): PollingSystem = SelectorSystem() + @volatile private[this] var _global: IORuntime = null // we don't need to synchronize this with IOApp, because we control the main thread @@ -202,7 +239,7 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type def global: IORuntime = { if (_global == null) { installGlobal { - val (compute, computeDown) = createWorkStealingComputeThreadPool() + val (compute, poller, computeDown) = createWorkStealingComputeThreadPool() val (blocking, blockingDown) = createDefaultBlockingExecutionContext() val shutdown = () => { computeDown() @@ -210,8 +247,9 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type resetGlobal() } - IORuntime(compute, blocking, compute, shutdown, IORuntimeConfig()) + IORuntime(compute, blocking, compute, List(poller), shutdown, IORuntimeConfig()) } + () } _global diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/LocalQueue.scala b/core/jvm/src/main/scala/cats/effect/unsafe/LocalQueue.scala index 12705ffd86..8b0a6b0e91 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/LocalQueue.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/LocalQueue.scala @@ -337,7 +337,7 @@ private final class LocalQueue extends LocalQueuePadding { * @return * a fiber to be executed directly */ - def enqueueBatch(batch: Array[Runnable], worker: WorkerThread): Runnable = { + def enqueueBatch(batch: Array[Runnable], worker: WorkerThread[_]): Runnable = { // A plain, unsynchronized load of the tail of the local queue. val tl = tail @@ -410,7 +410,7 @@ private final class LocalQueue extends LocalQueuePadding { * the fiber at the head of the queue, or `null` if the queue is empty (in order to avoid * unnecessary allocations) */ - def dequeue(worker: WorkerThread): Runnable = { + def dequeue(worker: WorkerThread[_]): Runnable = { // A plain, unsynchronized load of the tail of the local queue. val tl = tail @@ -487,7 +487,7 @@ private final class LocalQueue extends LocalQueuePadding { * a reference to the first fiber to be executed by the stealing [[WorkerThread]], or `null` * if the stealing was unsuccessful */ - def stealInto(dst: LocalQueue, dstWorker: WorkerThread): Runnable = { + def stealInto(dst: LocalQueue, dstWorker: WorkerThread[_]): Runnable = { // A plain, unsynchronized load of the tail of the destination queue, owned // by the executing thread. val dstTl = dst.tail diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/SelectorSystem.scala b/core/jvm/src/main/scala/cats/effect/unsafe/SelectorSystem.scala new file mode 100644 index 0000000000..bbb853b947 --- /dev/null +++ b/core/jvm/src/main/scala/cats/effect/unsafe/SelectorSystem.scala @@ -0,0 +1,169 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +import scala.util.control.NonFatal + +import java.nio.channels.SelectableChannel +import java.nio.channels.spi.{AbstractSelector, SelectorProvider} + +import SelectorSystem._ + +final class SelectorSystem private (provider: SelectorProvider) extends PollingSystem { + + type Api = Selector + + def close(): Unit = () + + def makeApi(access: (Poller => Unit) => Unit): Selector = + new SelectorImpl(access, provider) + + def makePoller(): Poller = new Poller(provider.openSelector()) + + def closePoller(poller: Poller): Unit = + poller.selector.close() + + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean = { + val millis = if (nanos >= 0) nanos / 1000000 else -1 + val selector = poller.selector + + if (millis == 0) selector.selectNow() + else if (millis > 0) selector.select(millis) + else selector.select() + + if (selector.isOpen()) { // closing selector interrupts select + var polled = false + + val ready = selector.selectedKeys().iterator() + while (ready.hasNext()) { + val key = ready.next() + ready.remove() + + var readyOps = 0 + var error: Throwable = null + try { + readyOps = key.readyOps() + // reset interest in triggered ops + key.interestOps(key.interestOps() & ~readyOps) + } catch { + case ex if NonFatal(ex) => + error = ex + readyOps = -1 // interest all waiters + } + + val value = if (error ne null) Left(error) else Right(readyOps) + + var head: CallbackNode = null + var prev: CallbackNode = null + var node = key.attachment().asInstanceOf[CallbackNode] + while (node ne null) { + val next = node.next + + if ((node.interest & readyOps) != 0) { // execute callback and drop this node + val cb = node.callback + if (cb != null) { + cb(value) + polled = true + } + if (prev ne null) prev.next = next + } else { // keep this node + prev = node + if (head eq null) + head = node + } + + node = next + } + + key.attach(head) // if key was canceled this will null attachment + () + } + + polled + } else false + } + + def needsPoll(poller: Poller): Boolean = + !poller.selector.keys().isEmpty() + + def interrupt(targetThread: Thread, targetPoller: Poller): Unit = { + targetPoller.selector.wakeup() + () + } + + final class SelectorImpl private[SelectorSystem] ( + access: (Poller => Unit) => Unit, + val provider: SelectorProvider + ) extends Selector { + + def select(ch: SelectableChannel, ops: Int): IO[Int] = IO.async { selectCb => + IO.async_[CallbackNode] { cb => + access { data => + try { + val selector = data.selector + val key = ch.keyFor(selector) + + val node = if (key eq null) { // not yet registered on this selector + val node = new CallbackNode(ops, selectCb, null) + ch.register(selector, ops, node) + node + } else { // existing key + // mixin the new interest + key.interestOps(key.interestOps() | ops) + val node = + new CallbackNode(ops, selectCb, key.attachment().asInstanceOf[CallbackNode]) + key.attach(node) + node + } + + cb(Right(node)) + } catch { case ex if NonFatal(ex) => cb(Left(ex)) } + } + }.map { node => + Some { + IO { + // set all interest bits + node.interest = -1 + // clear for gc + node.callback = null + } + } + } + } + + } + + final class Poller private[SelectorSystem] ( + private[SelectorSystem] val selector: AbstractSelector + ) + +} + +object SelectorSystem { + + def apply(provider: SelectorProvider): SelectorSystem = + new SelectorSystem(provider) + + def apply(): SelectorSystem = apply(SelectorProvider.provider()) + + private final class CallbackNode( + var interest: Int, + var callback: Either[Throwable, Int] => Unit, + var next: CallbackNode + ) +} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/SleepSystem.scala b/core/jvm/src/main/scala/cats/effect/unsafe/SleepSystem.scala new file mode 100644 index 0000000000..46ffa909e3 --- /dev/null +++ b/core/jvm/src/main/scala/cats/effect/unsafe/SleepSystem.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +import java.util.concurrent.locks.LockSupport + +object SleepSystem extends PollingSystem { + + type Api = AnyRef + type Poller = AnyRef + + def close(): Unit = () + + def makeApi(access: (Poller => Unit) => Unit): Api = this + + def makePoller(): Poller = this + + def closePoller(Poller: Poller): Unit = () + + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean = { + if (nanos < 0) + LockSupport.park() + else if (nanos > 0) + LockSupport.parkNanos(nanos) + else + () + false + } + + def needsPoll(poller: Poller): Boolean = false + + def interrupt(targetThread: Thread, targetPoller: Poller): Unit = + LockSupport.unpark(targetThread) + +} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/TimerHeap.scala b/core/jvm/src/main/scala/cats/effect/unsafe/TimerHeap.scala new file mode 100644 index 0000000000..4ae344085c --- /dev/null +++ b/core/jvm/src/main/scala/cats/effect/unsafe/TimerHeap.scala @@ -0,0 +1,488 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +/* + * Scala.js (https://www.scala-js.org/) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package cats.effect +package unsafe + +import scala.annotation.tailrec + +import java.util.Arrays +import java.util.concurrent.atomic.AtomicInteger + +/** + * A specialized heap that serves as a priority queue for timers i.e. callbacks with trigger + * times. + * + * In general, this heap is not threadsafe and modifications (insertion/removal) may only be + * performed on its owner WorkerThread. The exception is that the callback value of nodes may be + * `null`ed by other threads and published via data race. + * + * Other threads may traverse the heap with the `steal` method during which they may `null` some + * callbacks. This is entirely subject to data races. + * + * The only explicit synchronization is the `canceledCounter` atomic, which is used to track and + * publish cancelations from other threads. Because other threads cannot safely remove a node, + * they `null` the callback, toggle the `canceled` flag, and increment the counter to indicate + * that the owner thread should iterate the heap to remove these nodes a.k.a. "packing". + * + * To amortize the cost of packing, we only do so if canceled nodes make up at least half of the + * heap. In an ideal world, cancelation from external threads is relatively rare and those nodes + * are removed naturally as they surface to the top of the heap, such that we never exceed the + * packing threshold. + */ +private final class TimerHeap extends AtomicInteger { + // At most this many nodes are externally canceled and waiting to be removed from the heap. + canceledCounter => + + // And this is how many of those externally canceled nodes were already removed. + // We track this separately so we can increment on owner thread without overhead of the atomic. + private[this] var removedCanceledCounter = 0 + + // The index 0 is not used; the root is at index 1. + // This is standard practice in binary heaps, to simplify arithmetics. + private[this] var heap: Array[Node] = new Array(8) // TODO what initial value + private[this] var size: Int = 0 + + private[this] val RightUnit = Right(()) + + /** + * only called by owner thread + */ + @tailrec + def peekFirstTriggerTime(): Long = + if (size > 0) { + val root = heap(1) + + if (root.isDeleted()) { // DOA. Remove it and loop. + + removeAt(1) + if (root.isCanceled()) + removedCanceledCounter += 1 + + peekFirstTriggerTime() // loop + + } else { // We got a live one! + + val tt = root.triggerTime + if (tt != Long.MinValue) { // tt != sentinel + tt + } else { + // in the VERY unlikely case when + // the trigger time is exactly our + // sentinel, we just cheat a little + // (this could cause threads to wake + // up 1 ns too early): + Long.MaxValue + } + + } + } else { // size == 0 + Long.MinValue // sentinel + } + + /** + * for testing + */ + def peekFirstQuiescent(): Right[Nothing, Unit] => Unit = { + if (size > 0) heap(1).get() + else null + } + + /** + * only called by owner thread + */ + def pollFirstIfTriggered(now: Long): Right[Nothing, Unit] => Unit = { + val heap = this.heap // local copy + + @tailrec + def loop(): Right[Nothing, Unit] => Unit = if (size > 0) { + val root = heap(1) + val rootDeleted = root.isDeleted() + val rootExpired = !rootDeleted && isExpired(root, now) + if (rootDeleted || rootExpired) { + root.index = -1 + if (size > 1) { + heap(1) = heap(size) + fixDown(1) + } + heap(size) = null + size -= 1 + + if (root.isCanceled()) + removedCanceledCounter += 1 + + val back = root.getAndClear() + if (rootExpired && (back ne null)) back else loop() + } else null + } else null + + loop() + } + + /** + * called by other threads + */ + def steal(now: Long): Boolean = { + def go(heap: Array[Node], size: Int, m: Int): Boolean = + if (m <= size) { + val node = heap(m) + if ((node ne null) && isExpired(node, now)) { + val cb = node.getAndClear() + val invoked = cb ne null + if (invoked) cb(RightUnit) + + val leftInvoked = go(heap, size, 2 * m) + val rightInvoked = go(heap, size, 2 * m + 1) + + invoked || leftInvoked || rightInvoked + } else false + } else false + + val heap = this.heap // local copy + if (heap ne null) { + val size = Math.min(this.size, heap.length - 1) + go(heap, size, 1) + } else false + } + + /** + * only called by owner thread + */ + def insert( + now: Long, + delay: Long, + callback: Right[Nothing, Unit] => Unit, + out: Array[Right[Nothing, Unit] => Unit] + ): Function0[Unit] with Runnable = if (size > 0) { + val heap = this.heap // local copy + val triggerTime = computeTriggerTime(now, delay) + + val root = heap(1) + val rootDeleted = root.isDeleted() + val rootExpired = !rootDeleted && isExpired(root, now) + if (rootDeleted || rootExpired) { // see if we can just replace the root + root.index = -1 + if (root.isCanceled()) removedCanceledCounter += 1 + if (rootExpired) out(0) = root.getAndClear() + val node = new Node(triggerTime, callback, 1) + heap(1) = node + fixDown(1) + node + } else { // insert at the end + val heap = growIfNeeded() // new heap array if it grew + size += 1 + val node = new Node(triggerTime, callback, size) + heap(size) = node + fixUp(size) + node + } + } else { + val node = new Node(now + delay, callback, 1) + this.heap(1) = node + size += 1 + node + } + + /** + * only called by owner thread + */ + @tailrec + def packIfNeeded(): Unit = { + + val back = canceledCounter.get() + + // Account for canceled nodes that were already removed. + val canceledCount = back - removedCanceledCounter + + if (canceledCount >= size / 2) { // We have exceeded the packing threshold. + + // We will attempt to remove this many nodes. + val removeCount = // First try to use our current value but get latest if it is stale. + if (canceledCounter.compareAndSet(back, 0)) canceledCount + else canceledCounter.getAndSet(0) - removedCanceledCounter + + removedCanceledCounter = 0 // Reset, these have now been accounted for. + + // All external cancelations are now visible (published via canceledCounter). + pack(removeCount) + + } else { // canceledCounter will eventually overflow if we do not subtract removedCanceledCounter. + + if (canceledCounter.compareAndSet(back, canceledCount)) { + removedCanceledCounter = 0 // Reset, these have now been accounted for. + } else { + packIfNeeded() // canceledCounter was externally incremented, loop. + } + } + } + + private[this] def pack(removeCount: Int): Unit = { + val heap = this.heap // local copy + + // We track how many canceled nodes we removed so we can try to exit the loop early. + var i = 1 + var r = 0 + while (r < removeCount && i <= size) { + // We are careful to consider only *canceled* nodes, which increment the canceledCounter. + // A node may be deleted b/c it was stolen, but this does not increment the canceledCounter. + // To avoid leaks we must attempt to find a canceled node for every increment. + if (heap(i).isCanceled()) { + removeAt(i) + r += 1 + // Don't increment i, the new i may be canceled too. + } else { + i += 1 + } + } + } + + /** + * only called by owner thread + */ + private def removeAt(i: Int): Unit = { + val heap = this.heap // local copy + val back = heap(i) + back.getAndClear() + back.index = -1 + if (i == size) { + heap(i) = null + size -= 1 + } else { + val last = heap(size) + heap(size) = null + heap(i) = last + last.index = i + size -= 1 + fixUpOrDown(i) + } + } + + private[this] def isExpired(node: Node, now: Long): Boolean = + cmp(node.triggerTime, now) <= 0 // triggerTime <= now + + private[this] def growIfNeeded(): Array[Node] = { + val heap = this.heap // local copy + if (size >= heap.length - 1) { + val newHeap = Arrays.copyOf(heap, heap.length * 2, classOf[Array[Node]]) + this.heap = newHeap + newHeap + } else heap + } + + /** + * Fixes the heap property around the child at index `m`, either up the tree or down the tree, + * depending on which side is found to violate the heap property. + */ + private[this] def fixUpOrDown(m: Int): Unit = { + val heap = this.heap // local copy + if (m > 1 && cmp(heap(m >> 1), heap(m)) > 0) + fixUp(m) + else + fixDown(m) + } + + /** + * Fixes the heap property from the last child at index `size` up the tree, towards the root. + */ + private[this] def fixUp(m: Int): Unit = { + val heap = this.heap // local copy + + /* At each step, even though `m` changes, the element moves with it, and + * hence heap(m) is always the same initial `heapAtM`. + */ + val heapAtM = heap(m) + + @tailrec + def loop(m: Int): Unit = { + if (m > 1) { + val parent = m >> 1 + val heapAtParent = heap(parent) + if (cmp(heapAtParent, heapAtM) > 0) { + heap(parent) = heapAtM + heap(m) = heapAtParent + heapAtParent.index = m + loop(parent) + } else heapAtM.index = m + } else heapAtM.index = m + } + + loop(m) + } + + /** + * Fixes the heap property from the child at index `m` down the tree, towards the leaves. + */ + private[this] def fixDown(m: Int): Unit = { + val heap = this.heap // local copy + + /* At each step, even though `m` changes, the element moves with it, and + * hence heap(m) is always the same initial `heapAtM`. + */ + val heapAtM = heap(m) + + @tailrec + def loop(m: Int): Unit = { + var j = 2 * m // left child of `m` + if (j <= size) { + var heapAtJ = heap(j) + + // if the left child is greater than the right child, switch to the right child + if (j < size) { + val heapAtJPlus1 = heap(j + 1) + if (cmp(heapAtJ, heapAtJPlus1) > 0) { + j += 1 + heapAtJ = heapAtJPlus1 + } + } + + // if the node `m` is greater than the selected child, swap and recurse + if (cmp(heapAtM, heapAtJ) > 0) { + heap(m) = heapAtJ + heapAtJ.index = m + heap(j) = heapAtM + loop(j) + } else heapAtM.index = m + } else heapAtM.index = m + } + + loop(m) + } + + /** + * Compares trigger times. + * + * The trigger times are `System.nanoTime` longs, so they have to be compared in a peculiar + * way (see javadoc there). This makes this order non-transitive, which is quite bad. However, + * `computeTriggerTime` makes sure that there is no overflow here, so we're okay. + */ + private[this] def cmp( + xTriggerTime: Long, + yTriggerTime: Long + ): Int = { + val d = xTriggerTime - yTriggerTime + java.lang.Long.signum(d) + } + + private[this] def cmp(x: Node, y: Node): Int = + cmp(x.triggerTime, y.triggerTime) + + /** + * Computes the trigger time in an overflow-safe manner. The trigger time is essentially `now + * + delay`. However, we must constrain all trigger times in the heap to be within + * `Long.MaxValue` of each other (otherwise there will be overflow when comparing in `cpr`). + * Thus, if `delay` is so big, we'll reduce it to the greatest allowable (in `overflowFree`). + * + * From the public domain JSR-166 `ScheduledThreadPoolExecutor` (`triggerTime` method). + */ + private[this] def computeTriggerTime(now: Long, delay: Long): Long = { + val safeDelay = if (delay < (Long.MaxValue >> 1)) delay else overflowFree(now, delay) + now + safeDelay + } + + /** + * See `computeTriggerTime`. The overflow can happen if a callback was already triggered + * (based on `now`), but was not removed yet; and `delay` is sufficiently big. + * + * From the public domain JSR-166 `ScheduledThreadPoolExecutor` (`overflowFree` method). + * + * Pre-condition that the heap is non-empty. + */ + private[this] def overflowFree(now: Long, delay: Long): Long = { + val root = heap(1) + val rootDelay = root.triggerTime - now + if ((rootDelay < 0) && (delay - rootDelay < 0)) { + // head was already triggered, and `delay` is big enough, + // so we must clamp `delay`: + Long.MaxValue + rootDelay + } else { + delay + } + } + + override def toString() = if (size > 0) "TimerHeap(...)" else "TimerHeap()" + + private final class Node( + val triggerTime: Long, + private[this] var callback: Right[Nothing, Unit] => Unit, + var index: Int + ) extends Function0[Unit] + with Runnable { + + private[this] var canceled: Boolean = false + + def getAndClear(): Right[Nothing, Unit] => Unit = { + val back = callback + if (back ne null) // only clear if we read something + callback = null + back + } + + def get(): Right[Nothing, Unit] => Unit = callback + + /** + * Cancel this timer. + */ + def apply(): Unit = { + // we can always clear the callback, without explicitly publishing + callback = null + + // if this node is not removed immediately, this will be published by canceledCounter + canceled = true + + // if we're on the thread that owns this heap, we can remove ourselves immediately + val thread = Thread.currentThread() + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[_]] + val heap = TimerHeap.this + if (worker.ownsTimers(heap)) { + // remove only if we are still in the heap + if (index >= 0) heap.removeAt(index) + } else { // otherwise this heap will need packing + // it is okay to increment more than once if invoked multiple times + // but it will undermine the packIfNeeded short-circuit optimization + // b/c it will keep looking for more canceled nodes + canceledCounter.getAndIncrement() + () + } + } else { + canceledCounter.getAndIncrement() + () + } + } + + def run() = apply() + + def isDeleted(): Boolean = callback eq null + + def isCanceled(): Boolean = canceled + + override def toString() = s"Node($triggerTime, $callback})" + + } + +} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/TimerSkipList.scala b/core/jvm/src/main/scala/cats/effect/unsafe/TimerSkipList.scala deleted file mode 100644 index e50e639f6f..0000000000 --- a/core/jvm/src/main/scala/cats/effect/unsafe/TimerSkipList.scala +++ /dev/null @@ -1,778 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe - -import scala.annotation.tailrec - -import java.lang.Long.{MAX_VALUE, MIN_VALUE => MARKER} -import java.util.concurrent.ThreadLocalRandom -import java.util.concurrent.atomic.{AtomicLong, AtomicReference} - -/** - * Concurrent skip list holding timer callbacks and their associated trigger times. The 3 main - * operations are `pollFirstIfTriggered`, `insert`, and the "remove" returned by `insert` (for - * cancelling timers). - */ -private final class TimerSkipList() extends AtomicLong(MARKER + 1L) { sequenceNumber => - - /* - * This implementation is based on the public - * domain JSR-166 `ConcurrentSkipListMap`. - * Contains simplifications, because we just - * need a few main operations. Also, - * `pollFirstIfTriggered` contains an extra - * condition (compared to `pollFirstEntry` - * in the JSR-166 implementation), because - * we don't want to remove if the trigger time - * is still in the future. - * - * Our values are the callbacks, and used - * similarly. Our keys are essentially the - * trigger times, but see the comment in - * `insert`. Due to longs not having nulls, - * we use a special value for designating - * "marker" nodes (see `Node#isMarker`). - */ - - private[this] type Callback = - Right[Nothing, Unit] => Unit - - /** - * Base nodes (which form the base list) store the payload. - * - * `next` is the next node in the base list (with a key > than this). - * - * A `Node` is a special "marker" node (for deletion) if `sequenceNum == MARKER`. A `Node` is - * logically deleted if `cb eq null`. - * - * We're also (ab)using the `Node` class as the "canceller" for an inserted timer callback - * (see `run` method). - */ - private[unsafe] final class Node private[TimerSkipList] ( - val triggerTime: Long, - val sequenceNum: Long, - cb: Callback, - next: Node - ) extends TimerSkipListNodeBase[Callback, Node](cb, next) - with Function0[Unit] - with Runnable { - - /** - * Cancels the timer - */ - final def apply(): Unit = { - // TODO: We could null the callback here directly, - // TODO: and the do the lookup after (for unlinking). - TimerSkipList.this.doRemove(triggerTime, sequenceNum) - () - } - - final def run() = apply() - - private[TimerSkipList] final def isMarker: Boolean = { - // note: a marker node also has `triggerTime == MARKER`, - // but that's also a valid trigger time, so we need - // `sequenceNum` here - sequenceNum == MARKER - } - - private[TimerSkipList] final def isDeleted(): Boolean = { - getCb() eq null - } - - final override def toString: String = - "" - } - - /** - * Index nodes - */ - private[this] final class Index( - val node: Node, - val down: Index, - r: Index - ) extends AtomicReference[Index](r) { right => - - require(node ne null) - - final def getRight(): Index = { - right.get() // could be `getAcquire` - } - - final def setRight(nv: Index): Unit = { - right.set(nv) // could be `setPlain` - } - - final def casRight(ov: Index, nv: Index): Boolean = { - right.compareAndSet(ov, nv) - } - - final override def toString: String = - "Index(...)" - } - - /** - * The top left index node (or null if empty) - */ - private[this] val head = - new AtomicReference[Index] - - /** - * For testing - */ - private[unsafe] final def insertTlr( - now: Long, - delay: Long, - callback: Right[Nothing, Unit] => Unit - ): Runnable = { - insert(now, delay, callback, ThreadLocalRandom.current()) - } - - /** - * Inserts a new `callback` which will be triggered not earlier than `now + delay`. Returns a - * "canceller", which (if executed) removes (cancels) the inserted callback. (Of course, by - * that time the callback might've been already invoked.) - * - * @param now - * the current time as returned by `System.nanoTime` - * @param delay - * nanoseconds delay, must be nonnegative - * @param callback - * the callback to insert into the skip list - * @param tlr - * the `ThreadLocalRandom` of the current (calling) thread - */ - final def insert( - now: Long, - delay: Long, - callback: Right[Nothing, Unit] => Unit, - tlr: ThreadLocalRandom - ): Function0[Unit] with Runnable = { - require(delay >= 0L) - // we have to check for overflow: - val triggerTime = computeTriggerTime(now = now, delay = delay) - // Because our skip list can't handle multiple - // values (callbacks) for the same key, the - // key is not only the `triggerTime`, but - // conceptually a `(triggerTime, seqNo)` tuple. - // We generate unique (for this skip list) - // sequence numbers with an atomic counter. - val seqNo = { - val sn = sequenceNumber.getAndIncrement() - // In case of overflow (very unlikely), - // we make sure we don't use MARKER for - // a valid node (which would be very bad); - // otherwise the overflow can only cause - // problems with the ordering of callbacks - // with the exact same triggerTime... - // which is unspecified anyway (due to - // stealing). - if (sn != MARKER) sn - else sequenceNumber.getAndIncrement() - } - - doPut(triggerTime, seqNo, callback, tlr) - } - - /** - * Removes and returns the first (earliest) timer callback, if its trigger time is not later - * than `now`. Can return `null` if there is no such callback. - * - * It is the caller's responsibility to check for `null`, and actually invoke the callback (if - * desired). - * - * @param now - * the current time as returned by `System.nanoTime` - */ - final def pollFirstIfTriggered(now: Long): Right[Nothing, Unit] => Unit = { - doRemoveFirstNodeIfTriggered(now) - } - - /** - * Looks at the first callback in the list, and returns its trigger time. - * - * @return - * the `triggerTime` of the first callback, or `Long.MinValue` if the list is empty. - */ - final def peekFirstTriggerTime(): Long = { - val head = peekFirstNode() - if (head ne null) { - val tt = head.triggerTime - if (tt != MARKER) { - tt - } else { - // in the VERY unlikely case when - // the trigger time is exactly our - // sentinel, we just cheat a little - // (this could cause threads to wake - // up 1 ns too early): - MAX_VALUE - } - } else { - MARKER - } - } - - final override def toString: String = { - peekFirstNode() match { - case null => - "TimerSkipList()" - case _ => - "TimerSkipList(...)" - } - } - - /** - * For testing - */ - private[unsafe] final def peekFirstQuiescent(): Callback = { - val n = peekFirstNode() - if (n ne null) { - n.getCb() - } else { - null - } - } - - /** - * Compares keys, first by trigger time, then by sequence number; this method determines the - * "total order" that is used by the skip list. - * - * The trigger times are `System.nanoTime` longs, so they have to be compared in a peculiar - * way (see javadoc there). This makes this order non-transitive, which is quite bad. However, - * `computeTriggerTime` makes sure that there is no overflow here, so we're okay. - * - * Analogous to `cpr` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def cpr( - xTriggerTime: Long, - xSeqNo: Long, - yTriggerTime: Long, - ySeqNo: Long): Int = { - // first compare trigger times: - val d = xTriggerTime - yTriggerTime - if (d < 0) -1 - else if (d > 0) 1 - else { - // if times are equal, compare seq numbers: - if (xSeqNo < ySeqNo) -1 - else if (xSeqNo == ySeqNo) 0 - else 1 - } - } - - /** - * Computes the trigger time in an overflow-safe manner. The trigger time is essentially `now - * + delay`. However, we must constrain all trigger times in the skip list to be within - * `Long.MaxValue` of each other (otherwise there will be overflow when comparing in `cpr`). - * Thus, if `delay` is so big, we'll reduce it to the greatest allowable (in `overflowFree`). - * - * From the public domain JSR-166 `ScheduledThreadPoolExecutor` (`triggerTime` method). - */ - private[this] final def computeTriggerTime(now: Long, delay: Long): Long = { - val safeDelay = if (delay < (MAX_VALUE >> 1)) delay else overflowFree(now, delay) - now + safeDelay - } - - /** - * See `computeTriggerTime`. The overflow can happen if a callback was already triggered - * (based on `now`), but was not removed yet; and `delay` is sufficiently big. - * - * From the public domain JSR-166 `ScheduledThreadPoolExecutor` (`overflowFree` method). - */ - private[this] final def overflowFree(now: Long, delay: Long): Long = { - val head = peekFirstNode() - // Note, that there is a race condition here: - // the node we're looking at (`head`) can be - // concurrently removed/cancelled. But the - // consequence of that here is only that we - // will be MORE careful with `delay` than - // necessary. - if (head ne null) { - val headDelay = head.triggerTime - now - if ((headDelay < 0) && (delay - headDelay < 0)) { - // head was already triggered, and `delay` is big enough, - // so we must clamp `delay`: - MAX_VALUE + headDelay - } else { - delay - } - } else { - delay // empty - } - } - - /** - * Analogous to `doPut` in the JSR-166 `ConcurrentSkipListMap`. - */ - @tailrec - private[this] final def doPut( - triggerTime: Long, - seqNo: Long, - cb: Callback, - tlr: ThreadLocalRandom): Node = { - val h = head.get() // could be `getAcquire` - var levels = 0 // number of levels descended - var b: Node = if (h eq null) { - // head not initialized yet, do it now; - // first node of the base list is a sentinel - // (without payload): - val base = new Node(MARKER, MARKER, null: Callback, null) - val h = new Index(base, null, null) - if (head.compareAndSet(null, h)) base else null - } else { - // we have a head; find a node in the base list - // "close to" (but before) the inserion point: - var q: Index = h // current position, start from the head - var foundBase: Node = null // we're looking for this - while (foundBase eq null) { - // first try to go right: - q = walkRight(q, triggerTime, seqNo) - // then try to go down: - val d = q.down - if (d ne null) { - levels += 1 - q = d // went down 1 level, will continue going right - } else { - // reached the base list, break outer loop: - foundBase = q.node - } - } - foundBase - } - if (b ne null) { - // `b` is a node in the base list, "close to", - // but before the insertion point - var z: Node = null // will be the new node when inserted - var n: Node = null // next node - var go = true - while (go) { - var c = 0 // `cpr` result - n = b.getNext() - if (n eq null) { - // end of the list, insert right here - c = -1 - } else if (n.isMarker) { - // someone is deleting `b` right now, will - // restart insertion (as `z` is still null) - go = false - } else if (n.isDeleted()) { - unlinkNode(b, n) - c = 1 // will retry going right - } else { - c = cpr(triggerTime, seqNo, n.triggerTime, n.sequenceNum) - if (c > 0) { - // continue right - b = n - } // else: we assume c < 0, due to seqNr being unique - } - - if (c < 0) { - // found insertion point - val p = new Node(triggerTime, seqNo, cb, n) - if (b.casNext(n, p)) { - z = p - go = false - } // else: lost a race, retry - } - } - - if (z ne null) { - // we successfully inserted a new node; - // maybe add extra indices: - var rnd = tlr.nextLong() - if ((rnd & 0x3L) == 0L) { // add at least one index with 1/4 probability - // first create a "tower" of index - // nodes (all with `.right == null`): - var skips = levels - var x: Index = null // most recently created (topmost) index node in the tower - var go = true - while (go) { - // the height of the tower is at most 62 - // we create at most 62 indices in the tower - // (62 = 64 - 2; the 2 low bits are 0); - // also, the height is at most the number - // of levels we descended when inserting - x = new Index(z, x, null) - if (rnd >= 0L) { - // reached the first 0 bit in `rnd` - go = false - } else { - skips -= 1 - if (skips < 0) { - // reached the existing levels - go = false - } else { - // each additional index level has 1/2 probability - rnd <<= 1 - } - } - } - - // then actually add these index nodes to the skiplist: - if (addIndices(h, skips, x) && (skips < 0) && (head - .get() eq h)) { // could be `getAcquire` - // if we successfully added a full height - // "tower", try to also add a new level - // (with only 1 index node + the head) - val hx = new Index(z, x, null) - val nh = new Index(h.node, h, hx) // new head - head.compareAndSet(h, nh) - } - - if (z.isDeleted()) { - // was deleted while we added indices, - // need to clean up: - findPredecessor(triggerTime, seqNo) - () - } - } // else: we're done, and won't add indices - - z - } else { // restart - doPut(triggerTime, seqNo, cb, tlr) - } - } else { // restart - doPut(triggerTime, seqNo, cb, tlr) - } - } - - /** - * Starting from the `q` index node, walks right while possible by comparing keys - * (`triggerTime` and `seqNo`). Returns the last index node (at this level) which is still a - * predecessor of the node with the specified key (`triggerTime` and `seqNo`). This returned - * index node can be `q` itself. (This method assumes that the specified `q` is a predecessor - * of the node with the key.) - * - * This method has no direct equivalent in the JSR-166 `ConcurrentSkipListMap`; the same logic - * is embedded in various methods as a `while` loop. - */ - @tailrec - private[this] final def walkRight(q: Index, triggerTime: Long, seqNo: Long): Index = { - val r = q.getRight() - if (r ne null) { - val p = r.node - if (p.isMarker || p.isDeleted()) { - // marker or deleted node, unlink it: - q.casRight(r, r.getRight()) - // and retry: - walkRight(q, triggerTime, seqNo) - } else if (cpr(triggerTime, seqNo, p.triggerTime, p.sequenceNum) > 0) { - // we can still go right: - walkRight(r, triggerTime, seqNo) - } else { - // can't go right any more: - q - } - } else { - // can't go right any more: - q - } - } - - /** - * Finds the node with the specified key; deletes it logically by CASing the callback to null; - * unlinks it (first inserting a marker); removes associated index nodes; and possibly reduces - * index level. - * - * Analogous to `doRemove` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def doRemove(triggerTime: Long, seqNo: Long): Boolean = { - var b = findPredecessor(triggerTime, seqNo) - while (b ne null) { // outer - var inner = true - while (inner) { - val n = b.getNext() - if (n eq null) { - return false - } else if (n.isMarker) { - inner = false - b = findPredecessor(triggerTime, seqNo) - } else { - val ncb = n.getCb() - if (ncb eq null) { - unlinkNode(b, n) - // and retry `b.getNext()` - } else { - val c = cpr(triggerTime, seqNo, n.triggerTime, n.sequenceNum) - if (c > 0) { - b = n - } else if (c < 0) { - return false - } else if (n.casCb(ncb, null)) { - // successfully logically deleted - unlinkNode(b, n) - findPredecessor(triggerTime, seqNo) // cleanup - tryReduceLevel() - return true - } - } - } - } - } - - false - } - - /** - * Returns the first node of the base list. Skips logically deleted nodes, so the returned - * node was non-deleted when calling this method (but beware of concurrent deleters). - */ - private[this] final def peekFirstNode(): Node = { - var b = baseHead() - if (b ne null) { - var n: Node = null - while ({ - n = b.getNext() - (n ne null) && (n.isDeleted()) - }) { - b = n - } - - n - } else { - null - } - } - - /** - * Analogous to `doRemoveFirstEntry` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def doRemoveFirstNodeIfTriggered(now: Long): Callback = { - val b = baseHead() - if (b ne null) { - - @tailrec - def go(): Callback = { - val n = b.getNext() - if (n ne null) { - val tt = n.triggerTime - if (now - tt >= 0) { // triggered - val cb = n.getCb() - if (cb eq null) { - // alread (logically) deleted node - unlinkNode(b, n) - go() - } else if (n.casCb(cb, null)) { - unlinkNode(b, n) - tryReduceLevel() - findPredecessor(tt, n.sequenceNum) // clean index - cb - } else { - // lost race, retry - go() - } - } else { // not triggered yet - null - } - } else { - null - } - } - - go() - } else { - null - } - } - - /** - * The head of the base list (or `null` if uninitialized). - * - * Analogous to `baseHead` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def baseHead(): Node = { - val h = head.get() // could be `getAcquire` - if (h ne null) h.node else null - } - - /** - * Adds indices after an insertion was performed (e.g. `doPut`). Descends iteratively to the - * highest index to insert, and from then recursively calls itself to insert lower level - * indices. Returns `false` on staleness, which disables higher level insertions (from the - * recursive calls). - * - * Analogous to `addIndices` in the JSR-166 `ConcurrentSkipListMap`. - * - * @param _q - * starting index node for the current level - * @param _skips - * levels to skip down before inserting - * @param x - * the top of a "tower" of new indices (with `.right == null`) - * @return - * `true` iff we successfully inserted the new indices - */ - private[this] final def addIndices(_q: Index, _skips: Int, x: Index): Boolean = { - if (x ne null) { - var q = _q - var skips = _skips - val z = x.node - if ((z ne null) && !z.isMarker && (q ne null)) { - var retrying = false - while (true) { // find splice point - val r = q.getRight() - var c: Int = 0 // comparison result - if (r ne null) { - val p = r.node - if (p.isMarker || p.isDeleted()) { - // clean deleted node: - q.casRight(r, r.getRight()) - c = 0 - } else { - c = cpr(z.triggerTime, z.sequenceNum, p.triggerTime, p.sequenceNum) - } - if (c > 0) { - q = r - } else if (c == 0) { - // stale - return false - } - } else { - c = -1 - } - - if (c < 0) { - val d = q.down - if ((d ne null) && (skips > 0)) { - skips -= 1 - q = d - } else if ((d ne null) && !retrying && !addIndices(d, 0, x.down)) { - return false - } else { - x.setRight(r) - if (q.casRight(r, x)) { - return true - } else { - retrying = true // re-find splice point - } - } - } - } - } - } - - false - } - - /** - * Returns a base node whith key < the parameters. Also unlinks indices to deleted nodes while - * searching. - * - * Analogous to `findPredecessor` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def findPredecessor(triggerTime: Long, seqNo: Long): Node = { - var q: Index = head.get() // current index node (could be `getAcquire`) - if ((q eq null) || (seqNo == MARKER)) { - null - } else { - while (true) { - // go right: - q = walkRight(q, triggerTime, seqNo) - // go down: - val d = q.down - if (d ne null) { - q = d - } else { - // can't go down, we're done: - return q.node - } - } - - null // unreachable - } - } - - /** - * Tries to unlink the (logically) already deleted node `n` from its predecessor `b`. Before - * unlinking, this method inserts a "marker" node after `n`, to make sure there are no lost - * concurrent inserts. (An insert would do a CAS on `n.next`; linking a marker node after `n` - * makes sure the concurrent CAS on `n.next` will fail.) - * - * When this method returns, `n` is already unlinked from `b` (either by this method, or a - * concurrent thread). - * - * `b` or `n` may be `null`, in which case this method is a no-op. - * - * Analogous to `unlinkNode` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def unlinkNode(b: Node, n: Node): Unit = { - if ((b ne null) && (n ne null)) { - // makes sure `n` is marked, - // returns node after the marker - def mark(): Node = { - val f = n.getNext() - if ((f ne null) && f.isMarker) { - f.getNext() // `n` is already marked - } else if (n.casNext(f, new Node(MARKER, MARKER, null: Callback, f))) { - f // we've successfully marked `n` - } else { - mark() // lost race, retry - } - } - - val p = mark() - b.casNext(n, p) - // if this CAS failed, someone else already unlinked the marked `n` - () - } - } - - /** - * Tries to reduce the number of levels by removing the topmost level. - * - * Multiple conditions must be fulfilled to actually remove the level: not only the topmost - * (1st) level must be (likely) empty, but the 2nd and 3rd too. This is to (1) reduce the - * chance of mistakes (see below), and (2) reduce the chance of frequent adding/removing of - * levels (hysteresis). - * - * We can make mistakes here: we can (with a small probability) remove a level which is - * concurrently becoming non-empty. This can degrade performance, but does not impact - * correctness (e.g., we won't lose keys/values). To even further reduce the possibility of - * mistakes, if we detect one, we try to quickly undo the deletion we did. - * - * The reason for (rarely) allowing the removal of a level which shouldn't be removed, is that - * this is still better than allowing levels to just grow (which would also degrade - * performance). - * - * Analogous to `tryReduceLevel` in the JSR-166 `ConcurrentSkipListMap`. - */ - private[this] final def tryReduceLevel(): Unit = { - val lv1 = head.get() // could be `getAcquire` - if ((lv1 ne null) && (lv1.getRight() eq null)) { // 1st level seems empty - val lv2 = lv1.down - if ((lv2 ne null) && (lv2.getRight() eq null)) { // 2nd level seems empty - val lv3 = lv2.down - if ((lv3 ne null) && (lv3.getRight() eq null)) { // 3rd level seems empty - // the topmost 3 levels seem empty, - // so try to decrease levels by 1: - if (head.compareAndSet(lv1, lv2)) { - // successfully reduced level, - // but re-check if it's still empty: - if (lv1.getRight() ne null) { - // oops, we deleted a level - // with concurrent insert(s), - // try to fix our mistake: - head.compareAndSet(lv2, lv1) - () - } - } - } - } - } - } -} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala b/core/jvm/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala index 1fc56e657a..38ead18cb2 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/WorkStealingThreadPool.scala @@ -36,13 +36,15 @@ import cats.effect.tracing.TracingConstants import scala.collection.mutable import scala.concurrent.ExecutionContextExecutor import scala.concurrent.duration.{Duration, FiniteDuration} +import scala.util.control.NonFatal import java.time.Instant import java.time.temporal.ChronoField import java.util.Comparator import java.util.concurrent.{ConcurrentSkipListSet, ThreadLocalRandom} -import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} -import java.util.concurrent.locks.LockSupport +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference} + +import WorkStealingThreadPool._ /** * Work-stealing thread pool which manages a pool of [[WorkerThread]] s for the specific purpose @@ -59,12 +61,14 @@ import java.util.concurrent.locks.LockSupport * contention. Work stealing is tried using a linear search starting from a random worker thread * index. */ -private[effect] final class WorkStealingThreadPool( +private[effect] final class WorkStealingThreadPool[P]( threadCount: Int, // number of worker threads private[unsafe] val threadPrefix: String, // prefix for the name of worker threads private[unsafe] val blockerThreadPrefix: String, // prefix for the name of worker threads currently in a blocking region private[unsafe] val runtimeBlockingExpiration: Duration, private[unsafe] val blockedThreadDetectionEnabled: Boolean, + shutdownTimeout: Duration, + system: PollingSystem.WithPoller[P], reportFailure0: Throwable => Unit ) extends ExecutionContextExecutor with Scheduler { @@ -75,11 +79,27 @@ private[effect] final class WorkStealingThreadPool( /** * References to worker threads and their local queues. */ - private[this] val workerThreads: Array[WorkerThread] = new Array(threadCount) + private[this] val workerThreads: Array[WorkerThread[P]] = new Array(threadCount) private[unsafe] val localQueues: Array[LocalQueue] = new Array(threadCount) - private[unsafe] val sleepers: Array[TimerSkipList] = new Array(threadCount) + private[unsafe] val sleepers: Array[TimerHeap] = new Array(threadCount) private[unsafe] val parkedSignals: Array[AtomicBoolean] = new Array(threadCount) private[unsafe] val fiberBags: Array[WeakBag[Runnable]] = new Array(threadCount) + private[unsafe] val pollers: Array[P] = + new Array[AnyRef](threadCount).asInstanceOf[Array[P]] + + private[unsafe] def accessPoller(cb: P => Unit): Unit = { + + // figure out where we are + val thread = Thread.currentThread() + val pool = WorkStealingThreadPool.this + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[P]] + if (worker.isOwnedBy(pool)) // we're good + cb(worker.poller()) + else // possibly a blocking worker thread, possibly on another wstp + scheduleExternal(() => accessPoller(cb)) + } else scheduleExternal(() => accessPoller(cb)) + } /** * Atomic variable for used for publishing changes to the references in the `workerThreads` @@ -100,8 +120,8 @@ private[effect] final class WorkStealingThreadPool( */ private[this] val state: AtomicInteger = new AtomicInteger(threadCount << UnparkShift) - private[unsafe] val cachedThreads: ConcurrentSkipListSet[WorkerThread] = - new ConcurrentSkipListSet(Comparator.comparingInt[WorkerThread](_.nameIndex)) + private[unsafe] val cachedThreads: ConcurrentSkipListSet[WorkerThread[P]] = + new ConcurrentSkipListSet(Comparator.comparingInt[WorkerThread[P]](_.nameIndex)) /** * The shutdown latch of the work stealing thread pool. @@ -118,13 +138,16 @@ private[effect] final class WorkStealingThreadPool( while (i < threadCount) { val queue = new LocalQueue() localQueues(i) = queue - val sleepersList = new TimerSkipList() - sleepers(i) = sleepersList + val sleepersHeap = new TimerHeap() + sleepers(i) = sleepersHeap val parkedSignal = new AtomicBoolean(false) parkedSignals(i) = parkedSignal val index = i val fiberBag = new WeakBag[Runnable]() fiberBags(i) = fiberBag + val poller = system.makePoller() + pollers(i) = poller + val thread = new WorkerThread( index, @@ -132,8 +155,11 @@ private[effect] final class WorkStealingThreadPool( parkedSignal, externalQueue, fiberBag, - sleepersList, + sleepersHeap, + system, + poller, this) + workerThreads(i) = thread i += 1 } @@ -149,7 +175,7 @@ private[effect] final class WorkStealingThreadPool( } } - private[unsafe] def getWorkerThreads: Array[WorkerThread] = workerThreads + private[unsafe] def getWorkerThreads: Array[WorkerThread[P]] = workerThreads /** * Tries to steal work from other worker threads. This method does a linear search of the @@ -170,7 +196,7 @@ private[effect] final class WorkStealingThreadPool( private[unsafe] def stealFromOtherWorkerThread( dest: Int, random: ThreadLocalRandom, - destWorker: WorkerThread): Runnable = { + destWorker: WorkerThread[P]): Runnable = { val destQueue = localQueues(dest) val from = random.nextInt(threadCount) @@ -231,18 +257,7 @@ private[effect] final class WorkStealingThreadPool( // (note: it doesn't matter if we try to steal // from ourselves). val index = (from + i) % threadCount - val tsl = sleepers(index) - var invoked = false // whether we successfully invoked a timer - var cont = true - while (cont) { - val cb = tsl.pollFirstIfTriggered(now) - if (cb ne null) { - cb(RightUnit) - invoked = true - } else { - cont = false - } - } + val invoked = sleepers(index).steal(now) // whether we successfully invoked a timer if (invoked) { // we did some work, don't @@ -295,7 +310,7 @@ private[effect] final class WorkStealingThreadPool( // impossible. workerThreadPublisher.get() val worker = workerThreads(index) - LockSupport.unpark(worker) + system.interrupt(worker, pollers(index)) return true } @@ -305,24 +320,6 @@ private[effect] final class WorkStealingThreadPool( false } - /** - * A specialized version of `notifyParked`, for when we know which thread to wake up, and know - * that it should wake up due to a new timer (i.e., it must always wake up, even if only to go - * back to sleep, because its current sleeping time might be incorrect). - * - * @param index - * The index of the thread to notify (must be less than `threadCount`). - */ - private[this] final def notifyForTimer(index: Int): Unit = { - val signal = parkedSignals(index) - if (signal.getAndSet(false)) { - state.getAndAdd(DeltaSearching) - workerThreadPublisher.get() - val worker = workerThreads(index) - LockSupport.unpark(worker) - } // else: was already unparked - } - /** * Checks the number of active and searching worker threads and decides whether another thread * should be notified of new work. @@ -427,7 +424,7 @@ private[effect] final class WorkStealingThreadPool( * Updates the internal state to mark the given worker thread as parked. * * @note - * This method is intentionally duplicated, to accomodate the unconditional code paths in + * This method is intentionally duplicated, to accommodate the unconditional code paths in * the [[WorkerThread]] runloop. */ private[unsafe] def transitionWorkerToParked(): Unit = { @@ -449,7 +446,7 @@ private[effect] final class WorkStealingThreadPool( * @param newWorker * the new worker thread instance to be installed at the provided index */ - private[unsafe] def replaceWorker(index: Int, newWorker: WorkerThread): Unit = { + private[unsafe] def replaceWorker(index: Int, newWorker: WorkerThread[P]): Unit = { workerThreads(index) = newWorker workerThreadPublisher.lazySet(true) } @@ -472,8 +469,8 @@ private[effect] final class WorkStealingThreadPool( val pool = this val thread = Thread.currentThread() - if (thread.isInstanceOf[WorkerThread]) { - val worker = thread.asInstanceOf[WorkerThread] + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[P]] if (worker.isOwnedBy(pool)) { worker.reschedule(runnable) } else { @@ -490,8 +487,8 @@ private[effect] final class WorkStealingThreadPool( */ private[effect] def canExecuteBlockingCode(): Boolean = { val thread = Thread.currentThread() - if (thread.isInstanceOf[WorkerThread]) { - val worker = thread.asInstanceOf[WorkerThread] + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[P]] worker.canExecuteBlockingCodeOn(this) } else { false @@ -504,7 +501,7 @@ private[effect] final class WorkStealingThreadPool( */ private[effect] def prepareForBlocking(): Unit = { val thread = Thread.currentThread() - val worker = thread.asInstanceOf[WorkerThread] + val worker = thread.asInstanceOf[WorkerThread[_]] worker.prepareForBlocking() } @@ -532,7 +529,7 @@ private[effect] final class WorkStealingThreadPool( */ private[unsafe] def liveTraces(): ( Map[Runnable, Trace], - Map[WorkerThread, (Thread.State, Option[(Runnable, Trace)], Map[Runnable, Trace])], + Map[WorkerThread[P], (Thread.State, Option[(Runnable, Trace)], Map[Runnable, Trace])], Map[Runnable, Trace]) = { val externalFibers: Map[Runnable, Trace] = externalQueue .snapshot() @@ -547,7 +544,7 @@ private[effect] final class WorkStealingThreadPool( val map = mutable .Map - .empty[WorkerThread, (Thread.State, Option[(Runnable, Trace)], Map[Runnable, Trace])] + .empty[WorkerThread[P], (Thread.State, Option[(Runnable, Trace)], Map[Runnable, Trace])] val suspended = mutable.Map.empty[Runnable, Trace] var i = 0 @@ -586,8 +583,8 @@ private[effect] final class WorkStealingThreadPool( val pool = this val thread = Thread.currentThread() - if (thread.isInstanceOf[WorkerThread]) { - val worker = thread.asInstanceOf[WorkerThread] + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[P]] if (worker.isOwnedBy(pool)) { worker.schedule(runnable) } else { @@ -612,8 +609,8 @@ private[effect] final class WorkStealingThreadPool( val back = System.nanoTime() val thread = Thread.currentThread() - if (thread.isInstanceOf[WorkerThread]) { - thread.asInstanceOf[WorkerThread].now = back + if (thread.isInstanceOf[WorkerThread[_]]) { + thread.asInstanceOf[WorkerThread[_]].now = back } back @@ -626,8 +623,6 @@ private[effect] final class WorkStealingThreadPool( now.getEpochSecond() * 1000000 + now.getLong(ChronoField.MICRO_OF_SECOND) } - private[this] val RightUnit = IOFiber.RightUnit - /** * Tries to call the current worker's `sleep`, but falls back to `sleepExternal` if needed. */ @@ -635,8 +630,8 @@ private[effect] final class WorkStealingThreadPool( delay: FiniteDuration, callback: Right[Nothing, Unit] => Unit): Function0[Unit] with Runnable = { val thread = Thread.currentThread() - if (thread.isInstanceOf[WorkerThread]) { - val worker = thread.asInstanceOf[WorkerThread] + if (thread.isInstanceOf[WorkerThread[_]]) { + val worker = thread.asInstanceOf[WorkerThread[P]] if (worker.isOwnedBy(this)) { worker.sleep(delay, callback) } else { @@ -650,26 +645,37 @@ private[effect] final class WorkStealingThreadPool( } /** - * Chooses a random `TimerSkipList` from this pool, and inserts the `callback`. + * Reschedule onto a worker thread and then submit the sleep. */ private[this] final def sleepExternal( delay: FiniteDuration, callback: Right[Nothing, Unit] => Unit): Function0[Unit] with Runnable = { - val random = ThreadLocalRandom.current() - val idx = random.nextInt(threadCount) - val tsl = sleepers(idx) - val cancel = tsl.insert( - now = System.nanoTime(), - delay = delay.toNanos, - callback = callback, - tlr = random - ) - notifyForTimer(idx) + val scheduledAt = monotonicNanos() + val cancel = new ExternalSleepCancel + + scheduleExternal { () => + val worker = Thread.currentThread().asInstanceOf[WorkerThread[_]] + cancel.setCallback(worker.sleepLate(scheduledAt, delay, callback)) + } + cancel } override def sleep(delay: FiniteDuration, task: Runnable): Runnable = { - sleepInternal(delay, _ => task.run()) + val cb = new AtomicBoolean with (Right[Nothing, Unit] => Unit) { // run at most once + def apply(ru: Right[Nothing, Unit]) = if (compareAndSet(false, true)) { + try { + task.run() + } catch { + case ex if NonFatal(ex) => + reportFailure(ex) + } + } + } + + val cancel = sleepInternal(delay, cb) + + () => if (cb.compareAndSet(false, true)) cancel.run() else () } /** @@ -679,36 +685,72 @@ private[effect] final class WorkStealingThreadPool( def shutdown(): Unit = { // Clear the interrupt flag. val interruptCalling = Thread.interrupted() + val currentThread = Thread.currentThread() // Execute the shutdown logic only once. if (done.compareAndSet(false, true)) { - // Send an interrupt signal to each of the worker threads. - workerThreadPublisher.get() - // Note: while loops and mutable variables are used throughout this method // to avoid allocations of objects, since this method is expected to be // executed mostly in situations where the thread pool is shutting down in // the face of unhandled exceptions or as part of the whole JVM exiting. + + workerThreadPublisher.get() + + // Send an interrupt signal to each of the worker threads. var i = 0 while (i < threadCount) { - workerThreads(i).interrupt() + val workerThread = workerThreads(i) + if (workerThread ne currentThread) { + workerThread.interrupt() + } + i += 1 + } + + i = 0 + var joinTimeout = shutdownTimeout match { + case Duration.Inf => Long.MaxValue + case d => d.toNanos + } + while (i < threadCount && joinTimeout > 0) { + val workerThread = workerThreads(i) + if (workerThread ne currentThread) { + val now = System.nanoTime() + workerThread.join(joinTimeout / 1000000, (joinTimeout % 1000000).toInt) + val elapsed = System.nanoTime() - now + joinTimeout -= elapsed + } + i += 1 + } + + i = 0 + var allClosed = true + while (i < threadCount) { + val workerThread = workerThreads(i) + // only close the poller if it is safe to do so, leak otherwise ... + if ((workerThread eq currentThread) || !workerThread.isAlive()) { + system.closePoller(pollers(i)) + } else { + allClosed = false + } i += 1 } - // Clear the interrupt flag. - Thread.interrupted() + if (allClosed) { + system.close() + } - var t: WorkerThread = null + var t: WorkerThread[P] = null while ({ t = cachedThreads.pollFirst() t ne null }) { t.interrupt() + // don't bother joining, cached threads are not doing anything interesting } // Drain the external queue. externalQueue.clear() - if (interruptCalling) Thread.currentThread().interrupt() + if (interruptCalling) currentThread.interrupt() } } @@ -786,3 +828,29 @@ private[effect] final class WorkStealingThreadPool( private[unsafe] def getSuspendedFiberCount(): Long = workerThreads.map(_.getSuspendedFiberCount().toLong).sum } + +private object WorkStealingThreadPool { + + /** + * A wrapper for a cancelation callback that is created asynchronously. + */ + private final class ExternalSleepCancel + extends AtomicReference[Function0[Unit]] + with Function0[Unit] + with Runnable { callback => + def setCallback(cb: Function0[Unit]) = { + val back = callback.getAndSet(cb) + if (back eq CanceledSleepSentinel) + cb() // we were already canceled, invoke right away + } + + def apply() = { + val back = callback.getAndSet(CanceledSleepSentinel) + if (back ne null) back() + } + + def run() = apply() + } + + private val CanceledSleepSentinel: Function0[Unit] = () => () +} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/WorkerThread.scala b/core/jvm/src/main/scala/cats/effect/unsafe/WorkerThread.scala index 7cbda3907f..c42d71c97f 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/WorkerThread.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/WorkerThread.scala @@ -29,7 +29,6 @@ import scala.util.control.NonFatal import java.lang.Long.MIN_VALUE import java.util.concurrent.{LinkedTransferQueue, ThreadLocalRandom} import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.locks.LockSupport /** * Implementation of the worker thread at the heart of the [[WorkStealingThreadPool]]. @@ -42,7 +41,7 @@ import java.util.concurrent.locks.LockSupport * system when compared to a fixed size thread pool whose worker threads all draw tasks from a * single global work queue. */ -private final class WorkerThread( +private final class WorkerThread[P]( idx: Int, // Local queue instance with exclusive write access. private[this] var queue: LocalQueue, @@ -53,9 +52,11 @@ private final class WorkerThread( private[this] val external: ScalQueue[AnyRef], // A worker-thread-local weak bag for tracking suspended fibers. private[this] var fiberBag: WeakBag[Runnable], - private[this] var sleepers: TimerSkipList, + private[this] var sleepers: TimerHeap, + private[this] val system: PollingSystem.WithPoller[P], + private[this] var _poller: P, // Reference to the `WorkStealingThreadPool` in which this thread operates. - private[this] val pool: WorkStealingThreadPool) + pool: WorkStealingThreadPool[P]) extends Thread with BlockContext { @@ -106,6 +107,12 @@ private final class WorkerThread( private val indexTransfer: LinkedTransferQueue[Integer] = new LinkedTransferQueue() private[this] val runtimeBlockingExpiration: Duration = pool.runtimeBlockingExpiration + private[this] val RightUnit = Right(()) + private[this] val noop = new Function0[Unit] with Runnable { + def apply() = () + def run() = () + } + val nameIndex: Int = pool.blockedWorkerThreadNamingIndex.getAndIncrement() // Constructor code. @@ -118,6 +125,8 @@ private final class WorkerThread( setName(s"$prefix-$nameIndex") } + private[unsafe] def poller(): P = _poller + /** * Schedules the fiber for execution at the back of the local queue and notifies the work * stealing pool of newly available work. @@ -152,20 +161,53 @@ private final class WorkerThread( } } - def sleep( - delay: FiniteDuration, - callback: Right[Nothing, Unit] => Unit): Function0[Unit] with Runnable = { + private[this] def nanoTime(): Long = { // take the opportunity to update the current time, just in case other timers can benefit val _now = System.nanoTime() now = _now + _now + } + + def sleep( + delay: FiniteDuration, + callback: Right[Nothing, Unit] => Unit): Function0[Unit] with Runnable = + sleepImpl(nanoTime(), delay.toNanos, callback) + + /** + * A sleep that is being scheduled "late" + */ + def sleepLate( + scheduledAt: Long, + delay: FiniteDuration, + callback: Right[Nothing, Unit] => Unit): Function0[Unit] with Runnable = { + val _now = nanoTime() + val newDelay = delay.toNanos - (_now - scheduledAt) + if (newDelay > 0) { + sleepImpl(_now, newDelay, callback) + } else { + callback(RightUnit) + noop + } + } + + private[this] def sleepImpl( + now: Long, + delay: Long, + callback: Right[Nothing, Unit] => Unit): Function0[Unit] with Runnable = { + val out = new Array[Right[Nothing, Unit] => Unit](1) // note that blockers aren't owned by the pool, meaning we only end up here if !blocking - sleepers.insert( - now = _now, - delay = delay.toNanos, + val cancel = sleepers.insert( + now = now, + delay = delay, callback = callback, - tlr = random + out = out ) + + val cb = out(0) + if (cb ne null) cb(RightUnit) + + cancel } /** @@ -186,7 +228,7 @@ private final class WorkerThread( * `true` if this worker thread is owned by the provided work stealing thread pool, `false` * otherwise */ - def isOwnedBy(threadPool: WorkStealingThreadPool): Boolean = + def isOwnedBy(threadPool: WorkStealingThreadPool[_]): Boolean = (pool eq threadPool) && !blocking /** @@ -201,7 +243,7 @@ private final class WorkerThread( * `true` if this worker thread is owned by the provided work stealing thread pool, `false` * otherwise */ - def canExecuteBlockingCodeOn(threadPool: WorkStealingThreadPool): Boolean = + def canExecuteBlockingCodeOn(threadPool: WorkStealingThreadPool[_]): Boolean = pool eq threadPool /** @@ -249,6 +291,9 @@ private final class WorkerThread( foreign.toMap } + private[unsafe] def ownsTimers(timers: TimerHeap): Boolean = + sleepers eq timers + /** * The run loop of the [[WorkerThread]]. */ @@ -256,7 +301,7 @@ private final class WorkerThread( val self = this random = ThreadLocalRandom.current() val rnd = random - val RightUnit = IOFiber.RightUnit + val reportFailure = pool.reportFailure(_) /* * A counter (modulo `ExternalQueueTicks`) which represents the @@ -331,14 +376,17 @@ private final class WorkerThread( def park(): Int = { val tt = sleepers.peekFirstTriggerTime() val nextState = if (tt == MIN_VALUE) { // no sleepers - parkLoop() - - // After the worker thread has been unparked, look for work in the - // external queue. - 3 + if (parkLoop()) { + // we polled something, so go straight to local queue stuff + pool.transitionWorkerFromSearching(rnd) + 4 + } else { + // we were interrupted, look for more work in the external queue + 3 + } } else { if (parkUntilNextSleeper()) { - // we made it to the end of our sleeping, so go straight to local queue stuff + // we made it to the end of our sleeping/polling, so go straight to local queue stuff pool.transitionWorkerFromSearching(rnd) 4 } else { @@ -365,22 +413,28 @@ private final class WorkerThread( } } - def parkLoop(): Unit = { - var cont = true - while (cont && !done.get()) { + // returns true if polled event, false if unparked + def parkLoop(): Boolean = { + while (!done.get()) { // Park the thread until further notice. - LockSupport.park(pool) + val polled = system.poll(_poller, -1, reportFailure) // the only way we can be interrupted here is if it happened *externally* (probably sbt) - if (isInterrupted()) + if (isInterrupted()) { pool.shutdown() - else - // Spurious wakeup check. - cont = parked.get() + } else if (polled) { + if (parked.getAndSet(false)) + pool.doneSleeping() + return true + } else if (!parked.get()) { // Spurious wakeup check. + return false + } else // loop + () } + false } - // returns true if timed out, false if unparked + // returns true if timed out or polled event, false if unparked @tailrec def parkUntilNextSleeper(): Boolean = { if (done.get()) { @@ -390,13 +444,12 @@ private final class WorkerThread( if (triggerTime == MIN_VALUE) { // no sleeper (it was removed) parkLoop() - false } else { now = System.nanoTime() val nanos = triggerTime - now if (nanos > 0L) { - LockSupport.parkNanos(pool, nanos) + val polled = system.poll(_poller, nanos, reportFailure) if (isInterrupted()) { pool.shutdown() @@ -406,9 +459,9 @@ private final class WorkerThread( // it doesn't matter if we timed out or were awakened, the update is free-ish now = System.nanoTime() if (parked.get()) { - // we were either awakened spuriously, or we timed out - if (triggerTime - now <= 0) { - // we timed out + // we were either awakened spuriously, or we timed out or polled an event + if (polled || (triggerTime - now <= 0)) { + // we timed out or polled an event if (parked.getAndSet(false)) { pool.doneSleeping() } @@ -445,6 +498,8 @@ private final class WorkerThread( sleepers = null parked = null fiberBag = null + _active = null + _poller = null.asInstanceOf[P] // Add this thread to the cached threads data structure, to be picked up // by another thread in the future. @@ -510,6 +565,11 @@ private final class WorkerThread( } } + // Clean up any externally canceled timers + sleepers.packIfNeeded() + // give the polling system a chance to discover events + system.poll(_poller, 0, reportFailure) + // Obtain a fiber or batch of fibers from the external queue. val element = external.poll(rnd) if (element.isInstanceOf[Array[Runnable]]) { @@ -517,7 +577,7 @@ private final class WorkerThread( // The dequeued element was a batch of fibers. Enqueue the whole // batch on the local queue and execute the first fiber. - // Make room for the batch if the local queue cannot accomodate + // Make room for the batch if the local queue cannot accommodate // all of the fibers as is. queue.drainBatch(external, rnd) @@ -835,7 +895,16 @@ private final class WorkerThread( // for unparking. val idx = index val clone = - new WorkerThread(idx, queue, parked, external, fiberBag, sleepers, pool) + new WorkerThread( + idx, + queue, + parked, + external, + fiberBag, + sleepers, + system, + _poller, + pool) // Make sure the clone gets our old name: val clonePrefix = pool.threadPrefix clone.setName(s"$clonePrefix-$idx") @@ -864,6 +933,7 @@ private final class WorkerThread( sleepers = pool.sleepers(newIdx) parked = pool.parkedSignals(newIdx) fiberBag = pool.fiberBags(newIdx) + _poller = pool.pollers(newIdx) // Reset the name of the thread to the regular prefix. val prefix = pool.threadPrefix diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/metrics/ComputePoolSampler.scala b/core/jvm/src/main/scala/cats/effect/unsafe/metrics/ComputePoolSampler.scala index 27fe131379..6347db1870 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/metrics/ComputePoolSampler.scala +++ b/core/jvm/src/main/scala/cats/effect/unsafe/metrics/ComputePoolSampler.scala @@ -24,7 +24,7 @@ package metrics * @param compute * the monitored compute work stealing thread pool */ -private[unsafe] final class ComputePoolSampler(compute: WorkStealingThreadPool) +private[unsafe] final class ComputePoolSampler(compute: WorkStealingThreadPool[_]) extends ComputePoolSamplerMBean { def getWorkerThreadCount(): Int = compute.getWorkerThreadCount() def getActiveThreadCount(): Int = compute.getActiveThreadCount() diff --git a/core/native/src/main/scala/cats/effect/FileDescriptorPoller.scala b/core/native/src/main/scala/cats/effect/FileDescriptorPoller.scala new file mode 100644 index 0000000000..b4a99feb47 --- /dev/null +++ b/core/native/src/main/scala/cats/effect/FileDescriptorPoller.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +import cats.syntax.all._ + +trait FileDescriptorPoller { + + /** + * Registers a file descriptor with the poller and monitors read- and/or write-ready events. + */ + def registerFileDescriptor( + fileDescriptor: Int, + monitorReadReady: Boolean, + monitorWriteReady: Boolean + ): Resource[IO, FileDescriptorPollHandle] + +} + +object FileDescriptorPoller { + def find: IO[Option[FileDescriptorPoller]] = + IO.pollers.map(_.collectFirst { case poller: FileDescriptorPoller => poller }) + + def get = find.flatMap( + _.liftTo[IO](new RuntimeException("No FileDescriptorPoller installed in this IORuntime")) + ) +} + +trait FileDescriptorPollHandle { + + /** + * Recursively invokes `f` until it is no longer blocked. Typically `f` will call `read` or + * `recv` on the file descriptor. + * - If `f` fails because the file descriptor is blocked, then it should return `Left[A]`. + * Then `f` will be invoked again with `A` at a later point, when the file handle is ready + * for reading. + * - If `f` is successful, then it should return a `Right[B]`. The `IO` returned from this + * method will complete with `B`. + */ + def pollReadRec[A, B](a: A)(f: A => IO[Either[A, B]]): IO[B] + + /** + * Recursively invokes `f` until it is no longer blocked. Typically `f` will call `write` or + * `send` on the file descriptor. + * - If `f` fails because the file descriptor is blocked, then it should return `Left[A]`. + * Then `f` will be invoked again with `A` at a later point, when the file handle is ready + * for writing. + * - If `f` is successful, then it should return a `Right[B]`. The `IO` returned from this + * method will complete with `B`. + */ + def pollWriteRec[A, B](a: A)(f: A => IO[Either[A, B]]): IO[B] + +} diff --git a/core/native/src/main/scala/cats/effect/IOApp.scala b/core/native/src/main/scala/cats/effect/IOApp.scala index 674b6e2539..540c6b0c38 100644 --- a/core/native/src/main/scala/cats/effect/IOApp.scala +++ b/core/native/src/main/scala/cats/effect/IOApp.scala @@ -17,9 +17,11 @@ package cats.effect import cats.effect.metrics.{CpuStarvationWarningMetrics, NativeCpuStarvationMetrics} +import cats.syntax.all._ import scala.concurrent.CancellationException import scala.concurrent.duration._ +import scala.scalanative.meta.LinktimeInfo._ /** * The primary entry point to a Cats Effect application. Extend this trait rather than defining @@ -172,6 +174,16 @@ trait IOApp { protected def onCpuStarvationWarn(metrics: CpuStarvationWarningMetrics): IO[Unit] = CpuStarvationCheck.logWarning(metrics) + /** + * The [[unsafe.PollingSystem]] used by the [[runtime]] which will evaluate the [[IO]] + * produced by `run`. It is very unlikely that users will need to override this method. + * + * [[unsafe.PollingSystem]] implementors may provide their own flavors of [[IOApp]] that + * override this method. + */ + protected def pollingSystem: unsafe.PollingSystem = + unsafe.IORuntime.createDefaultPollingSystem() + /** * The entry point for your application. Will be called by the runtime when the process is * started. If the underlying runtime supports it, any arguments passed to the process will be @@ -193,13 +205,17 @@ trait IOApp { import unsafe.IORuntime val installed = IORuntime installGlobal { + val (loop, poller, loopDown) = IORuntime.createEventLoop(pollingSystem) IORuntime( - IORuntime.defaultComputeExecutionContext, - IORuntime.defaultComputeExecutionContext, - IORuntime.defaultScheduler, - () => IORuntime.resetGlobal(), - runtimeConfig - ) + loop, + loop, + loop, + List(poller), + () => { + loopDown() + IORuntime.resetGlobal() + }, + runtimeConfig) } _runtime = IORuntime.global @@ -225,20 +241,50 @@ trait IOApp { lazy val keepAlive: IO[Nothing] = IO.sleep(1.hour) >> keepAlive + val awaitInterruptOrStayAlive = + if (isLinux || isMac) + FileDescriptorPoller.find.flatMap { + case Some(poller) => + val interruptOrTerm = + Signal + .awaitInterrupt(poller) + .as(ExitCode(130)) + .race(Signal.awaitTerm(poller).as(ExitCode(143))) + + def hardExit(code: ExitCode) = + IO.sleep(runtime.config.shutdownHookTimeout) *> IO(System.exit(code.code)) + + interruptOrTerm.map(_.merge).flatTap(hardExit(_).start) + case None => keepAlive + } + else + keepAlive + + val fiberDumper = + if (isLinux || isMac) + FileDescriptorPoller.find.toResource.flatMap { + case Some(poller) => + val dump = IO.blocking(runtime.fiberMonitor.liveFiberSnapshot(System.err.print(_))) + Signal.foreachDump(poller, dump).background.void + case None => Resource.unit[IO] + } + else Resource.unit[IO] + + val starvationChecker = CpuStarvationCheck + .run(runtimeConfig, NativeCpuStarvationMetrics(), onCpuStarvationWarn) + .background + Spawn[IO] - .raceOutcome[ExitCode, Nothing]( - CpuStarvationCheck - .run(runtimeConfig, NativeCpuStarvationMetrics(), onCpuStarvationWarn) - .background - .surround(run(args.toList)), - keepAlive) + .raceOutcome[ExitCode, ExitCode]( + (fiberDumper *> starvationChecker).surround(run(args.toList)), + awaitInterruptOrStayAlive + ) + .map(_.merge) .flatMap { - case Left(Outcome.Canceled()) => + case Outcome.Canceled() => IO.raiseError(new CancellationException("IOApp main fiber was canceled")) - case Left(Outcome.Errored(t)) => IO.raiseError(t) - case Left(Outcome.Succeeded(code)) => code - case Right(Outcome.Errored(t)) => IO.raiseError(t) - case Right(_) => sys.error("impossible") + case Outcome.Errored(t) => IO.raiseError(t) + case Outcome.Succeeded(code) => code } .unsafeRunFiber( System.exit(0), diff --git a/core/native/src/main/scala/cats/effect/IOCompanionPlatform.scala b/core/native/src/main/scala/cats/effect/IOCompanionPlatform.scala index fe079244ef..e4b4157c21 100644 --- a/core/native/src/main/scala/cats/effect/IOCompanionPlatform.scala +++ b/core/native/src/main/scala/cats/effect/IOCompanionPlatform.scala @@ -62,4 +62,5 @@ private[effect] abstract class IOCompanionPlatform { this: IO.type => */ def readLine: IO[String] = Console[IO].readLine + } diff --git a/core/native/src/main/scala/cats/effect/Platform.scala b/core/native/src/main/scala/cats/effect/Platform.scala index 40b1985d3f..a92dbcd460 100644 --- a/core/native/src/main/scala/cats/effect/Platform.scala +++ b/core/native/src/main/scala/cats/effect/Platform.scala @@ -20,4 +20,6 @@ private object Platform { final val isJs = false final val isJvm = false final val isNative = true + + class static extends scala.annotation.Annotation } diff --git a/core/native/src/main/scala/cats/effect/Signal.scala b/core/native/src/main/scala/cats/effect/Signal.scala new file mode 100644 index 0000000000..9a0d9bc2c4 --- /dev/null +++ b/core/native/src/main/scala/cats/effect/Signal.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +import cats.syntax.all._ + +import scala.scalanative.libc.errno._ +import scala.scalanative.meta.LinktimeInfo._ +import scala.scalanative.posix.errno._ +import scala.scalanative.posix.fcntl._ +import scala.scalanative.posix.signal._ +import scala.scalanative.posix.signalOps._ +import scala.scalanative.posix.string._ +import scala.scalanative.posix.unistd._ +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +import java.io.IOException + +private object Signal { + + private[this] def mkPipe() = if (isLinux || isMac) { + val fd = stackalloc[CInt](2.toULong) + if (pipe(fd) != 0) + throw new IOException(fromCString(strerror(errno))) + + val readFd = fd(0) + val writeFd = fd(1) + + if (fcntl(readFd, F_SETFL, O_NONBLOCK) != 0) + throw new IOException(fromCString(strerror(errno))) + + if (fcntl(writeFd, F_SETFL, O_NONBLOCK) != 0) + throw new IOException(fromCString(strerror(errno))) + + Array(readFd, writeFd) + } else Array(0, 0) + + private[this] val interruptFds = mkPipe() + private[this] val interruptReadFd = interruptFds(0) + private[this] val interruptWriteFd = interruptFds(1) + + private[this] def onInterrupt(signum: CInt): Unit = { + val _ = signum + val buf = stackalloc[Byte]() + write(interruptWriteFd, buf, 1.toULong) + () + } + + private[this] val termFds = mkPipe() + private[this] val termReadFd = termFds(0) + private[this] val termWriteFd = termFds(1) + + private[this] def onTerm(signum: CInt): Unit = { + val _ = signum + val buf = stackalloc[Byte]() + write(termWriteFd, buf, 1.toULong) + () + } + + private[this] val dumpFds = mkPipe() + private[this] val dumpReadFd = dumpFds(0) + private[this] val dumpWriteFd = dumpFds(1) + + private[this] def onDump(signum: CInt): Unit = { + val _ = signum + val buf = stackalloc[Byte]() + write(dumpWriteFd, buf, 1.toULong) + () + } + + private[this] def installHandler(signum: CInt, handler: CFuncPtr1[CInt, Unit]): Unit = { + val action = stackalloc[sigaction]() + action.sa_handler = handler + sigaddset(action.at2, 13) // mask SIGPIPE + if (sigaction(signum, action, null) != 0) + throw new IOException(fromCString(strerror(errno))) + } + + private[this] final val SIGINT = 2 + private[this] final val SIGTERM = 15 + private[this] final val SIGUSR1 = if (isLinux) 10 else if (isMac) 30 else 0 + private[this] final val SIGINFO = 29 + + if (isLinux || isMac) { + installHandler(SIGINT, onInterrupt(_)) + installHandler(SIGTERM, onTerm(_)) + installHandler(SIGUSR1, onDump(_)) + if (isMac) installHandler(SIGINFO, onDump(_)) + } + + def awaitInterrupt(poller: FileDescriptorPoller): IO[Unit] = + registerAndAwaitSignal(poller, interruptReadFd) + + def awaitTerm(poller: FileDescriptorPoller): IO[Unit] = + registerAndAwaitSignal(poller, termReadFd) + + def foreachDump(poller: FileDescriptorPoller, action: IO[Unit]): IO[Nothing] = + poller.registerFileDescriptor(dumpReadFd, true, false).use { handle => + (awaitSignal(handle, dumpReadFd) *> action).foreverM + } + + private[this] def registerAndAwaitSignal(poller: FileDescriptorPoller, fd: Int): IO[Unit] = + poller.registerFileDescriptor(fd, true, false).use(awaitSignal(_, fd)) + + private[this] def awaitSignal(handle: FileDescriptorPollHandle, fd: Int): IO[Unit] = + handle.pollReadRec(()) { _ => + IO { + val buf = stackalloc[Byte]() + val rtn = read(fd, buf, 1.toULong) + if (rtn >= 0) Either.unit + else if (errno == EAGAIN) Left(()) + else throw new IOException(fromCString(strerror(errno))) + } + } + +} diff --git a/core/native/src/main/scala/cats/effect/unsafe/EpollSystem.scala b/core/native/src/main/scala/cats/effect/unsafe/EpollSystem.scala new file mode 100644 index 0000000000..4e8dfd28d0 --- /dev/null +++ b/core/native/src/main/scala/cats/effect/unsafe/EpollSystem.scala @@ -0,0 +1,320 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +import cats.effect.std.Mutex +import cats.syntax.all._ + +import org.typelevel.scalaccompat.annotation._ + +import scala.annotation.tailrec +import scala.scalanative.annotation.alwaysinline +import scala.scalanative.libc.errno._ +import scala.scalanative.meta.LinktimeInfo +import scala.scalanative.posix.errno._ +import scala.scalanative.posix.string._ +import scala.scalanative.posix.unistd +import scala.scalanative.runtime._ +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +import java.io.IOException +import java.util.{Collections, IdentityHashMap, Set} + +object EpollSystem extends PollingSystem { + + import epoll._ + import epollImplicits._ + + private[this] final val MaxEvents = 64 + + type Api = FileDescriptorPoller + + def close(): Unit = () + + def makeApi(access: (Poller => Unit) => Unit): Api = + new FileDescriptorPollerImpl(access) + + def makePoller(): Poller = { + val fd = epoll_create1(0) + if (fd == -1) + throw new IOException(fromCString(strerror(errno))) + new Poller(fd) + } + + def closePoller(poller: Poller): Unit = poller.close() + + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean = + poller.poll(nanos) + + def needsPoll(poller: Poller): Boolean = poller.needsPoll() + + def interrupt(targetThread: Thread, targetPoller: Poller): Unit = () + + private final class FileDescriptorPollerImpl private[EpollSystem] ( + access: (Poller => Unit) => Unit) + extends FileDescriptorPoller { + + def registerFileDescriptor( + fd: Int, + reads: Boolean, + writes: Boolean + ): Resource[IO, FileDescriptorPollHandle] = + Resource { + (Mutex[IO], Mutex[IO]).flatMapN { (readMutex, writeMutex) => + IO.async_[(PollHandle, IO[Unit])] { cb => + access { epoll => + val handle = new PollHandle(readMutex, writeMutex) + epoll.register(fd, reads, writes, handle, cb) + } + } + } + } + + } + + private final class PollHandle( + readMutex: Mutex[IO], + writeMutex: Mutex[IO] + ) extends FileDescriptorPollHandle { + + private[this] var readReadyCounter = 0 + private[this] var readCallback: Either[Throwable, Int] => Unit = null + + private[this] var writeReadyCounter = 0 + private[this] var writeCallback: Either[Throwable, Int] => Unit = null + + def notify(events: Int): Unit = { + if ((events & EPOLLIN) != 0) { + val counter = readReadyCounter + 1 + readReadyCounter = counter + val cb = readCallback + readCallback = null + if (cb ne null) cb(Right(counter)) + } + if ((events & EPOLLOUT) != 0) { + val counter = writeReadyCounter + 1 + writeReadyCounter = counter + val cb = writeCallback + writeCallback = null + if (cb ne null) cb(Right(counter)) + } + } + + def pollReadRec[A, B](a: A)(f: A => IO[Either[A, B]]): IO[B] = + readMutex.lock.surround { + def go(a: A, before: Int): IO[B] = + f(a).flatMap { + case Left(a) => + IO(readReadyCounter).flatMap { after => + if (before != after) + // there was a read-ready notification since we started, try again immediately + go(a, after) + else + IO.asyncCheckAttempt[Int] { cb => + IO { + readCallback = cb + // check again before we suspend + val now = readReadyCounter + if (now != before) { + readCallback = null + Right(now) + } else Left(Some(IO(this.readCallback = null))) + } + }.flatMap(go(a, _)) + } + case Right(b) => IO.pure(b) + } + + IO(readReadyCounter).flatMap(go(a, _)) + } + + def pollWriteRec[A, B](a: A)(f: A => IO[Either[A, B]]): IO[B] = + writeMutex.lock.surround { + def go(a: A, before: Int): IO[B] = + f(a).flatMap { + case Left(a) => + IO(writeReadyCounter).flatMap { after => + if (before != after) + // there was a write-ready notification since we started, try again immediately + go(a, after) + else + IO.asyncCheckAttempt[Int] { cb => + IO { + writeCallback = cb + // check again before we suspend + val now = writeReadyCounter + if (now != before) { + writeCallback = null + Right(now) + } else Left(Some(IO(this.writeCallback = null))) + } + }.flatMap(go(a, _)) + } + case Right(b) => IO.pure(b) + } + + IO(writeReadyCounter).flatMap(go(a, _)) + } + + } + + final class Poller private[EpollSystem] (epfd: Int) { + + private[this] val handles: Set[PollHandle] = + Collections.newSetFromMap(new IdentityHashMap) + + private[EpollSystem] def close(): Unit = + if (unistd.close(epfd) != 0) + throw new IOException(fromCString(strerror(errno))) + + private[EpollSystem] def poll(timeout: Long): Boolean = { + + val events = stackalloc[epoll_event](MaxEvents.toULong) + var polled = false + + @tailrec + def processEvents(timeout: Int): Unit = { + + val triggeredEvents = epoll_wait(epfd, events, MaxEvents, timeout) + + if (triggeredEvents >= 0) { + polled = true + + var i = 0 + while (i < triggeredEvents) { + val event = events + i.toLong + val handle = fromPtr(event.data) + handle.notify(event.events.toInt) + i += 1 + } + } else if (errno != EINTR) { // spurious wake-up by signal + throw new IOException(fromCString(strerror(errno))) + } + + if (triggeredEvents >= MaxEvents) + processEvents(0) // drain the ready list + else + () + } + + val timeoutMillis = if (timeout == -1) -1 else (timeout / 1000000).toInt + processEvents(timeoutMillis) + + polled + } + + private[EpollSystem] def needsPoll(): Boolean = !handles.isEmpty() + + private[EpollSystem] def register( + fd: Int, + reads: Boolean, + writes: Boolean, + handle: PollHandle, + cb: Either[Throwable, (PollHandle, IO[Unit])] => Unit + ): Unit = { + val event = stackalloc[epoll_event]() + event.events = + (EPOLLET | (if (reads) EPOLLIN else 0) | (if (writes) EPOLLOUT else 0)).toUInt + event.data = toPtr(handle) + + val result = + if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event) != 0) + Left(new IOException(fromCString(strerror(errno)))) + else { + handles.add(handle) + val remove = IO { + handles.remove(handle) + if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, null) != 0) + throw new IOException(fromCString(strerror(errno))) + } + Right((handle, remove)) + } + + cb(result) + } + + @alwaysinline private[this] def toPtr(handle: PollHandle): Ptr[Byte] = + fromRawPtr(Intrinsics.castObjectToRawPtr(handle)) + + @alwaysinline private[this] def fromPtr[A](ptr: Ptr[Byte]): PollHandle = + Intrinsics.castRawPtrToObject(toRawPtr(ptr)).asInstanceOf[PollHandle] + } + + @nowarn212 + @extern + private object epoll { + + final val EPOLL_CTL_ADD = 1 + final val EPOLL_CTL_DEL = 2 + final val EPOLL_CTL_MOD = 3 + + final val EPOLLIN = 0x001 + final val EPOLLOUT = 0x004 + final val EPOLLONESHOT = 1 << 30 + final val EPOLLET = 1 << 31 + + type epoll_event + type epoll_data_t = Ptr[Byte] + + def epoll_create1(flags: Int): Int = extern + + def epoll_ctl(epfd: Int, op: Int, fd: Int, event: Ptr[epoll_event]): Int = extern + + def epoll_wait(epfd: Int, events: Ptr[epoll_event], maxevents: Int, timeout: Int): Int = + extern + + } + + private object epollImplicits { + + implicit final class epoll_eventOps(epoll_event: Ptr[epoll_event]) { + def events: CUnsignedInt = !epoll_event.asInstanceOf[Ptr[CUnsignedInt]] + def events_=(events: CUnsignedInt): Unit = + !epoll_event.asInstanceOf[Ptr[CUnsignedInt]] = events + + def data: epoll_data_t = { + val offset = + if (LinktimeInfo.target.arch == "x86_64") + sizeof[CUnsignedInt] + else + sizeof[Ptr[Byte]] + !(epoll_event.asInstanceOf[Ptr[Byte]] + offset).asInstanceOf[Ptr[epoll_data_t]] + } + + def data_=(data: epoll_data_t): Unit = { + val offset = + if (LinktimeInfo.target.arch == "x86_64") + sizeof[CUnsignedInt] + else + sizeof[Ptr[Byte]] + !(epoll_event.asInstanceOf[Ptr[Byte]] + offset).asInstanceOf[Ptr[epoll_data_t]] = data + } + } + + implicit val epoll_eventTag: Tag[epoll_event] = + if (LinktimeInfo.target.arch == "x86_64") + Tag + .materializeCArrayTag[Byte, Nat.Digit2[Nat._1, Nat._2]] + .asInstanceOf[Tag[epoll_event]] + else + Tag + .materializeCArrayTag[Byte, Nat.Digit2[Nat._1, Nat._6]] + .asInstanceOf[Tag[epoll_event]] + } +} diff --git a/core/native/src/main/scala/cats/effect/unsafe/EventLoopExecutorScheduler.scala b/core/native/src/main/scala/cats/effect/unsafe/EventLoopExecutorScheduler.scala new file mode 100644 index 0000000000..e9ea74a5e3 --- /dev/null +++ b/core/native/src/main/scala/cats/effect/unsafe/EventLoopExecutorScheduler.scala @@ -0,0 +1,179 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} +import scala.concurrent.duration._ +import scala.scalanative.libc.errno._ +import scala.scalanative.libc.string._ +import scala.scalanative.meta.LinktimeInfo +import scala.scalanative.posix.time._ +import scala.scalanative.posix.timeOps._ +import scala.scalanative.unsafe._ +import scala.util.control.NonFatal + +import java.util.{ArrayDeque, PriorityQueue} + +private[effect] final class EventLoopExecutorScheduler[P]( + pollEvery: Int, + system: PollingSystem.WithPoller[P]) + extends ExecutionContextExecutor + with Scheduler + with FiberExecutor { + + private[unsafe] val poller: P = system.makePoller() + + private[this] var needsReschedule: Boolean = true + + private[this] val executeQueue: ArrayDeque[Runnable] = new ArrayDeque + private[this] val sleepQueue: PriorityQueue[SleepTask] = new PriorityQueue + + private[this] val noop: Runnable = () => () + + private[this] def scheduleIfNeeded(): Unit = if (needsReschedule) { + ExecutionContext.global.execute(() => loop()) + needsReschedule = false + } + + final def execute(runnable: Runnable): Unit = { + scheduleIfNeeded() + executeQueue.addLast(runnable) + } + + final def sleep(delay: FiniteDuration, task: Runnable): Runnable = + if (delay <= Duration.Zero) { + execute(task) + noop + } else { + scheduleIfNeeded() + val now = monotonicNanos() + val sleepTask = new SleepTask(now + delay.toNanos, task) + sleepQueue.offer(sleepTask) + sleepTask + } + + def reportFailure(t: Throwable): Unit = t.printStackTrace() + + def nowMillis() = System.currentTimeMillis() + + override def nowMicros(): Long = + if (LinktimeInfo.isFreeBSD || LinktimeInfo.isLinux || LinktimeInfo.isMac) { + val ts = stackalloc[timespec]() + if (clock_gettime(CLOCK_REALTIME, ts) != 0) + throw new RuntimeException(fromCString(strerror(errno))) + ts.tv_sec * 1000000 + ts.tv_nsec / 1000 + } else { + super.nowMicros() + } + + def monotonicNanos() = System.nanoTime() + + private[this] def loop(): Unit = { + needsReschedule = false + + var continue = true + + while (continue) { + // execute the timers + val now = monotonicNanos() + while (!sleepQueue.isEmpty() && sleepQueue.peek().at <= now) { + val task = sleepQueue.poll() + try task.runnable.run() + catch { + case t if NonFatal(t) => reportFailure(t) + case t: Throwable => IOFiber.onFatalFailure(t) + } + } + + // do up to pollEvery tasks + var i = 0 + while (i < pollEvery && !executeQueue.isEmpty()) { + val runnable = executeQueue.poll() + try runnable.run() + catch { + case t if NonFatal(t) => reportFailure(t) + case t: Throwable => IOFiber.onFatalFailure(t) + } + i += 1 + } + + // finally we poll + val timeout = + if (!executeQueue.isEmpty()) + 0 + else if (!sleepQueue.isEmpty()) + Math.max(sleepQueue.peek().at - monotonicNanos(), 0) + else + -1 + + /* + * if `timeout == -1` and there are no remaining events to poll for, we should break the + * loop immediately. This is unfortunate but necessary so that the event loop can yield to + * the Scala Native global `ExecutionContext` which is currently hard-coded into every + * test framework, including MUnit, specs2, and Weaver. + */ + if (system.needsPoll(poller) || timeout != -1) + system.poll(poller, timeout, reportFailure) + else () + + continue = !executeQueue.isEmpty() || !sleepQueue.isEmpty() || system.needsPoll(poller) + } + + needsReschedule = true + } + + private[this] final class SleepTask( + val at: Long, + val runnable: Runnable + ) extends Runnable + with Comparable[SleepTask] { + + def run(): Unit = { + sleepQueue.remove(this) + () + } + + def compareTo(that: SleepTask): Int = + java.lang.Long.compare(this.at, that.at) + } + + def shutdown(): Unit = system.close() + + def liveTraces(): Map[IOFiber[_], Trace] = { + val builder = Map.newBuilder[IOFiber[_], Trace] + executeQueue.forEach { + case f: IOFiber[_] => builder += f -> f.captureTrace() + case _ => () + } + builder.result() + } + +} + +private object EventLoopExecutorScheduler { + lazy val global = { + val system = + if (LinktimeInfo.isLinux) + EpollSystem + else if (LinktimeInfo.isMac) + KqueueSystem + else + SleepSystem + new EventLoopExecutorScheduler[system.Poller](64, system) + } +} diff --git a/core/jvm/src/main/scala/cats/effect/unsafe/FiberMonitorCompanionPlatform.scala b/core/native/src/main/scala/cats/effect/unsafe/FiberMonitorPlatform.scala similarity index 62% rename from core/jvm/src/main/scala/cats/effect/unsafe/FiberMonitorCompanionPlatform.scala rename to core/native/src/main/scala/cats/effect/unsafe/FiberMonitorPlatform.scala index 891ac3349a..f1ebf234c4 100644 --- a/core/jvm/src/main/scala/cats/effect/unsafe/FiberMonitorCompanionPlatform.scala +++ b/core/native/src/main/scala/cats/effect/unsafe/FiberMonitorPlatform.scala @@ -14,19 +14,23 @@ * limitations under the License. */ -package cats.effect.unsafe - -import cats.effect.tracing.TracingConstants +package cats.effect +package unsafe import scala.concurrent.ExecutionContext -private[unsafe] trait FiberMonitorCompanionPlatform { +private[effect] abstract class FiberMonitorPlatform { def apply(compute: ExecutionContext): FiberMonitor = { - if (TracingConstants.isStackTracing && compute.isInstanceOf[WorkStealingThreadPool]) { - val wstp = compute.asInstanceOf[WorkStealingThreadPool] - new FiberMonitor(wstp) + if (false) { // LinktimeInfo.debugMode && LinktimeInfo.isWeakReferenceSupported + if (compute.isInstanceOf[EventLoopExecutorScheduler[_]]) { + val loop = compute.asInstanceOf[EventLoopExecutorScheduler[_]] + new FiberMonitorImpl(loop) + } else { + new FiberMonitorImpl(null) + } } else { - new FiberMonitor(null) + new NoOpFiberMonitor() } } + } diff --git a/core/native/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala b/core/native/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala index 97493bf0a9..39e06b5d69 100644 --- a/core/native/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala +++ b/core/native/src/main/scala/cats/effect/unsafe/IORuntimeBuilderPlatform.scala @@ -18,17 +18,41 @@ package cats.effect.unsafe private[unsafe] abstract class IORuntimeBuilderPlatform { self: IORuntimeBuilder => + protected var customPollingSystem: Option[PollingSystem] = None + + /** + * Override the default [[PollingSystem]] + */ + def setPollingSystem(system: PollingSystem): IORuntimeBuilder = { + if (customPollingSystem.isDefined) { + throw new RuntimeException("Polling system can only be set once") + } + customPollingSystem = Some(system) + this + } + protected def platformSpecificBuild: IORuntime = { val defaultShutdown: () => Unit = () => () - val (compute, computeShutdown) = - customCompute.getOrElse((IORuntime.defaultComputeExecutionContext, defaultShutdown)) + lazy val (loop, poller, loopDown) = IORuntime.createEventLoop( + customPollingSystem.getOrElse(IORuntime.createDefaultPollingSystem()) + ) + val (compute, pollers, computeShutdown) = + customCompute + .map { case (c, s) => (c, Nil, s) } + .getOrElse( + ( + loop, + List(poller), + loopDown + )) val (blocking, blockingShutdown) = customBlocking.getOrElse((compute, defaultShutdown)) val (scheduler, schedulerShutdown) = - customScheduler.getOrElse((IORuntime.defaultScheduler, defaultShutdown)) + customScheduler.getOrElse((loop, defaultShutdown)) val shutdown = () => { computeShutdown() blockingShutdown() schedulerShutdown() + extraPollers.foreach(_._2()) extraShutdownHooks.reverse.foreach(_()) } val runtimeConfig = customConfig.getOrElse(IORuntimeConfig()) @@ -37,6 +61,7 @@ private[unsafe] abstract class IORuntimeBuilderPlatform { self: IORuntimeBuilder computeTransform(compute), blockingTransform(blocking), scheduler, + pollers ::: extraPollers.map(_._1), shutdown, runtimeConfig ) diff --git a/core/native/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala b/core/native/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala index bdfc9592b6..075fed2b7e 100644 --- a/core/native/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala +++ b/core/native/src/main/scala/cats/effect/unsafe/IORuntimeCompanionPlatform.scala @@ -17,12 +17,29 @@ package cats.effect.unsafe import scala.concurrent.ExecutionContext +import scala.scalanative.meta.LinktimeInfo private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type => - def defaultComputeExecutionContext: ExecutionContext = QueueExecutorScheduler + def defaultComputeExecutionContext: ExecutionContext = EventLoopExecutorScheduler.global - def defaultScheduler: Scheduler = QueueExecutorScheduler + def defaultScheduler: Scheduler = EventLoopExecutorScheduler.global + + def createEventLoop( + system: PollingSystem + ): (ExecutionContext with Scheduler, system.Api, () => Unit) = { + val loop = new EventLoopExecutorScheduler[system.Poller](64, system) + val poller = loop.poller + (loop, system.makeApi(cb => cb(poller)), () => loop.shutdown()) + } + + def createDefaultPollingSystem(): PollingSystem = + if (LinktimeInfo.isLinux) + EpollSystem + else if (LinktimeInfo.isMac) + KqueueSystem + else + SleepSystem private[this] var _global: IORuntime = null @@ -41,13 +58,19 @@ private[unsafe] abstract class IORuntimeCompanionPlatform { this: IORuntime.type def global: IORuntime = { if (_global == null) { installGlobal { + val (loop, poller, loopDown) = createEventLoop(createDefaultPollingSystem()) IORuntime( - defaultComputeExecutionContext, - defaultComputeExecutionContext, - defaultScheduler, - () => resetGlobal(), + loop, + loop, + loop, + List(poller), + () => { + loopDown() + resetGlobal() + }, IORuntimeConfig()) } + () } _global diff --git a/core/native/src/main/scala/cats/effect/unsafe/KqueueSystem.scala b/core/native/src/main/scala/cats/effect/unsafe/KqueueSystem.scala new file mode 100644 index 0000000000..0e7aae2395 --- /dev/null +++ b/core/native/src/main/scala/cats/effect/unsafe/KqueueSystem.scala @@ -0,0 +1,298 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect +package unsafe + +import cats.effect.std.Mutex +import cats.syntax.all._ + +import org.typelevel.scalaccompat.annotation._ + +import scala.annotation.tailrec +import scala.collection.mutable.LongMap +import scala.scalanative.libc.errno._ +import scala.scalanative.posix.errno._ +import scala.scalanative.posix.string._ +import scala.scalanative.posix.time._ +import scala.scalanative.posix.timeOps._ +import scala.scalanative.posix.unistd +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +import java.io.IOException + +object KqueueSystem extends PollingSystem { + + import event._ + import eventImplicits._ + + private final val MaxEvents = 64 + + type Api = FileDescriptorPoller + + def close(): Unit = () + + def makeApi(access: (Poller => Unit) => Unit): FileDescriptorPoller = + new FileDescriptorPollerImpl(access) + + def makePoller(): Poller = { + val fd = kqueue() + if (fd == -1) + throw new IOException(fromCString(strerror(errno))) + new Poller(fd) + } + + def closePoller(poller: Poller): Unit = poller.close() + + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean = + poller.poll(nanos) + + def needsPoll(poller: Poller): Boolean = + poller.needsPoll() + + def interrupt(targetThread: Thread, targetPoller: Poller): Unit = () + + private final class FileDescriptorPollerImpl private[KqueueSystem] ( + access: (Poller => Unit) => Unit + ) extends FileDescriptorPoller { + def registerFileDescriptor( + fd: Int, + reads: Boolean, + writes: Boolean + ): Resource[IO, FileDescriptorPollHandle] = + Resource.eval { + (Mutex[IO], Mutex[IO]).mapN { + new PollHandle(access, fd, _, _) + } + } + } + + // A kevent is identified by the (ident, filter) pair; there may only be one unique kevent per kqueue + @inline private def encodeKevent(ident: Int, filter: Short): Long = + (filter.toLong << 32) | ident.toLong + + private final class PollHandle( + access: (Poller => Unit) => Unit, + fd: Int, + readMutex: Mutex[IO], + writeMutex: Mutex[IO] + ) extends FileDescriptorPollHandle { + + def pollReadRec[A, B](a: A)(f: A => IO[Either[A, B]]): IO[B] = + readMutex.lock.surround { + a.tailRecM { a => + f(a).flatTap { r => + if (r.isRight) + IO.unit + else + IO.async[Unit] { kqcb => + IO.async_[Option[IO[Unit]]] { cb => + access { kqueue => + kqueue.evSet(fd, EVFILT_READ, EV_ADD.toUShort, kqcb) + cb(Right(Some(IO(kqueue.removeCallback(fd, EVFILT_READ))))) + } + } + + } + } + } + } + + def pollWriteRec[A, B](a: A)(f: A => IO[Either[A, B]]): IO[B] = + writeMutex.lock.surround { + a.tailRecM { a => + f(a).flatTap { r => + if (r.isRight) + IO.unit + else + IO.async[Unit] { kqcb => + IO.async_[Option[IO[Unit]]] { cb => + access { kqueue => + kqueue.evSet(fd, EVFILT_WRITE, EV_ADD.toUShort, kqcb) + cb(Right(Some(IO(kqueue.removeCallback(fd, EVFILT_WRITE))))) + } + } + } + } + } + } + + } + + final class Poller private[KqueueSystem] (kqfd: Int) { + + private[this] val changelistArray = new Array[Byte](sizeof[kevent64_s].toInt * MaxEvents) + @inline private[this] def changelist = + changelistArray.atUnsafe(0).asInstanceOf[Ptr[kevent64_s]] + private[this] var changeCount = 0 + + private[this] val callbacks = new LongMap[Either[Throwable, Unit] => Unit]() + + private[KqueueSystem] def evSet( + ident: Int, + filter: Short, + flags: CUnsignedShort, + cb: Either[Throwable, Unit] => Unit + ): Unit = { + val change = changelist + changeCount.toLong + + change.ident = ident.toULong + change.filter = filter + change.flags = (flags.toInt | EV_ONESHOT).toUShort + + callbacks.update(encodeKevent(ident, filter), cb) + + changeCount += 1 + } + + private[KqueueSystem] def removeCallback(ident: Int, filter: Short): Unit = { + callbacks -= encodeKevent(ident, filter) + () + } + + private[KqueueSystem] def close(): Unit = + if (unistd.close(kqfd) != 0) + throw new IOException(fromCString(strerror(errno))) + + private[KqueueSystem] def poll(timeout: Long): Boolean = { + + val eventlist = stackalloc[kevent64_s](MaxEvents.toULong) + var polled = false + + @tailrec + def processEvents(timeout: Ptr[timespec], changeCount: Int, flags: Int): Unit = { + + val triggeredEvents = + kevent64( + kqfd, + changelist, + changeCount, + eventlist, + MaxEvents, + flags.toUInt, + timeout + ) + + if (triggeredEvents >= 0) { + polled = true + + var i = 0 + var event = eventlist + while (i < triggeredEvents) { + val kevent = encodeKevent(event.ident.toInt, event.filter) + val cb = callbacks.getOrNull(kevent) + callbacks -= kevent + + if (cb ne null) + cb( + if ((event.flags.toLong & EV_ERROR) != 0) + Left(new IOException(fromCString(strerror(event.data.toInt)))) + else Either.unit + ) + + i += 1 + event += 1 + } + } else if (errno != EINTR) { // spurious wake-up by signal + throw new IOException(fromCString(strerror(errno))) + } + + if (triggeredEvents >= MaxEvents) + processEvents(null, 0, KEVENT_FLAG_IMMEDIATE) // drain the ready list + else + () + } + + val timeoutSpec = + if (timeout <= 0) null + else { + val ts = stackalloc[timespec]() + ts.tv_sec = timeout / 1000000000 + ts.tv_nsec = timeout % 1000000000 + ts + } + + val flags = if (timeout == 0) KEVENT_FLAG_IMMEDIATE else KEVENT_FLAG_NONE + + processEvents(timeoutSpec, changeCount, flags) + changeCount = 0 + + polled + } + + def needsPoll(): Boolean = changeCount > 0 || callbacks.nonEmpty + } + + @nowarn212 + @extern + private object event { + // Derived from https://opensource.apple.com/source/xnu/xnu-7195.81.3/bsd/sys/event.h.auto.html + + final val EVFILT_READ = -1 + final val EVFILT_WRITE = -2 + + final val KEVENT_FLAG_NONE = 0x000000 + final val KEVENT_FLAG_IMMEDIATE = 0x000001 + + final val EV_ADD = 0x0001 + final val EV_DELETE = 0x0002 + final val EV_ONESHOT = 0x0010 + final val EV_CLEAR = 0x0020 + final val EV_ERROR = 0x4000 + + type kevent64_s + + def kqueue(): CInt = extern + + def kevent64( + kq: CInt, + changelist: Ptr[kevent64_s], + nchanges: CInt, + eventlist: Ptr[kevent64_s], + nevents: CInt, + flags: CUnsignedInt, + timeout: Ptr[timespec] + ): CInt = extern + + } + + private object eventImplicits { + + implicit final class kevent64_sOps(kevent64_s: Ptr[kevent64_s]) { + def ident: CUnsignedLongInt = !kevent64_s.asInstanceOf[Ptr[CUnsignedLongInt]] + def ident_=(ident: CUnsignedLongInt): Unit = + !kevent64_s.asInstanceOf[Ptr[CUnsignedLongInt]] = ident + + def filter: CShort = !(kevent64_s.asInstanceOf[Ptr[CShort]] + 4) + def filter_=(filter: CShort): Unit = + !(kevent64_s.asInstanceOf[Ptr[CShort]] + 4) = filter + + def flags: CUnsignedShort = !(kevent64_s.asInstanceOf[Ptr[CUnsignedShort]] + 5) + def flags_=(flags: CUnsignedShort): Unit = + !(kevent64_s.asInstanceOf[Ptr[CUnsignedShort]] + 5) = flags + + def data: CLong = !(kevent64_s.asInstanceOf[Ptr[CLong]] + 2) + + def udata: Ptr[Byte] = !(kevent64_s.asInstanceOf[Ptr[Ptr[Byte]]] + 3) + def udata_=(udata: Ptr[Byte]): Unit = + !(kevent64_s.asInstanceOf[Ptr[Ptr[Byte]]] + 3) = udata + } + + implicit val kevent64_sTag: Tag[kevent64_s] = + Tag.materializeCArrayTag[Byte, Nat.Digit2[Nat._4, Nat._8]].asInstanceOf[Tag[kevent64_s]] + } +} diff --git a/core/native/src/main/scala/cats/effect/unsafe/PollingExecutorScheduler.scala b/core/native/src/main/scala/cats/effect/unsafe/PollingExecutorScheduler.scala index 85d9143687..c37a16677f 100644 --- a/core/native/src/main/scala/cats/effect/unsafe/PollingExecutorScheduler.scala +++ b/core/native/src/main/scala/cats/effect/unsafe/PollingExecutorScheduler.scala @@ -17,65 +17,50 @@ package cats.effect package unsafe -import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} +import scala.concurrent.ExecutionContextExecutor import scala.concurrent.duration._ -import scala.scalanative.libc.errno -import scala.scalanative.meta.LinktimeInfo -import scala.scalanative.unsafe._ -import scala.util.control.NonFatal - -import java.util.{ArrayDeque, PriorityQueue} +@deprecated("Use default runtime with a custom PollingSystem", "3.6.0") abstract class PollingExecutorScheduler(pollEvery: Int) extends ExecutionContextExecutor - with Scheduler { - - private[this] var needsReschedule: Boolean = true - - private[this] val executeQueue: ArrayDeque[Runnable] = new ArrayDeque - private[this] val sleepQueue: PriorityQueue[SleepTask] = new PriorityQueue - - private[this] val noop: Runnable = () => () - - private[this] def scheduleIfNeeded(): Unit = if (needsReschedule) { - ExecutionContext.global.execute(() => loop()) - needsReschedule = false - } + with Scheduler { outer => + + private[this] val loop = new EventLoopExecutorScheduler( + pollEvery, + new PollingSystem { + type Api = outer.type + type Poller = outer.type + private[this] var needsPoll = true + def close(): Unit = () + def makeApi(access: (Poller => Unit) => Unit): Api = outer + def makePoller(): Poller = outer + def closePoller(poller: Poller): Unit = () + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean = { + needsPoll = + if (nanos == -1) + poller.poll(Duration.Inf) + else + poller.poll(nanos.nanos) + true + } + def needsPoll(poller: Poller) = needsPoll + def interrupt(targetThread: Thread, targetPoller: Poller): Unit = () + } + ) - final def execute(runnable: Runnable): Unit = { - scheduleIfNeeded() - executeQueue.addLast(runnable) - } + final def execute(runnable: Runnable): Unit = + loop.execute(runnable) final def sleep(delay: FiniteDuration, task: Runnable): Runnable = - if (delay <= Duration.Zero) { - execute(task) - noop - } else { - scheduleIfNeeded() - val now = monotonicNanos() - val sleepTask = new SleepTask(now + delay.toNanos, task) - sleepQueue.offer(sleepTask) - sleepTask - } + loop.sleep(delay, task) - def reportFailure(t: Throwable): Unit = t.printStackTrace() + def reportFailure(t: Throwable): Unit = loop.reportFailure(t) - def nowMillis() = System.currentTimeMillis() + def nowMillis() = loop.nowMillis() - override def nowMicros(): Long = - if (LinktimeInfo.isFreeBSD || LinktimeInfo.isLinux || LinktimeInfo.isMac) { - import scala.scalanative.posix.time._ - import scala.scalanative.posix.timeOps._ - val ts = stackalloc[timespec]() - if (clock_gettime(CLOCK_REALTIME, ts) != 0) - throw new RuntimeException(s"clock_gettime: ${errno.errno}") - ts.tv_sec * 1000000 + ts.tv_nsec / 1000 - } else { - super.nowMicros() - } + override def nowMicros(): Long = loop.nowMicros() - def monotonicNanos() = System.nanoTime() + def monotonicNanos() = loop.monotonicNanos() /** * @param timeout @@ -90,65 +75,4 @@ abstract class PollingExecutorScheduler(pollEvery: Int) */ protected def poll(timeout: Duration): Boolean - private[this] def loop(): Unit = { - needsReschedule = false - - var continue = true - - while (continue) { - // execute the timers - val now = monotonicNanos() - while (!sleepQueue.isEmpty() && sleepQueue.peek().at <= now) { - val task = sleepQueue.poll() - try task.runnable.run() - catch { - case t if NonFatal(t) => reportFailure(t) - case t: Throwable => IOFiber.onFatalFailure(t) - } - } - - // do up to pollEvery tasks - var i = 0 - while (i < pollEvery && !executeQueue.isEmpty()) { - val runnable = executeQueue.poll() - try runnable.run() - catch { - case t if NonFatal(t) => reportFailure(t) - case t: Throwable => IOFiber.onFatalFailure(t) - } - i += 1 - } - - // finally we poll - val timeout = - if (!executeQueue.isEmpty()) - Duration.Zero - else if (!sleepQueue.isEmpty()) - Math.max(sleepQueue.peek().at - monotonicNanos(), 0).nanos - else - Duration.Inf - - val needsPoll = poll(timeout) - - continue = needsPoll || !executeQueue.isEmpty() || !sleepQueue.isEmpty() - } - - needsReschedule = true - } - - private[this] final class SleepTask( - val at: Long, - val runnable: Runnable - ) extends Runnable - with Comparable[SleepTask] { - - def run(): Unit = { - sleepQueue.remove(this) - () - } - - def compareTo(that: SleepTask): Int = - java.lang.Long.compare(this.at, that.at) - } - } diff --git a/core/native/src/main/scala/cats/effect/unsafe/SchedulerCompanionPlatform.scala b/core/native/src/main/scala/cats/effect/unsafe/SchedulerCompanionPlatform.scala index 520156d42c..de7aee4774 100644 --- a/core/native/src/main/scala/cats/effect/unsafe/SchedulerCompanionPlatform.scala +++ b/core/native/src/main/scala/cats/effect/unsafe/SchedulerCompanionPlatform.scala @@ -18,6 +18,7 @@ package cats.effect.unsafe private[unsafe] abstract class SchedulerCompanionPlatform { this: Scheduler.type => - def createDefaultScheduler(): (Scheduler, () => Unit) = (QueueExecutorScheduler, () => ()) + def createDefaultScheduler(): (Scheduler, () => Unit) = + (EventLoopExecutorScheduler.global, () => ()) } diff --git a/core/native/src/main/scala/cats/effect/unsafe/QueueExecutorScheduler.scala b/core/native/src/main/scala/cats/effect/unsafe/SleepSystem.scala similarity index 56% rename from core/native/src/main/scala/cats/effect/unsafe/QueueExecutorScheduler.scala rename to core/native/src/main/scala/cats/effect/unsafe/SleepSystem.scala index 811d6f260e..cea4bca406 100644 --- a/core/native/src/main/scala/cats/effect/unsafe/QueueExecutorScheduler.scala +++ b/core/native/src/main/scala/cats/effect/unsafe/SleepSystem.scala @@ -14,19 +14,30 @@ * limitations under the License. */ -package cats.effect.unsafe +package cats.effect +package unsafe -import scala.concurrent.duration._ +object SleepSystem extends PollingSystem { -// JVM WSTP sets ExternalQueueTicks = 64 so we steal it here -private[effect] object QueueExecutorScheduler extends PollingExecutorScheduler(64) { + type Api = AnyRef + type Poller = AnyRef - def poll(timeout: Duration): Boolean = { - if (timeout != Duration.Zero && timeout.isFinite) { - val nanos = timeout.toNanos + def close(): Unit = () + + def makeApi(access: (Poller => Unit) => Unit): Api = this + + def makePoller(): Poller = this + + def closePoller(poller: Poller): Unit = () + + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit): Boolean = { + if (nanos > 0) Thread.sleep(nanos / 1000000, (nanos % 1000000).toInt) - } false } + def needsPoll(poller: Poller): Boolean = false + + def interrupt(targetThread: Thread, targetPoller: Poller): Unit = () + } diff --git a/core/shared/src/main/scala/cats/effect/ContState.scala b/core/shared/src/main/scala/cats/effect/ContState.scala index 043a567b74..6208075699 100644 --- a/core/shared/src/main/scala/cats/effect/ContState.scala +++ b/core/shared/src/main/scala/cats/effect/ContState.scala @@ -20,6 +20,8 @@ import cats.effect.unsafe.WeakBag import java.util.concurrent.atomic.AtomicReference +import Platform.static + /** * Possible states (held in the `AtomicReference`): * - "initial": `get() == null` @@ -49,6 +51,6 @@ private object ContState { * important. It must be private (so that no user code can access it), and it mustn't be used * for any other purpose. */ - private val waitingSentinel: Either[Throwable, Any] = + @static private val waitingSentinel: Either[Throwable, Any] = new Right(null) } diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 49185821a6..ee95addc6a 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -19,12 +19,15 @@ package cats.effect import cats.{ Align, Alternative, + Applicative, CommutativeApplicative, Eval, + Foldable, Functor, Id, Monad, Monoid, + NonEmptyParallel, Now, Parallel, Semigroup, @@ -39,16 +42,12 @@ import cats.effect.kernel.CancelScope import cats.effect.kernel.GenTemporal.handleDuration import cats.effect.std.{Backpressure, Console, Env, Supervisor, UUIDGen} import cats.effect.tracing.{Tracing, TracingEvent} +import cats.effect.unsafe.IORuntime +import cats.syntax._ import cats.syntax.all._ import scala.annotation.unchecked.uncheckedVariance -import scala.concurrent.{ - CancellationException, - ExecutionContext, - Future, - Promise, - TimeoutException -} +import scala.concurrent._ import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} import scala.util.control.NonFatal @@ -56,6 +55,8 @@ import scala.util.control.NonFatal import java.util.UUID import java.util.concurrent.Executor +import Platform.static + /** * A pure abstraction representing the intention to perform a side effect, where the result of * that side effect may be obtained synchronously (via return) or asynchronously (via callback). @@ -159,6 +160,15 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def <&[B](that: IO[B]): IO[A] = both(that).map { case (a, _) => a } + /** + * Transform certain errors using `pf` and rethrow them. Non matching errors and successful + * values are not affected by this function. + * + * Implements `ApplicativeError.adaptError`. + */ + def adaptError[E](pf: PartialFunction[Throwable, Throwable]): IO[A] = + recoverWith(pf.andThen(IO.raiseError[A] _)) + /** * Replaces the result of this IO with the given value. */ @@ -183,6 +193,15 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def attempt: IO[Either[Throwable, A]] = IO.Attempt(this) + /** + * Reifies the value or error of the source and performs an effect on the result, then + * recovers the original value or error back into `IO`. + * + * Implements `MonadError.attemptTap`. + */ + def attemptTap[B](f: Either[Throwable, A] => IO[B]): IO[A] = + attempt.flatTap(f).rethrow + /** * Replaces failures in this IO with an empty Option. */ @@ -357,11 +376,27 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { */ def evalOn(ec: ExecutionContext): IO[A] = IO.EvalOn(this, ec) + /** + * Shifts the execution of the current IO to the specified [[java.util.concurrent.Executor]]. + * + * @see + * [[evalOn]] + */ + def evalOnExecutor(executor: Executor): IO[A] = + IO.asyncForIO.evalOnExecutor(this, executor) + def startOn(ec: ExecutionContext): IO[FiberIO[A @uncheckedVariance]] = start.evalOn(ec) + def startOnExecutor(executor: Executor): IO[FiberIO[A @uncheckedVariance]] = + IO.asyncForIO.startOnExecutor(this, executor) + def backgroundOn(ec: ExecutionContext): ResourceIO[IO[OutcomeIO[A @uncheckedVariance]]] = Resource.make(startOn(ec))(_.cancel).map(_.join) + def backgroundOnExecutor( + executor: Executor): ResourceIO[IO[OutcomeIO[A @uncheckedVariance]]] = + IO.asyncForIO.backgroundOnExecutor(this, executor) + /** * Given an effect which might be [[uncancelable]] and a finalizer, produce an effect which * can be canceled by running the finalizer. This combinator is useful for handling scenarios @@ -454,11 +489,8 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def guarantee(finalizer: IO[Unit]): IO[A] = // this is a little faster than the default implementation, which helps Resource IO uncancelable { poll => - val handled = finalizer handleErrorWith { t => - IO.executionContext.flatMap(ec => IO(ec.reportFailure(t))) - } - - poll(this).onCancel(finalizer).onError(_ => handled).flatTap(_ => finalizer) + val onError: PartialFunction[Throwable, IO[Unit]] = { case _ => finalizer.reportError } + poll(this).onCancel(finalizer).onError(onError).flatTap(_ => finalizer) } /** @@ -484,12 +516,10 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def guaranteeCase(finalizer: OutcomeIO[A @uncheckedVariance] => IO[Unit]): IO[A] = IO.uncancelable { poll => val finalized = poll(this).onCancel(finalizer(Outcome.canceled)) - val handled = finalized.onError { e => - finalizer(Outcome.errored(e)).handleErrorWith { t => - IO.executionContext.flatMap(ec => IO(ec.reportFailure(t))) - } + val onError: PartialFunction[Throwable, IO[Unit]] = { + case e => finalizer(Outcome.errored(e)).reportError } - handled.flatTap(a => finalizer(Outcome.succeeded(IO.pure(a)))) + finalized.onError(onError).flatTap { (a: A) => finalizer(Outcome.succeeded(IO.pure(a))) } } def handleError[B >: A](f: Throwable => B): IO[B] = @@ -553,8 +583,50 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def onCancel(fin: IO[Unit]): IO[A] = IO.OnCancel(this, fin) - def onError(f: Throwable => IO[Unit]): IO[A] = - handleErrorWith(t => f(t).voidError *> IO.raiseError(t)) + @deprecated("Use onError with PartialFunction argument", "3.6.0") + def onError(f: Throwable => IO[Unit]): IO[A] = { + val pf: PartialFunction[Throwable, IO[Unit]] = { case t => f(t).reportError } + onError(pf) + } + + /** + * Execute a callback on certain errors, then rethrow them. Any non matching error is rethrown + * as well. + * + * Implements `ApplicativeError.onError`. + */ + def onError(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] = + handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit) *> IO.raiseError(t)) + + /** + * Like `Parallel.parProductL` + */ + def parProductL[B](iob: IO[B])(implicit P: NonEmptyParallel[IO]): IO[A] = + P.parProductL[A, B](this)(iob) + + /** + * Like `Parallel.parProductR` + */ + def parProductR[B](iob: IO[B])(implicit P: NonEmptyParallel[IO]): IO[B] = + P.parProductR[A, B](this)(iob) + + /** + * Like `Parallel.parProduct` + */ + def parProduct[B](iob: IO[B])(implicit P: NonEmptyParallel[IO]): IO[(A, B)] = + Parallel.parProduct(this, iob)(P) + + /** + * Like `Parallel.parReplicateA` + */ + def parReplicateA(n: Int): IO[List[A]] = + List.fill(n)(this).parSequence + + /** + * Like `Parallel.parReplicateA_` + */ + def parReplicateA_(n: Int): IO[Unit] = + List.fill(n)(this).parSequence_ def race[B](that: IO[B]): IO[Either[A, B]] = IO.race(this, that) @@ -651,7 +723,6 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { else flatMap(a => replicateA(n - 1).map(a :: _)) - // TODO PR to cats def replicateA_(n: Int): IO[Unit] = if (n <= 0) IO.unit @@ -864,6 +935,19 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def void: IO[Unit] = map(_ => ()) + /** + * Similar to [[IO.voidError]], but also reports the error. + */ + private[effect] def reportError(implicit ev: A <:< Unit): IO[Unit] = { + val _ = ev + asInstanceOf[IO[Unit]].handleErrorWith { t => + IO.executionContext.flatMap(ec => IO(ec.reportFailure(t))) + } + } + + /** + * Discard any error raised by the source. + */ def voidError(implicit ev: A <:< Unit): IO[Unit] = { val _ = ev asInstanceOf[IO[Unit]].handleError(_ => ()) @@ -1089,7 +1173,48 @@ private[effect] trait IOLowPriorityImplicits { } } -object IO extends IOCompanionPlatform with IOLowPriorityImplicits { +object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TupleParallelSyntax { + + implicit final def catsSyntaxParallelSequence1[T[_], A]( + toia: T[IO[A]]): ParallelSequenceOps1[T, IO, A] = new ParallelSequenceOps1(toia) + + implicit final def catsSyntaxParallelSequence_[T[_], A]( + tioa: T[IO[A]]): ParallelSequence_Ops[T, IO, A] = + new ParallelSequence_Ops(tioa) + + implicit final def catsSyntaxParallelUnorderedSequence[T[_], A]( + tioa: T[IO[A]]): ParallelUnorderedSequenceOps[T, IO, A] = + new ParallelUnorderedSequenceOps(tioa) + + implicit final def catsSyntaxParallelFlatSequence1[T[_], A]( + tioa: T[IO[T[A]]]): ParallelFlatSequenceOps1[T, IO, A] = + new ParallelFlatSequenceOps1(tioa) + + implicit final def catsSyntaxParallelUnorderedFlatSequence[T[_], A]( + tiota: T[IO[T[A]]]): ParallelUnorderedFlatSequenceOps[T, IO, A] = + new ParallelUnorderedFlatSequenceOps(tiota) + + implicit final def catsSyntaxParallelSequenceFilter[T[_], A]( + x: T[IO[Option[A]]]): ParallelSequenceFilterOps[T, IO, A] = + new ParallelSequenceFilterOps(x) + + implicit class IOFlatSequenceOps[T[_], A](tiota: T[IO[T[A]]]) { + def flatSequence( + implicit T: Traverse[T], + G: Applicative[IO], + F: cats.FlatMap[T]): IO[T[A]] = { + tiota.sequence(T, G).map(F.flatten) + } + } + + implicit class IOSequenceOps[T[_], A](tioa: T[IO[A]]) { + def sequence(implicit T: Traverse[T], G: Applicative[IO]): IO[T[A]] = T.sequence(tioa)(G) + + def sequence_(implicit F: Foldable[T], G: Applicative[IO]): IO[Unit] = F.sequence_(tioa)(G) + } + + @static private[this] val _alignForIO = new IOAlign + @static private[this] val _asyncForIO: kernel.Async[IO] = new IOAsync /** * Newtype encoding for an `IO` datatype that has a `cats.Applicative` capable of doing @@ -1249,7 +1374,12 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { * } * }}} * - * Note that `async` is uncancelable during its registration. + * @note + * `async` is always uncancelable during its registration. The created effect will be + * uncancelable during its execution if the registration callback provides no finalizer + * (i.e. evaluates to `None`). If you need the created task to be cancelable, return a + * finalizer effect upon the registration. In a rare case when there's nothing to finalize, + * you can return `Some(IO.unit)` for that. * * @see * [[async_]] for a simplified variant without a finalizer @@ -1302,7 +1432,9 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { * This function can be thought of as a safer, lexically-constrained version of `Promise`, * where `IO` is like a safer, lazy version of `Future`. * - * Also, note that `async` is uncancelable during its registration. + * @note + * `async_` is uncancelable during both its registration and execution. If you need an + * asyncronous effect to be cancelable, consider using `async` instead. * * @see * [[async]] for more generic version providing a finalizer @@ -1320,6 +1452,24 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { IOCont(body, Tracing.calculateTracingEvent(k)) } + /** + * An effect that requests self-cancelation on the current fiber. + * + * `canceled` has a return type of `IO[Unit]` instead of `IO[Nothing]` due to execution + * continuing in a masked region. In the following example, the fiber requests + * self-cancelation in a masked region, so cancelation is suppressed until the fiber is + * completely unmasked. `fa` will run but `fb` will not. If `canceled` had a return type of + * `IO[Nothing]`, then it would not be possible to continue execution to `fa` (there would be + * no `Nothing` value to pass to the `flatMap`). + * + * {{{ + * + * IO.uncancelable { _ => + * IO.canceled *> fa + * } *> fb + * + * }}} + */ def canceled: IO[Unit] = Canceled /** @@ -1397,23 +1547,59 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { */ def some[A](a: A): IO[Option[A]] = pure(Some(a)) + /** + * Like `Parallel.parTraverse` + */ + def parTraverse[T[_]: Traverse, A, B](ta: T[A])(f: A => IO[B]): IO[T[B]] = + ta.parTraverse(f) + + /** + * Like `Parallel.parTraverse_` + */ + def parTraverse_[T[_]: Foldable, A, B](ta: T[A])(f: A => IO[B]): IO[Unit] = + ta.parTraverse_(f) + /** * Like `Parallel.parTraverse`, but limits the degree of parallelism. */ def parTraverseN[T[_]: Traverse, A, B](n: Int)(ta: T[A])(f: A => IO[B]): IO[T[B]] = _asyncForIO.parTraverseN(n)(ta)(f) + /** + * Like `Parallel.parTraverse_`, but limits the degree of parallelism. + */ + def parTraverseN_[T[_]: Foldable, A, B](n: Int)(ta: T[A])(f: A => IO[B]): IO[Unit] = + _asyncForIO.parTraverseN_(n)(ta)(f) + + /** + * Like `Parallel.parSequence` + */ + def parSequence[T[_]: Traverse, A](tioa: T[IO[A]]): IO[T[A]] = + tioa.parSequence + + /** + * Like `Parallel.parSequence_` + */ + def parSequence_[T[_]: Foldable, A](tioa: T[IO[A]]): IO[Unit] = + tioa.parSequence_ + /** * Like `Parallel.parSequence`, but limits the degree of parallelism. */ - def parSequenceN[T[_]: Traverse, A](n: Int)(tma: T[IO[A]]): IO[T[A]] = - _asyncForIO.parSequenceN(n)(tma) + def parSequenceN[T[_]: Traverse, A](n: Int)(tioa: T[IO[A]]): IO[T[A]] = + _asyncForIO.parSequenceN(n)(tioa) + + /** + * Like `Parallel.parSequence_`, but limits the degree of parallelism. + */ + def parSequenceN_[T[_]: Foldable, A](n: Int)(tma: T[IO[A]]): IO[Unit] = + _asyncForIO.parSequenceN_(n)(tma) /** * Like `Parallel.parReplicateA`, but limits the degree of parallelism. */ - def parReplicateAN[A](n: Int)(replicas: Int, ma: IO[A]): IO[List[A]] = - _asyncForIO.parReplicateAN(n)(replicas, ma) + def parReplicateAN[A](n: Int)(replicas: Int, ioa: IO[A]): IO[List[A]] = + _asyncForIO.parReplicateAN(n)(replicas, ioa) /** * Lifts a pure value into `IO`. @@ -1484,6 +1670,17 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { def trace: IO[Trace] = IOTrace + def traverse[T[_]: Traverse, A, B](ta: T[A])(f: A => IO[B]): IO[T[B]] = + ta.traverse(f)(_asyncForIO) + + def traverse_[T[_]: Foldable, A, B](ta: T[A])(f: A => IO[B]): IO[Unit] = + ta.traverse_(f)(_asyncForIO) + + private[effect] def runtime: IO[IORuntime] = ReadRT + + def pollers: IO[List[Any]] = + IO.runtime.map(_.pollers) + def uncancelable[A](body: Poll[IO] => IO[A]): IO[A] = Uncancelable(body, Tracing.calculateTracingEvent(body)) @@ -1752,7 +1949,7 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { implicit def alignForIO: Align[IO] = _alignForIO - private[this] val _alignForIO = new Align[IO] { + private[this] final class IOAlign extends Align[IO] { def align[A, B](fa: IO[A], fb: IO[B]): IO[Ior[A, B]] = alignWith(fa, fb)(identity) @@ -1765,8 +1962,7 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { def functor: Functor[IO] = Functor[IO] } - private[this] val _asyncForIO: kernel.Async[IO] = new kernel.Async[IO] - with StackSafeMonad[IO] { + private[this] final class IOAsync extends kernel.Async[IO] with StackSafeMonad[IO] { override def asyncCheckAttempt[A]( k: (Either[Throwable, A] => Unit) => IO[Either[Option[IO[Unit]], A]]): IO[A] = @@ -1799,6 +1995,9 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { override def handleError[A](fa: IO[A])(f: Throwable => A): IO[A] = fa.handleError(f) + override def onError[A](fa: IO[A])(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] = + fa.onError(pf) + override def timeout[A](fa: IO[A], duration: FiniteDuration)( implicit ev: TimeoutException <:< Throwable): IO[A] = { fa.timeout(duration) @@ -2086,6 +2285,10 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { def tag = 23 } + private[effect] case object ReadRT extends IO[IORuntime] { + def tag = 24 + } + // INTERNAL, only created by the runloop itself as the terminal state of several operations private[effect] case object EndFiber extends IO[Nothing] { def tag = -1 diff --git a/core/shared/src/main/scala/cats/effect/IODeferred.scala b/core/shared/src/main/scala/cats/effect/IODeferred.scala index f7e395646b..64665277e7 100644 --- a/core/shared/src/main/scala/cats/effect/IODeferred.scala +++ b/core/shared/src/main/scala/cats/effect/IODeferred.scala @@ -29,9 +29,10 @@ private final class IODeferred[A] extends Deferred[IO, A] { val removed = callbacks.clearHandle(handle) if (!removed) { val clearCount = clearCounter.incrementAndGet() - if ((clearCount & (clearCount - 1)) == 0) // power of 2 + if ((clearCount & (clearCount - 1)) == 0) { // power of 2 clearCounter.addAndGet(-callbacks.pack(clearCount)) - () + () + } } } @@ -55,7 +56,7 @@ private final class IODeferred[A] extends Deferred[IO, A] { } private[this] val cell = new AtomicReference(initial) - private[this] val callbacks = CallbackStack[Right[Nothing, A]](null) + private[this] val callbacks = CallbackStack.of[Right[Nothing, A]](null) private[this] val clearCounter = new AtomicInteger def complete(a: A): IO[Boolean] = IO { diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index 2d9a9e8b78..68b905635b 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -28,6 +28,8 @@ import scala.util.control.NonFatal import java.util.concurrent.RejectedExecutionException import java.util.concurrent.atomic.AtomicBoolean +import Platform.static + /* * Rationale on memory barrier exploitation in this class... * @@ -85,7 +87,7 @@ private final class IOFiber[A]( private[this] var currentCtx: ExecutionContext = startEC private[this] val objectState: ArrayStack[AnyRef] = ArrayStack() private[this] val finalizers: ArrayStack[IO[Unit]] = ArrayStack() - private[this] val callbacks: CallbackStack[OutcomeIO[A]] = CallbackStack(cb) + private[this] val callbacks: CallbackStack[OutcomeIO[A]] = CallbackStack.of(cb) private[this] var resumeTag: Byte = ExecR private[this] var resumeIO: IO[Any] = startIO private[this] val runtime: IORuntime = rt @@ -96,7 +98,7 @@ private final class IOFiber[A]( * Ideally these would be on the stack, but they can't because we sometimes need to * relocate our runloop to another fiber. */ - private[this] var conts: ByteStack = _ + private[this] var conts: ByteStack.T = _ private[this] var canceled: Boolean = false private[this] var masks: Int = 0 @@ -546,7 +548,8 @@ private final class IOFiber[A]( masks += 1 val id = masks val poll = new Poll[IO] { - def apply[B](ioa: IO[B]) = IO.Uncancelable.UnmaskRunLoop(ioa, id, IOFiber.this) + def apply[B](ioa: IO[B]): IO[B] = + IO.Uncancelable.UnmaskRunLoop(ioa, id, IOFiber.this) } val next = @@ -945,9 +948,11 @@ private final class IOFiber[A]( val scheduler = runtime.scheduler val cancelIO = - if (scheduler.isInstanceOf[WorkStealingThreadPool]) { + if (scheduler.isInstanceOf[WorkStealingThreadPool[_]]) { val cancel = - scheduler.asInstanceOf[WorkStealingThreadPool].sleepInternal(delay, cb) + scheduler + .asInstanceOf[WorkStealingThreadPool[_]] + .sleepInternal(delay, cb) IO.Delay(cancel, null) } else { val cancel = scheduler.sleep(delay, () => cb(RightUnit)) @@ -993,8 +998,8 @@ private final class IOFiber[A]( if (cur.hint eq IOFiber.TypeBlocking) { val ec = currentCtx - if (ec.isInstanceOf[WorkStealingThreadPool]) { - val wstp = ec.asInstanceOf[WorkStealingThreadPool] + if (ec.isInstanceOf[WorkStealingThreadPool[_]]) { + val wstp = ec.asInstanceOf[WorkStealingThreadPool[_]] if (wstp.canExecuteBlockingCode()) { wstp.prepareForBlocking() @@ -1015,6 +1020,20 @@ private final class IOFiber[A]( } else { blockingFallback(cur) } + } else if (isVirtualThread(Thread.currentThread())) { + var error: Throwable = null + val r = + try { + cur.thunk() + } catch { + case t if NonFatal(t) => + error = t + case t: Throwable => + onFatalFailure(t) + } + + val next = if (error eq null) succeeded(r, 0) else failed(error, 0) + runLoop(next, nextCancelation, nextAutoCede) } else { blockingFallback(cur) } @@ -1031,6 +1050,10 @@ private final class IOFiber[A]( case 23 => runLoop(succeeded(Trace(tracingEvents), 0), nextCancelation, nextAutoCede) + + /* ReadRT */ + case 24 => + runLoop(succeeded(runtime, 0), nextCancelation, nextAutoCede) } } } @@ -1295,8 +1318,8 @@ private final class IOFiber[A]( private[this] def rescheduleFiber(ec: ExecutionContext, fiber: IOFiber[_]): Unit = { if (Platform.isJvm) { - if (ec.isInstanceOf[WorkStealingThreadPool]) { - val wstp = ec.asInstanceOf[WorkStealingThreadPool] + if (ec.isInstanceOf[WorkStealingThreadPool[_]]) { + val wstp = ec.asInstanceOf[WorkStealingThreadPool[_]] wstp.reschedule(fiber) } else { scheduleOnForeignEC(ec, fiber) @@ -1308,8 +1331,8 @@ private final class IOFiber[A]( private[this] def scheduleFiber(ec: ExecutionContext, fiber: IOFiber[_]): Unit = { if (Platform.isJvm) { - if (ec.isInstanceOf[WorkStealingThreadPool]) { - val wstp = ec.asInstanceOf[WorkStealingThreadPool] + if (ec.isInstanceOf[WorkStealingThreadPool[_]]) { + val wstp = ec.asInstanceOf[WorkStealingThreadPool[_]] wstp.execute(fiber) } else { scheduleOnForeignEC(ec, fiber) @@ -1532,11 +1555,11 @@ private final class IOFiber[A]( private object IOFiber { /* prefetch */ - private[IOFiber] val TypeBlocking = Sync.Type.Blocking - private[IOFiber] val OutcomeCanceled = Outcome.Canceled() - private[effect] val RightUnit = Right(()) + @static private[IOFiber] val TypeBlocking = Sync.Type.Blocking + @static private[IOFiber] val OutcomeCanceled = Outcome.Canceled() + @static private[effect] val RightUnit = Right(()) - def onFatalFailure(t: Throwable): Null = { + @static def onFatalFailure(t: Throwable): Nothing = { val interrupted = Thread.interrupted() if (IORuntime.globalFatalFailureHandled.compareAndSet(false, true)) { diff --git a/core/shared/src/main/scala/cats/effect/SyncIO.scala b/core/shared/src/main/scala/cats/effect/SyncIO.scala index 9a010548d7..7ea67fd474 100644 --- a/core/shared/src/main/scala/cats/effect/SyncIO.scala +++ b/core/shared/src/main/scala/cats/effect/SyncIO.scala @@ -28,6 +28,8 @@ import scala.concurrent.duration._ import scala.util.Try import scala.util.control.NonFatal +import Platform.static + /** * A pure abstraction representing the intention to perform a side effect, where the result of * that side effect is obtained synchronously. @@ -396,7 +398,8 @@ private[effect] trait SyncIOLowPriorityImplicits { object SyncIO extends SyncIOCompanionPlatform with SyncIOLowPriorityImplicits { - private[this] val Delay = Sync.Type.Delay + @static private[this] val Delay = Sync.Type.Delay + @static private[this] val _syncForSyncIO: Sync[SyncIO] = new SyncIOSync // constructors @@ -576,48 +579,48 @@ object SyncIO extends SyncIOCompanionPlatform with SyncIOLowPriorityImplicits { def functor: Functor[SyncIO] = Functor[SyncIO] } - private[this] val _syncForSyncIO: Sync[SyncIO] = - new Sync[SyncIO] + private[this] final class SyncIOSync + extends Sync[SyncIO] with StackSafeMonad[SyncIO] with MonadCancel.Uncancelable[SyncIO, Throwable] { - def pure[A](x: A): SyncIO[A] = - SyncIO.pure(x) + def pure[A](x: A): SyncIO[A] = + SyncIO.pure(x) - def raiseError[A](e: Throwable): SyncIO[A] = - SyncIO.raiseError(e) + def raiseError[A](e: Throwable): SyncIO[A] = + SyncIO.raiseError(e) - def handleErrorWith[A](fa: SyncIO[A])(f: Throwable => SyncIO[A]): SyncIO[A] = - fa.handleErrorWith(f) + def handleErrorWith[A](fa: SyncIO[A])(f: Throwable => SyncIO[A]): SyncIO[A] = + fa.handleErrorWith(f) - def flatMap[A, B](fa: SyncIO[A])(f: A => SyncIO[B]): SyncIO[B] = - fa.flatMap(f) + def flatMap[A, B](fa: SyncIO[A])(f: A => SyncIO[B]): SyncIO[B] = + fa.flatMap(f) - def monotonic: SyncIO[FiniteDuration] = - SyncIO.monotonic + def monotonic: SyncIO[FiniteDuration] = + SyncIO.monotonic - def realTime: SyncIO[FiniteDuration] = - SyncIO.realTime + def realTime: SyncIO[FiniteDuration] = + SyncIO.realTime - def suspend[A](hint: Sync.Type)(thunk: => A): SyncIO[A] = - Suspend(hint, () => thunk) + def suspend[A](hint: Sync.Type)(thunk: => A): SyncIO[A] = + Suspend(hint, () => thunk) - override def attempt[A](fa: SyncIO[A]): SyncIO[Either[Throwable, A]] = - fa.attempt + override def attempt[A](fa: SyncIO[A]): SyncIO[Either[Throwable, A]] = + fa.attempt - override def redeem[A, B](fa: SyncIO[A])(recover: Throwable => B, f: A => B): SyncIO[B] = - fa.redeem(recover, f) + override def redeem[A, B](fa: SyncIO[A])(recover: Throwable => B, f: A => B): SyncIO[B] = + fa.redeem(recover, f) - override def redeemWith[A, B]( - fa: SyncIO[A])(recover: Throwable => SyncIO[B], bind: A => SyncIO[B]): SyncIO[B] = - fa.redeemWith(recover, bind) + override def redeemWith[A, B]( + fa: SyncIO[A])(recover: Throwable => SyncIO[B], bind: A => SyncIO[B]): SyncIO[B] = + fa.redeemWith(recover, bind) - override def unit: SyncIO[Unit] = - SyncIO.unit + override def unit: SyncIO[Unit] = + SyncIO.unit - def forceR[A, B](fa: SyncIO[A])(fb: SyncIO[B]): SyncIO[B] = - fa.attempt.productR(fb) - } + def forceR[A, B](fa: SyncIO[A])(fb: SyncIO[B]): SyncIO[B] = + fa.attempt.productR(fb) + } implicit def syncForSyncIO: Sync[SyncIO] with MonadCancel[SyncIO, Throwable] = _syncForSyncIO diff --git a/core/shared/src/main/scala/cats/effect/package.scala b/core/shared/src/main/scala/cats/effect/package.scala index b21f99941d..0270eae439 100644 --- a/core/shared/src/main/scala/cats/effect/package.scala +++ b/core/shared/src/main/scala/cats/effect/package.scala @@ -80,6 +80,4 @@ package object effect { val Ref = cekernel.Ref private[effect] type IOLocalState = scala.collection.immutable.Map[IOLocal[_], Any] - - private[effect] type ByteStack = ByteStack.T } diff --git a/core/shared/src/main/scala/cats/effect/tracing/RingBuffer.scala b/core/shared/src/main/scala/cats/effect/tracing/RingBuffer.scala index a0b25be91a..1585c0d6c9 100644 --- a/core/shared/src/main/scala/cats/effect/tracing/RingBuffer.scala +++ b/core/shared/src/main/scala/cats/effect/tracing/RingBuffer.scala @@ -14,7 +14,10 @@ * limitations under the License. */ -package cats.effect.tracing +package cats.effect +package tracing + +import Platform.static private[effect] final class RingBuffer private (logSize: Int) { @@ -59,6 +62,6 @@ private[effect] final class RingBuffer private (logSize: Int) { } private[effect] object RingBuffer { - def empty(logSize: Int): RingBuffer = + @static def empty(logSize: Int): RingBuffer = new RingBuffer(logSize) } diff --git a/core/shared/src/main/scala/cats/effect/tracing/Tracing.scala b/core/shared/src/main/scala/cats/effect/tracing/Tracing.scala index a41196ac75..3a6acb3f00 100644 --- a/core/shared/src/main/scala/cats/effect/tracing/Tracing.scala +++ b/core/shared/src/main/scala/cats/effect/tracing/Tracing.scala @@ -14,33 +14,36 @@ * limitations under the License. */ -package cats.effect.tracing - -import cats.effect.{IOFiber, Trace} +package cats.effect +package tracing import scala.collection.mutable.ArrayBuffer +import Platform.static + +private[effect] final class Tracing + private[effect] object Tracing extends TracingPlatform { import TracingConstants._ - private[this] final val TurnRight = "╰" + @static private[this] final val TurnRight = "╰" // private[this] final val InverseTurnRight = "╭" - private[this] final val Junction = "├" + @static private[this] final val Junction = "├" // private[this] final val Line = "│" - private[tracing] def buildEvent(): TracingEvent = { + @static private[tracing] def buildEvent(): TracingEvent = { new TracingEvent.StackTrace() } - private[this] final val runLoopFilter: Array[String] = + @static private[this] final val runLoopFilter: Array[String] = Array( "cats.effect.", "scala.runtime.", "scala.scalajs.runtime.", "scala.scalanative.runtime.") - private[tracing] final val stackTraceClassNameFilter: Array[String] = Array( + @static private[tracing] final val stackTraceClassNameFilter: Array[String] = Array( "cats.", "sbt.", "java.", @@ -50,7 +53,7 @@ private[effect] object Tracing extends TracingPlatform { "org.scalajs." ) - private[tracing] def combineOpAndCallSite( + @static private[tracing] def combineOpAndCallSite( methodSite: StackTraceElement, callSite: StackTraceElement): StackTraceElement = { val methodSiteMethodName = methodSite.getMethodName @@ -64,7 +67,7 @@ private[effect] object Tracing extends TracingPlatform { ) } - private[tracing] def isInternalClass(className: String): Boolean = { + @static private[tracing] def isInternalClass(className: String): Boolean = { var i = 0 val len = stackTraceClassNameFilter.length while (i < len) { @@ -75,7 +78,7 @@ private[effect] object Tracing extends TracingPlatform { false } - private[this] def getOpAndCallSite( + @static private[this] def getOpAndCallSite( stackTrace: Array[StackTraceElement]): StackTraceElement = { val len = stackTrace.length var idx = 1 @@ -98,7 +101,10 @@ private[effect] object Tracing extends TracingPlatform { null } - def augmentThrowable(enhancedExceptions: Boolean, t: Throwable, events: RingBuffer): Unit = { + @static def augmentThrowable( + enhancedExceptions: Boolean, + t: Throwable, + events: RingBuffer): Unit = { def applyRunLoopFilter(ste: StackTraceElement): Boolean = { val name = ste.getClassName var i = 0 @@ -144,13 +150,13 @@ private[effect] object Tracing extends TracingPlatform { } } - def getFrames(events: RingBuffer): List[StackTraceElement] = + @static def getFrames(events: RingBuffer): List[StackTraceElement] = events .toList() .collect { case ev: TracingEvent.StackTrace => getOpAndCallSite(ev.getStackTrace) } .filter(_ ne null) - def prettyPrint(trace: Trace): String = { + @static def prettyPrint(trace: Trace): String = { val frames = trace.toList frames @@ -163,7 +169,7 @@ private[effect] object Tracing extends TracingPlatform { .mkString(System.lineSeparator()) } - def captureTrace(runnable: Runnable): Option[(Runnable, Trace)] = { + @static def captureTrace(runnable: Runnable): Option[(Runnable, Trace)] = { runnable match { case f: IOFiber[_] if f.isDone => None case f: IOFiber[_] => Some(runnable -> f.captureTrace()) diff --git a/core/shared/src/main/scala/cats/effect/unsafe/IORuntime.scala b/core/shared/src/main/scala/cats/effect/unsafe/IORuntime.scala index dd845a2170..0aa6b79cb5 100644 --- a/core/shared/src/main/scala/cats/effect/unsafe/IORuntime.scala +++ b/core/shared/src/main/scala/cats/effect/unsafe/IORuntime.scala @@ -17,6 +17,8 @@ package cats.effect package unsafe +import cats.effect.Platform.static + import scala.concurrent.ExecutionContext import java.util.concurrent.atomic.AtomicBoolean @@ -38,6 +40,7 @@ final class IORuntime private[unsafe] ( val compute: ExecutionContext, private[effect] val blocking: ExecutionContext, val scheduler: Scheduler, + private[effect] val pollers: List[Any], private[effect] val fiberMonitor: FiberMonitor, val shutdown: () => Unit, val config: IORuntimeConfig @@ -57,34 +60,62 @@ final class IORuntime private[unsafe] ( } object IORuntime extends IORuntimeCompanionPlatform { + def apply( compute: ExecutionContext, blocking: ExecutionContext, scheduler: Scheduler, + pollers: List[Any], shutdown: () => Unit, config: IORuntimeConfig): IORuntime = { val fiberMonitor = FiberMonitor(compute) val unregister = registerFiberMonitorMBean(fiberMonitor) - val unregisterAndShutdown = () => { + def unregisterAndShutdown: () => Unit = () => { unregister() shutdown() + allRuntimes.remove(runtime, runtime.hashCode()) } - val runtime = - new IORuntime(compute, blocking, scheduler, fiberMonitor, unregisterAndShutdown, config) + lazy val runtime = + new IORuntime( + compute, + blocking, + scheduler, + pollers, + fiberMonitor, + unregisterAndShutdown, + config) allRuntimes.put(runtime, runtime.hashCode()) runtime } + def apply( + compute: ExecutionContext, + blocking: ExecutionContext, + scheduler: Scheduler, + shutdown: () => Unit, + config: IORuntimeConfig): IORuntime = + apply(compute, blocking, scheduler, Nil, shutdown, config) + + @deprecated("Preserved for bincompat", "3.6.0") + private[unsafe] def apply( + compute: ExecutionContext, + blocking: ExecutionContext, + scheduler: Scheduler, + fiberMonitor: FiberMonitor, + shutdown: () => Unit, + config: IORuntimeConfig): IORuntime = + new IORuntime(compute, blocking, scheduler, Nil, fiberMonitor, shutdown, config) + def builder(): IORuntimeBuilder = IORuntimeBuilder() private[effect] def testRuntime(ec: ExecutionContext, scheduler: Scheduler): IORuntime = - new IORuntime(ec, ec, scheduler, new NoOpFiberMonitor(), () => (), IORuntimeConfig()) + new IORuntime(ec, ec, scheduler, Nil, new NoOpFiberMonitor(), () => (), IORuntimeConfig()) - private[effect] final val allRuntimes: ThreadSafeHashtable[IORuntime] = + @static private[effect] final val allRuntimes: ThreadSafeHashtable[IORuntime] = new ThreadSafeHashtable(4) - private[effect] final val globalFatalFailureHandled: AtomicBoolean = + @static private[effect] final val globalFatalFailureHandled: AtomicBoolean = new AtomicBoolean(false) } diff --git a/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeBuilder.scala b/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeBuilder.scala index 4a07f52943..f0e44169bf 100644 --- a/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeBuilder.scala +++ b/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeBuilder.scala @@ -32,7 +32,8 @@ final class IORuntimeBuilder protected ( protected var customScheduler: Option[(Scheduler, () => Unit)] = None, protected var extraShutdownHooks: List[() => Unit] = Nil, protected var builderExecuted: Boolean = false, - protected var failureReporter: Throwable => Unit = _.printStackTrace() + protected var failureReporter: Throwable => Unit = _.printStackTrace(), + protected var extraPollers: List[(Any, () => Unit)] = Nil ) extends IORuntimeBuilderPlatform { /** @@ -119,6 +120,11 @@ final class IORuntimeBuilder protected ( this } + def addPoller(poller: Any, shutdown: () => Unit): IORuntimeBuilder = { + extraPollers = (poller, shutdown) :: extraPollers + this + } + def setFailureReporter(f: Throwable => Unit) = { failureReporter = f this diff --git a/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeConfig.scala b/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeConfig.scala index 31e55196be..546ffbe852 100644 --- a/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeConfig.scala +++ b/core/shared/src/main/scala/cats/effect/unsafe/IORuntimeConfig.scala @@ -233,7 +233,7 @@ object IORuntimeConfig extends IORuntimeConfigCompanionPlatform { shutdownHookTimeout: Duration, reportUnhandledFiberErrors: Boolean, cpuStarvationCheckInterval: FiniteDuration, - cpuStarvationCheckInitialDelay: FiniteDuration, + cpuStarvationCheckInitialDelay: Duration, cpuStarvationCheckThreshold: Double ): IORuntimeConfig = { new IORuntimeConfig( @@ -248,4 +248,26 @@ object IORuntimeConfig extends IORuntimeConfigCompanionPlatform { cpuStarvationCheckThreshold ) } + + def apply( + cancelationCheckThreshold: Int, + autoYieldThreshold: Int, + enhancedExceptions: Boolean, + traceBufferSize: Int, + shutdownHookTimeout: Duration, + reportUnhandledFiberErrors: Boolean, + cpuStarvationCheckInterval: FiniteDuration, + cpuStarvationCheckInitialDelay: FiniteDuration, + cpuStarvationCheckThreshold: Double): IORuntimeConfig = + apply( + cancelationCheckThreshold, + autoYieldThreshold, + enhancedExceptions, + traceBufferSize, + shutdownHookTimeout, + reportUnhandledFiberErrors, + cpuStarvationCheckInterval, + cpuStarvationCheckInitialDelay: Duration, + cpuStarvationCheckThreshold + ) } diff --git a/docs/faq.md b/docs/faq.md index ffd52bcf0c..e437f7f1b6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -9,7 +9,7 @@ title: FAQ ```scala-cli //> using scala "2.13.8" -//> using lib "org.typelevel::cats-effect::3.5.5" +//> using dep "org.typelevel::cats-effect::3.5.5" import cats.effect._ diff --git a/docs/std/async-await.md b/docs/std/async-await.md index ded96c006e..031f6c756b 100644 --- a/docs/std/async-await.md +++ b/docs/std/async-await.md @@ -9,7 +9,7 @@ Syntactic sugar that allows for direct-style programming. This feature is provided by an incubating side-library that lives in another repository ([typelevel/cats-effect-cps](https://github.com/typelevel/cats-effect-cps)), so that it can undergo updates at a higher pace than Cats Effect if required. -Because it relies on experimental functionality from the compiler, cats-effec-cps ought to be considered experimental until upstream support stabilises, at which point it will be folded into Cats Effect itself. +Because it relies on experimental functionality from the compiler, cats-effect-cps ought to be considered experimental until upstream support stabilises, at which point it will be folded into Cats Effect itself. ## Installation diff --git a/docs/third-party-resources.md b/docs/third-party-resources.md index c0ef43c99c..387a79070e 100644 --- a/docs/third-party-resources.md +++ b/docs/third-party-resources.md @@ -31,5 +31,5 @@ These are some selected videos on various topics in Cats Effect: * [Concurrency with Cats Effect](https://www.youtube.com/watch?v=Gig-f_HXvLI&ab_channel=FunctionalTV), Michael Pilquist * [How do Fibers work?](https://www.youtube.com/watch?v=x5_MmZVLiSM&ab_channel=ScalaWorld), Fabio Labella * [Cancellation in Cats Effect](https://www.youtube.com/watch?v=X9u3rgPz_zE&t=1002s&ab_channel=ScalaintheCity), Daniel Ciocîrlan -* [Intro to Cats Effect](https://www.youtube.com/watch?v=83pXEdCpY4A&ab_channel=thoughtbot), Gavin Biesi +* [Intro to Cats Effect](https://www.youtube.com/watch?v=83pXEdCpY4A&ab_channel=thoughtbot), Gavin Bisesi * [The IO Monad for Scala](https://www.youtube.com/watch?v=8_TWM2t97r4&t=811s&ab_channel=ScalaIOFR), Gabriel Volpe diff --git a/docs/tutorial.md b/docs/tutorial.md index d2f9b326d8..d3c02e753e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -160,7 +160,7 @@ import cats.effect.{IO, Resource} import java.io.{File, FileInputStream} def inputStream(f: File): Resource[IO, FileInputStream] = - Resource.fromAutoCloseable(IO(new FileInputStream(f))) + Resource.fromAutoCloseable(IO.blocking(new FileInputStream(f))) ``` That code is way simpler, but with that code we would not have control over what @@ -179,11 +179,11 @@ import java.io._ def inputOutputStreams(in: File, out: File): Resource[IO, (InputStream, OutputStream)] = ??? // transfer will do the real work -def transfer(origin: InputStream, destination: OutputStream): IO[Long] = ??? +def transfer(origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): IO[Long] = ??? def copy(origin: File, destination: File): IO[Long] = inputOutputStreams(origin, destination).use { case (in, out) => - transfer(in, out) + transfer(in, out, new Array[Byte](1024 * 10), 0) } ``` @@ -218,17 +218,17 @@ import java.io._ // function inputOutputStreams not needed // transfer will do the real work -def transfer(origin: InputStream, destination: OutputStream): IO[Long] = ??? +def transfer(origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): IO[Long] = ??? def copy(origin: File, destination: File): IO[Long] = { - val inIO: IO[InputStream] = IO(new FileInputStream(origin)) - val outIO:IO[OutputStream] = IO(new FileOutputStream(destination)) + val inIO: IO[InputStream] = IO.blocking(new FileInputStream(origin)) + val outIO:IO[OutputStream] = IO.blocking(new FileOutputStream(destination)) (inIO, outIO) // Stage 1: Getting resources .tupled // From (IO[InputStream], IO[OutputStream]) to IO[(InputStream, OutputStream)] .bracket{ case (in, out) => - transfer(in, out) // Stage 2: Using resources (for copying data, in this case) + transfer(in, out, new Array[Byte](1024 * 10), 0L) // Stage 2: Using resources (for copying data, in this case) } { case (in, out) => // Stage 3: Freeing resources (IO(in.close()), IO(out.close())) @@ -259,28 +259,22 @@ Finally we have our streams ready to go! We have to focus now on coding `transfer`. That function will have to define a loop that at each iteration reads data from the input stream into a buffer, and then writes the buffer contents into the output stream. At the same time, the loop will keep a counter -of the bytes transferred. To reuse the same buffer we should define it outside -the main loop, and leave the actual transmission of data to another function -`transmit` that uses that loop. Something like: +of the bytes transferred. ```scala mdoc:compile-only import cats.effect.IO -import cats.syntax.all._ import java.io._ -def transmit(origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): IO[Long] = +def transfer(origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): IO[Long] = for { - amount <- IO.blocking(origin.read(buffer, 0, buffer.size)) - count <- if(amount > -1) IO.blocking(destination.write(buffer, 0, amount)) >> transmit(origin, destination, buffer, acc + amount) - else IO.pure(acc) // End of read stream reached (by java.io.InputStream contract), nothing to write - } yield count // Returns the actual amount of bytes transmitted - -def transfer(origin: InputStream, destination: OutputStream): IO[Long] = - transmit(origin, destination, new Array[Byte](1024 * 10), 0L) + amount <- IO.blocking(origin.read(buffer, 0, buffer.length)) + count <- if (amount > -1) IO.blocking(destination.write(buffer, 0, amount)) >> transfer(origin, destination, buffer, acc + amount) + else IO.pure(acc) // End of read stream reached (by java.io.InputStream contract), nothing to write + } yield count // Returns the actual amount of bytes transferred ``` -Take a look at `transmit`, observe that both input and output actions are -created by invoking `IO.blocking` which return the actions encapsulated in a +Take a look at `transfer`, observe that both input and output actions are +created by invoking `IO.blocking` which return the actions encapsulated in (suspended in) `IO`. We can also just embed the actions by calling `IO(action)`, but when dealing with input/output actions it is advised that you instead use `IO.blocking(action)`. This way we help cats-effect to better plan how to assign @@ -293,7 +287,7 @@ the call to `read()` does not return a negative value that would signal that the end of the stream has been reached. `>>` is a Cats operator to sequence two operations where the output of the first is not needed by the second (_i.e._ it is equivalent to `first.flatMap(_ => second)`). In the code above that means -that after each write operation we recursively call `transmit` again, but as +that after each write operation we recursively call `transfer` again, but as `IO` is stack safe we are not concerned about stack overflow issues. At each iteration we increase the counter `acc` with the amount of bytes read at that iteration. @@ -307,7 +301,7 @@ cancelation in the next section. ### Dealing with cancelation Cancelation is a powerful but non-trivial cats-effect feature. In cats-effect, -some `IO` instances can be canceled ( _e.g._ by other `IO` instances running +some `IO` instances can be canceled (_e.g._ by other `IO` instances running concurrently) meaning that their evaluation will be aborted. If the programmer is careful, an alternative `IO` task will be run under cancelation, for example to deal with potential cleaning up activities. @@ -316,7 +310,7 @@ Thankfully, `Resource` makes dealing with cancelation an easy task. If the `IO` inside a `Resource.use` is canceled, the release section of that resource is run. In our example this means the input and output streams will be properly closed. Also, cats-effect does not cancel code inside `IO.blocking` instances. -In the case of our `transmit` function this means the execution would be +In the case of our `transfer` function this means the execution would be interrupted only between two calls to `IO.blocking`. If we want the execution of an IO instance to be interrupted when canceled, without waiting for it to finish, we must instantiate it using `IO.interruptible`. @@ -349,8 +343,7 @@ object Main extends IOApp { override def run(args: List[String]): IO[ExitCode] = for { - _ <- if(args.length < 2) IO.raiseError(new IllegalArgumentException("Need origin and destination files")) - else IO.unit + _ <- IO.raiseWhen(args.length < 2)(new IllegalArgumentException("Need origin and destination files")) orig = new File(args(0)) dest = new File(args(1)) count <- copy(orig, dest) @@ -361,8 +354,9 @@ object Main extends IOApp { Heed how `run` verifies the `args` list passed. If there are fewer than two arguments, an error is raised. As `IO` implements `MonadError` we can at any -moment call to `IO.raiseError` to interrupt a sequence of `IO` operations. The log -message is printed by means of handy `IO.println` method. +moment call to `IO.raiseWhen` or `IO.raiseError` to interrupt a sequence of `IO` +operations. The log message is printed by means of the handy `IO.println` +method. #### Copy program code You can check the [final version of our copy program @@ -371,7 +365,7 @@ here](https://github.com/lrodero/cats-effect-tutorial/blob/series/3.x/src/main/s The program can be run from `sbt` just by issuing this call: ```scala -> runMain catseffecttutorial.CopyFile origin.txt destination.txt +> runMain catseffecttutorial.copyfile.CopyFile origin.txt destination.txt ``` It can be argued that using `IO{java.nio.file.Files.copy(...)}` would get an @@ -405,23 +399,22 @@ import cats.effect.Sync import cats.syntax.all._ import java.io._ -def transmit[F[_]: Sync](origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): F[Long] = +def transfer[F[_]: Sync](origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): F[Long] = for { amount <- Sync[F].blocking(origin.read(buffer, 0, buffer.length)) - count <- if(amount > -1) Sync[F].blocking(destination.write(buffer, 0, amount)) >> transmit(origin, destination, buffer, acc + amount) + count <- if(amount > -1) Sync[F].blocking(destination.write(buffer, 0, amount)) >> transfer(origin, destination, buffer, acc + amount) else Sync[F].pure(acc) // End of read stream reached (by java.io.InputStream contract), nothing to write - } yield count // Returns the actual amount of bytes transmitted + } yield count // Returns the actual amount of bytes transferred ``` We leave as an exercise to code the polymorphic versions of `inputStream`, -`outputStream`, `inputOutputStreams`, `transfer` and `copy` functions. +`outputStream`, `inputOutputStreams` and `copy` functions. ```scala mdoc:compile-only import cats.effect._ import java.io._ -def transmit[F[_]: Sync](origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): F[Long] = ??? -def transfer[F[_]: Sync](origin: InputStream, destination: OutputStream): F[Long] = ??? +def transfer[F[_]: Sync](origin: InputStream, destination: OutputStream, buffer: Array[Byte], acc: Long): F[Long] = ??? def inputStream[F[_]: Sync](f: File): Resource[F, FileInputStream] = ??? def outputStream[F[_]: Sync](f: File): Resource[F, FileOutputStream] = ??? def inputOutputStreams[F[_]: Sync](in: File, out: File): Resource[F, (InputStream, OutputStream)] = ??? @@ -450,18 +443,16 @@ your IO-kungfu: 1. Modify the `IOApp` so it shows an error and abort the execution if the origin and destination files are the same, the origin file cannot be open for - reading or the destination file cannot be opened for writing. Also, if the + reading, or the destination file cannot be opened for writing. Also, if the destination file already exists, the program should ask for confirmation before overwriting that file. -2. Modify `transmit` so the buffer size is not hardcoded but passed as - parameter. -3. Test safe cancelation, checking that the streams are indeed being properly +2. Test safe cancelation, checking that the streams are indeed being properly closed. You can do that just by interrupting the program execution pressing `Ctrl-c`. To make sure you have the time to interrupt the program, introduce - a delay of a few seconds in the `transmit` function (see `IO.sleep`). And to + a delay of a few seconds in the `transfer` function (see `IO.sleep`). And to ensure that the release functionality in the `Resource`s is run you can add some log message there (see `IO.println`). -4. Create a new program able to copy folders. If the origin folder has +3. Create a new program able to copy folders. If the origin folder has subfolders, then their contents must be recursively copied too. Of course the copying must be safely cancelable at any moment. @@ -495,7 +486,7 @@ the other hand when the execution of some fiber is blocked _e.g._ because it must wait for a semaphore to be released, the thread running the fiber is recycled by cats-effect so it is available for other fibers. When the fiber execution can be resumed cats-effect will look for some free thread to continue -the execution. The term "_semantically blocked_" is used sometimes to denote +the execution. The term "_fiber blocking_" is used sometimes to denote that blocking the fiber does not involve halting any thread. Cats-effect also recycles threads of finished and canceled fibers. But keep in mind that, in contrast, if the fiber is truly blocked by some external action like waiting for @@ -507,23 +498,26 @@ info as a hint to optimize `IO` scheduling. Another difference with threads is that fibers are very cheap entities. We can spawn millions of them at ease without impacting the performance. -A worthy note is that you do not have to explicitly shut down fibers. If you spawn -a fiber and it finishes actively running its `IO` it will get cleaned up by the -garbage collector unless there is some other active memory reference to it. So basically -you can treat a fiber as any other regular object, except that when the fiber is _running_ -(present tense), the cats-effect runtime itself keeps the fiber alive. - -This has some interesting implications as well. Like if you create an `IO.async` node and -register the callback with something, and you're in a Fiber which has no strong object -references anywhere else (i.e. you did some sort of fire-and-forget thing), then the callback -itself is the only strong reference to the fiber. Meaning if the registration fails or the -system you registered with throws it away, the fiber will just gracefully disappear. - -Cats-effect implements some concurrency primitives to coordinate concurrent -fibers: [Deferred](std/deferred.md), [Ref](std/ref.md), `Semaphore`... - -Way more detailed info about concurrency in cats-effect can be found in [this -other tutorial 'Concurrency in Scala with +A worthy note is that you do not have to explicitly shut down fibers. If you +spawn a fiber and it finishes actively running its `IO` it will get cleaned up +by the garbage collector unless there is some other active memory reference to +it. So basically you can treat a fiber as any other regular object, except that +when the fiber is _running_ (present tense), the cats-effect runtime itself +keeps the fiber alive. + +This has some interesting implications as well. Like if you create an `IO.async` +node and register the callback with something, and you're in a fiber which has +no strong object references anywhere else (i.e. you did some sort of +fire-and-forget thing), then the callback itself is the only strong reference to +the fiber. Meaning if the registration fails or the system you registered with +throws it away, the fiber will just gracefully disappear. + +And a final hint: as with threads, often you will need to coordinate the work of +concurrent fibers. Writing concurrent code is a difficult exercise, but +cats-effect implements some concurrency primitives such as +[Deferred](std/deferred.md), [Ref](std/ref.md), [Semaphore](std/semaphore.md)... +that will help you in that task. Way more detailed info about concurrency in +cats-effect can be found in [this other tutorial 'Concurrency in Scala with Cats-Effect'](https://github.com/slouc/concurrency-in-scala-with-ce). Ok, now we have briefly discussed fibers we can start working on our @@ -537,39 +531,40 @@ integers (`1`, `2`, `3`...), consumer will just read that sequence. Our shared queue will be an instance of an immutable `Queue[Int]`. Accesses to the queue can (and will!) be concurrent, thus we need some way to -protect the queue so only one fiber at a time is handling it. The best way to -ensure an ordered access to some shared data is [Ref](std/ref.md). A -`Ref` instance wraps some given data and implements methods to manipulate that -data in a safe manner. When some fiber is runnning one of those methods, any -other call to any method of the `Ref` instance will be blocked. +protect the queue so only one fiber at a time is handling it. A good way to +ensure an ordered access to some shared data is [Ref](std/ref.md). A `Ref` +instance wraps some given data and implements methods to manipulate that data in +a safe manner. The `Ref` wrapping our queue will be `Ref[F, Queue[Int]]` (for some `F[_]`). Now, our `producer` method will be: - ```scala mdoc:compile-only import cats.effect._ import cats.effect.std.Console import cats.syntax.all._ -import collection.immutable.Queue +import scala.collection.immutable.Queue def producer[F[_]: Sync: Console](queueR: Ref[F, Queue[Int]], counter: Int): F[Unit] = for { - _ <- if(counter % 10000 == 0) Console[F].println(s"Produced $counter items") else Sync[F].unit + _ <- Sync[F].whenA(counter % 10000 == 0)(Console[F].println(s"Produced $counter items")) _ <- queueR.getAndUpdate(_.enqueue(counter + 1)) _ <- producer(queueR, counter + 1) } yield () ``` -First line just prints some log message every `10000` items, so we know if it is -'alive'. It uses type class `Console[_]`, which brings the capacity to print -and read strings (`IO.println` just uses `Console[IO].println` underneath). +First line just prints some log message every `10000` items, so we know if our +producer is still 'alive'. We can as well do `if(cond) then Console... else +Sync[F].unit` but this approach is more idiomatic. To print logs the code uses +type class `Console[_]`, which brings the capacity to print and read strings +(the `IO.println` call we used before just invokes `Console[IO].println` under +the hood). Then our code calls `queueR.getAndUpdate` to add data into the queue. Note that `.getAndUpdate` provides the current queue, then we use `.enqueue` to insert the next value `counter+1`. This call returns a new queue with the value added that is stored by the ref instance. If some other fiber is accessing to -`queueR` then the fiber is (semantically) blocked. +`queueR` then the fiber (but no thread) is blocked. The `consumer` method is a bit different. It will try to read data from the queue but it must be aware that the queue can be empty: @@ -578,14 +573,14 @@ queue but it must be aware that the queue can be empty: import cats.effect._ import cats.effect.std.Console import cats.syntax.all._ -import collection.immutable.Queue +import scala.collection.immutable.Queue def consumer[F[_]: Sync: Console](queueR: Ref[F, Queue[Int]]): F[Unit] = for { iO <- queueR.modify{ queue => queue.dequeueOption.fold((queue, Option.empty[Int])){case (i,queue) => (queue, Option(i))} } - _ <- if(iO.exists(_ % 10000 == 0)) Console[F].println(s"Consumed ${iO.get} items") else Sync[F].unit + _ <- Sync[F].whenA(iO.exists(_ % 10000 == 0))(Console[F].println(s"Consumed ${iO.get} items")) _ <- consumer(queueR) } yield () ``` @@ -603,7 +598,7 @@ We can now create a program that instantiates our `queueR` and runs both import cats.effect._ import cats.effect.std.Console import cats.syntax.all._ -import collection.immutable.Queue +import scala.collection.immutable.Queue object InefficientProducerConsumer extends IOApp { @@ -623,14 +618,11 @@ object InefficientProducerConsumer extends IOApp { } ``` -The full implementation of this naive producer consumer is available -[here](https://github.com/lrodero/cats-effect-tutorial/blob/series/3.x/src/main/scala/catseffecttutorial/producerconsumer/InefficientProducerConsumer.scala). - Our `run` function instantiates the shared queue wrapped in a `Ref` and boots the producer and consumer in parallel. To do to it uses `parMapN`, that creates and runs the fibers that will run the `IO`s passed as parameter. Then it takes the output of each fiber and applies a given function to them. In our case -both producer and consumer shall run forever until user presses CTRL-C which +both producer and consumer shall run forever until the user presses CTRL-C which will trigger a cancelation. Alternatively we could have used `start` method to explicitly create new @@ -639,7 +631,7 @@ wait for them to finish, something like: ```scala mdoc:compile-only import cats.effect._ -import collection.immutable.Queue +import scala.collection.immutable.Queue object InefficientProducerConsumer extends IOApp { @@ -666,7 +658,7 @@ happened. Cats Effect provides additional `joinWith` or `joinWithNever` methods to make sure at least that the error is raised with the usual `MonadError` semantics -(e.g., short-circuiting). Now that we are raising the error, we also need to +(_i.e._, short-circuiting). Now that we are raising the error, we also need to cancel the other running fibers. We can easily get ourselves trapped in a tangled mess of fibers to keep an eye on. On top of that the error raised by a fiber is not promoted until the call to `joinWith` or `.joinWithNever` is @@ -683,29 +675,87 @@ have some specific and unusual requirements you should prefer to use higher level commands such as `parMapN` or `parSequence` to work with fibers_. Ok, we stick to our implementation based on `.parMapN`. Are we done? Does it -Work? Well, it works... but it is far from ideal. If we run it we will find that -the producer runs faster than the consumer so the queue is constantly growing. -And, even if that was not the case, we must realize that the consumer will be -continually running regardless if there are elements in the queue, which is far -from ideal. We will try to improve it in the next section by using -[Deferred](std/deferred.md). Also we will use several consumers and -producers to balance production and consumption rate. +Work? Well, it works... but it is far from ideal. + +#### Issue 1: the producer outpaces the consumer +Now, if you run the program you will notice that almost no consumer logs are +shown, if any. This is a signal that the producer is running way faster than +the consumer. And why is that? Well, this is because how `Ref.modify` works. It +gets the current value, then it computes the update, and finally it tries to set +the new value if the current one has not been changed (by some other fiber), +otherwise it starts from the beginning. Unfortunately the producer is way faster +running its `queueR.getAndUpdate` call than the consumer is running its +`queueR.modify` call. So the consumer gets 'stuck' trying once and again to +update the `queueR` content. + +Can we alleviate this? Sure! There are a few options you can implement: +1. Making the producer artificially slower by introducing a call to `Async[F].sleep` + (_e.g._ for 1 microsecond). Truth is, in real world scenarios a producer will + not be as fast as in our example so this tweak is not that 'strange'. Note that + to be able to use `sleep` now `F` requires an implicit `Async[F]` instance. The + new producer will look like this: + ```scala mdoc:compile-only + import cats.effect._ + import cats.effect.std.Console + import cats.syntax.all._ + import scala.collection.immutable.Queue + import scala.concurrent.duration.DurationInt + + def producer[F[_]: Async: Console](queueR: Ref[F, Queue[Int]], counter: Int): F[Unit] = + for { + _ <- Async[F].whenA(counter % 10000 == 0)(Console[F].println(s"Produced $counter items")) + _ <- Async[F].sleep(1.microsecond) // To prevent overwhelming consumers + _ <- queueR.getAndUpdate(_.enqueue(counter + 1)) + _ <- producer(queueR, counter + 1) + } yield () + ``` +2. Replace `Ref` with `AtomicCell` to keep the `Queue` instance. `AtomicCell`, + like `Ref`, is a concurrent data structure to keep a reference to some data. + But unlike `Ref` it ensures that only one fiber can operate on that reference + at any given time. Thus the consumer won't have to try once and again to modify + its content. On the other hand `AtomicCell` is slower than `Ref`. This is + because `Ref` is nonblocking while `AtomicCell` will block calling fibers to + ensure only one operates on its content. +3. Make the queue bound by size so producers are forced to wait for consumers to + extract data when the queue is full. We will do this later on in Section + [Producer consumer with bounded +queue](#producer-consumer-with-bounded-queue). + +By the way, you may be tempted to speed up the `queueR.modify` call in the +consumer by using a mutable `Queue` instance. Do not! `Ref` _must_ be used only +with immutable data. + +#### Issue 2: consumer runs even if there are no elements in the queue +The consumer will be continually running regardless if there are elements in the +queue, which is far from ideal. If we have several consumers competing for the +data the problem gets even worse. We will address this problem in the next +section by using [Deferred](std/deferred.md). + +The full implementation of the naive producer consumer we have just created in +this section is available +[here](https://github.com/lrodero/cats-effect-tutorial/blob/series/3.x/src/main/scala/catseffecttutorial/producerconsumer/InefficientProducerConsumer.scala). ### A more solid implementation of the producer/consumer problem In our producer/consumer code we already protect access to the queue (our shared resource) using a `Ref`. Now, instead of using `Option` to represent elements retrieved from a possibly empty queue, we should instead block the caller fiber -somehow if queue is empty until some element can be returned. This will be done -by creating and keeping instances of `Deferred`. A `Deferred[F, A]` instance can -hold one single element of some type `A`. `Deferred` instances are created -empty, and can be filled only once. If some fiber tries to read the element from -an empty `Deferred` then it will be semantically blocked until some other fiber -fills (completes) it. - -Thus, alongside the queue of produced but not yet consumed elements, we have to -keep track of the `Deferred` instances created when the queue was empty that are -waiting for elements to be available. These instances will be kept in a new -queue `takers`. We will keep both queues in a new type `State`: +somehow if queue is empty until some element can be returned. This way we +prevent having consumer fibers running even when there is no element to +consume. This will be done by creating and keeping instances of `Deferred`. A +`Deferred[F, A]` instance can hold one single element of some type `A`. +`Deferred` instances are created empty, and can be filled only once. If some +fiber tries to read the element from an empty `Deferred` then it will wait +until some other fiber fills (completes) it. But recall that this waiting does +not involve blocking any physical thread, that's the beauty of fibers! + +Also, we will step up our code so we can handle several producers and consumers +in parallel. + +Ok, so, alongside the queue of produced but not yet consumed elements, we have +to keep track of the `Deferred` instances (created because consumers found an +emnpty queue) that are waiting for elements to be available. These instances +will be kept in a new queue `takers`. We will keep both queues in a new type +`State`: ```scala mdoc:compile-only import cats.effect.Deferred @@ -747,7 +797,7 @@ def consumer[F[_]: Async: Console](id: Int, stateR: Ref[F, State[F, Int]]): F[Un for { i <- take - _ <- if(i % 10000 == 0) Console[F].println(s"Consumer $id has reached $i items") else Async[F].unit + _ <- Async[F].whenA(i % 10000 == 0)(Console[F].println(s"Consumer $id has reached $i items")) _ <- consumer(id, stateR) } yield () } @@ -759,21 +809,22 @@ we will have now several producers and consumers running in parallel). The Note how it will block on `taker.get` when the queue is empty. The producer, for its part, will: -1. If there are waiting `takers`, it will take the first in the queue and offer - it the newly produced element (`taker.complete`). +1. If there are waiting `takers`, it will take the first one in the takers queue + and offer it the newly produced element (`taker.complete`). 2. If no `takers` are present, it will just enqueue the produced element. Thus the producer will look like: ```scala mdoc:compile-only -import cats.effect.{Deferred, Ref, Sync} +import cats.effect.{Async, Deferred, Ref} import cats.effect.std.Console import cats.syntax.all._ import scala.collection.immutable.Queue +import scala.concurrent.duration.DurationInt case class State[F[_], A](queue: Queue[A], takers: Queue[Deferred[F,A]]) -def producer[F[_]: Sync: Console](id: Int, counterR: Ref[F, Int], stateR: Ref[F, State[F,Int]]): F[Unit] = { +def producer[F[_]: Async: Console](id: Int, counterR: Ref[F, Int], stateR: Ref[F, State[F,Int]]): F[Unit] = { def offer(i: Int): F[Unit] = stateR.modify { @@ -781,18 +832,22 @@ def producer[F[_]: Sync: Console](id: Int, counterR: Ref[F, Int], stateR: Ref[F, val (taker, rest) = takers.dequeue State(queue, rest) -> taker.complete(i).void case State(queue, takers) => - State(queue.enqueue(i), takers) -> Sync[F].unit + State(queue.enqueue(i), takers) -> Async[F].unit }.flatten for { i <- counterR.getAndUpdate(_ + 1) _ <- offer(i) - _ <- if(i % 10000 == 0) Console[F].println(s"Producer $id has reached $i items") else Sync[F].unit + _ <- Async[F].whenA(i % 100000 == 0)(Console[F].println(s"Producer $id has reached $i items")) + _ <- Async[F].sleep(1.microsecond) // To prevent overwhelming consumers _ <- producer(id, counterR, stateR) } yield () } ``` +As in the previous section we introduce an artificial delay in order not to +overwhelm consumers. + Finally we modify our main program so it instantiates the counter and state `Ref`s. Also it will create several consumers and producers, 10 of each, and will start all of them in parallel: @@ -840,18 +895,12 @@ are started in their own fiber by the call to `parSequence`, which will wait for all of them to finish and then return the value passed as parameter. As in the previous example this program shall run forever until the user presses CTRL-C. -Having several consumers and producers improves the balance between consumers -and producers... but still, on the long run, queue tends to grow in size. To -fix this we will ensure the size of the queue is bounded, so whenever that max -size is reached producers will block as consumers do when the queue is empty. - - ### Producer consumer with bounded queue -Having a bounded queue implies that producers, when queue is full, will wait (be -'semantically blocked') until there is some empty bucket available to be filled. -So an implementation needs to keep track of these waiting producers. To do so we -will add a new queue `offerers` that will be added to the `State` alongside -`takers`. For each waiting producer the `offerers` queue will keep a +Having a bounded queue implies that producers, when the queue is full, will wait +(be 'fiber blocked') until there is some empty bucket available to be +filled. So an implementation needs to keep track of these waiting producers. To +do so we will add a new queue `offerers` that will be added to the `State` +alongside `takers`. For each waiting producer the `offerers` queue will keep a `Deferred[F, Unit]` that will be used to block the producer until the element it offers can be added to `queue` or directly passed to some consumer (`taker`). Alongside the `Deferred` instance we need to keep as well the actual element @@ -865,8 +914,8 @@ case class State[F[_], A](queue: Queue[A], capacity: Int, takers: Queue[Deferred ``` Of course both consumer and producer have to be modified to handle this new -queue `offerers`. A consumer can find four escenarios, depending on if `queue` -and `offerers` are each one empty or not. For each escenario a consumer shall: +queue `offerers`. A consumer can find four scenarios, depending on if `queue` +and `offerers` are each one empty or not. For each scenario a consumer shall: 1. If `queue` is not empty: 1. If `offerers` is empty then it will extract and return `queue`'s head. 2. If `offerers` is not empty (there is some producer waiting) then things @@ -915,7 +964,7 @@ def consumer[F[_]: Async: Console](id: Int, stateR: Ref[F, State[F, Int]]): F[Un for { i <- take - _ <- if(i % 10000 == 0) Console[F].println(s"Consumer $id has reached $i items") else Async[F].unit + _ <- Async[F].whenA(i % 100000 == 0)(Console[F].println(s"Consumer $id has reached $i items")) _ <- consumer(id, stateR) } yield () } @@ -958,14 +1007,21 @@ def producer[F[_]: Async: Console](id: Int, counterR: Ref[F, Int], stateR: Ref[F for { i <- counterR.getAndUpdate(_ + 1) _ <- offer(i) - _ <- if(i % 10000 == 0) Console[F].println(s"Producer $id has reached $i items") else Async[F].unit + _ <- Async[F].whenA(i % 100000 == 0)(Console[F].println(s"Producer $id has reached $i items")) _ <- producer(id, counterR, stateR) } yield () } ``` +We have removed the `Async[F].sleep` call that we used to slow down producers +because we do not need it any more. Even if producers could run faster than +consumers they have to wait when the queue is full for consumers to extract data +from the queue. So at the end of the day they will run at the same pace. + As you see, producer and consumer are coded around the idea of keeping and -modifying state, just as with unbounded queues. +modifying state, just as with unbounded queues. Also we do not need to introduce +an artificial delay in producers, as soon as the queue gets full they will be +'blocked' thus giving a chance to consumers to read data. As the final step we must adapt the main program to use these new consumers and producers. Let's say we limit the queue size to `100`, then we have: @@ -1098,7 +1154,7 @@ def producer[F[_]: Async: Console](id: Int, counterR: Ref[F, Int], stateR: Ref[F for { i <- counterR.getAndUpdate(_ + 1) _ <- offer(i) - _ <- if(i % 10000 == 0) Console[F].println(s"Producer $id has reached $i items") else Async[F].unit + _ <- Async[F].whenA(i % 100000 == 0)(Console[F].println(s"Producer $id has reached $i items")) _ <- producer(id, counterR, stateR) } yield () } @@ -1144,7 +1200,7 @@ def consumer[F[_]: Async: Console](id: Int, stateR: Ref[F, State[F, Int]]): F[Un for { i <- take - _ <- if(i % 10000 == 0) Console[F].println(s"Consumer $id has reached $i items") else Async[F].unit + _ <- Async[F].whenA(i % 100000 == 0)(Console[F].println(s"Consumer $id has reached $i items")) _ <- consumer(id, stateR) } yield () } diff --git a/docs/typeclasses.md b/docs/typeclasses.md index 45ce671d74..672f4621ef 100644 --- a/docs/typeclasses.md +++ b/docs/typeclasses.md @@ -23,4 +23,4 @@ Beyond the above, certain capabilities are *assumed* by Cats Effect but defined - Composing multiple effectful computations together sequentially, such that each is dependent on the previous - Raising and handling errors -Taken together, all of these capabilities define what it means to be an effect. Just as you can rely on the properties of integers when you perform basic mental arithmetic (e.g. you can assume that `1 + 2 + 3` is the same as `1 + 5`), so too can you rely on these powerful and general properties of *effects* to hold when you write complex programs. This allows you to understand and refactor your code based on rules and abstractions, rather than having to think about every possibly implementation detail and use-case. Additionally, it makes it possible for you and others to write very generic code which composes together making an absolute minimum of assumptions. This is the foundation of the Cats Effect ecosystem. +Taken together, all of these capabilities define what it means to be an effect. Just as you can rely on the properties of integers when you perform basic mental arithmetic (e.g. you can assume that `1 + 2 + 3` is the same as `1 + 5`), so too can you rely on these powerful and general properties of *effects* to hold when you write complex programs. This allows you to understand and refactor your code based on rules and abstractions, rather than having to think about every possible implementation detail and use-case. Additionally, it makes it possible for you and others to write very generic code which composes together making an absolute minimum of assumptions. This is the foundation of the Cats Effect ecosystem. diff --git a/flake.lock b/flake.lock index 43737aa7c4..cd2c510763 100644 --- a/flake.lock +++ b/flake.lock @@ -2,15 +2,17 @@ "nodes": { "devshell": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": [ + "typelevel-nix", + "nixpkgs" + ] }, "locked": { - "lastModified": 1682700442, - "narHash": "sha256-qjaAAcCYgp1pBBG7mY9z95ODUBZMtUpf0Qp3Gt/Wha0=", + "lastModified": 1722113426, + "narHash": "sha256-Yo/3loq572A8Su6aY5GP56knpuKYRvM2a1meP9oJZCw=", "owner": "numtide", "repo": "devshell", - "rev": "fb6673fe9fe4409e3f43ca86968261e970918a83", + "rev": "67cce7359e4cd3c45296fb4aaf6a19e2a9c757ae", "type": "github" }, "original": { @@ -20,30 +22,15 @@ } }, "flake-utils": { - "locked": { - "lastModified": 1642700792, - "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_2": { "inputs": { "systems": "systems" }, "locked": { - "lastModified": 1681202837, - "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", "type": "github" }, "original": { @@ -54,27 +41,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1677383253, - "narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9952d6bc395f5841262b006fbace8dd7e143b634", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1683442750, - "narHash": "sha256-IiJ0WWW6OcCrVFl1ijE+gTaP0ChFfV6dNkJR05yStmw=", + "lastModified": 1726871744, + "narHash": "sha256-V5LpfdHyQkUF7RfOaDPrZDP+oqz88lTJrMT1+stXNwo=", "owner": "nixos", "repo": "nixpkgs", - "rev": "eb751d65225ec53de9cf3d88acbf08d275882389", + "rev": "a1d92660c6b3b7c26fb883500a80ea9d33321be2", "type": "github" }, "original": { @@ -115,15 +86,15 @@ "typelevel-nix": { "inputs": { "devshell": "devshell", - "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs_2" + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1683546385, - "narHash": "sha256-QsxQsFlrturrBYY5nEp/J6UYe7NPL4i+QpXg+4HIl9o=", + "lastModified": 1727106412, + "narHash": "sha256-ciaPDMAtj8hsYtHAXL0fP2UNo4JDKKxSb0bfR+ATs2s=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "bb9774d68fa09d259f490c81546f36ec6774e96a", + "rev": "d4fe497c6a619962584f5dc4b2ca9d4f824e68c6", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 2d5487987c..0d6febd0ce 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ let pkgs = import nixpkgs { inherit system; - overlays = [ typelevel-nix.overlay ]; + overlays = [ typelevel-nix.overlays.default ]; }; mkShell = jdk: pkgs.devshell.mkShell { diff --git a/ioapp-tests/src/test/scala/IOAppSpec.scala b/ioapp-tests/src/test/scala/IOAppSpec.scala new file mode 100644 index 0000000000..71dc0da6e2 --- /dev/null +++ b/ioapp-tests/src/test/scala/IOAppSpec.scala @@ -0,0 +1,393 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +import org.specs2.mutable.Specification + +import scala.io.Source +import scala.sys.process.{BasicIO, Process, ProcessBuilder} + +import java.io.File + +class IOAppSpec extends Specification { + + abstract class Platform(val id: String) { outer => + def builder(proto: String, args: List[String]): ProcessBuilder + def pid(proto: String): Option[Int] + + def dumpSignal: String + + def sendSignal(pid: Int): Unit = { + Runtime.getRuntime().exec(s"kill -$dumpSignal $pid") + () + } + + def apply(proto: String, args: List[String]): Handle = { + val stdoutBuffer = new StringBuffer() + val stderrBuffer = new StringBuffer() + val p = builder(proto, args).run(BasicIO(false, stdoutBuffer, None).withError { in => + val err = Source.fromInputStream(in).getLines().mkString(System.lineSeparator()) + stderrBuffer.append(err) + () + }) + + new Handle { + def awaitStatus() = p.exitValue() + def term() = p.destroy() // TODO probably doesn't work + def stderr() = stderrBuffer.toString + def stdout() = stdoutBuffer.toString + def pid() = outer.pid(proto) + } + } + } + + object JVM extends Platform("jvm") { + val ClassPath = System.getProperty("catseffect.examples.classpath") + + val JavaHome = { + val path = sys.env.get("JAVA_HOME").orElse(sys.props.get("java.home")).get + if (path.endsWith("/jre")) { + // handle JDK 8 installations + path.replace("/jre", "") + } else { + path + } + } + + val dumpSignal = "USR1" + + def builder(proto: String, args: List[String]) = Process( + s"$JavaHome/bin/java", + List("-cp", ClassPath, s"catseffect.examples.$proto") ::: args) + + // scala.sys.process.Process and java.lang.Process lack getting PID support. Java 9+ introduced it but + // whatever because it's very hard to obtain a java.lang.Process from scala.sys.process.Process. + def pid(proto: String): Option[Int] = { + val jpsStdoutBuffer = new StringBuffer() + val jpsProcess = + Process(s"$JavaHome/bin/jps", List.empty).run(BasicIO(false, jpsStdoutBuffer, None)) + jpsProcess.exitValue() + + val output = jpsStdoutBuffer.toString + Source.fromString(output).getLines().find(_.contains(proto)).map(_.split(" ")(0).toInt) + } + + } + + object Node extends Platform("node") { + val dumpSignal = "USR2" + + def builder(proto: String, args: List[String]) = + Process( + s"node", + "--enable-source-maps" :: BuildInfo + .jsRunner + .getAbsolutePath :: s"catseffect.examples.$proto" :: args) + + def pid(proto: String): Option[Int] = { + val stdoutBuffer = new StringBuffer() + val process = + Process("ps", List("aux")).run(BasicIO(false, stdoutBuffer, None)) + process.exitValue() + + val output = stdoutBuffer.toString + Source + .fromString(output) + .getLines() + .find(l => l.contains(BuildInfo.jsRunner.getAbsolutePath) && l.contains(proto)) + .map(_.split(" +")(1).toInt) + } + } + + object Native extends Platform("native") { + val dumpSignal = "USR1" + + def builder(proto: String, args: List[String]) = + Process(BuildInfo.nativeRunner.getAbsolutePath, s"catseffect.examples.$proto" :: args) + + def pid(proto: String): Option[Int] = { + val stdoutBuffer = new StringBuffer() + val process = + Process("ps", List("aux")).run(BasicIO(false, stdoutBuffer, None)) + process.exitValue() + + val output = stdoutBuffer.toString + Source + .fromString(output) + .getLines() + .find(l => l.contains(BuildInfo.nativeRunner.getAbsolutePath) && l.contains(proto)) + .map(_.split(" +")(1).toInt) + } + } + + lazy val platform = BuildInfo.platform match { + case "jvm" => JVM + case "js" => Node + case "native" => Native + case platform => throw new RuntimeException(s"unknown platform $platform") + } + + lazy val isJava8 = + platform == JVM && sys.props.get("java.version").filter(_.startsWith("1.8")).isDefined + lazy val isWindows = System.getProperty("os.name").toLowerCase.contains("windows") + + s"IOApp (${platform.id})" should { + + if (!isWindows) { // these tests have all been emperically flaky on Windows CI builds, so they're disabled + + "evaluate and print hello world" in { + val h = platform("HelloWorld", Nil) + h.awaitStatus() mustEqual 0 + h.stdout() mustEqual s"Hello, World!${System.lineSeparator()}" + } + + "pass all arguments to child" in { + val expected = List("the", "quick", "brown", "fox jumped", "over") + val h = platform("Arguments", expected) + h.awaitStatus() mustEqual 0 + h.stdout() mustEqual expected.mkString( + "", + System.lineSeparator(), + System.lineSeparator()) + } + + "exit on non-fatal error" in { + val h = platform("NonFatalError", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + } + + "exit with leaked fibers" in { + val h = platform("LeakedFiber", List.empty) + h.awaitStatus() mustEqual 0 + } + + "exit on fatal error" in { + val h = platform("FatalError", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + h.stdout() must not(contain("sadness")) + } + + "exit on fatal error with other unsafe runs" in { + val h = platform("FatalErrorUnsafeRun", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + } + + "exit on raising a fatal error with attempt" in { + val h = platform("RaiseFatalErrorAttempt", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + h.stdout() must not(contain("sadness")) + } + + "exit on raising a fatal error with handleError" in { + val h = platform("RaiseFatalErrorHandle", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + h.stdout() must not(contain("sadness")) + } + + "exit on raising a fatal error inside a map" in { + val h = platform("RaiseFatalErrorMap", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + h.stdout() must not(contain("sadness")) + } + + "exit on raising a fatal error inside a flatMap" in { + val h = platform("RaiseFatalErrorFlatMap", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("Boom!") + h.stdout() must not(contain("sadness")) + } + + "warn on global runtime collision" in { + val h = platform("GlobalRacingInit", List.empty) + h.awaitStatus() mustEqual 0 + h.stderr() must contain( + "Cats Effect global runtime already initialized; custom configurations will be ignored") + h.stderr() must not(contain("boom")) + } + + "reset global runtime on shutdown" in { + val h = platform("GlobalShutdown", List.empty) + h.awaitStatus() mustEqual 0 + h.stderr() must not contain + "Cats Effect global runtime already initialized; custom configurations will be ignored" + h.stderr() must not(contain("boom")) + } + + // TODO reenable this test (#3919) + "warn on cpu starvation" in skipped { + val h = platform("CpuStarvation", List.empty) + h.awaitStatus() + val err = h.stderr() + err must not(contain("[WARNING] Failed to register Cats Effect CPU")) + err must contain("[WARNING] Your app's responsiveness") + // we use a regex because time has too many corner cases - a test run at just the wrong + // moment on new year's eve, etc + err must beMatching( + // (?s) allows matching across line breaks + """(?s)^\d{4}-[01]\d-[0-3]\dT[012]\d:[0-6]\d:[0-6]\d(?:\.\d{1,3})?Z \[WARNING\] Your app's responsiveness.*""" + ) + } + + "custom runtime installed as global" in { + val h = platform("CustomRuntime", List.empty) + h.awaitStatus() mustEqual 0 + } + + if (platform != Native) { + "abort awaiting shutdown hooks" in { + val h = platform("ShutdownHookImmediateTimeout", List.empty) + h.awaitStatus() mustEqual 0 + } + () + } + } + + if (!isWindows) { + // The jvm cannot gracefully terminate processes on Windows, so this + // test cannot be carried out properly. Same for testing IOApp in sbt. + + "run finalizers on TERM" in { + import _root_.java.io.{BufferedReader, FileReader} + + // we have to resort to this convoluted approach because Process#destroy kills listeners before killing the process + val test = File.createTempFile("cats-effect", "finalizer-test") + def readTest(): String = { + val reader = new BufferedReader(new FileReader(test)) + try { + reader.readLine() + } finally { + reader.close() + } + } + + val h = platform("Finalizers", test.getAbsolutePath() :: Nil) + + var i = 0 + while (!h.stdout().contains("Started") && i < 100) { + Thread.sleep(100) + i += 1 + } + + Thread.sleep( + 100 + ) // give thread scheduling just a sec to catch up and get us into the latch.await() + + h.term() + h.awaitStatus() mustEqual 143 + + i = 0 + while (readTest() == null && i < 100) { + i += 1 + } + readTest() must contain("canceled") + } + } else () + + "exit on fatal error without IOApp" in { + val h = platform("FatalErrorRaw", List.empty) + h.awaitStatus() + h.stdout() must not(contain("sadness")) + h.stderr() must not(contain("Promise already completed")) + } + + "exit on canceled" in { + val h = platform("Canceled", List.empty) + h.awaitStatus() mustEqual 1 + } + + if (!isJava8 && !isWindows && platform != Native) { + // JDK 8 does not have free signals for live fiber snapshots + // cannot observe signals sent to process termination on Windows + "live fiber snapshot" in { + val h = platform("LiveFiberSnapshot", List.empty) + + // wait for the application to fully start before trying to send the signal + while (!h.stdout().contains("ready")) { + Thread.sleep(100L) + } + + val pid = h.pid() + pid must beSome + pid.foreach(platform.sendSignal) + h.awaitStatus() + val stderr = h.stderr() + stderr must contain("cats.effect.IOFiber") + } + () + } + + if (platform == JVM) { + "shutdown on worker thread interruption" in { + val h = platform("WorkerThreadInterrupt", List.empty) + h.awaitStatus() mustEqual 1 + h.stderr() must contain("java.lang.InterruptedException") + ok + } + + "support main thread evaluation" in { + val h = platform("EvalOnMainThread", List.empty) + h.awaitStatus() mustEqual 0 + } + + "use configurable reportFailure for MainThread" in { + val h = platform("MainThreadReportFailure", List.empty) + h.awaitStatus() mustEqual 0 + } + + "warn on blocked threads" in { + val h = platform("BlockedThreads", List.empty) + h.awaitStatus() + val err = h.stderr() + err must contain( + "[WARNING] A Cats Effect worker thread was detected to be in a blocked state") + } + + "shut down WSTP on fatal error without IOApp" in { + val h = platform(FatalErrorShutsDownRt, List.empty) + h.awaitStatus() + h.stdout() must not(contain("sadness")) + h.stdout() must contain("done") + } + + () + } + + if (platform == Node) { + "gracefully ignore undefined process.exit" in { + val h = platform("UndefinedProcessExit", List.empty) + h.awaitStatus() mustEqual 0 + } + () + } + + "make specs2 happy" in ok + } + + trait Handle { + def awaitStatus(): Int + def term(): Unit + def stderr(): String + def stdout(): String + def pid(): Option[Int] + } +} diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/Generators.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/Generators.scala index bc2bbb796b..3ce90df4c4 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/Generators.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/Generators.scala @@ -40,7 +40,7 @@ trait Generators1[F[_]] extends Serializable { // Generators of base cases, with no recursion protected def baseGen[A: Arbitrary: Cogen]: List[(String, Gen[F[A]])] = { // prevent unused implicit param warnings, the params need to stay because - // this method is overriden in subtraits + // this method is overridden in subtraits val _ = (implicitly[Arbitrary[A]], implicitly[Cogen[A]]) Nil } @@ -49,7 +49,7 @@ trait Generators1[F[_]] extends Serializable { protected def recursiveGen[A: Arbitrary: Cogen]( deeper: GenK[F]): List[(String, Gen[F[A]])] = { // prevent unused params warnings, the params need to stay because - // this method is overriden in subtraits + // this method is overridden in subtraits val _ = (deeper, implicitly[Arbitrary[A]], implicitly[Cogen[A]]) Nil } diff --git a/kernel/js/src/main/scala/cats/effect/kernel/AsyncPlatform.scala b/kernel/js/src/main/scala/cats/effect/kernel/AsyncPlatform.scala index 6540b30ee2..7bfb993e2f 100644 --- a/kernel/js/src/main/scala/cats/effect/kernel/AsyncPlatform.scala +++ b/kernel/js/src/main/scala/cats/effect/kernel/AsyncPlatform.scala @@ -16,42 +16,43 @@ package cats.effect.kernel -import scala.scalajs.js.{|, defined, Function1, JavaScriptException, Promise, Thenable} +import scala.scalajs.js private[kernel] trait AsyncPlatform[F[_]] { this: Async[F] => - def fromPromise[A](iop: F[Promise[A]]): F[A] = fromThenable(widen(iop)) + def fromPromise[A](iop: F[js.Promise[A]]): F[A] = fromThenable(widen(iop)) - def fromPromiseCancelable[A](iop: F[(Promise[A], F[Unit])]): F[A] = + def fromPromiseCancelable[A](iop: F[(js.Promise[A], F[Unit])]): F[A] = fromThenableCancelable(widen(iop)) - def fromThenable[A](iot: F[Thenable[A]]): F[A] = + def fromThenable[A](iot: F[js.Thenable[A]]): F[A] = flatMap(iot) { t => async_[A] { cb => - t.`then`[Unit](mkOnFulfilled(cb), defined(mkOnRejected(cb))) + t.`then`[Unit](mkOnFulfilled(cb), js.defined(mkOnRejected(cb))) () } } - def fromThenableCancelable[A](iot: F[(Thenable[A], F[Unit])]): F[A] = + def fromThenableCancelable[A](iot: F[(js.Thenable[A], F[Unit])]): F[A] = flatMap(iot) { case (t, fin) => async[A] { cb => - as(delay(t.`then`[Unit](mkOnFulfilled(cb), defined(mkOnRejected(cb)))), Some(fin)) + as(delay(t.`then`[Unit](mkOnFulfilled(cb), js.defined(mkOnRejected(cb)))), Some(fin)) } } @inline private[this] def mkOnFulfilled[A]( - cb: Either[Throwable, A] => Unit): Function1[A, Unit | Thenable[Unit]] = - (v: A) => cb(Right(v)): Unit | Thenable[Unit] + cb: Either[Throwable, A] => Unit): js.Function1[A, js.UndefOr[js.Thenable[Unit]]] = + (v: A) => cb(Right(v)): js.UndefOr[js.Thenable[Unit]] @inline private[this] def mkOnRejected[A]( - cb: Either[Throwable, A] => Unit): Function1[Any, Unit | Thenable[Unit]] = { (a: Any) => - val e = a match { - case th: Throwable => th - case _ => JavaScriptException(a) - } + cb: Either[Throwable, A] => Unit): js.Function1[Any, js.UndefOr[js.Thenable[Unit]]] = { + (a: Any) => + val e = a match { + case th: Throwable => th + case _ => js.JavaScriptException(a) + } - cb(Left(e)): Unit | Thenable[Unit] + cb(Left(e)): js.UndefOr[js.Thenable[Unit]] } } diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala index 97e173eaed..bd2f3d9e76 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala @@ -117,7 +117,12 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * The effect returns `Option[F[Unit]]` which is an optional finalizer to be run in the event * that the fiber running `async(k)` is canceled. * - * Also, note that `async` is uncancelable during its registration. + * @note + * `async` is always uncancelable during its registration. The created effect will be + * uncancelable during its execution if the registration callback provides no finalizer + * (i.e. evaluates to `None`). If you need the created task to be cancelable, return a + * finalizer effect upon the registration. In a rare case when there's nothing to finalize, + * you can return `Some(F.unit)` for that. * * @see * [[async_]] for a simplified variant without a finalizer @@ -139,7 +144,9 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * This function can be thought of as a safer, lexically-constrained version of `Promise`, * where `IO` is like a safer, lazy version of `Future`. * - * Also, note that `async_` is uncancelable during its registration. + * @note + * `async_` is uncancelable during both its registration and execution. If you need an + * asyncronous effect to be cancelable, consider using `async` instead. * * @see * [[async]] for more generic version providing a finalizer @@ -166,6 +173,23 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { */ def evalOn[A](fa: F[A], ec: ExecutionContext): F[A] + /** + * [[Async.evalOn]] with provided [[java.util.concurrent.Executor]] + */ + def evalOnExecutor[A](fa: F[A], executor: Executor): F[A] = { + require(executor != null, "Cannot pass null Executor as an argument") + executor match { + case ec: ExecutionContext => + evalOn[A](fa, ec: ExecutionContext) + case executor => + flatMap(executionContext) { refEc => + val newEc: ExecutionContext = + ExecutionContext.fromExecutor(executor, refEc.reportFailure) + evalOn[A](fa, newEc) + } + } + } + /** * [[Async.evalOn]] as a natural transformation. */ @@ -174,6 +198,14 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { def apply[A](fa: F[A]): F[A] = evalOn(fa, ec) } + /** + * [[Async.evalOnExecutor]] as a natural transformation. + */ + def evalOnExecutorK(executor: Executor): F ~> F = + new (F ~> F) { + def apply[A](fa: F[A]): F[A] = evalOnExecutor(fa, executor) + } + /** * Start a new fiber on a different execution context. * @@ -182,6 +214,14 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { def startOn[A](fa: F[A], ec: ExecutionContext): F[Fiber[F, Throwable, A]] = evalOn(start(fa), ec) + /** + * Start a new fiber on a different executor. + * + * See [[GenSpawn.start]] for more details. + */ + def startOnExecutor[A](fa: F[A], executor: Executor): F[Fiber[F, Throwable, A]] = + evalOnExecutor(start(fa), executor) + /** * Start a new background fiber on a different execution context. * @@ -192,6 +232,16 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { ec: ExecutionContext): Resource[F, F[Outcome[F, Throwable, A]]] = Resource.make(startOn(fa, ec))(_.cancel)(this).map(_.join) + /** + * Start a new background fiber on a different executor. + * + * See [[GenSpawn.background]] for more details. + */ + def backgroundOnExecutor[A]( + fa: F[A], + executor: Executor): Resource[F, F[Outcome[F, Throwable, A]]] = + Resource.make(startOnExecutor(fa, executor))(_.cancel)(this).map(_.join) + /** * Obtain a reference to the current execution context. */ @@ -367,7 +417,7 @@ object Async { implicit protected def F: Async[F] override protected final def delegate = super.delegate - override protected final def C = F + override protected final def C: Clock[F] = F override def unique: OptionT[F, Unique.Token] = delay(new Unique.Token()) @@ -438,7 +488,7 @@ object Async { implicit protected def F: Async[F] override protected final def delegate = super.delegate - override protected final def C = F + override protected final def C: Clock[F] = F override def unique: EitherT[F, E, Unique.Token] = delay(new Unique.Token()) @@ -510,7 +560,7 @@ object Async { implicit protected def F: Async[F] override protected final def delegate = super.delegate - override protected final def C = F + override protected final def C: Clock[F] = F override def unique: IorT[F, L, Unique.Token] = delay(new Unique.Token()) @@ -581,7 +631,7 @@ object Async { implicit protected def F: Async[F] override protected final def delegate = super.delegate - override protected final def C = F + override protected final def C: Clock[F] = F override def unique: WriterT[F, L, Unique.Token] = delay(new Unique.Token()) @@ -649,7 +699,7 @@ object Async { implicit protected def F: Async[F] override protected final def delegate = super.delegate - override protected final def C = F + override protected final def C: Clock[F] = F override def unique: Kleisli[F, R, Unique.Token] = delay(new Unique.Token()) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Clock.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Clock.scala index 816ee43fdc..9e2dd99526 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Clock.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Clock.scala @@ -64,7 +64,7 @@ object Clock { implicit F0: Monad[F], C0: Clock[F]): Clock[OptionT[F, *]] = new OptionTClock[F] { - def applicative = OptionT.catsDataMonadForOptionT(F) + def applicative: Applicative[OptionT[F, *]] = OptionT.catsDataMonadForOptionT(F) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 } @@ -73,7 +73,7 @@ object Clock { implicit F0: Monad[F], C0: Clock[F]): Clock[EitherT[F, E, *]] = new EitherTClock[F, E] { - def applicative = EitherT.catsDataMonadErrorForEitherT(F) + def applicative: Applicative[EitherT[F, E, *]] = EitherT.catsDataMonadErrorForEitherT(F) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 } @@ -82,7 +82,8 @@ object Clock { implicit F0: Monad[F], C0: Clock[F]): Clock[StateT[F, S, *]] = new StateTClock[F, S] { - def applicative = IndexedStateT.catsDataMonadForIndexedStateT(F) + def applicative: Applicative[IndexedStateT[F, S, S, *]] = + IndexedStateT.catsDataMonadForIndexedStateT(F) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 } @@ -92,7 +93,7 @@ object Clock { C0: Clock[F], L0: Monoid[L]): Clock[WriterT[F, L, *]] = new WriterTClock[F, L] { - def applicative = WriterT.catsDataMonadForWriterT(F, L) + def applicative: Applicative[WriterT[F, L, *]] = WriterT.catsDataMonadForWriterT(F, L) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 @@ -105,7 +106,7 @@ object Clock { C0: Clock[F], L0: Semigroup[L]): Clock[IorT[F, L, *]] = new IorTClock[F, L] { - def applicative = IorT.catsDataMonadErrorForIorT(F, L) + def applicative: Applicative[IorT[F, L, *]] = IorT.catsDataMonadErrorForIorT(F, L) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 @@ -116,7 +117,7 @@ object Clock { implicit F0: Monad[F], C0: Clock[F]): Clock[Kleisli[F, R, *]] = new KleisliClock[F, R] { - def applicative = Kleisli.catsDataMonadForKleisli(F) + def applicative: Applicative[Kleisli[F, R, *]] = Kleisli.catsDataMonadForKleisli(F) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 } @@ -126,7 +127,7 @@ object Clock { C0: Clock[F], D0: Defer[F]): Clock[ContT[F, R, *]] = new ContTClock[F, R] { - def applicative = ContT.catsDataContTMonad(D) + def applicative: Applicative[ContT[F, R, *]] = ContT.catsDataContTMonad(D) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 implicit override def D: Defer[F] = D0 @@ -137,7 +138,8 @@ object Clock { C0: Clock[F], L0: Monoid[L]): Clock[ReaderWriterStateT[F, R, L, S, *]] = new ReaderWriterStateTClock[F, R, L, S] { - def applicative = IndexedReaderWriterStateT.catsDataMonadForRWST(F, L) + def applicative: Applicative[ReaderWriterStateT[F, R, L, S, *]] = + IndexedReaderWriterStateT.catsDataMonadForRWST(F, L) implicit override def F: Monad[F] = F0 implicit override def C: Clock[F] = C0 diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala index be07c22c89..d39d90d47f 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -16,7 +16,7 @@ package cats.effect.kernel -import cats.{Monoid, Semigroup, Traverse} +import cats.{Foldable, Monoid, Semigroup, Traverse} import cats.data.{EitherT, IorT, Kleisli, OptionT, WriterT} import cats.effect.kernel.instances.spawn._ import cats.effect.kernel.syntax.all._ @@ -123,6 +123,12 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { def parSequenceN[T[_]: Traverse, A](n: Int)(tma: T[F[A]]): F[T[A]] = parTraverseN(n)(tma)(identity) + /** + * Like `Parallel.parSequence_`, but limits the degree of parallelism. + */ + def parSequenceN_[T[_]: Foldable, A](n: Int)(tma: T[F[A]]): F[Unit] = + parTraverseN_(n)(tma)(identity) + /** * Like `Parallel.parTraverse`, but limits the degree of parallelism. Note that the semantics * of this operation aim to maximise fairness: when a spot to execute becomes available, every @@ -136,6 +142,19 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { MiniSemaphore[F](n).flatMap { sem => ta.parTraverse { a => sem.withPermit(f(a)) } } } + /** + * Like `Parallel.parTraverse_`, but limits the degree of parallelism. Note that the semantics + * of this operation aim to maximise fairness: when a spot to execute becomes available, every + * task has a chance to claim it, and not only the next `n` tasks in `ta` + */ + def parTraverseN_[T[_]: Foldable, A, B](n: Int)(ta: T[A])(f: A => F[B]): F[Unit] = { + require(n >= 1, s"Concurrency limit should be at least 1, was: $n") + + implicit val F: GenConcurrent[F, E] = this + + MiniSemaphore[F](n).flatMap { sem => ta.parTraverse_ { a => sem.withPermit(f(a)) } } + } + override def racePair[A, B](fa: F[A], fb: F[B]) : F[Either[(Outcome[F, E, A], Fiber[F, E, B]), (Fiber[F, E, A], Outcome[F, E, B])]] = { implicit val F: GenConcurrent[F, E] = this diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala index 7d5737afb1..cc8a8e0a1b 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -131,7 +131,7 @@ import cats.syntax.all._ * * {{{ * - * // Starts a fiber that continously prints "A". + * // Starts a fiber that continuously prints "A". * // After 10 seconds, the resource scope exits so the fiber is canceled. * F.background(F.delay(println("A")).foreverM).use { _ => * F.sleep(10.seconds) @@ -202,7 +202,7 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { * * {{{ * - * // Starts a fiber that continously prints "A". + * // Starts a fiber that continuously prints "A". * // After 10 seconds, the resource scope exits so the fiber is canceled. * F.background(F.delay(println("A")).foreverM).use { _ => * F.sleep(10.seconds) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala index 585b7a03a0..56d6d0d270 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala @@ -310,7 +310,7 @@ object GenTemporal { with Clock.OptionTClock[F] { implicit protected def F: GenTemporal[F, E] - protected def C = F + protected def C: Clock[F] = F override protected def delegate: MonadError[OptionT[F, *], E] = OptionT.catsDataMonadErrorForOptionT[F, E] @@ -325,7 +325,7 @@ object GenTemporal { with Clock.EitherTClock[F, E0] { implicit protected def F: GenTemporal[F, E] - protected def C = F + protected def C: Clock[F] = F override protected def delegate: MonadError[EitherT[F, E0, *], E] = EitherT.catsDataMonadErrorFForEitherT[F, E, E0] @@ -339,7 +339,7 @@ object GenTemporal { with Clock.IorTClock[F, L] { implicit protected def F: GenTemporal[F, E] - protected def C = F + protected def C: Clock[F] = F override protected def delegate: MonadError[IorT[F, L, *], E] = IorT.catsDataMonadErrorFForIorT[F, L, E] @@ -353,7 +353,7 @@ object GenTemporal { with Clock.WriterTClock[F, L] { implicit protected def F: GenTemporal[F, E] - protected def C = F + protected def C: Clock[F] = F implicit protected def L: Monoid[L] @@ -369,7 +369,7 @@ object GenTemporal { with Clock.KleisliClock[F, R] { implicit protected def F: GenTemporal[F, E] - protected def C = F + protected def C: Clock[F] = F override protected def delegate: MonadError[Kleisli[F, R, *], E] = Kleisli.catsDataMonadErrorForKleisli[F, R, E] diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala index 2b633dce37..fe65675076 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala @@ -41,7 +41,7 @@ import scala.util.{Either, Left, Right} * `A`. This is to support monad transformers. Consider * * {{{ - * val oc: OutcomeIO[Int] = + * val oc: OptionT[IO, Outcome[OptionT[IO, *], Throwable, Int]] = * for { * fiber <- Spawn[OptionT[IO, *]].start(OptionT.none[IO, Int]) * oc <- fiber.join @@ -49,7 +49,15 @@ import scala.util.{Either, Left, Right} * }}} * * If the fiber succeeds then there is no value of type `Int` to be wrapped in `Succeeded`, - * hence `Succeeded` contains a value of type `OptionT[IO, Int]` instead. + * hence `Succeeded` contains a value of type `OptionT[IO, Int]` instead: + * + * {{{ + * def run: IO[Unit] = + * for { + * res <- oc.flatMap(_.embedNever).value // `res` is `Option[Int]` here + * _ <- Console[IO].println(res) // prints "None" + * } yield () + * }}} * * In general you can assume that binding on the value of type `F[A]` contained in `Succeeded` * does not perform further effects. In the case of `IO` that means that the outcome has been @@ -229,8 +237,8 @@ object Outcome extends LowPriorityImplicits { } @tailrec - def tailRecM[A, B](a: A)(f: A => Outcome[F, E, Either[A, B]]): Outcome[F, E, B] = - f(a) match { + def tailRecM[A, B](a0: A)(f: A => Outcome[F, E, Either[A, B]]): Outcome[F, E, B] = + f(a0) match { case Succeeded(fa) => Traverse[F].sequence[Either[A, *], B](fa) match { // Dotty can't infer this case Left(a) => tailRecM(a)(f) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Ref.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Ref.scala index b04cd3c469..b57c75fc81 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Ref.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Ref.scala @@ -19,7 +19,7 @@ package effect package kernel import cats.data.State -import cats.effect.kernel.Ref.TransformedRef +import cats.effect.kernel.Ref.{TransformedRef, TransformedRef2} import cats.syntax.all._ /** @@ -186,8 +186,12 @@ abstract class Ref[F[_], A] extends RefSource[F, A] with RefSink[F, A] { /** * Modify the context `F` using transformation `f`. */ - def mapK[G[_]](f: F ~> G)(implicit F: Functor[F]): Ref[G, A] = - new TransformedRef(this, f) + def mapK[G[_]](f: F ~> G)(implicit G: Functor[G], dummy: DummyImplicit): Ref[G, A] = + new TransformedRef2(this, f) + + @deprecated("Use mapK with Functor[G] constraint", "3.6.0") + def mapK[G[_]](f: F ~> G, F: Functor[F]): Ref[G, A] = + new TransformedRef(this, f)(F) } object Ref { @@ -361,6 +365,27 @@ object Ref { def empty[A: Monoid]: F[Ref[F, A]] = of(Monoid[A].empty) } + final private[kernel] class TransformedRef2[F[_], G[_], A]( + underlying: Ref[F, A], + trans: F ~> G)( + implicit G: Functor[G] + ) extends Ref[G, A] { + override def get: G[A] = trans(underlying.get) + override def set(a: A): G[Unit] = trans(underlying.set(a)) + override def getAndSet(a: A): G[A] = trans(underlying.getAndSet(a)) + override def tryUpdate(f: A => A): G[Boolean] = trans(underlying.tryUpdate(f)) + override def tryModify[B](f: A => (A, B)): G[Option[B]] = trans(underlying.tryModify(f)) + override def update(f: A => A): G[Unit] = trans(underlying.update(f)) + override def modify[B](f: A => (A, B)): G[B] = trans(underlying.modify(f)) + override def tryModifyState[B](state: State[A, B]): G[Option[B]] = + trans(underlying.tryModifyState(state)) + override def modifyState[B](state: State[A, B]): G[B] = trans(underlying.modifyState(state)) + + override def access: G[(A, A => G[Boolean])] = + G.compose[(A, *)].compose[A => *].map(trans(underlying.access))(trans(_)) + } + + @deprecated("Use TransformedRef2 with Functor[G] constraint", "3.6.0") final private[kernel] class TransformedRef[F[_], G[_], A]( underlying: Ref[F, A], trans: F ~> G)( diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Resource.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Resource.scala index ac09d4f99e..6ee94c2dbc 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Resource.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Resource.scala @@ -1223,7 +1223,6 @@ private[effect] trait ResourceHOInstances0 extends ResourceHOInstances1 { implicit def catsEffectAsyncForResource[F[_]](implicit F0: Async[F]): Async[Resource[F, *]] = new ResourceAsync[F] { def F = F0 - override def applicative = this } implicit def catsEffectSemigroupKForResource[F[_], A]( @@ -1242,7 +1241,6 @@ private[effect] trait ResourceHOInstances1 extends ResourceHOInstances2 { implicit F0: Temporal[F]): Temporal[Resource[F, *]] = new ResourceTemporal[F] { def F = F0 - override def applicative = this } implicit def catsEffectSyncForResource[F[_]](implicit F0: Sync[F]): Sync[Resource[F, *]] = @@ -1257,7 +1255,6 @@ private[effect] trait ResourceHOInstances2 extends ResourceHOInstances3 { implicit F0: Concurrent[F]): Concurrent[Resource[F, *]] = new ResourceConcurrent[F] { def F = F0 - override def applicative = this } implicit def catsEffectClockForResource[F[_]]( @@ -1430,8 +1427,6 @@ abstract private[effect] class ResourceAsync[F[_]] with Async[Resource[F, *]] { self => implicit protected def F: Async[F] - override def applicative = this - override def unique: Resource[F, Unique.Token] = Resource.unique diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Sync.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Sync.scala index 1def70d9f3..30ed9206f3 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Sync.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Sync.scala @@ -214,7 +214,7 @@ object Sync { with Clock.OptionTClock[F] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): OptionT[F, A] = OptionT.liftF(F.suspend(hint)(thunk)) @@ -225,7 +225,7 @@ object Sync { with MonadCancel.EitherTMonadCancel[F, E, Throwable] with Clock.EitherTClock[F, E] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): EitherT[F, E, A] = EitherT.liftF(F.suspend(hint)(thunk)) @@ -236,7 +236,7 @@ object Sync { with MonadCancel.StateTMonadCancel[F, S, Throwable] with Clock.StateTClock[F, S] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): StateT[F, S, A] = StateT.liftF(F.suspend(hint)(thunk)) @@ -247,7 +247,7 @@ object Sync { with MonadCancel.WriterTMonadCancel[F, S, Throwable] with Clock.WriterTClock[F, S] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): WriterT[F, S, A] = WriterT.liftF(F.suspend(hint)(thunk)) @@ -258,7 +258,7 @@ object Sync { with MonadCancel.IorTMonadCancel[F, L, Throwable] with Clock.IorTClock[F, L] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): IorT[F, L, A] = IorT.liftF(F.suspend(hint)(thunk)) @@ -269,7 +269,7 @@ object Sync { with MonadCancel.KleisliMonadCancel[F, R, Throwable] with Clock.KleisliClock[F, R] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): Kleisli[F, R, A] = Kleisli.liftF(F.suspend(hint)(thunk)) @@ -280,7 +280,7 @@ object Sync { with MonadCancel.ReaderWriterStateTMonadCancel[F, R, L, S, Throwable] with Clock.ReaderWriterStateTClock[F, R, L, S] { implicit protected def F: Sync[F] - protected def C = F + protected def C: Clock[F] = F def suspend[A](hint: Type)(thunk: => A): ReaderWriterStateT[F, R, L, S, A] = ReaderWriterStateT.liftF(F.suspend(hint)(thunk)) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/syntax/AsyncSyntax.scala b/kernel/shared/src/main/scala/cats/effect/kernel/syntax/AsyncSyntax.scala index ce396202e5..bbf1ff071d 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/syntax/AsyncSyntax.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/syntax/AsyncSyntax.scala @@ -16,10 +16,12 @@ package cats.effect.kernel.syntax -import cats.effect.kernel.{Async, Fiber, Outcome, Resource, Sync} +import cats.effect.kernel._ import scala.concurrent.ExecutionContext +import java.util.concurrent.Executor + trait AsyncSyntax { implicit def asyncOps[F[_], A](wrapped: F[A]): AsyncOps[F, A] = new AsyncOps(wrapped) @@ -30,13 +32,23 @@ final class AsyncOps[F[_], A] private[syntax] (private[syntax] val wrapped: F[A] def evalOn(ec: ExecutionContext)(implicit F: Async[F]): F[A] = Async[F].evalOn(wrapped, ec) + def evalOnExecutor(executor: Executor)(implicit F: Async[F]): F[A] = + Async[F].evalOnExecutor(wrapped, executor) + def startOn(ec: ExecutionContext)(implicit F: Async[F]): F[Fiber[F, Throwable, A]] = Async[F].startOn(wrapped, ec) + def startOnExecutor(executor: Executor)(implicit F: Async[F]): F[Fiber[F, Throwable, A]] = + Async[F].startOnExecutor(wrapped, executor) + def backgroundOn(ec: ExecutionContext)( implicit F: Async[F]): Resource[F, F[Outcome[F, Throwable, A]]] = Async[F].backgroundOn(wrapped, ec) + def backgroundOnExecutor(executor: Executor)( + implicit F: Async[F]): Resource[F, F[Outcome[F, Throwable, A]]] = + Async[F].backgroundOnExecutor(wrapped, executor) + def syncStep[G[_]: Sync](limit: Int)(implicit F: Async[F]): G[Either[F[A], A]] = Async[F].syncStep[G, A](wrapped, limit) } diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/syntax/GenConcurrentSyntax.scala b/kernel/shared/src/main/scala/cats/effect/kernel/syntax/GenConcurrentSyntax.scala index cfc010601c..f0d0c44233 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/syntax/GenConcurrentSyntax.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/syntax/GenConcurrentSyntax.scala @@ -16,7 +16,7 @@ package cats.effect.kernel.syntax -import cats.Traverse +import cats.{Foldable, Traverse} import cats.effect.kernel.GenConcurrent trait GenConcurrentSyntax { @@ -52,6 +52,11 @@ final class ConcurrentParTraverseNOps[T[_], A] private[syntax] ( f: A => F[B] )(implicit T: Traverse[T], F: GenConcurrent[F, _]): F[T[B]] = F.parTraverseN(n)(wrapped)(f) + + def parTraverseN_[F[_], B](n: Int)( + f: A => F[B] + )(implicit T: Foldable[T], F: GenConcurrent[F, _]): F[Unit] = + F.parTraverseN_(n)(wrapped)(f) } final class ConcurrentParSequenceNOps[T[_], F[_], A] private[syntax] ( @@ -59,4 +64,7 @@ final class ConcurrentParSequenceNOps[T[_], F[_], A] private[syntax] ( ) extends AnyVal { def parSequenceN(n: Int)(implicit T: Traverse[T], F: GenConcurrent[F, _]): F[T[A]] = F.parSequenceN(n)(wrapped) + + def parSequenceN_(n: Int)(implicit T: Foldable[T], F: GenConcurrent[F, _]): F[Unit] = + F.parSequenceN_(n)(wrapped) } diff --git a/kernel/shared/src/test/scala/cats/effect/kernel/SyntaxSpec.scala b/kernel/shared/src/test/scala/cats/effect/kernel/SyntaxSpec.scala index 324179b03d..0536eb3c03 100644 --- a/kernel/shared/src/test/scala/cats/effect/kernel/SyntaxSpec.scala +++ b/kernel/shared/src/test/scala/cats/effect/kernel/SyntaxSpec.scala @@ -46,11 +46,21 @@ class SyntaxSpec extends Specification { result: F[List[Int]] } + { + val result = List(1).parTraverseN_(3)(F.pure) + result: F[Unit] + } + { val result = List(target).parSequenceN(3) result: F[List[A]] } + { + val result = List(target).parSequenceN_(3) + result: F[Unit] + } + { val result = target.parReplicateAN(3)(5) result: F[List[A]] diff --git a/laws/shared/src/main/scala/cats/effect/laws/AsyncTests.scala b/laws/shared/src/main/scala/cats/effect/laws/AsyncTests.scala index 7bf47e4002..6557d02b5d 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/AsyncTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/AsyncTests.scala @@ -24,6 +24,7 @@ import cats.laws.discipline.SemigroupalTests.Isomorphisms import org.scalacheck._ import org.scalacheck.Prop.forAll import org.scalacheck.util.Pretty +import org.typelevel.discipline.Laws import scala.concurrent.ExecutionContext import scala.concurrent.duration.FiniteDuration @@ -84,7 +85,7 @@ trait AsyncTests[F[_]] extends GenTemporalTests[F, Throwable] with SyncTests[F] new RuleSet { val name = "async" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( temporal[A, B, C]( tolerance, @@ -194,7 +195,7 @@ trait AsyncTests[F[_]] extends GenTemporalTests[F, Throwable] with SyncTests[F] new RuleSet { val name = "async" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( temporal[A, B, C](tolerance)( implicitly[Arbitrary[A]], diff --git a/laws/shared/src/main/scala/cats/effect/laws/ClockTests.scala b/laws/shared/src/main/scala/cats/effect/laws/ClockTests.scala index 62fae2f3af..2443249a34 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/ClockTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/ClockTests.scala @@ -29,8 +29,8 @@ trait ClockTests[F[_]] extends Laws { def clock(implicit exec: F[Boolean] => Prop): RuleSet = { new RuleSet { val name = "clock" - val bases = Nil - val parents = Seq() + val bases: Seq[(String, Laws#RuleSet)] = Nil + val parents: Seq[RuleSet] = Seq() val props = Seq("monotonicity" -> exec(laws.monotonicity)) } diff --git a/laws/shared/src/main/scala/cats/effect/laws/GenSpawnTests.scala b/laws/shared/src/main/scala/cats/effect/laws/GenSpawnTests.scala index ad5b2a46b8..ad5a9a182b 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/GenSpawnTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/GenSpawnTests.scala @@ -24,6 +24,7 @@ import cats.laws.discipline.SemigroupalTests.Isomorphisms import org.scalacheck._ import org.scalacheck.Prop.forAll import org.scalacheck.util.Pretty +import org.typelevel.discipline.Laws trait GenSpawnTests[F[_], E] extends MonadCancelTests[F, E] with UniqueTests[F] { @@ -68,7 +69,7 @@ trait GenSpawnTests[F[_], E] extends MonadCancelTests[F, E] with UniqueTests[F] // these are the OLD LAWS retained only for bincompat new RuleSet { val name = "concurrent" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( monadCancel[A, B, C]( implicitly[Arbitrary[A]], @@ -159,7 +160,7 @@ trait GenSpawnTests[F[_], E] extends MonadCancelTests[F, E] with UniqueTests[F] new RuleSet { val name = "concurrent" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( monadCancel[A, B, C]( implicitly[Arbitrary[A]], diff --git a/laws/shared/src/main/scala/cats/effect/laws/GenTemporalTests.scala b/laws/shared/src/main/scala/cats/effect/laws/GenTemporalTests.scala index d2918603d2..3964827309 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/GenTemporalTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/GenTemporalTests.scala @@ -24,6 +24,7 @@ import cats.laws.discipline.SemigroupalTests.Isomorphisms import org.scalacheck._ import org.scalacheck.Prop.forAll import org.scalacheck.util.Pretty +import org.typelevel.discipline.Laws import scala.concurrent.duration.FiniteDuration @@ -84,7 +85,7 @@ trait GenTemporalTests[F[_], E] extends GenSpawnTests[F, E] with ClockTests[F] { new RuleSet { val name = "temporal" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( spawn[A, B, C]( implicitly[Arbitrary[A]], @@ -177,7 +178,7 @@ trait GenTemporalTests[F[_], E] extends GenSpawnTests[F, E] with ClockTests[F] { new RuleSet { val name = "temporal" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( spawn[A, B, C]( implicitly[Arbitrary[A]], diff --git a/laws/shared/src/main/scala/cats/effect/laws/MonadCancelTests.scala b/laws/shared/src/main/scala/cats/effect/laws/MonadCancelTests.scala index d8f14e068a..62729c5c71 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/MonadCancelTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/MonadCancelTests.scala @@ -25,6 +25,7 @@ import cats.laws.discipline.SemigroupalTests.Isomorphisms import org.scalacheck._ import org.scalacheck.Prop.forAll import org.scalacheck.util.Pretty +import org.typelevel.discipline.Laws trait MonadCancelTests[F[_], E] extends MonadErrorTests[F, E] { @@ -111,7 +112,7 @@ trait MonadCancelTests[F[_], E] extends MonadErrorTests[F, E] { new RuleSet { val name = "monadCancel" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq(monadError[A, B, C]) val props = { diff --git a/laws/shared/src/main/scala/cats/effect/laws/SyncTests.scala b/laws/shared/src/main/scala/cats/effect/laws/SyncTests.scala index 3a9ca80d48..0d5e250abf 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/SyncTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/SyncTests.scala @@ -23,6 +23,7 @@ import cats.laws.discipline.SemigroupalTests.Isomorphisms import org.scalacheck._ import org.scalacheck.Prop.forAll +import org.typelevel.discipline.Laws trait SyncTests[F[_]] extends MonadCancelTests[F, Throwable] @@ -58,7 +59,7 @@ trait SyncTests[F[_]] new RuleSet { val name = "sync" - val bases = Nil + val bases: Seq[(String, Laws#RuleSet)] = Nil val parents = Seq( monadCancel[A, B, C]( implicitly[Arbitrary[A]], diff --git a/laws/shared/src/main/scala/cats/effect/laws/UniqueTests.scala b/laws/shared/src/main/scala/cats/effect/laws/UniqueTests.scala index a7a5b031f4..3009eed7ff 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/UniqueTests.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/UniqueTests.scala @@ -29,8 +29,8 @@ trait UniqueTests[F[_]] extends Laws { def unique(implicit exec: F[Boolean] => Prop): RuleSet = { new RuleSet { val name = "unique" - val bases = Nil - val parents = Seq() + val bases: Seq[(String, Laws#RuleSet)] = Nil + val parents: Seq[RuleSet] = Seq() val props = Seq("uniqueness" -> exec(laws.uniqueness)) } diff --git a/laws/shared/src/test/scala/cats/effect/kernel/OutcomeSpec.scala b/laws/shared/src/test/scala/cats/effect/kernel/OutcomeSpec.scala index e2ad0f283c..92ee1e355e 100644 --- a/laws/shared/src/test/scala/cats/effect/kernel/OutcomeSpec.scala +++ b/laws/shared/src/test/scala/cats/effect/kernel/OutcomeSpec.scala @@ -21,7 +21,6 @@ import cats.{Eq, Eval, Id, MonadError} import cats.effect.kernel.testkit.OutcomeGenerators import cats.laws.discipline.{ApplicativeErrorTests, MonadErrorTests} -import org.scalacheck.{Arbitrary, Cogen} import org.specs2.mutable.Specification import org.typelevel.discipline.specs2.mutable.Discipline @@ -35,9 +34,6 @@ class OutcomeSpec extends Specification with Discipline { implicit def monadErrorOutcomeIdInt: MonadError[OutcomeIdInt, Int] = Outcome.monadError[Id, Int] - implicit def arbitraryOutcomeId[A: Arbitrary: Cogen]: Arbitrary[OutcomeIdInt[A]] = - arbitraryOutcome[Id, Int, A] - implicit def eqOutcomeId[A]: Eq[OutcomeIdInt[A]] = Outcome.eq[Id, Int, A] implicit def eqId[A]: Eq[Id[A]] = Eq.instance(_ == _) diff --git a/laws/shared/src/test/scala/cats/effect/laws/OptionTPureConcSpec.scala b/laws/shared/src/test/scala/cats/effect/laws/OptionTPureConcSpec.scala index a89d9a29e8..6e67eb1f53 100644 --- a/laws/shared/src/test/scala/cats/effect/laws/OptionTPureConcSpec.scala +++ b/laws/shared/src/test/scala/cats/effect/laws/OptionTPureConcSpec.scala @@ -25,7 +25,6 @@ import cats.effect.kernel.testkit.{pure, OutcomeGenerators, PureConcGenerators, import cats.effect.kernel.testkit.TimeT._ import cats.effect.kernel.testkit.pure._ import cats.laws.discipline.arbitrary._ -import cats.syntax.all._ import org.scalacheck.Prop import org.specs2.mutable._ @@ -55,7 +54,7 @@ class OptionTPureConcSpec extends Specification with Discipline with BaseSpec { case Outcome.Succeeded(fa) => observed = true - OptionT(fa.value.map(_ must beNone).as(None)) + OptionT(fa.value.map(_ must beNone).map(_ => None)) case _ => Applicative[OptionT[PureConc[Int, *], *]].unit } diff --git a/project/CI.scala b/project/CI.scala index 3dd6faeb51..5e42c26114 100644 --- a/project/CI.scala +++ b/project/CI.scala @@ -56,11 +56,7 @@ object CI { command = "ciJS", rootProject = "rootJS", jsEnv = Some(JSEnv.NodeJS), - testCommands = List( - "test", - "set Global / testJSIOApp := true", - "testsJVM/testOnly *.IOAppSpec", - "set Global / testJSIOApp := false"), + testCommands = List("test"), mimaReport = true, suffixCommands = List("exampleJS/compile") ) diff --git a/project/Common.scala b/project/Common.scala index 8f3fbc51be..b10cde586c 100644 --- a/project/Common.scala +++ b/project/Common.scala @@ -29,14 +29,17 @@ object Common extends AutoPlugin { override def requires = plugins.JvmPlugin && TypelevelPlugin && ScalafixPlugin override def trigger = allRequirements + override def buildSettings = + Seq( + semanticdbEnabled := true, + semanticdbVersion := scalafixSemanticdb.revision + ) + override def projectSettings = Seq( headerLicense := Some( HeaderLicense.ALv2(s"${startYear.value.get}-2024", organizationName.value) ), - ThisBuild / scalafixDependencies += "com.github.liancheng" %% "organize-imports" % "0.6.0", - ThisBuild / semanticdbEnabled := !tlIsScala3.value, - ThisBuild / semanticdbVersion := scalafixSemanticdb.revision, tlVersionIntroduced ++= { if (crossProjectPlatform.?.value.contains(NativePlatform)) List("2.12", "2.13", "3").map(_ -> "3.4.0").toMap diff --git a/project/build.properties b/project/build.properties index 46e43a97ed..bc7390601f 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.2 +sbt.version=1.10.3 diff --git a/project/plugins.sbt b/project/plugins.sbt index 943fe02da5..2b6b2e9c8d 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,17 +1,12 @@ libraryDependencies += "org.scala-js" %% "scalajs-env-selenium" % "1.1.1" -addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.5.0-M10") +addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.7.4") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.1") -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.13") -addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.1") -addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.4") -addSbtPlugin("pl.project13.scala" % "sbt-jcstress" % "0.2.0") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") -addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.7") -addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.5.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17") +addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.6.1") addSbtPlugin("com.lightbend.sbt" % "sbt-java-formatter" % "0.8.0") -addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.13.0") addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.4") diff --git a/scalafix/project/build.properties b/scalafix/project/build.properties index 46e43a97ed..bc7390601f 100644 --- a/scalafix/project/build.properties +++ b/scalafix/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.2 +sbt.version=1.10.3 diff --git a/scalafix/project/plugins.sbt b/scalafix/project/plugins.sbt index 56a53c90c3..d8c6965c5c 100644 --- a/scalafix/project/plugins.sbt +++ b/scalafix/project/plugins.sbt @@ -1 +1 @@ -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.29") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.13.0") diff --git a/scalafix/v3_0_0/tests/src/test/scala/fix/CatsEffectTests.scala b/scalafix/v3_0_0/tests/src/test/scala/fix/CatsEffectTests.scala index 8447707b34..4d1a178364 100644 --- a/scalafix/v3_0_0/tests/src/test/scala/fix/CatsEffectTests.scala +++ b/scalafix/v3_0_0/tests/src/test/scala/fix/CatsEffectTests.scala @@ -1,8 +1,8 @@ package fix -import org.scalatest.FunSuiteLike +import org.scalatest.funsuite.AnyFunSuiteLike import scalafix.testkit.AbstractSemanticRuleSuite -class CatsEffectTests extends AbstractSemanticRuleSuite with FunSuiteLike { +class CatsEffectTests extends AbstractSemanticRuleSuite with AnyFunSuiteLike { runAllTests() } diff --git a/scalafix/v3_3_0/tests/src/test/scala/fix/CatsEffectTests.scala b/scalafix/v3_3_0/tests/src/test/scala/fix/CatsEffectTests.scala index 8447707b34..4d1a178364 100644 --- a/scalafix/v3_3_0/tests/src/test/scala/fix/CatsEffectTests.scala +++ b/scalafix/v3_3_0/tests/src/test/scala/fix/CatsEffectTests.scala @@ -1,8 +1,8 @@ package fix -import org.scalatest.FunSuiteLike +import org.scalatest.funsuite.AnyFunSuiteLike import scalafix.testkit.AbstractSemanticRuleSuite -class CatsEffectTests extends AbstractSemanticRuleSuite with FunSuiteLike { +class CatsEffectTests extends AbstractSemanticRuleSuite with AnyFunSuiteLike { runAllTests() } diff --git a/scripts/data/scastie.sbt b/scripts/data/scastie.sbt index 0c11d2f069..fbeae236bf 100644 --- a/scripts/data/scastie.sbt +++ b/scripts/data/scastie.sbt @@ -1,6 +1,7 @@ scalacOptions ++= Seq( "-deprecation", - "-encoding", "UTF-8", + "-encoding", + "UTF-8", "-feature", "-unchecked" ) diff --git a/scripts/data/scastie.scala b/scripts/data/scastie.scala index d465972a02..eb33d4e537 100644 --- a/scripts/data/scastie.scala +++ b/scripts/data/scastie.scala @@ -8,8 +8,8 @@ object Hello extends IOApp.Simple { def sleepPrint(word: String, name: String, rand: Random[IO]) = for { delay <- rand.betweenInt(200, 700) - _ <- IO.sleep(delay.millis) - _ <- IO.println(s"$word, $name") + _ <- IO.sleep(delay.millis) + _ <- IO.println(s"$word, $name") } yield () val run = @@ -21,7 +21,7 @@ object Hello extends IOApp.Simple { name <- IO.pure("Daniel") english <- sleepPrint("Hello", name, rand).foreverM.start - french <- sleepPrint("Bonjour", name, rand).foreverM.start + french <- sleepPrint("Bonjour", name, rand).foreverM.start spanish <- sleepPrint("Hola", name, rand).foreverM.start _ <- IO.sleep(5.seconds) diff --git a/std/js/src/main/scala/cats/effect/std/DispatcherPlatform.scala b/std/js/src/main/scala/cats/effect/std/DispatcherPlatform.scala index 46c8e6b1ae..c504aead30 100644 --- a/std/js/src/main/scala/cats/effect/std/DispatcherPlatform.scala +++ b/std/js/src/main/scala/cats/effect/std/DispatcherPlatform.scala @@ -16,7 +16,7 @@ package cats.effect.std -import scala.scalajs.js.{|, Function1, JavaScriptException, Promise, Thenable} +import scala.scalajs.js private[std] trait DispatcherPlatform[F[_]] { this: Dispatcher[F] => @@ -24,10 +24,10 @@ private[std] trait DispatcherPlatform[F[_]] { this: Dispatcher[F] => * Submits an effect to be executed, returning a `Promise` that holds the result of its * evaluation. */ - def unsafeToPromise[A](fa: F[A]): Promise[A] = - new Promise[A]((resolve: Function1[A | Thenable[A], _], reject: Function1[Any, _]) => + def unsafeToPromise[A](fa: F[A]): js.Promise[A] = + new js.Promise[A]((resolve, reject) => unsafeRunAsync(fa) { - case Left(JavaScriptException(e)) => + case Left(js.JavaScriptException(e)) => reject(e) () diff --git a/std/js/src/main/scala/cats/effect/std/EnvCompanionPlatform.scala b/std/js/src/main/scala/cats/effect/std/EnvCompanionPlatform.scala index b55a26e7a6..89db9e7d83 100644 --- a/std/js/src/main/scala/cats/effect/std/EnvCompanionPlatform.scala +++ b/std/js/src/main/scala/cats/effect/std/EnvCompanionPlatform.scala @@ -18,7 +18,6 @@ package cats.effect.std import cats.data.OptionT import cats.effect.kernel.Sync -import cats.syntax.all._ import scala.collection.immutable.Iterable import scala.scalajs.js diff --git a/std/native/src/main/scala/cats/effect/std/SecureRandomCompanionPlatform.scala b/std/native/src/main/scala/cats/effect/std/SecureRandomCompanionPlatform.scala index ca7c11233b..b8c8cbcb72 100644 --- a/std/native/src/main/scala/cats/effect/std/SecureRandomCompanionPlatform.scala +++ b/std/native/src/main/scala/cats/effect/std/SecureRandomCompanionPlatform.scala @@ -34,7 +34,7 @@ private[std] trait SecureRandomCompanionPlatform { var i = 0 while (i < len) { val n = Math.min(256, len - i) - if (sysrandom.getentropy(bytes.at(i), n.toULong) < 0) + if (sysrandom.getentropy(bytes.atUnsafe(i), n.toULong) < 0) throw new RuntimeException(fromCString(strerror(errno))) i += n } diff --git a/std/shared/src/main/scala/cats/effect/std/ConsoleCrossPlatform.scala b/std/shared/src/main/scala/cats/effect/std/ConsoleCrossPlatform.scala index 11bddcc616..38aaedd020 100644 --- a/std/shared/src/main/scala/cats/effect/std/ConsoleCrossPlatform.scala +++ b/std/shared/src/main/scala/cats/effect/std/ConsoleCrossPlatform.scala @@ -256,6 +256,7 @@ private[std] abstract class ConsoleCompanionCrossPlatform { if (len > 0) { if (builder.charAt(len - 1) == '\r') { builder.deleteCharAt(len - 1) + () } } builder.toString() diff --git a/std/shared/src/main/scala/cats/effect/std/MapRef.scala b/std/shared/src/main/scala/cats/effect/std/MapRef.scala index a071951e90..465b7fedec 100644 --- a/std/shared/src/main/scala/cats/effect/std/MapRef.scala +++ b/std/shared/src/main/scala/cats/effect/std/MapRef.scala @@ -17,7 +17,6 @@ package cats.effect.std import cats._ -import cats.conversions.all._ import cats.data._ import cats.effect.kernel._ import cats.syntax.all._ @@ -193,7 +192,7 @@ object MapRef extends MapRefCompanionPlatform { def tryModify[B](f: Option[V] => (Option[V], B)): F[Option[B]] = // we need the suspend because we do effects inside - delay { + delay[F[Option[B]]] { val init = chm.get(k) if (init == null) { f(None) match { diff --git a/std/shared/src/main/scala/cats/effect/std/PQueue.scala b/std/shared/src/main/scala/cats/effect/std/PQueue.scala index c0a8a04d8e..06d641cc22 100644 --- a/std/shared/src/main/scala/cats/effect/std/PQueue.scala +++ b/std/shared/src/main/scala/cats/effect/std/PQueue.scala @@ -59,7 +59,7 @@ object PQueue { assertNonNegative(capacity) F.ref(State.empty[F, A]).map { ref => new PQueueImpl[F, A](ref, capacity) { - implicit val Ord = O + implicit val Ord: Order[A] = O } } } @@ -214,37 +214,7 @@ object PQueue { else () } -trait PQueueSource[F[_], A] { - - /** - * Dequeues the least element from the PQueue, possibly fiber blocking until an element - * becomes available. - * - * O(log(n)) - * - * Note: If there are multiple elements with least priority, the order in which they are - * dequeued is undefined. If you want to break ties with FIFO order you will need an - * additional `Ref[F, Long]` to track insertion, and embed that information into your instance - * for `Order[A]`. - */ - def take: F[A] - - /** - * Attempts to dequeue the least element from the PQueue, if one is available without fiber - * blocking. - * - * O(log(n)) - * - * @return - * an effect that describes whether the dequeueing of an element from the PQueue succeeded - * without blocking, with `None` denoting that no element was available - * - * Note: If there are multiple elements with least priority, the order in which they are - * dequeued is undefined. If you want to break ties with FIFO order you will need an - * additional `Ref[F, Long]` to track insertion, and embed that information into your instance - * for `Order[A]`. - */ - def tryTake: F[Option[A]] +trait PQueueSource[F[_], A] extends QueueSource[F, A] { /** * Attempts to dequeue elements from the PQueue, if they are available without semantically @@ -260,33 +230,12 @@ trait PQueueSource[F[_], A] { * Note: If there are multiple elements with least priority, the order in which they are * dequeued is undefined. */ - def tryTakeN(maxN: Option[Int])(implicit F: Monad[F]): F[List[A]] = { - PQueueSource.assertMaxNPositive(maxN) - - def loop(i: Int, limit: Int, acc: List[A]): F[List[A]] = - if (i >= limit) - F.pure(acc.reverse) - else - tryTake flatMap { - case Some(a) => loop(i + 1, limit, a :: acc) - case None => F.pure(acc.reverse) - } - - maxN match { - case Some(limit) => loop(0, limit, Nil) - case None => loop(0, Int.MaxValue, Nil) - } - } + override def tryTakeN(maxN: Option[Int])(implicit F: Monad[F]): F[List[A]] = + QueueSource.tryTakeN[F, A](maxN, tryTake) - def size: F[Int] } object PQueueSource { - private def assertMaxNPositive(maxN: Option[Int]): Unit = maxN match { - case Some(n) if n <= 0 => - throw new IllegalArgumentException(s"Provided maxN parameter must be positive, was $n") - case _ => () - } implicit def catsFunctorForPQueueSource[F[_]: Functor]: Functor[PQueueSource[F, *]] = new Functor[PQueueSource[F, *]] { @@ -302,31 +251,7 @@ object PQueueSource { } } -trait PQueueSink[F[_], A] { - - /** - * Enqueues the given element, possibly fiber blocking until sufficient capacity becomes - * available. - * - * O(log(n)) - * - * @param a - * the element to be put in the PQueue - */ - def offer(a: A): F[Unit] - - /** - * Attempts to enqueue the given element without fiber blocking. - * - * O(log(n)) - * - * @param a - * the element to be put in the PQueue - * @return - * an effect that describes whether the enqueuing of the given element succeeded without - * blocking - */ - def tryOffer(a: A): F[Boolean] +trait PQueueSink[F[_], A] extends QueueSink[F, A] { /** * Attempts to enqueue the given elements without semantically blocking. If an item in the @@ -339,17 +264,13 @@ trait PQueueSink[F[_], A] { * @return * an effect that contains the remaining valus that could not be offered. */ - def tryOfferN(list: List[A])(implicit F: Monad[F]): F[List[A]] = list match { - case Nil => F.pure(list) - case h :: t => - tryOffer(h).ifM( - tryOfferN(t), - F.pure(list) - ) - } + override def tryOfferN(list: List[A])(implicit F: Monad[F]): F[List[A]] = + QueueSink.tryOfferN(list, tryOffer) + } object PQueueSink { + implicit def catsContravariantForPQueueSink[F[_]]: Contravariant[PQueueSink[F, *]] = new Contravariant[PQueueSink[F, *]] { override def contramap[A, B](fa: PQueueSink[F, A])(f: B => A): PQueueSink[F, B] = diff --git a/std/shared/src/main/scala/cats/effect/std/Queue.scala b/std/shared/src/main/scala/cats/effect/std/Queue.scala index ba8138e873..1ca998591d 100644 --- a/std/shared/src/main/scala/cats/effect/std/Queue.scala +++ b/std/shared/src/main/scala/cats/effect/std/Queue.scala @@ -105,6 +105,34 @@ object Queue { private[effect] def unboundedForAsync[F[_], A](implicit F: Async[F]): F[Queue[F, A]] = F.delay(new UnboundedAsyncQueue()) + private[effect] def droppingForConcurrent[F[_], A](capacity: Int)( + implicit F: GenConcurrent[F, _]): F[Queue[F, A]] = + F.ref(State.empty[F, A]).map(new DroppingQueue(capacity, _)) + + private[effect] def droppingForAsync[F[_], A](capacity: Int)( + implicit F: Async[F]): F[Queue[F, A]] = + F.delay(new DroppingAsyncQueue(capacity)) + + /** + * Creates a new `Queue` subject to some `capacity` bound which supports a side-effecting + * `tryOffer` function, allowing impure code to directly add values to the queue without + * indirecting through something like [[Dispatcher]]. This can improve performance + * significantly in some common cases. Note that the queue produced by this constructor can be + * used as a perfectly conventional [[Queue]] (as it is a subtype). + * + * @param capacity + * the maximum capacity of the queue (must be strictly greater than 1 and less than 32768) + * @return + * an empty bounded queue + * @see + * [[cats.effect.std.unsafe.BoundedQueue]] + */ + def unsafeBounded[F[_], A](capacity: Int)( + implicit F: Async[F]): F[unsafe.BoundedQueue[F, A]] = { + require(capacity > 1 && capacity < Short.MaxValue.toInt * 2) + F.delay(new BoundedAsyncQueue(capacity)) + } + /** * Constructs a queue through which a single element can pass only in the case when there are * at least one taking fiber and at least one offering fiber for `F` data types that are @@ -135,6 +163,21 @@ object Queue { unboundedForConcurrent } + /** + * Creates a new unbounded `Queue` which supports a side-effecting `offer` function, allowing + * impure code to directly add values to the queue without indirecting through something like + * [[Dispatcher]]. This can improve performance significantly in some common cases. Note that + * the queue produced by this constructor can be used as a perfectly conventional [[Queue]] + * (as it is a subtype). + * + * @return + * an empty unbounded queue + * @see + * [[cats.effect.std.unsafe.UnboundedQueue]] + */ + def unsafeUnbounded[F[_], A](implicit F: Async[F]): F[unsafe.UnboundedQueue[F, A]] = + F.delay(new UnboundedAsyncQueue()) + /** * Constructs an empty, bounded, dropping queue holding up to `capacity` elements for `F` data * types that are [[cats.effect.kernel.GenConcurrent]]. When the queue is full (contains @@ -149,7 +192,18 @@ object Queue { */ def dropping[F[_], A](capacity: Int)(implicit F: GenConcurrent[F, _]): F[Queue[F, A]] = { assertPositive(capacity, "Dropping") - F.ref(State.empty[F, A]).map(new DroppingQueue(capacity, _)) + // async queue can't handle capacity == 1 and allocates eagerly, so cap at 64k + if (1 < capacity && capacity < Short.MaxValue.toInt * 2) { + F match { + case f0: Async[F] => + droppingForAsync[F, A](capacity)(f0) + + case _ => + droppingForConcurrent[F, A](capacity) + } + } else { + droppingForConcurrent[F, A](capacity) + } } /** @@ -538,104 +592,21 @@ object Queue { private val EitherUnit: Either[Nothing, Unit] = Right(()) - /* - * Does not correctly handle bound = 0 because take waiters are async[Unit] - */ - private final class BoundedAsyncQueue[F[_], A](capacity: Int)(implicit F: Async[F]) + private abstract class BaseBoundedAsyncQueue[F[_], A](capacity: Int)(implicit F: Async[F]) extends Queue[F, A] { + require(capacity > 1) - private[this] val buffer = new UnsafeBounded[A](capacity) + protected[this] val buffer = new UnsafeBounded[A](capacity) - private[this] val takers = new UnsafeUnbounded[Either[Throwable, Unit] => Unit]() - private[this] val offerers = new UnsafeUnbounded[Either[Throwable, Unit] => Unit]() + protected[this] val takers = new UnsafeUnbounded[Either[Throwable, Unit] => Unit]() + protected[this] val offerers = new UnsafeUnbounded[Either[Throwable, Unit] => Unit]() - private[this] val FailureSignal = cats.effect.std.FailureSignal // prefetch + protected[this] val FailureSignal = cats.effect.std.FailureSignal // prefetch // private[this] val takers = new ConcurrentLinkedQueue[AtomicReference[Either[Throwable, Unit] => Unit]]() // private[this] val offerers = new ConcurrentLinkedQueue[AtomicReference[Either[Throwable, Unit] => Unit]]() - def offer(a: A): F[Unit] = - F uncancelable { poll => - F defer { - try { - // attempt to put into the buffer; if the buffer is full, it will raise an exception - buffer.put(a) - // println(s"offered: size = ${buffer.size()}") - - // we successfully put, if there are any takers, grab the first one and wake it up - notifyOne(takers) - F.unit - } catch { - case FailureSignal => - // capture whether or not we were successful in our retry - var succeeded = false - - // a latch blocking until some taker notifies us - val wait = F.async[Unit] { k => - F delay { - // add ourselves to the listeners queue - val clear = offerers.put(k) - - try { - // now that we're listening, re-attempt putting - buffer.put(a) - - // it worked! clear ourselves out of the queue - clear() - // our retry succeeded - succeeded = true - - // manually complete our own callback - // note that we could have a race condition here where we're already completed - // async will deduplicate these calls for us - // additionally, the continuation (below) is held until the registration completes - k(EitherUnit) - - // we *might* have negated a notification by succeeding here - // unnecessary wake-ups are mostly harmless (only slight fairness loss) - notifyOne(offerers) - - // technically it's possible to already have waiting takers. notify one of them - notifyOne(takers) - - // we're immediately complete, so no point in creating a finalizer - None - } catch { - case FailureSignal => - // our retry failed, meaning the queue is still full and we're listening, so suspend - // println(s"failed offer size = ${buffer.size()}") - Some(F.delay(clear())) - } - } - } - - val notifyAnyway = F delay { - // we might have been awakened and canceled simultaneously - // try waking up another offerer just in case - notifyOne(offerers) - } - - // suspend until the buffer put can succeed - // if succeeded is true then we've *already* put - // if it's false, then some taker woke us up, so race the retry with other offers - (poll(wait) *> F.defer(if (succeeded) F.unit else poll(offer(a)))) - .onCancel(notifyAnyway) - } - } - } - - def tryOffer(a: A): F[Boolean] = F delay { - try { - buffer.put(a) - notifyOne(takers) - true - } catch { - case FailureSignal => - false - } - } - val size: F[Int] = F.delay(buffer.size()) val take: F[A] = @@ -769,7 +740,7 @@ object Queue { // TODO could optimize notifications by checking if buffer is completely empty on put @tailrec - private[this] def notifyOne( + protected[this] final def notifyOne( waiters: UnsafeUnbounded[Either[Throwable, Unit] => Unit]): Unit = { // capture whether or not we should loop (structured in this way to avoid nested try/catch, which has a performance cost) val retry = @@ -802,16 +773,113 @@ object Queue { } } - private final class UnboundedAsyncQueue[F[_], A]()(implicit F: Async[F]) extends Queue[F, A] { + /* + * Does not correctly handle bound = 0 because take waiters are async[Unit] + */ + private final class BoundedAsyncQueue[F[_], A](capacity: Int)(implicit F: Async[F]) + extends BaseBoundedAsyncQueue[F, A](capacity) + with unsafe.BoundedQueue[F, A] { + + def offer(a: A): F[Unit] = + F uncancelable { poll => + F defer { + try { + // attempt to put into the buffer; if the buffer is full, it will raise an exception + buffer.put(a) + // println(s"offered: size = ${buffer.size()}") + + // we successfully put, if there are any takers, grab the first one and wake it up + notifyOne(takers) + F.unit + } catch { + case FailureSignal => + // capture whether or not we were successful in our retry + var succeeded = false + + // a latch blocking until some taker notifies us + val wait = F.async[Unit] { k => + F delay { + // add ourselves to the listeners queue + val clear = offerers.put(k) + + try { + // now that we're listening, re-attempt putting + buffer.put(a) + + // it worked! clear ourselves out of the queue + clear() + // our retry succeeded + succeeded = true + + // manually complete our own callback + // note that we could have a race condition here where we're already completed + // async will deduplicate these calls for us + // additionally, the continuation (below) is held until the registration completes + k(EitherUnit) + + // we *might* have negated a notification by succeeding here + // unnecessary wake-ups are mostly harmless (only slight fairness loss) + notifyOne(offerers) + + // technically it's possible to already have waiting takers. notify one of them + notifyOne(takers) + + // we're immediately complete, so no point in creating a finalizer + None + } catch { + case FailureSignal => + // our retry failed, meaning the queue is still full and we're listening, so suspend + // println(s"failed offer size = ${buffer.size()}") + Some(F.delay(clear())) + } + } + } + + val notifyAnyway = F delay { + // we might have been awakened and canceled simultaneously + // try waking up another offerer just in case + notifyOne(offerers) + } + + // suspend until the buffer put can succeed + // if succeeded is true then we've *already* put + // if it's false, then some taker woke us up, so race the retry with other offers + (poll(wait) *> F.defer(if (succeeded) F.unit else poll(offer(a)))) + .onCancel(notifyAnyway) + } + } + } + + def unsafeTryOffer(a: A): Boolean = { + try { + buffer.put(a) + notifyOne(takers) + true + } catch { + case FailureSignal => + false + } + } + + def tryOffer(a: A): F[Boolean] = F.delay(unsafeTryOffer(a)) + + } + + private final class UnboundedAsyncQueue[F[_], A]()(implicit F: Async[F]) + extends Queue[F, A] + with unsafe.UnboundedQueue[F, A] { + private[this] val buffer = new UnsafeUnbounded[A]() private[this] val takers = new UnsafeUnbounded[Either[Throwable, Unit] => Unit]() private[this] val FailureSignal = cats.effect.std.FailureSignal // prefetch - def offer(a: A): F[Unit] = F delay { + def unsafeOffer(a: A): Unit = { buffer.put(a) notifyOne() } + def offer(a: A): F[Unit] = F.delay(unsafeOffer(a)) + def tryOffer(a: A): F[Boolean] = F delay { buffer.put(a) notifyOne() @@ -916,6 +984,29 @@ object Queue { } } + private final class DroppingAsyncQueue[F[_], A](capacity: Int)(implicit F: Async[F]) + extends BaseBoundedAsyncQueue[F, A](capacity) { + + def offer(a: A): F[Unit] = + F.delay { + tryOfferUnsafe(a) + () + } + + def tryOffer(a: A): F[Boolean] = + F.delay(tryOfferUnsafe(a)) + + private def tryOfferUnsafe(a: A): Boolean = + try { + buffer.put(a) + notifyOne(takers) + true + } catch { + case FailureSignal => + false + } + } + // ported with love from https://github.com/JCTools/JCTools/blob/master/jctools-core/src/main/java/org/jctools/queues/MpmcArrayQueue.java private[effect] final class UnsafeBounded[A](bound: Int) { require(bound > 1) @@ -1110,133 +1201,3 @@ object Queue { } } } - -trait QueueSource[F[_], A] { - - /** - * Dequeues an element from the front of the queue, possibly fiber blocking until an element - * becomes available. - */ - def take: F[A] - - /** - * Attempts to dequeue an element from the front of the queue, if one is available without - * fiber blocking. - * - * @return - * an effect that describes whether the dequeueing of an element from the queue succeeded - * without blocking, with `None` denoting that no element was available - */ - def tryTake: F[Option[A]] - - /** - * Attempts to dequeue elements from the front of the queue, if they are available without - * semantically blocking. This method does not guarantee any additional performance benefits - * beyond simply recursively calling [[tryTake]], though some implementations will provide a - * more efficient implementation. - * - * @param maxN - * The max elements to dequeue. Passing `None` will try to dequeue the whole queue. - * - * @return - * an effect that contains the dequeued elements - */ - def tryTakeN(maxN: Option[Int])(implicit F: Monad[F]): F[List[A]] = { - QueueSource.assertMaxNPositive(maxN) - - def loop(i: Int, limit: Int, acc: List[A]): F[List[A]] = - if (i >= limit) - F.pure(acc.reverse) - else - tryTake flatMap { - case Some(a) => loop(i + 1, limit, a :: acc) - case None => F.pure(acc.reverse) - } - - maxN match { - case Some(limit) => loop(0, limit, Nil) - case None => loop(0, Int.MaxValue, Nil) - } - } - - def size: F[Int] -} - -object QueueSource { - private[std] def assertMaxNPositive(maxN: Option[Int]): Unit = maxN match { - case Some(n) if n <= 0 => - throw new IllegalArgumentException(s"Provided maxN parameter must be positive, was $n") - case _ => () - } - - implicit def catsFunctorForQueueSource[F[_]: Functor]: Functor[QueueSource[F, *]] = - new Functor[QueueSource[F, *]] { - override def map[A, B](fa: QueueSource[F, A])(f: A => B): QueueSource[F, B] = - new QueueSource[F, B] { - override def take: F[B] = - fa.take.map(f) - override def tryTake: F[Option[B]] = { - fa.tryTake.map(_.map(f)) - } - override def size: F[Int] = - fa.size - } - } -} - -trait QueueSink[F[_], A] { - - /** - * Enqueues the given element at the back of the queue, possibly fiber blocking until - * sufficient capacity becomes available. - * - * @param a - * the element to be put at the back of the queue - */ - def offer(a: A): F[Unit] - - /** - * Attempts to enqueue the given element at the back of the queue without semantically - * blocking. - * - * @param a - * the element to be put at the back of the queue - * @return - * an effect that describes whether the enqueuing of the given element succeeded without - * blocking - */ - def tryOffer(a: A): F[Boolean] - - /** - * Attempts to enqueue the given elements at the back of the queue without semantically - * blocking. If an item in the list cannot be enqueued, the remaining elements will be - * returned. This is a convenience method that recursively runs `tryOffer` and does not offer - * any additional performance benefits. - * - * @param list - * the elements to be put at the back of the queue - * @return - * an effect that contains the remaining valus that could not be offered. - */ - def tryOfferN(list: List[A])(implicit F: Monad[F]): F[List[A]] = list match { - case Nil => F.pure(list) - case h :: t => - tryOffer(h).ifM( - tryOfferN(t), - F.pure(list) - ) - } -} - -object QueueSink { - implicit def catsContravariantForQueueSink[F[_]]: Contravariant[QueueSink[F, *]] = - new Contravariant[QueueSink[F, *]] { - override def contramap[A, B](fa: QueueSink[F, A])(f: B => A): QueueSink[F, B] = - new QueueSink[F, B] { - override def offer(b: B): F[Unit] = - fa.offer(f(b)) - override def tryOffer(b: B): F[Boolean] = - fa.tryOffer(f(b)) - } - } -} diff --git a/std/shared/src/main/scala/cats/effect/std/QueueSink.scala b/std/shared/src/main/scala/cats/effect/std/QueueSink.scala new file mode 100644 index 0000000000..c590b13f77 --- /dev/null +++ b/std/shared/src/main/scala/cats/effect/std/QueueSink.scala @@ -0,0 +1,83 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats +package effect +package std + +import cats.syntax.all._ + +trait QueueSink[F[_], A] { + + /** + * Enqueues the given element at the back of the queue, possibly fiber blocking until + * sufficient capacity becomes available. + * + * @param a + * the element to be put at the back of the queue + */ + def offer(a: A): F[Unit] + + /** + * Attempts to enqueue the given element at the back of the queue without semantically + * blocking. + * + * @param a + * the element to be put at the back of the queue + * @return + * an effect that describes whether the enqueuing of the given element succeeded without + * blocking + */ + def tryOffer(a: A): F[Boolean] + + /** + * Attempts to enqueue the given elements at the back of the queue without semantically + * blocking. If an item in the list cannot be enqueued, the remaining elements will be + * returned. This is a convenience method that recursively runs `tryOffer` and does not offer + * any additional performance benefits. + * + * @param list + * the elements to be put at the back of the queue + * @return + * an effect that contains the remaining valus that could not be offered. + */ + def tryOfferN(list: List[A])(implicit F: Monad[F]): F[List[A]] = + QueueSink.tryOfferN(list, tryOffer) +} + +object QueueSink { + + private[std] def tryOfferN[F[_], A](list: List[A], tryOffer: A => F[Boolean])( + implicit F: Monad[F]): F[List[A]] = list match { + case Nil => F.pure(list) + case h :: t => + tryOffer(h).ifM( + tryOfferN(t, tryOffer), + F.pure(list) + ) + } + + implicit def catsContravariantForQueueSink[F[_]]: Contravariant[QueueSink[F, *]] = + new Contravariant[QueueSink[F, *]] { + override def contramap[A, B](fa: QueueSink[F, A])(f: B => A): QueueSink[F, B] = + new QueueSink[F, B] { + override def offer(b: B): F[Unit] = + fa.offer(f(b)) + override def tryOffer(b: B): F[Boolean] = + fa.tryOffer(f(b)) + } + } +} diff --git a/std/shared/src/main/scala/cats/effect/std/QueueSource.scala b/std/shared/src/main/scala/cats/effect/std/QueueSource.scala new file mode 100644 index 0000000000..c8ecae3ce0 --- /dev/null +++ b/std/shared/src/main/scala/cats/effect/std/QueueSource.scala @@ -0,0 +1,99 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats +package effect +package std + +import cats.syntax.all._ + +trait QueueSource[F[_], A] { + + /** + * Dequeues an element from the front of the queue, possibly fiber blocking until an element + * becomes available. + */ + def take: F[A] + + /** + * Attempts to dequeue an element from the front of the queue, if one is available without + * fiber blocking. + * + * @return + * an effect that describes whether the dequeueing of an element from the queue succeeded + * without blocking, with `None` denoting that no element was available + */ + def tryTake: F[Option[A]] + + /** + * Attempts to dequeue elements from the front of the queue, if they are available without + * semantically blocking. This method does not guarantee any additional performance benefits + * beyond simply recursively calling [[tryTake]], though some implementations will provide a + * more efficient implementation. + * + * @param maxN + * The max elements to dequeue. Passing `None` will try to dequeue the whole queue. + * + * @return + * an effect that contains the dequeued elements + */ + def tryTakeN(maxN: Option[Int])(implicit F: Monad[F]): F[List[A]] = + QueueSource.tryTakeN(maxN, tryTake) + + def size: F[Int] +} + +object QueueSource { + + private[std] def tryTakeN[F[_], A](maxN: Option[Int], tryTake: F[Option[A]])( + implicit F: Monad[F]): F[List[A]] = { + QueueSource.assertMaxNPositive(maxN) + + def loop(i: Int, limit: Int, acc: List[A]): F[List[A]] = + if (i >= limit) + F.pure(acc.reverse) + else + tryTake flatMap { + case Some(a) => loop(i + 1, limit, a :: acc) + case None => F.pure(acc.reverse) + } + + maxN match { + case Some(limit) => loop(0, limit, Nil) + case None => loop(0, Int.MaxValue, Nil) + } + } + + private[std] def assertMaxNPositive(maxN: Option[Int]): Unit = maxN match { + case Some(n) if n <= 0 => + throw new IllegalArgumentException(s"Provided maxN parameter must be positive, was $n") + case _ => () + } + + implicit def catsFunctorForQueueSource[F[_]: Functor]: Functor[QueueSource[F, *]] = + new Functor[QueueSource[F, *]] { + override def map[A, B](fa: QueueSource[F, A])(f: A => B): QueueSource[F, B] = + new QueueSource[F, B] { + override def take: F[B] = + fa.take.map(f) + override def tryTake: F[Option[B]] = { + fa.tryTake.map(_.map(f)) + } + override def size: F[Int] = + fa.size + } + } +} diff --git a/std/shared/src/main/scala/cats/effect/std/Semaphore.scala b/std/shared/src/main/scala/cats/effect/std/Semaphore.scala index 6d32f22f0b..4b6e7d66c4 100644 --- a/std/shared/src/main/scala/cats/effect/std/Semaphore.scala +++ b/std/shared/src/main/scala/cats/effect/std/Semaphore.scala @@ -18,7 +18,6 @@ package cats package effect package std -import cats.Applicative import cats.effect.kernel._ import cats.effect.kernel.syntax.all._ import cats.syntax.all._ diff --git a/std/shared/src/main/scala/cats/effect/std/internal/BinomialHeap.scala b/std/shared/src/main/scala/cats/effect/std/internal/BinomialHeap.scala index 0740fc302a..de439d9fca 100644 --- a/std/shared/src/main/scala/cats/effect/std/internal/BinomialHeap.scala +++ b/std/shared/src/main/scala/cats/effect/std/internal/BinomialHeap.scala @@ -66,7 +66,7 @@ private[std] object BinomialHeap { def apply[A](trees: List[BinomialTree[A]])(implicit ord: Order[A]) = new BinomialHeap[A](trees) { - implicit val Ord = ord + implicit val Ord: Order[A] = ord } /** diff --git a/std/shared/src/main/scala/cats/effect/std/unsafe/BoundedQueue.scala b/std/shared/src/main/scala/cats/effect/std/unsafe/BoundedQueue.scala new file mode 100644 index 0000000000..d341b69d14 --- /dev/null +++ b/std/shared/src/main/scala/cats/effect/std/unsafe/BoundedQueue.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats +package effect +package std +package unsafe + +import cats.syntax.all._ + +/** + * A [[Queue]] which supports a side-effecting variant of `tryOffer`, allowing impure code to + * add elements to the queue without having to indirect through something like [[Dispatcher]]. + * Note that a side-effecting variant of `offer` is impossible without hard-blocking a thread + * (when the queue is full), and thus is not supported by this API. For a variant of the same + * queue which supports a side-effecting `offer`, see [[UnboundedQueue]]. + * + * @see + * [[Queue.unsafeBounded]] + */ +trait BoundedQueue[F[_], A] extends Queue[F, A] with BoundedQueueSink[F, A] + +object BoundedQueue { + + def apply[F[_]: kernel.Async, A](bound: Int): F[BoundedQueue[F, A]] = + Queue.unsafeBounded[F, A](bound) + + implicit def catsInvariantForBoundedQueue[F[_]: Functor]: Invariant[Queue[F, *]] = + new Invariant[Queue[F, *]] { + override def imap[A, B](fa: Queue[F, A])(f: A => B)(g: B => A): Queue[F, B] = + new Queue[F, B] { + override def offer(b: B): F[Unit] = + fa.offer(g(b)) + override def tryOffer(b: B): F[Boolean] = + fa.tryOffer(g(b)) + override def take: F[B] = + fa.take.map(f) + override def tryTake: F[Option[B]] = + fa.tryTake.map(_.map(f)) + override def size: F[Int] = + fa.size + } + } +} diff --git a/std/shared/src/main/scala/cats/effect/std/unsafe/BoundedQueueSink.scala b/std/shared/src/main/scala/cats/effect/std/unsafe/BoundedQueueSink.scala new file mode 100644 index 0000000000..9e38779e35 --- /dev/null +++ b/std/shared/src/main/scala/cats/effect/std/unsafe/BoundedQueueSink.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats +package effect +package std +package unsafe + +trait BoundedQueueSink[F[_], A] extends QueueSink[F, A] { + def unsafeTryOffer(a: A): Boolean +} + +object BoundedQueueSink { + implicit def catsContravariantForBoundedQueueSink[F[_]] + : Contravariant[BoundedQueueSink[F, *]] = + new Contravariant[BoundedQueueSink[F, *]] { + override def contramap[A, B](fa: BoundedQueueSink[F, A])( + f: B => A): BoundedQueueSink[F, B] = + new BoundedQueueSink[F, B] { + override def unsafeTryOffer(b: B): Boolean = + fa.unsafeTryOffer(f(b)) + override def offer(b: B): F[Unit] = + fa.offer(f(b)) + override def tryOffer(b: B): F[Boolean] = + fa.tryOffer(f(b)) + } + } +} diff --git a/std/shared/src/main/scala/cats/effect/std/unsafe/UnboundedQueue.scala b/std/shared/src/main/scala/cats/effect/std/unsafe/UnboundedQueue.scala new file mode 100644 index 0000000000..5627a36fc8 --- /dev/null +++ b/std/shared/src/main/scala/cats/effect/std/unsafe/UnboundedQueue.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats +package effect +package std +package unsafe + +import cats.syntax.all._ + +/** + * A [[Queue]] which supports a side-effecting variant of `offer`, allowing impure code to add + * elements to the queue without having to indirect through something like [[Dispatcher]]. + * + * @see + * [[Queue.unsafeUnbounded]] + */ +trait UnboundedQueue[F[_], A] + extends Queue[F, A] + with BoundedQueue[F, A] + with UnboundedQueueSink[F, A] + +object UnboundedQueue { + + def apply[F[_]: kernel.Async, A]: F[UnboundedQueue[F, A]] = + Queue.unsafeUnbounded[F, A] + + implicit def catsInvariantForUnboundedQueue[F[_]: Functor]: Invariant[Queue[F, *]] = + new Invariant[Queue[F, *]] { + override def imap[A, B](fa: Queue[F, A])(f: A => B)(g: B => A): Queue[F, B] = + new Queue[F, B] { + override def offer(b: B): F[Unit] = + fa.offer(g(b)) + override def tryOffer(b: B): F[Boolean] = + fa.tryOffer(g(b)) + override def take: F[B] = + fa.take.map(f) + override def tryTake: F[Option[B]] = + fa.tryTake.map(_.map(f)) + override def size: F[Int] = + fa.size + } + } +} diff --git a/std/shared/src/main/scala/cats/effect/std/unsafe/UnboundedQueueSink.scala b/std/shared/src/main/scala/cats/effect/std/unsafe/UnboundedQueueSink.scala new file mode 100644 index 0000000000..428ee60c88 --- /dev/null +++ b/std/shared/src/main/scala/cats/effect/std/unsafe/UnboundedQueueSink.scala @@ -0,0 +1,46 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats +package effect +package std +package unsafe + +trait UnboundedQueueSink[F[_], A] extends QueueSink[F, A] with BoundedQueueSink[F, A] { + def unsafeOffer(a: A): Unit + + def unsafeTryOffer(a: A): Boolean = { + unsafeOffer(a) + true + } +} + +object UnboundedQueueSink { + implicit def catsContravariantForUnboundedQueueSink[F[_]] + : Contravariant[UnboundedQueueSink[F, *]] = + new Contravariant[UnboundedQueueSink[F, *]] { + override def contramap[A, B](fa: UnboundedQueueSink[F, A])( + f: B => A): UnboundedQueueSink[F, B] = + new UnboundedQueueSink[F, B] { + override def unsafeOffer(b: B): Unit = + fa.unsafeOffer(f(b)) + override def offer(b: B): F[Unit] = + fa.offer(f(b)) + override def tryOffer(b: B): F[Boolean] = + fa.tryOffer(f(b)) + } + } +} diff --git a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest1.scala b/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest1.scala deleted file mode 100644 index d8c898bfe8..0000000000 --- a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest1.scala +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe - -import org.openjdk.jcstress.annotations.{Outcome => JOutcome, Ref => _, _} -import org.openjdk.jcstress.annotations.Expect._ -import org.openjdk.jcstress.annotations.Outcome.Outcomes -import org.openjdk.jcstress.infra.results.JJJJ_Result - -@JCStressTest -@State -@Description("TimerSkipList insert/pollFirstIfTriggered race") -@Outcomes( - Array( - new JOutcome( - id = Array("1024, -9223372036854775679, 1, 0"), - expect = ACCEPTABLE_INTERESTING, - desc = "insert won"), - new JOutcome( - id = Array("1024, -9223372036854775679, 0, 1"), - expect = ACCEPTABLE_INTERESTING, - desc = "pollFirst won") - )) -class SkipListTest1 { - - private[this] val headCb = - newCallback() - - private[this] val m = { - val m = new TimerSkipList - // head is 1025L: - m.insertTlr(now = 1L, delay = 1024L, callback = headCb) - for (i <- 2 to 128) { - m.insertTlr(now = i.toLong, delay = 1024L, callback = newCallback()) - } - m - } - - private[this] val newCb = - newCallback() - - @Actor - def insert(r: JJJJ_Result): Unit = { - // head is 1025L now, we insert 1024L: - val cancel = m.insertTlr(now = 128L, delay = 896L, callback = newCb).asInstanceOf[m.Node] - r.r1 = cancel.triggerTime - r.r2 = cancel.sequenceNum - } - - @Actor - def pollFirst(r: JJJJ_Result): Unit = { - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r3 = if (cb eq headCb) 0L else if (cb eq newCb) 1L else -1L - } - - @Arbiter - def arbiter(r: JJJJ_Result): Unit = { - val otherCb = m.pollFirstIfTriggered(now = 2048L) - r.r4 = if (otherCb eq headCb) 0L else if (otherCb eq newCb) 1L else -1L - } - - private[this] final def newCallback(): Right[Nothing, Unit] => Unit = { - new Function1[Right[Nothing, Unit], Unit] with Serializable { - final override def apply(r: Right[Nothing, Unit]): Unit = () - } - } -} diff --git a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest2.scala b/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest2.scala deleted file mode 100644 index e46312c387..0000000000 --- a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest2.scala +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe - -import org.openjdk.jcstress.annotations.{Outcome => JOutcome, Ref => _, _} -import org.openjdk.jcstress.annotations.Expect._ -import org.openjdk.jcstress.annotations.Outcome.Outcomes -import org.openjdk.jcstress.infra.results.JJJJJJ_Result - -@JCStressTest -@State -@Description("TimerSkipList insert/insert race") -@Outcomes( - Array( - new JOutcome( - id = Array("1100, -9223372036854775679, 1100, -9223372036854775678, 1, 2"), - expect = ACCEPTABLE_INTERESTING, - desc = "insert1 won"), - new JOutcome( - id = Array("1100, -9223372036854775678, 1100, -9223372036854775679, 2, 1"), - expect = ACCEPTABLE_INTERESTING, - desc = "insert2 won") - )) -class SkipListTest2 { - - private[this] val m = { - val DELAY = 1024L - val m = new TimerSkipList - for (i <- 1 to 128) { - m.insertTlr(now = i.toLong, delay = DELAY, callback = newCallback()) - } - m - } - - private[this] final val NOW = 128L - private[this] final val MAGIC = 972L - - private[this] val newCb1 = - newCallback() - - private[this] val newCb2 = - newCallback() - - @Actor - def insert1(r: JJJJJJ_Result): Unit = { - // the list contains times between 1025 and 1152, we insert at 1100: - val cancel = m.insertTlr(now = NOW, delay = MAGIC, callback = newCb1).asInstanceOf[m.Node] - r.r1 = cancel.triggerTime - r.r2 = cancel.sequenceNum - } - - @Actor - def insert2(r: JJJJJJ_Result): Unit = { - // the list contains times between 1025 and 1152, we insert at 1100: - val cancel = m.insertTlr(now = NOW, delay = MAGIC, callback = newCb2).asInstanceOf[m.Node] - r.r3 = cancel.triggerTime - r.r4 = cancel.sequenceNum - } - - @Arbiter - def arbiter(r: JJJJJJ_Result): Unit = { - // first remove all the items before the racy ones: - while ({ - val tt = m.peekFirstTriggerTime() - m.pollFirstIfTriggered(now = 2048L) - tt != (NOW + MAGIC) // there is an already existing callback with this triggerTime, we also remove that - }) {} - // then look at the 2 racy inserts: - val first = m.pollFirstIfTriggered(now = 2048L) - val second = m.pollFirstIfTriggered(now = 2048L) - r.r5 = if (first eq newCb1) 1L else if (first eq newCb2) 2L else -1L - r.r6 = if (second eq newCb1) 1L else if (second eq newCb2) 2L else -1L - } - - private[this] final def newCallback(): Right[Nothing, Unit] => Unit = { - new Function1[Right[Nothing, Unit], Unit] with Serializable { - final override def apply(r: Right[Nothing, Unit]): Unit = () - } - } -} diff --git a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest3.scala b/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest3.scala deleted file mode 100644 index 2c35374841..0000000000 --- a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest3.scala +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe - -import org.openjdk.jcstress.annotations.{Outcome => JOutcome, Ref => _, _} -import org.openjdk.jcstress.annotations.Expect._ -import org.openjdk.jcstress.annotations.Outcome.Outcomes -import org.openjdk.jcstress.infra.results.JJJJ_Result - -@JCStressTest -@State -@Description("TimerSkipList insert/cancel race") -@Outcomes( - Array( - new JOutcome( - id = Array("1100, -9223372036854775678, 1, 1"), - expect = ACCEPTABLE_INTERESTING, - desc = "ok") - )) -class SkipListTest3 { - - private[this] val m = { - val DELAY = 1024L - val m = new TimerSkipList - for (i <- 1 to 128) { - m.insertTlr(now = i.toLong, delay = DELAY, callback = newCallback()) - } - m - } - - private[this] final val NOW = 128L - private[this] final val MAGIC = 972L - - private[this] val cancelledCb = - newCallback() - - private[this] val canceller: Runnable = - m.insertTlr(128L, MAGIC, cancelledCb) - - private[this] val newCb = - newCallback() - - @Actor - def insert(r: JJJJ_Result): Unit = { - // the list contains times between 1025 and 1152, we insert at 1100: - val cancel = - m.insertTlr(now = NOW, delay = MAGIC, callback = newCb).asInstanceOf[m.Node] - r.r1 = cancel.triggerTime - r.r2 = cancel.sequenceNum - } - - @Actor - def cancel(): Unit = { - canceller.run() - } - - @Arbiter - def arbiter(r: JJJJ_Result): Unit = { - // first remove all the items before the racy ones: - while ({ - val tt = m.peekFirstTriggerTime() - m.pollFirstIfTriggered(now = 2048L) - tt != (NOW + MAGIC) // there is an already existing callback with this triggerTime, we also remove that - }) {} - // then look at the inserted item: - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r3 = if (cb eq newCb) 1L else 0L - // the cancelled one must be missing: - val other = m.pollFirstIfTriggered(now = 2048L) - r.r4 = if (other eq cancelledCb) 0L else if (other eq newCb) -1L else 1L - } - - private[this] final def newCallback(): Right[Nothing, Unit] => Unit = { - new Function1[Right[Nothing, Unit], Unit] with Serializable { - final override def apply(r: Right[Nothing, Unit]): Unit = () - } - } -} diff --git a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest4.scala b/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest4.scala deleted file mode 100644 index 9d1f0fa3a1..0000000000 --- a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest4.scala +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe - -import org.openjdk.jcstress.annotations.{Outcome => JOutcome, Ref => _, _} -import org.openjdk.jcstress.annotations.Expect._ -import org.openjdk.jcstress.annotations.Outcome.Outcomes -import org.openjdk.jcstress.infra.results.JJJ_Result - -@JCStressTest -@State -@Description("TimerSkipList pollFirstIfTriggered/pollFirstIfTriggered race") -@Outcomes( - Array( - new JOutcome( - id = Array("1, 0, 0"), - expect = ACCEPTABLE_INTERESTING, - desc = "pollFirst1 won"), - new JOutcome( - id = Array("0, 1, 0"), - expect = ACCEPTABLE_INTERESTING, - desc = "pollFirst2 won") - )) -class SkipListTest4 { - - private[this] val headCb = - newCallback() - - private[this] val secondCb = - newCallback() - - private[this] val m = { - val m = new TimerSkipList - // head is 1025L: - m.insertTlr(now = 1L, delay = 1024L, callback = headCb) - // second is 1026L: - m.insertTlr(now = 2L, delay = 1024L, callback = secondCb) - for (i <- 3 to 128) { - m.insertTlr(now = i.toLong, delay = 1024L, callback = newCallback()) - } - m - } - - @Actor - def pollFirst1(r: JJJ_Result): Unit = { - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r1 = if (cb eq headCb) 1L else if (cb eq secondCb) 0L else -1L - } - - @Actor - def pollFirst2(r: JJJ_Result): Unit = { - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r2 = if (cb eq headCb) 1L else if (cb eq secondCb) 0L else -1L - } - - @Arbiter - def arbiter(r: JJJ_Result): Unit = { - val otherCb = m.pollFirstIfTriggered(now = 2048L) - r.r3 = if (otherCb eq headCb) -1L else if (otherCb eq secondCb) -1L else 0L - } - - private[this] final def newCallback(): Right[Nothing, Unit] => Unit = { - new Function1[Right[Nothing, Unit], Unit] with Serializable { - final override def apply(r: Right[Nothing, Unit]): Unit = () - } - } -} diff --git a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest5.scala b/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest5.scala deleted file mode 100644 index a67b6f9a01..0000000000 --- a/stress-tests/src/test/scala/cats/effect/unsafe/SkipListTest5.scala +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect.unsafe - -import org.openjdk.jcstress.annotations.{Outcome => JOutcome, Ref => _, _} -import org.openjdk.jcstress.annotations.Expect._ -import org.openjdk.jcstress.annotations.Outcome.Outcomes -import org.openjdk.jcstress.infra.results.JJJ_Result - -@JCStressTest -@State -@Description("TimerSkipList pollFirstIfTriggered/pollFirstIfTriggered race (single element)") -@Outcomes( - Array( - new JOutcome( - id = Array("1, 0, 0"), - expect = ACCEPTABLE_INTERESTING, - desc = "pollFirst1 won"), - new JOutcome( - id = Array("0, 1, 0"), - expect = ACCEPTABLE_INTERESTING, - desc = "pollFirst2 won") - )) -class SkipListTest5 { - - private[this] val headCb = - newCallback() - - private[this] val m = { - val m = new TimerSkipList - // head is 1025L: - m.insertTlr(now = 1L, delay = 1024L, callback = headCb) - m - } - - @Actor - def pollFirst1(r: JJJ_Result): Unit = { - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r1 = if (cb eq headCb) 1L else if (cb eq null) 0L else -1L - } - - @Actor - def pollFirst2(r: JJJ_Result): Unit = { - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r2 = if (cb eq headCb) 1L else if (cb eq null) 0L else -1L - } - - @Arbiter - def arbiter(r: JJJ_Result): Unit = { - val cb = m.pollFirstIfTriggered(now = 2048L) - r.r3 = if (cb eq null) 0L else -1L - } - - private[this] final def newCallback(): Right[Nothing, Unit] => Unit = { - new Function1[Right[Nothing, Unit], Unit] with Serializable { - final override def apply(r: Right[Nothing, Unit]): Unit = () - } - } -} diff --git a/tests/js/src/main/scala/catseffect/examplesplatform.scala b/tests/js/src/main/scala/catseffect/examplesplatform.scala index 83f8c1a497..53ef8c7ac2 100644 --- a/tests/js/src/main/scala/catseffect/examplesplatform.scala +++ b/tests/js/src/main/scala/catseffect/examplesplatform.scala @@ -110,19 +110,4 @@ package examples { def run(args: List[String]): IO[ExitCode] = IO.pure(ExitCode.Success) } - // stub - object EvalOnMainThread extends IOApp { - def run(args: List[String]): IO[ExitCode] = IO.never - } - - // stub - object MainThreadReportFailure extends IOApp { - def run(args: List[String]): IO[ExitCode] = IO.never - } - - // stub - object BlockedThreads extends IOApp.Simple { - val run = IO.never - } - } diff --git a/tests/js/src/test/scala/cats/effect/unsafe/JSArrayQueueSpec.scala b/tests/js/src/test/scala/cats/effect/unsafe/JSArrayQueueSpec.scala index 3d1ed11988..5975c29f3f 100644 --- a/tests/js/src/test/scala/cats/effect/unsafe/JSArrayQueueSpec.scala +++ b/tests/js/src/test/scala/cats/effect/unsafe/JSArrayQueueSpec.scala @@ -66,6 +66,8 @@ class JSArrayQueueSpec extends BaseSpec with ScalaCheck { val expected = shadow.dequeue() got must beEqualTo(expected) checkContents() + } else { + ok } } diff --git a/tests/jvm-native/src/main/scala/catseffect/examplesjvmnative.scala b/tests/jvm-native/src/main/scala/catseffect/examplesjvmnative.scala new file mode 100644 index 0000000000..878e515ea3 --- /dev/null +++ b/tests/jvm-native/src/main/scala/catseffect/examplesjvmnative.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package catseffect + +import cats.effect.{ExitCode, IO, IOApp} + +import java.io.{File, FileWriter} + +package examples { + object Finalizers extends IOApp { + + def writeToFile(string: String, file: File): IO[Unit] = + IO(new FileWriter(file)).bracket { writer => IO(writer.write(string)) }(writer => + IO(writer.close())) + + def run(args: List[String]): IO[ExitCode] = + (IO(println("Started")) >> IO.never) + .onCancel(writeToFile("canceled", new File(args.head))) + .as(ExitCode.Success) + } +} diff --git a/tests/jvm/src/main/scala/catseffect/examplesplatform.scala b/tests/jvm/src/main/scala/catseffect/examplesplatform.scala index 64d02762fc..7fa6f16638 100644 --- a/tests/jvm/src/main/scala/catseffect/examplesplatform.scala +++ b/tests/jvm/src/main/scala/catseffect/examplesplatform.scala @@ -22,7 +22,6 @@ import cats.syntax.all._ import scala.concurrent.ExecutionContext import scala.concurrent.duration._ -import java.io.File import java.util.concurrent.atomic.AtomicReference package object examples { @@ -37,7 +36,7 @@ package examples { super.runtimeConfig.copy(shutdownHookTimeout = Duration.Zero) val run: IO[Unit] = - IO(System.exit(0)).uncancelable + IO.blocking(System.exit(0)).uncancelable } object FatalErrorUnsafeRun extends IOApp { @@ -51,24 +50,6 @@ package examples { } yield ExitCode.Success } - object Finalizers extends IOApp { - import java.io.FileWriter - - def writeToFile(string: String, file: File): IO[Unit] = - IO(new FileWriter(file)).bracket { writer => IO(writer.write(string)) }(writer => - IO(writer.close())) - - def run(args: List[String]): IO[ExitCode] = - (IO(println("Started")) >> IO.never) - .onCancel(writeToFile("canceled", new File(args.head))) - .as(ExitCode.Success) - } - - // just a stub to satisfy compiler, never run on JVM - object UndefinedProcessExit extends IOApp { - def run(args: List[String]): IO[ExitCode] = IO.never - } - object EvalOnMainThread extends IOApp { def run(args: List[String]): IO[ExitCode] = IO(Thread.currentThread().getId()).evalOn(MainThread) map { diff --git a/tests/jvm/src/test/scala/cats/effect/DetectPlatform.scala b/tests/jvm/src/test/scala/cats/effect/DetectPlatform.scala index e736df07e7..fc8b5d5d26 100644 --- a/tests/jvm/src/test/scala/cats/effect/DetectPlatform.scala +++ b/tests/jvm/src/test/scala/cats/effect/DetectPlatform.scala @@ -21,4 +21,7 @@ trait DetectPlatform { def isJS: Boolean = false def isJVM: Boolean = true def isNative: Boolean = false + + def javaMajorVersion: Int = + System.getProperty("java.version").stripPrefix("1.").takeWhile(_.isDigit).toInt } diff --git a/tests/jvm/src/test/scala/cats/effect/IOAppSpec.scala b/tests/jvm/src/test/scala/cats/effect/IOAppSpec.scala deleted file mode 100644 index c6718b38e1..0000000000 --- a/tests/jvm/src/test/scala/cats/effect/IOAppSpec.scala +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect - -import org.specs2.mutable.Specification - -import scala.io.Source -import scala.sys.process.{BasicIO, Process, ProcessBuilder} - -import java.io.File - -class IOAppSpec extends Specification { - - abstract class Platform(val id: String) { outer => - def builder(proto: AnyRef, args: List[String]): ProcessBuilder - def pid(proto: AnyRef): Option[Int] - - def dumpSignal: String - - def sendSignal(pid: Int): Unit = { - Runtime.getRuntime().exec(s"kill -$dumpSignal $pid") - () - } - - def apply(proto: AnyRef, args: List[String]): Handle = { - val stdoutBuffer = new StringBuffer() - val stderrBuffer = new StringBuffer() - val p = builder(proto, args).run(BasicIO(false, stdoutBuffer, None).withError { in => - val err = Source.fromInputStream(in).getLines().mkString(System.lineSeparator()) - stderrBuffer.append(err) - () - }) - - new Handle { - def awaitStatus() = p.exitValue() - def term() = p.destroy() // TODO probably doesn't work - def stderr() = stderrBuffer.toString - def stdout() = stdoutBuffer.toString - def pid() = outer.pid(proto) - } - } - } - - object JVM extends Platform("jvm") { - val ClassPath = System.getProperty("sbt.classpath") - - val JavaHome = { - val path = sys.env.get("JAVA_HOME").orElse(sys.props.get("java.home")).get - if (path.endsWith("/jre")) { - // handle JDK 8 installations - path.replace("/jre", "") - } else { - path - } - } - - val dumpSignal = "USR1" - - def builder(proto: AnyRef, args: List[String]) = Process( - s"$JavaHome/bin/java", - List("-cp", ClassPath, proto.getClass.getName.replaceAll("\\$$", "")) ::: args) - - // scala.sys.process.Process and java.lang.Process lack getting PID support. Java 9+ introduced it but - // whatever because it's very hard to obtain a java.lang.Process from scala.sys.process.Process. - def pid(proto: AnyRef): Option[Int] = { - val mainName = proto.getClass.getSimpleName.replace("$", "") - val jpsStdoutBuffer = new StringBuffer() - val jpsProcess = - Process(s"$JavaHome/bin/jps", List.empty).run(BasicIO(false, jpsStdoutBuffer, None)) - jpsProcess.exitValue() - - val output = jpsStdoutBuffer.toString - Source.fromString(output).getLines().find(_.contains(mainName)).map(_.split(" ")(0).toInt) - } - - } - - object Node extends Platform("node") { - val dumpSignal = "USR2" - - def builder(proto: AnyRef, args: List[String]) = - Process( - s"node", - "--enable-source-maps" :: BuildInfo - .jsRunner - .getAbsolutePath :: proto.getClass.getName.init :: args) - - def pid(proto: AnyRef): Option[Int] = { - val mainName = proto.getClass.getName.init - val stdoutBuffer = new StringBuffer() - val process = - Process("ps", List("aux")).run(BasicIO(false, stdoutBuffer, None)) - process.exitValue() - - val output = stdoutBuffer.toString - Source - .fromString(output) - .getLines() - .find(l => l.contains(BuildInfo.jsRunner.getAbsolutePath) && l.contains(mainName)) - .map(_.split(" +")(1).toInt) - } - } - - if (BuildInfo.testJSIOApp) - test(Node) - else - test(JVM) - - def test(platform: Platform): Unit = { - s"IOApp (${platform.id})" should { - import catseffect.examples._ - - val isWindows = System.getProperty("os.name").toLowerCase.contains("windows") - - if (isWindows) { - // these tests have all been emperically flaky on Windows CI builds, so they're disabled - "evaluate and print hello world" in skipped("this test is unreliable on Windows") - "pass all arguments to child" in skipped("this test is unreliable on Windows") - - "exit with leaked fibers" in skipped("this test is unreliable on Windows") - - "exit on non-fatal error" in skipped("this test is unreliable on Windows") - "exit on fatal error" in skipped("this test is unreliable on Windows") - - "exit on fatal error with other unsafe runs" in skipped( - "this test is unreliable on Windows") - - "warn on global runtime collision" in skipped("this test is unreliable on Windows") - "abort awaiting shutdown hooks" in skipped("this test is unreliable on Windows") - - // The jvm cannot gracefully terminate processes on Windows, so this - // test cannot be carried out properly. Same for testing IOApp in sbt. - "run finalizers on TERM" in skipped( - "cannot observe graceful process termination on Windows") - } else { - "evaluate and print hello world" in { - val h = platform(HelloWorld, Nil) - h.awaitStatus() mustEqual 0 - h.stdout() mustEqual s"Hello, World!${System.lineSeparator()}" - } - - "pass all arguments to child" in { - val expected = List("the", "quick", "brown", "fox jumped", "over") - val h = platform(Arguments, expected) - h.awaitStatus() mustEqual 0 - h.stdout() mustEqual expected.mkString( - "", - System.lineSeparator(), - System.lineSeparator()) - } - - "exit on non-fatal error" in { - val h = platform(NonFatalError, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - } - - "exit with leaked fibers" in { - val h = platform(LeakedFiber, List.empty) - h.awaitStatus() mustEqual 0 - } - - "exit on fatal error" in { - val h = platform(FatalError, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - h.stdout() must not(contain("sadness")) - } - - "exit on fatal error with other unsafe runs" in { - val h = platform(FatalErrorUnsafeRun, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - } - - "exit on raising a fatal error with attempt" in { - val h = platform(RaiseFatalErrorAttempt, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - h.stdout() must not(contain("sadness")) - } - - "exit on raising a fatal error with handleError" in { - val h = platform(RaiseFatalErrorHandle, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - h.stdout() must not(contain("sadness")) - } - - "exit on raising a fatal error inside a map" in { - val h = platform(RaiseFatalErrorMap, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - h.stdout() must not(contain("sadness")) - } - - "exit on raising a fatal error inside a flatMap" in { - val h = platform(RaiseFatalErrorFlatMap, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("Boom!") - h.stdout() must not(contain("sadness")) - } - - "warn on global runtime collision" in { - val h = platform(GlobalRacingInit, List.empty) - h.awaitStatus() mustEqual 0 - h.stderr() must contain( - "Cats Effect global runtime already initialized; custom configurations will be ignored") - h.stderr() must not(contain("boom")) - } - - "reset global runtime on shutdown" in { - val h = platform(GlobalShutdown, List.empty) - h.awaitStatus() mustEqual 0 - h.stderr() must not contain - "Cats Effect global runtime already initialized; custom configurations will be ignored" - h.stderr() must not(contain("boom")) - } - - "custom runtime installed as global" in { - val h = platform(CustomRuntime, List.empty) - h.awaitStatus() mustEqual 0 - } - - "abort awaiting shutdown hooks" in { - val h = platform(ShutdownHookImmediateTimeout, List.empty) - h.awaitStatus() mustEqual 0 - } - - "run finalizers on TERM" in { - import _root_.java.io.{BufferedReader, FileReader} - - // we have to resort to this convoluted approach because Process#destroy kills listeners before killing the process - val test = File.createTempFile("cats-effect", "finalizer-test") - def readTest(): String = { - val reader = new BufferedReader(new FileReader(test)) - try { - reader.readLine() - } finally { - reader.close() - } - } - - val h = platform(Finalizers, test.getAbsolutePath() :: Nil) - - var i = 0 - while (!h.stdout().contains("Started") && i < 100) { - Thread.sleep(100) - i += 1 - } - - Thread.sleep( - 100 - ) // give thread scheduling just a sec to catch up and get us into the latch.await() - - h.term() - h.awaitStatus() mustEqual 143 - - i = 0 - while (readTest() == null && i < 100) { - i += 1 - } - readTest() must contain("canceled") - } - } - - "exit on fatal error without IOApp" in { - val h = platform(FatalErrorRaw, List.empty) - h.awaitStatus() - h.stdout() must not(contain("sadness")) - h.stderr() must not(contain("Promise already completed")) - } - - "exit on canceled" in { - val h = platform(Canceled, List.empty) - h.awaitStatus() mustEqual 1 - } - - if (BuildInfo.testJSIOApp) { - "gracefully ignore undefined process.exit" in { - val h = platform(UndefinedProcessExit, List.empty) - h.awaitStatus() mustEqual 0 - } - - "support main thread evaluation" in skipped( - "JavaScript is all main thread, all the time") - - "warn on cpu starvation" in skipped( - "starvation detection works on Node, but the test struggles with determinism") - } else { - val isJava8 = sys.props.get("java.version").filter(_.startsWith("1.8")).isDefined - - if (isJava8) { - "live fiber snapshot" in skipped( - "JDK 8 does not have free signals for live fiber snapshots") - } else if (isWindows) { - "live fiber snapshot" in skipped( - "cannot observe signals sent to process termination on Windows") - } else { - "live fiber snapshot" in { - val h = platform(LiveFiberSnapshot, List.empty) - - // wait for the application to fully start before trying to send the signal - while (!h.stdout().contains("ready")) { - Thread.sleep(100L) - } - - val pid = h.pid() - pid must beSome - pid.foreach(platform.sendSignal) - h.awaitStatus() - val stderr = h.stderr() - stderr must contain("cats.effect.IOFiber") - } - } - - "shutdown on worker thread interruption" in { - val h = platform(WorkerThreadInterrupt, List.empty) - h.awaitStatus() mustEqual 1 - h.stderr() must contain("java.lang.InterruptedException") - ok - } - - "shut down WSTP on fatal error without IOApp" in { - val h = platform(FatalErrorShutsDownRt, List.empty) - h.awaitStatus() - h.stdout() must not(contain("sadness")) - h.stdout() must contain("done") - } - - "support main thread evaluation" in { - val h = platform(EvalOnMainThread, List.empty) - h.awaitStatus() mustEqual 0 - } - - "use configurable reportFailure for MainThread" in { - val h = platform(MainThreadReportFailure, List.empty) - h.awaitStatus() mustEqual 0 - } - - "warn on blocked threads" in { - val h = platform(BlockedThreads, List.empty) - h.awaitStatus() - val err = h.stderr() - err must contain( - "[WARNING] A Cats Effect worker thread was detected to be in a blocked state") - } - - "warn on cpu starvation" in { - val h = platform(CpuStarvation, List.empty) - h.awaitStatus() - val err = h.stderr() - err must not(contain("[WARNING] Failed to register Cats Effect CPU")) - err must contain("[WARNING] Your app's responsiveness") - // we use a regex because time has too many corner cases - a test run at just the wrong - // moment on new year's eve, etc - err must beMatching( - // (?s) allows matching across line breaks - """(?s)^\d{4}-[01]\d-[0-3]\dT[012]\d:[0-6]\d:[0-6]\d(?:\.\d{1,3})?Z \[WARNING\] Your app's responsiveness.*""" - ) - } - } - } - () - } - - trait Handle { - def awaitStatus(): Int - def term(): Unit - def stderr(): String - def stdout(): String - def pid(): Option[Int] - } -} diff --git a/tests/jvm/src/test/scala/cats/effect/IOPlatformSpecification.scala b/tests/jvm/src/test/scala/cats/effect/IOPlatformSpecification.scala index c1124ff042..63ad3e78ca 100644 --- a/tests/jvm/src/test/scala/cats/effect/IOPlatformSpecification.scala +++ b/tests/jvm/src/test/scala/cats/effect/IOPlatformSpecification.scala @@ -17,7 +17,13 @@ package cats.effect import cats.effect.std.Semaphore -import cats.effect.unsafe.{IORuntime, IORuntimeConfig, WorkStealingThreadPool} +import cats.effect.unsafe.{ + IORuntime, + IORuntimeConfig, + PollingSystem, + SleepSystem, + WorkStealingThreadPool +} import cats.syntax.all._ import org.scalacheck.Prop.forAll @@ -30,12 +36,13 @@ import java.util.concurrent.{ CancellationException, CompletableFuture, CountDownLatch, + ExecutorService, Executors, ThreadLocalRandom } -import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicLong} +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference} -trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => +trait IOPlatformSpecification extends DetectPlatform { self: BaseSpec with ScalaCheck => def platformSpecs = { "platform" should { @@ -263,7 +270,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => "run a timer which crosses into a blocking region" in realWithRuntime { rt => rt.scheduler match { - case sched: WorkStealingThreadPool => + case sched: WorkStealingThreadPool[_] => // we structure this test by calling the runtime directly to avoid nondeterminism val delay = IO.async[Unit] { cb => IO { @@ -286,7 +293,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => "run timers exactly once when crossing into a blocking region" in realWithRuntime { rt => rt.scheduler match { - case sched: WorkStealingThreadPool => + case sched: WorkStealingThreadPool[_] => IO defer { val ai = new AtomicInteger(0) @@ -304,7 +311,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => "run a timer registered on a blocker" in realWithRuntime { rt => rt.scheduler match { - case sched: WorkStealingThreadPool => + case sched: WorkStealingThreadPool[_] => // we structure this test by calling the runtime directly to avoid nondeterminism val delay = IO.async[Unit] { cb => IO { @@ -324,7 +331,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => } "safely detect hard-blocked threads even while blockers are being created" in { - val (compute, shutdown) = + val (compute, _, shutdown) = IORuntime.createWorkStealingComputeThreadPool(blockedThreadDetectionEnabled = true) implicit val runtime: IORuntime = @@ -344,7 +351,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => // this test ensures that the parkUntilNextSleeper bit works "run a timer when parking thread" in { - val (pool, shutdown) = IORuntime.createWorkStealingComputeThreadPool(threads = 1) + val (pool, _, shutdown) = IORuntime.createWorkStealingComputeThreadPool(threads = 1) implicit val runtime: IORuntime = IORuntime.builder().setCompute(pool, shutdown).build() @@ -359,7 +366,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => // this test ensures that we always see the timer, even when it fires just as we're about to park "run a timer when detecting just prior to park" in { - val (pool, shutdown) = IORuntime.createWorkStealingComputeThreadPool(threads = 1) + val (pool, _, shutdown) = IORuntime.createWorkStealingComputeThreadPool(threads = 1) implicit val runtime: IORuntime = IORuntime.builder().setCompute(pool, shutdown).build() @@ -387,7 +394,7 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => // we race a lot of "sleeps", it must not hang // (this includes inserting and cancelling - // a lot of callbacks into the skip list, + // a lot of callbacks into the heap, // thus hopefully stressing the data structure): List .fill(500) { @@ -421,16 +428,28 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => spin.as(ok) } + "lots of externally-canceled timers" in real { + Resource + .make(IO(Executors.newSingleThreadExecutor()))(exec => IO(exec.shutdownNow()).void) + .map(ExecutionContext.fromExecutor(_)) + .use { ec => + IO.sleep(1.day).start.flatMap(_.cancel.evalOn(ec)).parReplicateA_(100000) + } + .as(ok) + } + "not lose cedeing threads from the bypass when blocker transitioning" in { // writing this test in terms of IO seems to not reproduce the issue 0.until(5) foreach { _ => - val wstp = new WorkStealingThreadPool( + val wstp = new WorkStealingThreadPool[AnyRef]( threadCount = 2, threadPrefix = "testWorker", blockerThreadPrefix = "testBlocker", runtimeBlockingExpiration = 3.seconds, reportFailure0 = _.printStackTrace(), - blockedThreadDetectionEnabled = false + blockedThreadDetectionEnabled = false, + shutdownTimeout = 1.second, + system = SleepSystem ) val runtime = IORuntime @@ -464,6 +483,84 @@ trait IOPlatformSpecification { self: BaseSpec with ScalaCheck => ok } + + "wake parked thread for polled events" in { + + trait DummyPoller { + def poll: IO[Unit] + } + + object DummySystem extends PollingSystem { + type Api = DummyPoller + type Poller = AtomicReference[List[Either[Throwable, Unit] => Unit]] + + def close() = () + + def makePoller() = new AtomicReference(List.empty[Either[Throwable, Unit] => Unit]) + def needsPoll(poller: Poller) = poller.get.nonEmpty + def closePoller(poller: Poller) = () + + def interrupt(targetThread: Thread, targetPoller: Poller) = + SleepSystem.interrupt(targetThread, SleepSystem.makePoller()) + + def poll(poller: Poller, nanos: Long, reportFailure: Throwable => Unit) = { + poller.getAndSet(Nil) match { + case Nil => + SleepSystem.poll(SleepSystem.makePoller(), nanos, reportFailure) + case cbs => + cbs.foreach(_.apply(Right(()))) + true + } + } + + def makeApi(access: (Poller => Unit) => Unit): DummySystem.Api = + new DummyPoller { + def poll = IO.async_[Unit] { cb => + access { poller => + poller.getAndUpdate(cb :: _) + () + } + } + } + } + + val (pool, poller, shutdown) = IORuntime.createWorkStealingComputeThreadPool( + threads = 2, + pollingSystem = DummySystem) + + implicit val runtime: IORuntime = + IORuntime.builder().setCompute(pool, shutdown).addPoller(poller, () => ()).build() + + try { + val test = + IO.pollers.map(_.head.asInstanceOf[DummyPoller]).flatMap { poller => + val blockAndPoll = IO.blocking(Thread.sleep(10)) *> poller.poll + blockAndPoll.replicateA(100).as(true) + } + test.unsafeRunSync() must beTrue + } finally { + runtime.shutdown() + } + } + + if (javaMajorVersion >= 21) + "block in-place on virtual threads" in real { + val loomExec = classOf[Executors] + .getDeclaredMethod("newVirtualThreadPerTaskExecutor") + .invoke(null) + .asInstanceOf[ExecutorService] + + val loomEc = ExecutionContext.fromExecutor(loomExec) + + IO.blocking { + classOf[Thread] + .getDeclaredMethod("isVirtual") + .invoke(Thread.currentThread()) + .asInstanceOf[Boolean] + }.evalOn(loomEc) + } + else + "block in-place on virtual threads" in skipped("virtual threads not supported") } } } diff --git a/tests/jvm/src/test/scala/cats/effect/RunnersPlatform.scala b/tests/jvm/src/test/scala/cats/effect/RunnersPlatform.scala index 4ea365b349..19bb62b57d 100644 --- a/tests/jvm/src/test/scala/cats/effect/RunnersPlatform.scala +++ b/tests/jvm/src/test/scala/cats/effect/RunnersPlatform.scala @@ -30,7 +30,7 @@ trait RunnersPlatform extends BeforeAfterAll { val (blocking, blockDown) = IORuntime.createDefaultBlockingExecutionContext(threadPrefix = s"io-blocking-${getClass.getName}") - val (compute, compDown) = + val (compute, poller, compDown) = IORuntime.createWorkStealingComputeThreadPool( threadPrefix = s"io-compute-${getClass.getName}", blockerThreadPrefix = s"io-blocker-${getClass.getName}") @@ -39,6 +39,7 @@ trait RunnersPlatform extends BeforeAfterAll { compute, blocking, compute, + List(poller), { () => compDown() blockDown() diff --git a/tests/jvm/src/test/scala/cats/effect/SelectorSpec.scala b/tests/jvm/src/test/scala/cats/effect/SelectorSpec.scala new file mode 100644 index 0000000000..78ea55d4aa --- /dev/null +++ b/tests/jvm/src/test/scala/cats/effect/SelectorSpec.scala @@ -0,0 +1,120 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +import cats.effect.unsafe.IORuntime + +import scala.concurrent.duration._ + +import java.nio.ByteBuffer +import java.nio.channels.Pipe +import java.nio.channels.SelectionKey._ + +class SelectorSpec extends BaseSpec { + + def getSelector: IO[Selector] = + IO.pollers.map(_.collectFirst { case selector: Selector => selector }).map(_.get) + + def mkPipe: Resource[IO, Pipe] = + Resource + .eval(getSelector) + .flatMap { selector => + Resource.make(IO(selector.provider.openPipe())) { pipe => + IO(pipe.sink().close()).guarantee(IO(pipe.source().close())) + } + } + .evalTap { pipe => + IO { + pipe.sink().configureBlocking(false) + pipe.source().configureBlocking(false) + } + } + + "Selector" should { + + "notify read-ready events" in real { + mkPipe.use { pipe => + for { + selector <- getSelector + buf <- IO(ByteBuffer.allocate(4)) + _ <- IO(pipe.sink.write(ByteBuffer.wrap(Array(1, 2, 3)))).background.surround { + selector.select(pipe.source, OP_READ) *> IO(pipe.source.read(buf)) + } + _ <- IO(pipe.sink.write(ByteBuffer.wrap(Array(42)))).background.surround { + selector.select(pipe.source, OP_READ) *> IO(pipe.source.read(buf)) + } + } yield buf.array().toList must be_==(List[Byte](1, 2, 3, 42)) + } + } + + "setup multiple callbacks" in real { + mkPipe.use { pipe => + for { + selector <- getSelector + _ <- selector.select(pipe.source, OP_READ).parReplicateA_(10) <& + IO(pipe.sink.write(ByteBuffer.wrap(Array(1, 2, 3)))) + } yield ok + } + } + + "works after blocking" in real { + mkPipe.use { pipe => + for { + selector <- getSelector + _ <- IO.blocking(()) + _ <- selector.select(pipe.sink, OP_WRITE) + } yield ok + } + } + + "gracefully handles illegal ops" in real { + mkPipe.use { pipe => + // get off the wstp to test async codepaths + IO.blocking(()) *> getSelector.flatMap { selector => + selector.select(pipe.sink, OP_READ).attempt.map { + case Left(_: IllegalArgumentException) => true + case _ => false + } + } + } + } + + "handles concurrent close" in { + val (pool, poller, shutdown) = IORuntime.createWorkStealingComputeThreadPool(threads = 1) + implicit val runtime: IORuntime = + IORuntime.builder().setCompute(pool, shutdown).addPoller(poller, () => ()).build() + + try { + val test = getSelector + .flatMap { selector => + mkPipe.allocated.flatMap { + case (pipe, close) => + selector.select(pipe.source, OP_READ).background.surround { + IO.sleep(1.millis) *> close *> IO.sleep(1.millis) + } + } + } + .replicateA_(1000) + .as(true) + test.unsafeRunSync() must beTrue + } finally { + runtime.shutdown() + } + } + } + +} diff --git a/tests/jvm/src/test/scala/cats/effect/std/DeferredJVMSpec.scala b/tests/jvm/src/test/scala/cats/effect/std/DeferredJVMSpec.scala index 97575da46f..21e6453ffb 100644 --- a/tests/jvm/src/test/scala/cats/effect/std/DeferredJVMSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/std/DeferredJVMSpec.scala @@ -18,7 +18,7 @@ package cats.effect package std import cats.effect.kernel.Deferred -import cats.effect.unsafe.IORuntime +import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} import org.specs2.mutable.Specification import org.specs2.specification.BeforeAfterEach @@ -47,7 +47,7 @@ abstract class BaseDeferredJVMTests(parallelism: Int) implicit val runtime: IORuntime = IORuntime.global - def before = + def before: Any = service = Executors.newFixedThreadPool( parallelism, new ThreadFactory { @@ -61,7 +61,7 @@ abstract class BaseDeferredJVMTests(parallelism: Int) } ) - def after = { + def after: Any = { service.shutdown() assert(service.awaitTermination(60, TimeUnit.SECONDS), "has active threads") } @@ -232,12 +232,12 @@ abstract class BaseDeferredJVMTests(parallelism: Int) try { ioa.unsafeRunTimed(10.seconds)( - unsafe.IORuntime( + IORuntime( ctx, ctx, - unsafe.Scheduler.fromScheduledExecutor(scheduler), + Scheduler.fromScheduledExecutor(scheduler), () => (), - unsafe.IORuntimeConfig())) + IORuntimeConfig())) } finally { executor.shutdown() scheduler.shutdown() diff --git a/tests/jvm/src/test/scala/cats/effect/unsafe/HelperThreadParkSpec.scala b/tests/jvm/src/test/scala/cats/effect/unsafe/HelperThreadParkSpec.scala index 170e0fe905..4c08026578 100644 --- a/tests/jvm/src/test/scala/cats/effect/unsafe/HelperThreadParkSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/unsafe/HelperThreadParkSpec.scala @@ -33,7 +33,7 @@ class HelperThreadParkSpec extends BaseSpec { s"io-blocking-${getClass.getName}") val (scheduler, schedDown) = IORuntime.createDefaultScheduler(threadPrefix = s"io-scheduler-${getClass.getName}") - val (compute, compDown) = + val (compute, _, compDown) = IORuntime.createWorkStealingComputeThreadPool( threadPrefix = s"io-compute-${getClass.getName}", threads = 2) diff --git a/tests/jvm/src/test/scala/cats/effect/unsafe/SleepersSpec.scala b/tests/jvm/src/test/scala/cats/effect/unsafe/SleepersSpec.scala index 26d1a8fc4a..de3e4c5a9e 100644 --- a/tests/jvm/src/test/scala/cats/effect/unsafe/SleepersSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/unsafe/SleepersSpec.scala @@ -25,17 +25,24 @@ class SleepersSpec extends Specification { "SleepCallback" should { "have a trigger time in the future" in { - val sleepers = new TimerSkipList + val sleepers = new TimerHeap val now = 100.millis.toNanos val delay = 500.millis.toNanos - sleepers.insertTlr(now, delay, _ => ()) + sleepers.insert(now, delay, _ => (), new Array(1)) val triggerTime = sleepers.peekFirstTriggerTime() val expected = 600.millis.toNanos // delay + now triggerTime mustEqual expected } - def dequeueAll(sleepers: TimerSkipList): List[(Long, Right[Nothing, Unit] => Unit)] = { + def collectOuts(outs: (Long, Array[Right[Nothing, Unit] => Unit])*) + : List[(Long, Right[Nothing, Unit] => Unit)] = + outs.toList.flatMap { + case (now, out) => + Option(out(0)).map(now -> _).toList + } + + def dequeueAll(sleepers: TimerHeap): List[(Long, Right[Nothing, Unit] => Unit)] = { @tailrec def loop(acc: List[(Long, Right[Nothing, Unit] => Unit)]) : List[(Long, Right[Nothing, Unit] => Unit)] = { @@ -56,7 +63,7 @@ class SleepersSpec extends Specification { } "be ordered according to the trigger time" in { - val sleepers = new TimerSkipList + val sleepers = new TimerHeap val now1 = 100.millis.toNanos val delay1 = 500.millis.toNanos @@ -74,22 +81,26 @@ class SleepersSpec extends Specification { val cb2 = newCb() val cb3 = newCb() - sleepers.insertTlr(now1, delay1, cb1) - sleepers.insertTlr(now2, delay2, cb2) - sleepers.insertTlr(now3, delay3, cb3) + val out1 = new Array[Right[Nothing, Unit] => Unit](1) + val out2 = new Array[Right[Nothing, Unit] => Unit](1) + val out3 = new Array[Right[Nothing, Unit] => Unit](1) + sleepers.insert(now1, delay1, cb1, out1) + sleepers.insert(now2, delay2, cb2, out2) + sleepers.insert(now3, delay3, cb3, out3) - val ordering = dequeueAll(sleepers) + val ordering = + collectOuts(now1 -> out1, now2 -> out2, now3 -> out3) ::: dequeueAll(sleepers) val expectedOrdering = List(expected2 -> cb2, expected3 -> cb3, expected1 -> cb1) ordering mustEqual expectedOrdering } "be ordered correctly even if Long overflows" in { - val sleepers = new TimerSkipList + val sleepers = new TimerHeap val now1 = Long.MaxValue - 20L val delay1 = 10.nanos.toNanos - val expected1 = Long.MaxValue - 10L // no overflow yet + // val expected1 = Long.MaxValue - 10L // no overflow yet val now2 = Long.MaxValue - 5L val delay2 = 10.nanos.toNanos @@ -98,11 +109,13 @@ class SleepersSpec extends Specification { val cb1 = newCb() val cb2 = newCb() - sleepers.insertTlr(now1, delay1, cb1) - sleepers.insertTlr(now2, delay2, cb2) + val out1 = new Array[Right[Nothing, Unit] => Unit](1) + val out2 = new Array[Right[Nothing, Unit] => Unit](1) + sleepers.insert(now1, delay1, cb1, out1) + sleepers.insert(now2, delay2, cb2, out2) - val ordering = dequeueAll(sleepers) - val expectedOrdering = List(expected1 -> cb1, expected2 -> cb2) + val ordering = collectOuts(now1 -> out1, now2 -> out2) ::: dequeueAll(sleepers) + val expectedOrdering = List(now2 -> cb1, expected2 -> cb2) ordering mustEqual expectedOrdering } diff --git a/tests/jvm/src/test/scala/cats/effect/unsafe/StripedHashtableSpec.scala b/tests/jvm/src/test/scala/cats/effect/unsafe/StripedHashtableSpec.scala index 07b72f9356..bfffc7ec25 100644 --- a/tests/jvm/src/test/scala/cats/effect/unsafe/StripedHashtableSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/unsafe/StripedHashtableSpec.scala @@ -32,7 +32,7 @@ class StripedHashtableSpec extends BaseSpec { val (blocking, blockDown) = IORuntime.createDefaultBlockingExecutionContext(threadPrefix = s"io-blocking-${getClass.getName}") - val (compute, compDown) = + val (compute, _, compDown) = IORuntime.createWorkStealingComputeThreadPool( threadPrefix = s"io-compute-${getClass.getName}", blockerThreadPrefix = s"io-blocker-${getClass.getName}") diff --git a/tests/jvm/src/test/scala/cats/effect/unsafe/TimerSkipListSpec.scala b/tests/jvm/src/test/scala/cats/effect/unsafe/TimerHeapSpec.scala similarity index 61% rename from tests/jvm/src/test/scala/cats/effect/unsafe/TimerSkipListSpec.scala rename to tests/jvm/src/test/scala/cats/effect/unsafe/TimerHeapSpec.scala index 2bd71440bd..798e75c073 100644 --- a/tests/jvm/src/test/scala/cats/effect/unsafe/TimerSkipListSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/unsafe/TimerHeapSpec.scala @@ -18,7 +18,7 @@ package cats.effect.unsafe import org.specs2.mutable.Specification -class TimerSkipListSpec extends Specification { +class TimerHeapSpec extends Specification { /** * Creates a new callback, making sure it's a separate object @@ -34,27 +34,32 @@ class TimerSkipListSpec extends Specification { private val cb4 = newCb() private val cb5 = newCb() - "TimerSkipList" should { + "TimerHeap" should { "correctly insert / pollFirstIfTriggered" in { - val m = new TimerSkipList + val m = new TimerHeap + val out = new Array[Right[Nothing, Unit] => Unit](1) m.pollFirstIfTriggered(Long.MinValue) must beNull m.pollFirstIfTriggered(Long.MaxValue) must beNull - m.toString mustEqual "TimerSkipList()" + m.toString mustEqual "TimerHeap()" - m.insertTlr(0L, 0L, cb0) - m.toString mustEqual "TimerSkipList(...)" - m.pollFirstIfTriggered(Long.MinValue) must beNull + m.insert(0L, 0L, cb0, out) + out(0) must beNull + m.toString mustEqual "TimerHeap(...)" + m.pollFirstIfTriggered(Long.MinValue + 1) must beNull m.pollFirstIfTriggered(Long.MaxValue) mustEqual cb0 m.pollFirstIfTriggered(Long.MaxValue) must beNull m.pollFirstIfTriggered(Long.MinValue) must beNull - m.insertTlr(0L, 10L, cb0) - m.insertTlr(0L, 30L, cb1) - m.insertTlr(0L, 0L, cb2) - m.insertTlr(0L, 20L, cb3) + m.insert(0L, 10L, cb0, out) + out(0) must beNull + m.insert(0L, 30L, cb1, out) + out(0) must beNull + m.insert(0L, 0L, cb2, out) + out(0) must beNull + m.insert(0L, 20L, cb3, out) + out(0) mustEqual cb2 m.pollFirstIfTriggered(-1L) must beNull - m.pollFirstIfTriggered(0L) mustEqual cb2 m.pollFirstIfTriggered(0L) must beNull m.pollFirstIfTriggered(10L) mustEqual cb0 m.pollFirstIfTriggered(10L) must beNull @@ -66,65 +71,82 @@ class TimerSkipListSpec extends Specification { } "correctly insert / remove (cancel)" in { - val m = new TimerSkipList - val r0 = m.insertTlr(0L, 0L, cb0) - val r1 = m.insertTlr(0L, 1L, cb1) - val r5 = m.insertTlr(0L, 5L, cb5) - val r4 = m.insertTlr(0L, 4L, cb4) - val r2 = m.insertTlr(0L, 2L, cb2) - val r3 = m.insertTlr(0L, 3L, cb3) + val m = new TimerHeap + val out = new Array[Right[Nothing, Unit] => Unit](1) + val r0 = m.insert(0L, 1L, cb0, out) + out(0) must beNull + val r1 = m.insert(0L, 2L, cb1, out) + out(0) must beNull + val r5 = m.insert(0L, 6L, cb5, out) + out(0) must beNull + val r4 = m.insert(0L, 5L, cb4, out) + out(0) must beNull + val r2 = m.insert(0L, 3L, cb2, out) + out(0) must beNull + val r3 = m.insert(0L, 4L, cb3, out) + out(0) must beNull m.peekFirstQuiescent() mustEqual cb0 - m.peekFirstTriggerTime() mustEqual 0L + m.peekFirstTriggerTime() mustEqual 1L r0.run() + m.peekFirstTriggerTime() mustEqual 2L m.peekFirstQuiescent() mustEqual cb1 - m.peekFirstTriggerTime() mustEqual 1L m.pollFirstIfTriggered(Long.MaxValue) mustEqual cb1 m.peekFirstQuiescent() mustEqual cb2 - m.peekFirstTriggerTime() mustEqual 2L + m.peekFirstTriggerTime() mustEqual 3L r1.run() // NOP r3.run() + m.packIfNeeded() m.peekFirstQuiescent() mustEqual cb2 - m.peekFirstTriggerTime() mustEqual 2L + m.peekFirstTriggerTime() mustEqual 3L m.pollFirstIfTriggered(Long.MaxValue) mustEqual cb2 m.peekFirstQuiescent() mustEqual cb4 - m.peekFirstTriggerTime() mustEqual 4L + m.peekFirstTriggerTime() mustEqual 5L m.pollFirstIfTriggered(Long.MaxValue) mustEqual cb4 m.peekFirstQuiescent() mustEqual cb5 - m.peekFirstTriggerTime() mustEqual 5L + m.peekFirstTriggerTime() mustEqual 6L r2.run() r5.run() + m.packIfNeeded() m.peekFirstQuiescent() must beNull m.peekFirstTriggerTime() mustEqual Long.MinValue m.pollFirstIfTriggered(Long.MaxValue) must beNull r4.run() // NOP + m.packIfNeeded() m.pollFirstIfTriggered(Long.MaxValue) must beNull } "behave correctly when nanoTime wraps around" in { - val m = new TimerSkipList + val m = new TimerHeap val startFrom = Long.MaxValue - 100L var nanoTime = startFrom - val removersBuilder = Vector.newBuilder[Runnable] + val removers = new Array[Runnable](200) val callbacksBuilder = Vector.newBuilder[Right[Nothing, Unit] => Unit] - for (_ <- 0 until 200) { + val triggeredBuilder = Vector.newBuilder[Right[Nothing, Unit] => Unit] + for (i <- 0 until 200) { + if (i >= 10 && i % 2 == 0) removers(i - 10).run() val cb = newCb() - val r = m.insertTlr(nanoTime, 10L, cb) - removersBuilder += r + val out = new Array[Right[Nothing, Unit] => Unit](1) + val r = m.insert(nanoTime, 10L, cb, out) + triggeredBuilder ++= Option(out(0)) + removers(i) = r callbacksBuilder += cb nanoTime += 1L } - val removers = removersBuilder.result() - for (idx <- 0 until removers.size by 2) { + for (idx <- 190 until removers.size by 2) { removers(idx).run() } nanoTime += 100L val callbacks = callbacksBuilder.result() - for (i <- 0 until 200 by 2) { + while ({ val cb = m.pollFirstIfTriggered(nanoTime) - val expected = callbacks(i + 1) - cb mustEqual expected - } + triggeredBuilder ++= Option(cb) + cb ne null + }) {} + val triggered = triggeredBuilder.result() + + val nonCanceled = callbacks.grouped(2).map(_.last).toVector + triggered should beEqualTo(nonCanceled) ok } diff --git a/tests/jvm/src/test/scala/cats/effect/unsafe/TimerSkipListIOSpec.scala b/tests/jvm/src/test/scala/cats/effect/unsafe/TimerSkipListIOSpec.scala deleted file mode 100644 index 3eecd476d0..0000000000 --- a/tests/jvm/src/test/scala/cats/effect/unsafe/TimerSkipListIOSpec.scala +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2020-2024 Typelevel - * - * 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. - */ - -package cats.effect -package unsafe - -import cats.syntax.all._ - -import scala.concurrent.duration._ - -import java.util.concurrent.{ConcurrentSkipListSet, ThreadLocalRandom} -import java.util.concurrent.atomic.AtomicLong - -class TimerSkipListIOSpec extends BaseSpec { - - final val N = 50000 - final val DELAY = 10000L // ns - - private def drainUntilDone(m: TimerSkipList, done: Ref[IO, Boolean]): IO[Unit] = { - val pollSome: IO[Long] = IO { - while ({ - val cb = m.pollFirstIfTriggered(System.nanoTime()) - if (cb ne null) { - cb(Right(())) - true - } else false - }) {} - m.peekFirstTriggerTime() - } - def go(lastOne: Boolean): IO[Unit] = pollSome.flatMap { next => - if (next == Long.MinValue) IO.cede - else { - IO.defer { - val now = System.nanoTime() - val delay = next - now - if (delay > 0L) IO.sleep(delay.nanos) - else IO.unit - } - } - } *> { - if (lastOne) IO.unit - else done.get.ifM(go(lastOne = true), IO.cede *> go(lastOne = false)) - } - - go(lastOne = false) - } - - "TimerSkipList" should { - - "insert/pollFirstIfTriggered concurrently" in real { - IO.ref(false).flatMap { done => - IO { (new TimerSkipList, new AtomicLong) }.flatMap { - case (m, ctr) => - val insert = IO { - m.insert( - now = System.nanoTime(), - delay = DELAY, - callback = { _ => ctr.getAndIncrement; () }, - tlr = ThreadLocalRandom.current() - ) - } - val inserts = - (insert.parReplicateA_(N) *> IO.sleep(2 * DELAY.nanos)).guarantee(done.set(true)) - - val polls = drainUntilDone(m, done).parReplicateA_(2) - - IO.both(inserts, polls).flatMap { _ => - IO.sleep(0.5.second) *> IO { - m.pollFirstIfTriggered(System.nanoTime()) must beNull - ctr.get() mustEqual N.toLong - } - } - } - } - } - - "insert/cancel concurrently" in real { - IO.ref(false).flatMap { done => - IO { (new TimerSkipList, new ConcurrentSkipListSet[Int]) }.flatMap { - case (m, called) => - def insert(id: Int): IO[Runnable] = IO { - val now = System.nanoTime() - val canceller = m.insert( - now = now, - delay = DELAY, - callback = { _ => called.add(id); () }, - tlr = ThreadLocalRandom.current() - ) - canceller - } - - def cancel(c: Runnable): IO[Unit] = IO { - c.run() - } - - val firstBatch = (0 until N).toList - val secondBatch = (N until (2 * N)).toList - - for { - // add the first N callbacks: - cancellers <- firstBatch.traverse(insert) - // then race removing those, and adding another N: - _ <- IO.both( - cancellers.parTraverse(cancel), - secondBatch.parTraverse(insert) - ) - // since the fibers calling callbacks - // are not running yet, the cancelled - // ones must never be invoked - _ <- IO.both( - IO.sleep(2 * DELAY.nanos).guarantee(done.set(true)), - drainUntilDone(m, done).parReplicateA_(2) - ) - _ <- IO { - assert(m.pollFirstIfTriggered(System.nanoTime()) eq null) - // no cancelled callback should've been called, - // and all the other ones must've been called: - val calledIds = { - val b = Set.newBuilder[Int] - val it = called.iterator() - while (it.hasNext()) { - b += it.next() - } - b.result() - } - calledIds mustEqual secondBatch.toSet - } - } yield ok - } - } - } - } -} diff --git a/tests/jvm/src/test/scala/cats/effect/unsafe/WorkerThreadNameSpec.scala b/tests/jvm/src/test/scala/cats/effect/unsafe/WorkerThreadNameSpec.scala index 7b94bb410f..c99b390abf 100644 --- a/tests/jvm/src/test/scala/cats/effect/unsafe/WorkerThreadNameSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/unsafe/WorkerThreadNameSpec.scala @@ -16,7 +16,7 @@ package cats.effect.unsafe -import cats.effect.{BaseSpec /*, IO*/} +import cats.effect.BaseSpec import cats.effect.testkit.TestInstances import scala.concurrent.duration._ @@ -30,7 +30,7 @@ class WorkerThreadNameSpec extends BaseSpec with TestInstances { s"io-blocking-${getClass.getName}") val (scheduler, schedDown) = IORuntime.createDefaultScheduler(threadPrefix = s"io-scheduler-${getClass.getName}") - val (compute, compDown) = + val (compute, _, compDown) = IORuntime.createWorkStealingComputeThreadPool( threads = 1, threadPrefix = s"io-compute-${getClass.getName}", diff --git a/tests/native/src/main/scala/catseffect/examplesplatform.scala b/tests/native/src/main/scala/catseffect/examplesplatform.scala index bc12704004..2e33e7753c 100644 --- a/tests/native/src/main/scala/catseffect/examplesplatform.scala +++ b/tests/native/src/main/scala/catseffect/examplesplatform.scala @@ -16,8 +16,63 @@ package catseffect -import scala.concurrent.ExecutionContext +import cats.effect.{ExitCode, IO, IOApp} +import cats.effect.unsafe.IORuntime +import cats.syntax.all._ + +import scala.collection.mutable package object examples { - def exampleExecutionContext = ExecutionContext.global + def exampleExecutionContext = IORuntime.defaultComputeExecutionContext +} + +package examples { + + object NativeRunner { + val apps = mutable.Map.empty[String, () => IOApp] + def register(app: IOApp): Unit = apps(app.getClass.getName.init) = () => app + def registerLazy(name: String, app: => IOApp): Unit = + apps(name) = () => app + + val rawApps = mutable.Map.empty[String, () => RawApp] + def registerRaw(app: RawApp): Unit = rawApps(app.getClass.getName.init) = () => app + + register(HelloWorld) + register(Arguments) + register(NonFatalError) + register(FatalError) + register(RaiseFatalErrorAttempt) + register(RaiseFatalErrorHandle) + register(RaiseFatalErrorMap) + register(RaiseFatalErrorFlatMap) + registerRaw(FatalErrorRaw) + register(Canceled) + registerLazy("catseffect.examples.GlobalRacingInit", GlobalRacingInit) + registerLazy("catseffect.examples.GlobalShutdown", GlobalShutdown) + register(LiveFiberSnapshot) + register(FatalErrorUnsafeRun) + register(Finalizers) + register(LeakedFiber) + register(CustomRuntime) + register(CpuStarvation) + + def main(args: Array[String]): Unit = { + val app = args(0) + apps + .get(app) + .map(_().main(args.tail)) + .orElse(rawApps.get(app).map(_().main(args.tail))) + .get + } + } + + object FatalErrorUnsafeRun extends IOApp { + def run(args: List[String]): IO[ExitCode] = + for { + _ <- (0 until 100).toList.traverse(_ => IO.never.start) + _ <- IO(throw new OutOfMemoryError("Boom!")).start + _ <- IO.never[Unit] + } yield ExitCode.Success + } + } diff --git a/tests/native/src/test/scala/cats/effect/FileDescriptorPollerSpec.scala b/tests/native/src/test/scala/cats/effect/FileDescriptorPollerSpec.scala new file mode 100644 index 0000000000..879f122fdc --- /dev/null +++ b/tests/native/src/test/scala/cats/effect/FileDescriptorPollerSpec.scala @@ -0,0 +1,145 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +import cats.effect.std.CountDownLatch +import cats.syntax.all._ + +import scala.concurrent.duration._ +import scala.scalanative.libc.errno._ +import scala.scalanative.posix.errno._ +import scala.scalanative.posix.fcntl._ +import scala.scalanative.posix.string._ +import scala.scalanative.posix.unistd +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +import java.io.IOException + +class FileDescriptorPollerSpec extends BaseSpec { + + final class Pipe( + val readFd: Int, + val writeFd: Int, + val readHandle: FileDescriptorPollHandle, + val writeHandle: FileDescriptorPollHandle + ) { + def read(buf: Array[Byte], offset: Int, length: Int): IO[Unit] = + readHandle + .pollReadRec(()) { _ => + IO(guard(unistd.read(readFd, buf.atUnsafe(offset), length.toULong))) + } + .void + + def write(buf: Array[Byte], offset: Int, length: Int): IO[Unit] = + writeHandle + .pollWriteRec(()) { _ => + IO(guard(unistd.write(writeFd, buf.atUnsafe(offset), length.toULong))) + } + .void + + private def guard(thunk: => CInt): Either[Unit, CInt] = { + val rtn = thunk + if (rtn < 0) { + val en = errno + if (en == EAGAIN || en == EWOULDBLOCK) + Left(()) + else + throw new IOException(fromCString(strerror(errno))) + } else + Right(rtn) + } + } + + def mkPipe: Resource[IO, Pipe] = + Resource + .make { + IO { + val fd = stackalloc[CInt](2.toULong) + if (unistd.pipe(fd) != 0) + throw new IOException(fromCString(strerror(errno))) + (fd(0), fd(1)) + } + } { + case (readFd, writeFd) => + IO { + unistd.close(readFd) + unistd.close(writeFd) + () + } + } + .evalTap { + case (readFd, writeFd) => + IO { + if (fcntl(readFd, F_SETFL, O_NONBLOCK) != 0) + throw new IOException(fromCString(strerror(errno))) + if (fcntl(writeFd, F_SETFL, O_NONBLOCK) != 0) + throw new IOException(fromCString(strerror(errno))) + } + } + .flatMap { + case (readFd, writeFd) => + Resource.eval(FileDescriptorPoller.get).flatMap { poller => + ( + poller.registerFileDescriptor(readFd, true, false), + poller.registerFileDescriptor(writeFd, false, true) + ).mapN(new Pipe(readFd, writeFd, _, _)) + } + } + + "FileDescriptorPoller" should { + + "notify read-ready events" in real { + mkPipe.use { pipe => + for { + buf <- IO(new Array[Byte](4)) + _ <- pipe.write(Array[Byte](1, 2, 3), 0, 3).background.surround(pipe.read(buf, 0, 3)) + _ <- pipe.write(Array[Byte](42), 0, 1).background.surround(pipe.read(buf, 3, 1)) + } yield buf.toList must be_==(List[Byte](1, 2, 3, 42)) + } + } + + "handle lots of simultaneous events" in real { + def test(n: Int) = mkPipe.replicateA(n).use { pipes => + CountDownLatch[IO](n).flatMap { latch => + pipes + .traverse_ { pipe => + (pipe.read(new Array[Byte](1), 0, 1) *> latch.release).background + } + .surround { + IO { // trigger all the pipes at once + pipes.foreach { pipe => + unistd.write(pipe.writeFd, Array[Byte](42).atUnsafe(0), 1.toULong) + } + }.background.surround(latch.await.as(true)) + } + } + } + + // multiples of 64 to exercise ready queue draining logic + test(64) *> test(128) *> + test(1000) // a big, non-64-multiple + } + + "hang if never ready" in real { + mkPipe.use { pipe => + pipe.read(new Array[Byte](1), 0, 1).as(false).timeoutTo(1.second, IO.pure(true)) + } + } + } + +} diff --git a/tests/native/src/test/scala/cats/effect/unsafe/SchedulerSpec.scala b/tests/native/src/test/scala/cats/effect/unsafe/SchedulerSpec.scala index 7b2847f11a..1a266c9ed1 100644 --- a/tests/native/src/test/scala/cats/effect/unsafe/SchedulerSpec.scala +++ b/tests/native/src/test/scala/cats/effect/unsafe/SchedulerSpec.scala @@ -17,6 +17,8 @@ package cats.effect package unsafe +import scala.concurrent.duration._ + class SchedulerSpec extends BaseSpec { "Default scheduler" should { @@ -27,12 +29,18 @@ class SchedulerSpec extends BaseSpec { deltas = times.map(_ - start) } yield deltas.exists(_.toMicros % 1000 != 0) } + "correctly calculate real time" in real { IO.realTime.product(IO(System.currentTimeMillis())).map { case (realTime, currentTime) => (realTime.toMillis - currentTime) should be_<=(1L) } } + + "sleep for correct duration" in real { + val duration = 1500.millis + IO.sleep(duration).timed.map(_._1 should be_>=(duration)) + } } } diff --git a/tests/shared/src/test/scala-2.13+/cats/effect/IOImplicitSpec.scala b/tests/shared/src/test/scala-2.13+/cats/effect/IOImplicitSpec.scala new file mode 100644 index 0000000000..1e98bf7b5e --- /dev/null +++ b/tests/shared/src/test/scala-2.13+/cats/effect/IOImplicitSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect + +class IOImplicitSpec extends BaseSpec { + + "Can resolve IO sequence ops without import of cats.syntax.all" in { // compilation test + for { + _ <- List(IO(1)).sequence_ + _ <- Option(IO(1)).sequence + _ <- Option(IO(1)).sequence_ + _ <- List(IO(List(1))).flatSequence + } yield () + true + } + + "Can resolve IO.Par ops without import of cats.syntax.all" in { // compilation test + for { + _ <- Option(IO(1)).parSequence + _ <- Option(IO(1)).parSequence_ + _ <- IO(1).parReplicateA(2) + _ <- IO(1).parReplicateA_(2) + _ <- IO(1).parProduct(IO(2)) + _ <- IO(1).parProductL(IO(2)) + _ <- IO(1).parProductR(IO(2)) + _ <- List(IO(Option(1))).parSequenceFilter + _ <- List(IO(1)).parUnorderedSequence + _ <- List(IO(List(1))).parFlatSequence + _ <- List(IO(List(1))).parUnorderedFlatSequence + _ <- (IO(1), IO(2)).parMapN(_ + _) + _ <- (IO(1), IO(2)).parTupled + _ <- (IO(1), IO(2)).parFlatMapN { case (x, y) => IO.pure(x + y) } + _ <- (IO(1), IO(2), IO(3)).parMapN(_ + _ + _) + _ <- (IO(1), IO(2), IO(3)).parTupled + _ <- (IO(1), IO(2), IO(3)).parFlatMapN { case (x, y, z) => IO.pure(x + y + z) } + } yield () + true + } +} diff --git a/tests/shared/src/test/scala-2.13+/cats/effect/IOParImplicitSpec.scala b/tests/shared/src/test/scala-2.13+/not/cats/effect/IOParImplicitSpec.scala similarity index 100% rename from tests/shared/src/test/scala-2.13+/cats/effect/IOParImplicitSpec.scala rename to tests/shared/src/test/scala-2.13+/not/cats/effect/IOParImplicitSpec.scala diff --git a/tests/shared/src/test/scala/cats/effect/CallbackStackSpec.scala b/tests/shared/src/test/scala/cats/effect/CallbackStackSpec.scala index 8a06629e67..8f0dd5899d 100644 --- a/tests/shared/src/test/scala/cats/effect/CallbackStackSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/CallbackStackSpec.scala @@ -16,13 +16,11 @@ package cats.effect -import cats.syntax.all._ - class CallbackStackSpec extends BaseSpec with DetectPlatform { "CallbackStack" should { "correctly report the number removed" in { - val stack = CallbackStack[Unit](null) + val stack = CallbackStack.of[Unit](null) val handle = stack.push(_ => ()) stack.push(_ => ()) val removed = stack.clearHandle(handle) @@ -34,7 +32,7 @@ class CallbackStackSpec extends BaseSpec with DetectPlatform { "handle race conditions in pack" in real { - IO(CallbackStack[Unit](null)).flatMap { stack => + IO(CallbackStack.of[Unit](null)).flatMap { stack => val pushClearPack = for { handle <- IO(stack.push(_ => ())) removed <- IO(stack.clearHandle(handle)) diff --git a/tests/shared/src/test/scala/cats/effect/IOPropSpec.scala b/tests/shared/src/test/scala/cats/effect/IOPropSpec.scala index 1d3b3c47f5..2784c3f497 100644 --- a/tests/shared/src/test/scala/cats/effect/IOPropSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/IOPropSpec.scala @@ -67,6 +67,33 @@ class IOPropSpec extends BaseSpec with Discipline { } } + "parTraverseN_" should { + + "never exceed the maximum bound of concurrent tasks" in realProp { + for { + length <- Gen.chooseNum(0, 50) + limit <- Gen.chooseNum(1, 15, 2, 5) + } yield length -> limit + } { + case (length, limit) => + Queue.unbounded[IO, Int].flatMap { q => + val task = q.offer(1) >> IO.sleep(7.millis) >> q.offer(-1) + val testRun = List.fill(length)(task).parSequenceN_(limit) + def check(acc: Int = 0): IO[Unit] = + q.tryTake.flatMap { + case None => IO.unit + case Some(n) => + val newAcc = acc + n + if (newAcc > limit) + IO.raiseError(new Exception(s"Limit of $limit exceeded, was $newAcc")) + else check(newAcc) + } + + testRun >> check().mustEqual(()) + } + } + } + "parSequenceN" should { "give the same result as parSequence" in realProp( Gen.posNum[Int].flatMap(n => arbitrary[List[Int]].map(n -> _))) { diff --git a/tests/shared/src/test/scala/cats/effect/IOSpec.scala b/tests/shared/src/test/scala/cats/effect/IOSpec.scala index e4ccc13a2e..e22698a4bc 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSpec.scala @@ -24,11 +24,12 @@ import cats.kernel.laws.discipline.MonoidTests import cats.laws.discipline.{AlignTests, SemigroupKTests} import cats.laws.discipline.arbitrary._ import cats.syntax.all._ +import cats.~> import org.scalacheck.Prop import org.typelevel.discipline.specs2.mutable.Discipline -import scala.concurrent.{CancellationException, ExecutionContext, TimeoutException} +import scala.concurrent.{CancellationException, ExecutionContext, Promise, TimeoutException} import scala.concurrent.duration._ import Prop.forAll @@ -114,6 +115,26 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { (IO.pure(42) orElse IO.raiseError[Int](TestException)) must completeAs(42) } + "adaptError is a no-op for a successful effect" in ticked { implicit ticker => + IO(42).adaptError { case x => x } must completeAs(42) + } + + "adaptError is a no-op for a non-matching error" in ticked { implicit ticker => + case object TestException1 extends RuntimeException + case object TestException2 extends RuntimeException + IO.raiseError[Unit](TestException1).adaptError { + case TestException2 => TestException2 + } must failAs(TestException1) + } + + "adaptError transforms the error in a failed effect" in ticked { implicit ticker => + case object TestException1 extends RuntimeException + case object TestException2 extends RuntimeException + IO.raiseError[Unit](TestException1).adaptError { + case TestException1 => TestException2 + } must failAs(TestException2) + } + "attempt is redeem with Left(_) for recover and Right(_) for map" in ticked { implicit ticker => forAll { (io: IO[Int]) => io.attempt eqv io.redeem(Left(_), Right(_)) } @@ -125,6 +146,12 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { } } + "attemptTap(f) is an alias for attempt.flatTap(f).rethrow" in ticked { implicit ticker => + forAll { (io: IO[Int], f: Either[Throwable, Int] => IO[Int]) => + io.attemptTap(f) eqv io.attempt.flatTap(f).rethrow + } + } + "rethrow is inverse of attempt" in ticked { implicit ticker => forAll { (io: IO[Int]) => io.attempt.rethrow eqv io } } @@ -1359,8 +1386,9 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { "catch exceptions in cont" in ticked { implicit ticker => IO.cont[Unit, Unit](new Cont[IO, Unit, Unit] { - override def apply[F[_]](implicit F: MonadCancel[F, Throwable]) = { (_, _, _) => - throw new Exception + override def apply[F[_]](implicit F: MonadCancel[F, Throwable]) + : (Either[Throwable, Unit] => Unit, F[Unit], cats.effect.IO ~> F) => F[Unit] = { + (_, _, _) => throw new Exception } }).voidError must completeAs(()) } @@ -1588,6 +1616,34 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { } + "parTraverseN_" should { + + "throw when n < 1" in real { + IO.defer { + List.empty[Int].parTraverseN_(0)(_.pure[IO]) + }.mustFailWith[IllegalArgumentException] + } + + "propagate errors" in real { + List(1, 2, 3) + .parTraverseN_(2) { (n: Int) => + if (n == 2) IO.raiseError(new RuntimeException) else n.pure[IO] + } + .mustFailWith[RuntimeException] + } + + "be cancelable" in ticked { implicit ticker => + val p = for { + f <- List(1, 2, 3).parTraverseN_(2)(_ => IO.never).start + _ <- IO.sleep(100.millis) + _ <- f.cancel + } yield true + + p must completeAs(true) + } + + } + "parallel" should { "run parallel actually in parallel" in real { val x = IO.sleep(2.seconds) >> IO.pure(1) @@ -1849,13 +1905,41 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { "timeoutAndForget" should { "terminate on an uncancelable fiber" in real { - IO.never.uncancelable.timeoutAndForget(1.second).attempt flatMap { e => + IO.never.uncancelable.timeoutAndForget(1.second).attempt flatMap { r => IO { - e must beLike { case Left(e) => e must haveClass[TimeoutException] } + r must beLike { case Left(e) => e must haveClass[TimeoutException] } } } } } + + "no-op when canceling an expired timer 1" in realWithRuntime { rt => + // this one excercises a timer removed via `TimerHeap#pollFirstIfTriggered` + IO(Promise[Unit]()) + .flatMap { p => + IO(rt.scheduler.sleep(1.nanosecond, () => p.success(()))).flatMap { cancel => + IO.fromFuture(IO(p.future)) *> IO(cancel.run()) + } + } + .as(ok) + } + + "no-op when canceling an expired timer 2" in realWithRuntime { rt => + // this one excercises a timer removed via `TimerHeap#insert` + IO(Promise[Unit]()) + .flatMap { p => + IO(rt.scheduler.sleep(1.nanosecond, () => p.success(()))).flatMap { cancel => + IO.sleep(1.nanosecond) *> IO.fromFuture(IO(p.future)) *> IO(cancel.run()) + } + } + .as(ok) + } + + "no-op when canceling a timer twice" in realWithRuntime { rt => + IO(rt.scheduler.sleep(1.day, () => ())) + .flatMap(cancel => IO(cancel.run()) *> IO(cancel.run())) + .as(ok) + } } "syncStep" should { diff --git a/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala b/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala index dcb8977a24..e37aa4e757 100644 --- a/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala @@ -42,6 +42,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Concurrent.memoize does not evaluate the effect if the inner `F[A]` isn't bound" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { ref <- Ref.of[F, Int](0) action = ref.update(_ + 1) @@ -57,6 +59,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Concurrent.memoize evaluates effect once if inner `F[A]` is bound twice" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { ref <- Ref.of[F, Int](0) action = ref.modify { s => @@ -77,6 +81,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Concurrent.memoize effect evaluates effect once if the inner `F[A]` is bound twice (race)" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { ref <- Ref.of[F, Int](0) action = ref.modify { s => @@ -108,6 +114,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Memoized effects can be canceled when there are no other active subscribers (1)" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { completed <- Ref[F].of(false) action = liftK(IO.sleep(200.millis)) >> completed.set(true) @@ -127,6 +135,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Memoized effects can be canceled when there are no other active subscribers (2)" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { completed <- Ref[F].of(false) action = liftK(IO.sleep(300.millis)) >> completed.set(true) @@ -149,6 +159,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Memoized effects can be canceled when there are no other active subscribers (3)" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { completed <- Ref[F].of(false) action = liftK(IO.sleep(300.millis)) >> completed.set(true) @@ -171,6 +183,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Running a memoized effect after it was previously canceled reruns it" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { started <- Ref[F].of(0) completed <- Ref[F].of(0) @@ -195,6 +209,8 @@ class MemoizeSpec extends BaseSpec with Discipline { "Attempting to cancel a memoized effect with active subscribers is a no-op" in ticked { implicit ticker => + import cats.syntax.all._ + val op = for { startCounter <- Ref[F].of(0) condition <- Deferred[F, Unit] diff --git a/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala b/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala index c742981370..0529109025 100644 --- a/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala @@ -603,7 +603,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { "propagate the exit case" in { import Resource.ExitCase - "use succesfully, test left" >> ticked { implicit ticker => + "use successfully, test left" >> ticked { implicit ticker => var got: ExitCase = null val r = Resource.onFinalizeCase(ec => IO { got = ec }) r.both(Resource.unit).use(_ => IO.unit) must completeAs(()) @@ -725,7 +725,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { "propagate the exit case" in { import Resource.ExitCase - "use succesfully, test left" >> ticked { implicit ticker => + "use successfully, test left" >> ticked { implicit ticker => var got: ExitCase = null val r = Resource.onFinalizeCase(ec => IO { got = ec }) r.combineK(Resource.unit).use(_ => IO.unit) must completeAs(()) @@ -848,7 +848,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { val outerInit = Resource.make(IO.unit)(_ => IO { outerClosed = true }) val async = Async[Resource[IO, *]].async[Unit] { cb => - (inner *> Resource.eval(IO(cb(Right(()))))).as(None) + (inner *> Resource.eval(IO(cb(Right(()))))).map(_ => None) } (outerInit *> async *> waitR).use_.unsafeToFuture() @@ -933,11 +933,11 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { val winner = Resource .make(IO.unit)(_ => IO { winnerClosed = true }) .evalMap(_ => IO.sleep(100.millis)) - .as("winner") + .map(_ => "winner") val loser = Resource .make(IO.unit)(_ => IO { loserClosed = true }) .evalMap(_ => IO.sleep(200.millis)) - .as("loser") + .map(_ => "loser") val target = winner.race(loser).evalMap(e => IO { results = e }) *> waitR *> Resource.eval(IO { @@ -986,7 +986,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { case Left(()) => acquiredRight.get.ifM(loserReleased.get.map(_ must beRight[Unit]), IO.pure(ok)) case Right(()) => - acquiredLeft.get.ifM(loserReleased.get.map(_ must beLeft[Unit]).void, IO.pure(ok)) + acquiredLeft.get.ifM(loserReleased.get.map(_ must beLeft[Unit]), IO.pure(ok)) } } diff --git a/tests/shared/src/test/scala/cats/effect/kernel/LensRefSpec.scala b/tests/shared/src/test/scala/cats/effect/kernel/LensRefSpec.scala index f42066850d..bb3bba6cdc 100644 --- a/tests/shared/src/test/scala/cats/effect/kernel/LensRefSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/kernel/LensRefSpec.scala @@ -18,7 +18,7 @@ package cats package effect package kernel -import cats.{Eq, Show} +import cats.Eq import cats.data.State import scala.concurrent.duration._ @@ -148,6 +148,7 @@ class LensRefSpec extends BaseSpec with DetectPlatform { outer => op must completeAs((false, Foo(5, -1))) } + else () "tryModify - successfully modifies underlying Ref" in ticked { implicit ticker => val op = for { @@ -180,6 +181,7 @@ class LensRefSpec extends BaseSpec with DetectPlatform { outer => op must completeAs((None, Foo(5, -1))) } + else () "tryModifyState - successfully modifies underlying Ref" in ticked { implicit ticker => val op = for { diff --git a/tests/shared/src/test/scala/cats/effect/kernel/MiniSemaphoreSpec.scala b/tests/shared/src/test/scala/cats/effect/kernel/MiniSemaphoreSpec.scala index 2f30cc0210..cd67b0a8dd 100644 --- a/tests/shared/src/test/scala/cats/effect/kernel/MiniSemaphoreSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/kernel/MiniSemaphoreSpec.scala @@ -18,8 +18,6 @@ package cats package effect package kernel -import cats.syntax.all._ - import scala.concurrent.duration._ class MiniSemaphoreSpec extends BaseSpec { outer => diff --git a/tests/shared/src/test/scala/cats/effect/kernel/RefSpec.scala b/tests/shared/src/test/scala/cats/effect/kernel/RefSpec.scala index 6a87868026..1ef569f0f1 100644 --- a/tests/shared/src/test/scala/cats/effect/kernel/RefSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/kernel/RefSpec.scala @@ -121,6 +121,7 @@ class RefSpec extends BaseSpec with DetectPlatform { outer => op must completeAs(false) } + else () "tryModifyState - modification occurs successfully" in ticked { implicit ticker => val op = for { diff --git a/tests/shared/src/test/scala/cats/effect/std/AtomicCellSpec.scala b/tests/shared/src/test/scala/cats/effect/std/AtomicCellSpec.scala index bfcbb73b4d..9f3a6a73f0 100644 --- a/tests/shared/src/test/scala/cats/effect/std/AtomicCellSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/std/AtomicCellSpec.scala @@ -18,8 +18,6 @@ package cats package effect package std -import cats.syntax.all._ - import org.specs2.specification.core.Fragments import scala.concurrent.duration._ diff --git a/tests/shared/src/test/scala/cats/effect/std/QueueSpec.scala b/tests/shared/src/test/scala/cats/effect/std/QueueSpec.scala index 2dee60880b..c2dc7e6e8f 100644 --- a/tests/shared/src/test/scala/cats/effect/std/QueueSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/std/QueueSpec.scala @@ -41,6 +41,29 @@ class BoundedQueueSpec extends BaseSpec with QueueTests[Queue] with DetectPlatfo boundedQueueTests(Queue.bounded) } + "BoundedQueue (unsafe)" should { + "permit tryOffer when empty" in real { + Queue.unsafeBounded[IO, Int](1024) flatMap { q => + for { + attempt <- IO(q.unsafeTryOffer(42)) + _ <- IO(attempt must beTrue) + i <- q.take + _ <- IO(i mustEqual 42) + } yield ok + } + } + + "forbid tryOffer when full" in real { + Queue.unsafeBounded[IO, Int](8) flatMap { q => + for { + _ <- 0.until(8).toList.traverse_(q.offer(_)) + attempt <- IO(q.unsafeTryOffer(42)) + _ <- IO(attempt must beFalse) + } yield ok + } + } + } + "BoundedQueue constructor" should { "not OOM" in real { Queue.bounded[IO, Unit](Int.MaxValue).as(true) @@ -278,7 +301,11 @@ class BoundedQueueSpec extends BaseSpec with QueueTests[Queue] with DetectPlatfo q <- constructor(64) produce = 0.until(fiberCount).toList.parTraverse_(producer(q, _)) - consume = 0.until(fiberCount).toList.parTraverse(consumer(q, _)).map(_.flatten) + consume = 0 + .until(fiberCount) + .toList + .parTraverse(consumer(q, _)) + .map(_.flatMap(identity)) results <- produce &> consume @@ -362,6 +389,18 @@ class UnboundedQueueSpec extends BaseSpec with QueueTests[Queue] { unboundedQueueTests(Queue.unboundedForAsync) } + "UnboundedQueue (unsafe)" should { + "pass a value from unsafeOffer to take" in real { + Queue.unsafeUnbounded[IO, Int] flatMap { q => + for { + _ <- IO(q.unsafeOffer(42)) + i <- q.take + _ <- IO(i mustEqual 42) + } yield ok + } + } + } + "UnboundedQueue mapk" should { unboundedQueueTests(Queue.unbounded[IO, Int].map(_.mapK(FunctionK.id))) } @@ -379,7 +418,11 @@ class UnboundedQueueSpec extends BaseSpec with QueueTests[Queue] { class DroppingQueueSpec extends BaseSpec with QueueTests[Queue] { sequential - "DroppingQueue" should { + "DroppingQueue (concurrent)" should { + droppingQueueTests(i => if (i < 1) Queue.dropping(i) else Queue.droppingForConcurrent(i)) + } + + "DroppingQueue (async)" should { droppingQueueTests(Queue.dropping) } diff --git a/tests/shared/src/test/scala/cats/effect/unsafe/IORuntimeSpec.scala b/tests/shared/src/test/scala/cats/effect/unsafe/IORuntimeSpec.scala new file mode 100644 index 0000000000..a7db35fe3e --- /dev/null +++ b/tests/shared/src/test/scala/cats/effect/unsafe/IORuntimeSpec.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2020-2024 Typelevel + * + * 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. + */ + +package cats.effect.unsafe + +import cats.effect.BaseSpec + +class IORuntimeSpec extends BaseSpec { + + "IORuntimeSpec" should { + "cleanup allRuntimes collection on shutdown" in { + val (defaultScheduler, closeScheduler) = Scheduler.createDefaultScheduler() + + val runtime = IORuntime(null, null, defaultScheduler, closeScheduler, IORuntimeConfig()) + + IORuntime.allRuntimes.unsafeHashtable().find(_ == runtime) must beEqualTo(Some(runtime)) + + val _ = runtime.shutdown() + + IORuntime.allRuntimes.unsafeHashtable().find(_ == runtime) must beEqualTo(None) + } + + } + +}