diff --git a/indra/llcommon/llcoros.h b/indra/llcommon/llcoros.h
index 678633497de06b46421c33eebe47d75c6be99254..dedb6c8ecac23d62728d86edac09cbb815dcdc51 100644
--- a/indra/llcommon/llcoros.h
+++ b/indra/llcommon/llcoros.h
@@ -239,4 +239,17 @@ class LL_COMMON_API LLCoros: public LLSingleton<LLCoros>
     static void delete_CoroData(CoroData* cdptr);
 };
 
+namespace llcoro
+{
+
+inline
+std::string logname()
+{
+    static std::string main("main");
+    std::string name(LLCoros::instance().getName());
+    return name.empty()? main : name;
+}
+
+} // llcoro
+
 #endif /* ! defined(LL_LLCOROS_H) */
diff --git a/indra/llcommon/tests/lleventcoro_test.cpp b/indra/llcommon/tests/lleventcoro_test.cpp
index 2e4b6ba8236c16558bfff60d1c96301c89c4c725..4e774b27d9f793183d3cf26c41af1b56307481bc 100644
--- a/indra/llcommon/tests/lleventcoro_test.cpp
+++ b/indra/llcommon/tests/lleventcoro_test.cpp
@@ -45,6 +45,7 @@
 #include "llcoros.h"
 #include "lleventcoro.h"
 #include "../test/debug.h"
+#include "../test/sync.h"
 
 using namespace llcoro;
 
@@ -58,8 +59,9 @@ using namespace llcoro;
 class ImmediateAPI
 {
 public:
-    ImmediateAPI():
-        mPump("immediate", true)
+    ImmediateAPI(Sync& sync):
+        mPump("immediate", true),
+        mSync(sync)
     {
         mPump.listen("API", boost::bind(&ImmediateAPI::operator(), this, _1));
     }
@@ -68,22 +70,18 @@ class ImmediateAPI
 
     // Invoke this with an LLSD map containing:
     // ["value"]: Integer value. We will reply with ["value"] + 1.
-    // ["reply"]: Name of LLEventPump on which to send success response.
-    // ["error"]: Name of LLEventPump on which to send error response.
-    // ["fail"]: Presence of this key selects ["error"], else ["success"] as
-    // the name of the pump on which to send the response.
+    // ["reply"]: Name of LLEventPump on which to send response.
     bool operator()(const LLSD& event) const
     {
+        mSync.bump();
         LLSD::Integer value(event["value"]);
-        LLSD::String replyPumpName(event.has("fail")? "error" : "reply");
-        LLEventPumps::instance().obtain(event[replyPumpName]).post(value + 1);
-        // give listener a chance to process
-        llcoro::suspend();
+        LLEventPumps::instance().obtain(event["reply"]).post(value + 1);
         return false;
     }
 
 private:
     LLEventStream mPump;
+    Sync& mSync;
 };
 
 /*****************************************************************************
@@ -91,34 +89,29 @@ class ImmediateAPI
 *****************************************************************************/
 namespace tut
 {
-    struct coroutine_data {};
-    typedef test_group<coroutine_data> coroutine_group;
+    struct test_data
+    {
+        Sync mSync;
+        ImmediateAPI immediateAPI{mSync};
+        std::string replyName, errorName, threw, stringdata;
+        LLSD result, errordata;
+        int which;
+
+        void explicit_wait(boost::shared_ptr<LLCoros::Promise<std::string>>& cbp);
+        void waitForEventOn1();
+        void coroPump();
+        void postAndWait1();
+        void coroPumpPost();
+    };
+    typedef test_group<test_data> coroutine_group;
     typedef coroutine_group::object object;
     coroutine_group coroutinegrp("coroutine");
 
-    // use static data so we can intersperse coroutine functions with the
-    // tests that engage them
-    ImmediateAPI immediateAPI;
-    std::string replyName, errorName, threw, stringdata;
-    LLSD result, errordata;
-    int which;
-
-    // reinit vars at the start of each test
-    void clear()
-    {
-        replyName.clear();
-        errorName.clear();
-        threw.clear();
-        stringdata.clear();
-        result = LLSD();
-        errordata = LLSD();
-        which = 0;
-    }
-
-    void explicit_wait(boost::shared_ptr<LLCoros::Promise<std::string>>& cbp)
+    void test_data::explicit_wait(boost::shared_ptr<LLCoros::Promise<std::string>>& cbp)
     {
         BEGIN
         {
+            mSync.bump();
             // The point of this test is to verify / illustrate suspending a
             // coroutine for something other than an LLEventPump. In other
             // words, this shows how to adapt to any async operation that
@@ -136,6 +129,7 @@ namespace tut
             // calling get() on the future causes us to suspend
             debug("about to suspend");
             stringdata = future.get();
+            mSync.bump();
             ensure_equals("Got it", stringdata, "received");
         }
         END
@@ -144,30 +138,32 @@ namespace tut
     template<> template<>
     void object::test<1>()
     {
-        clear();
         set_test_name("explicit_wait");
         DEBUG;
 
         // Construct the coroutine instance that will run explicit_wait.
         boost::shared_ptr<LLCoros::Promise<std::string>> respond;
         LLCoros::instance().launch("test<1>",
-                                   boost::bind(explicit_wait, boost::ref(respond)));
+                                   [this, &respond](){ explicit_wait(respond); });
+        mSync.bump();
         // When the coroutine waits for the future, it returns here.
         debug("about to respond");
         // Now we're the I/O subsystem delivering a result. This should make
         // the coroutine ready.
         respond->set_value("received");
         // but give it a chance to wake up
-        llcoro::suspend();
+        mSync.yield();
         // ensure the coroutine ran and woke up again with the intended result
         ensure_equals(stringdata, "received");
     }
 
-    void waitForEventOn1()
+    void test_data::waitForEventOn1()
     {
         BEGIN
         {
+            mSync.bump();
             result = suspendUntilEventOn("source");
+            mSync.bump();
         }
         END
     }
@@ -175,25 +171,27 @@ namespace tut
     template<> template<>
     void object::test<2>()
     {
-        clear();
         set_test_name("waitForEventOn1");
         DEBUG;
-        LLCoros::instance().launch("test<2>", waitForEventOn1);
+        LLCoros::instance().launch("test<2>", [this](){ waitForEventOn1(); });
+        mSync.bump();
         debug("about to send");
         LLEventPumps::instance().obtain("source").post("received");
         // give waitForEventOn1() a chance to run
-        llcoro::suspend();
+        mSync.yield();
         debug("back from send");
         ensure_equals(result.asString(), "received");
     }
 
-    void coroPump()
+    void test_data::coroPump()
     {
         BEGIN
         {
+            mSync.bump();
             LLCoroEventPump waiter;
             replyName = waiter.getName();
             result = waiter.suspend();
+            mSync.bump();
         }
         END
     }
@@ -201,26 +199,28 @@ namespace tut
     template<> template<>
     void object::test<3>()
     {
-        clear();
         set_test_name("coroPump");
         DEBUG;
-        LLCoros::instance().launch("test<3>", coroPump);
+        LLCoros::instance().launch("test<3>", [this](){ coroPump(); });
+        mSync.bump();
         debug("about to send");
         LLEventPumps::instance().obtain(replyName).post("received");
         // give coroPump() a chance to run
-        llcoro::suspend();
+        mSync.yield();
         debug("back from send");
         ensure_equals(result.asString(), "received");
     }
 
-    void postAndWait1()
+    void test_data::postAndWait1()
     {
         BEGIN
         {
+            mSync.bump();
             result = postAndSuspend(LLSDMap("value", 17),       // request event
                                  immediateAPI.getPump(),     // requestPump
                                  "reply1",                   // replyPump
                                  "reply");                   // request["reply"] = name
+            mSync.bump();
         }
         END
     }
@@ -228,22 +228,21 @@ namespace tut
     template<> template<>
     void object::test<4>()
     {
-        clear();
         set_test_name("postAndWait1");
         DEBUG;
-        LLCoros::instance().launch("test<4>", postAndWait1);
-        // give postAndWait1() a chance to run
-        llcoro::suspend();
+        LLCoros::instance().launch("test<4>", [this](){ postAndWait1(); });
         ensure_equals(result.asInteger(), 18);
     }
 
-    void coroPumpPost()
+    void test_data::coroPumpPost()
     {
         BEGIN
         {
+            mSync.bump();
             LLCoroEventPump waiter;
             result = waiter.postAndSuspend(LLSDMap("value", 17),
                                         immediateAPI.getPump(), "reply");
+            mSync.bump();
         }
         END
     }
@@ -251,12 +250,9 @@ namespace tut
     template<> template<>
     void object::test<5>()
     {
-        clear();
         set_test_name("coroPumpPost");
         DEBUG;
-        LLCoros::instance().launch("test<5>", coroPumpPost);
-        // give coroPumpPost() a chance to run
-        llcoro::suspend();
+        LLCoros::instance().launch("test<5>", [this](){ coroPumpPost(); });
         ensure_equals(result.asInteger(), 18);
     }
 }
diff --git a/indra/test/CMakeLists.txt b/indra/test/CMakeLists.txt
index 0f14862cbae39ca8fe4b21d4a919ca2f4f6322ee..87536e146ba6f81f7a1a626c8504a1606c23b6c0 100644
--- a/indra/test/CMakeLists.txt
+++ b/indra/test/CMakeLists.txt
@@ -67,6 +67,7 @@ set(test_HEADER_FILES
     llpipeutil.h
     llsdtraits.h
     lltut.h
+    sync.h
     )
 
 if (NOT WINDOWS)
diff --git a/indra/test/sync.h b/indra/test/sync.h
new file mode 100644
index 0000000000000000000000000000000000000000..cafbc034b4b8d56c7c4be92653d3c283a0edbbb7
--- /dev/null
+++ b/indra/test/sync.h
@@ -0,0 +1,85 @@
+/**
+ * @file   sync.h
+ * @author Nat Goodspeed
+ * @date   2019-03-13
+ * @brief  Synchronize coroutines within a test program so we can observe side
+ *         effects. Certain test programs test coroutine synchronization
+ *         mechanisms. Such tests usually want to interleave coroutine
+ *         executions in strictly stepwise fashion. This class supports that
+ *         paradigm.
+ * 
+ * $LicenseInfo:firstyear=2019&license=viewerlgpl$
+ * Copyright (c) 2019, Linden Research, Inc.
+ * $/LicenseInfo$
+ */
+
+#if ! defined(LL_SYNC_H)
+#define LL_SYNC_H
+
+#include "llcond.h"
+#include "lltut.h"
+#include "stringize.h"
+#include "llerror.h"
+#include "llcoros.h"
+
+/**
+ * Instantiate Sync in any test in which we need to suspend one coroutine
+ * until we're sure that another has had a chance to run. Simply calling
+ * llcoro::suspend() isn't necessarily enough; that provides a chance for the
+ * other to run, but doesn't guarantee that it has. If each coroutine is
+ * consistent about calling Sync::bump() every time it wakes from any
+ * suspension, Sync::yield() and yield_until() should at least ensure that
+ * somebody else has had a chance to run.
+ */
+class Sync
+{
+    LLScalarCond<int> mCond{0};
+    F32Milliseconds mTimeout;
+
+public:
+    Sync(F32Milliseconds timeout=F32Milliseconds(10.0f)):
+        mTimeout(timeout)
+    {}
+
+    /// Bump mCond by n steps -- ideally, do this every time a participating
+    /// coroutine wakes up from any suspension. The choice to bump() after
+    /// resumption rather than just before suspending is worth calling out:
+    /// this practice relies on the fact that condition_variable::notify_all()
+    /// merely marks a suspended coroutine ready to run, rather than
+    /// immediately resuming it. This way, though, even if a coroutine exits
+    /// before reaching its next suspend point, the other coroutine isn't
+    /// left waiting forever.
+    void bump(int n=1)
+    {
+        LL_DEBUGS() << llcoro::logname() << " bump(" << n << ") -> " << (mCond.get() + n) << LL_ENDL;
+        mCond.set_all(mCond.get() + n);
+    }
+
+    /// suspend until "somebody else" has bumped mCond by n steps
+    void yield(int n=1)
+    {
+        return yield_until(STRINGIZE("Sync::yield_for(" << n << ") timed out after "
+                                     << int(mTimeout.value()) << "ms"),
+                           mCond.get() + n);
+    }
+
+    /// suspend until "somebody else" has bumped mCond to a specific value
+    void yield_until(int until)
+    {
+        return yield_until(STRINGIZE("Sync::yield_until(" << until << ") timed out after "
+                                     << int(mTimeout.value()) << "ms"),
+                           until);
+    }
+
+private:
+    void yield_until(const std::string& desc, int until)
+    {
+        std::string name(llcoro::logname());
+        LL_DEBUGS() << name << " yield_until(" << until << ") suspending" << LL_ENDL;
+        tut::ensure(name + ' ' + desc, mCond.wait_for_equal(mTimeout, until));
+        // each time we wake up, bump mCond
+        bump();
+    }
+};
+
+#endif /* ! defined(LL_SYNC_H) */