diff --git a/core/jvm/src/main/scala/cats/effect/IOApp.scala b/core/jvm/src/main/scala/cats/effect/IOApp.scala
index 6f1a0857af5..5b69c0e61b1 100644
--- a/core/jvm/src/main/scala/cats/effect/IOApp.scala
+++ b/core/jvm/src/main/scala/cats/effect/IOApp.scala
@@ -314,6 +314,29 @@ trait IOApp {
protected def onCpuStarvationWarn(metrics: CpuStarvationWarningMetrics): IO[Unit] =
CpuStarvationCheck.logWarning(metrics)
+ /**
+ * Defines what to do when IOApp detects that `main` is being invoked on a `Thread` which
+ * isn't the main process thread. This condition can happen when we are running inside of an
+ * `sbt run` with `fork := false`
+ */
+ private def onNonMainThreadDetected(): Unit = {
+ val shouldPrint =
+ Option(System.getProperty("cats.effect.warnOnNonMainThreadDetected"))
+ .map(_.equalsIgnoreCase("true"))
+ .getOrElse(true)
+ if (shouldPrint)
+ System
+ .err
+ .println(
+ """|Warning: IOApp `main` is running on a thread other than the main thread.
+ |This may prevent correct resource cleanup after `main` completes.
+ |This condition could be caused by executing `sbt run` with `fork := false`.
+ |Set `Compile / run / fork := true` in this project to resolve this.
+ |""".stripMargin
+ )
+ else ()
+ }
+
/**
* 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
@@ -333,6 +356,7 @@ trait IOApp {
final def main(args: Array[String]): Unit = {
// checked in openjdk 8-17; this attempts to detect when we're running under artificial environments, like sbt
val isForked = Thread.currentThread().getId() == 1
+ if (!isForked) onNonMainThreadDetected()
val installed = if (runtime == null) {
import unsafe.IORuntime
diff --git a/docs/core/io-runtime-config.md b/docs/core/io-runtime-config.md
index 0a48199b48b..39828ad4293 100644
--- a/docs/core/io-runtime-config.md
+++ b/docs/core/io-runtime-config.md
@@ -28,7 +28,8 @@ This can be done for example with the [EnvironmentPlugin for Webpack](https://we
| `cats.effect.detectBlockedThreads`
N/A | `Boolean` (`false`) | Whether or not we should detect blocked threads. |
| `cats.effect.logNonDaemonThreadsOnExit`
N/A | `Boolean` (`true`) | Whether or not we should check for non-daemon threads on JVM exit. |
| `cats.effect.logNonDaemonThreads.sleepIntervalMillis`
N/A | `Long` (`10000L`) | Time to sleep between checking for presence of non-daemon threads. |
-| `cats.effect.cancelation.check.threshold `
`CATS_EFFECT_CANCELATION_CHECK_THRESHOLD` | `Int` (`512`) | Configure how often cancellation is checked. By default, every 512 iterations of the run loop. |
+| `cats.effect.warnOnNonMainThreadDetected`
N/A | `Boolean` (`true`) | Print a warning message when IOApp `main` runs on a non-main thread |
+| `cats.effect.cancelation.check.threshold`
`CATS_EFFECT_CANCELATION_CHECK_THRESHOLD` | `Int` (`512`) | Configure how often cancellation is checked. By default, every 512 iterations of the run loop. |
| `cats.effect.auto.yield.threshold.multiplier`
`CATS_EFFECT_AUTO_YIELD_THRESHOLD_MULTIPLIER` | `Int` (`2`) | `autoYieldThreshold = autoYieldThresholdMultiplier x cancelationCheckThreshold`. See [thread model](../thread-model.md). |
| `cats.effect.tracing.exceptions.enhanced`
`CATS_EFFECT_TRACING_EXCEPTIONS_ENHANCED` | `Boolean` (`true`) | Augment the stack traces of caught exceptions to include frames from the asynchronous stack traces. See [tracing](../tracing.md). |
| `cats.effect.tracing.buffer.size`
`CATS_EFFECT_TRACING_BUFFER_SIZE` | `Int` (`16`) | Number of stack frames retained in the tracing buffer. Will be rounded up to next power of two. |