Repository: DynamoRIO/dynamorio (2025-07-29T20:32:59Z)

Issue:
Non-alarm signals can accumulate and cause lock problems
For #7504 in my memtrace sleep test I have one thread in a sleep loop and another thread in a busy loop where it sends SIGUSR1 to the sleep thread to test EINTR.  In some runs I see a rank order violaton:

```
Run 24
<rank order violation dynamo_areas(readwrite)@dr/git/src/core/vmareas.c:1600 acquired after memory_info_buf_lock(mutex)@dr/git/src/core/unix/memquery_linux.c:70 in tid:207489>
<press enter to continue>


Thread 2 (Thread 0x7f327bbf36c0 (LWP 2126985) "tool.drcacheoff"):
#0  syscall_ready () at dr/git/src/core/drlibc/drlibc_x86.asm:187
#1  0x0000000000001fff in ?? ()
#2  0x000056117566fd4b in read_syscall (fd=0, buf=0x7f327bd45177, nbytes=1) at dr/git/src/core/drlibc/drlibc_unix.c:289
#3  0x000056117566fe8e in os_read (f=0, buf=0x7f327bd45177, count=1) at dr/git/src/core/drlibc/drlibc_unix.c:370
#4  0x000056117529924a in d_r_notify (priority=SYSLOG_CRITICAL, internal=true, synch=false, substitution_num=0, prefix=0x5611756a6c4c "SYSLOG_CRITICAL", fmt=0x5611756a6c18 "rank order violation %s acquired after %s in tid:%x") at dr/git/src/core/utils.c:1939
#5  0x000056117529602c in deadlock_avoidance_lock (lock=0x7f327bc363f0, acquired=true, ownable=true) at dr/git/src/core/utils.c:606
#6  0x0000561175296a05 in d_r_mutex_trylock (lock=0x7f327bc363f0) at dr/git/src/core/utils.c:917
#7  0x00005611752968de in d_r_mutex_lock_app (lock=0x7f327bc363f0, mc=0x0) at dr/git/src/core/utils.c:852
#8  0x00005611752969b8 in d_r_mutex_lock (lock=0x7f327bc363f0) at dr/git/src/core/utils.c:898
#9  0x0000561175297413 in d_r_write_lock (rw=0x7f327bc363f0) at dr/git/src/core/utils.c:1244
#10 0x00005611753552db in dynamo_vm_areas_lock () at dr/git/src/core/vmareas.c:3595
#11 0x000056117532fc51 in get_guarded_real_memory (reserve_size=65536, commit_size=4096, prot=3, add_vm=true, guarded=true, min_addr=0x0, which=(VMM_SPECIAL_HEAP | VMM_PER_THREAD), comment=0x5611756ba8e8 "special_heap") at dr/git/src/core/heap.c:2856
#12 0x000056117533b26b in special_heap_create_unit (su=0x7f327bd02118, pc=0x0, size=57344, unit_full=false) at dr/git/src/core/heap.c:5067
#13 0x000056117533cdeb in special_heap_calloc (special=0x7f327bd02118, num=1) at dr/git/src/core/heap.c:5469
#14 0x000056117533d6f4 in special_heap_alloc (special=0x7f327bd02118) at dr/git/src/core/heap.c:5506
#15 0x00005611754af322 in record_pending_signal (dcontext=0x7f327bd006c0, sig=10, ucxt=0x7f327bd46380, frame=0x7f327bd46378, forged=false, access_address=0x0) at dr/git/src/core/unix/signal.c:5254
#16 0x00005611754b1bbc in main_signal_handler_C (sig=10, siginfo=0x7f327bd464b0, ucxt=0x7f327bd46380, xsp=0x7f327bd46378 "خFu\021V") at dr/git/src/core/unix/signal.c:6144
#17 0x000056117546aed8 in xfer_to_new_libdr () at dr/git/src/core/arch/x86/x86.asm:1203
#18 0x0000000000000007 in ?? ()
#19 0x0000000000000000 in ?? ()

Thread 1 (Thread 0x7f347c067640 (LWP 2126984) "tool.drcacheoff"):
#0  syscall_ready () at dr/git/src/core/drlibc/drlibc_x86.asm:187
#1  0x0000000000000246 in ?? ()
#2  0x00005611754ce291 in ksynch_wait (futex=0x7f327bc5c838, mustbe=1, timeout_ms=0) at dr/git/src/core/unix/ksynch_linux.c:125
#3  0x000056117549d7e1 in mutex_wait_contended_lock (lock=0x7f327bc5c838, mc=0x0) at dr/git/src/core/unix/os.c:10592
#4  0x0000561175296961 in d_r_mutex_lock_app (lock=0x7f327bc5c838, mc=0x0) at dr/git/src/core/utils.c:885
#5  0x00005611752969b8 in d_r_mutex_lock (lock=0x7f327bc5c838) at dr/git/src/core/utils.c:898
#6  0x0000561175297413 in d_r_write_lock (rw=0x7f327bc5c838) at dr/git/src/core/utils.c:1244
#7  0x00005611754c735d in memcache_lock () at dr/git/src/core/unix/memcache.c:140
#8  0x00005611754c801f in memcache_query_memory (pc=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", out_info=0x7f327bca3850) at dr/git/src/core/unix/memcache.c:366
#9  0x000056117549d459 in query_memory_ex (pc=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", out_info=0x7f327bca3850) at dr/git/src/core/unix/os.c:10458
#10 0x000056117549d4f2 in get_memory_info (pc=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", base_pc=0x0, size=0x0, prot=0x7f327bca38cc) at dr/git/src/core/unix/os.c:10478
#11 0x000056117535cf1d in check_thread_vm_area (dcontext=0x7f327bc80980, pc=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", tag=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", vmlist=0x7f327bca3b10, flags=0x7f327bca3b08, stop=0x7f327bca3b58, xfer=false) at dr/git/src/core/vmareas.c:8325
#12 0x0000561175428c0c in check_new_page_start (dcontext=0x7f327bc80980, bb=0x7f327bca3ad0) at dr/git/src/core/arch/interp.c:716
#13 0x0000561175439a29 in init_interp_build_bb (dcontext=0x7f327bc80980, bb=0x7f327bca3ad0, start=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", initial_flags=33554432, for_trace=true, unmangled_ilist=0x7f327bca8078) at dr/git/src/core/arch/interp.c:5053
#14 0x0000561175439e39 in build_basic_block_fragment (dcontext=0x7f327bc80980, start=0x7f347bc9e8e6 <__pthread_kill_implementation+150> "\211\330H\213T$\bdH+\024%(", initial_flags=33554432, link=true, visible=false, for_trace=true, unmangled_ilist=0x7f327bca8078) at dr/git/src/core/arch/interp.c:5126
#15 0x000056117533e7bb in create_private_copy (dcontext=0x7f327bc80980, f=0x7f327bd63b70) at dr/git/src/core/monitor.c:176
#16 0x0000561175347a37 in monitor_cache_enter (dcontext=0x7f327bc80980, f=0x7f327bd63b70) at dr/git/src/core/monitor.c:2186
#17 0x000056117527b5ac in d_r_dispatch (dcontext=0x7f327bc80980) at dr/git/src/core/dispatch.c:194
#18 0x000056113512017f in ?? ()
#19 0x0000000000000000 in ?? ()


(gdb) p *su
$1 = {
  top_unit = 0x7f327bd36000,
  cur_unit = 0x7f327bd36000,
  block_size = 3264,
  block_alignment = 64,
  free_list = 0x0,
  cfree_list = 0x0,
  num_units = 1,
  writable = true,
  executable = false,
  use_lock = false,
  in_iterator = false,
  persistent = true,
  per_thread = true,
<...>
(gdb) p *su->top_unit
$2 = {
  alloc_pc = 0x7f327bd36000 "",
  start_pc = 0x7f327bd36040 "خFu\021V",
  end_pc = 0x7f327bd3d000 "",
  cur_pc = 0x7f327bd3c640 '\315' <repeats 199 times>, <incomplete sequence \315>...,
  reserved_end_pc = 0x7f327bd3d000 "",
  id = 0,
  next = 0x0
}
(gdb) p su->top_unit->end_pc - su->top_unit->start_pc
$3 = 28608
(gdb) p (su->top_unit->end_pc - su->top_unit->start_pc)/su->block_size
$4 = 8

(gdb) p info->num_pending
$5 = 8
```

```
        /* create new unit */
        /* Since vmm lock, dynamo_vm_areas lock, all_memory_areas lock (on
         * linux), etc. will be acquired, and presumably !su->use_lock means
         * user can't handle ANY lock being acquired, we warn here: xref PR
         * 596768.  In release build, we try to acqure the memory anyway.  I'm
         * worried about pcprofile: can only fit ~1K in one unit and so will
         * easily run out...should it allocate additional units up front?
         * => PR 596808.
         */
        DODEBUG({
            if (su->top_unit != NULL /*init*/ && !su->use_lock) {
                SYSLOG_INTERNAL_WARNING_ONCE("potentially unsafe: allocating a new "
                                             "fragile special heap unit!");
            }
        });
```

```
OPTION_DEFAULT(uint, max_pending_signals, 8,
```

There was a warning but I have it masked off.

So we have a 9th pending SIGUSR1.

Only alarm signals are dropped at 3rd pending.

For non-realtime signals it seems like we should drop regardless of whether
it's an alarm, if they're accumulating: that seems better than potential
deadlock.



Discussion:

Patch:
diff --git a/clients/drcachesim/CMakeLists.txt b/clients/drcachesim/CMakeLists.txt
index 8a9a0c66f10..c9ef3436871 100644
--- a/clients/drcachesim/CMakeLists.txt
+++ b/clients/drcachesim/CMakeLists.txt
@@ -1275,6 +1275,15 @@ if (BUILD_TESTS)
       endif ()
       add_win32_flags(tool.drcacheoff.burst_futex ON)
       use_DynamoRIO_drmemtrace_tracer(tool.drcacheoff.burst_futex)
+
+      # -time_syscall_scale is Linux-only.
+      add_executable(tool.drcacheoff.burst_sleep
+        "${CMAKE_CURRENT_SOURCE_DIR}/tests/burst_sleep.cpp")
+      configure_DynamoRIO_static(tool.drcacheoff.burst_sleep)
+      use_DynamoRIO_static_client(tool.drcacheoff.burst_sleep drmemtrace_static)
+      target_link_libraries(tool.drcacheoff.burst_sleep drmemtrace_raw2trace
+        drmemtrace_analyzer test_helpers)
+      use_DynamoRIO_drmemtrace_tracer(tool.drcacheoff.burst_sleep)
     endif ()
 
   endif ()
diff --git a/clients/drcachesim/common/options.cpp b/clients/drcachesim/common/options.cpp
index 8ba43627560..de740bfcf08 100644
--- a/clients/drcachesim/common/options.cpp
+++ b/clients/drcachesim/common/options.cpp
@@ -1291,12 +1291,19 @@ droption_t<bool> op_pt2ir_best_effort(
     "seen in converted syscall traces).");
 
 droption_t<int> op_scale_timers(
-    DROPTION_SCOPE_ALL, "scale_timers", 0, 0, (std::numeric_limits<int>::max)(),
-    "If non-0, inflate application timer periods by this value",
-    "If non-zero, application timer initial durations and periodic durations are "
+    DROPTION_SCOPE_ALL, "scale_timers", 1, 1, (std::numeric_limits<int>::max)(),
+    "If >1, inflate application timer periods by this value",
+    "If >1, application timer initial durations and periodic durations are "
     "inflated by this scale.  This can help preserve relative timing between timer-based "
     "application work and other application work in the presence of "
     "significant slowdowns from tracing.  Currently only supported on Linux.");
+droption_t<int> op_scale_timeouts(
+    DROPTION_SCOPE_ALL, "scale_timeouts", 1, 1, (std::numeric_limits<int>::max)(),
+    "If >1, inflate syscall timeouts by this value",
+    "If >1, time arguments to certain system calls (currently Linux-only "
+    "sleeps) are multiplied by the specified value.  This can help preserve relative "
+    "timing among application threads in the presence of significant slowdowns from "
+    "tracing.");
 
 } // namespace drmemtrace
 } // namespace dynamorio
diff --git a/clients/drcachesim/common/options.h b/clients/drcachesim/common/options.h
index bbcb76877f0..b14f2319d2e 100644
--- a/clients/drcachesim/common/options.h
+++ b/clients/drcachesim/common/options.h
@@ -247,6 +247,7 @@ extern dynamorio::droption::droption_t<uint64_t> op_trim_after_instr;
 extern dynamorio::droption::droption_t<bool> op_abort_on_invariant_error;
 extern dynamorio::droption::droption_t<bool> op_pt2ir_best_effort;
 extern dynamorio::droption::droption_t<int> op_scale_timers;
+extern dynamorio::droption::droption_t<int> op_scale_timeouts;
 
 } // namespace drmemtrace
 } // namespace dynamorio
diff --git a/clients/drcachesim/tracer/tracer.cpp b/clients/drcachesim/tracer/tracer.cpp
index 4359b804e61..c389c741239 100644
--- a/clients/drcachesim/tracer/tracer.cpp
+++ b/clients/drcachesim/tracer/tracer.cpp
@@ -423,7 +423,7 @@ instrumentation_exit()
         DR_ASSERT(false);
 #ifdef LINUX
     // XXX i#7504: Time and timer scaling currently only supports Linux.
-    if (op_scale_timers.get_value() > 0) {
+    if (op_scale_timers.get_value() > 1 || op_scale_timeouts.get_value() > 1) {
         bool ok = drx_unregister_time_scaling();
         DR_ASSERT(ok);
     }
@@ -485,13 +485,14 @@ instrumentation_init()
     DR_ASSERT(ok);
 #ifdef LINUX
     // XXX i#7504: Time and timer scaling currently only supports Linux.
-    if (op_scale_timers.get_value() > 0) {
+    if (op_scale_timers.get_value() > 1 || op_scale_timeouts.get_value() > 1) {
         drx_time_scale_t scale = {
             sizeof(scale),
         };
         scale.timer_scale = op_scale_timers.get_value();
-        scale.timeout_scale = 1;
-        NOTIFY(1, "Registering timer scaling %dx\n", scale.timer_scale);
+        scale.timeout_scale = op_scale_timeouts.get_value();
+        NOTIFY(1, "Registering timer scaling %dx timeout scaling %dx\n",
+               scale.timer_scale, scale.timeout_scale);
         ok = drx_register_time_scaling(&scale);
         DR_ASSERT(ok);
     }
diff --git a/core/unix/signal.c b/core/unix/signal.c
index c5511fc2a17..3f15feff338 100644
--- a/core/unix/signal.c
+++ b/core/unix/signal.c
@@ -140,6 +140,12 @@ sig_is_alarm_signal(int sig)
     return (sig == SIGALRM || sig == SIGVTALRM || sig == SIGPROF);
 }
 
+static inline bool
+sig_is_real_time(int sig)
+{
+    return sig >= OFFS_RT;
+}
+
 /* We do not use SIGSTKSZ b/c for things like code modification
  * we end up calling many core routines and so want more space.
  * Also, SIGSTKSZ is now defined as sysconf(_SC_SIGSTKSZ) and we
@@ -5230,10 +5236,12 @@ record_pending_signal(dcontext_t *dcontext, int sig, kernel_ucontext_t *ucxt,
         if (!blocked || sig >= OFFS_RT || (blocked && info->sigpending[sig] == NULL)) {
             /* only have 1 pending for blocked non-rt signals */
 
-            /* to avoid accumulating signals if we're slow in presence of
-             * a high-rate itimer we only keep 2 alarm signals (PR 596768)
+            /* To avoid accumulating signals if we're slow in presence of
+             * a high-rate itimer we only keep 2 alarm signals (PR 596768).
+             * We extend this to any non-real-time signal (where POSIX says only
+             * one has to be queued) to avoid lock issues.
              */
-            if (sig_is_alarm_signal(sig)) {
+            if (!sig_is_real_time(sig)) {
                 if (info->sigpending[sig] != NULL &&
                     info->sigpending[sig]->next != NULL) {
                     ASSERT(info->sigpending[sig]->next->next == NULL);


Test patch:
diff --git a/clients/drcachesim/tests/burst_sleep.cpp b/clients/drcachesim/tests/burst_sleep.cpp
new file mode 100644
index 00000000000..04fb6f62e25
--- /dev/null
+++ b/clients/drcachesim/tests/burst_sleep.cpp
@@ -0,0 +1,309 @@
+/* **********************************************************
+ * Copyright (c) 2019-2025 Google, Inc.  All rights reserved.
+ * **********************************************************/
+
+/*
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice,
+ *   this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright notice,
+ *   this list of conditions and the following disclaimer in the documentation
+ *   and/or other materials provided with the distribution.
+ *
+ * * Neither the name of Google, Inc. nor the names of its contributors may be
+ *   used to endorse or promote products derived from this software without
+ *   specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL GOOGLE, INC. OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+ * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
+ * DAMAGE.
+ */
+
+// Tests -time_syscall_scale during tracing.
+
+// Enable asserts in release build testing too.
+#undef NDEBUG
+
+#include <assert.h>
+#include <math.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <signal.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <atomic>
+#include <iostream>
+#include <thread>
+
+#include "dr_api.h"
+#include "drmemtrace/drmemtrace.h"
+#include "drcovlib.h"
+#include "analysis_tool.h"
+#include "scheduler.h"
+#include "tracer/raw2trace.h"
+#include "tracer/raw2trace_directory.h"
+#include "../../core/unix/include/syscall_target.h"
+
+#ifndef LINUX
+#    error Only Linux supported for this test.
+#endif
+
+namespace dynamorio {
+namespace drmemtrace {
+
+bool
+my_setenv(const char *var, const char *value)
+{
+    return setenv(var, value, 1 /*override*/) == 0;
+}
+
+/****************************************************************************
+ * Code that gets traced.
+ */
+
+static pthread_cond_t condvar;
+static bool child_ready;
+static pthread_mutex_t lock;
+static std::atomic<bool> child_should_exit;
+static std::atomic<bool> saw_eintr;
+static std::atomic<int> sleep_count;
+
+static void
+handler(int sig)
+{
+    // Nothing; just interrupt the sleep.
+}
+
+static void *
+thread_routine(void *arg)
+{
+    signal(SIGUSR1, handler);
+
+    pthread_mutex_lock(&lock);
+    child_ready = true;
+    pthread_cond_signal(&condvar);
+    pthread_mutex_unlock(&lock);
+
+    struct timespec sleeptime;
+    sleeptime.tv_sec = 0;
+    sleeptime.tv_nsec = 100000;
+    struct timespec remaining;
+    while (!child_should_exit.load(std::memory_order_acquire)) {
+        sleep_count.fetch_add(1, std::memory_order_release);
+        int res = nanosleep(&sleeptime, &remaining);
+        if (res != 0) {
+            assert(errno == EINTR);
+            // Ensure the remaining time was deflated.
+            assert(remaining.tv_sec <= sleeptime.tv_sec);
+            saw_eintr.store(true, std::memory_order_release);
+        }
+    }
+    return nullptr;
+}
+
+static double
+do_some_work()
+{
+    // It is difficult to use a constant iter count in our work loop and still
+    // produce a good sleep count across varying test machines: too few and we don't
+    // have enough to see a scale effect; too many and the test takes too long on
+    // slower machines.  We solve this by figuring out an iter count experimentally.
+    // The first time we're called, we run until we see MIN_SLEEPS sleeps and 1 EINTR
+    // in the other thread.  We record that iter count and use it next time.
+    constexpr int MIN_SLEEPS = 50;
+    static int computed_iters;
+
+    pthread_t thread;
+    void *retval;
+    pthread_mutex_init(&lock, NULL);
+    pthread_cond_init(&condvar, NULL);
+
+    pthread_mutex_lock(&lock);
+    child_ready = false;
+    pthread_mutex_unlock(&lock);
+    child_should_exit.store(false, std::memory_order_release);
+    saw_eintr.store(true /*false*/, std::memory_order_release);
+    sleep_count.store(0, std::memory_order_release);
+    child_should_exit.store(false, std::memory_order_release);
+
+    int res = pthread_create(&thread, NULL, thread_routine, nullptr);
+    assert(res == 0);
+    // Wait for the child to start running.
+    pthread_mutex_lock(&lock);
+    while (!child_ready)
+        pthread_cond_wait(&condvar, &lock);
+    pthread_mutex_unlock(&lock);
+    // Now take some time doing work so we can measure how many sleeps the child
+    // accomplishes in this time period.
+#ifdef DEBUG
+    constexpr int EINTR_PERIOD = 30;
+#else
+    // Signal delivery is much faster, and we only want a few interruptions to test
+    // that path: too many results in too many sleep syscalls in the count.
+    constexpr int EINTR_PERIOD = 600;
+#endif
+    double val = static_cast<double>(MIN_SLEEPS);
+    int i = 0;
+    while (true) {
+        if (computed_iters == 0) {
+            if (sleep_count.load(std::memory_order_acquire) >= MIN_SLEEPS) {
+                computed_iters = i;
+                std::cerr << "iters for >= " << MIN_SLEEPS
+                          << " sleeps: " << computed_iters << "\n";
+                break;
+            }
+        } else if (i >= computed_iters &&
+                   // We want to test the scaled (2nd run) EINTR path.
+                   // We don't require on the 1st as that slows down
+                   // debug test times (from 1.5s up to >10s if we require an
+                   // EINTR: because there's so much other work being done it
+                   // dwarfs the short app sleep) for no benefit.
+                   // But in the inflated sleep run we hit EINTR pretty easily,
+                   // so we're comfortable requiring it without worrying it
+                   // will skew the results and make it seem there are more
+                   // sleeps because we have more requirements (that would
+                   // cause the test to move toward failure in any case and
+                   // we would be more likely to notice).
+                   saw_eintr.load(std::memory_order_acquire)) {
+            break;
+        }
+        ++i;
+        val += sin(val);
+        // Test interrupting the thread's sleeps.
+        if (!saw_eintr.load(std::memory_order_acquire) && i % EINTR_PERIOD == 0) {
+            pthread_kill(thread, SIGUSR1);
+        }
+    }
+    // Clean up.
+    child_should_exit.store(true, std::memory_order_release);
+    res = pthread_join(thread, &retval);
+    assert(res == 0);
+    pthread_cond_destroy(&condvar);
+    pthread_mutex_destroy(&lock);
+    return val;
+}
+
+/****************************************************************************
+ * Trace processing code.
+ */
+
+static std::string
+post_process(const std::string &out_subdir)
+{
+    const char *raw_dir;
+    drmemtrace_status_t mem_res = drmemtrace_get_output_path(&raw_dir);
+    assert(mem_res == DRMEMTRACE_SUCCESS);
+    std::string outdir = std::string(raw_dir) + DIRSEP + out_subdir;
+    void *dr_context = dr_standalone_init();
+    /* Now write a final trace to a location that the drcachesim -indir step
+     * run by the outer test harness will find (TRACE_FILENAME).
+     * Use a new scope to free raw2trace_directory_t before dr_standalone_exit().
+     * We could alternatively make a scope exit template helper.
+     */
+    {
+        raw2trace_directory_t dir;
+        if (!dr_create_dir(outdir.c_str())) {
+            std::cerr << "Failed to create output dir";
+            assert(false);
+        }
+        std::string dir_err = dir.initialize(raw_dir, outdir);
+        assert(dir_err.empty());
+        raw2trace_t raw2trace(dir.modfile_bytes_, dir.in_files_, dir.out_files_,
+                              dir.out_archives_, dir.encoding_file_,
+                              dir.serial_schedule_file_, dir.cpu_schedule_file_,
+                              dr_context, 0);
+        std::string error = raw2trace.do_conversion();
+        if (!error.empty()) {
+            std::cerr << "raw2trace failed: " << error << "\n";
+            assert(false);
+        }
+    }
+    dr_standalone_exit();
+    return outdir;
+}
+
+static std::string
+gather_trace(const std::string &tracer_ops, const std::string &out_subdir)
+{
+    std::string dr_ops("-stderr_mask 0xc -client_lib ';;-offline " + tracer_ops + "'");
+    if (!my_setenv("DYNAMORIO_OPTIONS", dr_ops.c_str()))
+        std::cerr << "failed to set env var!\n";
+    dr_app_setup();
+    assert(!dr_app_running_under_dynamorio());
+    dr_app_start();
+    assert(dr_app_running_under_dynamorio());
+    double res = do_some_work();
+    assert(res > 0.);
+    dr_app_stop_and_cleanup();
+    assert(!dr_app_running_under_dynamorio());
+
+    return post_process(out_subdir);
+}
+
+static int
+count_sleeps(const std::string &dir)
+{
+    int count = 0;
+    scheduler_t scheduler;
+    std::vector<scheduler_t::input_workload_t> sched_inputs;
+    sched_inputs.emplace_back(dir);
+    if (scheduler.init(sched_inputs, 1, scheduler_t::make_scheduler_serial_options()) !=
+        scheduler_t::STATUS_SUCCESS) {
+        std::cerr << "Failed to initialize scheduler " << scheduler.get_error_string()
+                  << "\n";
+    }
+    auto *stream = scheduler.get_stream(0);
+    while (true) {
+        memref_t memref;
+        scheduler_t::stream_status_t status = stream->next_record(memref);
+        if (status == scheduler_t::STATUS_EOF) {
+            break;
+        }
+        assert(status == scheduler_t::STATUS_OK);
+        if (memref.marker.type == TRACE_TYPE_MARKER &&
+            memref.marker.marker_type == TRACE_MARKER_TYPE_SYSCALL &&
+            (memref.marker.marker_value == SYS_nanosleep ||
+             memref.marker.marker_value == SYS_clock_nanosleep)) {
+            ++count;
+        }
+    }
+    return count;
+}
+
+int
+test_main(int argc, const char *argv[])
+{
+    // The first gather_trace call must be the default as computed_iters
+    // is determined in the first call.
+    std::string dir_default = gather_trace("", "default");
+    std::string dir_scale = gather_trace("-scale_timeouts 20", "scale");
+
+    void *dr_context = dr_standalone_init();
+
+    int sleeps_default = count_sleeps(dir_default);
+    int sleeps_scale = count_sleeps(dir_scale);
+    std::cerr << "sleeps default=" << sleeps_default << " scale=" << sleeps_scale << "\n";
+    // With a 10x scale, require at least a 2x difference (no higher, to allow
+    // for variation on loaded test machines).
+    assert(sleeps_default > 2 * sleeps_scale);
+
+    dr_standalone_exit();
+    std::cerr << "all done\n";
+    return 0;
+}
+
+} // namespace drmemtrace
+} // namespace dynamorio
diff --git a/clients/drcachesim/tests/offline-burst_sleep.templatex b/clients/drcachesim/tests/offline-burst_sleep.templatex
new file mode 100644
index 00000000000..5128b2e2e5b
--- /dev/null
+++ b/clients/drcachesim/tests/offline-burst_sleep.templatex
@@ -0,0 +1,1 @@
+.*all done
diff --git a/suite/tests/CMakeLists.txt b/suite/tests/CMakeLists.txt
index aaab2cb6dba..443ec3a8603 100644
--- a/suite/tests/CMakeLists.txt
+++ b/suite/tests/CMakeLists.txt
@@ -4849,6 +4849,10 @@ if (BUILD_CLIENTS)
         set(tool.drcacheoff.scale_time_nodr ON)
         set(tool.drcacheoff.scale_time_nopost ON)
         torunonly_drcacheoff(scale_time tool.drcacheoff.scale_time "" "" "")
+
+        set(tool.drcacheoff.burst_sleep_nodr ON)
+        set(tool.drcacheoff.burst_sleep_nopost ON)
+        torunonly_drcacheoff(burst_sleep tool.drcacheoff.burst_sleep "" "" "")
       endif ()
 
       if (UNIX)