diff --git a/indra/llcommon/CMakeLists.txt b/indra/llcommon/CMakeLists.txt
index d17ee4c70a1b41768ae93f6274219fb366a2be66..eeb315ead69608d8d5623990d84081d14d6a8e38 100644
--- a/indra/llcommon/CMakeLists.txt
+++ b/indra/llcommon/CMakeLists.txt
@@ -340,6 +340,7 @@ if (LL_TESTS)
   LL_ADD_INTEGRATION_TEST(llheteromap "" "${test_libs}")
   LL_ADD_INTEGRATION_TEST(llinstancetracker "" "${test_libs}")
   LL_ADD_INTEGRATION_TEST(llleap "" "${test_libs}")
+  LL_ADD_INTEGRATION_TEST(llmainthreadtask "" "${test_libs}")
   LL_ADD_INTEGRATION_TEST(llpounceable "" "${test_libs}")
   LL_ADD_INTEGRATION_TEST(llprocess "" "${test_libs}")
   LL_ADD_INTEGRATION_TEST(llprocessor "" "${test_libs}")
diff --git a/indra/llcommon/tests/llmainthreadtask_test.cpp b/indra/llcommon/tests/llmainthreadtask_test.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e54c7eda18c370baf96fa75a6caaf4168ab1bd9d
--- /dev/null
+++ b/indra/llcommon/tests/llmainthreadtask_test.cpp
@@ -0,0 +1,139 @@
+/**
+ * @file   llmainthreadtask_test.cpp
+ * @author Nat Goodspeed
+ * @date   2019-12-05
+ * @brief  Test for llmainthreadtask.
+ * 
+ * $LicenseInfo:firstyear=2019&license=viewerlgpl$
+ * Copyright (c) 2019, Linden Research, Inc.
+ * $/LicenseInfo$
+ */
+
+// Precompiled header
+#include "linden_common.h"
+// associated header
+#include "llmainthreadtask.h"
+// STL headers
+// std headers
+#include <atomic>
+// external library headers
+// other Linden headers
+#include "../test/lltut.h"
+#include "../test/sync.h"
+#include "llthread.h"               // on_main_thread()
+#include "lleventtimer.h"
+#include "lockstatic.h"
+
+/*****************************************************************************
+*   TUT
+*****************************************************************************/
+namespace tut
+{
+    struct llmainthreadtask_data
+    {
+        // 2-second timeout
+        Sync mSync{F32Milliseconds(2000.0f)};
+
+        llmainthreadtask_data()
+        {
+            // we're not testing the result; this is just to cache the
+            // initial thread as the main thread.
+            on_main_thread();
+        }
+    };
+    typedef test_group<llmainthreadtask_data> llmainthreadtask_group;
+    typedef llmainthreadtask_group::object object;
+    llmainthreadtask_group llmainthreadtaskgrp("llmainthreadtask");
+
+    template<> template<>
+    void object::test<1>()
+    {
+        set_test_name("inline");
+        bool ran = false;
+        bool result = LLMainThreadTask::dispatch(
+            [&ran]()->bool{
+                ran = true;
+                return true;
+            });
+        ensure("didn't run lambda", ran);
+        ensure("didn't return result", result);
+    }
+
+    struct StaticData
+    {
+        std::mutex mMutex;          // LockStatic looks for mMutex
+        bool ran{false};
+    };
+    typedef llthread::LockStatic<StaticData> LockStatic;
+
+    template<> template<>
+    void object::test<2>()
+    {
+        set_test_name("cross-thread");
+        std::atomic_bool result(false);
+        // wrapping our thread lambda in a packaged_task will catch any
+        // exceptions it might throw and deliver them via future
+        std::packaged_task<void()> thread_work(
+            [this, &result](){
+                // lock static data first
+                LockStatic lk;
+                // unblock test<2>()'s yield_until(1)
+                mSync.set(1);
+                // dispatch work to main thread -- should block here
+                bool on_main(
+                    LLMainThreadTask::dispatch(
+                        lk, // unlock this before blocking!
+                        []()->bool{
+                            // have to lock static mutex to set static data
+                            LockStatic()->ran = true;
+                            // indicate whether task was run on the main thread
+                            return on_main_thread();
+                        }));
+                // wait for test<2>() to unblock us again
+                mSync.yield_until(3);
+                result = on_main;
+            });
+        auto thread_result = thread_work.get_future();
+        std::thread thread;
+        try
+        {
+            // run thread_work
+            thread = std::thread(std::move(thread_work));
+            // wait for thread to set(1)
+            mSync.yield_until(1);
+            // try to acquire the lock, should block because thread has it
+            LockStatic lk;
+            // wake up when dispatch() unlocks the static mutex
+            ensure("shouldn't have run yet", !lk->ran);
+            ensure("shouldn't have returned yet", !result);
+            // unlock so the task can acquire the lock
+            lk.unlock();
+            // run the task -- should unblock thread, which will immediately block
+            // on mSync
+            LLEventTimer::updateClass();
+            // 'lk', having unlocked, can no longer be used to access; relock with
+            // a new LockStatic instance
+            ensure("should now have run", LockStatic()->ran);
+            ensure("returned too early", !result);
+            // okay, let thread perform the assignment
+            mSync.set(3);
+        }
+        catch (...)
+        {
+            // A test failure exception anywhere in the try block can cause
+            // the test program to terminate without explanation when
+            // ~thread() finds that 'thread' is still joinable. We could
+            // either join() or detach() it -- but since it might be blocked
+            // waiting for something from the main thread that now can never
+            // happen, it's safer to detach it.
+            thread.detach();
+            throw;
+        }
+        // 'thread' should be all done now
+        thread.join();
+        // deliver any exception thrown by thread_work
+        thread_result.get();
+        ensure("ran changed", LockStatic()->ran);
+        ensure("didn't run on main thread", result);
+    }
+} // namespace tut