Repository: wasmerio/wasmer (language: rust)

Issue:
Panic in spawned thread causes dead lock (?)
### Describe the bug

See reproduction steps

```
wasmer 4.3.7 (c6c348d 2024-09-09)
binary: wasmer-cli
commit-hash: c6c348d00fa3582eb3b3fda07e69fdc695156193
commit-date: 2024-09-09
host: x86_64-pc-windows-msvc
compiler: singlepass,cranelift,llvm
rustc 1.81.0 (eeb90cda1 2024-09-04)
binary: rustc
commit-hash: eeb90cda1969383f56a2637cbd3037bdf598841c
commit-date: 2024-09-04
host: x86_64-pc-windows-msvc
release: 1.81.0
LLVM version: 18.1.7
```

### Steps to reproduce

```rs
fn main() {
    std::thread::spawn(|| {
        panic!("dead lock?"); // dead locks
    })
    .join()
    .unwrap();
}
```
run it with `cargo wasix run`

### Expected behavior

Process should exit with non-zero code, just like `cargo run` does
```
thread '<unnamed>' panicked at src/main.rs:3:9:
dead lock?
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' panicked at src/main.rs:6:6:
called `Result::unwrap()` on an `Err` value: Any { .. }
error: process didn't exit successfully: `target\debug\wasix-threading.exe` (exit code: 101)
```



Discussion / hints:
Thanks, we'll investigate this issue.
@xxshady please be aware that when rust is compiled to WASM, you can't have unwinding panics; every panic is an abort. The fix to this problem would be to abort the entire WASIX process when a thread panics, which is also the native behavior with `panic = 'abort'`. This may not be what you expected the fix to be.

Interestingly, my testing shows that calling `exit()` from a non-main thread is also broken, with the same underlying reason as this issue.

Fix patch:
diff --git a/lib/wasix/src/bin_factory/exec.rs b/lib/wasix/src/bin_factory/exec.rs
index 2dd675796f3..1b0cd4391cf 100644
--- a/lib/wasix/src/bin_factory/exec.rs
+++ b/lib/wasix/src/bin_factory/exec.rs
@@ -279,6 +279,7 @@ fn call_module(
         if let Err(err) = call_ret {
             match err.downcast::<WasiError>() {
                 Ok(WasiError::Exit(code)) if code.is_success() => Ok(Errno::Success),
+                Ok(WasiError::ThreadExit) => Ok(Errno::Success),
                 Ok(WasiError::Exit(code)) => {
                     runtime.on_taint(TaintReason::NonZeroExitCode(code));
                     Err(WasiError::Exit(code).into())
diff --git a/lib/wasix/src/lib.rs b/lib/wasix/src/lib.rs
index 3f93f3c22dc..939c13c4fae 100644
--- a/lib/wasix/src/lib.rs
+++ b/lib/wasix/src/lib.rs
@@ -114,6 +114,8 @@ pub use crate::{
 pub enum WasiError {
     #[error("WASI exited with code: {0}")]
     Exit(ExitCode),
+    #[error("WASI thread exited")]
+    ThreadExit,
     #[error("WASI deep sleep: {0:?}")]
     DeepSleep(DeepSleepWork),
     #[error("The WASI version could not be determined")]
diff --git a/lib/wasix/src/runners/wasi.rs b/lib/wasix/src/runners/wasi.rs
index e8728059bc7..b5b3c121452 100644
--- a/lib/wasix/src/runners/wasi.rs
+++ b/lib/wasix/src/runners/wasi.rs
@@ -404,6 +404,9 @@ impl crate::runners::Runner for WasiRunner {
                                     WasiRuntimeError::Wasi(WasiError::Exit(a)) => {
                                         WasiRuntimeError::Wasi(WasiError::Exit(*a))
                                     }
+                                    WasiRuntimeError::Wasi(WasiError::ThreadExit) => {
+                                        WasiRuntimeError::Wasi(WasiError::ThreadExit)
+                                    }
                                     WasiRuntimeError::Wasi(WasiError::UnknownWasiVersion) => {
                                         WasiRuntimeError::Wasi(WasiError::UnknownWasiVersion)
                                     }
diff --git a/lib/wasix/src/state/env.rs b/lib/wasix/src/state/env.rs
index 15306311fe2..6f60cb6735b 100644
--- a/lib/wasix/src/state/env.rs
+++ b/lib/wasix/src/state/env.rs
@@ -1252,32 +1252,33 @@ impl WasiEnv {
 
     /// Cleans up all the open files (if this is the main thread)
     #[allow(clippy::await_holding_lock)]
-    pub fn blocking_on_exit(&self, exit_code: Option<ExitCode>) {
-        let cleanup = self.on_exit(exit_code);
+    pub fn blocking_on_exit(&self, process_exit_code: Option<ExitCode>) {
+        let cleanup = self.on_exit(process_exit_code);
         InlineWaker::block_on(cleanup);
     }
 
     /// Cleans up all the open files (if this is the main thread)
     #[allow(clippy::await_holding_lock)]
-    pub fn on_exit(&self, exit_code: Option<ExitCode>) -> BoxFuture<'static, ()> {
+    pub fn on_exit(&self, process_exit_code: Option<ExitCode>) -> BoxFuture<'static, ()> {
         const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
 
         // If snap-shooting is enabled then we should record an event that the thread has exited.
         #[cfg(feature = "journal")]
         if self.should_journal() && self.has_active_journal() {
-            if let Err(err) = JournalEffector::save_thread_exit(self, self.tid(), exit_code) {
+            if let Err(err) = JournalEffector::save_thread_exit(self, self.tid(), process_exit_code)
+            {
                 tracing::warn!("failed to save snapshot event for thread exit - {}", err);
             }
 
             if self.thread.is_main() {
-                if let Err(err) = JournalEffector::save_process_exit(self, exit_code) {
+                if let Err(err) = JournalEffector::save_process_exit(self, process_exit_code) {
                     tracing::warn!("failed to save snapshot event for process exit - {}", err);
                 }
             }
         }
 
-        // If this is the main thread then also close all the files
-        if self.thread.is_main() {
+        // If the process wants to exit, also close all files and terminate it
+        if let Some(process_exit_code) = process_exit_code {
             let process = self.process.clone();
             let disable_fs_cleanup = self.disable_fs_cleanup;
             let pid = self.pid();
@@ -1303,8 +1304,7 @@ impl WasiEnv {
                 }
 
                 // Terminate the process
-                let exit_code = exit_code.unwrap_or_else(|| Errno::Canceled.into());
-                process.terminate(exit_code);
+                process.terminate(process_exit_code);
             })
         } else {
             Box::pin(async {})
diff --git a/lib/wasix/src/state/func_env.rs b/lib/wasix/src/state/func_env.rs
index 5de4602234d..dfd50449723 100644
--- a/lib/wasix/src/state/func_env.rs
+++ b/lib/wasix/src/state/func_env.rs
@@ -284,7 +284,7 @@ impl WasiFunctionEnv {
     /// This function should only be called from within a syscall
     /// as it can potentially execute local thread variable cleanup
     /// code
-    pub fn on_exit(&self, store: &mut impl AsStoreMut, exit_code: Option<ExitCode>) {
+    pub fn on_exit(&self, store: &mut impl AsStoreMut, process_exit_code: Option<ExitCode>) {
         trace!(
             "wasi[{}:{}]::on_exit",
             self.data(store).pid(),
@@ -292,7 +292,7 @@ impl WasiFunctionEnv {
         );
 
         // Cleans up all the open files (if this is the main thread)
-        self.data(store).blocking_on_exit(exit_code);
+        self.data(store).blocking_on_exit(process_exit_code);
     }
 
     /// Bootstraps this main thread and context with any journals that
diff --git a/lib/wasix/src/syscalls/wasix/thread_exit.rs b/lib/wasix/src/syscalls/wasix/thread_exit.rs
index e6f348c7ead..c7529c99753 100644
--- a/lib/wasix/src/syscalls/wasix/thread_exit.rs
+++ b/lib/wasix/src/syscalls/wasix/thread_exit.rs
@@ -3,15 +3,14 @@ use crate::syscalls::*;
 
 /// ### `thread_exit()`
 /// Terminates the current running thread, if this is the last thread then
-/// the process will also exit with the specified exit code. An exit code
-/// of 0 indicates successful termination of the thread. The meanings of
-/// other values is dependent on the environment.
+/// the process will also exit with code 0.
+/// The exit code parameter is a left over from a previous version of this
+/// syscall, maintained here to keep the syscall backwards-compatible, but
+/// is otherwise unused.
 ///
-/// ## Parameters
-///
-/// * `rval` - The exit code returned by the process.
-#[instrument(level = "trace", skip_all, fields(%exitcode), ret)]
-pub fn thread_exit(ctx: FunctionEnvMut<'_, WasiEnv>, exitcode: ExitCode) -> Result<(), WasiError> {
-    tracing::debug!(tid=%ctx.data().thread.id(), %exitcode);
-    Err(WasiError::Exit(exitcode))
+/// This syscall does not return.
+#[instrument(level = "trace", skip_all, fields(%_exitcode), ret)]
+pub fn thread_exit(ctx: FunctionEnvMut<'_, WasiEnv>, _exitcode: ExitCode) -> Result<(), WasiError> {
+    tracing::debug!(tid=%ctx.data().thread.id(), "thread exit");
+    Err(WasiError::ThreadExit)
 }
diff --git a/lib/wasix/src/syscalls/wasix/thread_spawn.rs b/lib/wasix/src/syscalls/wasix/thread_spawn.rs
index 3f47e16fed2..a6908c56bb4 100644
--- a/lib/wasix/src/syscalls/wasix/thread_spawn.rs
+++ b/lib/wasix/src/syscalls/wasix/thread_spawn.rs
@@ -203,10 +203,19 @@ fn call_module<M: MemorySize>(
                 .map_err(|_| Errno::Overflow)
                 .unwrap(),
         );
+        trace!("callback finished (ret={:?})", call_ret);
+
         let mut ret = Errno::Success;
+        let mut exit_code = None;
         if let Err(err) = call_ret {
             match err.downcast::<WasiError>() {
+                Ok(WasiError::ThreadExit) => {
+                    trace!("thread exited cleanly");
+                    ret = Errno::Success;
+                }
                 Ok(WasiError::Exit(code)) => {
+                    trace!(exit_code = ?code, "thread requested exit");
+                    exit_code = Some(code);
                     ret = if code.is_success() {
                         Errno::Success
                     } else {
@@ -226,6 +235,7 @@ fn call_module<M: MemorySize>(
                         .runtime
                         .on_taint(TaintReason::UnknownWasiVersion);
                     ret = Errno::Noexec;
+                    exit_code = Some(ExitCode::Other(128 + ret as i32));
                 }
                 Err(err) => {
                     debug!("failed with runtime error: {}", err);
@@ -233,13 +243,15 @@ fn call_module<M: MemorySize>(
                         .runtime
                         .on_taint(TaintReason::RuntimeError(err));
                     ret = Errno::Noexec;
+                    exit_code = Some(ExitCode::Other(128 + ret as i32));
                 }
             }
+        } else {
+            debug!("thread exited cleanly without calling thread_exit");
         }
-        trace!("callback finished (ret={})", ret);
 
         // Clean up the environment
-        env.on_exit(store, Some(ret.into()));
+        env.on_exit(store, exit_code);
 
         // Return the result
         Ok(ret as u32)


Test patch:
diff --git a/tests/integration/cli/tests/snapshot.rs b/tests/integration/cli/tests/snapshot.rs
index a75274eddc5..7761092699b 100644
--- a/tests/integration/cli/tests/snapshot.rs
+++ b/tests/integration/cli/tests/snapshot.rs
@@ -1281,3 +1281,81 @@ fn test_snapshot_fs_rename() {
         .run_wasm(include_bytes!("./wasm/fs-rename.wasm"));
     assert_json_snapshot!(snapshot);
 }
+
+#[cfg_attr(any(target_env = "musl", target_os = "windows"), ignore)]
+#[test]
+fn test_snapshot_exit_0_from_main() {
+    let snapshot = TestBuilder::new()
+        .with_name(function!())
+        .run_wasm(include_bytes!("./wasm/exit-0-from-main.wasm"));
+    assert!(matches!(
+        snapshot.result,
+        TestResult::Success(TestOutput { exit_code: 0, .. })
+    ));
+    assert_json_snapshot!(snapshot);
+}
+
+#[cfg_attr(any(target_env = "musl", target_os = "windows"), ignore)]
+#[test]
+fn test_snapshot_exit_1_from_main() {
+    let snapshot = TestBuilder::new()
+        .with_name(function!())
+        .run_wasm(include_bytes!("./wasm/exit-1-from-main.wasm"));
+    assert!(matches!(
+        snapshot.result,
+        TestResult::Success(TestOutput { exit_code: 1, .. })
+    ));
+    assert_json_snapshot!(snapshot);
+}
+
+#[cfg_attr(any(target_env = "musl", target_os = "windows"), ignore)]
+#[test]
+fn test_snapshot_exit_0_from_worker() {
+    let snapshot = TestBuilder::new()
+        .with_name(function!())
+        .run_wasm(include_bytes!("./wasm/exit-0-from-worker.wasm"));
+    assert!(matches!(
+        snapshot.result,
+        TestResult::Success(TestOutput { exit_code: 0, .. })
+    ));
+    assert_json_snapshot!(snapshot);
+}
+
+#[cfg_attr(any(target_env = "musl", target_os = "windows"), ignore)]
+#[test]
+fn test_snapshot_exit_1_from_worker() {
+    let snapshot = TestBuilder::new()
+        .with_name(function!())
+        .run_wasm(include_bytes!("./wasm/exit-1-from-worker.wasm"));
+    assert!(matches!(
+        snapshot.result,
+        TestResult::Success(TestOutput { exit_code: 1, .. })
+    ));
+    assert_json_snapshot!(snapshot);
+}
+
+#[cfg_attr(any(target_env = "musl", target_os = "windows"), ignore)]
+#[test]
+fn test_snapshot_worker_terminating_normally() {
+    let snapshot = TestBuilder::new()
+        .with_name(function!())
+        .run_wasm(include_bytes!("./wasm/worker-terminating-normally.wasm"));
+    assert!(matches!(
+        snapshot.result,
+        TestResult::Success(TestOutput { exit_code: 0, .. })
+    ));
+    assert_json_snapshot!(snapshot);
+}
+
+#[cfg_attr(any(target_env = "musl", target_os = "windows"), ignore)]
+#[test]
+fn test_snapshot_worker_panicking() {
+    let snapshot = TestBuilder::new()
+        .with_name(function!())
+        .run_wasm(include_bytes!("./wasm/worker-panicking.wasm"));
+    assert!(matches!(
+        snapshot.result,
+        TestResult::Success(TestOutput { exit_code: 173, .. })
+    ));
+    assert_json_snapshot!(snapshot);
+}
diff --git a/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_0_from_main.snap b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_0_from_main.snap
new file mode 100644
index 00000000000..ecff3deec8e
--- /dev/null
+++ b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_0_from_main.snap
@@ -0,0 +1,21 @@
+---
+source: tests/integration/cli/tests/snapshot.rs
+expression: snapshot
+---
+{
+  "spec": {
+    "name": "snapshot::test_snapshot_exit_0_from_main",
+    "use_packages": [],
+    "include_webcs": [],
+    "cli_args": [],
+    "enable_threads": true,
+    "enable_network": false
+  },
+  "result": {
+    "Success": {
+      "stdout": "",
+      "stderr": "",
+      "exit_code": 0
+    }
+  }
+}
diff --git a/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_0_from_worker.snap b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_0_from_worker.snap
new file mode 100644
index 00000000000..700990daa18
--- /dev/null
+++ b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_0_from_worker.snap
@@ -0,0 +1,22 @@
+---
+source: tests/integration/cli/tests/snapshot.rs
+assertion_line: 1309
+expression: snapshot
+---
+{
+  "spec": {
+    "name": "snapshot::test_snapshot_exit_0_from_worker",
+    "use_packages": [],
+    "include_webcs": [],
+    "cli_args": [],
+    "enable_threads": true,
+    "enable_network": false
+  },
+  "result": {
+    "Success": {
+      "stdout": "",
+      "stderr": "",
+      "exit_code": 0
+    }
+  }
+}
diff --git a/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_1_from_main.snap b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_1_from_main.snap
new file mode 100644
index 00000000000..648532b4846
--- /dev/null
+++ b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_1_from_main.snap
@@ -0,0 +1,22 @@
+---
+source: tests/integration/cli/tests/snapshot.rs
+assertion_line: 1300
+expression: snapshot
+---
+{
+  "spec": {
+    "name": "snapshot::test_snapshot_exit_1_from_main",
+    "use_packages": [],
+    "include_webcs": [],
+    "cli_args": [],
+    "enable_threads": true,
+    "enable_network": false
+  },
+  "result": {
+    "Success": {
+      "stdout": "",
+      "stderr": "",
+      "exit_code": 1
+    }
+  }
+}
diff --git a/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_1_from_worker.snap b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_1_from_worker.snap
new file mode 100644
index 00000000000..6057c50b025
--- /dev/null
+++ b/tests/integration/cli/tests/snapshots/snapshot__snapshot_exit_1_from_worker.snap
@@ -0,0 +1,22 @@
+---
+source: tests/integration/cli/tests/snapshot.rs
+assertion_line: 1318
+expression: snapshot
+---
+{
+  "spec": {
+    "name": "snapshot::test_snapshot_exit_1_from_worker",
+    "use_packages": [],
+    "include_webcs": [],
+    "cli_args": [],
+    "enable_threads": true,
+    "enable_network": false
+  },
+  "result": {
+    "Success": {
+      "stdout": "",
+      "stderr": "",
+      "exit_code": 1
+    }
+  }
+}
diff --git a/tests/integration/cli/tests/snapshots/snapshot__snapshot_worker_panicking.snap b/tests/integration/cli/tests/snapshots/snapshot__snapshot_worker_panicking.snap
new file mode 100644
index 00000000000..108e4647220
--- /dev/null
+++ b/tests/integration/cli/tests/snapshots/snapshot__snapshot_worker_panicking.snap
@@ -0,0 +1,22 @@
+---
+source: tests/integration/cli/tests/snapshot.rs
+assertion_line: 1336
+expression: snapshot
+---
+{
+  "spec": {
+    "name": "snapshot::test_snapshot_worker_panicking",
+    "use_packages": [],
+    "include_webcs": [],
+    "cli_args": [],
+    "enable_threads": true,
+    "enable_network": false
+  },
+  "result": {
+    "Success": {
+      "stdout": "",
+      "stderr": "thread '<unnamed>' panicked at src/bin/worker-panicking.rs:3:9:\nchild thread panicking\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n",
+      "exit_code": 173
+    }
+  }
+}
diff --git a/tests/integration/cli/tests/snapshots/snapshot__snapshot_worker_terminating_normally.snap b/tests/integration/cli/tests/snapshots/snapshot__snapshot_worker_terminating_normally.snap
new file mode 100644
index 00000000000..7bc3af32a3d
--- /dev/null
+++ b/tests/integration/cli/tests/snapshots/snapshot__snapshot_worker_terminating_normally.snap
@@ -0,0 +1,22 @@
+---
+source: tests/integration/cli/tests/snapshot.rs
+assertion_line: 1327
+expression: snapshot
+---
+{
+  "spec": {
+    "name": "snapshot::test_snapshot_worker_terminating_normally",
+    "use_packages": [],
+    "include_webcs": [],
+    "cli_args": [],
+    "enable_threads": true,
+    "enable_network": false
+  },
+  "result": {
+    "Success": {
+      "stdout": "In child thread\nIn main thread\n",
+      "stderr": "",
+      "exit_code": 0
+    }
+  }
+}
diff --git a/tests/integration/cli/tests/wasm/exit-0-from-main.wasm b/tests/integration/cli/tests/wasm/exit-0-from-main.wasm
new file mode 100644
index 00000000000..18049620588
Binary files /dev/null and b/tests/integration/cli/tests/wasm/exit-0-from-main.wasm differ
diff --git a/tests/integration/cli/tests/wasm/exit-0-from-worker.wasm b/tests/integration/cli/tests/wasm/exit-0-from-worker.wasm
new file mode 100644
index 00000000000..929389d31a2
Binary files /dev/null and b/tests/integration/cli/tests/wasm/exit-0-from-worker.wasm differ
diff --git a/tests/integration/cli/tests/wasm/exit-1-from-main.wasm b/tests/integration/cli/tests/wasm/exit-1-from-main.wasm
new file mode 100644
index 00000000000..43f62bf9163
Binary files /dev/null and b/tests/integration/cli/tests/wasm/exit-1-from-main.wasm differ
diff --git a/tests/integration/cli/tests/wasm/exit-1-from-worker.wasm b/tests/integration/cli/tests/wasm/exit-1-from-worker.wasm
new file mode 100644
index 00000000000..855c4ddaf24
Binary files /dev/null and b/tests/integration/cli/tests/wasm/exit-1-from-worker.wasm differ
diff --git a/tests/integration/cli/tests/wasm/worker-panicking.wasm b/tests/integration/cli/tests/wasm/worker-panicking.wasm
new file mode 100644
index 00000000000..8dc96393741
Binary files /dev/null and b/tests/integration/cli/tests/wasm/worker-panicking.wasm differ
diff --git a/tests/integration/cli/tests/wasm/worker-terminating-normally.wasm b/tests/integration/cli/tests/wasm/worker-terminating-normally.wasm
new file mode 100644
index 00000000000..2ccbd04537e
Binary files /dev/null and b/tests/integration/cli/tests/wasm/worker-terminating-normally.wasm differ
diff --git a/tests/rust-wasi-tests/src/bin/exit-0-from-main.rs b/tests/rust-wasi-tests/src/bin/exit-0-from-main.rs
new file mode 100644
index 00000000000..44490fa2464
--- /dev/null
+++ b/tests/rust-wasi-tests/src/bin/exit-0-from-main.rs
@@ -0,0 +1,3 @@
+fn main() {
+    std::process::exit(0);
+}
diff --git a/tests/rust-wasi-tests/src/bin/exit-0-from-worker.rs b/tests/rust-wasi-tests/src/bin/exit-0-from-worker.rs
new file mode 100644
index 00000000000..fb5d491c2f6
--- /dev/null
+++ b/tests/rust-wasi-tests/src/bin/exit-0-from-worker.rs
@@ -0,0 +1,3 @@
+fn main() {
+    std::thread::spawn(|| std::process::exit(0)).join().unwrap();
+}
diff --git a/tests/rust-wasi-tests/src/bin/exit-1-from-main.rs b/tests/rust-wasi-tests/src/bin/exit-1-from-main.rs
new file mode 100644
index 00000000000..081ca869c86
--- /dev/null
+++ b/tests/rust-wasi-tests/src/bin/exit-1-from-main.rs
@@ -0,0 +1,3 @@
+fn main() {
+    std::process::exit(1);
+}
diff --git a/tests/rust-wasi-tests/src/bin/exit-1-from-worker.rs b/tests/rust-wasi-tests/src/bin/exit-1-from-worker.rs
new file mode 100644
index 00000000000..b1cb0e5818b
--- /dev/null
+++ b/tests/rust-wasi-tests/src/bin/exit-1-from-worker.rs
@@ -0,0 +1,3 @@
+fn main() {
+    std::thread::spawn(|| std::process::exit(1)).join().unwrap();
+}
diff --git a/tests/rust-wasi-tests/src/bin/worker-panicking.rs b/tests/rust-wasi-tests/src/bin/worker-panicking.rs
new file mode 100644
index 00000000000..a3d50ce29a8
--- /dev/null
+++ b/tests/rust-wasi-tests/src/bin/worker-panicking.rs
@@ -0,0 +1,11 @@
+fn main() {
+    std::thread::spawn(|| {
+        panic!("child thread panicking");
+    })
+    .join()
+    .unwrap();
+
+    println!("In main thread");
+
+    std::process::exit(0);
+}
diff --git a/tests/rust-wasi-tests/src/bin/worker-terminating-normally.rs b/tests/rust-wasi-tests/src/bin/worker-terminating-normally.rs
new file mode 100644
index 00000000000..d252f7b22db
--- /dev/null
+++ b/tests/rust-wasi-tests/src/bin/worker-terminating-normally.rs
@@ -0,0 +1,11 @@
+fn main() {
+    std::thread::spawn(|| {
+        println!("In child thread");
+    })
+    .join()
+    .unwrap();
+
+    println!("In main thread");
+
+    std::process::exit(0);
+}